Guard first recovery-action retention by current attention eligibility

Share the canonical human/open predicate with the feed, retain source visibility and actor authority, and preserve manual controls for existing rows. Prove ordinary retry exhaustion remains visible after a denied pre-archive.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-31 12:07:52 -05:00
1 parent 9a79769c72
commit a61af9888e
5 files changed
+90 -8

No files matched your search

@@ -58,3 +58,9 @@ Explicit blocker queries apply the dependency owner's pending-finalization predi
A fresh archive check consumes the private graph completeness result. An unexpanded frontier cannot establish absence of live work, so that check disarms without archiving. Supported issue, relation, run and workspace changes reactivate the root; restart reconstructs it. Completed-only history remains fully classified and can archive normally. No incomplete-root polling is added.
The ordinary workspace-operation recorder also signals no-run finalization in its owning transaction. Export with a workspace identity invalidates that company's blocker source, including every root sharing the exported workspace; only a known issue without a workspace uses the existing reverse-issue hint. This rare source change uses bounded company/source paging and does not rebuild unrelated retention kinds. Run-backed finalization retains its existing native-status fanout. This lets a parked incomplete tree reactivate when export clears its done blockers, without an incomplete-root retry timer. Recorder rollback leaves the operation barrier and attention state unchanged.
## First manual recovery-action materialization
Creating a recovery-action retention row through Archive, Revive or Keep requires the action to be currently open and human-owned, with a visible source issue readable by the actor. The feed and manual materializer share the same recovery eligibility SQL. The materializer retains its action-row lock, same-company source join and canonical issue visibility/read authority. Existing retention rows retain their manual controls; TTL archival and other source kinds are unchanged.
The real-owner regression creates an agent-owned action, attempts pre-archive, then exhausts that same recovery identity through the ordinary recovery service. The parent materializer hides the resulting board request; the repaired path rejects pre-archive and leaves the request visible. System ownership, resolved actions, hidden/harness/conversation sources and foreign-company records cannot create their first row. Existing-row Archive/Revive/Keep and aged legacy agent-owned TTL archival remain covered. The clean parent-module control fails on the lost visible board request; the repaired full materialization, decision-service and attention-service cohort passes 110 cases. A conversation fixture identity omission was caught and corrected locally before the clean causal control and final qualification; it is not counted as product failure evidence.
@@ -1,10 +1,11 @@
import { randomUUID } from "node:crypto";
import { and, eq, inArray, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { agents, authUsers, companies, companyMemberships, createDb, decisionRetention, issueRelations, issues } from "@paperclipai/db";
import { agents, authUsers, companies, companyMemberships, createDb, decisionRetention, issueRecoveryActions, issueRelations, issues } from "@paperclipai/db";
import type { AttentionSourceKind } from "@paperclipai/shared";
import { attentionService } from "../services/attention.js";
import { decisionRetentionService } from "../services/decision-retention.js";
import { issueRecoveryActionService } from "../services/issue-recovery-actions.js";
import { issueService } from "../services/issues.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
@@ -41,6 +42,58 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
return feed.items.some(item => item.sourceKind === sourceKind && item.subject.id === sourceId);
}
it("cannot pre-archive an unseen agent recovery before ordinary retry exhaustion asks the board for help", async () => {
const row = await issue();
const owner = issueRecoveryActionService(db);
const input = { companyId, sourceIssueId: row.id, kind: "stranded_assigned_issue" as const,
ownerType: "agent" as const, ownerAgentId: agentId, cause: "fixture", fingerprint: "same-recovery",
nextAction: "Retry the existing work", maxAttempts: 2 };
const action = await owner.upsertSourceScoped(input);
expect(action).toMatchObject({ ownerType: "agent", status: "active" });
let rejected: unknown;
try { await decisionRetentionService(db).archive(mutation("recovery_action", action.id)); } catch (error) { rejected = error; }
const escalated = await owner.upsertSourceScoped(input);
expect(escalated).toMatchObject({ id: action.id, ownerType: "board", status: "escalated" });
expect(await visible("recovery_action", action.id)).toBe(true);
expect(rejected).toMatchObject({ status: 404 });
expect((await decisionRetentionService(db).getState(companyId, "recovery_action", action.id))?.archivedAt).toBeNull();
});
it.each(["system", "resolved", "hidden", "harness", "conversation", "other_company"] as const)(
"rejects first retention state for a currently ineligible recovery source: %s", async scenario => {
const foreignCompany = randomUUID();
if (scenario === "other_company") await db.insert(companies).values({ id: foreignCompany, name: "Foreign source", issuePrefix: `F${foreignCompany.slice(0, 6)}` });
const row = await issue({ companyId: scenario === "other_company" ? foreignCompany : companyId,
hiddenAt: scenario === "hidden" ? new Date() : null, harnessKind: scenario === "harness" ? "fixture" : null,
...(scenario === "conversation" ? { conversationAgentId: agentId, conversationUserId: userId, assigneeAgentId: agentId, conversationState: "active", status: "in_progress" } : {}) });
const [action] = await db.insert(issueRecoveryActions).values({ companyId: row.companyId, sourceIssueId: row.id,
kind: "stranded_assigned_issue", cause: "fixture", fingerprint: scenario, nextAction: "Recover",
ownerType: scenario === "system" ? "system" : "board", status: scenario === "resolved" ? "resolved" : "active" }).returning();
await expect(decisionRetentionService(db).archive(mutation("recovery_action", action.id))).rejects.toMatchObject({ status: 404 });
expect(await db.select().from(decisionRetention).where(eq(decisionRetention.sourceId, action.id))).toEqual([]);
});
it("preserves existing recovery manual controls and legacy agent-owned TTL archival", async () => {
const row = await issue(), owner = issueRecoveryActionService(db), retention = decisionRetentionService(db);
const input = { companyId, sourceIssueId: row.id, kind: "stranded_assigned_issue" as const,
cause: "fixture", fingerprint: "known-recovery", nextAction: "Recover" };
const action = await owner.upsertSourceScoped({ ...input, ownerType: "board" });
expect(await visible("recovery_action", action.id)).toBe(true);
await owner.upsertSourceScoped({ ...input, ownerType: "agent", ownerAgentId: agentId });
expect((await retention.archive(mutation("recovery_action", action.id))).archivedAt).toBeInstanceOf(Date);
await retention.revive(mutation("recovery_action", action.id));
await retention.setKeep({ ...mutation("recovery_action", action.id), keep: true });
await retention.setKeep({ ...mutation("recovery_action", action.id), keep: false });
// An already materialized legacy row retains its existing TTL contract.
const activityAt = new Date(Date.now() - 100 * 86_400_000).toISOString();
await db.update(issueRecoveryActions).set({ updatedAt: new Date(activityAt) }).where(eq(issueRecoveryActions.id, action.id));
const states = await retention.syncItems(companyId, [{ sourceKind: "recovery_action", subject: { id: action.id }, activityAt }]);
const state = [...states.values()][0];
expect(await retention.autoArchive({ companyId, items: [{ sourceKind: "recovery_action", subject: { id: action.id }, activityAt,
keep: state.keep, archivedAt: null, retentionVersion: state.version }] })).toBe(1);
expect(await retention.getState(companyId, "recovery_action", action.id)).toMatchObject({ archivedReason: "idle_ttl" });
});
it.each(["review", "blocker_attention"] as const)("cannot pre-archive a future %s before an ordinary transition makes it visible", async kind => {
const row = await issue({ assigneeUserId: userId });
let rejected: unknown;
@@ -1,7 +1,13 @@
import { and, eq, gt, inArray, sql, type SQL } from "drizzle-orm";
import { heartbeatRuns, type Db } from "@paperclipai/db";
import { heartbeatRuns, issueRecoveryActions, type Db } from "@paperclipai/db";
export const HUMAN_RECOVERY_OWNER_TYPES = ["user", "board"] as const;
const OPEN_RECOVERY_ATTENTION_STATUSES = ["active", "escalated"] as const;
export function recoveryActionAttentionCondition() {
return and(inArray(issueRecoveryActions.status, [...OPEN_RECOVERY_ATTENTION_STATUSES]),
inArray(issueRecoveryActions.ownerType, [...HUMAN_RECOVERY_OWNER_TYPES]))!;
}
export const ATTENTION_BUDGET_WARNING_PERCENT = 85;
export const ATTENTION_AGENT_ERROR_STATUS = "error";
+2 -4
View File
@@ -1,4 +1,4 @@
import { HUMAN_RECOVERY_OWNER_TYPES, ATTENTION_BUDGET_WARNING_PERCENT, ATTENTION_AGENT_ERROR_STATUS, collapsePendingConfirmationsToNewest, readRunIssueId, listFailedRunsWithNewerRun, requiresReviewAttention, terminalBlockerRequiresAttention } from "./attention-source-eligibility.js";
import { recoveryActionAttentionCondition, ATTENTION_BUDGET_WARNING_PERCENT, ATTENTION_AGENT_ERROR_STATUS, collapsePendingConfirmationsToNewest, readRunIssueId, listFailedRunsWithNewerRun, requiresReviewAttention, terminalBlockerRequiresAttention } from "./attention-source-eligibility.js";
import { approvalExecutionIssueCondition, firstApprovalIssueId } from "./approval-visibility.js";
import { and, asc, desc, eq, inArray, isNull, notInArray, or, sql, type SQL } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
@@ -107,7 +107,6 @@ const SOURCE_RANK: Record<AttentionSourceKind, number> = {
};
const PENDING_INTERACTION_STATUSES = ["pending"] as const;
const OPEN_RECOVERY_STATUSES = ["active", "escalated"] as const;
const DETAIL_EXCERPT_LENGTH = 160;
const DETAIL_IMAGE_LIMIT = 3;
const OPEN_DECISION_DEFAULT_LIMIT = 500;
@@ -1413,8 +1412,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
.from(issueRecoveryActions)
.where(and(
eq(issueRecoveryActions.companyId, companyId),
inArray(issueRecoveryActions.status, [...OPEN_RECOVERY_STATUSES]),
inArray(issueRecoveryActions.ownerType, [...HUMAN_RECOVERY_OWNER_TYPES]),
recoveryActionAttentionCondition(),
))
.orderBy(desc(issueRecoveryActions.updatedAt), desc(issueRecoveryActions.id));
const [recoveryIssueMap, recoveryImageMap] = await Promise.all([
+21 -2
View File
@@ -1,5 +1,5 @@
import { isProspectiveBlockedTransition } from "./routable-blocked.js";
import { requiresReviewAttention, terminalBlockerRequiresAttention, ATTENTION_AGENT_ERROR_STATUS } from "./attention-source-eligibility.js";
import { recoveryActionAttentionCondition, requiresReviewAttention, terminalBlockerRequiresAttention, ATTENTION_AGENT_ERROR_STATUS } from "./attention-source-eligibility.js";
import { attentionIssueCondition } from "./issue-visibility.js";
import { issueService } from "./issues.js";
import { parseIssueExecutionState } from "./issue-execution-policy.js";
@@ -188,7 +188,7 @@ export function decisionRetentionService(
)).then((rows) => rows[0] ?? null);
}
async function materializeSourceState(database: Db, input: { companyId: string; sourceKind: AttentionSourceKind; sourceId: string; actor: Pick<DecisionMutationActor, "userId"> }) {
async function materializeSourceState(database: Db, input: { companyId: string; sourceKind: AttentionSourceKind; sourceId: string; authActor: AuthorizationActor; actor: Pick<DecisionMutationActor, "userId"> }) {
const tables: Record<AttentionSourceKind, string> = {
approval: "approvals", join_request: "join_requests", decision: "decisions",
issue_thread_interaction: "issue_thread_interactions", recovery_action: "issue_recovery_actions",
@@ -199,6 +199,25 @@ export function decisionRetentionService(
eq(decisionRetention.companyId, input.companyId), eq(decisionRetention.sourceKind, input.sourceKind),
eq(decisionRetention.sourceId, input.sourceId),
)).then(rows => rows[0]);
if (input.sourceKind === "recovery_action") {
const [source] = await database.select().from(issueRecoveryActions).where(and(
eq(issueRecoveryActions.companyId, input.companyId), eq(issueRecoveryActions.id, input.sourceId),
)).for("update");
if (!source) throw notFound("Attention source not found");
if (!await existingState()) {
const [eligible] = await database.select({ id: issueRecoveryActions.id }).from(issueRecoveryActions)
.innerJoin(issues, and(eq(issues.id, issueRecoveryActions.sourceIssueId), eq(issues.companyId, issueRecoveryActions.companyId)))
.where(and(eq(issueRecoveryActions.companyId, input.companyId), eq(issueRecoveryActions.id, source.id),
recoveryActionAttentionCondition(), attentionIssueCondition(false)));
if (!eligible || !await canReadDecisionSource(database, input.authActor, input.companyId, input.sourceKind, input.sourceId)) {
throw notFound("Attention source not found");
}
}
await decisionRetentionService(database).syncItems(input.companyId, [{
sourceKind: input.sourceKind, subject: { id: source.id }, activityAt: source.updatedAt.toISOString(),
}]);
return;
}
if (input.sourceKind === "agent_error_alert") {
const [source] = await database.select().from(agents).where(and(eq(agents.companyId, input.companyId), eq(agents.id, input.sourceId))).for("update");
if (!source || (!await existingState() && source.status !== ATTENTION_AGENT_ERROR_STATUS)) throw notFound("Attention source not found");