mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 08:28:48 +01:00
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:
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 : {} }
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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 ?? ""));
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user