mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 02:32:07 +01:00
Revert "Split run progress from liveness disposition"
This reverts commit 55518170ad.
This commit is contained in:
1 parent
caf37a355d
commit
dd57bfba21
40 files changed
+141
-3563
No files matched your search
@@ -26,11 +26,8 @@ import {
|
||||
issueDocuments,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
approvals,
|
||||
issueApprovals,
|
||||
issueComments,
|
||||
issueRelations,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
issueWorkProducts,
|
||||
projects,
|
||||
@@ -80,7 +77,6 @@ import {
|
||||
sanitizeRuntimeServiceBaseEnv,
|
||||
} from "./workspace-runtime.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import { issueThreadInteractionService } from "./issue-thread-interactions.js";
|
||||
import { parseIssueExecutionState } from "./issue-execution-policy.js";
|
||||
import {
|
||||
ISSUE_TREE_CONTROL_INTERACTION_WAKE_REASONS,
|
||||
@@ -113,10 +109,6 @@ import {
|
||||
readContinuationAttempt,
|
||||
} from "./recovery/index.js";
|
||||
import { isAutomaticRecoverySuppressedByPauseHold } from "./recovery/pause-hold-guard.js";
|
||||
import {
|
||||
isLiveExplicitApprovalWaitingPath,
|
||||
isLiveExplicitInteractionWaitingPath,
|
||||
} from "./recovery/explicit-waiting-paths.js";
|
||||
import { recoveryService } from "./recovery/service.js";
|
||||
import { productivityReviewService } from "./productivity-review.js";
|
||||
import { withAgentStartLock } from "./agent-start-lock.js";
|
||||
@@ -136,8 +128,6 @@ import { environmentRuntimeService } from "./environment-runtime.js";
|
||||
import { environmentRunOrchestrator } from "./environment-run-orchestrator.js";
|
||||
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
||||
|
||||
const EXPLICIT_WAITING_INTERACTION_KINDS = ["request_confirmation", "ask_user_questions"];
|
||||
|
||||
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
|
||||
const MAX_PERSISTED_LOG_CHUNK_CHARS = 64 * 1024;
|
||||
const MAX_RUN_EVENT_PAYLOAD_STRING_CHARS = 16 * 1024;
|
||||
@@ -2778,68 +2768,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
});
|
||||
}
|
||||
|
||||
async function hasExplicitIssueWait(issue: {
|
||||
companyId: string;
|
||||
id: string;
|
||||
assigneeUserId?: string | null;
|
||||
executionState?: Record<string, unknown> | null;
|
||||
}) {
|
||||
await issueThreadInteractionService(db).expireInvalidPendingRequestConfirmationsForIssue(
|
||||
{ id: issue.id, companyId: issue.companyId },
|
||||
{ agentId: null, userId: null },
|
||||
);
|
||||
|
||||
const [interactionRows, approvalRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
issueId: issueThreadInteractions.issueId,
|
||||
companyId: issueThreadInteractions.companyId,
|
||||
status: issueThreadInteractions.status,
|
||||
createdByUserId: issueThreadInteractions.createdByUserId,
|
||||
createdAt: issueThreadInteractions.createdAt,
|
||||
})
|
||||
.from(issueThreadInteractions)
|
||||
.where(
|
||||
and(
|
||||
eq(issueThreadInteractions.companyId, issue.companyId),
|
||||
eq(issueThreadInteractions.issueId, issue.id),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
inArray(issueThreadInteractions.kind, EXPLICIT_WAITING_INTERACTION_KINDS),
|
||||
),
|
||||
)
|
||||
.limit(5),
|
||||
db
|
||||
.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", "revision_requested"]),
|
||||
),
|
||||
)
|
||||
.limit(5),
|
||||
]);
|
||||
|
||||
return interactionRows.some((row) => isLiveExplicitInteractionWaitingPath(issue, row)) ||
|
||||
approvalRows.some((row) => isLiveExplicitApprovalWaitingPath(issue, row));
|
||||
}
|
||||
|
||||
async function handleRunLivenessContinuation(run: typeof heartbeatRuns.$inferSelect) {
|
||||
const livenessState = run.livenessState as RunLivenessState | null;
|
||||
if (!livenessState) return;
|
||||
const hasProgressFollowup = livenessState === "advanced" && Boolean(run.nextAction?.trim());
|
||||
if (livenessState !== "plan_only" && livenessState !== "empty_response" && !hasProgressFollowup) return;
|
||||
if (livenessState !== "plan_only" && livenessState !== "empty_response") return;
|
||||
|
||||
const context = parseObject(run.contextSnapshot);
|
||||
const issueId = readNonEmptyString(context.issueId);
|
||||
@@ -2854,7 +2785,6 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
executionState: issues.executionState,
|
||||
projectId: issues.projectId,
|
||||
})
|
||||
@@ -2880,14 +2810,6 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
})
|
||||
: null;
|
||||
if (issue) {
|
||||
if (await hasExplicitIssueWait(issue)) {
|
||||
await setRunStatus(run.id, run.status, {
|
||||
livenessReason:
|
||||
`${run.livenessReason ?? "Run ended without concrete progress"}; continuation suppressed by explicit interaction or approval wait`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const productivityHold = await productivityReviews.isProductivityReviewContinuationHoldActive({
|
||||
companyId: issue.companyId,
|
||||
issueId: issue.id,
|
||||
@@ -2909,14 +2831,6 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (await isAutomaticRecoverySuppressedByPauseHold(db, issue.companyId, issue.id, treeControlSvc)) {
|
||||
await setRunStatus(run.id, run.status, {
|
||||
livenessReason:
|
||||
`${run.livenessReason ?? "Run ended without concrete progress"}; continuation suppressed by active pause hold`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const nextAttempt = readContinuationAttempt(run.continuationAttempt) + 1;
|
||||
@@ -3741,88 +3655,6 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const activePauseHold = await treeControlSvc.getActivePauseHoldGate(dueRun.companyId, dueRunIssueId);
|
||||
if (activePauseHold) {
|
||||
const reason = "Cancelled because issue is held by an active subtree pause hold";
|
||||
const cancelled = await db
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
status: "cancelled",
|
||||
finishedAt: now,
|
||||
error: reason,
|
||||
errorCode: "issue_paused",
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.id, dueRun.id),
|
||||
eq(heartbeatRuns.status, "scheduled_retry"),
|
||||
lte(heartbeatRuns.scheduledRetryAt, now),
|
||||
),
|
||||
)
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!cancelled) continue;
|
||||
|
||||
if (cancelled.wakeupRequestId) {
|
||||
await db
|
||||
.update(agentWakeupRequests)
|
||||
.set({
|
||||
status: "cancelled",
|
||||
finishedAt: now,
|
||||
error: reason,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(agentWakeupRequests.id, cancelled.wakeupRequestId));
|
||||
}
|
||||
|
||||
if (issue && issue.executionRunId === cancelled.id) {
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(issues.id, issue.id), eq(issues.executionRunId, cancelled.id)));
|
||||
}
|
||||
|
||||
await appendRunEvent(cancelled, await nextRunEventSeq(cancelled.id), {
|
||||
eventType: "lifecycle",
|
||||
stream: "system",
|
||||
level: "warn",
|
||||
message: "Scheduled retry cancelled because issue is held by an active subtree pause hold",
|
||||
payload: {
|
||||
issueId: dueRunIssueId,
|
||||
holdId: activePauseHold.holdId,
|
||||
rootIssueId: activePauseHold.rootIssueId,
|
||||
scheduledRetryAttempt: cancelled.scheduledRetryAttempt,
|
||||
scheduledRetryAt: cancelled.scheduledRetryAt ? new Date(cancelled.scheduledRetryAt).toISOString() : null,
|
||||
scheduledRetryReason: cancelled.scheduledRetryReason,
|
||||
},
|
||||
});
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: cancelled.companyId,
|
||||
actorType: "system",
|
||||
actorId: "system",
|
||||
agentId: cancelled.agentId,
|
||||
runId: cancelled.id,
|
||||
action: "issue.tree_hold_run_interrupted",
|
||||
entityType: "heartbeat_run",
|
||||
entityId: cancelled.id,
|
||||
details: {
|
||||
issueId: dueRunIssueId,
|
||||
holdId: activePauseHold.holdId,
|
||||
rootIssueId: activePauseHold.rootIssueId,
|
||||
source: "heartbeat.promote_due_scheduled_retries",
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const promoted = await db
|
||||
@@ -7663,8 +7495,6 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
|
||||
reapOrphanedRuns,
|
||||
|
||||
handleRunLivenessContinuationForTest: handleRunLivenessContinuation,
|
||||
|
||||
promoteDueScheduledRetries,
|
||||
|
||||
resumeQueuedRuns,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { and, asc, eq, gt, inArray, sql } from "drizzle-orm";
|
||||
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
documents,
|
||||
@@ -68,45 +68,6 @@ type IssueResolutionContext = {
|
||||
assigneeUserId: string | null;
|
||||
};
|
||||
|
||||
function isAgentAuthoredWaitingInteraction(input: CreateIssueThreadInteraction, actor: InteractionActor) {
|
||||
return Boolean(actor.agentId) && (input.kind === "request_confirmation" || input.kind === "ask_user_questions");
|
||||
}
|
||||
|
||||
async function moveIssueToReviewForAgentWaitingInteraction(
|
||||
db: Db,
|
||||
issue: { id: string; companyId: string },
|
||||
input: CreateIssueThreadInteraction,
|
||||
actor: InteractionActor,
|
||||
) {
|
||||
if (!actor.agentId || !isAgentAuthoredWaitingInteraction(input, actor)) return null;
|
||||
|
||||
const issueContext = await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
status: issues.status,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issue.id))
|
||||
.then((rows: IssueResolutionContext[]) => rows[0] ?? null);
|
||||
|
||||
if (!issueContext || issueContext.companyId !== issue.companyId) {
|
||||
throw notFound("Issue not found");
|
||||
}
|
||||
if (issueContext.status !== "todo" && issueContext.status !== "in_progress") return null;
|
||||
if (issueContext.assigneeUserId) return null;
|
||||
|
||||
return issueService(db).update(issue.id, {
|
||||
status: "in_review",
|
||||
assigneeAgentId: issueContext.assigneeAgentId ?? actor.agentId,
|
||||
assigneeUserId: null,
|
||||
actorAgentId: actor.agentId ?? null,
|
||||
actorUserId: actor.userId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function isIssueThreadInteractionIdempotencyConflict(error: unknown): boolean {
|
||||
if (typeof error !== "object" || error === null) return false;
|
||||
const err = error as { code?: string; constraint?: string; constraint_name?: string };
|
||||
@@ -463,55 +424,6 @@ async function expireStaleRequestConfirmationTarget(db: Db | any, args: {
|
||||
return hydrateInteraction(updated);
|
||||
}
|
||||
|
||||
async function expireRequestConfirmationSupersededByLatestUserComment(db: Db | any, args: {
|
||||
row: IssueThreadInteractionRow;
|
||||
actor: InteractionActor;
|
||||
}): Promise<IssueThreadInteraction | null> {
|
||||
if (args.row.kind !== "request_confirmation" || args.row.status !== "pending") return null;
|
||||
const interaction = hydrateInteraction(args.row) as RequestConfirmationInteraction;
|
||||
if (interaction.payload.supersedeOnUserComment !== true) return null;
|
||||
|
||||
const [comment] = await db
|
||||
.select({ id: issueComments.id })
|
||||
.from(issueComments)
|
||||
.where(and(
|
||||
eq(issueComments.companyId, args.row.companyId),
|
||||
eq(issueComments.issueId, args.row.issueId),
|
||||
gt(issueComments.createdAt, args.row.createdAt),
|
||||
sql`${issueComments.authorUserId} is not null`,
|
||||
))
|
||||
.orderBy(asc(issueComments.createdAt))
|
||||
.limit(1);
|
||||
if (!comment) return null;
|
||||
|
||||
const now = new Date();
|
||||
const [updated] = await db
|
||||
.update(issueThreadInteractions)
|
||||
.set({
|
||||
status: "expired",
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "superseded_by_comment",
|
||||
commentId: comment.id,
|
||||
},
|
||||
resolvedByAgentId: args.actor.agentId ?? null,
|
||||
resolvedByUserId: args.actor.userId ?? null,
|
||||
resolvedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(
|
||||
eq(issueThreadInteractions.id, args.row.id),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw conflict("Interaction has already been resolved");
|
||||
}
|
||||
await touchIssue(db, args.row.issueId);
|
||||
return hydrateInteraction(updated);
|
||||
}
|
||||
|
||||
export function issueThreadInteractionService(db: Db) {
|
||||
async function getIdempotentInteraction(args: {
|
||||
issueId: string;
|
||||
@@ -728,7 +640,6 @@ export function issueThreadInteractionService(db: Db) {
|
||||
idempotencyKey: data.idempotencyKey,
|
||||
});
|
||||
}
|
||||
await moveIssueToReviewForAgentWaitingInteraction(db, issue, data, actor);
|
||||
return hydrateInteraction(existing);
|
||||
}
|
||||
}
|
||||
@@ -806,7 +717,6 @@ export function issueThreadInteractionService(db: Db) {
|
||||
return hydrateInteraction(existing);
|
||||
}
|
||||
|
||||
await moveIssueToReviewForAgentWaitingInteraction(db, issue, data, actor);
|
||||
await touchIssue(db, issue.id);
|
||||
return hydrateInteraction(created);
|
||||
},
|
||||
@@ -1182,33 +1092,6 @@ export function issueThreadInteractionService(db: Db) {
|
||||
return expired;
|
||||
},
|
||||
|
||||
expireInvalidPendingRequestConfirmationsForIssue: async (
|
||||
issue: { id: string; companyId: string },
|
||||
actor: InteractionActor,
|
||||
) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(issueThreadInteractions)
|
||||
.where(and(
|
||||
eq(issueThreadInteractions.companyId, issue.companyId),
|
||||
eq(issueThreadInteractions.issueId, issue.id),
|
||||
eq(issueThreadInteractions.kind, "request_confirmation"),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
));
|
||||
|
||||
const expired: IssueThreadInteraction[] = [];
|
||||
for (const row of rows) {
|
||||
const staleTarget = await expireStaleRequestConfirmationTarget(db, { row, actor });
|
||||
if (staleTarget) {
|
||||
expired.push(staleTarget);
|
||||
continue;
|
||||
}
|
||||
const superseded = await expireRequestConfirmationSupersededByLatestUserComment(db, { row, actor });
|
||||
if (superseded) expired.push(superseded);
|
||||
}
|
||||
return expired;
|
||||
},
|
||||
|
||||
answerQuestions: async (
|
||||
issue: { id: string; companyId: string },
|
||||
interactionId: string,
|
||||
|
||||
+40
-462
@@ -29,10 +29,6 @@ import {
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
IssueBlockerAttention,
|
||||
IssueBlockerAttentionReason,
|
||||
IssueBlockerAttentionState,
|
||||
IssueBlockerNextActionHint,
|
||||
IssueBlockerNextActionOwner,
|
||||
IssueProductivityReview,
|
||||
IssueProductivityReviewTrigger,
|
||||
IssueRelationIssueSummary,
|
||||
@@ -55,10 +51,6 @@ import {
|
||||
issueTreeControlService,
|
||||
type ActiveIssueTreePauseHoldGate,
|
||||
} from "./issue-tree-control.js";
|
||||
import {
|
||||
isLiveExplicitApprovalWaitingPath,
|
||||
isLiveExplicitInteractionWaitingPath,
|
||||
} from "./recovery/explicit-waiting-paths.js";
|
||||
|
||||
const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"];
|
||||
const MAX_ISSUE_COMMENT_PAGE_LIMIT = 500;
|
||||
@@ -678,7 +670,6 @@ const ACTIVE_RUN_STATUSES = ["queued", "running"];
|
||||
const BLOCKER_ATTENTION_ACTIVE_RUN_STATUSES = ["queued", "running"];
|
||||
const BLOCKER_ATTENTION_ACTIVE_WAKE_STATUSES = ["queued", "deferred_issue_execution"];
|
||||
const BLOCKER_ATTENTION_PENDING_INTERACTION_STATUSES = ["pending"];
|
||||
const BLOCKER_ATTENTION_WAITING_INTERACTION_KINDS = ["request_confirmation", "ask_user_questions"];
|
||||
const BLOCKER_ATTENTION_PENDING_APPROVAL_STATUSES = ["pending", "revision_requested"];
|
||||
const BLOCKER_ATTENTION_OPEN_RECOVERY_ORIGIN_KIND = "harness_liveness_escalation";
|
||||
const PRODUCTIVITY_REVIEW_ORIGIN_KIND = "issue_productivity_review";
|
||||
@@ -696,18 +687,6 @@ const BLOCKER_ATTENTION_OPEN_RECOVERY_TERMINAL_STATUSES = ["done", "cancelled"];
|
||||
const BLOCKER_ATTENTION_MAX_DEPTH = 8;
|
||||
const BLOCKER_ATTENTION_MAX_NODES = 2000;
|
||||
const BLOCKER_ATTENTION_INVOKABLE_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]);
|
||||
// Leaves whose latest issue-linked run looks "productive but stranded" — the
|
||||
// run produced concrete progress or described future work, but the issue is
|
||||
// non-terminal and no continuation/wake/interaction took ownership of the next
|
||||
// step. These leaves drive the recovery_needed surface.
|
||||
const BLOCKER_ATTENTION_RECOVERY_LIVENESS_STATES = new Set([
|
||||
"advanced",
|
||||
"needs_followup",
|
||||
"plan_only",
|
||||
"empty_response",
|
||||
]);
|
||||
const BLOCKER_ATTENTION_RECOVERY_LEAF_STATUSES = new Set(["todo", "in_progress"]);
|
||||
const BLOCKER_ATTENTION_DEFAULT_MAX_CONTINUATION_ATTEMPTS = 2;
|
||||
|
||||
type IssueBlockerAttentionNode = {
|
||||
id: string;
|
||||
@@ -717,7 +696,6 @@ type IssueBlockerAttentionNode = {
|
||||
title: string;
|
||||
status: string;
|
||||
executionRunId?: string | null;
|
||||
executionState?: Record<string, unknown> | null;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
};
|
||||
@@ -744,10 +722,6 @@ type IssueBlockerAttentionAgentRow = {
|
||||
companyId: string;
|
||||
status: string;
|
||||
};
|
||||
type IssueBlockerExplicitWaitingPath = {
|
||||
owner: IssueBlockerNextActionOwner;
|
||||
hint: IssueBlockerNextActionHint;
|
||||
};
|
||||
|
||||
async function activeRunMapForIssues(
|
||||
dbOrTx: any,
|
||||
@@ -796,113 +770,9 @@ function createIssueBlockerAttention(input: Partial<IssueBlockerAttention> = {})
|
||||
attentionBlockerCount: input.attentionBlockerCount ?? 0,
|
||||
sampleBlockerIdentifier: input.sampleBlockerIdentifier ?? null,
|
||||
sampleStalledBlockerIdentifier: input.sampleStalledBlockerIdentifier ?? null,
|
||||
nextActionOwner: input.nextActionOwner ?? null,
|
||||
nextActionHint: input.nextActionHint ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
type LeafRecoveryClassification = {
|
||||
reason: Extract<
|
||||
IssueBlockerAttentionReason,
|
||||
"productive_run_stopped" | "continuation_exhausted" | "continuation_suppressed"
|
||||
>;
|
||||
hint: IssueBlockerNextActionHint;
|
||||
owner: IssueBlockerNextActionOwner;
|
||||
};
|
||||
|
||||
function leafRecoveryHintForReason(
|
||||
reason: LeafRecoveryClassification["reason"],
|
||||
owner: IssueBlockerNextActionOwner,
|
||||
): IssueBlockerNextActionHint {
|
||||
if (owner.type === "user") return "reassign";
|
||||
if (owner.type === "none") return "reassign";
|
||||
if (reason === "productive_run_stopped") return "wake_to_continue";
|
||||
if (reason === "continuation_exhausted") return "create_recovery_issue";
|
||||
return "needs_human_review";
|
||||
}
|
||||
|
||||
function ownerForLeafRecovery(node: IssueBlockerAttentionNode): IssueBlockerNextActionOwner {
|
||||
if (node.assigneeAgentId) {
|
||||
return { type: "agent", agentId: node.assigneeAgentId, userId: null };
|
||||
}
|
||||
if (node.assigneeUserId) {
|
||||
return { type: "user", agentId: null, userId: node.assigneeUserId };
|
||||
}
|
||||
return { type: "none", agentId: null, userId: null };
|
||||
}
|
||||
|
||||
function ownerForExplicitWait(input: { assigneeAgentId?: string | null; assigneeUserId?: string | null }): IssueBlockerNextActionOwner {
|
||||
if (input.assigneeUserId) {
|
||||
return { type: "user", agentId: null, userId: input.assigneeUserId };
|
||||
}
|
||||
if (input.assigneeAgentId) {
|
||||
return { type: "agent", agentId: input.assigneeAgentId, userId: null };
|
||||
}
|
||||
return { type: "user", agentId: null, userId: null };
|
||||
}
|
||||
|
||||
function explicitWaitingPathForOwner(owner: IssueBlockerNextActionOwner): IssueBlockerExplicitWaitingPath {
|
||||
return {
|
||||
owner,
|
||||
hint: "needs_human_review",
|
||||
};
|
||||
}
|
||||
|
||||
type IssueBlockerAttentionLatestRunRow = {
|
||||
issueId: string | null;
|
||||
status: string;
|
||||
livenessState: string | null;
|
||||
continuationAttempt: number | null;
|
||||
finishedAt: Date | null;
|
||||
};
|
||||
|
||||
type IssueBlockerAttentionLatestRun = {
|
||||
status: string;
|
||||
livenessState: string | null;
|
||||
continuationAttempt: number | null;
|
||||
finishedAt: Date | null;
|
||||
};
|
||||
|
||||
function classifyLeafRecovery(input: {
|
||||
node: IssueBlockerAttentionNode;
|
||||
latestRun: IssueBlockerAttentionLatestRun | null;
|
||||
hasActivePath: boolean;
|
||||
hasWaitingPath: boolean;
|
||||
hasProductivityReview: boolean;
|
||||
agentInvokable: boolean;
|
||||
maxContinuationAttempts: number;
|
||||
}): LeafRecoveryClassification | null {
|
||||
const { node, latestRun, hasActivePath, hasWaitingPath, hasProductivityReview, agentInvokable, maxContinuationAttempts } = input;
|
||||
if (!BLOCKER_ATTENTION_RECOVERY_LEAF_STATUSES.has(node.status)) return null;
|
||||
if (hasActivePath || hasWaitingPath) return null;
|
||||
if (!latestRun) return null;
|
||||
if (latestRun.status !== "succeeded") return null;
|
||||
if (!latestRun.livenessState || !BLOCKER_ATTENTION_RECOVERY_LIVENESS_STATES.has(latestRun.livenessState)) return null;
|
||||
|
||||
const owner = ownerForLeafRecovery(node);
|
||||
// If the leaf has no agent owner at all, fall through to needs_attention so
|
||||
// the existing "no owner" surface continues to apply.
|
||||
if (owner.type === "none") return null;
|
||||
|
||||
let reason: LeafRecoveryClassification["reason"];
|
||||
if (hasProductivityReview) {
|
||||
reason = "continuation_suppressed";
|
||||
} else if ((latestRun.continuationAttempt ?? 0) >= maxContinuationAttempts) {
|
||||
reason = "continuation_exhausted";
|
||||
} else {
|
||||
reason = "productive_run_stopped";
|
||||
}
|
||||
|
||||
// An invokable agent who can be woken keeps the wake_to_continue affordance;
|
||||
// otherwise the user needs to reassign or escalate.
|
||||
let hint = leafRecoveryHintForReason(reason, owner);
|
||||
if (owner.type === "agent" && !agentInvokable) {
|
||||
hint = reason === "continuation_exhausted" ? "create_recovery_issue" : "needs_human_review";
|
||||
}
|
||||
|
||||
return { reason, hint, owner };
|
||||
}
|
||||
|
||||
function blockerSampleIdentifier(node: IssueBlockerAttentionNode | null | undefined) {
|
||||
return node?.identifier ?? node?.id ?? null;
|
||||
}
|
||||
@@ -1136,27 +1006,18 @@ async function listIssueBlockerAttentionMap(
|
||||
companyId: string,
|
||||
issueRows: IssueBlockerAttentionInputNode[],
|
||||
): Promise<Map<string, IssueBlockerAttention>> {
|
||||
const sameCompanyRows = issueRows.filter((row) => row.companyId === companyId);
|
||||
const roots = sameCompanyRows.filter((row) => row.status === "blocked");
|
||||
// Non-blocked, non-terminal input issues with an agent owner are eligible to
|
||||
// surface as invalid leaves on their own detail/inbox surfaces.
|
||||
const recoverySeedRows = sameCompanyRows.filter((row) =>
|
||||
BLOCKER_ATTENTION_RECOVERY_LEAF_STATUSES.has(row.status) && Boolean(row.assigneeAgentId),
|
||||
);
|
||||
const roots = issueRows.filter((row) => row.companyId === companyId && row.status === "blocked");
|
||||
const attentionMap = new Map<string, IssueBlockerAttention>();
|
||||
for (const row of issueRows) {
|
||||
if (row.status !== "blocked") {
|
||||
attentionMap.set(row.id, createIssueBlockerAttention());
|
||||
}
|
||||
}
|
||||
if (roots.length === 0 && recoverySeedRows.length === 0) return attentionMap;
|
||||
if (roots.length === 0) return attentionMap;
|
||||
|
||||
const nodesById = new Map<string, IssueBlockerAttentionNode>();
|
||||
const edgesByIssueId = new Map<string, IssueBlockerAttentionEdge[]>();
|
||||
for (const root of roots) nodesById.set(root.id, { ...root });
|
||||
for (const seed of recoverySeedRows) {
|
||||
if (!nodesById.has(seed.id)) nodesById.set(seed.id, { ...seed });
|
||||
}
|
||||
|
||||
let frontier = roots.map((root) => root.id);
|
||||
let truncated = false;
|
||||
@@ -1175,7 +1036,6 @@ async function listIssueBlockerAttentionMap(
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
executionRunId: issues.executionRunId,
|
||||
executionState: issues.executionState,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
})
|
||||
@@ -1201,7 +1061,6 @@ async function listIssueBlockerAttentionMap(
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
executionRunId: issues.executionRunId,
|
||||
executionState: issues.executionState,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
})
|
||||
@@ -1301,73 +1160,26 @@ async function listIssueBlockerAttentionMap(
|
||||
}
|
||||
}
|
||||
|
||||
const nonTerminalNodeIds = [...nodesById.values()]
|
||||
.filter((node) => node.status !== "done" && node.status !== "cancelled")
|
||||
const reviewNodeIds = [...nodesById.values()]
|
||||
.filter((node) => node.status === "in_review")
|
||||
.map((node) => node.id);
|
||||
const explicitWaitingByIssueId = new Map<string, IssueBlockerExplicitWaitingPath>();
|
||||
const setExplicitWaitingPath = (issueId: string | null | undefined, path: IssueBlockerExplicitWaitingPath) => {
|
||||
if (!issueId || explicitWaitingByIssueId.has(issueId)) return;
|
||||
explicitWaitingByIssueId.set(issueId, path);
|
||||
};
|
||||
for (const node of nodesById.values()) {
|
||||
if (node.status === "done" || node.status === "cancelled") continue;
|
||||
if (node.assigneeUserId) {
|
||||
setExplicitWaitingPath(node.id, explicitWaitingPathForOwner(ownerForExplicitWait(node)));
|
||||
}
|
||||
}
|
||||
if (nonTerminalNodeIds.length > 0) {
|
||||
for (const chunk of chunkList(nonTerminalNodeIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
|
||||
const interactionRows: Array<{
|
||||
issueId: string;
|
||||
companyId: string;
|
||||
status: string;
|
||||
createdByUserId: string | null;
|
||||
createdAt: Date;
|
||||
}> = await dbOrTx
|
||||
.select({
|
||||
issueId: issueThreadInteractions.issueId,
|
||||
companyId: issueThreadInteractions.companyId,
|
||||
status: issueThreadInteractions.status,
|
||||
createdByUserId: issueThreadInteractions.createdByUserId,
|
||||
createdAt: issueThreadInteractions.createdAt,
|
||||
})
|
||||
const explicitWaitingIssueIds = new Set<string>();
|
||||
if (reviewNodeIds.length > 0) {
|
||||
for (const chunk of chunkList(reviewNodeIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
|
||||
const interactionRows: Array<{ issueId: string }> = await dbOrTx
|
||||
.select({ issueId: issueThreadInteractions.issueId })
|
||||
.from(issueThreadInteractions)
|
||||
.where(
|
||||
and(
|
||||
eq(issueThreadInteractions.companyId, companyId),
|
||||
inArray(issueThreadInteractions.status, BLOCKER_ATTENTION_PENDING_INTERACTION_STATUSES),
|
||||
inArray(issueThreadInteractions.kind, BLOCKER_ATTENTION_WAITING_INTERACTION_KINDS),
|
||||
inArray(issueThreadInteractions.issueId, chunk),
|
||||
),
|
||||
);
|
||||
for (const row of interactionRows) {
|
||||
const node = nodesById.get(row.issueId);
|
||||
if (!node || !isLiveExplicitInteractionWaitingPath(node, row)) continue;
|
||||
setExplicitWaitingPath(row.issueId, explicitWaitingPathForOwner(ownerForExplicitWait({
|
||||
assigneeUserId: node?.assigneeUserId ?? null,
|
||||
})));
|
||||
}
|
||||
for (const row of interactionRows) explicitWaitingIssueIds.add(row.issueId);
|
||||
|
||||
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,
|
||||
})
|
||||
const approvalRows: Array<{ issueId: string }> = await dbOrTx
|
||||
.select({ issueId: issueApprovals.issueId })
|
||||
.from(issueApprovals)
|
||||
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
|
||||
.where(
|
||||
@@ -1377,24 +1189,10 @@ async function listIssueBlockerAttentionMap(
|
||||
inArray(issueApprovals.issueId, chunk),
|
||||
),
|
||||
);
|
||||
for (const row of approvalRows) {
|
||||
const node = nodesById.get(row.issueId);
|
||||
if (!node || !isLiveExplicitApprovalWaitingPath(node, row)) continue;
|
||||
setExplicitWaitingPath(row.issueId, explicitWaitingPathForOwner(ownerForExplicitWait({
|
||||
assigneeUserId: node?.assigneeUserId ?? null,
|
||||
})));
|
||||
}
|
||||
for (const row of approvalRows) explicitWaitingIssueIds.add(row.issueId);
|
||||
|
||||
const recoveryRows: Array<{
|
||||
originId: string | null;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
}> = await dbOrTx
|
||||
.select({
|
||||
originId: issues.originId,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
})
|
||||
const recoveryRows: Array<{ originId: string | null }> = await dbOrTx
|
||||
.select({ originId: issues.originId })
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
@@ -1406,7 +1204,7 @@ async function listIssueBlockerAttentionMap(
|
||||
),
|
||||
);
|
||||
for (const row of recoveryRows) {
|
||||
setExplicitWaitingPath(row.originId, explicitWaitingPathForOwner(ownerForExplicitWait(row)));
|
||||
if (row.originId) explicitWaitingIssueIds.add(row.originId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1423,165 +1221,40 @@ async function listIssueBlockerAttentionMap(
|
||||
: [];
|
||||
const agentsById = new Map(agentRows.map((agent) => [agent.id, agent]));
|
||||
|
||||
// Recovery-leaf inputs: latest issue-linked run + productivity-review hold.
|
||||
// We only need this for non-blocked agent-owned non-terminal nodes; gate the
|
||||
// queries on that subset so we do not pull runs for every blocker chain.
|
||||
const recoveryCandidateIds = [...nodesById.values()]
|
||||
.filter((node) =>
|
||||
BLOCKER_ATTENTION_RECOVERY_LEAF_STATUSES.has(node.status) &&
|
||||
Boolean(node.assigneeAgentId) &&
|
||||
!activeIssueIds.has(node.id) &&
|
||||
!explicitWaitingByIssueId.has(node.id),
|
||||
)
|
||||
.map((node) => node.id);
|
||||
|
||||
const latestRunByIssueId = new Map<string, IssueBlockerAttentionLatestRun>();
|
||||
if (recoveryCandidateIds.length > 0) {
|
||||
for (const chunk of chunkList(recoveryCandidateIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
|
||||
const runRows: IssueBlockerAttentionLatestRunRow[] = await dbOrTx
|
||||
.select({
|
||||
issueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`,
|
||||
status: heartbeatRuns.status,
|
||||
livenessState: heartbeatRuns.livenessState,
|
||||
continuationAttempt: heartbeatRuns.continuationAttempt,
|
||||
finishedAt: heartbeatRuns.finishedAt,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, companyId),
|
||||
inArray(sql<string>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, chunk),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id));
|
||||
for (const row of runRows) {
|
||||
if (!row.issueId) continue;
|
||||
if (latestRunByIssueId.has(row.issueId)) continue;
|
||||
latestRunByIssueId.set(row.issueId, {
|
||||
status: row.status,
|
||||
livenessState: row.livenessState,
|
||||
continuationAttempt: row.continuationAttempt,
|
||||
finishedAt: row.finishedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const productivityReviewIssueIds = new Set<string>();
|
||||
if (recoveryCandidateIds.length > 0) {
|
||||
for (const chunk of chunkList(recoveryCandidateIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
|
||||
const productivityRows: 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, chunk),
|
||||
notInArray(issues.status, PRODUCTIVITY_REVIEW_TERMINAL_STATUSES),
|
||||
),
|
||||
);
|
||||
for (const row of productivityRows) {
|
||||
if (row.originId) productivityReviewIssueIds.add(row.originId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const leafRecoveryByNodeId = new Map<string, LeafRecoveryClassification>();
|
||||
for (const nodeId of recoveryCandidateIds) {
|
||||
const node = nodesById.get(nodeId);
|
||||
if (!node) continue;
|
||||
const assignee = node.assigneeAgentId ? agentsById.get(node.assigneeAgentId) : null;
|
||||
const agentInvokable = Boolean(
|
||||
assignee && assignee.companyId === companyId && BLOCKER_ATTENTION_INVOKABLE_AGENT_STATUSES.has(assignee.status),
|
||||
);
|
||||
const classification = classifyLeafRecovery({
|
||||
node,
|
||||
latestRun: latestRunByIssueId.get(nodeId) ?? null,
|
||||
hasActivePath: activeIssueIds.has(nodeId),
|
||||
hasWaitingPath: explicitWaitingByIssueId.has(nodeId),
|
||||
hasProductivityReview: productivityReviewIssueIds.has(nodeId),
|
||||
agentInvokable,
|
||||
maxContinuationAttempts: BLOCKER_ATTENTION_DEFAULT_MAX_CONTINUATION_ATTEMPTS,
|
||||
});
|
||||
if (classification) leafRecoveryByNodeId.set(nodeId, classification);
|
||||
}
|
||||
|
||||
type PathClassification = {
|
||||
covered: boolean;
|
||||
coveredReason: IssueBlockerAttentionReason | null;
|
||||
stalled: boolean;
|
||||
sampleBlockerIdentifier: string | null;
|
||||
sampleStalledBlockerIdentifier: string | null;
|
||||
recoveryLeafIdentifier: string | null;
|
||||
recovery: LeafRecoveryClassification | null;
|
||||
waitingPath: IssueBlockerExplicitWaitingPath | null;
|
||||
};
|
||||
const emptyPath = (sample: string | null): PathClassification => ({
|
||||
covered: false,
|
||||
coveredReason: null,
|
||||
stalled: false,
|
||||
sampleBlockerIdentifier: sample,
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
recoveryLeafIdentifier: null,
|
||||
recovery: null,
|
||||
waitingPath: null,
|
||||
});
|
||||
const coveredPath = (
|
||||
sample: string | null,
|
||||
coveredReason: IssueBlockerAttentionReason = null,
|
||||
waitingPath: IssueBlockerExplicitWaitingPath | null = null,
|
||||
): PathClassification => ({
|
||||
covered: true,
|
||||
coveredReason,
|
||||
stalled: false,
|
||||
sampleBlockerIdentifier: sample,
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
recoveryLeafIdentifier: null,
|
||||
recovery: null,
|
||||
waitingPath,
|
||||
});
|
||||
const classifyPath = (
|
||||
nodeId: string,
|
||||
seen: Set<string>,
|
||||
): PathClassification => {
|
||||
const sample = blockerSampleIdentifier(nodesById.get(nodeId));
|
||||
if (truncated || seen.has(nodeId)) {
|
||||
return emptyPath(sample);
|
||||
return { covered: false, stalled: false, sampleBlockerIdentifier: sample, sampleStalledBlockerIdentifier: null };
|
||||
}
|
||||
const node = nodesById.get(nodeId);
|
||||
if (!node || node.companyId !== companyId) {
|
||||
return emptyPath(nodeId);
|
||||
return { covered: false, stalled: false, sampleBlockerIdentifier: nodeId, sampleStalledBlockerIdentifier: null };
|
||||
}
|
||||
const nodeSample = blockerSampleIdentifier(node);
|
||||
if (node.status === "done") {
|
||||
return coveredPath(nodeSample);
|
||||
}
|
||||
const explicitWaitingPath = explicitWaitingByIssueId.get(node.id) ?? null;
|
||||
if (explicitWaitingPath) {
|
||||
return coveredPath(nodeSample, "explicit_waiting", explicitWaitingPath);
|
||||
return { covered: true, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
|
||||
}
|
||||
if (node.status === "in_review") {
|
||||
if (activeIssueIds.has(node.id)) {
|
||||
return coveredPath(nodeSample);
|
||||
const hasWaitingPath = activeIssueIds.has(node.id) || Boolean(node.assigneeUserId) || explicitWaitingIssueIds.has(node.id);
|
||||
if (hasWaitingPath) {
|
||||
return { covered: true, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
|
||||
}
|
||||
return {
|
||||
covered: false,
|
||||
coveredReason: null,
|
||||
stalled: true,
|
||||
sampleBlockerIdentifier: nodeSample,
|
||||
sampleStalledBlockerIdentifier: nodeSample,
|
||||
recoveryLeafIdentifier: null,
|
||||
recovery: null,
|
||||
waitingPath: null,
|
||||
};
|
||||
return { covered: false, stalled: true, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: nodeSample };
|
||||
}
|
||||
if (activeIssueIds.has(node.id)) {
|
||||
return coveredPath(nodeSample);
|
||||
return { covered: true, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
|
||||
}
|
||||
if (node.status === "cancelled") {
|
||||
return emptyPath(nodeSample);
|
||||
return { covered: false, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
|
||||
}
|
||||
|
||||
const downstream = (edgesByIssueId.get(node.id) ?? []).filter((edge) => nodesById.get(edge.blockerIssueId)?.status !== "done");
|
||||
@@ -1591,95 +1264,48 @@ async function listIssueBlockerAttentionMap(
|
||||
const classified = downstream.map((edge) => classifyPath(edge.blockerIssueId, nextSeen));
|
||||
const stalledChild = classified.find((result) => result.stalled || result.sampleStalledBlockerIdentifier);
|
||||
const sampleStalled = stalledChild?.sampleStalledBlockerIdentifier ?? null;
|
||||
const recoveryChild = classified.find((result) => result.recovery && result.recoveryLeafIdentifier);
|
||||
const recoveryLeafIdentifier = recoveryChild?.recoveryLeafIdentifier ?? null;
|
||||
const recovery = recoveryChild?.recovery ?? null;
|
||||
const waitingEntry = classified.find((result) => result.coveredReason === "explicit_waiting" && result.waitingPath);
|
||||
const waitingPath = waitingEntry?.waitingPath ?? null;
|
||||
const hardAttention = classified.find((result) => !result.covered && !result.stalled && !result.recovery);
|
||||
const hardAttention = classified.find((result) => !result.covered && !result.stalled);
|
||||
if (hardAttention) {
|
||||
return {
|
||||
covered: false,
|
||||
coveredReason: null,
|
||||
stalled: false,
|
||||
sampleBlockerIdentifier: hardAttention.sampleBlockerIdentifier,
|
||||
sampleStalledBlockerIdentifier: sampleStalled,
|
||||
recoveryLeafIdentifier,
|
||||
recovery,
|
||||
waitingPath,
|
||||
};
|
||||
}
|
||||
if (recoveryChild) {
|
||||
return {
|
||||
covered: false,
|
||||
coveredReason: null,
|
||||
stalled: false,
|
||||
sampleBlockerIdentifier: recoveryLeafIdentifier ?? recoveryChild.sampleBlockerIdentifier,
|
||||
sampleStalledBlockerIdentifier: sampleStalled,
|
||||
recoveryLeafIdentifier,
|
||||
recovery,
|
||||
waitingPath,
|
||||
};
|
||||
}
|
||||
const stalledEntry = classified.find((result) => result.stalled);
|
||||
if (stalledEntry) {
|
||||
return {
|
||||
covered: false,
|
||||
coveredReason: null,
|
||||
stalled: true,
|
||||
sampleBlockerIdentifier: stalledEntry.sampleBlockerIdentifier,
|
||||
sampleStalledBlockerIdentifier: sampleStalled,
|
||||
recoveryLeafIdentifier: null,
|
||||
recovery: null,
|
||||
waitingPath,
|
||||
};
|
||||
}
|
||||
return {
|
||||
covered: true,
|
||||
coveredReason: waitingPath ? "explicit_waiting" : null,
|
||||
stalled: false,
|
||||
sampleBlockerIdentifier: waitingEntry?.sampleBlockerIdentifier ?? classified[0]?.sampleBlockerIdentifier ?? nodeSample,
|
||||
sampleBlockerIdentifier: classified[0]?.sampleBlockerIdentifier ?? nodeSample,
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
recoveryLeafIdentifier: null,
|
||||
recovery: null,
|
||||
waitingPath,
|
||||
};
|
||||
}
|
||||
|
||||
const recovery = leafRecoveryByNodeId.get(node.id) ?? null;
|
||||
if (recovery) {
|
||||
return {
|
||||
covered: false,
|
||||
coveredReason: null,
|
||||
stalled: false,
|
||||
sampleBlockerIdentifier: nodeSample,
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
recoveryLeafIdentifier: nodeSample,
|
||||
recovery,
|
||||
waitingPath: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (node.assigneeAgentId) {
|
||||
const assignee = agentsById.get(node.assigneeAgentId);
|
||||
if (!assignee || assignee.companyId !== companyId || !BLOCKER_ATTENTION_INVOKABLE_AGENT_STATUSES.has(assignee.status)) {
|
||||
return emptyPath(nodeSample);
|
||||
return { covered: false, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
|
||||
}
|
||||
}
|
||||
|
||||
return emptyPath(nodeSample);
|
||||
return { covered: false, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
|
||||
};
|
||||
|
||||
for (const root of roots) {
|
||||
const topLevelEdges = (edgesByIssueId.get(root.id) ?? []).filter((edge) => nodesById.get(edge.blockerIssueId)?.status !== "done");
|
||||
if (topLevelEdges.length === 0) {
|
||||
const waitingPath = explicitWaitingByIssueId.get(root.id) ?? null;
|
||||
attentionMap.set(root.id, createIssueBlockerAttention({
|
||||
state: waitingPath ? "covered" : "needs_attention",
|
||||
reason: waitingPath ? "explicit_waiting" : "attention_required",
|
||||
sampleBlockerIdentifier: waitingPath ? blockerSampleIdentifier(nodesById.get(root.id)) : null,
|
||||
nextActionOwner: waitingPath?.owner ?? null,
|
||||
nextActionHint: waitingPath?.hint ?? null,
|
||||
state: "needs_attention",
|
||||
reason: "attention_required",
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
@@ -1690,48 +1316,28 @@ async function listIssueBlockerAttentionMap(
|
||||
}));
|
||||
const coveredBlockerCount = classified.filter((entry) => entry.result.covered).length;
|
||||
const stalledBlockerCount = classified.filter((entry) => entry.result.stalled).length;
|
||||
const recoveryEntry = classified.find((entry) => entry.result.recovery && entry.result.recoveryLeafIdentifier);
|
||||
const recoveryBlockerCount = classified.filter((entry) => entry.result.recovery).length;
|
||||
const attentionBlockerCount = classified.length - coveredBlockerCount - stalledBlockerCount - recoveryBlockerCount;
|
||||
const hardAttentionEntry = classified.find((entry) => !entry.result.covered && !entry.result.stalled && !entry.result.recovery);
|
||||
const attentionBlockerCount = classified.length - coveredBlockerCount - stalledBlockerCount;
|
||||
const hardAttentionEntry = classified.find((entry) => !entry.result.covered && !entry.result.stalled);
|
||||
const stalledEntry = classified.find((entry) => entry.result.stalled);
|
||||
const waitingEntry = classified.find((entry) => entry.result.coveredReason === "explicit_waiting" && entry.result.waitingPath);
|
||||
const sampleEntry = hardAttentionEntry ?? recoveryEntry ?? stalledEntry ?? classified[0] ?? null;
|
||||
const sampleEntry = hardAttentionEntry ?? stalledEntry ?? classified[0] ?? null;
|
||||
const sampleNode = sampleEntry ? nodesById.get(sampleEntry.edge.blockerIssueId) : null;
|
||||
const sampleStalledFromChain = classified
|
||||
.map((entry) => entry.result.sampleStalledBlockerIdentifier)
|
||||
.find((value) => value);
|
||||
const recoveryLeafIdentifier = recoveryEntry?.result.recoveryLeafIdentifier ?? null;
|
||||
const recoveryClassification = recoveryEntry?.result.recovery ?? null;
|
||||
|
||||
let state: IssueBlockerAttentionState;
|
||||
let reason: IssueBlockerAttentionReason;
|
||||
let nextActionOwner: IssueBlockerNextActionOwner | null = null;
|
||||
let nextActionHint: IssueBlockerNextActionHint = null;
|
||||
let sampleBlockerIdentifier =
|
||||
sampleEntry?.result.sampleBlockerIdentifier ?? blockerSampleIdentifier(sampleNode);
|
||||
let state: IssueBlockerAttention["state"];
|
||||
let reason: IssueBlockerAttention["reason"];
|
||||
if (attentionBlockerCount > 0) {
|
||||
state = "needs_attention";
|
||||
reason = "attention_required";
|
||||
} else if (recoveryClassification) {
|
||||
state = "recovery_needed";
|
||||
reason = recoveryClassification.reason;
|
||||
nextActionOwner = recoveryClassification.owner;
|
||||
nextActionHint = recoveryClassification.hint;
|
||||
sampleBlockerIdentifier = recoveryLeafIdentifier ?? sampleBlockerIdentifier;
|
||||
} else if (stalledBlockerCount > 0) {
|
||||
state = "stalled";
|
||||
reason = "stalled_review";
|
||||
} else {
|
||||
state = "covered";
|
||||
reason = waitingEntry
|
||||
? "explicit_waiting"
|
||||
: topLevelEdges.every((edge) => nodesById.get(edge.blockerIssueId)?.parentId === root.id)
|
||||
? "active_child"
|
||||
: "active_dependency";
|
||||
nextActionOwner = waitingEntry?.result.waitingPath?.owner ?? null;
|
||||
nextActionHint = waitingEntry?.result.waitingPath?.hint ?? null;
|
||||
sampleBlockerIdentifier = waitingEntry?.result.sampleBlockerIdentifier ?? sampleBlockerIdentifier;
|
||||
reason = topLevelEdges.every((edge) => nodesById.get(edge.blockerIssueId)?.parentId === root.id)
|
||||
? "active_child"
|
||||
: "active_dependency";
|
||||
}
|
||||
|
||||
attentionMap.set(root.id, createIssueBlockerAttention({
|
||||
@@ -1741,37 +1347,9 @@ async function listIssueBlockerAttentionMap(
|
||||
coveredBlockerCount,
|
||||
stalledBlockerCount,
|
||||
attentionBlockerCount,
|
||||
sampleBlockerIdentifier,
|
||||
sampleBlockerIdentifier: sampleEntry?.result.sampleBlockerIdentifier ?? blockerSampleIdentifier(sampleNode),
|
||||
sampleStalledBlockerIdentifier:
|
||||
stalledEntry?.result.sampleStalledBlockerIdentifier ?? sampleStalledFromChain ?? null,
|
||||
nextActionOwner,
|
||||
nextActionHint,
|
||||
}));
|
||||
}
|
||||
|
||||
// Non-blocked recovery seeds: a leaf that is itself the invalid endpoint
|
||||
// gets its own recovery_needed surface (e.g. opening the leaf directly from
|
||||
// an inbox wake).
|
||||
for (const seed of recoverySeedRows) {
|
||||
const waitingPath = explicitWaitingByIssueId.get(seed.id) ?? null;
|
||||
if (waitingPath) {
|
||||
attentionMap.set(seed.id, createIssueBlockerAttention({
|
||||
state: "covered",
|
||||
reason: "explicit_waiting",
|
||||
sampleBlockerIdentifier: blockerSampleIdentifier(nodesById.get(seed.id)) ?? seed.identifier ?? seed.id,
|
||||
nextActionOwner: waitingPath.owner,
|
||||
nextActionHint: waitingPath.hint,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
const recovery = leafRecoveryByNodeId.get(seed.id);
|
||||
if (!recovery) continue;
|
||||
attentionMap.set(seed.id, createIssueBlockerAttention({
|
||||
state: "recovery_needed",
|
||||
reason: recovery.reason,
|
||||
sampleBlockerIdentifier: blockerSampleIdentifier(nodesById.get(seed.id)) ?? seed.identifier ?? seed.id,
|
||||
nextActionOwner: recovery.owner,
|
||||
nextActionHint: recovery.hint,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
const EXPLICIT_WAITING_PATH_FRESHNESS_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
type MaybeDate = Date | string | null | undefined;
|
||||
|
||||
export type ExplicitWaitingIssueInput = {
|
||||
companyId: string;
|
||||
id: string;
|
||||
assigneeUserId?: string | null;
|
||||
executionState?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type ExplicitWaitingInteractionInput = {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
status?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
createdAt?: MaybeDate;
|
||||
};
|
||||
|
||||
export type ExplicitWaitingApprovalInput = {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
status?: string | null;
|
||||
requestedByUserId?: string | null;
|
||||
linkedByUserId?: string | null;
|
||||
createdAt?: MaybeDate;
|
||||
updatedAt?: MaybeDate;
|
||||
linkedAt?: MaybeDate;
|
||||
};
|
||||
|
||||
function parseDate(value: MaybeDate) {
|
||||
if (!value) return null;
|
||||
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
function latestDate(...values: MaybeDate[]) {
|
||||
return values
|
||||
.map(parseDate)
|
||||
.filter((value): value is Date => Boolean(value))
|
||||
.sort((left, right) => right.getTime() - left.getTime())[0] ?? null;
|
||||
}
|
||||
|
||||
function currentParticipantIsUser(executionState: Record<string, unknown> | null | undefined) {
|
||||
const participant = executionState?.currentParticipant;
|
||||
if (!participant || typeof participant !== "object") return false;
|
||||
const value = participant as Record<string, unknown>;
|
||||
return value.type === "user" && typeof value.userId === "string" && value.userId.trim().length > 0;
|
||||
}
|
||||
|
||||
function issueHasHumanOwner(issue: ExplicitWaitingIssueInput) {
|
||||
return Boolean(issue.assigneeUserId) || currentParticipantIsUser(issue.executionState);
|
||||
}
|
||||
|
||||
function isFreshWaitingPath(createdAt: MaybeDate, now: Date) {
|
||||
const created = parseDate(createdAt);
|
||||
if (!created) return false;
|
||||
return now.getTime() - created.getTime() <= EXPLICIT_WAITING_PATH_FRESHNESS_MS;
|
||||
}
|
||||
|
||||
export function isLiveExplicitInteractionWaitingPath(
|
||||
issue: ExplicitWaitingIssueInput,
|
||||
interaction: ExplicitWaitingInteractionInput,
|
||||
now = new Date(),
|
||||
) {
|
||||
if (interaction.companyId !== issue.companyId || interaction.issueId !== issue.id) return false;
|
||||
if (interaction.status && interaction.status !== "pending") return false;
|
||||
if (issueHasHumanOwner(issue)) return true;
|
||||
if (interaction.createdByUserId) return true;
|
||||
return isFreshWaitingPath(interaction.createdAt, now);
|
||||
}
|
||||
|
||||
export function isLiveExplicitApprovalWaitingPath(
|
||||
issue: ExplicitWaitingIssueInput,
|
||||
approval: ExplicitWaitingApprovalInput,
|
||||
now = new Date(),
|
||||
) {
|
||||
if (approval.companyId !== issue.companyId || approval.issueId !== issue.id) return false;
|
||||
if (approval.status && approval.status !== "pending" && approval.status !== "revision_requested") return false;
|
||||
if (issueHasHumanOwner(issue)) return true;
|
||||
if (approval.requestedByUserId || approval.linkedByUserId) return true;
|
||||
return isFreshWaitingPath(latestDate(approval.updatedAt, approval.linkedAt, approval.createdAt), now);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { buildIssueGraphLivenessIncidentKey } from "./origins.js";
|
||||
import {
|
||||
isLiveExplicitApprovalWaitingPath,
|
||||
isLiveExplicitInteractionWaitingPath,
|
||||
} from "./explicit-waiting-paths.js";
|
||||
|
||||
export type IssueLivenessSeverity = "warning" | "critical";
|
||||
|
||||
@@ -56,12 +52,6 @@ export interface IssueLivenessWaitingPathInput {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
status: string;
|
||||
createdByUserId?: string | null;
|
||||
requestedByUserId?: string | null;
|
||||
linkedByUserId?: string | null;
|
||||
createdAt?: Date | string | null;
|
||||
updatedAt?: Date | string | null;
|
||||
linkedAt?: Date | string | null;
|
||||
}
|
||||
|
||||
export interface IssueLivenessDependencyPathEntry {
|
||||
@@ -143,16 +133,11 @@ function hasActiveExecutionPath(
|
||||
}
|
||||
|
||||
function hasWaitingPath(
|
||||
issue: IssueLivenessIssueInput,
|
||||
companyId: string,
|
||||
issueId: string,
|
||||
waitingPaths: IssueLivenessWaitingPathInput[],
|
||||
kind: "interaction" | "approval",
|
||||
now: Date,
|
||||
) {
|
||||
return waitingPaths.some((entry) =>
|
||||
kind === "interaction"
|
||||
? isLiveExplicitInteractionWaitingPath(issue, entry, now)
|
||||
: isLiveExplicitApprovalWaitingPath(issue, entry, now)
|
||||
);
|
||||
return waitingPaths.some((entry) => entry.companyId === companyId && entry.issueId === issueId);
|
||||
}
|
||||
|
||||
function readPrincipalAgentId(principal: unknown): string | null {
|
||||
@@ -323,7 +308,6 @@ function finding(input: {
|
||||
}
|
||||
|
||||
export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): IssueLivenessFinding[] {
|
||||
const now = new Date();
|
||||
const issuesById = new Map(input.issues.map((issue) => [issue.id, issue]));
|
||||
const agentsById = new Map(input.agents.map((agent) => [agent.id, agent]));
|
||||
const blockersByBlockedIssueId = new Map<string, IssueLivenessRelationInput[]>();
|
||||
@@ -368,9 +352,9 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu
|
||||
function hasExplicitWaitingPath(issue: IssueLivenessIssueInput) {
|
||||
return Boolean(issue.assigneeUserId) ||
|
||||
hasActiveExecutionPath(issue.companyId, issue.id, activeRuns, queuedWakeRequests) ||
|
||||
hasWaitingPath(issue, pendingInteractions, "interaction", now) ||
|
||||
hasWaitingPath(issue, pendingApprovals, "approval", now) ||
|
||||
openRecoveryIssues.some((entry) => entry.companyId === issue.companyId && entry.issueId === issue.id);
|
||||
hasWaitingPath(issue.companyId, issue.id, pendingInteractions) ||
|
||||
hasWaitingPath(issue.companyId, issue.id, pendingApprovals) ||
|
||||
hasWaitingPath(issue.companyId, issue.id, openRecoveryIssues);
|
||||
}
|
||||
|
||||
function reviewFinding(
|
||||
|
||||
@@ -8,14 +8,13 @@ export const RUN_LIVENESS_CONTINUATION_REASON = RECOVERY_REASON_KINDS.runLivenes
|
||||
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">;
|
||||
type HeartbeatRunRow = typeof heartbeatRuns.$inferSelect;
|
||||
type IssueRow = Pick<
|
||||
typeof issues.$inferSelect,
|
||||
"id" | "companyId" | "identifier" | "title" | "status" | "assigneeAgentId" | "executionState" | "projectId"
|
||||
@@ -105,15 +104,7 @@ export function decideRunLivenessContinuation(input: {
|
||||
} = input;
|
||||
const maxAttempts = input.maxAttempts ?? DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS;
|
||||
|
||||
if (!livenessState) {
|
||||
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) {
|
||||
if (!livenessState || !ACTIONABLE_LIVENESS_STATES.has(livenessState)) {
|
||||
return { kind: "skip", reason: "liveness state is not actionable for continuation" };
|
||||
}
|
||||
if (!issue) return { kind: "skip", reason: "issue not found" };
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
MIN_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
|
||||
type IssueGraphLivenessAutoRecoveryPreview,
|
||||
type IssueGraphLivenessAutoRecoveryPreviewItem,
|
||||
type RunLivenessState,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
agents,
|
||||
@@ -32,7 +31,6 @@ import { budgetService } from "../budgets.js";
|
||||
import { instanceSettingsService } from "../instance-settings.js";
|
||||
import { issueTreeControlService } from "../issue-tree-control.js";
|
||||
import { issueService } from "../issues.js";
|
||||
import { issueThreadInteractionService } from "../issue-thread-interactions.js";
|
||||
import { getRunLogStore } from "../run-log-store.js";
|
||||
import {
|
||||
RECOVERY_ORIGIN_KINDS,
|
||||
@@ -44,18 +42,7 @@ import {
|
||||
classifyIssueGraphLiveness,
|
||||
type IssueLivenessFinding,
|
||||
} from "./issue-graph-liveness.js";
|
||||
import {
|
||||
isLiveExplicitApprovalWaitingPath,
|
||||
isLiveExplicitInteractionWaitingPath,
|
||||
} from "./explicit-waiting-paths.js";
|
||||
import { isAutomaticRecoverySuppressedByPauseHold } from "./pause-hold-guard.js";
|
||||
import {
|
||||
RUN_LIVENESS_CONTINUATION_REASON,
|
||||
buildRunLivenessContinuationIdempotencyKey,
|
||||
decideRunLivenessContinuation,
|
||||
findExistingRunLivenessContinuationWake,
|
||||
readContinuationAttempt,
|
||||
} from "./run-liveness-continuations.js";
|
||||
|
||||
const EXECUTION_PATH_HEARTBEAT_RUN_STATUSES = ["queued", "running", "scheduled_retry"] as const;
|
||||
const UNSUCCESSFUL_HEARTBEAT_RUN_TERMINAL_STATUSES = ["failed", "cancelled", "timed_out"] as const;
|
||||
@@ -66,7 +53,6 @@ const ACTIVE_RUN_OUTPUT_EVIDENCE_TAIL_BYTES = 8 * 1024;
|
||||
const STRANDED_ISSUE_RECOVERY_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.strandedIssueRecovery;
|
||||
const STALE_ACTIVE_RUN_EVALUATION_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.staleActiveRunEvaluation;
|
||||
const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
|
||||
const EXPLICIT_WAITING_INTERACTION_KINDS = ["request_confirmation", "ask_user_questions"];
|
||||
|
||||
type RecoveryWakeupOptions = {
|
||||
source?: "timer" | "assignment" | "on_demand" | "automation";
|
||||
@@ -86,21 +72,9 @@ type RecoveryWakeup = (
|
||||
|
||||
type LatestIssueRun = Pick<
|
||||
typeof heartbeatRuns.$inferSelect,
|
||||
| "id"
|
||||
| "companyId"
|
||||
| "agentId"
|
||||
| "status"
|
||||
| "error"
|
||||
| "errorCode"
|
||||
| "contextSnapshot"
|
||||
| "livenessState"
|
||||
| "livenessReason"
|
||||
| "nextAction"
|
||||
| "continuationAttempt"
|
||||
"id" | "agentId" | "status" | "error" | "errorCode" | "contextSnapshot" | "livenessState"
|
||||
> | null;
|
||||
|
||||
type PostRunIssueDisposition = "terminal" | "explicitly_waiting" | "explicitly_live" | "invalid";
|
||||
|
||||
type WatchdogDecisionActor =
|
||||
| { type: "board"; userId?: string | null; runId?: string | null }
|
||||
| { type: "agent"; agentId?: string | null; runId?: string | null }
|
||||
@@ -214,14 +188,16 @@ function isUnsuccessfulTerminalIssueRun(latestRun: LatestIssueRun) {
|
||||
);
|
||||
}
|
||||
|
||||
function isProductiveProgressRun(latestRun: LatestIssueRun) {
|
||||
function isSuccessfulInProgressContinuationRun(latestRun: LatestIssueRun) {
|
||||
return latestRun?.status === "succeeded";
|
||||
}
|
||||
|
||||
function isProductiveContinuationRun(latestRun: LatestIssueRun) {
|
||||
return latestRun?.status === "succeeded" &&
|
||||
(
|
||||
latestRun.livenessState === "advanced" ||
|
||||
(latestRun.livenessState === "advanced" ||
|
||||
latestRun.livenessState === "completed" ||
|
||||
latestRun.livenessState === "blocked" ||
|
||||
latestRun.livenessState === "needs_followup"
|
||||
);
|
||||
latestRun.livenessState === "needs_followup");
|
||||
}
|
||||
|
||||
function parseLivenessIncidentKey(incidentKey: string | null | undefined) {
|
||||
@@ -330,16 +306,12 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
return db
|
||||
.select({
|
||||
id: heartbeatRuns.id,
|
||||
companyId: heartbeatRuns.companyId,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
status: heartbeatRuns.status,
|
||||
error: heartbeatRuns.error,
|
||||
errorCode: heartbeatRuns.errorCode,
|
||||
contextSnapshot: heartbeatRuns.contextSnapshot,
|
||||
livenessState: heartbeatRuns.livenessState,
|
||||
livenessReason: heartbeatRuns.livenessReason,
|
||||
nextAction: heartbeatRuns.nextAction,
|
||||
continuationAttempt: heartbeatRuns.continuationAttempt,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
@@ -399,103 +371,6 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
.then((rows) => Boolean(rows[0]));
|
||||
}
|
||||
|
||||
async function hasExplicitWaitingPath(
|
||||
issue: Pick<
|
||||
typeof issues.$inferSelect,
|
||||
"companyId" | "id" | "status" | "assigneeUserId" | "executionState"
|
||||
>,
|
||||
) {
|
||||
if (issue.status === "done" || issue.status === "cancelled") return true;
|
||||
if (issue.status === "blocked" || issue.status === "in_review") return true;
|
||||
if (issue.executionState) return true;
|
||||
await issueThreadInteractionService(db).expireInvalidPendingRequestConfirmationsForIssue(
|
||||
{ id: issue.id, companyId: issue.companyId },
|
||||
{ agentId: null, userId: null },
|
||||
);
|
||||
|
||||
const [interactionRows, approvalRows, recoveryIssue] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
issueId: issueThreadInteractions.issueId,
|
||||
companyId: issueThreadInteractions.companyId,
|
||||
status: issueThreadInteractions.status,
|
||||
createdByUserId: issueThreadInteractions.createdByUserId,
|
||||
createdAt: issueThreadInteractions.createdAt,
|
||||
})
|
||||
.from(issueThreadInteractions)
|
||||
.where(
|
||||
and(
|
||||
eq(issueThreadInteractions.companyId, issue.companyId),
|
||||
eq(issueThreadInteractions.issueId, issue.id),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
inArray(issueThreadInteractions.kind, EXPLICIT_WAITING_INTERACTION_KINDS),
|
||||
),
|
||||
)
|
||||
.limit(5),
|
||||
db
|
||||
.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", "revision_requested"]),
|
||||
),
|
||||
)
|
||||
.limit(5),
|
||||
db
|
||||
.select({ id: issues.id })
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, issue.companyId),
|
||||
eq(issues.originKind, STRANDED_ISSUE_RECOVERY_ORIGIN_KIND),
|
||||
eq(issues.originId, issue.id),
|
||||
notInArray(issues.status, ["done", "cancelled"]),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null),
|
||||
]);
|
||||
|
||||
return Boolean(
|
||||
interactionRows.some((row) => isLiveExplicitInteractionWaitingPath(issue, row)) ||
|
||||
approvalRows.some((row) => isLiveExplicitApprovalWaitingPath(issue, row)) ||
|
||||
recoveryIssue,
|
||||
);
|
||||
}
|
||||
|
||||
async function classifyPostRunIssueDisposition(
|
||||
issue: typeof issues.$inferSelect,
|
||||
latestRun: LatestIssueRun,
|
||||
): Promise<PostRunIssueDisposition> {
|
||||
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";
|
||||
}
|
||||
|
||||
function didAutomaticContinuationFinishWithoutLivePath(latestRun: LatestIssueRun) {
|
||||
if (latestRun?.status !== "succeeded") return false;
|
||||
const latestContext = parseObject(latestRun.contextSnapshot);
|
||||
return readNonEmptyString(latestContext.retryReason) === "issue_continuation_needed";
|
||||
}
|
||||
|
||||
async function enqueueStrandedIssueRecovery(input: {
|
||||
issueId: string;
|
||||
agentId: string;
|
||||
@@ -559,89 +434,6 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
});
|
||||
}
|
||||
|
||||
async function continueOrEscalateSuccessfulStrandedRun(input: {
|
||||
issue: typeof issues.$inferSelect;
|
||||
agent: typeof agents.$inferSelect;
|
||||
latestRun: NonNullable<LatestIssueRun>;
|
||||
}) {
|
||||
const { issue, agent, latestRun } = input;
|
||||
const livenessState = latestRun.livenessState as RunLivenessState | null;
|
||||
const nextAttempt = readContinuationAttempt(latestRun.continuationAttempt) + 1;
|
||||
const idempotencyKey = buildRunLivenessContinuationIdempotencyKey({
|
||||
issueId: issue.id,
|
||||
sourceRunId: latestRun.id,
|
||||
livenessState: livenessState ?? "failed",
|
||||
nextAttempt,
|
||||
});
|
||||
const existingWake = await findExistingRunLivenessContinuationWake(db, {
|
||||
companyId: issue.companyId,
|
||||
idempotencyKey,
|
||||
});
|
||||
const budgetBlocked = await isInvocationBudgetBlocked(issue, agent.id);
|
||||
if (budgetBlocked) return "skipped" as const;
|
||||
|
||||
const decision = decideRunLivenessContinuation({
|
||||
run: latestRun,
|
||||
issue,
|
||||
agent,
|
||||
livenessState,
|
||||
livenessReason: latestRun.livenessReason,
|
||||
nextAction: latestRun.nextAction,
|
||||
budgetBlocked,
|
||||
idempotentWakeExists: Boolean(existingWake),
|
||||
});
|
||||
|
||||
if (decision.kind === "enqueue") {
|
||||
const queued = await deps.enqueueWakeup(agent.id, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: RUN_LIVENESS_CONTINUATION_REASON,
|
||||
payload: decision.payload,
|
||||
contextSnapshot: decision.contextSnapshot,
|
||||
idempotencyKey: decision.idempotencyKey,
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: "recovery.reconcile_stranded_assigned_issue",
|
||||
});
|
||||
if (!queued) return "skipped" as const;
|
||||
|
||||
await db
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
continuationAttempt: decision.nextAttempt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(heartbeatRuns.id, latestRun.id));
|
||||
return "queued" as const;
|
||||
}
|
||||
|
||||
if (decision.kind === "skip" && decision.reason === "continuation wake already exists for this source run and attempt") {
|
||||
return "skipped" as const;
|
||||
}
|
||||
|
||||
const continuationDetail = decision.kind === "exhausted"
|
||||
? `\n\n${decision.comment}`
|
||||
: [
|
||||
"",
|
||||
"",
|
||||
"Paperclip did not queue an automatic continuation because the latest successful run did not leave a safe runnable action path.",
|
||||
"",
|
||||
`- Liveness state: \`${livenessState ?? "unknown"}\``,
|
||||
`- Reason: ${decision.reason}`,
|
||||
].join("\n");
|
||||
|
||||
const updated = await escalateStrandedAssignedIssue({
|
||||
issue,
|
||||
previousStatus: "in_progress",
|
||||
latestRun,
|
||||
comment:
|
||||
"Paperclip observed a successful terminal run for this assigned `in_progress` issue, " +
|
||||
"but no live execution, queued wake, waiting interaction, approval, or recovery owner now holds the next action." +
|
||||
continuationDetail +
|
||||
"\n\nMoving it to `blocked` so it is visible for intervention.",
|
||||
});
|
||||
return updated ? "escalated" as const : "skipped" as const;
|
||||
}
|
||||
|
||||
async function isInvocationBudgetBlocked(issue: typeof issues.$inferSelect, agentId: string) {
|
||||
const budgetBlock = await budgets.getInvocationBlock(issue.companyId, agentId, {
|
||||
issueId: issue.id,
|
||||
@@ -1824,11 +1616,6 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await hasExplicitWaitingPath(issue)) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const latestRun = await getLatestIssueRun(issue.companyId, issue.id);
|
||||
if (isStrandedIssueRecoveryIssue(issue) && isUnsuccessfulTerminalIssueRun(latestRun)) {
|
||||
const updated = await escalateStrandedRecoveryIssueInPlace({
|
||||
@@ -1918,57 +1705,15 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const postRunDisposition = await classifyPostRunIssueDisposition(issue, latestRun);
|
||||
if (postRunDisposition !== "invalid") {
|
||||
if (postRunDisposition === "explicitly_live" && latestRun?.status === "succeeded") {
|
||||
if (isProductiveProgressRun(latestRun)) {
|
||||
result.productiveContinuationObserved += 1;
|
||||
} else {
|
||||
result.successfulContinuationObserved += 1;
|
||||
}
|
||||
if (isSuccessfulInProgressContinuationRun(latestRun)) {
|
||||
if (isProductiveContinuationRun(latestRun)) {
|
||||
result.productiveContinuationObserved += 1;
|
||||
} else {
|
||||
result.successfulContinuationObserved += 1;
|
||||
}
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (didAutomaticContinuationFinishWithoutLivePath(latestRun)) {
|
||||
const updated = await escalateStrandedAssignedIssue({
|
||||
issue,
|
||||
previousStatus: "in_progress",
|
||||
latestRun,
|
||||
comment:
|
||||
"Paperclip observed a successful automatic continuation for this assigned `in_progress` issue, " +
|
||||
"but the run has finished and no live execution, queued wake, waiting interaction, approval, or recovery owner now holds the next action. " +
|
||||
"Moving it to `blocked` so it is visible for intervention.",
|
||||
});
|
||||
if (updated) {
|
||||
result.escalated += 1;
|
||||
result.issueIds.push(issue.id);
|
||||
} else {
|
||||
result.skipped += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (latestRun?.status === "succeeded") {
|
||||
const successfulRunRecovery = await continueOrEscalateSuccessfulStrandedRun({
|
||||
issue,
|
||||
agent,
|
||||
latestRun,
|
||||
});
|
||||
if (successfulRunRecovery === "queued") {
|
||||
result.continuationRequeued += 1;
|
||||
result.issueIds.push(issue.id);
|
||||
} else if (successfulRunRecovery === "escalated") {
|
||||
result.escalated += 1;
|
||||
result.issueIds.push(issue.id);
|
||||
} else {
|
||||
result.skipped += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (didAutomaticRecoveryFail(latestRun, "issue_continuation_needed")) {
|
||||
const failureSummary = summarizeRunFailureForIssueComment(latestRun);
|
||||
const updated = await escalateStrandedAssignedIssue({
|
||||
@@ -2111,26 +1856,14 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
companyId: issueThreadInteractions.companyId,
|
||||
issueId: issueThreadInteractions.issueId,
|
||||
status: issueThreadInteractions.status,
|
||||
createdByUserId: issueThreadInteractions.createdByUserId,
|
||||
createdAt: issueThreadInteractions.createdAt,
|
||||
})
|
||||
.from(issueThreadInteractions)
|
||||
.where(
|
||||
and(
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
inArray(issueThreadInteractions.kind, EXPLICIT_WAITING_INTERACTION_KINDS),
|
||||
),
|
||||
),
|
||||
.where(eq(issueThreadInteractions.status, "pending")),
|
||||
db
|
||||
.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))
|
||||
|
||||
@@ -317,10 +317,6 @@ export function classifyRunLiveness(input: RunLivenessClassificationInput): RunL
|
||||
return output("completed", `Issue is ${issueStatus}`);
|
||||
}
|
||||
|
||||
if (issueStatus === "in_review") {
|
||||
return output("needs_followup", "Issue is in review and waiting on an explicit review path", nextAction);
|
||||
}
|
||||
|
||||
if (declaredBlocker(input)) {
|
||||
return output("blocked", issueStatus === "blocked" ? "Issue status is blocked" : "Run output declared a concrete blocker", nextAction);
|
||||
}
|
||||
@@ -329,20 +325,12 @@ export function classifyRunLiveness(input: RunLivenessClassificationInput): RunL
|
||||
return output("empty_response", "Run succeeded without useful output or concrete action evidence");
|
||||
}
|
||||
|
||||
if (planExempt && usefulOutput) {
|
||||
return output("advanced", "Planning/document task produced useful output and is exempt from plan-only classification");
|
||||
if (concreteEvidence) {
|
||||
return output("advanced", `Run produced concrete action evidence: ${evidenceReason(evidence)}`);
|
||||
}
|
||||
|
||||
if (concreteEvidence) {
|
||||
const reason = `Run produced concrete action evidence: ${evidenceReason(evidence)}`;
|
||||
if (nextAction && actionability === "runnable") {
|
||||
return output("advanced", `${reason}; runnable next action remains`, nextAction);
|
||||
}
|
||||
if (nextAction && (actionability === "manager_review" || actionability === "unknown")) {
|
||||
const disposition = actionability === "manager_review" ? "manager review" : "ambiguous review";
|
||||
return output("needs_followup", `${reason}; next action requires ${disposition}`, nextAction);
|
||||
}
|
||||
return output("advanced", reason);
|
||||
if (planExempt && usefulOutput) {
|
||||
return output("advanced", "Planning/document task produced useful output and is exempt from plan-only classification");
|
||||
}
|
||||
|
||||
if (looksLikePlanningOnly(input) || nextAction) {
|
||||
|
||||
Reference in new issue
Block a user