mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 10:50:38 +09:00
[codex] Add source-scoped recovery actions (#5599)
## Thinking Path > - Paperclip is a control plane for autonomous AI companies, where work must end with a clear disposition rather than ambiguous agent liveness. > - Recovery currently detects stalled or missing-next-step issues, but source issue recovery can become split across child recovery issues, blockers, and comments. > - That makes it harder for operators and agents to see who owns recovery and what exact action is needed on the original issue. > - Source-scoped recovery actions give the original issue a first-class active recovery state with owner, evidence, wake policy, and resolution outcome. > - This pull request adds the recovery-action data model, backend reconciliation and resolution APIs, and board UI indicators/actions. > - The benefit is clearer stalled-work recovery without losing source issue context or relying on comments as the liveness path. ## What Changed - Added the `issue_recovery_actions` schema, shared types/constants/validators, and an idempotent `0084_issue_recovery_actions` migration ordered after current `master` migrations. - Updated stranded/missing-disposition recovery to create source-scoped recovery actions, wake the recovery owner on the source issue, and avoid locking the source issue for recovery-action wakes. - Added API support for reading active recovery actions on issue detail/list surfaces and resolving them with restored, blocked, cancelled, or false-positive outcomes. - Require blocked recovery resolutions to have an unresolved first-class blocker, and removed the UI shortcut that could mark recovery blocked without a blocker selection path. - Surfaced recovery indicators/actions in the issue UI, blocker notices, active run panels, issue rows, and Storybook coverage. - Updated docs and focused tests for recovery semantics, ownership, races, stale comments, and UI behavior. ## Verification - `pnpm exec vitest run server/src/__tests__/issue-recovery-actions.test.ts server/src/__tests__/heartbeat-process-recovery.test.ts ui/src/components/IssueRecoveryActionCard.test.tsx ui/src/components/IssueBlockedNotice.test.tsx ui/src/api/issues.test.ts` — 5 files, 72 tests passed. - `pnpm --filter @paperclipai/shared typecheck` — passed. - `pnpm --filter @paperclipai/db typecheck` — passed, including migration numbering check. - `pnpm --filter @paperclipai/server typecheck` — passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. - Follow-up verification after blocker-resolution guard: `pnpm exec vitest run server/src/__tests__/issue-recovery-actions.test.ts ui/src/components/IssueRecoveryActionCard.test.tsx ui/src/api/issues.test.ts` — 3 files, 27 tests passed. - Follow-up `pnpm --filter @paperclipai/server typecheck` — passed. - Follow-up `pnpm --filter @paperclipai/ui typecheck` — passed. - UI states are available in `ui/storybook/stories/source-issue-recovery.stories.tsx`; screenshot capture helper is `scripts/screenshot-recovery-card.cjs`. ## Risks - Medium: recovery behavior changes from child recovery issue ownership toward source-scoped actions, so operators may see stalled-work state in new places. - Migration risk is mitigated by using the next migration slot after `master` and making the table/constraints/index creation idempotent for anyone who previously applied the old branch-local `0082_dizzy_master_mold` migration. - Existing child recovery issue paths are still guarded for already-created recovery issues, but new source-scoped flows should be watched in CI and Greptile review. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent, tool use enabled for shell, Git, GitHub, and local test execution. Context window not exposed by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
c445e59256
commit
0808b388ee
57 changed files with 3947 additions and 224 deletions
|
|
@ -42,6 +42,7 @@ import {
|
|||
heartbeatService,
|
||||
ISSUE_LIST_DEFAULT_LIMIT,
|
||||
issueApprovalService,
|
||||
issueRecoveryActionService,
|
||||
issueService,
|
||||
logActivity,
|
||||
syncInstructionsBundleConfigFromFilePath,
|
||||
|
|
@ -1741,16 +1742,18 @@ export function agentRoutes(
|
|||
}
|
||||
|
||||
const issuesSvc = issueService(db);
|
||||
const recoveryActionsSvc = issueRecoveryActionService(db);
|
||||
const rows = await issuesSvc.list(req.actor.companyId, {
|
||||
assigneeAgentId: req.actor.agentId,
|
||||
status: "todo,in_progress,blocked",
|
||||
includeRoutineExecutions: true,
|
||||
limit: ISSUE_LIST_DEFAULT_LIMIT,
|
||||
});
|
||||
const dependencyReadiness = await issuesSvc.listDependencyReadiness(
|
||||
req.actor.companyId,
|
||||
rows.map((issue) => issue.id),
|
||||
);
|
||||
const issueIds = rows.map((issue) => issue.id);
|
||||
const [dependencyReadiness, recoveryActionByIssue] = await Promise.all([
|
||||
issuesSvc.listDependencyReadiness(req.actor.companyId, issueIds),
|
||||
recoveryActionsSvc.listActiveForIssues(req.actor.companyId, issueIds),
|
||||
]);
|
||||
|
||||
res.json(
|
||||
rows.map((issue) => ({
|
||||
|
|
@ -1764,6 +1767,7 @@ export function agentRoutes(
|
|||
parentId: issue.parentId,
|
||||
updatedAt: issue.updatedAt,
|
||||
activeRun: issue.activeRun,
|
||||
activeRecoveryAction: recoveryActionByIssue.get(issue.id) ?? null,
|
||||
dependencyReady: dependencyReadiness.get(issue.id)?.isDependencyReady ?? true,
|
||||
unresolvedBlockerCount: dependencyReadiness.get(issue.id)?.unresolvedBlockerCount ?? 0,
|
||||
unresolvedBlockerIssueIds: dependencyReadiness.get(issue.id)?.unresolvedBlockerIssueIds ?? [],
|
||||
|
|
|
|||
|
|
@ -2,9 +2,16 @@ import { randomUUID } from "node:crypto";
|
|||
import { Router, type Request, type Response } from "express";
|
||||
import multer from "multer";
|
||||
import { z } from "zod";
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import { and, desc, eq, inArray, notInArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { activityLog, executionWorkspaces, issueExecutionDecisions, projectWorkspaces } from "@paperclipai/db";
|
||||
import {
|
||||
activityLog,
|
||||
executionWorkspaces,
|
||||
issueExecutionDecisions,
|
||||
issueRelations,
|
||||
issues as issueRows,
|
||||
projectWorkspaces,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
addIssueCommentSchema,
|
||||
acceptIssueThreadInteractionSchema,
|
||||
|
|
@ -18,6 +25,7 @@ import {
|
|||
createChildIssueSchema,
|
||||
createIssueSchema,
|
||||
resolveCreateIssueStatusDefault,
|
||||
resolveIssueRecoveryActionSchema,
|
||||
feedbackTargetTypeSchema,
|
||||
feedbackTraceStatusSchema,
|
||||
feedbackVoteValueSchema,
|
||||
|
|
@ -37,6 +45,7 @@ import {
|
|||
type CompanySearchQuery,
|
||||
type CompanySearchResponse,
|
||||
type ExecutionWorkspace,
|
||||
type IssueRelationIssueSummary,
|
||||
type SuccessfulRunHandoffState,
|
||||
} from "@paperclipai/shared";
|
||||
import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry";
|
||||
|
|
@ -53,6 +62,7 @@ import {
|
|||
goalService,
|
||||
heartbeatService,
|
||||
issueApprovalService,
|
||||
issueRecoveryActionService,
|
||||
issueThreadInteractionService,
|
||||
ISSUE_LIST_DEFAULT_LIMIT,
|
||||
ISSUE_LIST_MAX_LIMIT,
|
||||
|
|
@ -405,6 +415,47 @@ async function listSuccessfulRunHandoffStates(
|
|||
return states;
|
||||
}
|
||||
|
||||
type RecoveryActionsLister = {
|
||||
listActiveForIssues: (
|
||||
companyId: string,
|
||||
sourceIssueIds: string[],
|
||||
) => Promise<Map<string, NonNullable<IssueRelationIssueSummary["activeRecoveryAction"]>>>;
|
||||
};
|
||||
|
||||
async function relationRecoveryActionMap(
|
||||
recoveryActionsSvc: RecoveryActionsLister,
|
||||
companyId: string,
|
||||
relations: { blockedBy: IssueRelationIssueSummary[]; blocks: IssueRelationIssueSummary[] },
|
||||
): Promise<Map<string, NonNullable<IssueRelationIssueSummary["activeRecoveryAction"]>>> {
|
||||
const candidates: IssueRelationIssueSummary[] = [];
|
||||
const visit = (summary: IssueRelationIssueSummary) => {
|
||||
candidates.push(summary);
|
||||
for (const terminal of summary.terminalBlockers ?? []) {
|
||||
visit(terminal);
|
||||
}
|
||||
};
|
||||
for (const blocker of relations.blockedBy) visit(blocker);
|
||||
for (const blocking of relations.blocks) visit(blocking);
|
||||
if (candidates.length === 0) return new Map();
|
||||
const ids = [...new Set(candidates.map((summary) => summary.id))];
|
||||
return recoveryActionsSvc.listActiveForIssues(companyId, ids);
|
||||
}
|
||||
|
||||
function withRecoveryActionsOnRelationSummaries(
|
||||
relations: { blockedBy: IssueRelationIssueSummary[]; blocks: IssueRelationIssueSummary[] },
|
||||
recoveryActionByIssueId: Map<string, NonNullable<IssueRelationIssueSummary["activeRecoveryAction"]>>,
|
||||
) {
|
||||
const augment = (summary: IssueRelationIssueSummary): IssueRelationIssueSummary => ({
|
||||
...summary,
|
||||
activeRecoveryAction: recoveryActionByIssueId.get(summary.id) ?? summary.activeRecoveryAction ?? null,
|
||||
terminalBlockers: summary.terminalBlockers?.map(augment),
|
||||
});
|
||||
return {
|
||||
blockedBy: relations.blockedBy.map(augment),
|
||||
blocks: relations.blocks.map(augment),
|
||||
};
|
||||
}
|
||||
|
||||
const ACTIVE_REVIEW_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]);
|
||||
|
||||
const INVALID_AGENT_IN_REVIEW_DISPOSITION_MESSAGE =
|
||||
|
|
@ -787,6 +838,7 @@ export function issueRoutes(
|
|||
const projectsSvc = projectService(db);
|
||||
const goalsSvc = goalService(db);
|
||||
const issueApprovalsSvc = issueApprovalService(db);
|
||||
const recoveryActionsSvc = issueRecoveryActionService(db);
|
||||
const executionWorkspacesSvc = executionWorkspaceServiceDirect(db);
|
||||
const workProductsSvc = workProductService(db);
|
||||
const documentsSvc = documentService(db);
|
||||
|
|
@ -1447,14 +1499,15 @@ export function issueRoutes(
|
|||
limit,
|
||||
offset,
|
||||
});
|
||||
const handoffStates = await listSuccessfulRunHandoffStates(
|
||||
db,
|
||||
companyId,
|
||||
result.map((issue) => issue.id),
|
||||
);
|
||||
const issueIds = result.map((issue) => issue.id);
|
||||
const [handoffStates, recoveryActionByIssue] = await Promise.all([
|
||||
listSuccessfulRunHandoffStates(db, companyId, issueIds),
|
||||
recoveryActionsSvc.listActiveForIssues(companyId, issueIds),
|
||||
]);
|
||||
res.json(result.map((issue) => ({
|
||||
...issue,
|
||||
successfulRunHandoff: handoffStates.get(issue.id) ?? null,
|
||||
activeRecoveryAction: recoveryActionByIssue.get(issue.id) ?? null,
|
||||
})));
|
||||
});
|
||||
|
||||
|
|
@ -1541,6 +1594,7 @@ export function issueRoutes(
|
|||
attachments,
|
||||
continuationSummary,
|
||||
currentExecutionWorkspace,
|
||||
activeRecoveryAction,
|
||||
] =
|
||||
await Promise.all([
|
||||
resolveIssueProjectAndGoal(issue),
|
||||
|
|
@ -1554,7 +1608,17 @@ export function issueRoutes(
|
|||
svc.listAttachments(issue.id),
|
||||
documentsSvc.getIssueDocumentByKey(issue.id, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY),
|
||||
currentExecutionWorkspacePromise,
|
||||
recoveryActionsSvc.getActiveForIssue(issue.companyId, issue.id),
|
||||
]);
|
||||
const recoveryActionsByRelationIssue = await relationRecoveryActionMap(
|
||||
recoveryActionsSvc,
|
||||
issue.companyId,
|
||||
relations,
|
||||
);
|
||||
const relationsWithRecoveryActions = withRecoveryActionsOnRelationSummaries(
|
||||
relations,
|
||||
recoveryActionsByRelationIssue,
|
||||
);
|
||||
|
||||
res.json({
|
||||
issue: {
|
||||
|
|
@ -1567,12 +1631,13 @@ export function issueRoutes(
|
|||
...(blockerAttention ? { blockerAttention } : {}),
|
||||
productivityReview,
|
||||
scheduledRetry,
|
||||
activeRecoveryAction,
|
||||
priority: issue.priority,
|
||||
projectId: issue.projectId,
|
||||
goalId: goal?.id ?? issue.goalId,
|
||||
parentId: issue.parentId,
|
||||
blockedBy: relations.blockedBy,
|
||||
blocks: relations.blocks,
|
||||
blockedBy: relationsWithRecoveryActions.blockedBy,
|
||||
blocks: relationsWithRecoveryActions.blocks,
|
||||
assigneeAgentId: issue.assigneeAgentId,
|
||||
assigneeUserId: issue.assigneeUserId,
|
||||
originKind: issue.originKind,
|
||||
|
|
@ -1649,6 +1714,7 @@ export function issueRoutes(
|
|||
referenceSummary,
|
||||
successfulRunHandoffStates,
|
||||
scheduledRetry,
|
||||
activeRecoveryAction,
|
||||
] = await Promise.all([
|
||||
resolveIssueProjectAndGoal(issue),
|
||||
svc.getAncestors(issue.id),
|
||||
|
|
@ -1660,7 +1726,17 @@ export function issueRoutes(
|
|||
issueReferencesSvc.listIssueReferenceSummary(issue.id),
|
||||
listSuccessfulRunHandoffStates(db, issue.companyId, [issue.id]),
|
||||
svc.getCurrentScheduledRetry(issue.id),
|
||||
recoveryActionsSvc.getActiveForIssue(issue.companyId, issue.id),
|
||||
]);
|
||||
const recoveryActionsByRelationIssue = await relationRecoveryActionMap(
|
||||
recoveryActionsSvc,
|
||||
issue.companyId,
|
||||
relations,
|
||||
);
|
||||
const relationsWithRecoveryActions = withRecoveryActionsOnRelationSummaries(
|
||||
relations,
|
||||
recoveryActionsByRelationIssue,
|
||||
);
|
||||
const mentionedProjects = mentionedProjectIds.length > 0
|
||||
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
|
||||
: [];
|
||||
|
|
@ -1676,8 +1752,9 @@ export function issueRoutes(
|
|||
productivityReview,
|
||||
successfulRunHandoff: successfulRunHandoffStates.get(issue.id) ?? null,
|
||||
scheduledRetry,
|
||||
blockedBy: relations.blockedBy,
|
||||
blocks: relations.blocks,
|
||||
activeRecoveryAction,
|
||||
blockedBy: relationsWithRecoveryActions.blockedBy,
|
||||
blocks: relationsWithRecoveryActions.blocks,
|
||||
relatedWork: referenceSummary,
|
||||
referencedIssueIdentifiers: referenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id),
|
||||
...documentPayload,
|
||||
|
|
@ -1689,6 +1766,148 @@ export function issueRoutes(
|
|||
});
|
||||
});
|
||||
|
||||
router.get("/issues/:id/recovery-actions", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const active = await recoveryActionsSvc.getActiveForIssue(issue.companyId, issue.id);
|
||||
res.json({
|
||||
active,
|
||||
actions: active ? [active] : [],
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/issues/:id/recovery-actions/resolve", validate(resolveIssueRecoveryActionSchema), async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const existing = await svc.getById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return;
|
||||
|
||||
const { actionId, outcome, sourceIssueStatus, resolutionNote } = req.body;
|
||||
if (outcome === "false_positive" || outcome === "cancelled") {
|
||||
assertBoard(req);
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const updateFields = sourceIssueStatus ? { status: sourceIssueStatus } : {};
|
||||
await assertAgentInReviewReviewPath({
|
||||
existing,
|
||||
updateFields,
|
||||
actorType: req.actor.type,
|
||||
});
|
||||
|
||||
const actionStatus = outcome === "cancelled" ? "cancelled" : "resolved";
|
||||
const result = await db.transaction(async (tx) => {
|
||||
let issue = existing;
|
||||
if (outcome === "blocked") {
|
||||
const unresolvedBlockers = await tx
|
||||
.select({ id: issueRows.id })
|
||||
.from(issueRelations)
|
||||
.innerJoin(issueRows, eq(issueRelations.issueId, issueRows.id))
|
||||
.where(
|
||||
and(
|
||||
eq(issueRelations.companyId, existing.companyId),
|
||||
eq(issueRelations.relatedIssueId, existing.id),
|
||||
eq(issueRelations.type, "blocks"),
|
||||
notInArray(issueRows.status, ["done", "cancelled"]),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (unresolvedBlockers.length === 0) {
|
||||
throw unprocessable("Blocked recovery resolution requires an unresolved first-class blocker on the source issue");
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceIssueStatus) {
|
||||
const updatedIssue = await svc.update(
|
||||
id,
|
||||
{
|
||||
status: sourceIssueStatus,
|
||||
actorAgentId: actor.agentId ?? null,
|
||||
actorUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
if (!updatedIssue) throw notFound("Issue not found");
|
||||
issue = updatedIssue;
|
||||
}
|
||||
|
||||
const recoveryAction = await recoveryActionsSvc.resolveActiveForIssue(
|
||||
{
|
||||
companyId: existing.companyId,
|
||||
sourceIssueId: existing.id,
|
||||
actionId: actionId ?? null,
|
||||
status: actionStatus,
|
||||
outcome,
|
||||
resolutionNote: resolutionNote ?? null,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
if (!recoveryAction) throw notFound("Active recovery action not found");
|
||||
|
||||
return { issue, recoveryAction };
|
||||
});
|
||||
|
||||
await routinesSvc.syncRunStatusForIssue(result.issue.id);
|
||||
|
||||
if (sourceIssueStatus && existing.status !== result.issue.status) {
|
||||
await logActivity(db, {
|
||||
companyId: result.issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.updated",
|
||||
entityType: "issue",
|
||||
entityId: result.issue.id,
|
||||
details: {
|
||||
identifier: result.issue.identifier,
|
||||
status: result.issue.status,
|
||||
source: "recovery_action_resolution",
|
||||
recoveryActionId: result.recoveryAction.id,
|
||||
_previous: {
|
||||
status: existing.status,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: result.issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.recovery_action_resolved",
|
||||
entityType: "issue",
|
||||
entityId: result.issue.id,
|
||||
details: {
|
||||
identifier: result.issue.identifier,
|
||||
recoveryActionId: result.recoveryAction.id,
|
||||
recoveryActionStatus: result.recoveryAction.status,
|
||||
outcome: result.recoveryAction.outcome,
|
||||
sourceIssueStatus: sourceIssueStatus ?? null,
|
||||
resolutionNote: result.recoveryAction.resolutionNote,
|
||||
},
|
||||
});
|
||||
|
||||
res.json({
|
||||
issue: {
|
||||
...result.issue,
|
||||
activeRecoveryAction: null,
|
||||
},
|
||||
recoveryAction: result.recoveryAction,
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/issues/:id/work-products", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue