mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 16:35:48 +01:00
Carry qualified recovery retention eligibility through 18384
Normal parent merge; only the five independently reviewed retention repair paths change. Preserve every other owner and master blob. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
33b41c3228
5 files changed
+90
-8
No files matched your search
@@ -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";
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user