mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 08:28:48 +01:00
Merge qualified sleep review fixes and prerequisites
Co-Authored-By: Paperclip <noreply@paperclip.ing> * commit '74970cc21e2e5e1881516228bc7c7dbbba881a63': fix(retention): wake blocker retention after no-run workspace export Bound blocker workspace history reads by candidate workspace Validate eligibility before first attention retention materialization fix(retention): exclude finalized blockers before graph bounds test: hold lifecycle verification before retry identity assertions Exercise saved legacy input through its eligibility producer
This commit is contained in:
commit
2d59dd1ba3
11 files changed
+645
-135
No files matched your search
@@ -235,7 +235,11 @@ export function attentionRetentionService(db: Db) {
|
||||
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 attention = evidence ? evidence.blockers.get(row.id) : (await issueService(database).listBlockerAttention(candidate.companyId, [row])).get(row.id);
|
||||
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
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { isProspectiveBlockedTransition } from "./routable-blocked.js";
|
||||
import { 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";
|
||||
@@ -184,17 +188,56 @@ export function decisionRetentionService(
|
||||
)).then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function materializeSourceState(database: Db, input: { companyId: string; sourceKind: AttentionSourceKind; sourceId: string }) {
|
||||
async function materializeSourceState(database: Db, input: { companyId: string; sourceKind: AttentionSourceKind; sourceId: string; 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",
|
||||
};
|
||||
if (input.sourceKind === "blocker_attention") {
|
||||
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 === "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");
|
||||
const activityAt = source.unblockDescriptor?.owner === "board" && isProspectiveBlockedTransition(source)
|
||||
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(),
|
||||
|
||||
+107
-113
@@ -2520,124 +2520,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 rows: Array<{ issueId: string | null; executionWorkspaceId: string | null;
|
||||
phase: string; status: string; startedAt: Date }> = [];
|
||||
for (const chunk of chunkList(executionWorkspaceIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
|
||||
if (lockSources) {
|
||||
|
||||
|
||||
const pairs = [...new Map(blockerWorkspacePairs.map(pair => [`${pair.blockerIssueId}:${pair.executionWorkspaceId}`, pair])).values()];
|
||||
if (lockSources) {
|
||||
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 });
|
||||
}
|
||||
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,
|
||||
chunk,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
rows.push(...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,
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -3885,12 +3848,8 @@ async function readIssueBlockerAttentionBatch(
|
||||
BLOCKER_ATTENTION_MAX_NODES * 2 - edgeCount);
|
||||
if (remaining <= 0) break;
|
||||
const rowLimit = remaining + 1;
|
||||
const explicitBlockerRowsPromise: Promise<
|
||||
IssueBlockerAttentionQueryRow[]
|
||||
> = dbOrTx
|
||||
const explicitBlocker = dbOrTx
|
||||
.select({
|
||||
issueId: issueRelations.relatedIssueId,
|
||||
blockerIssueId: issues.id,
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
parentId: issues.parentId,
|
||||
@@ -3902,14 +3861,49 @@ async function readIssueBlockerAttentionBatch(
|
||||
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
|
||||
|
||||
@@ -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