perf(server): stop recurring attention retention scans (#18388)

Reviewed and verified at a61af9888e.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-31 12:28:40 -05:00
1 parent b6b57079b9
commit dce383c733
55 files changed
+2532 -422

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -62,7 +62,7 @@ describeEmbeddedPostgres("attention service", () => {
beforeAll(async () => {
if (!externalUrl) tempDb = await startEmbeddedPostgresTestDatabase("paperclip-attention-service-");
db = createDb(externalUrl ?? tempDb!.connectionString);
db = createDb(externalUrl ?? tempDb!.connectionString, { maxConnections: 6 });
}, 30_000);
afterEach(async () => {
@@ -0,0 +1,103 @@
import { randomUUID } from "node:crypto";
import { sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { applyPendingMigrations, companies, createDb, issues } from "@paperclipai/db";
import { issueService } from "../services/issues.js";
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_BLOCKER_HISTORY_TEST_DATABASE_URL");
const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
type Plan = { "Node Type": string; "Relation Name"?: string; "Actual Rows": number; "Actual Loops": number;
"Rows Removed by Filter"?: number; "Rows Removed by Index Recheck"?: number; Plans?: Plan[] };
describe.skipIf(!support.supported)("blocker workspace retained history cost", () => {
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
let db: ReturnType<typeof createDb>;
let capture = false;
const queries: Array<{ query: string; parameters: unknown[] }> = [];
beforeAll(async () => {
if (external) await applyPendingMigrations(external);
else temporary = await startEmbeddedPostgresTestDatabase("blocker-history-");
db = createDb(external ?? temporary!.connectionString, { maxConnections: 6, idleTimeoutSeconds: 0, maxLifetimeSeconds: 0 });
db.$client.options.onnotice = () => {};
db.$client.options.debug = (_connection, query, parameters) => {
if (capture && query.includes("workspace_operations")) queries.push({ query, parameters: [...parameters] });
};
}, 30_000);
afterEach(async () => { capture = false; queries.length = 0; await db.execute(sql`truncate companies cascade`); });
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await temporary?.cleanup(); });
it.each(["finalized", "pending"] as const)("bounds operation-history work for 2100 blockers sharing 5000 %s operations", async kind => {
const companyId = randomUUID(), workspaceId = randomUUID(), unrelatedWorkspaceId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Shared history", issuePrefix: `SH${companyId.slice(0, 6)}` });
const [root] = await db.insert(issues).values({ companyId, title: "History dependent", status: "blocked" }).returning();
await db.execute(sql`insert into execution_workspaces (id, company_id, name, mode, strategy_type)
values (${workspaceId}::uuid, ${companyId}::uuid, 'Shared operation history', 'shared', 'project_primary'),
(${unrelatedWorkspaceId}::uuid, ${companyId}::uuid, 'Unrelated history', 'shared', 'project_primary')`);
await db.execute(sql`insert into workspace_operations (company_id, execution_workspace_id, phase, status, started_at)
select ${companyId}::uuid, ${unrelatedWorkspaceId}::uuid, 'worktree_prepare', 'succeeded',
'2025-01-01T00:00:00Z'::timestamptz + n * interval '1 second' from generate_series(1, 50000) n`);
await db.execute(sql`insert into workspace_operations (company_id, execution_workspace_id, phase, status, started_at)
select ${companyId}::uuid, ${workspaceId}::uuid,
case when n = 5000 and ${kind} = 'finalized' then 'workspace_finalize' else 'worktree_prepare' end,
'succeeded', '2026-01-01T00:00:00Z'::timestamptz + n * interval '1 second' from generate_series(1, 5000) n`);
await db.execute(sql`with history as (
insert into issues (company_id, title, status, execution_workspace_id)
select ${companyId}::uuid, 'done blocker ' || n, 'done', ${workspaceId}::uuid
from generate_series(1, 2100) n returning id)
insert into issue_relations (company_id, issue_id, related_issue_id, type)
select ${companyId}::uuid, id, ${root.id}::uuid, 'blocks' from history`);
await db.execute(sql`analyze workspace_operations`);
await db.execute(sql`analyze issues`);
await db.execute(sql`analyze issue_relations`);
capture = true;
const batch = await issueService(db).listBlockerAttentionBatch(companyId, [root]);
capture = false;
expect(queries.length).toBeGreaterThan(0);
expect(Math.max(...queries.map(statement => statement.parameters.length))).toBeLessThanOrEqual(1100);
let operationRowsExamined = 0;
const measurements: unknown[] = [];
for (const statement of queries) {
const result = await db.$client.unsafe("EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) " + statement.query,
statement.parameters as never[]);
const explain = (result[0]!["QUERY PLAN"] as Array<{ Plan: Plan }>)[0]!;
const visit = (node: Plan) => {
if (node["Relation Name"] === "workspace_operations") operationRowsExamined += node["Actual Loops"]
* (node["Actual Rows"] + (node["Rows Removed by Filter"] ?? 0) + (node["Rows Removed by Index Recheck"] ?? 0));
node.Plans?.forEach(visit);
};
visit(explain.Plan);
measurements.push({ ...statement, explain });
}
process.stdout.write(JSON.stringify({ sharedWorkspaceHistory: { kind, blockers: 2100, retainedOperations: 5000, unrelatedOperations: 50000,
operationRowsExamined, queryCount: queries.length, measurements } }) + "\n");
// The finite graph/pair pages may each read the 5,000 candidate rows once.
// Neither 2,100 blockers nor 50,000 unrelated operations may multiply that work.
expect(operationRowsExamined).toBeLessThanOrEqual(55_000);
expect(batch.deferredRootIds.has(root.id)).toBe(kind === "pending");
if (kind === "finalized") expect(batch.nodeCount).toBe(1);
}, 120_000);
it("preserves attributed precedence and the strict later-finalize boundary", async () => {
const companyId = randomUUID(), workspaceId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Finalize policy", issuePrefix: `FP${companyId.slice(0, 6)}` });
await db.execute(sql`insert into execution_workspaces (id, company_id, name, mode, strategy_type)
values (${workspaceId}::uuid, ${companyId}::uuid, 'Policy workspace', 'shared', 'project_primary')`);
const roots = await db.insert(issues).values(["older", "equal", "fallback"].map(title => ({ companyId, title, status: "blocked" }))).returning();
const blockers = await db.insert(issues).values(roots.map(root => ({ companyId, title: root.title + " blocker", status: "done", executionWorkspaceId: workspaceId }))).returning();
for (let index = 0; index < roots.length; index += 1) {
await db.execute(sql`insert into issue_relations (company_id, issue_id, related_issue_id, type)
values (${companyId}::uuid, ${blockers[index]!.id}::uuid, ${roots[index]!.id}::uuid, 'blocks')`);
}
await db.execute(sql`insert into workspace_operations (company_id, execution_workspace_id, issue_id, phase, status, started_at) values
(${companyId}::uuid, ${workspaceId}::uuid, ${blockers[0]!.id}::uuid, 'worktree_prepare', 'succeeded', '2026-01-01T00:00:01Z'),
(${companyId}::uuid, ${workspaceId}::uuid, ${blockers[1]!.id}::uuid, 'worktree_prepare', 'succeeded', '2026-01-01T00:00:02Z'),
(${companyId}::uuid, ${workspaceId}::uuid, null, 'workspace_finalize', 'succeeded', '2026-01-01T00:00:02Z'),
(${companyId}::uuid, ${workspaceId}::uuid, null, 'worktree_prepare', 'succeeded', '2026-01-01T00:00:03Z')`);
const service = issueService(db);
expect((await service.listBlockerAttentionBatch(companyId, [roots[0]!])).nodeCount).toBe(1);
expect((await service.listBlockerAttentionBatch(companyId, [roots[1]!])).nodeCount).toBe(2);
expect((await service.listBlockerAttentionBatch(companyId, [roots[2]!])).nodeCount).toBe(2);
});
});
@@ -4185,6 +4185,7 @@ describe("company portability", () => {
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
db.transaction = async (write: (tx: typeof db) => Promise<unknown>) => write(db);
return { db, insertedRelationValues, monitorUpdates };
}
@@ -73,7 +73,7 @@ const support = externalUrl ? { supported: true } : await getEmbeddedPostgresTes
const claim = (id: string, updatedAt = new Date(), owner = db) => writeDecisionChange(owner, async tx => tx.update(decisions).set({
status: "decided", executionStatus: "running", chosenOptionId: "yes", decidedByUserId: userId,
updatedAt, inputValues: {}, metadata: { continuationPending: true },
}).where(eq(decisions.id, id)));
}).where(eq(decisions.id, id)), { companyId });
async function start() {
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => !draining, canReconcile: () => true,
onError: error => errors.push(error) });
@@ -467,7 +467,7 @@ const support = externalUrl ? { supported: true } : await getEmbeddedPostgresTes
} else {
await writeDecisionChange(db, async tx => tx.update(decisions).set({ status: "decided", executionStatus: "running",
chosenOptionId: "archive", decidedByUserId: userId, updatedAt: new Date(Date.now() - 120_000),
metadata: { ...row.metadata, continuationPending: true } }).where(eq(decisions.id, row.id)));
metadata: { ...row.metadata, continuationPending: true } }).where(eq(decisions.id, row.id)), { companyId });
}
await start(); await settled(row.id, committed ? "failed" : "succeeded"); await quiet();
const [retained] = await db.select().from(decisionRetention);
@@ -0,0 +1,187 @@
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, 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";
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_RETENTION_MATERIALIZATION_TEST_DATABASE_URL");
const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("retention first-materialization security", () => {
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
let db: ReturnType<typeof createDb>;
let companyId: string, agentId: string, userId: string;
const users: string[] = [];
beforeAll(async () => {
if (!external) temporary = await startEmbeddedPostgresTestDatabase("retention-security-");
db = createDb(external ?? temporary!.connectionString, { maxConnections: 6, idleTimeoutSeconds: 0, maxLifetimeSeconds: 0 });
db.$client.options.onnotice = () => {};
}, 30_000);
beforeEach(async () => {
companyId = randomUUID(); agentId = randomUUID(); userId = randomUUID(); users.push(userId);
await db.insert(authUsers).values({ id: userId, name: "Security operator", email: `${userId}@retention.test`, createdAt: new Date(), updatedAt: new Date() });
await db.insert(companies).values({ id: companyId, name: "Materialization", issuePrefix: `S${companyId.slice(0, 6)}`, defaultResponsibleUserId: userId });
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, membershipRole: "owner", status: "active" });
await db.insert(agents).values({ id: agentId, companyId, name: "Source agent", status: "idle", adapterType: "process" });
});
afterEach(async () => { await db.execute(sql`truncate companies cascade`); });
afterAll(async () => { if (db) { await db.delete(authUsers).where(inArray(authUsers.id, users)); await db.$client.end(); } await temporary?.cleanup(); });
const mutation = (sourceKind: AttentionSourceKind, sourceId: string) => ({ companyId, sourceKind, sourceId,
authActor: { type: "board" as const, source: "local_implicit" as const, userId, companyIds: [companyId], isInstanceAdmin: true },
actor: { actorType: "user" as const, actorId: userId, userId, agentId: null, runId: null, agentApiKeyId: null, responsibleUserId: userId } });
async function issue(values: Partial<typeof issues.$inferInsert> = {}) {
return (await db.insert(issues).values({ companyId, title: "Security source", status: "todo", createdByAgentId: agentId, ...values }).returning())[0]!;
}
async function visible(sourceKind: AttentionSourceKind, sourceId: string) {
const feed = await attentionService(db).list(companyId, { all: true, allowUnscopedAll: true, userId, excludeSources: ["approval", "join_request"] });
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;
try { await decisionRetentionService(db).archive(mutation(kind, row.id)); } catch (error) { rejected = error; }
await issueService(db).update(row.id, kind === "review"
? { status: "in_review", actorUserId: userId }
: { status: "blocked", unblockDescriptor: { owner: "board", action: "Choose the next owner" }, actorUserId: userId });
expect(await visible(kind, row.id)).toBe(true);
expect(rejected).toMatchObject({ status: 404 });
expect((await decisionRetentionService(db).getState(companyId, kind, row.id))?.archivedAt).toBeNull();
});
it.each(["review", "blocker_attention", "productivity_review"] as const)("does not materialize Keep for an ineligible %s", async kind => {
const row = await issue();
await expect(decisionRetentionService(db).setKeep({ ...mutation(kind, row.id), keep: true })).rejects.toMatchObject({ status: 404 });
expect(await decisionRetentionService(db).getState(companyId, kind, row.id)).toBeNull();
});
it("allows Keep on an unseen eligible review and preserves existing manual actions after it leaves review", async () => {
const row = await issue({ status: "in_review", assigneeUserId: userId });
const service = decisionRetentionService(db), input = mutation("review", row.id);
expect(await service.getState(companyId, "review", row.id)).toBeNull();
expect((await service.setKeep({ ...input, keep: true })).keep).toBe(true);
await issueService(db).update(row.id, { status: "todo", actorUserId: userId });
expect((await service.archive(input)).archivedReason).toBe("manual");
expect((await service.setKeep({ ...input, keep: false })).keep).toBe(false);
expect((await service.revive(input)).archivedAt).toBeNull();
});
it("allows unseen current board-owned blocker attention", async () => {
const row = await issue({ status: "blocked", unblockDescriptor: { owner: "board", action: "Choose next owner" }, blockedTransitionAt: new Date() });
expect((await decisionRetentionService(db).setKeep({ ...mutation("blocker_attention", row.id), keep: true })).keep).toBe(true);
});
it("requires current agent error status for a new alert but preserves existing row actions", async () => {
const service = decisionRetentionService(db), input = mutation("agent_error_alert", agentId);
await expect(service.archive(input)).rejects.toMatchObject({ status: 404 });
expect(await service.getState(companyId, "agent_error_alert", agentId)).toBeNull();
await db.update(agents).set({ status: "error" }).where(eq(agents.id, agentId));
expect((await service.setKeep({ ...input, keep: true })).keep).toBe(true);
await db.update(agents).set({ status: "idle" }).where(eq(agents.id, agentId));
expect((await service.archive(input)).archivedAt).toBeInstanceOf(Date);
expect((await service.revive(input)).archivedAt).toBeNull();
});
it("preserves historical productivity and terminal-leaf rows without inventing a new source", async () => {
const row = await issue(); const service = decisionRetentionService(db);
await service.syncItems(companyId, ["productivity_review", "blocker_attention"].map(sourceKind => ({ sourceKind: sourceKind as AttentionSourceKind, subject: { id: row.id }, activityAt: row.updatedAt.toISOString() })));
for (const kind of ["productivity_review", "blocker_attention"] as const) {
const input = mutation(kind, row.id);
expect((await service.archive(input)).archivedAt).toBeInstanceOf(Date);
expect((await service.revive(input)).archivedAt).toBeNull();
expect((await service.setKeep({ ...input, keep: true })).keep).toBe(true);
}
});
it("requires ordinary feed materialization for an unseen TODO terminal leaf, then preserves its manual controls", async () => {
const root = await issue({ status: "blocked" });
const leaf = await issue({ status: "todo" });
await db.insert(issueRelations).values({ companyId, issueId: leaf.id, relatedIssueId: root.id, type: "blocks" });
const service = decisionRetentionService(db), input = mutation("blocker_attention", leaf.id);
await expect(service.setKeep({ ...input, keep: true })).rejects.toMatchObject({ status: 404 });
expect(await service.getState(companyId, "blocker_attention", leaf.id)).toBeNull();
const attention = await issueService(db).listBlockerAttention(companyId, [root]);
expect(attention.get(root.id)).toMatchObject({ terminalBlockerIssueId: leaf.id, state: "needs_attention", blockingTreeLive: false });
expect(await visible("blocker_attention", leaf.id)).toBe(true);
expect((await service.archive(input)).archivedAt).toBeInstanceOf(Date);
expect((await service.revive(input)).archivedAt).toBeNull();
expect((await service.setKeep({ ...input, keep: true })).keep).toBe(true);
});
it.each(["review", "blocker_attention"] as const)("does not pre-materialize hidden %s", async kind => {
const row = await issue({ hiddenAt: new Date(), status: kind === "review" ? "in_review" : "blocked", assigneeUserId: userId,
unblockDescriptor: { owner: "board", action: "Choose next owner" }, blockedTransitionAt: new Date() });
await expect(decisionRetentionService(db).archive(mutation(kind, row.id))).rejects.toMatchObject({ status: 404 });
expect(await decisionRetentionService(db).getState(companyId, kind, row.id)).toBeNull();
});
it.each(["review", "agent_error_alert"] as const)("keeps company and agent authorization isolated for %s", async kind => {
const foreign = randomUUID();
await db.insert(companies).values({ id: foreign, name: "Foreign", issuePrefix: "FOREIGN" });
const sourceId = kind === "review" ? (await issue({ companyId: foreign, status: "in_review", assigneeUserId: userId })).id
: (await db.insert(agents).values({ companyId: foreign, name: "Foreign error", status: "error" }).returning())[0]!.id;
const service = decisionRetentionService(db);
await expect(service.archive(mutation(kind, sourceId))).rejects.toMatchObject({ status: 404 });
const input = { ...mutation(kind, sourceId), companyId: foreign, authActor: { type: "agent" as const, agentId, companyId },
actor: { actorType: "agent" as const, actorId: agentId, userId: null, agentId, runId: null, agentApiKeyId: null, responsibleUserId: null } };
await expect(service.archive(input)).rejects.toMatchObject({ status: 404 });
expect(await db.select().from(decisionRetention).where(and(eq(decisionRetention.companyId, foreign), eq(decisionRetention.sourceId, sourceId)))).toHaveLength(0);
});
});
@@ -793,7 +793,7 @@ describePg("decisionService", () => {
expect((await service().get(id))?.metadata.continuationPending).toBe(false);
}
const markReady = (id: string, source = db) => writeDecisionChange(source, async tx => tx.update(decisions)
.set({ status: "cancelled", metadata: { continuationPending: true } }).where(eq(decisions.id, id)));
.set({ status: "cancelled", metadata: { continuationPending: true } }).where(eq(decisions.id, id)), { companyId });
it("coalesces sequential tenant writes without dropping pending recovery", async () => {
const row = await createCommentDecision();
@@ -73,7 +73,7 @@ describeEmbeddedPostgres("git restore point retention", () => {
} = {}) {
const companyId = input.companyId ?? randomUUID();
if (!input.companyId) {
await db.insert(companies).values({ id: companyId, name: "Restore", issuePrefix: `R${companyId.slice(0, 6)}` });
await db.insert(companies).values({ id: companyId, name: "Restore", issuePrefix: `R${companyId.replaceAll("-", "")}` });
}
const issueStatus = input.issueStatus ?? "done";
const closedAt = new Date(now.getTime() - (input.closedDaysAgo ?? 10) * DAY_MS);
@@ -8,6 +8,7 @@ import {
approvals,
companies,
createDb,
applyPendingMigrations,
heartbeatRuns,
issueApprovals,
issueRelations,
@@ -21,7 +22,10 @@ import {
import { issueService } from "../services/issues.js";
import { buildIssueGraphLivenessIncidentKey } from "../services/recovery/origins.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_BLOCKER_ATTENTION_TEST_DATABASE_URL");
const embeddedPostgresSupport = externalUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
@@ -36,8 +40,9 @@ describeEmbeddedPostgres("issue blocker attention", () => {
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-blocker-attention-");
db = createDb(tempDb.connectionString);
if (externalUrl) await applyPendingMigrations(externalUrl);
else tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-blocker-attention-");
db = createDb(externalUrl ?? tempDb!.connectionString, { maxConnections: 6 });
svc = issueService(db);
}, 20_000);
@@ -55,6 +60,7 @@ describeEmbeddedPostgres("issue blocker attention", () => {
});
afterAll(async () => {
await db?.$client.end({ timeout: 1 });
await tempDb?.cleanup();
});
+1 -1
View File
@@ -4151,7 +4151,7 @@ const dependencyTestDatabaseUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_ISSU
beforeAll(async () => {
if (!dependencyTestDatabaseUrl) tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-blockers-");
db = createDb(dependencyTestDatabaseUrl ?? tempDb!.connectionString);
db = createDb(dependencyTestDatabaseUrl ?? tempDb!.connectionString, { maxConnections: 6 });
svc = issueService(db);
await ensureIssueRelationsTable(db);
}, 20_000);
@@ -1,3 +1,4 @@
import { attentionRetentionService } from "../services/attention-retention.js";
import { statusCardEvaluationService, registerStatusCardEvaluationWork } from "../services/status-card-evaluation.js";
import { nativeHarnessBackupExpirationService } from "../services/native-runtime/native-harness-backup-expiration.js";
import { skillVersionRetentionService } from "../services/skill-version-retention.js";
@@ -381,6 +382,11 @@ vi.mock("../realtime/live-events-ws.js", () => ({
setupLiveEventsWebSocketServer: vi.fn(),
}));
vi.mock("../services/attention-retention.js", async importOriginal => ({
...await importOriginal<typeof import("../services/attention-retention.js")>(),
attentionRetentionService: vi.fn(() => ({ load: vi.fn(async () => ({ rows: [], complete: true })), check: vi.fn() })),
}));
vi.mock("../services/join-request-retention.js", async importOriginal => ({
...await importOriginal<typeof import("../services/join-request-retention.js")>(),
joinRequestRetentionService: vi.fn(() => ({ sweep: vi.fn(async () => ({ archived: 0 })), next: vi.fn(async () => null) })),
@@ -1022,7 +1028,7 @@ describe("startServer feedback export wiring", () => {
}
});
it.each([false, true])("registers archive delivery separately from retention sweeps (scheduler %s)", async enabled => {
it.each([false, true])("registers source retention separately from archive delivery without tick scans (scheduler %s)", async enabled => {
loadConfigMock.mockReturnValue(buildTestConfig({ heartbeatSchedulerEnabled: enabled, heartbeatSchedulerIntervalMs: 30000 }));
let tick: (() => void) | undefined;
const timer = vi.spyOn(globalThis, "setInterval").mockImplementation(((callback: () => void, interval: number) => {
@@ -1034,12 +1040,17 @@ describe("startServer feedback export wiring", () => {
const registration = deliveryWorkRegisterMock.mock.calls.find(([queue]) => queue === DELIVERY_QUEUES.decisionArchiveDelivery);
if (!enabled) {
expect(registration).toBeUndefined();
expect(attentionRetentionService).not.toHaveBeenCalled();
expect(decisionRetentionService).not.toHaveBeenCalled();
expect(tick).toBeDefined(); tick!(); await settleStartupHousekeeping();
expect(decisionRetentionService).not.toHaveBeenCalled();
return;
}
expect(registration).toBeDefined();
expect(deliveryWorkRegisterMock.mock.calls.some(([queue]) => queue === DELIVERY_QUEUES.attentionRetention)).toBe(true);
expect(attentionRetentionService).toHaveBeenCalledOnce();
const source = vi.mocked(attentionRetentionService).mock.results[0]!.value;
expect(source.load).toHaveBeenCalledOnce();
const retention = vi.mocked(decisionRetentionService).mock.results[0]!.value;
expect(retention.deliverNotifications).toHaveBeenCalledOnce();
expect(retention.nextArchiveNotificationAt).toHaveBeenCalledOnce();
@@ -1047,9 +1058,10 @@ describe("startServer feedback export wiring", () => {
const companyReads = () => vi.mocked(db.select).mock.results.filter(({ value }) =>
vi.mocked(value.from).mock.calls.some(([table]) => table === companies)).length;
const before = companyReads();
expect(before).toBeGreaterThan(0); expect(tick).toBeDefined();
expect(before).toBe(0); expect(tick).toBeDefined();
tick!(); await settleStartupHousekeeping();
expect(companyReads()).toBeGreaterThan(before);
expect(companyReads()).toBe(before);
expect(source.load).toHaveBeenCalledOnce();
expect(retention.deliverNotifications).toHaveBeenCalledOnce();
await registration![1].run(new AbortController().signal);
expect(retention.deliverNotifications).toHaveBeenCalledTimes(2);
+26 -2
View File
@@ -1,3 +1,5 @@
import { PgDialect } from "drizzle-orm/pg-core";
import type { SQL } from "drizzle-orm";
import { describe, expect, it, vi } from "vitest";
import {
enrichWorkProductMetadataWithDiff,
@@ -33,6 +35,18 @@ function createWorkProductRow(overrides: Partial<Record<string, unknown>> = {})
};
}
function retentionReads(insideTransaction: () => boolean) {
return vi.fn(async (query: SQL) => {
const statement = new PgDialect().sqlToQuery(query);
expect(insideTransaction()).toBe(true);
expect(statement.params).toContain("company-1");
expect(statement.params).toContain("issue-1");
if (statement.sql.includes("origin_kind")) return [{ id: "issue-1", originKind: null, originId: null }];
if (statement.sql.includes("with recursive affected")) return [{ issueId: "issue-1" }];
throw new Error(`Unexpected work-product fixture query: ${statement.sql}`);
});
}
describe("workProductService", () => {
it("extracts runner totals and enriches work-product metadata", () => {
const summary = workProductDiffSummaryFromEventPayload({
@@ -158,11 +172,16 @@ describe("workProductService", () => {
const insertValues = vi.fn(() => ({ returning: insertReturning }));
const txInsert = vi.fn(() => ({ values: insertValues }));
let transactionOpen = false;
const tx = {
execute: retentionReads(() => transactionOpen),
update: txUpdate,
insert: txInsert,
};
const transaction = vi.fn(async (callback: (input: typeof tx) => Promise<unknown>) => await callback(tx));
const transaction = vi.fn(async (callback: (input: typeof tx) => Promise<unknown>) => {
transactionOpen = true;
try { return await callback(tx); } finally { transactionOpen = false; }
});
const svc = workProductService({ transaction } as any);
const result = await svc.createForIssue("issue-1", "company-1", {
@@ -194,11 +213,16 @@ describe("workProductService", () => {
const updateSet = vi.fn(() => ({ where: updateWhere }));
const txUpdate = vi.fn(() => ({ set: updateSet }));
let transactionOpen = false;
const tx = {
execute: retentionReads(() => transactionOpen),
select: txSelect,
update: txUpdate,
};
const transaction = vi.fn(async (callback: (input: typeof tx) => Promise<unknown>) => await callback(tx));
const transaction = vi.fn(async (callback: (input: typeof tx) => Promise<unknown>) => {
transactionOpen = true;
try { return await callback(tx); } finally { transactionOpen = false; }
});
const svc = workProductService({ transaction } as any);
const result = await svc.update("work-product-1", {
+3 -22
View File
@@ -1,3 +1,4 @@
import { attentionRetentionService, registerAttentionRetentionWork } from "./services/attention-retention.js";
import { registerProviderTraceRetentionWork } from "./services/provider-trace-retention-work.js";
import { registerRunEvidenceMigrationWork } from "./services/run-evidence-migration-work.js";
import { registerSkillVersionRetentionWork } from "./services/skill-version-retention-work.js";
@@ -126,7 +127,6 @@ import { cloudActorHeaderSourceFromHeaders, resolveCloudTenantActor } from "./mi
import {
feedbackService,
applyManagedEnvironments,
attentionService,
backfillPrincipalAccessCompatibility,
backfillLegacyToolOAuthTokens,
bootstrapExecutionPolicyFromEnv,
@@ -1887,23 +1887,6 @@ async function startServerWithDatabaseTeardown(
if (config.heartbeatSchedulerEnabled) await heartbeat.registerStoppedInstructionCollectionWork(deliveryWork,
() => !isIdleTaskDrainActive() && !heartbeatSchedulerStopped && !isWarmStandby()).ready;
const runRetentionSweep = async () => {
const activeCompanies = await db.select({ id: companies.id }).from(companies).where(eq(companies.status, "active"));
let archived = 0;
for (const company of activeCompanies) {
// Cursor pagination rebuilds the whole feed for every page; one
// unscoped all-items build keeps this sweep at a single feed build
// per company per tick.
const page = await attentionService(db as any).list(company.id, {
includeDismissed: true,
excludeSources: ["approval", "join_request"],
all: true,
allowUnscopedAll: true,
});
archived += await retentionExecutor.autoArchive({ companyId: company.id, items: page.items });
}
return { archived };
};
// Startup housekeeping: provider and connection sweeps that every timer tick
// repeats and that neither serving requests nor run recovery depends on.
// Readiness does not wait for it. It is tracked as scheduler work, so idle
@@ -1948,7 +1931,8 @@ async function startServerWithDatabaseTeardown(
() => !isIdleTaskDrainActive() && !isWarmStandby() && !heartbeatSchedulerStopped).ready);
await step("status_card_evaluation", () => registerStatusCardEvaluationWork(deliveryWork, statusCardEvaluation,
() => !isIdleTaskDrainActive() && !isWarmStandby() && !heartbeatSchedulerStopped).ready);
await step("retention_sweep", runRetentionSweep);
await step("attention_retention", () => registerAttentionRetentionWork(deliveryWork, attentionRetentionService(db as any),
() => !isIdleTaskDrainActive() && !isWarmStandby() && !heartbeatSchedulerStopped).ready);
};
const createHeartbeatSchedulerTick = (scheduled: ReturnType<typeof registerScheduledWork>) => () => {
@@ -1958,9 +1942,6 @@ async function startServerWithDatabaseTeardown(
trackHeartbeatSchedulerWork((async () => {
if (isIdleTaskDrainActive() || heartbeatSchedulerStopped) return;
scheduleRoutineStatusReconcile();
trackHeartbeatSchedulerWork(runRetentionSweep().catch((err: unknown) => {
logger.error({ err }, "decision retention sweep failed");
}));
const sweptRuntimeStatuses = heartbeat.sweepExpiredRuntimeStatuses();
if (sweptRuntimeStatuses > 0) {
logger.info(
@@ -40,7 +40,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
let companyId: string;
const workers: ReturnType<typeof configureAgentLifecycle>[] = [];
beforeAll(async () => {
if (external) { await applyPendingMigrations(external); db = createDb(external); }
if (external) { await applyPendingMigrations(external); db = createDb(external, { maxConnections: 6 }); }
else { database = await startEmbeddedPostgresTestDatabase("paperclip-agent-lifecycle-"); db = createDb(database.connectionString); }
}, 90_000);
afterAll(async () => {
@@ -1,3 +1,4 @@
import { notifyAttentionRetentionWork } from "./retention-work-notifications.js";
import { and, eq, sql } from "drizzle-orm";
import { agents, agentRuntimeState, companies, heartbeatRuns, type Db } from "@paperclipai/db";
import { normalizeCents, usdToCents, type AccountingFinding, type AccountingHealth, type AccountingInspection } from "@paperclipai/shared";
@@ -75,6 +76,8 @@ export function accountingIntegrityService(db: Db, hooks: BudgetServiceHooks = {
if (!reason.trim()) throw conflict("A repair reason is required");
const before = await inspect(tx, companyId);
if (before.fingerprint !== fingerprint) throw conflict("Accounting changed since inspection; inspect again before repairing");
if (before.findings.some(f => f.repairable && f.kind === "agent_projection"))
await notifyAttentionRetentionWork(tx, { companyId, sourceKind: "agent_error_alert" });
for (const finding of before.findings.filter(f => f.repairable)) {
if (finding.kind === "company_projection") await tx.update(companies).set({ spentMonthlyCents: sql`${finding.expected.cents}::numeric`, updatedAt: new Date() }).where(eq(companies.id, companyId));
if (finding.kind === "agent_projection") await tx.update(agents).set({ spentMonthlyCents: sql`${finding.expected.cents}::numeric`, updatedAt: new Date() }).where(and(eq(agents.id, finding.entityId), eq(agents.companyId, companyId)));
@@ -1,3 +1,4 @@
import { notifyAttentionRetentionWork } from "./retention-work-notifications.js";
import { notifyDeferredExecutionEligibility } from "./deferred-execution-notifications.js";
import { notifyRunDispatch } from "./run-dispatch-notifications.js";
import { notifySkillVersionPinChange } from "./skill-version-retention-notifications.js";
@@ -18,6 +19,7 @@ import { clearPrimaryAgent } from "./primary-agent.js";
import { budgetServiceInTransaction } from "./budgets.js";
export async function prepareAgentConfiguration(db: Db, existing: AgentConfigurationRecord, data: AgentConfigurationPatch, options?: UpdateAgentOptions): Promise<AgentConfigurationPatch> {
await notifyAttentionRetentionWork(db, { companyId: existing.companyId });
const id = existing.id;
const { ensureManager, assertNoCycle, assertCompanyShortnameAvailable, assertBuiltInAgentMetadataMutationAllowed } = agentRecordQueries(db);
if (existing.status === "pending_approval" && !options?.allowPendingApprovalConfigUpdate) {
+2
View File
@@ -1,3 +1,4 @@
import { notifyAttentionRetentionWork } from "./retention-work-notifications.js";
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
import { notifyExternalConversationSettlement } from "./external-conversation-settlement-notifications.js";
@@ -35,6 +36,7 @@ export async function deleteAgentDependencies(tx: Db, companyId: string, id: str
if (classifierHold) throw conflict("Wait for active prompt injection checks or resolve their unknown charges in Costs before deleting this agent", {
code: "agent_prompt_injection_accounting_pending",
});
await notifyAttentionRetentionWork(tx, { companyId });
await notifyExternalConversationSettlement(tx, { companyId });
await issueThreadInteractionService(tx).cancelPendingForDeletedAddressee(companyId, id);
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
+2
View File
@@ -1,3 +1,4 @@
import { notifyAttentionRetentionWork } from "./retention-work-notifications.js";
import { notifySkillVersionRetention, notifySkillVersionPinChange } from "./skill-version-retention-notifications.js";
import { agentAppearanceSchema, randomAgentAppearance } from "@paperclipai/shared";
import { normalizePaperclipRunnerAdapterConfig } from "@paperclipai/adapter-utils/server-utils";
@@ -16,6 +17,7 @@ import { initializePrimaryAgent } from "./primary-agent.js";
import { resolveAgentRunnerTaskDriverForCompany, resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js";
export async function prepareAgentHire(db: Db, companyId: string, input: CreateAgentData, options?: CreateAgentOptions): Promise<CreateAgentData> {
await notifyAttentionRetentionWork(db, { companyId });
const { runner, ...data } = input;
const selection = options?.runnerResolved
? { adapterType: data.adapterType ?? "process", adapterConfig: isPlainRecord(data.adapterConfig) ? data.adapterConfig : {} }
+2
View File
@@ -1,3 +1,4 @@
import { notifyAttentionRetentionWork } from "./retention-work-notifications.js";
import { notifyDeferredExecutionEligibility } from "./deferred-execution-notifications.js";
import { notifyRunDispatch } from "./run-dispatch-notifications.js";
import { notifyNativeReviewResponse } from "./native-review-response-notifications.js";
@@ -40,6 +41,7 @@ export function createAgentLifecycleEffects(_hooks: BudgetServiceHooks = {}): li
},
notifyWork: tx => notifyDeliveryWork(tx, DELIVERY_QUEUES.agentLifecycle),
async beforeStatusChange(tx, companyId, agentId, before, after) {
await notifyAttentionRetentionWork(tx, { companyId });
if (before !== after) {
await notifyRunDispatch(tx, { companyId });
await notifyDeferredExecutionEligibility(tx, { companyId });
@@ -1,3 +1,4 @@
import { notifyAttentionRetentionWork } from "./retention-work-notifications.js";
import { and, eq, inArray } from "drizzle-orm";
import { agents, projects, type Db } from "@paperclipai/db";
import { trustAuthorizationPolicySchema, type AgentProjectAccess } from "@paperclipai/shared";
@@ -55,6 +56,7 @@ export async function addAgentProjectAccess(db: Db, actor: AuthorizationActor, c
const added = requested.filter(id => !previous.includes(id));
const next = [...previous, ...added];
if (added.length) {
await notifyAttentionRetentionWork(tx, { companyId, sourceKind: "agent_error_alert" });
await tx.update(agents).set({ permissions: { ...agent.permissions, authorizationPolicy: {
...policy, trustBoundary: { ...boundary, mode: "low_trust_review", companyId, projectIds: next },
} }, updatedAt: new Date() }).where(and(eq(agents.id, agentId), eq(agents.companyId, companyId)));
+5 -1
View File
@@ -1,3 +1,4 @@
import { notifyAttentionRetentionWork } from "./retention-work-notifications.js";
import { type BudgetServiceHooks } from "./budgets.js";
import { and, desc, eq, inArray } from "drizzle-orm";
@@ -52,7 +53,9 @@ export function agentService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
});
}
const updated = await db
const updated = await db.transaction(async tx => {
await notifyAttentionRetentionWork(tx, { companyId: existing.companyId });
return tx
.update(agents)
.set({
permissions: normalizeAgentPermissions({ ...existing.permissions, ...permissions }),
@@ -61,6 +64,7 @@ export function agentService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
.where(eq(agents.id, id))
.returning()
.then((rows) => rows[0] ?? null);
});
return updated ? getById(updated.id) : null;
},
+324
View File
@@ -0,0 +1,324 @@
import { and, eq, getTableColumns, inArray, isNull, sql, type SQL } from "drizzle-orm";
import { agents, decisionRetention, decisions, heartbeatRuns,
issueApprovals, issueRecoveryActions, issueThreadInteractions, issues, approvals, type Db } from "@paperclipai/db";
import { evaluateAgentInvokability } from "./agent-invokability.js";
import { HUMAN_RECOVERY_OWNER_TYPES, ATTENTION_BUDGET_WARNING_PERCENT, ATTENTION_AGENT_ERROR_STATUS, collapsePendingConfirmationsToNewest, listFailedRunsWithNewerRun, readRunIssueId, requiresReviewAttention, terminalBlockerRequiresAttention } from "./attention-source-eligibility.js";
import { decisionRetentionService, DEFAULT_DECISION_ARCHIVE_DAYS } from "./decision-retention.js";
import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
import { DELIVERY_QUEUES } from "./delivery-work-notifications.js";
import { attentionIssueCondition } from "./issue-visibility.js";
import { RETRY_EXHAUSTION_MESSAGE_PREFIX } from "./heartbeat-run-events.js";
import { issueService } from "./issues.js";
import { parseIssueExecutionState } from "./issue-execution-policy.js";
import { isProspectiveBlockedTransition } from "./routable-blocked.js";
import { registerScopedDeadlineWork } from "./scoped-deadline-work.js";
import type { AttentionRetentionScope } from "./retention-work-notifications.js";
const archiveDelay = DEFAULT_DECISION_ARCHIVE_DAYS * 86_400_000;
const kinds = ["decision", "issue_thread_interaction", "recovery_action", "blocker_attention", "review",
"failed_run", "budget_alert", "agent_error_alert"] as const;
type Kind = typeof kinds[number];
type Scope = AttentionRetentionScope;
type Candidate = { sourceKind: Kind; sourceId: string; companyId: string; activityAt: string; deadlineAt: number; issueId: string | null };
type Entry = Candidate & { id: string; dueAt: number; rootId: string; variant?: "owner" | "terminal" };
type Cursor = { sourceKind: string; sourceId: string };
type PageIssue = typeof issues.$inferSelect & { activityAt: string; deadlineAt: number;
blockedActivityAt: string | null; blockedDeadlineAt: number | null };
type PageEvidence = {
decisions: Map<string, { originIssueId: string }>;
recoveries: Map<string, typeof issueRecoveryActions.$inferSelect>;
runs: Map<string, typeof heartbeatRuns.$inferSelect>;
agents: Set<string>;
visibleIssues: Map<string, PageIssue>;
newerRuns: Set<string>;
deferredRoots: Set<string>;
blockers: Awaited<ReturnType<ReturnType<typeof issueService>["listBlockerAttention"]>>;
};
const sourceTables: Record<Kind, SQL> = {
decision: sql`decisions`, issue_thread_interaction: sql`issue_thread_interactions`,
recovery_action: sql`issue_recovery_actions`, blocker_attention: sql`issues`, review: sql`issues`,
failed_run: sql`heartbeat_runs`, budget_alert: sql`budget_incidents`, agent_error_alert: sql`agents`,
};
const sources: Record<Kind, SQL> = {
decision: sql`select 'decision' as source_kind, d.id::text as source_id, d.company_id, d.updated_at as activity_at, d.origin_issue_id::text as issue_id
from decisions d where d.status = 'open'`,
issue_thread_interaction: sql`select 'issue_thread_interaction', i.id::text, i.company_id, i.updated_at, i.issue_id::text
from issue_thread_interactions i where i.status = 'pending' and i.addressee_user_id is null`,
recovery_action: sql`select 'recovery_action', r.id::text, r.company_id, r.updated_at, r.source_issue_id::text
from issue_recovery_actions r where r.status in ('active', 'escalated') and r.owner_type in (${sql.join(HUMAN_RECOVERY_OWNER_TYPES.map(owner => sql`${owner}`), sql`, `)})`,
blocker_attention: sql`select 'blocker_attention', i.id::text, i.company_id, i.updated_at, i.id::text
from issues i where i.status = 'blocked' and i.hidden_at is null and i.harness_kind is null`,
review: sql`select 'review', i.id::text, i.company_id, i.updated_at, i.id::text
from issues i where i.status = 'in_review' and i.hidden_at is null and i.harness_kind is null`,
failed_run: sql`select 'failed_run', r.id::text, r.company_id, coalesce(r.finished_at, r.updated_at, r.created_at), coalesce(r.context_snapshot->>'issueId', r.context_snapshot->>'taskId')
from heartbeat_runs r where r.status in ('failed', 'timed_out') and exists (
select 1 from heartbeat_run_events e where e.run_id = r.id and e.company_id = r.company_id
and e.event_type = 'lifecycle' and e.message like ${`${RETRY_EXHAUSTION_MESSAGE_PREFIX}%`})`,
budget_alert: sql`select 'budget_alert', b.id::text, b.company_id, b.updated_at, null::text from budget_incidents b
where b.status = 'open' and (b.threshold_type = 'hard' or (b.amount_limit > 0
and round(b.amount_observed::numeric / b.amount_limit * 10000) / 100 >= ${ATTENTION_BUDGET_WARNING_PERCENT}))`,
agent_error_alert: sql`select 'agent_error_alert', a.id::text, a.company_id, a.updated_at, null::text from agents a where a.status = ${ATTENTION_AGENT_ERROR_STATUS}`,
};
export function attentionRetentionService(db: Db) {
const cursors = new Map<string, Cursor>();
async function candidates(database: Db, scope: Scope = {}, cursor?: Cursor, sourceId?: string): Promise<Candidate[]> {
const issueIds = scope.issueIds ?? (scope.issueId ? [scope.issueId] : []);
const selected = kinds.filter(kind => (!scope.sourceKind || scope.sourceKind === kind)
&& (!issueIds.length || !["budget_alert", "agent_error_alert"].includes(kind)));
if (!selected.length) return [];
const rows = await database.execute(sql`with source_candidates(source_kind, source_id, company_id, activity_at, issue_id) as (${sql.join(selected.map(kind => sources[kind]), sql` union all `)})
select s.source_kind as "sourceKind", s.source_id as "sourceId", s.company_id as "companyId",
s.issue_id as "issueId", s.activity_at::text as "activityAt", ceil(extract(epoch from s.activity_at) * 1000 + ${archiveDelay}) as "deadlineAt"
from source_candidates s inner join companies c on c.id = s.company_id
left join decision_retention r on r.company_id = s.company_id and r.source_kind = s.source_kind and r.source_id = s.source_id
where c.status = 'active'
${issueIds.length ? sql`and s.issue_id in (${sql.join(issueIds.map(id => sql`${id}`), sql`, `)})` : sql``}
and (${scope.companyId ?? null}::uuid is null or s.company_id = ${scope.companyId ?? null}::uuid)
and (${sourceId ?? scope.sourceId ?? null}::text is null or s.source_id = ${sourceId ?? scope.sourceId ?? null})
and (s.source_kind = 'blocker_attention' or ((r.id is null or r.keep = false) and r.archived_at is null))
${cursor ? sql`and (s.source_kind, s.source_id) > (${cursor.sourceKind}, ${cursor.sourceId})` : sql``}
order by s.source_kind, s.source_id limit 100`) as unknown as Candidate[];
return rows.map(row => ({ ...row, deadlineAt: Number(row.deadlineAt) }));
}
async function visibleIssue(database: Db, companyId: string, issueId: string, conversations = false) {
const [row] = await database.select().from(issues).where(and(eq(issues.companyId, companyId), eq(issues.id, issueId),
attentionIssueCondition(conversations)));
return row;
}
async function interactionEligibility(database: Db, candidates: Candidate[]) {
const result = new Map<string, boolean>();
const byCompany = new Map<string, string[]>();
for (const candidate of candidates) {
if (candidate.sourceKind !== "issue_thread_interaction") continue;
const ids = byCompany.get(candidate.companyId) ?? [];
ids.push(candidate.sourceId);
byCompany.set(candidate.companyId, ids);
}
for (const [companyId, ids] of byCompany) {
const rows = await database.select().from(issueThreadInteractions).where(and(
eq(issueThreadInteractions.companyId, companyId), inArray(issueThreadInteractions.id, ids),
eq(issueThreadInteractions.status, "pending"), isNull(issueThreadInteractions.addresseeUserId)));
if (!rows.length) continue;
const visible = await database.select({ id: issues.id }).from(issues).where(and(eq(issues.companyId, companyId),
inArray(issues.id, rows.map(row => row.issueId)), attentionIssueCondition(true)));
const visibleIds = new Set(visible.map(row => row.id));
const roster = await database.select().from(agents).where(eq(agents.companyId, companyId));
const boardAddressed = (row: typeof issueThreadInteractions.$inferSelect) => !row.addresseeAgentId
|| !evaluateAgentInvokability(roster.find(agent => agent.id === row.addresseeAgentId), roster).invokable;
const confirmationIssueIds = rows.filter(row => row.kind === "request_confirmation").map(row => row.issueId);
const siblings = confirmationIssueIds.length ? await database.select().from(issueThreadInteractions).where(and(
eq(issueThreadInteractions.companyId, companyId), inArray(issueThreadInteractions.issueId, confirmationIssueIds),
eq(issueThreadInteractions.status, "pending"), eq(issueThreadInteractions.kind, "request_confirmation"),
isNull(issueThreadInteractions.addresseeUserId))) : [];
const newest = new Set(collapsePendingConfirmationsToNewest(siblings.filter(boardAddressed)).map(row => row.id));
for (const row of rows) result.set(row.id, visibleIds.has(row.issueId) && boardAddressed(row)
&& (row.kind !== "request_confirmation" || newest.has(row.id)));
}
return result;
}
async function reviewEligibility(database: Db, candidates: Candidate[]) {
const result = new Map<string, boolean>();
const byCompany = new Map<string, string[]>();
for (const candidate of candidates) {
if (candidate.sourceKind !== "review") continue;
const ids = byCompany.get(candidate.companyId) ?? [];
ids.push(candidate.sourceId);
byCompany.set(candidate.companyId, ids);
}
for (const [companyId, ids] of byCompany) {
const rows = await database.select().from(issues).where(and(eq(issues.companyId, companyId),
inArray(issues.id, ids), attentionIssueCondition(false)));
const pendingApprovals = await database.select({ issueId: issueApprovals.issueId }).from(issueApprovals)
.innerJoin(approvals, eq(approvals.id, issueApprovals.approvalId)).where(and(
eq(issueApprovals.companyId, companyId), eq(approvals.companyId, companyId),
inArray(issueApprovals.issueId, ids), eq(approvals.status, "pending")));
const approved = new Set(pendingApprovals.map(row => row.issueId));
const stalled = await issueService(database).listReviewAttention(companyId, rows);
for (const row of rows) {
const state = parseIssueExecutionState(row.executionState);
result.set(row.id, requiresReviewAttention({
hasHumanParticipant: state?.status === "pending" && state.currentParticipant?.type === "user",
hasUserAssignee: Boolean(row.assigneeUserId), hasPendingApproval: approved.has(row.id),
stalled: stalled.get(row.id)?.state === "stalled",
}));
}
}
return result;
}
async function pageEvidence(database: Db, candidates: Candidate[]) {
const byCompany = new Map<string, Candidate[]>();
for (const candidate of candidates) {
if (!["decision", "recovery_action", "failed_run", "blocker_attention"].includes(candidate.sourceKind)) continue;
const rows = byCompany.get(candidate.companyId) ?? [];
rows.push(candidate);
byCompany.set(candidate.companyId, rows);
}
const result = new Map<string, PageEvidence>();
for (const [companyId, rows] of byCompany) {
const ids = (kind: Kind) => rows.filter(row => row.sourceKind === kind).map(row => row.sourceId);
const decisionIds = ids("decision"), recoveryIds = ids("recovery_action"), runIds = ids("failed_run"), blockerIds = ids("blocker_attention");
const decisionRows = decisionIds.length ? await database.select({ id: decisions.id, originIssueId: decisions.originIssueId })
.from(decisions).where(and(eq(decisions.companyId, companyId), inArray(decisions.id, decisionIds), eq(decisions.status, "open"))) : [];
const recoveryRows = recoveryIds.length ? await database.select().from(issueRecoveryActions).where(and(
eq(issueRecoveryActions.companyId, companyId), inArray(issueRecoveryActions.id, recoveryIds))) : [];
const runRows = runIds.length ? await database.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.id, runIds))) : [];
const runAgents = runRows.length ? await database.select({ id: agents.id }).from(agents).where(and(
eq(agents.companyId, companyId), inArray(agents.id, [...new Set(runRows.map(row => row.agentId))]),
sql`${agents.status} <> 'terminated'`)) : [];
const readIssues = async (issueIds: string[]) => issueIds.length ? database.select({ ...getTableColumns(issues),
activityAt: sql<string>`${issues.updatedAt}::text`,
deadlineAt: sql<number>`ceil(extract(epoch from ${issues.updatedAt}) * 1000 + ${archiveDelay})`.mapWith(Number),
blockedActivityAt: sql<string | null>`${issues.blockedTransitionAt}::text`,
blockedDeadlineAt: sql<number | null>`ceil(extract(epoch from ${issues.blockedTransitionAt}) * 1000 + ${archiveDelay})`.mapWith(Number),
}).from(issues).where(and(eq(issues.companyId, companyId), inArray(issues.id, [...new Set(issueIds)]),
attentionIssueCondition(false))) : [];
const issueIds = [...blockerIds, ...decisionRows.map(row => row.originIssueId), ...recoveryRows.map(row => row.sourceIssueId),
...runRows.map(row => readRunIssueId(row.contextSnapshot))].filter((id): id is string => Boolean(id));
const visibleRows = await readIssues(issueIds);
const visible = new Map(visibleRows.map(row => [row.id, row]));
const blockerRoots = visibleRows.filter(row => blockerIds.includes(row.id));
const blockerBatch = blockerRoots.length ? await issueService(database).listBlockerAttentionBatch(companyId, blockerRoots)
: { attention: new Map(), deferredRootIds: new Set<string>() };
const blockers: PageEvidence["blockers"] = blockerBatch.attention;
const terminalIds = [...blockers.values()].filter(terminalBlockerRequiresAttention)
.map(attention => attention.terminalBlockerIssueId).filter((id): id is string => id != null && !visible.has(id));
for (const row of await readIssues(terminalIds)) visible.set(row.id, row);
result.set(companyId, {
decisions: new Map(decisionRows.map(row => [row.id, row])), recoveries: new Map(recoveryRows.map(row => [row.id, row])),
runs: new Map(runRows.map(row => [row.id, row])), agents: new Set(runAgents.map(row => row.id)), visibleIssues: visible,
newerRuns: await listFailedRunsWithNewerRun(database, companyId,
runRows.map(run => ({ ...run, issueId: readRunIssueId(run.contextSnapshot) }))), blockers, deferredRoots: blockerBatch.deferredRootIds,
});
}
return result;
}
async function eligible(database: Db, candidate: Candidate, variant?: "owner" | "terminal", lock = false, reviewAttention?: Map<string, boolean>, interactionAttention?: Map<string, boolean>, evidence?: PageEvidence): Promise<Entry | null> {
if (candidate.sourceKind === "blocker_attention" && variant === "terminal" && evidence?.deferredRoots.has(candidate.sourceId)) {
return { ...candidate, rootId: candidate.sourceId, variant,
id: `${candidate.sourceKind}:${candidate.sourceId}:terminal`, dueAt: Date.now() };
}
let sourceId = candidate.sourceId;
let activityAt = candidate.activityAt;
let dueAt = candidate.deadlineAt;
if (candidate.sourceKind === "decision") {
const row = evidence ? evidence.decisions.get(sourceId) : (await database.select({ originIssueId: decisions.originIssueId }).from(decisions)
.where(and(eq(decisions.companyId, candidate.companyId), eq(decisions.id, sourceId), eq(decisions.status, "open"))))[0];
if (!row || row.originIssueId && !(evidence ? evidence.visibleIssues.has(row.originIssueId)
: await visibleIssue(database, candidate.companyId, row.originIssueId))) return null;
} else if (candidate.sourceKind === "issue_thread_interaction") {
const attention = interactionAttention ?? await interactionEligibility(database, [candidate]);
if (!attention.get(sourceId)) return null;
} else if (candidate.sourceKind === "recovery_action") {
const row = evidence ? evidence.recoveries.get(sourceId) : (await database.select().from(issueRecoveryActions).where(and(eq(issueRecoveryActions.companyId, candidate.companyId), eq(issueRecoveryActions.id, sourceId))))[0];
if (!row || !(evidence ? evidence.visibleIssues.has(row.sourceIssueId)
: await visibleIssue(database, candidate.companyId, row.sourceIssueId))) return null;
} else if (candidate.sourceKind === "review") {
const attention = reviewAttention ?? await reviewEligibility(database, [candidate]);
if (!attention.get(sourceId)) return null;
} else if (candidate.sourceKind === "blocker_attention") {
const row = evidence ? evidence.visibleIssues.get(sourceId) : await visibleIssue(database, candidate.companyId, sourceId);
if (!row) return null;
if (variant === "owner") {
if (row.unblockDescriptor?.owner !== "board" || !isProspectiveBlockedTransition(row)) return null;
const activity = evidence ? { at: evidence.visibleIssues.get(sourceId)!.blockedActivityAt!, due: evidence.visibleIssues.get(sourceId)!.blockedDeadlineAt! } : (await database.execute(sql`select blocked_transition_at::text as at,
ceil(extract(epoch from blocked_transition_at) * 1000 + ${archiveDelay}) as due from issues
where company_id = ${candidate.companyId}::uuid and id = ${row.id}::uuid`) as unknown as Array<{ at: string; due: string }>)[0];
activityAt = activity.at; dueAt = Number(activity.due);
} else {
const fresh = evidence ? undefined : await issueService(database).listBlockerAttentionBatch(candidate.companyId, [row]);
// A bounded graph cannot establish absence of live work beyond its frontier.
// Keep unknown roots visible; a later source change can revalidate them.
if (fresh?.deferredRootIds.has(row.id)) return null;
const attention = evidence ? evidence.blockers.get(row.id) : fresh!.attention.get(row.id);
if (!terminalBlockerRequiresAttention(attention)) return null;
sourceId = attention.terminalBlockerIssueId ?? row.id;
if (lock && sourceId !== candidate.sourceId) await database.execute(sql`select id from issues
where company_id = ${candidate.companyId}::uuid and id = ${sourceId}::uuid for update`);
const terminal = evidence ? evidence.visibleIssues.get(sourceId) : await visibleIssue(database, candidate.companyId, sourceId);
if (!terminal) return null;
const activity = evidence ? { at: evidence.visibleIssues.get(sourceId)!.activityAt, due: evidence.visibleIssues.get(sourceId)!.deadlineAt } : (await database.execute(sql`select updated_at::text as at,
ceil(extract(epoch from updated_at) * 1000 + ${archiveDelay}) as due from issues
where company_id = ${candidate.companyId}::uuid and id = ${sourceId}::uuid`) as unknown as Array<{ at: string; due: string }>)[0];
activityAt = activity.at; dueAt = Number(activity.due);
}
const [retention] = await database.select().from(decisionRetention).where(and(eq(decisionRetention.companyId, candidate.companyId),
eq(decisionRetention.sourceKind, candidate.sourceKind), eq(decisionRetention.sourceId, sourceId)));
if (retention?.keep || retention?.archivedAt) return null;
} else if (candidate.sourceKind === "failed_run") {
const run = evidence ? evidence.runs.get(sourceId) : (await database.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, candidate.companyId), eq(heartbeatRuns.id, sourceId))))[0];
if (!run) return null;
const agent = evidence ? evidence.agents.has(run.agentId) : (await database.select({ id: agents.id }).from(agents).where(and(eq(agents.companyId, candidate.companyId),
eq(agents.id, run.agentId), sql`${agents.status} <> 'terminated'`)))[0];
const issueId = readRunIssueId(run.contextSnapshot);
if (!agent || issueId && !(evidence ? evidence.visibleIssues.has(issueId)
: await visibleIssue(database, candidate.companyId, issueId))) return null;
if ((evidence ? evidence.newerRuns : await listFailedRunsWithNewerRun(database, candidate.companyId, [{ ...run, issueId }])).has(run.id)) return null;
}
return { ...candidate, sourceId, activityAt, rootId: candidate.sourceId, variant,
id: `${candidate.sourceKind}:${candidate.sourceId}:${variant ?? "source"}`, dueAt };
}
async function load(scope?: Scope, signal?: AbortSignal) {
const key = JSON.stringify(scope ?? {});
const rows = await candidates(db, scope, cursors.get(key));
const entries: Entry[] = [];
const reviews = await reviewEligibility(db, rows);
const interactions = await interactionEligibility(db, rows);
const evidence = await pageEvidence(db, rows);
for (const row of rows) {
if (signal?.aborted) return { rows: null, complete: true };
for (const variant of row.sourceKind === "blocker_attention" ? ["owner", "terminal"] as const : [undefined]) {
const entry = await eligible(db, row, variant, false, reviews, interactions, evidence.get(row.companyId));
if (entry) entries.push(entry);
}
}
if (rows.length === 100) {
const last = rows[rows.length - 1]!;
cursors.set(key, { sourceKind: last.sourceKind, sourceId: last.sourceId });
} else cursors.delete(key);
return { rows: entries, complete: rows.length < 100 };
}
async function check(entry: Entry) {
return db.transaction(async transaction => {
const database = transaction as unknown as Db;
await database.execute(sql`select id from ${sourceTables[entry.sourceKind]}
where company_id = ${entry.companyId}::uuid and id = ${entry.rootId}::uuid for update`);
const [candidate] = await candidates(database, { companyId: entry.companyId, sourceKind: entry.sourceKind }, undefined, entry.rootId);
if (!candidate) return null;
const current = await eligible(database, candidate, entry.variant, true);
if (!current || current.dueAt > Date.now()) return current;
const retention = decisionRetentionService(database);
const item = { sourceKind: current.sourceKind, subject: { id: current.sourceId }, activityAt: current.activityAt };
const state = (await retention.syncItems(current.companyId, [item])).get(`${current.sourceKind}:${current.sourceId}`)!;
await retention.autoArchive({ companyId: current.companyId, items: [{ ...item,
keep: state.keep, archivedAt: state.archivedAt?.toISOString() ?? null, retentionVersion: state.version }] });
return null;
});
}
return { load, check };
}
export function registerAttentionRetentionWork(coordinator: ReturnType<typeof createDeliveryWorkCoordinator>,
service: ReturnType<typeof attentionRetentionService>, canRun: () => boolean) {
return registerScopedDeadlineWork(coordinator, {
queue: DELIVERY_QUEUES.attentionRetention,
decodeScope: hint => hint === "*" ? undefined : JSON.parse(hint) as Scope,
matches: (entry, scope) => (!scope?.companyId || scope.companyId === entry.companyId)
&& (!scope?.sourceKind || scope.sourceKind === entry.sourceKind)
&& (!scope?.sourceId || scope.sourceId === entry.sourceId)
&& (!scope?.issueId || scope.issueId === entry.issueId)
&& (!scope?.issueIds?.length || entry.issueId !== null && scope.issueIds.includes(entry.issueId)),
load: service.load, check: service.check,
}, canRun);
}
@@ -0,0 +1,118 @@
import { and, eq, gt, inArray, sql, type SQL } from "drizzle-orm";
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";
export function collapsePendingConfirmationsToNewest<T extends {
id: string;
issueId: string;
kind: string;
createdAt: Date;
payload: unknown;
}>(rows: T[]) {
const independent = (row: T) => Boolean(row.payload && typeof row.payload === "object" && "qualityReview" in row.payload && row.payload.qualityReview);
const newestByGroup = new Map<string, T>();
for (const row of rows) {
if (row.kind !== "request_confirmation" || independent(row)) continue;
const groupKey = `${row.issueId}:${row.kind}`;
const newest = newestByGroup.get(groupKey);
if (
!newest
|| row.createdAt.getTime() > newest.createdAt.getTime()
|| (row.createdAt.getTime() === newest.createdAt.getTime() && row.id > newest.id)
) {
newestByGroup.set(groupKey, row);
}
}
return rows.filter((row) => (
row.kind !== "request_confirmation"
|| independent(row)
|| newestByGroup.get(`${row.issueId}:${row.kind}`)?.id === row.id
));
}
export function readRunIssueId(contextSnapshot: Record<string, unknown> | null) {
const issueId = contextSnapshot?.issueId ?? contextSnapshot?.taskId;
return typeof issueId === "string" && issueId.length > 0 ? issueId : null;
}
type FailedRunProbe = { id: string; agentId: string; issueId: string | null; createdAt: Date };
const FAILED_RUN_PROBE_BATCH_SIZE = 1000;
export async function listFailedRunsWithNewerRun(db: Db, companyId: string, failedRuns: FailedRunProbe[]) {
const withNewerRun = new Set<string>();
const keyed = failedRuns.filter(run => run.issueId !== null);
for (let start = 0; start < keyed.length; start += FAILED_RUN_PROBE_BATCH_SIZE) {
const batch = keyed.slice(start, start + FAILED_RUN_PROBE_BATCH_SIZE);
for (const id of await listFailedRunBatchWithNewerRun(db, companyId, batch)) withNewerRun.add(id);
}
const unkeyed = failedRuns.filter(run => run.issueId === null);
if (unkeyed.length === 0) return withNewerRun;
const agentIds = [...new Set(unkeyed.map(run => run.agentId))];
const oldestCreatedAt = unkeyed.reduce((oldest, run) => run.createdAt < oldest ? run.createdAt : oldest,
unkeyed[0]!.createdAt);
const latestByAgent = new Map<string, Date>();
for (let start = 0; start < agentIds.length; start += FAILED_RUN_PROBE_BATCH_SIZE) {
const rows = await db.select({
agentId: heartbeatRuns.agentId,
createdAt: sql<Date>`max(${heartbeatRuns.createdAt})`.mapWith(heartbeatRuns.createdAt),
}).from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, companyId),
inArray(heartbeatRuns.agentId, agentIds.slice(start, start + FAILED_RUN_PROBE_BATCH_SIZE)),
gt(heartbeatRuns.createdAt, oldestCreatedAt),
sql`coalesce(${heartbeatRuns.contextSnapshot} ->> 'issueId', ${heartbeatRuns.contextSnapshot} ->> 'taskId', '') = ''`,
)).groupBy(heartbeatRuns.agentId);
for (const row of rows) latestByAgent.set(row.agentId, row.createdAt);
}
for (const run of unkeyed) {
if ((latestByAgent.get(run.agentId)?.getTime() ?? 0) > run.createdAt.getTime()) withNewerRun.add(run.id);
}
return withNewerRun;
}
async function listFailedRunBatchWithNewerRun(db: Db, companyId: string, failedRuns: FailedRunProbe[]) {
if (failedRuns.length === 0) return [];
const failed = sql.join(
failedRuns.map((run) => sql`(${run.id}::uuid, ${run.agentId}::uuid, ${run.issueId}::text, ${new Date(run.createdAt.getTime() + 1).toISOString()}::timestamptz)`),
sql`, `,
);
const runIssueId = sql`(${heartbeatRuns.contextSnapshot} ->> 'issueId')`;
const runTaskId = sql`(${heartbeatRuns.contextSnapshot} ->> 'taskId')`;
const newerRunExists = (match: SQL) => sql`exists (
select 1 from ${heartbeatRuns}
where ${heartbeatRuns.companyId} = ${companyId}
and ${heartbeatRuns.agentId} = failed.agent_id
and ${heartbeatRuns.createdAt} >= failed.newer_from
and ${match}
)`;
const rows = await db.execute(sql`
select failed.id
from (values ${failed}) as failed (id, agent_id, issue_id, newer_from)
where ${newerRunExists(sql`${runIssueId} = failed.issue_id`)}
or ${newerRunExists(sql`${runTaskId} = failed.issue_id and ${runIssueId} is null`)}
`) as unknown as Array<{ id: string }>;
return rows.map((row) => row.id);
}
export function requiresReviewAttention(input: {
hasHumanParticipant: boolean; hasUserAssignee: boolean; hasPendingApproval: boolean; stalled: boolean;
}) {
return input.hasHumanParticipant || input.hasUserAssignee || input.hasPendingApproval || input.stalled;
}
export function terminalBlockerRequiresAttention<T extends { state?: string; blockingTreeLive?: boolean }>(
attention: T | null | undefined,
): attention is T & { state: "stalled" | "needs_attention" } {
return Boolean(attention && !attention.blockingTreeLive && ["stalled", "needs_attention"].includes(attention.state ?? ""));
}
+10 -109
View File
@@ -1,5 +1,6 @@
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, gt, inArray, isNotNull, isNull, notInArray, or, sql, type SQL } from "drizzle-orm";
import { and, asc, desc, eq, inArray, isNull, notInArray, or, sql, type SQL } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
@@ -58,7 +59,7 @@ import {
BLOCKER_ATTENTION_MAX_NODES,
issueService,
} from "./issues.js";
import { executionIssueCondition, visibleIssueCondition } from "./issue-visibility.js";
import { attentionIssueCondition, executionIssueCondition } from "./issue-visibility.js";
import { parseIssueExecutionState } from "./issue-execution-policy.js";
import { isProspectiveBlockedTransition } from "./routable-blocked.js";
import { issueReadSqlCondition, type AuthorizationActor } from "./authorization.js";
@@ -106,8 +107,6 @@ const SOURCE_RANK: Record<AttentionSourceKind, number> = {
};
const PENDING_INTERACTION_STATUSES = ["pending"] as const;
const OPEN_RECOVERY_STATUSES = ["active", "escalated"] as const;
const HUMAN_RECOVERY_OWNER_TYPES = ["user", "board"] as const;
const DETAIL_EXCERPT_LENGTH = 160;
const DETAIL_IMAGE_LIMIT = 3;
const OPEN_DECISION_DEFAULT_LIMIT = 500;
@@ -766,37 +765,6 @@ export function interactionResolverAudience(
};
}
function collapsePendingConfirmationsToNewest<T extends {
id: string;
issueId: string;
kind: string;
createdAt: Date;
payload: unknown;
}>(rows: T[]) {
// Quality gates bind independent outputs; a newer visual approval must not
// hide a still-pending copy approval (or affect its badge count).
const independent = (row: T) => Boolean(row.payload && typeof row.payload === "object" && "qualityReview" in row.payload && row.payload.qualityReview);
const newestByGroup = new Map<string, T>();
for (const row of rows) {
if (row.kind !== "request_confirmation" || independent(row)) continue;
const groupKey = `${row.issueId}:${row.kind}`;
const newest = newestByGroup.get(groupKey);
if (
!newest
|| row.createdAt.getTime() > newest.createdAt.getTime()
|| (row.createdAt.getTime() === newest.createdAt.getTime() && row.id > newest.id)
) {
newestByGroup.set(groupKey, row);
}
}
return rows.filter((row) => (
row.kind !== "request_confirmation"
|| independent(row)
|| newestByGroup.get(`${row.issueId}:${row.kind}`)?.id === row.id
));
}
function budgetObservedPercent(amountObserved: number, amountLimit: number) {
return amountLimit > 0 ? Math.round((amountObserved / amountLimit) * 10_000) / 100 : 0;
}
@@ -866,9 +834,7 @@ async function issueSummaryMap(
.where(and(
eq(issues.companyId, companyId),
inArray(issues.id, ids),
includeConversations
? or(executionIssueCondition(), and(visibleIssueCondition(), isNotNull(issues.conversationAgentId)))
: executionIssueCondition(),
attentionIssueCondition(includeConversations),
readCondition,
));
return new Map(rows.map((row) => [row.id, {
@@ -1108,70 +1074,6 @@ function resolveBlockingIssue(
return null;
}
function readRunIssueId(contextSnapshot: Record<string, unknown> | null) {
const issueId = contextSnapshot?.issueId ?? contextSnapshot?.taskId;
return typeof issueId === "string" && issueId.length > 0 ? issueId : null;
}
type FailedRunProbe = { id: string; agentId: string; issueId: string | null; createdAt: Date };
const FAILED_RUN_PROBE_BATCH_SIZE = 1000;
async function listFailedRunsWithNewerRun(db: Db, companyId: string, failedRuns: FailedRunProbe[]) {
const withNewerRun = new Set<string>();
const keyed = failedRuns.filter(run => run.issueId !== null);
for (let start = 0; start < keyed.length; start += FAILED_RUN_PROBE_BATCH_SIZE) {
const batch = keyed.slice(start, start + FAILED_RUN_PROBE_BATCH_SIZE);
for (const id of await listFailedRunBatchWithNewerRun(db, companyId, batch)) withNewerRun.add(id);
}
const unkeyed = failedRuns.filter(run => run.issueId === null);
if (unkeyed.length === 0) return withNewerRun;
const agentIds = [...new Set(unkeyed.map(run => run.agentId))];
const oldestCreatedAt = unkeyed.reduce((oldest, run) => run.createdAt < oldest ? run.createdAt : oldest,
unkeyed[0]!.createdAt);
const latestByAgent = new Map<string, Date>();
for (let start = 0; start < agentIds.length; start += FAILED_RUN_PROBE_BATCH_SIZE) {
const rows = await db.select({
agentId: heartbeatRuns.agentId,
createdAt: sql<Date>`max(${heartbeatRuns.createdAt})`.mapWith(heartbeatRuns.createdAt),
}).from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, companyId),
inArray(heartbeatRuns.agentId, agentIds.slice(start, start + FAILED_RUN_PROBE_BATCH_SIZE)),
gt(heartbeatRuns.createdAt, oldestCreatedAt),
sql`coalesce(${heartbeatRuns.contextSnapshot} ->> 'issueId', ${heartbeatRuns.contextSnapshot} ->> 'taskId', '') = ''`,
)).groupBy(heartbeatRuns.agentId);
for (const row of rows) latestByAgent.set(row.agentId, row.createdAt);
}
for (const run of unkeyed) {
if ((latestByAgent.get(run.agentId)?.getTime() ?? 0) > run.createdAt.getTime()) withNewerRun.add(run.id);
}
return withNewerRun;
}
async function listFailedRunBatchWithNewerRun(db: Db, companyId: string, failedRuns: FailedRunProbe[]) {
if (failedRuns.length === 0) return [];
const failed = sql.join(
failedRuns.map((run) => sql`(${run.id}::uuid, ${run.agentId}::uuid, ${run.issueId}::text, ${new Date(run.createdAt.getTime() + 1).toISOString()}::timestamptz)`),
sql`, `,
);
const runIssueId = sql`(${heartbeatRuns.contextSnapshot} ->> 'issueId')`;
const runTaskId = sql`(${heartbeatRuns.contextSnapshot} ->> 'taskId')`;
const newerRunExists = (match: SQL) => sql`exists (
select 1 from ${heartbeatRuns}
where ${heartbeatRuns.companyId} = ${companyId}
and ${heartbeatRuns.agentId} = failed.agent_id
and ${heartbeatRuns.createdAt} >= failed.newer_from
and ${match}
)`;
const rows = await db.execute(sql`
select failed.id
from (values ${failed}) as failed (id, agent_id, issue_id, newer_from)
where ${newerRunExists(sql`${runIssueId} = failed.issue_id`)}
or ${newerRunExists(sql`${runTaskId} = failed.issue_id and ${runIssueId} is null`)}
`) as unknown as Array<{ id: string }>;
return rows.map((row) => row.id);
}
export function attentionService(db: Db, serviceOptions: AttentionServiceOptions = {}) {
const openDecisionLimit = Math.min(
Math.max(Math.trunc(serviceOptions.openDecisionLimit ?? OPEN_DECISION_DEFAULT_LIMIT), 1),
@@ -1510,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([
@@ -1638,8 +1539,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
}));
}
const blockerAttention = issue.blockerAttention;
if (blockerAttention?.state !== "stalled" && blockerAttention?.state !== "needs_attention") continue;
if (blockerAttention.blockingTreeLive) continue;
if (!terminalBlockerRequiresAttention(blockerAttention)) continue;
const issueSummary = blockedIssueSummaries.get(issue.id) ?? null;
const terminalIssueId = blockerAttention.terminalBlockerIssueId ?? issue.id;
const terminalSummary = terminalBlockerSummaries.get(terminalIssueId)
@@ -1741,7 +1641,8 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
const pendingApprovalId = pendingApprovalByIssueId.get(review.id) ?? null;
const reviewAttention = reviewAttentionByIssueId.get(review.id);
const stalled = reviewAttention?.state === "stalled";
if (!hasHumanParticipant && !review.assigneeUserId && !pendingApprovalId && !stalled) continue;
if (!requiresReviewAttention({ hasHumanParticipant, hasUserAssignee: Boolean(review.assigneeUserId),
hasPendingApproval: Boolean(pendingApprovalId), stalled })) continue;
const issue = reviewIssueMap.get(review.id);
if (!issue) continue;
const dedupKey = `review:${review.id}`;
@@ -1860,7 +1761,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
const budgetOverview = await budgetService(db).overview(companyId);
for (const incident of budgetOverview.activeIncidents) {
const observedPercent = budgetObservedPercent(incident.amountObserved, incident.amountLimit);
if (incident.thresholdType !== "hard" && observedPercent < 85) continue;
if (incident.thresholdType !== "hard" && observedPercent < ATTENTION_BUDGET_WARNING_PERCENT) continue;
const dedupKey = `budget:${incident.policyId}:${toIso(incident.windowStart)}:${incident.thresholdType}`;
add(createItem({
companyId,
@@ -1923,7 +1824,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
updatedAt: agents.updatedAt,
})
.from(agents)
.where(and(eq(agents.companyId, companyId), eq(agents.status, "error")))
.where(and(eq(agents.companyId, companyId), eq(agents.status, ATTENTION_AGENT_ERROR_STATUS)))
.orderBy(desc(agents.updatedAt), desc(agents.id));
for (const agent of erroredAgents) {
+14 -9
View File
@@ -2,7 +2,7 @@ import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
import { notifyDeferredExecutionEligibility } from "./deferred-execution-notifications.js";
import { notifyRunDispatch } from "./run-dispatch-notifications.js";
import { notifyApprovalWork } from "./approval-work-notifications.js";
import { notifyCompanyRetentionWork } from "./retention-work-notifications.js";
import { notifyAttentionRetentionWork, notifyCompanyRetentionWork } from "./retention-work-notifications.js";
import { addCents, compareCents, normalizeCents } from "@paperclipai/shared";
import { and, desc, eq, inArray, sql, or, isNull } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
@@ -497,6 +497,7 @@ export function budgetServiceInTransaction(db: Db, publications: ActivityPublica
windowEnd: end,
});
await notifyAttentionRetentionWork(db, { companyId: policy.companyId, sourceKind: "budget_alert" });
if (thresholdType === "hard") await notifyApprovalWork(db, policy.companyId);
const approval = thresholdType === "hard"
? await db
@@ -535,7 +536,8 @@ export function budgetServiceInTransaction(db: Db, publications: ActivityPublica
return incident ? { incident, created: true } : null;
}
async function resolveOpenSoftIncidents(policyId: string) {
async function resolveOpenSoftIncidents(policyId: string, companyId: string) {
await notifyAttentionRetentionWork(db, { companyId, sourceKind: "budget_alert" });
await db
.update(budgetIncidents)
.set({
@@ -554,10 +556,12 @@ export function budgetServiceInTransaction(db: Db, publications: ActivityPublica
async function resolveOpenIncidentsForPolicy(
policyId: string,
companyId: string,
approvalStatus: "approved" | "rejected" | null,
decidedByUserId: string | null,
thresholdType?: BudgetThresholdType,
) {
await notifyAttentionRetentionWork(db, { companyId, sourceKind: "budget_alert" });
const condition = and(eq(budgetIncidents.policyId, policyId), eq(budgetIncidents.status, "open"),
thresholdType ? eq(budgetIncidents.thresholdType, thresholdType) : undefined);
const openRows = await db
@@ -646,9 +650,9 @@ export function budgetServiceInTransaction(db: Db, publications: ActivityPublica
if (policy.isActive && policy.hardStopEnabled && policy.amount > 0 && observed.recoveringRunCount > 0) recoveringPolicy ??= policy;
// A closed UTC window, inactive policy, or budget raise releases only
// budget-owned pauses. Manual pauses remain operator decisions.
await resolveOpenIncidentsForPolicy(policy.id, "approved", "budget_service", "hard");
await resolveOpenIncidentsForPolicy(policy.id, policy.companyId, "approved", "budget_service", "hard");
if (!policy.isActive || !policy.notifyEnabled || compareCents(observed.totalExact, Math.ceil(policy.amount * policy.warnPercent / 100)) < 0) {
await resolveOpenIncidentsForPolicy(policy.id, "approved", "budget_service", "soft");
await resolveOpenIncidentsForPolicy(policy.id, policy.companyId, "approved", "budget_service", "soft");
}
}
// Agent status has its own admission gate and error code. Preserve a
@@ -705,7 +709,7 @@ export function budgetServiceInTransaction(db: Db, publications: ActivityPublica
}
if (observedBlocks(policy, observed)) {
await resolveOpenSoftIncidents(policy.id);
await resolveOpenSoftIncidents(policy.id, policy.companyId);
const hardIncident = await createIncidentIfNeeded(policy, "hard", observed);
await pauseAndCancelScopeForBudget(policy);
if (hardIncident?.created) {
@@ -875,11 +879,11 @@ export function budgetServiceInTransaction(db: Db, publications: ActivityPublica
const observed = await computeObservedSpend(db, row);
if (!observedBlocks(row, observed)) {
await resumeScopeFromBudget(row);
await resolveOpenIncidentsForPolicy(row.id, "approved", actorUserId ?? "budget_service", "hard");
await resolveOpenIncidentsForPolicy(row.id, row.companyId, "approved", actorUserId ?? "budget_service", "hard");
if (row.notifyEnabled && compareCents(observed.totalExact, Math.ceil(row.amount * row.warnPercent / 100)) >= 0) {
await createIncidentIfNeeded(row, "soft", observed);
} else {
await resolveOpenIncidentsForPolicy(row.id, null, null, "soft");
await resolveOpenIncidentsForPolicy(row.id, row.companyId, null, null, "soft");
}
} else {
const softThreshold = Math.ceil((row.amount * row.warnPercent) / 100);
@@ -887,14 +891,14 @@ export function budgetServiceInTransaction(db: Db, publications: ActivityPublica
await createIncidentIfNeeded(row, "soft", observed);
}
if (observedBlocks(row, observed)) {
await resolveOpenSoftIncidents(row.id);
await resolveOpenSoftIncidents(row.id, row.companyId);
await createIncidentIfNeeded(row, "hard", observed);
await pauseAndCancelScopeForBudget(row);
}
}
} else {
await resumeScopeFromBudget(row);
await resolveOpenIncidentsForPolicy(row.id, "approved", actorUserId ?? "budget_service");
await resolveOpenIncidentsForPolicy(row.id, row.companyId, "approved", actorUserId ?? "budget_service");
}
await recordActivity({
@@ -975,6 +979,7 @@ export function budgetServiceInTransaction(db: Db, publications: ActivityPublica
}
const now = new Date();
await notifyAttentionRetentionWork(db, { companyId, sourceKind: "budget_alert" });
await notifyDeliveryWork(db, DELIVERY_QUEUES.budgetEnforcement, companyId);
await db
.update(budgetPolicies)
@@ -1,3 +1,4 @@
import { notifyAttentionRetentionWork } from "./retention-work-notifications.js";
import { notifyIssueRecovery } from "./issue-recovery-notifications.js";
import { createAgentLifecycle } from "./agent-lifecycle.js";
import { agentAppearanceSchema, agentHarnessType, paperclipRunnerProfileForHarness, agentRunner, type AgentRunnerChoice } from "@paperclipai/shared";
@@ -6467,6 +6468,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
if (relationRows.length > 0) {
const relationCompanyId = targetCompany.id;
await db.transaction(async tx => {
await notifyAttentionRetentionWork(tx, { companyId: relationCompanyId });
await notifyIssueRecovery(tx, { companyId: relationCompanyId });
await tx
.insert(issueRelations)
+1 -1
View File
@@ -23,7 +23,7 @@ export function decisionExpiryService(db: Db, deliverContinuation: (id: string)
agentId: row.originAgentId, runId: row.originRunId, action: "decision.expired", entityType: "decision",
entityId: row.id, details: { expiredReason: reason } }, publications);
return row;
});
}, { companyId: decision.companyId, sourceId: decision.id });
if (!updated) return 0;
for (const publication of publications) publishActivity(publication);
await deliverContinuation(updated.id);
+92 -15
View File
@@ -1,3 +1,8 @@
import { isProspectiveBlockedTransition } from "./routable-blocked.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";
import { notifyRetentionWork } from "./retention-work-notifications.js";
import { decisionArchiveDeliveryService, type ArchiveNotificationBatch } from "./decision-archive-delivery.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
@@ -15,7 +20,6 @@ import {
issueRecoveryActions,
issueThreadInteractions,
issues,
joinRequests,
} from "@paperclipai/db";
import type {
AttentionArchiveManifestEntry,
@@ -145,7 +149,7 @@ export function decisionRetentionService(
const existing = new Set(rows.map(row => sourceKey(row.sourceKind, row.sourceId)));
const missing = unique.filter(item => !existing.has(itemSourceKey(item)));
if (missing.length) {
for (const kind of new Set(missing.map(item => item.sourceKind))) await notifyRetentionWork(tx, kind);
for (const item of missing) await notifyRetentionWork(tx, item.sourceKind, { companyId, sourceId: item.subject.id });
await tx.insert(decisionRetention).values(missing.map(item => ({
companyId, sourceKind: item.sourceKind, sourceId: item.subject.id, sourceActivityAt: new Date(item.activityAt),
}))).onConflictDoNothing({ target: [decisionRetention.companyId, decisionRetention.sourceKind, decisionRetention.sourceId] });
@@ -155,7 +159,7 @@ export function decisionRetentionService(
for (const item of unique) {
const key = itemSourceKey(item), row = byKey.get(key), activityAt = new Date(item.activityAt);
if (!row || row.sourceActivityAt.getTime() === activityAt.getTime()) continue;
await notifyRetentionWork(tx, item.sourceKind);
await notifyRetentionWork(tx, item.sourceKind, { companyId, sourceId: item.subject.id });
const [updated] = await tx.update(decisionRetention).set({
sourceActivityAt: activityAt, version: sql`${decisionRetention.version} + 1`, updatedAt: new Date(),
}).where(and(eq(decisionRetention.id, row.id), ne(decisionRetention.sourceActivityAt, activityAt))).returning();
@@ -169,9 +173,9 @@ export function decisionRetentionService(
return sourceKey(item.sourceKind, item.subject.id);
}
async function writeRetention<T>(kind: AttentionSourceKind, write: (tx: Db) => Promise<T>) {
async function writeRetention<T>(companyId: string, kind: AttentionSourceKind, sourceId: string, write: (tx: Db) => Promise<T>) {
return db.transaction(async tx => {
await notifyRetentionWork(tx, kind);
await notifyRetentionWork(tx, kind, { companyId, sourceId });
return write(tx as unknown as Db);
});
}
@@ -184,14 +188,87 @@ export function decisionRetentionService(
)).then((rows) => rows[0] ?? null);
}
async function materializeSourceState(database: Db, input: { companyId: string; sourceKind: AttentionSourceKind; sourceId: string }) {
const table = input.sourceKind === "approval" ? approvals : input.sourceKind === "join_request" ? joinRequests : null;
if (!table) return;
const [source] = await database.select({ updatedAt: table.updatedAt }).from(table)
.where(and(eq(table.companyId, input.companyId), eq(table.id, input.sourceId))).for("update");
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",
failed_run: "heartbeat_runs", productivity_review: "issues", blocker_attention: "issues", review: "issues",
budget_alert: "budget_incidents", agent_error_alert: "agents",
};
const existingState = () => database.select({ id: decisionRetention.id }).from(decisionRetention).where(and(
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");
await decisionRetentionService(database).syncItems(input.companyId, [{
sourceKind: input.sourceKind, subject: { id: input.sourceId }, activityAt: source.updatedAt.toISOString(),
}]);
return;
}
if (["review", "blocker_attention", "productivity_review"].includes(input.sourceKind)) {
const [source] = await database.select().from(issues).where(and(eq(issues.companyId, input.companyId), eq(issues.id, input.sourceId))).for("update");
if (!source) throw notFound("Attention source not found");
if (!await existingState()) {
const [visible] = await database.select({ id: issues.id }).from(issues).where(and(
eq(issues.companyId, input.companyId), eq(issues.id, source.id), attentionIssueCondition(false),
));
let eligible = false;
if (visible && input.sourceKind === "review" && source.status === "in_review") {
const [pendingApproval] = await database.select({ id: approvals.id }).from(issueApprovals)
.innerJoin(approvals, eq(approvals.id, issueApprovals.approvalId)).where(and(
eq(issueApprovals.companyId, input.companyId), eq(approvals.companyId, input.companyId),
eq(issueApprovals.issueId, source.id), eq(approvals.status, "pending"),
)).limit(1);
const state = parseIssueExecutionState(source.executionState);
const attention = await issueService(database).listReviewAttention(input.companyId, [source]);
eligible = requiresReviewAttention({
hasHumanParticipant: state?.status === "pending" && state.currentParticipant?.type === "user",
hasUserAssignee: Boolean(source.assigneeUserId), hasPendingApproval: Boolean(pendingApproval),
stalled: attention.get(source.id)?.state === "stalled",
});
} else if (visible && input.sourceKind === "blocker_attention" && source.status === "blocked") {
const owner = source.unblockDescriptor?.owner;
const ownsDescriptor = owner === "board" || (owner && "userId" in owner && owner.userId === input.actor.userId);
const attention = (await issueService(database).listBlockerAttention(input.companyId, [source])).get(source.id);
eligible = Boolean(ownsDescriptor && isProspectiveBlockedTransition(source))
|| (terminalBlockerRequiresAttention(attention) && (attention.terminalBlockerIssueId ?? source.id) === source.id);
}
if (!eligible) throw notFound("Attention source not found");
}
const activityAt = input.sourceKind === "blocker_attention" && source.unblockDescriptor?.owner === "board" && isProspectiveBlockedTransition(source)
? source.blockedTransitionAt : source.updatedAt;
await decisionRetentionService(database).syncItems(input.companyId, [{
sourceKind: input.sourceKind, subject: { id: input.sourceId }, activityAt: activityAt.toISOString(),
}]);
return;
}
const activity = input.sourceKind === "failed_run" ? sql`coalesce(finished_at, updated_at, created_at)` : sql`updated_at`;
const [source] = await database.execute(sql`select ${activity} as "activityAt" from ${sql.identifier(tables[input.sourceKind])}
where company_id = ${input.companyId}::uuid and id = ${input.sourceId}::uuid for update`) as unknown as Array<{ activityAt: Date | string }>;
if (!source) throw notFound("Attention source not found");
await decisionRetentionService(database).syncItems(input.companyId, [{
sourceKind: input.sourceKind, subject: { id: input.sourceId }, activityAt: source.updatedAt.toISOString(),
sourceKind: input.sourceKind, subject: { id: input.sourceId }, activityAt: new Date(source.activityAt).toISOString(),
}]);
}
@@ -206,7 +283,7 @@ export function decisionRetentionService(
if (!(await canReadDecisionSource(db, input.authActor, input.companyId, input.sourceKind, input.sourceId))) {
throw notFound("Attention source not found");
}
const updated = await writeRetention(input.sourceKind, async tx => {
const updated = await writeRetention(input.companyId, input.sourceKind, input.sourceId, async tx => {
await materializeSourceState(tx, input);
return tx.update(decisionRetention).set({
keep: input.keep,
@@ -247,7 +324,7 @@ export function decisionRetentionService(
throw notFound("Attention source not found");
}
const now = new Date();
const updated = await writeRetention(input.sourceKind, async tx => {
const updated = await writeRetention(input.companyId, input.sourceKind, input.sourceId, async tx => {
await materializeSourceState(tx, input);
return tx.update(decisionRetention).set(input.archived ? {
archivedAt: now,
@@ -324,7 +401,7 @@ export function decisionRetentionService(
}
const now = new Date();
for (const entry of input.manifest) {
await notifyRetentionWork(txDb, entry.sourceKind as AttentionSourceKind);
await notifyRetentionWork(txDb, entry.sourceKind as AttentionSourceKind, { companyId: input.companyId, sourceId: entry.sourceId });
const archived = await txDb.update(decisionRetention).set({
archivedAt: now,
archivedReason: "accepted_proposal",
@@ -368,7 +445,7 @@ export function decisionRetentionService(
await db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
for (const item of candidates) {
await notifyRetentionWork(txDb, item.sourceKind);
await notifyRetentionWork(txDb, item.sourceKind, { companyId: input.companyId, sourceId: item.subject.id });
const updated = await txDb.update(decisionRetention).set({
archivedAt: now,
archivedReason: "idle_ttl",
@@ -1,18 +1,23 @@
import { notifyAttentionRetentionWork } from "./retention-work-notifications.js";
import type { Db } from "@paperclipai/db";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
export function notifyDecisionCreated(transaction: object) {
type DecisionWorkScope = { companyId: string; sourceId?: string };
export async function notifyDecisionCreated(transaction: object, scope: DecisionWorkScope) {
await notifyAttentionRetentionWork(transaction, { ...scope, sourceKind: "decision" });
return notifyDeliveryWork(transaction, DELIVERY_QUEUES.decisionExpiry);
}
export async function notifyDecisionChange(transaction: object) {
export async function notifyDecisionChange(transaction: object, scope: DecisionWorkScope) {
await notifyAttentionRetentionWork(transaction, { ...scope, sourceKind: "decision" });
await notifyDeliveryWork(transaction, DELIVERY_QUEUES.decisionContinuation);
await notifyDeliveryWork(transaction, DELIVERY_QUEUES.decisionExpiry);
}
export function writeDecisionChange<T>(db: Db, write: (transaction: Db) => Promise<T>): Promise<T> {
export function writeDecisionChange<T>(db: Db, write: (transaction: Db) => Promise<T>, scope: DecisionWorkScope): Promise<T> {
return db.transaction(async transaction => {
await notifyDecisionChange(transaction);
await notifyDecisionChange(transaction, scope);
await notifyDeliveryWork(transaction, DELIVERY_QUEUES.decisionEffectRecovery);
return write(transaction as unknown as Db);
});
+13 -12
View File
@@ -239,7 +239,7 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
...(input.additionalTargetSnapshots ?? {}),
};
const id = randomUUID();
await notifyDecisionCreated(dbOrTx);
await notifyDecisionCreated(dbOrTx, { companyId: input.companyId, sourceId: id });
await notifyDecisionTargetWork(dbOrTx, `decision:${id}`);
const [created] = await dbOrTx.insert(decisions).values({ id, companyId: input.companyId, bundleId: input.bundleId ?? null,
originAgentId: input.agentId, originIssueId: provenance.issueId, originRunId: input.runId, ruleKey: input.ruleKey ?? null,
@@ -581,7 +581,8 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
return attempt;
}
async function settleInvalidEffectOutcome(id: string) {
async function settleInvalidEffectOutcome(decision: typeof decisions.$inferSelect) {
const id = decision.id;
const publications: ActivityPublication[] = [];
await writeDecisionChange(db, async tx => {
const [current] = await tx.select().from(decisions).where(eq(decisions.id, id)).for("update");
@@ -601,14 +602,14 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
action: "decision.recovery_failed", entityType: "decision", entityId: id,
details: { reason, executionStatus, chosenOptionId: current.chosenOptionId, decidedByUserId: current.decidedByUserId },
}, publications);
});
}, { companyId: decision.companyId, sourceId: id });
for (const publication of publications) publishActivity(publication);
return outcome(id);
}
async function executeDecisionEffects(decision: typeof decisions.$inferSelect, userActor: AuthorizationActor) {
const option = decision.options.find((item) => item.id === decision.chosenOptionId);
if (!option || !decision.decidedByUserId) return settleInvalidEffectOutcome(decision.id);
if (!option || !decision.decidedByUserId) return settleInvalidEffectOutcome(decision);
const run = await db.select({ responsibleUserId: heartbeatRuns.responsibleUserId }).from(heartbeatRuns).where(eq(heartbeatRuns.id, decision.originRunId)).then((rows) => rows[0] ?? null);
const metadata = decision.metadata as Record<string, unknown>;
if (metadata.kind === "attention_archive_proposal") {
@@ -618,7 +619,7 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
targetSnapshots: decision.targetSnapshots as Record<string, Snapshot>,
}), decision.signedSpec)) {
await writeDecisionChange(db, async tx => tx.update(decisions).set({ executionStatus: "failed", updatedAt: new Date(), metadata: { ...metadata, archiveProposalError: "invalid_signature", ...(decision.continuationPolicy === "wake_origin_agent" ? { continuationPending: true } : {}) } })
.where(and(eq(decisions.id, decision.id), eq(decisions.executionStatus, "running"))));
.where(and(eq(decisions.id, decision.id), eq(decisions.executionStatus, "running"))), { companyId: decision.companyId, sourceId: decision.id });
return outcome(decision.id);
}
if (option.id === "archive") {
@@ -629,7 +630,7 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
const manifestHash = manifest ? hashAttentionArchiveManifest(manifest) : null;
if (!manifest || manifestHash !== metadata.manifestHash) {
await writeDecisionChange(db, async tx => tx.update(decisions).set({ executionStatus: "failed", updatedAt: new Date(), metadata: { ...metadata, archiveProposalError: "manifest_mismatch", ...(decision.continuationPolicy === "wake_origin_agent" ? { continuationPending: true } : {}) } })
.where(and(eq(decisions.id, decision.id), eq(decisions.executionStatus, "running"))));
.where(and(eq(decisions.id, decision.id), eq(decisions.executionStatus, "running"))), { companyId: decision.companyId, sourceId: decision.id });
return outcome(decision.id);
}
const originActor: AuthorizationActor = {
@@ -651,7 +652,7 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
} catch (error) {
const message = error instanceof Error ? error.message : "archive_proposal_failed";
await writeDecisionChange(db, async tx => tx.update(decisions).set({ executionStatus: "failed", updatedAt: new Date(), metadata: { ...metadata, archiveProposalError: message, ...(decision.continuationPolicy === "wake_origin_agent" ? { continuationPending: true } : {}) } })
.where(and(eq(decisions.id, decision.id), eq(decisions.executionStatus, "running"))));
.where(and(eq(decisions.id, decision.id), eq(decisions.executionStatus, "running"))), { companyId: decision.companyId, sourceId: decision.id });
return outcome(decision.id);
}
}
@@ -659,7 +660,7 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
executionStatus: "succeeded",
updatedAt: new Date(),
metadata: { ...metadata, ...(decision.continuationPolicy === "wake_origin_agent" ? { continuationPending: true } : {}) },
}).where(and(eq(decisions.id, decision.id), eq(decisions.executionStatus, "running"))));
}).where(and(eq(decisions.id, decision.id), eq(decisions.executionStatus, "running"))), { companyId: decision.companyId, sourceId: decision.id });
return outcome(decision.id);
}
for (let index = 0; index < option.effects.length; index += 1) await executeEffect(decision, option.effects[index]!, index, userActor, decision.decidedByUserId, run?.responsibleUserId ?? null);
@@ -667,7 +668,7 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
const successful = rows.filter((row) => row.status === "executed").length;
const status = rows.every((row) => row.status === "executed") ? "succeeded" : successful ? "partial" : "failed";
await writeDecisionChange(db, async tx => tx.update(decisions).set({ executionStatus: status, updatedAt: new Date(), metadata: { ...decision.metadata,
...(decision.continuationPolicy === "wake_origin_agent" ? { continuationPending: true } : {}) } }).where(and(eq(decisions.id, decision.id), eq(decisions.executionStatus, "running"))));
...(decision.continuationPolicy === "wake_origin_agent" ? { continuationPending: true } : {}) } }).where(and(eq(decisions.id, decision.id), eq(decisions.executionStatus, "running"))), { companyId: decision.companyId, sourceId: decision.id });
return outcome(decision.id);
}
@@ -706,7 +707,7 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
decideIdempotencyKey: input.idempotencyKey ?? null,
...(current.continuationPolicy === "wake_origin_agent" ? { continuationPending: true } : {}),
...(input.dismissed ? { dismissed: true, dismissReason: input.dismissReason ?? null } : {}) } })
.where(and(eq(decisions.id, current.id), eq(decisions.status, "open"))).returning());
.where(and(eq(decisions.id, current.id), eq(decisions.status, "open"))).returning(), { companyId: current.companyId, sourceId: current.id });
if (!claimed) throw conflict("decision_already_resolved", { code: "decision_already_resolved" });
const run = await db.select({ responsibleUserId: heartbeatRuns.responsibleUserId }).from(heartbeatRuns).where(eq(heartbeatRuns.id, claimed.originRunId)).then((rows) => rows[0] ?? null);
await logActivity(db, { companyId: claimed.companyId, actorType: "system", actorId: "decision-executor", agentId: claimed.originAgentId, runId: claimed.originRunId,
@@ -724,7 +725,7 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
return (await get(id))!;
}
const [updated] = await writeDecisionChange(db, async tx => tx.update(decisions).set({ status: "cancelled", updatedAt: new Date(), metadata: { ...current.metadata,
...(current.continuationPolicy === "wake_origin_agent" ? { continuationPending: true } : {}) } }).where(and(eq(decisions.id, id), eq(decisions.status, "open"))).returning());
...(current.continuationPolicy === "wake_origin_agent" ? { continuationPending: true } : {}) } }).where(and(eq(decisions.id, id), eq(decisions.status, "open"))).returning(), { companyId: current.companyId, sourceId: id });
if (!updated) throw conflict("decision_already_resolved", { code: "decision_already_resolved" });
await logActivity(db, { companyId: updated.companyId, actorType: actor.actorType, actorId: actor.actorId, runId: actor.runId, action: "decision.cancelled", entityType: "decision", entityId: id });
await continuations.deliver(updated.id);
@@ -738,7 +739,7 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
if (empty) return decide({ id, optionId: empty.id, decidedByUserId: userId, userActor, dismissed: true, dismissReason: reason });
const [updated] = await writeDecisionChange(db, async tx => tx.update(decisions).set({ status: "decided", executionStatus: "succeeded", chosenOptionId: "dismissed", decidedByUserId: userId,
decidedAt: new Date(), updatedAt: new Date(), metadata: { ...current.metadata, dismissed: true, dismissReason: reason ?? null,
...(current.continuationPolicy === "wake_origin_agent" ? { continuationPending: true } : {}) } }).where(and(eq(decisions.id, id), eq(decisions.status, "open"))).returning());
...(current.continuationPolicy === "wake_origin_agent" ? { continuationPending: true } : {}) } }).where(and(eq(decisions.id, id), eq(decisions.status, "open"))).returning(), { companyId: current.companyId, sourceId: id });
if (!updated) throw conflict("decision_already_resolved", { code: "decision_already_resolved" });
await logActivity(db, { companyId: updated.companyId, actorType: "system", actorId: "decision-executor", agentId: updated.originAgentId,
runId: updated.originRunId, responsibleUserIdOverride: userId, action: "decision.dismissed", entityType: "decision", entityId: updated.id,
@@ -47,6 +47,7 @@ export const DELIVERY_QUEUES = {
executionDisposition: "execution-disposition",
environmentCleanup: "environment-cleanup",
approvalQueueSeeding: "approval-queue-seeding",
attentionRetention: "attention-retention",
approvalRetention: "approval-retention",
joinRequestRetention: "join-request-retention",
decisionTargets: "decision-targets",
@@ -1,3 +1,4 @@
import { notifyAttentionRetentionWork } from "./retention-work-notifications.js";
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
import { notifyDeferredExecutionEligibility } from "./deferred-execution-notifications.js";
import { notifyRunDispatch } from "./run-dispatch-notifications.js";
@@ -8,6 +9,7 @@ export type ExecutionWorkScope = { companyId: string; agentId?: string; runId?:
export async function notifyExecutionRunWork(transaction: object, scope?: ExecutionWorkScope) {
if (scope) {
await notifyAttentionRetentionWork(transaction, scope);
await notifyStatusCardIssueChange(transaction, scope.companyId);
await notifyRunDispatch(transaction, scope);
await notifyDeferredExecutionEligibility(transaction, scope);
@@ -25,6 +27,7 @@ export function writeExecutionControl<T>(
scope: ExecutionWorkScope | undefined,
): Promise<T> {
return db.transaction(async transaction => {
if (scope) await notifyAttentionRetentionWork(transaction, scope);
if (scope) await notifyStatusCardIssueChange(transaction, scope.companyId);
await notifyRunDispatch(transaction, scope);
if (scope) await notifyDeferredExecutionEligibility(transaction, scope);
@@ -1,3 +1,4 @@
import { notifyAttentionRetentionWork } from "../retention-work-notifications.js";
import { and, eq, inArray, isNotNull, or, sql } from "drizzle-orm";
import { dotRunnerAssignments, environmentLeases, heartbeatRuns, issueRecoveryActions, issues, nativeRunFinalizations, type Db } from "@paperclipai/db";
import { authorizeExternalBotOperator } from "../external-bot-authority.js";
@@ -74,6 +75,7 @@ export async function inspectGrokWorkspaceObligations(db: Db, companyId: string,
return { ...rest, externalWorkspaceInspection: inspection };
};
await tx.update(heartbeatRuns).set({ resultJson: retireFileObligation(run.resultJson!) }).where(eq(heartbeatRuns.id, run.id));
if (actions.length) await notifyAttentionRetentionWork(tx, { companyId, sourceKind: "recovery_action" });
for (const action of actions) {
await tx.update(issueRecoveryActions).set({ evidence: retireFileObligation(action.evidence), updatedAt: new Date(),
nextAction: "Inspect Grok and verify its connection before sending a new task message. The old work will not be replayed.",
+7 -2
View File
@@ -1,3 +1,4 @@
import { notifyAttentionRetentionWork } from "./retention-work-notifications.js";
import { readNativeEventEvidenceOutcome } from "./native-runtime/evidence-classifier.js";
import { notifyNativeStatusReassessment } from "./native-status-reassessment-notifications.js";
import { isNativeResponseEvidence, notifyNativeResponsePresentation } from "./native-response-presentation-notifications.js";
@@ -9,6 +10,8 @@ import type { Db } from "@paperclipai/db";
import { heartbeatRunEvents, heartbeatRuns } from "@paperclipai/db";
import { nativeSha256 } from "./native-runtime/canonical.js";
export const RETRY_EXHAUSTION_MESSAGE_PREFIX = "Bounded retry exhausted";
export interface AppendHeartbeatRunEventInput {
companyId: string;
runId: string;
@@ -74,10 +77,11 @@ export async function appendHeartbeatRunEvent(
db: Db,
input: AppendHeartbeatRunEventInput,
): Promise<AppendHeartbeatRunEventResult> {
const projectsRetention = input.eventType === "lifecycle" && Boolean(input.message?.startsWith(RETRY_EXHAUSTION_MESSAGE_PREFIX));
const projectsReassessment = readNativeEventEvidenceOutcome(input.payload?.prpEvent) !== null;
const projectsProgress = isSafeNativeChatProgressEventType(input.eventType);
const projectsResponse = isNativeResponseEvidence(input.eventType, input.payload?.prpEvent);
if (!input.nativeSource && !input.retryExhaustion && !projectsProgress && !projectsResponse && !projectsReassessment) {
if (!input.nativeSource && !input.retryExhaustion && !projectsProgress && !projectsResponse && !projectsReassessment && !projectsRetention) {
const allocated = db.$with("allocated_run_event", {}).as(sql`
update ${heartbeatRuns}
set next_event_seq = ${heartbeatRuns.nextEventSeq} + 1,
@@ -129,7 +133,7 @@ export async function appendHeartbeatRunEvent(
eq(heartbeatRunEvents.runId, input.runId),
eq(heartbeatRunEvents.agentId, input.agentId),
eq(heartbeatRunEvents.eventType, "lifecycle"),
sql`${heartbeatRunEvents.message} like 'Bounded retry exhausted%'`,
sql`${heartbeatRunEvents.message} like ${`${RETRY_EXHAUSTION_MESSAGE_PREFIX}%`}`,
sql`${heartbeatRunEvents.payload} @> ${JSON.stringify(input.retryExhaustion)}::jsonb`,
))
.limit(1)
@@ -187,6 +191,7 @@ export async function appendHeartbeatRunEvent(
if (projectsResponse && run.runtimeMode === "native") {
await notifyNativeResponsePresentation(tx, { companyId: run.companyId, runId: input.runId });
}
if (projectsRetention) await notifyAttentionRetentionWork(tx, { companyId: input.companyId, sourceKind: "failed_run" });
const seq = await allocateHeartbeatRunEventSeq(
tx as unknown as Db,
input.runId,
@@ -1,3 +1,4 @@
import { notifyAttentionRetentionWork } from "../retention-work-notifications.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "../delivery-work-notifications.js";
import { agents, heartbeatRuns, type Db } from "@paperclipai/db";
import { and, asc, desc, eq, lte, sql } from "drizzle-orm";
@@ -91,6 +92,7 @@ export function createHeartbeatAgentStatus(db: Db) {
)).limit(1);
const nextStatus = running ? "running" : settledStatus(outcome, options?.keepIdleOnFailure);
const now = new Date();
await notifyAttentionRetentionWork(tx, { companyId: existing.companyId });
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
const [updated] = await tx.update(agents).set({
status: nextStatus,
@@ -140,6 +142,7 @@ export function createHeartbeatAgentStatus(db: Db) {
eq(heartbeatRuns.agentId, candidate.id)))
.orderBy(desc(heartbeatRuns.finishedAt), desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id)).limit(1);
const nextStatus = settledStatus(lastRun?.status);
await notifyAttentionRetentionWork(tx, { companyId: candidate.companyId });
const [agent] = await tx.update(agents).set({ status: nextStatus,
errorReason: nextStatus === "error" ? errorReason(lastRun?.error) : null, updatedAt: now,
}).where(and(eq(agents.id, candidate.id), eq(agents.companyId, candidate.companyId),
+2
View File
@@ -1,3 +1,4 @@
import { notifyAttentionRetentionWork } from "../retention-work-notifications.js";
import { notifyStatusCardIssueChange } from "../status-card-notifications.js";
import { notifyScheduledRetryDispatch } from "../run-dispatch-notifications.js";
import { deriveTaskKeyWithHeartbeatFallback } from "./run-state.js";
@@ -999,6 +1000,7 @@ export function createHeartbeatRetries(db: Db, dependencies: HeartbeatRetryDepen
retryContextSnapshot.previousRunId = continuation.previousRunId;
}
await notifyAttentionRetentionWork(tx, { companyId: run.companyId });
await notifyScheduledRetryDispatch(tx, { companyId: run.companyId });
const wakeupRequest = await tx
.insert(agentWakeupRequests)
@@ -1,3 +1,4 @@
import { notifyAttentionRetentionWork } from "./retention-work-notifications.js";
import { notifyIssueRecovery } from "./issue-recovery-notifications.js";
import { notifyNativeRecoveryNotices } from "./native-recovery-notice-notifications.js";
import { notifyUnsafeWorkspaceExportRecovery } from "./unsafe-workspace-export-notifications.js";
@@ -323,6 +324,7 @@ export function issueRecoveryActionService(db: DbOrTransaction) {
): Promise<IssueRecoveryAction> {
try {
const created = await db.transaction(async (tx) => {
await notifyAttentionRetentionWork(tx, { companyId: input.companyId });
await notifyIssueRecovery(tx, { companyId: input.companyId });
await notifyNativeRecoveryNotices(tx, { companyId: input.companyId, issueId: input.sourceIssueId });
await notifyDeliveryWork(tx, DELIVERY_QUEUES.executionDisposition);
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getTableName } from "drizzle-orm";
import { PgDialect } from "drizzle-orm/pg-core";
import { getTableName, type SQL } from "drizzle-orm";
const mockCreateChild = vi.fn();
@@ -48,8 +49,18 @@ function createFakeDb(args: {
const toolActionRequestUpdates: Array<Record<string, unknown>> = [];
const inserts: Array<{ table: string; values: Record<string, unknown> }> = [];
let selectCallCount = 0;
let transactionDepth = 0;
const db: any = {
execute: vi.fn(async (query: SQL) => {
const statement = new PgDialect().sqlToQuery(query);
expect(transactionDepth).toBeGreaterThan(0);
expect(statement.params).toContain(interactionRow.companyId);
expect(statement.params).toContain(interactionRow.issueId);
if (statement.sql.includes("origin_kind")) return [{ id: interactionRow.issueId, originKind: null, originId: null }];
if (statement.sql.includes("with recursive affected")) return [{ issueId: interactionRow.issueId }];
throw new Error(`Unexpected interaction fixture query: ${statement.sql}`);
}),
select: vi.fn(() => {
selectCallCount += 1;
const rows = selectCallCount === 1 ? [interactionRow] : (args.parentRows ?? []);
@@ -84,7 +95,10 @@ function createFakeDb(args: {
inserts.push({ table: getTableName(table as never), values });
},
})),
transaction: async (callback: (tx: typeof db) => Promise<void>) => callback(db),
transaction: async (callback: (tx: typeof db) => Promise<void>) => {
transactionDepth++;
try { return await callback(db); } finally { transactionDepth--; }
},
};
return {
@@ -177,6 +191,7 @@ describe("issueThreadInteractionService", () => {
};
let selectCallCount = 0;
let transactionDepth = 0;
const db: any = {
select: vi.fn(() => {
selectCallCount += 1;
+2
View File
@@ -1,3 +1,4 @@
import { notifyAttentionRetentionWork } from "./retention-work-notifications.js";
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
import { and, eq } from "drizzle-orm";
import { heartbeatRuns, issues, type Db } from "@paperclipai/db";
@@ -77,6 +78,7 @@ export async function setIssueTitle(
|| (issue.title === title && !issue.titleNeedsGeneration)) {
return { result: await saveReceipt({ id: issue.id, title: issue.title, titleNeedsGeneration: issue.titleNeedsGeneration, changed: false }), publication: null };
}
await notifyAttentionRetentionWork(tx, { companyId });
await notifyStatusCardIssueChange(tx, companyId);
await tx.update(issues).set({ title, titleNeedsGeneration: false, updatedAt: new Date() }).where(predicate);
await issueReferenceService(tx).syncIssue(issueId, tx);
+7 -1
View File
@@ -1,5 +1,5 @@
import { nonIdleSlackIssueCondition } from "./slack-conversation-state.js";
import { and, isNull, type SQL } from "drizzle-orm";
import { and, isNull, isNotNull, or, type SQL } from "drizzle-orm";
import { issues } from "@paperclipai/db";
export function visibleIssueCondition(): SQL {
@@ -14,3 +14,9 @@ export function visibleIssueSql(alias = "issues") {
export function executionIssueCondition(): SQL {
return and(visibleIssueCondition(), isNull(issues.conversationAgentId), nonIdleSlackIssueCondition())!;
}
export function attentionIssueCondition(includeConversations = false): SQL {
return includeConversations
? or(executionIssueCondition(), and(visibleIssueCondition(), isNotNull(issues.conversationAgentId)))!
: executionIssueCondition();
}
+265 -227
View File
@@ -1,3 +1,4 @@
import { notifyIssueAttentionRetentionWork } from "./retention-work-notifications.js";
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
import { notifyDeferredExecutionEligibility } from "./deferred-execution-notifications.js";
import { notifyIssueRecovery } from "./issue-recovery-notifications.js";
@@ -2518,120 +2519,87 @@ export async function listUnfinalizedExecutionWorkspaceIds(
return unfinalized;
}
/** Workspace-wide facts are shared by blockers, rather than rereading retained
* operation history for every issue pointing at the same workspace. The ordered
* OFFSET boundary keeps the rare finalize filter from turning into a company
* history scan; the existing workspace/started-at index can stream candidates. */
function blockerWorkspaceFacts(companyId: string, workspaceScope: SQL) {
return sql`(
with candidate_workspaces as materialized (
select distinct workspace_id from (${workspaceScope}) scoped_workspaces(workspace_id)
), workspace_facts as materialized (
select candidate.workspace_id, finalized.started_at as finalized_at,
legacy.phase, legacy.status, legacy.started_at
from candidate_workspaces candidate
left join lateral (
select started_at from (
select phase, status, started_at from workspace_operations
where company_id = ${companyId}::uuid
and execution_workspace_id = candidate.workspace_id
order by started_at desc offset 0
) workspace_history
where phase = 'workspace_finalize' and status = 'succeeded'
order by started_at desc limit 1
) finalized on true
left join lateral (
select phase, status, started_at from workspace_operations
where company_id = ${companyId}::uuid
and execution_workspace_id = candidate.workspace_id and issue_id is null
order by started_at desc limit 1
) legacy on true
) select * from workspace_facts
) as blocker_workspace_facts`;
}
/** An issue's own operation takes precedence over the legacy NULL fallback.
* Only a strictly later successful finalize clears either barrier. */
function pendingFinalizeBlockerCondition(companyId: string, issueId: SQL, workspaceId: SQL) {
return sql`${workspaceId} is not null and exists (
select 1 from (values (1)) seed(value)
left join lateral (
select phase, status, started_at from workspace_operations blocker_operation
where blocker_operation.company_id = ${companyId}::uuid
and blocker_operation.execution_workspace_id = ${workspaceId}
and blocker_operation.issue_id = ${issueId}
order by blocker_operation.started_at desc limit 1
) latest_blocker_operation on true
where coalesce(latest_blocker_operation.started_at, blocker_workspace_facts.started_at) is not null
and not (coalesce(latest_blocker_operation.phase, blocker_workspace_facts.phase) = 'workspace_finalize'
and coalesce(latest_blocker_operation.status, blocker_workspace_facts.status) = 'succeeded')
and (blocker_workspace_facts.finalized_at is null
or blocker_workspace_facts.finalized_at <= coalesce(latest_blocker_operation.started_at, blocker_workspace_facts.started_at))
)`;
}
async function listPendingFinalizeBlockerIssueIds(
dbOrTx: Pick<Db, "select">,
companyId: string,
blockerWorkspacePairs: Array<{
blockerIssueId: string;
executionWorkspaceId: string;
}>,
blockerWorkspacePairs: Array<{ blockerIssueId: string; executionWorkspaceId: string }>,
lockSources = false,
): Promise<Set<string>> {
const pending = new Set<string>();
const blockerIssueIds = [
...new Set(blockerWorkspacePairs.map((pair) => pair.blockerIssueId)),
];
const executionWorkspaceIds = [
...new Set(blockerWorkspacePairs.map((pair) => pair.executionWorkspaceId)),
];
if (blockerIssueIds.length === 0 || executionWorkspaceIds.length === 0)
return pending;
const blockerWorkspaceKeys = new Set(
blockerWorkspacePairs.map(
(pair) => `${pair.blockerIssueId}:${pair.executionWorkspaceId}`,
),
);
const pairs = [...new Map(blockerWorkspacePairs.map(pair => [`${pair.blockerIssueId}:${pair.executionWorkspaceId}`, pair])).values()];
if (lockSources) {
await dbOrTx.select({ id: executionWorkspaces.id }).from(executionWorkspaces)
.where(and(eq(executionWorkspaces.companyId, companyId), inArray(executionWorkspaces.id, executionWorkspaceIds)))
.for("update", { noWait: true });
}
const query = dbOrTx
.select({
issueId: workspaceOperations.issueId,
executionWorkspaceId: workspaceOperations.executionWorkspaceId,
phase: workspaceOperations.phase,
status: workspaceOperations.status,
startedAt: workspaceOperations.startedAt,
})
.from(workspaceOperations)
.where(
and(
eq(workspaceOperations.companyId, companyId),
inArray(
workspaceOperations.executionWorkspaceId,
executionWorkspaceIds,
),
),
);
const rows = await (lockSources ? query.for("share", { noWait: true }) : query);
const latestAttributedByBlockerWorkspace = new Map<
string,
{ phase: string; status: string; startedAt: Date }
>();
const latestUnattributedByWorkspace = new Map<
string,
{ phase: string; status: string; startedAt: Date }
>();
const latestSuccessfulFinalizeByWorkspace = new Map<string, Date>();
for (const row of rows) {
if (!row.executionWorkspaceId) continue;
if (row.phase === "workspace_finalize" && row.status === "succeeded") {
const current = latestSuccessfulFinalizeByWorkspace.get(
row.executionWorkspaceId,
);
if (!current || row.startedAt > current) {
latestSuccessfulFinalizeByWorkspace.set(
row.executionWorkspaceId,
row.startedAt,
);
}
}
if (row.issueId) {
const key = `${row.issueId}:${row.executionWorkspaceId}`;
if (!blockerWorkspaceKeys.has(key)) continue;
const current = latestAttributedByBlockerWorkspace.get(key);
if (!current || row.startedAt > current.startedAt) {
latestAttributedByBlockerWorkspace.set(key, {
phase: row.phase,
status: row.status,
startedAt: row.startedAt,
});
}
continue;
}
const current = latestUnattributedByWorkspace.get(row.executionWorkspaceId);
if (!current || row.startedAt > current.startedAt) {
latestUnattributedByWorkspace.set(row.executionWorkspaceId, {
phase: row.phase,
status: row.status,
startedAt: row.startedAt,
});
const workspaceIds = [...new Set(pairs.map(pair => pair.executionWorkspaceId))];
for (const chunk of chunkList(workspaceIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
await dbOrTx.select({ id: executionWorkspaces.id }).from(executionWorkspaces)
.where(and(eq(executionWorkspaces.companyId, companyId), inArray(executionWorkspaces.id, chunk)))
.for("update", { noWait: true });
await dbOrTx.select({ id: workspaceOperations.id }).from(workspaceOperations)
.where(and(eq(workspaceOperations.companyId, companyId), inArray(workspaceOperations.executionWorkspaceId, chunk)))
.for("share", { noWait: true });
}
}
for (const pair of blockerWorkspacePairs) {
const latest =
latestAttributedByBlockerWorkspace.get(
`${pair.blockerIssueId}:${pair.executionWorkspaceId}`,
) ?? latestUnattributedByWorkspace.get(pair.executionWorkspaceId);
if (!latest) continue; // no ops recorded -> nothing to finalize for this blocker
if (latest.phase === "workspace_finalize" && latest.status === "succeeded")
continue;
const laterSuccessfulFinalize = latestSuccessfulFinalizeByWorkspace.get(
pair.executionWorkspaceId,
);
if (laterSuccessfulFinalize && laterSuccessfulFinalize > latest.startedAt)
continue;
pending.add(pair.blockerIssueId);
// Each pair occurs in both the source and the workspace scope.
for (const chunk of chunkList(pairs, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE / 2)) {
const values = sql.join(chunk.map(pair => sql`(${pair.blockerIssueId}::uuid, ${pair.executionWorkspaceId}::uuid)`), sql`, `);
const rows = await dbOrTx.select({ id: sql<string>`blocker_source.issue_id` })
.from(sql`(values ${values}) as blocker_source(issue_id, workspace_id)`)
.leftJoin(blockerWorkspaceFacts(companyId, sql`select workspace_id from (values ${values}) workspace_scope(issue_id, workspace_id)`),
sql`blocker_workspace_facts.workspace_id = blocker_source.workspace_id`)
.where(pendingFinalizeBlockerCondition(companyId, sql`blocker_source.issue_id`, sql`blocker_source.workspace_id`));
for (const row of rows) pending.add(row.id);
}
return pending;
}
@@ -3436,6 +3404,7 @@ type IssueBlockerAttentionEdge = {
blockerIssueId: string;
};
type IssueBlockerAttentionQueryRow = IssueBlockerAttentionNode & {
executionWorkspaceId?: string | null;
issueId: string | null;
blockerIssueId: string;
};
@@ -3824,11 +3793,15 @@ async function terminalExplicitBlockersByRoot(
return terminalByRoot;
}
async function listIssueBlockerAttentionMap(
async function listIssueBlockerAttentionMap(dbOrTx: any, companyId: string, issueRows: IssueBlockerAttentionInputNode[]) {
return (await readIssueBlockerAttentionBatch(dbOrTx, companyId, issueRows)).attention;
}
async function readIssueBlockerAttentionBatch(
dbOrTx: any,
companyId: string,
issueRows: IssueBlockerAttentionInputNode[],
): Promise<Map<string, IssueBlockerAttention>> {
) {
const roots = issueRows.filter(
(row) => row.companyId === companyId && row.status === "blocked",
);
@@ -3838,42 +3811,44 @@ async function listIssueBlockerAttentionMap(
attentionMap.set(row.id, createIssueBlockerAttention());
}
}
if (roots.length === 0) return attentionMap;
const deferredRootIds = new Set<string>(roots.slice(BLOCKER_ATTENTION_MAX_NODES).map(root => root.id));
let edgeCount = 0, fetchedRows = 0;
const result = () => ({ attention: attentionMap, deferredRootIds, nodeCount: nodesById.size, edgeCount, fetchedRows });
if (roots.length === 0) return { attention: attentionMap, deferredRootIds, nodeCount: 0, edgeCount, fetchedRows };
const nodesById = new Map<string, IssueBlockerAttentionNode>();
const edgesByIssueId = new Map<string, IssueBlockerAttentionEdge[]>();
for (const root of roots) nodesById.set(root.id, { ...root });
// Returned rows are already bounded by the residual fetch budget. Keep their
// positive live-work evidence even when they cannot enter the traversal graph.
const observedNodes = new Map<string, IssueBlockerAttentionNode>();
const observedChildrenByParent = new Map<string, Set<string>>();
const admittedRoots = roots.slice(0, BLOCKER_ATTENTION_MAX_NODES);
for (const root of admittedRoots) nodesById.set(root.id, { ...root });
let frontier = roots.map((root) => root.id);
let truncated = false;
type Traversal = { rootId: string; nodes: Set<string>; expanded: Set<string>; frontier: string[]; truncated: boolean };
const traversals = new Map<string, Traversal>(admittedRoots.map(root => [root.id, {
rootId: root.id, nodes: new Set([root.id]), expanded: new Set<string>(), frontier: [root.id], truncated: false,
}]));
const expanded = new Set<string>();
const queried = new Set<string>();
const pendingFinalizeBlockerIssueIds = new Set<string>();
for (
let depth = 0;
frontier.length > 0 && depth < BLOCKER_ATTENTION_MAX_DEPTH;
depth += 1
) {
const nextFrontier = new Set<string>();
for (let depth = 0; depth < BLOCKER_ATTENTION_MAX_DEPTH; depth += 1) {
const active = [...traversals.values()].filter(traversal => !traversal.truncated && traversal.frontier.length > 0);
if (active.length === 0) break;
const unread = new Set<string>();
for (const traversal of active) for (const id of traversal.frontier) if (!expanded.has(id)) unread.add(id);
const frontier = [...unread];
for (const chunk of chunkList(
[...new Set(frontier)],
frontier,
ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE,
)) {
const readinessByIssueId = await listIssueDependencyReadinessMap(
dbOrTx,
companyId,
chunk,
);
for (const readiness of readinessByIssueId.values()) {
for (const blockerIssueId of readiness.pendingFinalizeBlockerIssueIds) {
pendingFinalizeBlockerIssueIds.add(blockerIssueId);
}
}
const explicitBlockerRowsPromise: Promise<
IssueBlockerAttentionQueryRow[]
> = dbOrTx
const remaining = Math.min(BLOCKER_ATTENTION_MAX_NODES - nodesById.size,
BLOCKER_ATTENTION_MAX_NODES * 2 - edgeCount);
if (remaining <= 0) break;
const rowLimit = remaining + 1;
const explicitBlocker = dbOrTx
.select({
issueId: issueRelations.relatedIssueId,
blockerIssueId: issues.id,
id: issues.id,
companyId: issues.companyId,
parentId: issues.parentId,
@@ -3881,19 +3856,55 @@ async function listIssueBlockerAttentionMap(
title: issues.title,
status: issues.status,
executionRunId: issues.executionRunId,
executionWorkspaceId: issues.executionWorkspaceId,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
})
// Resolve the globally unique relation target first. The company guard
// below remains authoritative, without a repeated company-index scan.
.from(issues).where(eq(issues.id, issueRelations.issueId))
.limit(1).as("explicit_blocker");
const workspaceScope = sql`select distinct candidate.execution_workspace_id
from issue_relations workspace_relation
inner join lateral (
select company_id, status, execution_workspace_id from issues
where id = workspace_relation.issue_id limit 1
) candidate on true
where workspace_relation.company_id = ${companyId}::uuid
and workspace_relation.type = 'blocks'
and workspace_relation.related_issue_id in (${sql.join(chunk.map(id => sql`${id}::uuid`), sql`, `)})
and candidate.company_id = ${companyId}::uuid
and candidate.status = 'done' and candidate.execution_workspace_id is not null`;
const explicitBlockerRowsPromise: Promise<
IssueBlockerAttentionQueryRow[]
> = dbOrTx
.select({
issueId: issueRelations.relatedIssueId,
blockerIssueId: explicitBlocker.id,
id: explicitBlocker.id,
companyId: explicitBlocker.companyId,
parentId: explicitBlocker.parentId,
identifier: explicitBlocker.identifier,
title: explicitBlocker.title,
status: explicitBlocker.status,
executionRunId: explicitBlocker.executionRunId,
executionWorkspaceId: explicitBlocker.executionWorkspaceId,
assigneeAgentId: explicitBlocker.assigneeAgentId,
assigneeUserId: explicitBlocker.assigneeUserId,
})
.from(issueRelations)
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
.innerJoinLateral(explicitBlocker, sql`true`)
.leftJoin(blockerWorkspaceFacts(companyId, workspaceScope),
sql`blocker_workspace_facts.workspace_id = ${explicitBlocker.executionWorkspaceId}`)
.where(
and(
eq(issueRelations.companyId, companyId),
eq(issueRelations.type, "blocks"),
inArray(issueRelations.relatedIssueId, chunk),
eq(issues.companyId, companyId),
eq(explicitBlocker.companyId, companyId),
or(ne(explicitBlocker.status, "done"), pendingFinalizeBlockerCondition(companyId, sql`${explicitBlocker.id}`, sql`${explicitBlocker.executionWorkspaceId}`)),
),
);
).limit(rowLimit);
const childRowsPromise: Promise<IssueBlockerAttentionQueryRow[]> = dbOrTx
.select({
issueId: issues.parentId,
@@ -3918,68 +3929,83 @@ async function listIssueBlockerAttentionMap(
BLOCKER_ATTENTION_CHILD_TERMINAL_STATUSES,
),
),
);
).limit(rowLimit);
const [explicitBlockerRows, childRows] = await Promise.all([
explicitBlockerRowsPromise,
childRowsPromise,
]);
for (const id of chunk) queried.add(id);
fetchedRows += explicitBlockerRows.length + childRows.length;
const pendingFinalize = await listPendingFinalizeBlockerIssueIds(dbOrTx, companyId,
explicitBlockerRows.flatMap(row => row.status === "done" && row.executionWorkspaceId
? [{ blockerIssueId: row.blockerIssueId, executionWorkspaceId: row.executionWorkspaceId }] : []));
for (const id of pendingFinalize) pendingFinalizeBlockerIssueIds.add(id);
let complete = explicitBlockerRows.length < rowLimit && childRows.length < rowLimit;
const unresolvedExplicitBlockerRows = explicitBlockerRows.filter(
(row) =>
row.status !== "done" ||
pendingFinalizeBlockerIssueIds.has(row.blockerIssueId),
);
appendBlockerAttentionEdges(edgesByIssueId, [
...unresolvedExplicitBlockerRows
.filter(
(row): row is IssueBlockerAttentionQueryRow & { issueId: string } =>
row.issueId !== null,
)
.map((row) => ({
issueId: row.issueId,
blockerIssueId: row.blockerIssueId,
})),
...childRows
.filter(
(row): row is IssueBlockerAttentionQueryRow & { issueId: string } =>
row.issueId !== null,
)
.map((row) => ({
issueId: row.issueId,
blockerIssueId: row.blockerIssueId,
})),
]);
for (const row of [...unresolvedExplicitBlockerRows, ...childRows]) {
if (!row.issueId || nodesById.has(row.blockerIssueId)) continue;
nodesById.set(row.blockerIssueId, {
id: row.blockerIssueId,
companyId: row.companyId,
parentId: row.parentId,
identifier: row.identifier,
title: row.title,
status: row.status,
executionRunId: row.executionRunId,
assigneeAgentId: row.assigneeAgentId,
assigneeUserId: row.assigneeUserId,
});
nextFrontier.add(row.blockerIssueId);
if (!row.issueId) continue;
observedNodes.set(row.blockerIssueId, row);
const observed = observedChildrenByParent.get(row.issueId) ?? new Set<string>();
observed.add(row.blockerIssueId);
observedChildrenByParent.set(row.issueId, observed);
if (!nodesById.has(row.blockerIssueId)) {
if (nodesById.size >= BLOCKER_ATTENTION_MAX_NODES) { complete = false; continue; }
nodesById.set(row.blockerIssueId, {
id: row.blockerIssueId, companyId: row.companyId, parentId: row.parentId, identifier: row.identifier,
title: row.title, status: row.status, executionRunId: row.executionRunId,
assigneeAgentId: row.assigneeAgentId, assigneeUserId: row.assigneeUserId,
});
}
const edges = edgesByIssueId.get(row.issueId) ?? [];
if (edges.some(edge => edge.blockerIssueId === row.blockerIssueId)) continue;
if (edgeCount >= BLOCKER_ATTENTION_MAX_NODES * 2) { complete = false; continue; }
edges.push({ issueId: row.issueId, blockerIssueId: row.blockerIssueId });
edgesByIssueId.set(row.issueId, edges);
edgeCount++;
}
if (complete) for (const id of chunk) expanded.add(id);
}
if (nodesById.size > BLOCKER_ATTENTION_MAX_NODES) {
truncated = true;
break;
for (const traversal of active) {
if (traversal.frontier.some(id => !expanded.has(id))) {
for (const id of traversal.frontier) if (queried.has(id)) {
traversal.expanded.add(id);
for (const edge of edgesByIssueId.get(id) ?? []) if (nodesById.has(edge.blockerIssueId)) traversal.nodes.add(edge.blockerIssueId);
}
traversal.truncated = true;
traversal.frontier = [];
deferredRootIds.add(traversal.rootId);
continue;
}
const nextFrontier = new Set<string>();
for (const id of traversal.frontier) {
traversal.expanded.add(id);
for (const edge of edgesByIssueId.get(id) ?? []) {
if (traversal.nodes.has(edge.blockerIssueId) || !nodesById.has(edge.blockerIssueId)) continue;
traversal.nodes.add(edge.blockerIssueId);
nextFrontier.add(edge.blockerIssueId);
}
}
traversal.truncated = traversal.nodes.size > BLOCKER_ATTENTION_MAX_NODES;
traversal.frontier = traversal.truncated ? [] : [...nextFrontier];
}
frontier = [...nextFrontier];
}
if (frontier.length > 0) truncated = true;
for (const traversal of traversals.values()) {
if (traversal.frontier.length > 0) traversal.truncated = true;
}
const nodeIds = [...nodesById.keys()];
const evidenceNodes = new Map([...nodesById, ...observedNodes]);
const nodeIds = [...evidenceNodes.keys()];
const activeIssueIds = new Set<string>();
const agentIds = new Set<string>();
const issueIdByExecutionRunId = new Map<string, string>();
for (const node of nodesById.values()) {
for (const node of evidenceNodes.values()) {
if (node.assigneeAgentId) agentIds.add(node.assigneeAgentId);
if (node.executionRunId)
issueIdByExecutionRunId.set(node.executionRunId, node.id);
@@ -4037,7 +4063,7 @@ async function listIssueBlockerAttentionMap(
}
}
const explicitWaitCandidateIds = [...nodesById.values()]
const explicitWaitCandidateIds = [...evidenceNodes.values()]
.filter((node) => node.status !== "done")
.map((node) => node.id);
const explicitWaitingIssueIds = new Set<string>();
@@ -4112,23 +4138,26 @@ async function listIssueBlockerAttentionMap(
ownerType: string;
ownerAgentId: string | null;
ownerUserId: string | null;
}> = await dbOrTx
.select({
id: issueRecoveryActions.id,
sourceIssueId: issueRecoveryActions.sourceIssueId,
status: issueRecoveryActions.status,
ownerType: issueRecoveryActions.ownerType,
ownerAgentId: issueRecoveryActions.ownerAgentId,
ownerUserId: issueRecoveryActions.ownerUserId,
})
.from(issueRecoveryActions)
.where(
and(
eq(issueRecoveryActions.companyId, companyId),
inArray(issueRecoveryActions.status, ["active", "escalated"]),
inArray(issueRecoveryActions.sourceIssueId, explicitWaitCandidateIds),
),
);
}> = [];
for (const chunk of chunkList(explicitWaitCandidateIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
recoveryActionRows.push(...await dbOrTx
.select({
id: issueRecoveryActions.id,
sourceIssueId: issueRecoveryActions.sourceIssueId,
status: issueRecoveryActions.status,
ownerType: issueRecoveryActions.ownerType,
ownerAgentId: issueRecoveryActions.ownerAgentId,
ownerUserId: issueRecoveryActions.ownerUserId,
})
.from(issueRecoveryActions)
.where(
and(
eq(issueRecoveryActions.companyId, companyId),
inArray(issueRecoveryActions.status, ["active", "escalated"]),
inArray(issueRecoveryActions.sourceIssueId, chunk),
),
));
}
const recoveryActionIds = recoveryActionRows.map((row) => row.id);
const liveRecoveryActionIds = new Set<string>();
for (const chunk of chunkList(
@@ -4191,22 +4220,11 @@ async function listIssueBlockerAttentionMap(
}
}
const agentRows: IssueBlockerAttentionAgentRow[] =
agentIds.size > 0
? await dbOrTx
.select({
id: agents.id,
companyId: agents.companyId,
status: agents.status,
})
.from(agents)
.where(
and(
eq(agents.companyId, companyId),
inArray(agents.id, [...agentIds]),
),
)
: [];
const agentRows: IssueBlockerAttentionAgentRow[] = [];
for (const chunk of chunkList([...agentIds], ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
agentRows.push(...await dbOrTx.select({ id: agents.id, companyId: agents.companyId, status: agents.status })
.from(agents).where(and(eq(agents.companyId, companyId), inArray(agents.id, chunk))));
}
const agentsById = new Map(agentRows.map((agent) => [agent.id, agent]));
type PathClassification = {
@@ -4219,6 +4237,7 @@ async function listIssueBlockerAttentionMap(
const classifyPath = (
nodeId: string,
seen: Set<string>,
truncated: boolean,
): PathClassification => {
const sample = blockerSampleIdentifier(nodesById.get(nodeId));
if (truncated || seen.has(nodeId)) {
@@ -4323,7 +4342,7 @@ async function listIssueBlockerAttentionMap(
const nextSeen = new Set(seen);
nextSeen.add(nodeId);
const classified = downstream.map((edge) =>
classifyPath(edge.blockerIssueId, nextSeen),
classifyPath(edge.blockerIssueId, nextSeen, truncated),
);
const stalledChild = classified.find(
(result) => result.stalled || result.sampleStalledBlockerIdentifier,
@@ -4389,18 +4408,22 @@ async function listIssueBlockerAttentionMap(
};
};
const pathHasLiveWork = (nodeId: string, seen: Set<string>): boolean => {
if (seen.has(nodeId)) return false;
const nodeHasLiveWork = (node: IssueBlockerAttentionNode) =>
node.status === "in_progress" || activeIssueIds.has(node.id) ||
explicitWaitingIssueIds.has(node.id) || Boolean(node.assigneeUserId);
const observedChildHasLiveWork = (parentId: string) =>
[...observedChildrenByParent.get(parentId) ?? []].some(id => {
const node = observedNodes.get(id);
return node?.companyId === companyId && nodeHasLiveWork(node);
});
const pathHasLiveWork = (nodeId: string, seen: Set<string>, traversal: Traversal): boolean => {
if (seen.has(nodeId) || !traversal.nodes.has(nodeId)) return false;
const node = nodesById.get(nodeId);
if (!node || node.companyId !== companyId) return false;
if (
node.status === "in_progress" ||
activeIssueIds.has(node.id) ||
explicitWaitingIssueIds.has(node.id) ||
Boolean(node.assigneeUserId)
)
return true;
if (nodeHasLiveWork(node)) return true;
if (!traversal.expanded.has(nodeId)) return false;
if (observedChildHasLiveWork(nodeId)) return true;
const nextSeen = new Set(seen);
nextSeen.add(nodeId);
return (edgesByIssueId.get(node.id) ?? []).some((edge) => {
@@ -4410,7 +4433,7 @@ async function listIssueBlockerAttentionMap(
!pendingFinalizeBlockerIssueIds.has(edge.blockerIssueId)
)
return false;
return pathHasLiveWork(edge.blockerIssueId, nextSeen);
return pathHasLiveWork(edge.blockerIssueId, nextSeen, traversal);
});
};
@@ -4423,6 +4446,7 @@ async function listIssueBlockerAttentionMap(
};
for (const root of roots) {
const traversal = traversals.get(root.id)!;
const topLevelEdges = (edgesByIssueId.get(root.id) ?? []).filter((edge) => {
const blocker = nodesById.get(edge.blockerIssueId);
return (
@@ -4444,7 +4468,7 @@ async function listIssueBlockerAttentionMap(
const classified = topLevelEdges.map((edge) => ({
edge,
result: classifyPath(edge.blockerIssueId, new Set([root.id])),
result: classifyPath(edge.blockerIssueId, new Set([root.id]), traversal.truncated),
}));
const coveredBlockerCount = classified.filter(
(entry) => entry.result.covered,
@@ -4526,8 +4550,8 @@ async function listIssueBlockerAttentionMap(
stalledEntry?.result.sampleStalledBlockerIdentifier ??
sampleStalledFromChain ??
null,
blockingTreeLive: topLevelEdges.some((edge) =>
pathHasLiveWork(edge.blockerIssueId, new Set([root.id])),
blockingTreeLive: observedChildHasLiveWork(root.id) || topLevelEdges.some((edge) =>
pathHasLiveWork(edge.blockerIssueId, new Set([root.id]), traversal),
),
directBlockerIssueId: sampleEntry?.edge.blockerIssueId ?? null,
terminalBlockerIssueId,
@@ -4542,7 +4566,7 @@ async function listIssueBlockerAttentionMap(
);
}
return attentionMap;
return result();
}
type IssueReviewAttentionInput = Pick<IssueRow, "id" | "companyId" | "status">;
@@ -7602,7 +7626,7 @@ export function issueService(db: Db) {
companyId: string,
blockedByIssueIds: string[],
actor: { agentId?: string | null; userId?: string | null } = {},
dbOrTx: any = db,
dbOrTx: any,
) {
const deduped = [...new Set(blockedByIssueIds)];
if (deduped.some((candidate) => candidate === issueId)) {
@@ -7631,6 +7655,7 @@ export function issueService(db: Db) {
await assertNoBlockingCycles(companyId, issueId, deduped, dbOrTx);
}
await notifyIssueAttentionRetentionWork(dbOrTx, { companyId, issueId });
await notifyIssueRecovery(dbOrTx, { companyId });
await notifyDeferredExecutionEligibility(dbOrTx, { companyId });
await dbOrTx
@@ -9380,6 +9405,9 @@ export function issueService(db: Db) {
return listIssueDependencyReadinessMap(dbOrTx, companyId, issueIds);
},
listBlockerAttentionBatch: async (companyId: string, issueRows: IssueBlockerAttentionInputNode[], dbOrTx: any = db) =>
readIssueBlockerAttentionBatch(dbOrTx, companyId, issueRows),
listBlockerAttention: async (
companyId: string,
issueRows: IssueBlockerAttentionInputNode[],
@@ -9684,6 +9712,8 @@ export function issueService(db: Db) {
if (blockParentUntilDone) {
await db.transaction(async tx => {
await tx.select({ id: issues.id }).from(issues).where(and(
eq(issues.companyId, parent.companyId), eq(issues.id, parent.id))).for("update");
const existingBlockers = await tx
.select({ blockerIssueId: issueRelations.issueId })
.from(issueRelations)
@@ -10620,6 +10650,8 @@ export function issueService(db: Db) {
await notifyRunDispatch(tx, { companyId: values.companyId });
await notifyDeferredExecutionEligibility(tx, { companyId: values.companyId });
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
values.id ??= randomUUID();
await notifyIssueAttentionRetentionWork(tx, { companyId, issueId: values.id, parentId: values.parentId, originKind: values.originKind, originId: values.originId });
await notifyStatusCardIssueChange(tx, companyId);
const [issue] = await tx.insert(issues).values(values).returning();
await attachOwnedDraftImages(tx, issue, issue.description, { userId: issueData.createdByUserId, agentId: issueData.createdByAgentId });
@@ -11509,6 +11541,7 @@ export function issueService(db: Db) {
|| issueData.parentId !== undefined || issueData.executionWorkspaceId !== undefined) {
await notifyTerminalWorkspaceWork(tx, `issue:${id}`);
}
await notifyIssueAttentionRetentionWork(tx, { companyId: receiptExisting.companyId, issueId: id, parentId: patch.parentId, originKind: patch.originKind, originId: patch.originId });
if (patch.status !== undefined || patch.assigneeAgentId !== undefined || patch.assigneeUserId !== undefined
|| patch.hiddenAt !== undefined || patch.conversationAgentId !== undefined || patch.parentId !== undefined
|| patch.executionState !== undefined || patch.executionRunId !== undefined || patch.checkoutRunId !== undefined
@@ -11977,6 +12010,7 @@ export function issueService(db: Db) {
await requestRestorePointDeletion(tx, { sourceIssueId: id });
const [owner] = await tx.select({ companyId: issues.companyId }).from(issues).where(eq(issues.id, id));
if (owner) {
await notifyIssueAttentionRetentionWork(tx, { companyId: owner.companyId, issueId: id });
await notifyExternalConversationSettlement(tx, { companyId: owner.companyId });
await notifyApprovalWork(tx, owner.companyId);
await notifyStatusCardIssueChange(tx, owner.companyId);
@@ -12112,6 +12146,7 @@ export function issueService(db: Db) {
)
: isNull(issues.executionRunId);
const updated = await db.transaction(async tx => {
await notifyIssueAttentionRetentionWork(tx, { companyId: issueCompany.companyId, issueId: id });
await notifyStatusCardIssueChange(tx, issueCompany.companyId);
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
await notifyRunDispatch(tx, { companyId: issueCompany.companyId });
@@ -12247,6 +12282,7 @@ export function issueService(db: Db) {
}
const staleExecutionRunId = current.executionRunId;
const adopted = await db.transaction(async tx => {
await notifyIssueAttentionRetentionWork(tx, { companyId: issueCompany.companyId, issueId: id });
await notifyStatusCardIssueChange(tx, issueCompany.companyId);
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
await notifyRunDispatch(tx, { companyId: issueCompany.companyId });
@@ -12505,6 +12541,7 @@ export function issueService(db: Db) {
existing.status === "in_progress" ? "todo" : existing.status;
await notifyTerminalWorkspaceWork(tx, `issue:${id}`);
await notifyNativeStatusReassessment(tx, { companyId: existing.companyId, issueId: id });
await notifyIssueAttentionRetentionWork(tx, { companyId: existing.companyId, issueId: id });
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
await notifyRunDispatch(tx, { companyId: existing.companyId });
await notifyDeferredExecutionEligibility(tx, { companyId: existing.companyId });
@@ -12559,6 +12596,7 @@ export function issueService(db: Db) {
patch.assigneeAgentId = null;
}
await notifyIssueAttentionRetentionWork(tx, { companyId: existing.companyId, issueId: id });
await notifyStatusCardIssueChange(tx, existing.companyId);
await notifyTerminalWorkspaceWork(tx, `issue:${id}`);
if (existing.checkoutRunId || existing.executionRunId || options.clearAssignee) {
@@ -1,3 +1,4 @@
import { notifyAttentionRetentionWork } from "./retention-work-notifications.js";
import { notifyNativeEnvironmentSettlement } from "./native-environment-settlement-notifications.js";
import { notifyExternalAgentSettlement } from "../modules/external-agents/index.js";
import { notifyExternalConversationSettlement } from "./external-conversation-settlement-notifications.js";
@@ -14,6 +15,7 @@ import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notificatio
export type NativeFinalizationWorkScope = { companyId: string; runId?: string; issueId?: string };
export async function notifyNativeFinalizationWork(transaction: object, scope: NativeFinalizationWorkScope) {
await notifyAttentionRetentionWork(transaction, { companyId: scope.companyId });
if (scope.issueId || scope.runId) {
await notifyNativeEnvironmentSettlement(transaction, scope);
await notifyExternalAgentSettlement(transaction, scope);
@@ -1,3 +1,4 @@
import { notifyAttentionRetentionWork } from "../retention-work-notifications.js";
import { notifyScheduledRetryDispatch } from "../run-dispatch-notifications.js";
import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
@@ -50,6 +51,7 @@ export async function materializeNativeProviderCapacityRetry(input: {
scheduledRetryAt: dueAt.toISOString(),
executionRetryAccounting: accountingForScheduledRetry(run, NATIVE_PROVIDER_OVERLOADED_CODE, attempt),
};
await notifyAttentionRetentionWork(input.tx, { companyId: input.companyId });
await notifyScheduledRetryDispatch(input.tx, { companyId: run.companyId });
const [wake] = await input.tx.insert(agentWakeupRequests).values({
companyId: run.companyId, agentId: run.agentId, source: "automation", triggerDetail: "system",
@@ -1,3 +1,4 @@
import { notifyAttentionRetentionWork } from "../retention-work-notifications.js";
import { notifyStatusCardIssueChange } from "../status-card-notifications.js";
import {
agents,
@@ -241,6 +242,7 @@ export async function claimNativeReviewExecutionLock(db: Db, input: {
if (!run || !["queued", "running"].includes(run.status) || !admitted || !expected
|| admitted.nativeReviewInteractionId !== expected.nativeReviewInteractionId
|| admitted.nativeReviewDecisionId !== expected.nativeReviewDecisionId) return false;
await notifyAttentionRetentionWork(tx, { companyId: input.companyId });
await notifyStatusCardIssueChange(tx, input.companyId);
const claimed = await tx.update(issues).set({
executionRunId: input.runId,
@@ -4492,6 +4492,17 @@ describe("retained native cleanup activation", () => {
releaseCommit = release;
});
const db = {
execute: async (query: SQL) => {
// Retention hints read the fixture's run/issue relationship inside the
// same cleanup transaction; this fixture has one issue and no ancestors.
const statement = new PgDialect().sqlToQuery(query);
expect(transactionOpen).toBe(true);
expect(statement.params).toContain(run.companyId);
if (statement.sql.includes("from heartbeat_runs")) return [{ id: run.nativeIssueId }];
if (statement.sql.includes("origin_kind")) return [{ id: run.nativeIssueId, originKind: null, originId: null }];
if (statement.sql.includes("with recursive affected")) return [{ issueId: run.nativeIssueId }];
throw new Error(`Unexpected cleanup fixture query: ${statement.sql}`);
},
select: () => ({
from: (table: unknown) => {
const rows =
@@ -1,3 +1,4 @@
import { notifyAttentionRetentionWork } from "../retention-work-notifications.js";
import { notifyStatusCardIssueChange } from "../status-card-notifications.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "../delivery-work-notifications.js";
import { notifyUnsafeWorkspaceExportRecovery } from "../unsafe-workspace-export-notifications.js";
@@ -180,6 +181,7 @@ export async function reconstructNativeWorkspace(input: {
...(source.interveningExecutions.length ? { interveningExecutions: source.interveningExecutions } : {}) } };
await markExecutionReconciliation(db, source.action, decision, input.actorId);
const now = new Date();
await notifyAttentionRetentionWork(tx, { companyId: input.companyId });
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: input.companyId, issueId: input.issueId });
await tx.update(issueRecoveryActions).set({ status: "resolved", outcome: "handed_back", resolvedAt: now, updatedAt: now,
resolutionNote: "workspace_reconstruction_authorized", nextAction: "Continue from saved sources. The original unexported edits are lost; verify the selected attachments before rebuilding. Do not repeat completed external actions or reset the original work limits.",
@@ -1,5 +1,6 @@
import { notifyStatusCardIssueChange } from "../status-card-notifications.js";
import { nativeRunKeyScope, nativeSkillTestToolAllowed, assertNativeSkillTestTool } from "./native-skill-test-scope.js";
import { notifyAttentionRetentionWork } from "../retention-work-notifications.js";
import { requiredQualityReviewSchema } from "@paperclipai/shared";
import { priorRecoveryApiReceipt } from "../recovery-effect-receipts.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "../delivery-work-notifications.js";
@@ -2080,6 +2081,7 @@ export class PaperclipRunnerToolAuthority {
replayMonitor = summarizeIssueMonitor(target, normalizeIssueExecutionPolicy(target.executionPolicy));
return { taskId: target.id, status: target.status, monitor: replayMonitor };
}
await notifyAttentionRetentionWork(tx, { companyId: this.binding.companyId });
const patch = prepareIssueMonitorUpdate(target, input.monitor, actor);
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
await notifyStatusCardIssueChange(tx, this.binding.companyId);
@@ -1,3 +1,4 @@
import { notifyAttentionRetentionWork } from "../retention-work-notifications.js";
import { and, eq, isNull, ne, sql } from "drizzle-orm";
import { completionContracts, heartbeatRuns, issues, issueThreadInteractions, statusDecisionEffects, statusDecisions, workAssessments, type Db } from "@paperclipai/db";
import { hasPublishedGitHubReview } from "../chat-channels/github/review-wait.js";
@@ -70,6 +71,7 @@ export async function supersedeExternalReviewRequestsInTransaction(
publications: ActivityPublication[],
) {
for (const { interaction } of candidates) {
await notifyAttentionRetentionWork(tx, { companyId: binding.companyId });
const reason = "A later published review no longer requests human input.";
const [retired] = await tx.update(issueThreadInteractions).set({ status: "cancelled",
result: { version: 1, outcome: "withdrawn", reason }, resolvedAt: new Date(), updatedAt: new Date(),
@@ -1,3 +1,4 @@
import { notifyIssueAttentionRetentionWork } from "./retention-work-notifications.js";
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
import { notifyIssueRecovery } from "./issue-recovery-notifications.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
@@ -5,6 +6,7 @@ import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notificatio
export type NativeStatusReassessmentScope = { companyId: string; issueId?: string; runId?: string; agentId?: string };
export async function notifyNativeStatusReassessment(tx: object, scope: NativeStatusReassessmentScope) {
await notifyIssueAttentionRetentionWork(tx, { companyId: scope.companyId, issueId: scope.issueId, runId: scope.runId });
await notifyStatusCardIssueChange(tx, scope.companyId);
await notifyIssueRecovery(tx, scope ? { companyId: scope.companyId } : undefined);
return notifyDeliveryWork(tx, DELIVERY_QUEUES.nativeStatusReassessment,
@@ -1,3 +1,4 @@
import { notifyAttentionRetentionWork } from "./retention-work-notifications.js";
import { createAgentLifecycle } from "./agent-lifecycle.js";
import { and, eq, isNull, ne } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
@@ -340,6 +341,7 @@ export function pluginManagedAgentService(
// and its canonical history. Transfer only its proven stable-key marker to
// the replacement installation, atomically with the new scoped bindings.
await upsertBinding(companyId, declaration, agentId, {}, adapterType, tx);
await notifyAttentionRetentionWork(tx, { companyId, sourceKind: "agent_error_alert" });
await tx.update(agents).set({
metadata: managedMetadata(options.pluginId, options.pluginKey, declaration, agent.metadata),
updatedAt: new Date(),
@@ -1,14 +1,89 @@
import { notifyApprovalWork } from "./approval-work-notifications.js";
import { RECOVERY_ORIGIN_KINDS, parseIssueGraphLivenessIncidentKey } from "./recovery/origins.js";
import { sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import type { AttentionSourceKind } from "@paperclipai/shared";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
export async function notifyRetentionWork(transaction: object, kind: AttentionSourceKind) {
export const ATTENTION_RETENTION_MAX_ISSUE_IDS = 100;
export type AttentionRetentionScope = { companyId?: string; sourceKind?: AttentionSourceKind;
issueId?: string; issueIds?: string[]; sourceId?: string };
export async function notifyAttentionRetentionWork(transaction: object, scope: AttentionRetentionScope = {}) {
const issueIds = [...new Set([...(scope.issueIds ?? []), ...(scope.issueId ? [scope.issueId] : [])])].sort();
await notifyDeliveryWork(transaction, DELIVERY_QUEUES.attentionRetention, JSON.stringify({
companyId: scope.companyId, sourceKind: scope.sourceKind, sourceId: scope.sourceId,
issueId: issueIds.length === 1 ? issueIds[0] : undefined,
issueIds: issueIds.length > 1 && issueIds.length <= ATTENTION_RETENTION_MAX_ISSUE_IDS ? issueIds : undefined,
}));
}
export async function notifyIssueAttentionRetentionWork(transaction: object,
scope: { companyId: string; issueId?: string; parentId?: string | null; runId?: string; originKind?: string | null; originId?: string | null }) {
const database = transaction as Db;
const seeds = [scope.issueId, scope.parentId].filter((id): id is string => Boolean(id));
if (!seeds.length && scope.runId) {
const rows = await database.execute(sql`select issue_id::text as id from heartbeat_runs
where company_id = ${scope.companyId}::uuid and id = ${scope.runId}::uuid and issue_id is not null
union select native_issue_id::text from heartbeat_runs
where company_id = ${scope.companyId}::uuid and id = ${scope.runId}::uuid and native_issue_id is not null
union select issue.id::text from heartbeat_runs run inner join issues issue
on issue.company_id = run.company_id
and issue.id::text = coalesce(run.context_snapshot->>'issueId', run.context_snapshot->>'taskId')
where run.company_id = ${scope.companyId}::uuid and run.id = ${scope.runId}::uuid`);
for (const row of rows) seeds.push(row.id as string);
}
if (!seeds.length) return notifyAttentionRetentionWork(transaction, { companyId: scope.companyId });
const origins = await database.execute(sql`select id::text as id, origin_kind as "originKind", origin_id as "originId" from issues
where company_id = ${scope.companyId}::uuid
and id in (${sql.join([...new Set(seeds)].map(id => sql`${id}::uuid`), sql`, `)})`);
const current = origins.find(source => source.id === scope.issueId);
origins.push({ originKind: scope.originKind ?? current?.originKind, originId: scope.originId ?? current?.originId });
const references: string[] = [];
for (const source of origins) {
if (source.originKind === RECOVERY_ORIGIN_KINDS.strandedIssueRecovery && typeof source.originId === "string") {
references.push(source.originId);
}
const incident = source.originKind === RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation
? parseIssueGraphLivenessIncidentKey(source.originId as string | null) : null;
if (incident?.companyId === scope.companyId) references.push(incident.issueId, incident.leafIssueId);
}
if (references.length) {
const trusted = await database.execute(sql`select id::text as id from issues
where company_id = ${scope.companyId}::uuid
and id::text in (${sql.join([...new Set(references)].map(id => sql`${id}`), sql`, `)})`);
for (const row of trusted) seeds.push(row.id as string);
}
// UNION deduplicates visited UUIDs (including cycles). Each relation edge is
// unique by schema, so 101 neighbours guarantee overflow of a 100-ID scope.
// Bound that adjacency before materialization and stream at most 101 unique
// IDs to the caller; an inner UNION DISTINCT would consume the full fan-in.
const affected = await database.execute(sql`with recursive affected(id) as (
select unnest(array[${sql.join([...new Set(seeds)].map(id => sql`${id}::uuid`), sql`, `)}])
union
select parent.id from affected current cross join lateral (
(select related_issue_id as id from issue_relations where company_id = ${scope.companyId}::uuid
and issue_id = current.id and type = 'blocks' limit ${ATTENTION_RETENTION_MAX_ISSUE_IDS + 1})
union all select parent_id from issues where company_id = ${scope.companyId}::uuid
and id = current.id and parent_id is not null
) parent
) select id::text as "issueId" from affected limit ${ATTENTION_RETENTION_MAX_ISSUE_IDS + 1}`);
// The sentinel means the affected set is incomplete. Reconstruct the whole
// company through the existing paged authority instead of dropping ancestors.
await notifyAttentionRetentionWork(transaction, { companyId: scope.companyId,
issueIds: affected.length <= ATTENTION_RETENTION_MAX_ISSUE_IDS ? affected.map(row => row.issueId as string) : undefined,
});
}
export async function notifyRetentionWork(transaction: object, kind: AttentionSourceKind, scope: { companyId?: string; sourceId?: string } = {}) {
if (kind === "approval") await notifyDeliveryWork(transaction, DELIVERY_QUEUES.approvalRetention);
if (kind === "join_request") await notifyDeliveryWork(transaction, DELIVERY_QUEUES.joinRequestRetention);
if (kind !== "approval" && kind !== "join_request") await notifyAttentionRetentionWork(transaction, { ...scope, sourceKind: kind,
sourceId: kind === "blocker_attention" ? undefined : scope.sourceId });
}
export async function notifyCompanyRetentionWork(transaction: object, companyId: string) {
await notifyAttentionRetentionWork(transaction, { companyId });
await notifyApprovalWork(transaction, companyId);
await notifyRetentionWork(transaction, "join_request");
}
@@ -1,3 +1,4 @@
import { notifyAttentionRetentionWork } from "./retention-work-notifications.js";
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
import { notifyUnsafeWorkspaceExportRecovery } from "./unsafe-workspace-export-notifications.js";
@@ -127,6 +128,7 @@ export async function settleExternalConversation(db: Db, companyId: string, issu
const ready = await readSettlement(tx, companyId, issueId, true);
if (!ready) return false;
const { binding, issue, run } = ready;
await notifyAttentionRetentionWork(tx, { companyId });
const changed = await tx.update(chatConversations).set({ state: "waiting", updatedAt: new Date() })
.where(and(eq(chatConversations.id, binding.conversation.id), eq(chatConversations.companyId, companyId), eq(chatConversations.state, "active")))
.returning({ id: chatConversations.id });
@@ -1,3 +1,4 @@
import { notifyAttentionRetentionWork } from "./retention-work-notifications.js";
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
import { notifyExternalConversationSettlement } from "./external-conversation-settlement-notifications.js";
@@ -46,6 +47,7 @@ export function nonIdleSlackIssueCondition() {
/** Called inside message admission while its transaction owns the issue lock. */
export async function resumeExternalConversation(tx: Db, companyId: string, issueId: string) {
await notifyAttentionRetentionWork(tx, { companyId });
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
await notifyExternalConversationSettlement(tx, { companyId, issueId });
// Only the verified idle convention may leave In Review automatically.
+8 -1
View File
@@ -1,3 +1,4 @@
import { notifyAttentionRetentionWork, notifyIssueAttentionRetentionWork } from "./retention-work-notifications.js";
import { notifyNativeResultCommit } from "./native-result-commit-notifications.js";
import { notifyNativeWorkspaceFinalization } from "./native-workspace-finalization-notifications.js";
import { notifyNativeStatusReassessment } from "./native-status-reassessment-notifications.js";
@@ -480,8 +481,14 @@ export function workspaceOperationService(db: Db) {
async recordOperation(recordInput) {
async function writeState<T>(write: (database: Db) => Promise<T>): Promise<T> {
if (recordInput.phase !== "workspace_finalize" || !input.heartbeatRunId) return write(db);
if (recordInput.phase !== "workspace_finalize" || (!input.heartbeatRunId && !executionWorkspaceId && !input.issueId)) return write(db);
return db.transaction(async tx => {
if (!input.heartbeatRunId) {
// An export clears barriers for every issue sharing this workspace.
if (executionWorkspaceId) await notifyAttentionRetentionWork(tx, { companyId: input.companyId, sourceKind: "blocker_attention" });
else await notifyIssueAttentionRetentionWork(tx, { companyId: input.companyId, issueId: input.issueId ?? undefined });
return write(tx as unknown as Db);
}
await notifyNativeResultCommit(tx, { companyId: input.companyId,
runId: input.heartbeatRunId!, issueId: input.issueId ?? undefined });
await notifyNativeWorkspaceFinalization(tx, { companyId: input.companyId,