mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Task recovery explains access failures before a user retries saved work. > - A task project and its selected workspace are separate settings. > - Moving a task can retain files from a project that the agent cannot access. > - The recovery notice checked only the task project or a past failure record. > - This pull request checks the projects required by the current workspace selection. > - The notice now offers Retry only after those project checks pass. ## Linked Issues or Issue Description Follow-up to #17509. Related: #13230 concerns workspace binding consistency; this fix changes recovery presentation only. **What happened?** A task could show “Project access is ready” after moving to an allowed project while keeping a repository from a denied project. Typed failures could also keep the notice blocked after an authorized workspace replacement. **Expected behavior** Use the current task, workspace, and source project requirements. Honor a valid pending selection for the next admission. Keep stale, archived, or missing selections blocked without offering a project-permission repair. **Steps to reproduce** 1. Assign a low-trust agent to a task whose selected source belongs to a denied project. 2. Persist a project access failure. 3. Move the task to an allowed project without changing its selected files. 4. Reload the task. The notice must still explain the access block. 5. Select an allowed source through the workspace API. Reload the task. The notice must offer an explicit retry without starting work. **Paperclip version or commit** Reproduced after #17509 on master. The regression failed on the old projection and passed with this change. ## What Changed - Batch the current workspace project requirements for tasks with project access failures. - Check retained workspace and source projects, plus valid pending workspace selections. - Keep unknown historical denial projects unnamed. Do not expose unrelated project identifiers. - Keep unavailable workspace selections in the non-retryable failure state. Adding project permissions cannot repair these selections. - Add real database regressions for project moves, pending and applied repairs, stale revisions, archived pending selections, and foreign-company workspaces. - Update the recovery contract and verification recipe. ## Verification - Red-green regression with the real issue update service and execution access check. - Focused tests: 17 tests passed across `agent-project-access.test.ts` and `task-interruption-presentation.test.ts` after rebasing on current master. - Browser: the retained source stays blocked when the task project is allowed. Manage project access opens Permissions / Trust with Allowed projects and Add project. - The normal workspace API accepted an allowed pending selection. Returning to the task showed “Project access is ready” and Retry. Permissions and run count did not change. - Full repository typecheck and build passed before the final presentation adjustment. The final adjustment passed server typecheck, server build, and all 17 focused tests. The stale, archived, and foreign-workspace cases failed before this adjustment. Map and module-boundary checks passed. - Browser: an archived pending workspace shows the existing failure notice without Retry or a project-permission action. - `pnpm test:run` was attempted and stopped after 15 native runtime database tests failed because embedded PostgreSQL could not initialize on this host. The focused database tests passed on isolated PostgreSQL 18 in Docker. All technical CI checks passed on `a6e9f977`. Fresh CI and review are required for the presentation adjustment. One Runner shard passed on its single rerun after a provider shutdown failure. A fresh local Runner build also passed that case and its reuse sibling. The prior Paperclip Review could not publish its assessment. Its saved presentation finding is addressed. Merge waits for a fresh published review and all checks on the current head. ## Risks - Presentation must follow workspace selection precedence. Tests cover retained bindings, valid pending changes, stale changes, and company isolation. - This adds at most three bounded reads for tasks with a project access failure. It adds no reads for this purpose on unaffected tasks. - Dispatch remains the authorization authority. This change does not grant access, move files, or start execution. No migration is required. ## Model Used OpenAI Codex, GPT-6, with reasoning, repository inspection, code execution, and browser tools. The exact serving model identifier and context window were not exposed in this session. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
171 lines
12 KiB
TypeScript
171 lines
12 KiB
TypeScript
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
|
import { agents, agentWakeupRequests, approvals, companies, heartbeatRuns, issueApprovals, issueRecoveryActions,
|
|
issues, issueTreeHoldMembers, issueTreeHolds, projects, type Db } from "@paperclipai/db";
|
|
import type { TaskInterruptionPresentation } from "@paperclipai/shared";
|
|
import { isAiAuthenticationBlocked } from "./ai-auth-failure.js";
|
|
import { readSystemRecovery } from "./system-recovery-state.js";
|
|
import { PROJECT_ACCESS_DENIED } from "./project-access-denial.js";
|
|
import { authorizationService } from "./authorization.js";
|
|
import { taskWorkspaceProjectRequirements } from "./task-workspace-access.js";
|
|
|
|
type ProjectionIssue = Pick<typeof issues.$inferSelect, "id" | "companyId" | "status" | "assigneeAgentId" | "projectId">;
|
|
type ProjectionRun = Pick<typeof heartbeatRuns.$inferSelect,
|
|
"id" | "status" | "scheduledRetryAt" | "errorCode" | "livenessState" | "resultJson"> &
|
|
Partial<Pick<typeof heartbeatRuns.$inferSelect, "agentId" | "error">>;
|
|
type ProjectionState = {
|
|
run?: ProjectionRun;
|
|
action?: typeof issueRecoveryActions.$inferSelect;
|
|
wait?: { id: string; requestedAt: Date };
|
|
agent?: Pick<typeof agents.$inferSelect, "status" | "pauseReason">;
|
|
projectAccessAllowed?: boolean;
|
|
projectAccessProjectId?: string | null;
|
|
workspaceSelectionAvailable?: boolean;
|
|
project?: Pick<typeof projects.$inferSelect, "pausedAt" | "pauseReason">;
|
|
company?: Pick<typeof companies.$inferSelect, "status" | "pauseReason">;
|
|
paused: boolean;
|
|
approval: boolean;
|
|
};
|
|
|
|
/** Recognize old persisted failures without displaying arbitrary run error text. */
|
|
function deniedProjectId(issue: ProjectionIssue, run?: ProjectionRun): string | null | undefined {
|
|
const historical = run?.errorCode === "setup_failed" && run.error === "Project is outside this low-trust boundary.";
|
|
if (!run || run.status !== "failed" || run.agentId !== issue.assigneeAgentId
|
|
|| (run.errorCode !== PROJECT_ACCESS_DENIED && !historical)) return undefined;
|
|
const detail = run.resultJson?.projectAccessDenied;
|
|
return detail && typeof detail === "object" && "projectId" in detail && typeof detail.projectId === "string"
|
|
? detail.projectId : null;
|
|
}
|
|
|
|
/** Presentation never grants authority. Dispatch repeats all gates under its
|
|
* locks. The same projection is used in detail, lists and attention surfaces. */
|
|
export function projectTaskInterruption(issue: ProjectionIssue, input: ProjectionState, now = Date.now()): TaskInterruptionPresentation | null {
|
|
const { run, wait, action, agent, project, company } = input;
|
|
if (!run) return null;
|
|
const system = readSystemRecovery(action?.evidence);
|
|
const base: TaskInterruptionPresentation = { intentId: wait?.id ?? null, responsibility: "system",
|
|
state: "resolved", nextRetryAt: null, canRetry: false, userAction: null,
|
|
sourceRunIds: [run.id], observedRunId: run.id };
|
|
if (["done", "cancelled"].includes(issue.status) || (run.status === "succeeded" && !wait)) return base;
|
|
const userAction = !issue.assigneeAgentId ? "assign_agent" : input.approval || run.errorCode === "native_provider_approval_required" ? "approval"
|
|
: isAiAuthenticationBlocked(run) ? "reconnect"
|
|
: run.errorCode === "native_provider_model_rejected" ? "choose_model"
|
|
: [agent?.pauseReason, project?.pauseReason, company?.pauseReason].some(reason => reason === "budget" || reason === "budget_exceeded")
|
|
? "budget" : input.paused || project?.pausedAt || agent?.status === "paused" || company?.status === "paused" ? "resume" : null;
|
|
if (userAction) return { ...base, responsibility: "user", state: "waiting_for_user", userAction };
|
|
if (["running", "queued"].includes(run.status)) return { ...base, state: "working" };
|
|
const deniedProject = deniedProjectId(issue, run);
|
|
let projectAccess: TaskInterruptionPresentation["projectAccess"];
|
|
if (deniedProject !== undefined) {
|
|
// Permissions cannot repair a stale or unavailable workspace selection.
|
|
if (input.workspaceSelectionAvailable === false) return { ...base, state: "failed" };
|
|
const ready = input.projectAccessAllowed === true;
|
|
const projectId = input.projectAccessProjectId === undefined ? deniedProject : input.projectAccessProjectId;
|
|
projectAccess = { agentId: run.agentId!, projectId: projectId === issue.projectId ? projectId : null, ready };
|
|
if (!ready) return { ...base, responsibility: "user", state: "waiting_for_user",
|
|
userAction: "project_access", projectAccess };
|
|
}
|
|
const retryAt = system?.nextAttemptAt ?? run.scheduledRetryAt?.getTime();
|
|
if (retryAt && retryAt > now) return { ...base, state: "retry_scheduled",
|
|
nextRetryAt: new Date(retryAt).toISOString(), canRetry: true };
|
|
const activeObservation = action?.evidence.systemRecoveryActiveAt;
|
|
if (system?.state === "running" && typeof activeObservation === "number" &&
|
|
activeObservation > now - 45_000) return { ...base, state: "working" };
|
|
if (wait && wait.requestedAt.getTime() > now - 10_000 && system?.state !== "exhausted")
|
|
return { ...base, state: "working" };
|
|
return { ...base, state: "failed", canRetry: true, ...(projectAccess ? { projectAccess } : {}) };
|
|
}
|
|
|
|
/** Batch list reads rather than doing seven queries per row. Keep run logs and
|
|
* large result bodies out of this projection; only authentication evidence is
|
|
* needed for the typed reconnect action. */
|
|
export async function taskInterruptionPresentations(db: Db, companyId: string, input: ProjectionIssue[]) {
|
|
const selected = input.filter(issue => issue.companyId === companyId);
|
|
const result = new Map<string, TaskInterruptionPresentation | null>();
|
|
if (!selected.length) return result;
|
|
const ids = selected.map(issue => issue.id);
|
|
const runIssue = sql<string>`coalesce(${heartbeatRuns.nativeIssueId}::text, ${heartbeatRuns.issueId}::text, ${heartbeatRuns.contextSnapshot}->>'issueId')`;
|
|
const wakeIssue = sql<string>`${agentWakeupRequests.payload}->>'issueId'`;
|
|
const agentIds = selected.flatMap(issue => issue.assigneeAgentId ? [issue.assigneeAgentId] : []);
|
|
const projectIds = selected.flatMap(issue => issue.projectId ? [issue.projectId] : []);
|
|
const [runs, waits, agentRows, projectRows, pauseRows, approvalRows, companyRows] = await Promise.all([
|
|
db.selectDistinctOn([runIssue], { issueId: runIssue, id: heartbeatRuns.id, status: heartbeatRuns.status,
|
|
agentId: heartbeatRuns.agentId,
|
|
error: sql<string | null>`case when ${heartbeatRuns.errorCode} = 'setup_failed'
|
|
and ${heartbeatRuns.error} = 'Project is outside this low-trust boundary.' then ${heartbeatRuns.error} else null end`,
|
|
errorCode: heartbeatRuns.errorCode, livenessState: heartbeatRuns.livenessState, scheduledRetryAt: heartbeatRuns.scheduledRetryAt,
|
|
resultJson: sql<Record<string, unknown>>`jsonb_build_object('configurationIncomplete', ${heartbeatRuns.resultJson}->'configurationIncomplete',
|
|
'projectAccessDenied', ${heartbeatRuns.resultJson}->'projectAccessDenied')`,
|
|
}).from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), inArray(runIssue, ids)))
|
|
.orderBy(runIssue, desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id)),
|
|
db.selectDistinctOn([wakeIssue], { issueId: wakeIssue, id: agentWakeupRequests.id, requestedAt: agentWakeupRequests.requestedAt })
|
|
.from(agentWakeupRequests).where(and(eq(agentWakeupRequests.companyId, companyId), inArray(wakeIssue, ids),
|
|
eq(agentWakeupRequests.status, "deferred_issue_execution"), eq(agentWakeupRequests.requestedByActorType, "user")))
|
|
.orderBy(wakeIssue, desc(agentWakeupRequests.requestedAt)),
|
|
agentIds.length ? db.select({ id: agents.id, status: agents.status, pauseReason: agents.pauseReason }).from(agents)
|
|
.where(and(eq(agents.companyId, companyId), inArray(agents.id, agentIds))) : [],
|
|
projectIds.length ? db.select({ id: projects.id, pausedAt: projects.pausedAt, pauseReason: projects.pauseReason }).from(projects)
|
|
.where(and(eq(projects.companyId, companyId), inArray(projects.id, projectIds))) : [],
|
|
db.select({ issueId: issueTreeHoldMembers.issueId }).from(issueTreeHoldMembers).innerJoin(issueTreeHolds, and(
|
|
eq(issueTreeHolds.companyId, issueTreeHoldMembers.companyId), eq(issueTreeHolds.id, issueTreeHoldMembers.holdId)))
|
|
.where(and(eq(issueTreeHoldMembers.companyId, companyId), inArray(issueTreeHoldMembers.issueId, ids),
|
|
eq(issueTreeHolds.status, "active"), eq(issueTreeHolds.mode, "pause"))),
|
|
db.select({ issueId: issueApprovals.issueId }).from(issueApprovals).innerJoin(approvals, and(
|
|
eq(approvals.companyId, issueApprovals.companyId), eq(approvals.id, issueApprovals.approvalId)))
|
|
.where(and(eq(issueApprovals.companyId, companyId), inArray(issueApprovals.issueId, ids),
|
|
inArray(approvals.status, ["pending", "revision_requested"]))),
|
|
db.select({ status: companies.status, pauseReason: companies.pauseReason }).from(companies).where(eq(companies.id, companyId)),
|
|
]);
|
|
const runIds = runs.map(run => run.id);
|
|
const actions = runIds.length ? await db.selectDistinctOn([issueRecoveryActions.sourceIssueId]).from(issueRecoveryActions).where(and(
|
|
eq(issueRecoveryActions.companyId, companyId), inArray(issueRecoveryActions.sourceIssueId, ids),
|
|
inArray(sql`${issueRecoveryActions.evidence}->>'runId'`, runIds),
|
|
sql`${issueRecoveryActions.evidence} ? 'systemRecovery'`,
|
|
)).orderBy(issueRecoveryActions.sourceIssueId, desc(issueRecoveryActions.createdAt)) : [];
|
|
const byIssue = <T extends { issueId: string }>(rows: T[]) => new Map(rows.map(row => [row.issueId, row]));
|
|
const runMap = byIssue(runs), waitMap = byIssue(waits);
|
|
// Recheck current authority, including standard trust, root-issue grants and
|
|
// project policy. Do not reuse the failed run's frozen policy for a new retry.
|
|
// Only denied tasks need these reads; deduplicate projects and batch per agent.
|
|
// A task's organizational project can differ from its retained source. Old
|
|
// failures did not record the denied project, and even typed failures can
|
|
// become obsolete after a workspace selection changes.
|
|
const deniedIssues = selected.filter(issue => issue.assigneeAgentId && !["done", "cancelled"].includes(issue.status)
|
|
&& deniedProjectId(issue, runMap.get(issue.id)) !== undefined);
|
|
const requirements = await taskWorkspaceProjectRequirements(db, companyId, deniedIssues.map(issue => issue.id));
|
|
const deniedByAgent = new Map<string, Set<string>>();
|
|
for (const issue of deniedIssues) {
|
|
const ids = deniedByAgent.get(issue.assigneeAgentId!) ?? new Set<string>();
|
|
for (const projectId of requirements.get(issue.id)?.projectIds ?? []) ids.add(projectId);
|
|
if (ids.size) deniedByAgent.set(issue.assigneeAgentId!, ids);
|
|
}
|
|
const currentAccess = new Map<string, boolean>();
|
|
const authorization = authorizationService(db);
|
|
for (const [agentId, ids] of deniedByAgent) {
|
|
const projectIds = [...ids];
|
|
const decisions = await authorization.decideReads({ type: "agent", agentId, companyId, source: "agent_key" },
|
|
projectIds.map(projectId => ({ type: "project" as const, companyId, projectId })));
|
|
projectIds.forEach((projectId, index) => currentAccess.set(`${agentId}:${projectId}`, decisions[index]?.allowed === true));
|
|
}
|
|
const actionMap = new Map(actions.map(action => [action.sourceIssueId, action]));
|
|
const agentMap = new Map(agentRows.map(agent => [agent.id, agent]));
|
|
const projectMap = new Map(projectRows.map(project => [project.id, project]));
|
|
const pauses = new Set(pauseRows.map(row => row.issueId)), approvalsSet = new Set(approvalRows.map(row => row.issueId));
|
|
for (const issue of selected) {
|
|
const required = requirements.get(issue.id);
|
|
const denied = required?.projectIds.filter(projectId => currentAccess.get(`${issue.assigneeAgentId}:${projectId}`) !== true);
|
|
result.set(issue.id, projectTaskInterruption(issue, {
|
|
run: runMap.get(issue.id), action: actionMap.get(issue.id), wait: waitMap.get(issue.id),
|
|
projectAccessAllowed: denied?.length === 0,
|
|
projectAccessProjectId: denied?.includes(issue.projectId ?? "") ? issue.projectId : null,
|
|
workspaceSelectionAvailable: required?.available ?? false,
|
|
agent: agentMap.get(issue.assigneeAgentId ?? ""), project: projectMap.get(issue.projectId ?? ""),
|
|
company: companyRows[0], paused: pauses.has(issue.id), approval: approvalsSet.has(issue.id),
|
|
}));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export async function taskInterruptionPresentation(db: Db, issue: ProjectionIssue) {
|
|
return (await taskInterruptionPresentations(db, issue.companyId, [issue])).get(issue.id) ?? null;
|
|
}
|