mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 02:32:07 +01:00
Merge commit 'e4b1ed83e4c4b029a4f6f82e3537e1129076814e' into codex/sleep-09-aggregate
* commit 'e4b1ed83e4c4b029a4f6f82e3537e1129076814e': fix: bound status-card active-hours calculation work fix: scope comment-triggered recovery to the affected issue test: await issue recovery dispatch and cancellation settlement fix(server): bound blocker facts and preserve origin authority test: await committed recovery and adapter admission outcomes test: model retention reads in native cleanup transaction fixture
This commit is contained in:
commit
fa54edbcd3
22 files changed
+601
-106
No files matched your search
@@ -32,6 +32,7 @@ type PageEvidence = {
|
||||
agents: Set<string>;
|
||||
visibleIssues: Map<string, PageIssue>;
|
||||
newerRuns: Set<string>;
|
||||
deferredRoots: Set<string>;
|
||||
blockers: Awaited<ReturnType<ReturnType<typeof issueService>["listBlockerAttention"]>>;
|
||||
};
|
||||
|
||||
@@ -184,7 +185,9 @@ export function attentionRetentionService(db: Db) {
|
||||
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 blockers: PageEvidence["blockers"] = blockerRoots.length ? await issueService(database).listBlockerAttention(companyId, blockerRoots) : new Map();
|
||||
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);
|
||||
@@ -192,13 +195,17 @@ export function attentionRetentionService(db: Db) {
|
||||
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,
|
||||
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;
|
||||
|
||||
@@ -9,7 +9,8 @@ type WriteDb = Db | Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
export function insertIssueComment(db: WriteDb, input: PgInsertValue<typeof issueComments> & { companyId: string }) {
|
||||
return db.transaction(async tx => {
|
||||
await notifyStatusCardIssueChange(tx, input.companyId);
|
||||
await notifyIssueRecovery(tx, { companyId: input.companyId });
|
||||
await notifyIssueRecovery(tx, { companyId: input.companyId,
|
||||
issueId: typeof input.issueId === "string" ? input.issueId : undefined });
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.dotEvents, `company:${input.companyId}`);
|
||||
return tx.insert(issueComments).values(input).returning();
|
||||
});
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
|
||||
export type IssueRecoveryScope = { companyId: string };
|
||||
/** An issue hint is reserved for local facts such as comments. Lifecycle,
|
||||
* graph, run and eligibility changes retain company scope because their
|
||||
* consequences can include ancestors and shared execution references. */
|
||||
export type IssueRecoveryScope = { companyId: string; issueId?: string };
|
||||
|
||||
export async function notifyIssueRecovery(transaction: object, scope?: IssueRecoveryScope) {
|
||||
const hint = scope ? JSON.stringify({ companyId: scope.companyId }) : "*";
|
||||
const companyHint = scope ? JSON.stringify({ companyId: scope.companyId }) : "*";
|
||||
const issueHint = scope ? JSON.stringify({ companyId: scope.companyId, issueId: scope.issueId }) : "*";
|
||||
for (const queue of [DELIVERY_QUEUES.orphanBlockers, DELIVERY_QUEUES.activeRecoveryActions, DELIVERY_QUEUES.strandedAssignments, DELIVERY_QUEUES.resolvedDependencies,
|
||||
DELIVERY_QUEUES.taskWatchdogs, DELIVERY_QUEUES.staleIssueLocks]) {
|
||||
await notifyDeliveryWork(transaction, queue, hint);
|
||||
await notifyDeliveryWork(transaction, queue,
|
||||
queue === DELIVERY_QUEUES.strandedAssignments || queue === DELIVERY_QUEUES.staleIssueLocks ? issueHint : companyHint);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -3443,6 +3443,7 @@ type IssueBlockerAttentionEdge = {
|
||||
blockerIssueId: string;
|
||||
};
|
||||
type IssueBlockerAttentionQueryRow = IssueBlockerAttentionNode & {
|
||||
executionWorkspaceId?: string | null;
|
||||
issueId: string | null;
|
||||
blockerIssueId: string;
|
||||
};
|
||||
@@ -3831,11 +3832,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",
|
||||
);
|
||||
@@ -3845,37 +3850,38 @@ 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 });
|
||||
const admittedRoots = roots.slice(0, BLOCKER_ATTENTION_MAX_NODES);
|
||||
for (const root of admittedRoots) nodesById.set(root.id, { ...root });
|
||||
|
||||
type Traversal = { nodes: Set<string>; expanded: Set<string>; frontier: string[]; truncated: boolean };
|
||||
const traversals = new Map<string, Traversal>(roots.map(root => [root.id, {
|
||||
nodes: new Set([root.id]), expanded: new Set<string>(), frontier: [root.id], 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; 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 frontier = [...new Set(active.flatMap(traversal => traversal.frontier))].filter(id => !expanded.has(id));
|
||||
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(
|
||||
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 remaining = Math.min(BLOCKER_ATTENTION_MAX_NODES - nodesById.size,
|
||||
BLOCKER_ATTENTION_MAX_NODES * 2 - edgeCount);
|
||||
if (remaining <= 0) break;
|
||||
const rowLimit = remaining + 1;
|
||||
const explicitBlockerRowsPromise: Promise<
|
||||
IssueBlockerAttentionQueryRow[]
|
||||
> = dbOrTx
|
||||
@@ -3889,6 +3895,7 @@ async function listIssueBlockerAttentionMap(
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
executionRunId: issues.executionRunId,
|
||||
executionWorkspaceId: issues.executionWorkspaceId,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
})
|
||||
@@ -3901,7 +3908,7 @@ async function listIssueBlockerAttentionMap(
|
||||
inArray(issueRelations.relatedIssueId, chunk),
|
||||
eq(issues.companyId, companyId),
|
||||
),
|
||||
);
|
||||
).limit(rowLimit);
|
||||
const childRowsPromise: Promise<IssueBlockerAttentionQueryRow[]> = dbOrTx
|
||||
.select({
|
||||
issueId: issues.parentId,
|
||||
@@ -3926,56 +3933,56 @@ 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,
|
||||
});
|
||||
if (!row.issueId) continue;
|
||||
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++;
|
||||
}
|
||||
for (const id of chunk) expanded.add(id);
|
||||
if (complete) for (const id of chunk) expanded.add(id);
|
||||
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -4555,7 +4562,7 @@ async function listIssueBlockerAttentionMap(
|
||||
);
|
||||
}
|
||||
|
||||
return attentionMap;
|
||||
return result();
|
||||
}
|
||||
|
||||
type IssueReviewAttentionInput = Pick<IssueRow, "id" | "companyId" | "status">;
|
||||
@@ -9393,6 +9400,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[],
|
||||
|
||||
@@ -4302,6 +4302,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 =
|
||||
|
||||
@@ -8,7 +8,7 @@ export type NativeStatusReassessmentScope = { companyId: string; issueId?: strin
|
||||
export async function notifyNativeStatusReassessment(tx: object, scope: NativeStatusReassessmentScope) {
|
||||
await notifyStatusCardIssueChange(tx, scope.companyId);
|
||||
await notifyIssueAttentionRetentionWork(tx, { companyId: scope.companyId, issueId: scope.issueId, runId: scope.runId });
|
||||
await notifyIssueRecovery(tx, scope);
|
||||
await notifyIssueRecovery(tx, scope ? { companyId: scope.companyId } : undefined);
|
||||
return notifyDeliveryWork(tx, DELIVERY_QUEUES.nativeStatusReassessment,
|
||||
JSON.stringify({ companyId: scope.companyId, issueId: scope.issueId, runId: scope.runId, agentId: scope.agentId }));
|
||||
}
|
||||
@@ -32,13 +32,14 @@ export function registerIssueRecoveryWork(
|
||||
reconstructionIntervalMs: 30_000,
|
||||
beforePass: async () => { enabled = !(await owner.schedulingSuppressed()); },
|
||||
decodeScope: hint => hint === "*" ? undefined : JSON.parse(hint) as IssueRecoveryScope,
|
||||
matches: (entry, scope) => !scope || entry.companyId === scope.companyId,
|
||||
matches: (entry, scope) => !scope || (entry.companyId === scope.companyId && (!scope.issueId || entry.id === scope.issueId)),
|
||||
load: async scope => {
|
||||
const key = scope?.companyId ?? "*";
|
||||
const key = scope ? JSON.stringify([scope.companyId, scope.issueId]) : "*";
|
||||
if (!enabled) { cursors.delete(key); return []; }
|
||||
const cursor = cursors.get(key);
|
||||
const rows = await db.select({ id: issues.id, companyId: issues.companyId }).from(issues).where(and(
|
||||
scope ? eq(issues.companyId, scope.companyId) : undefined,
|
||||
scope?.issueId ? eq(issues.id, scope.issueId) : undefined,
|
||||
cursor ? gt(issues.id, cursor) : undefined,
|
||||
statuses ? inArray(issues.status, statuses) : or(isNotNull(issues.checkoutRunId), isNotNull(issues.executionRunId)),
|
||||
statuses ? isNull(issues.hiddenAt) : undefined,
|
||||
|
||||
@@ -20,9 +20,10 @@ export async function notifyIssueAttentionRetentionWork(transaction: object,
|
||||
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 coalesce(context_snapshot->>'issueId', context_snapshot->>'taskId') from heartbeat_runs
|
||||
where company_id = ${scope.companyId}::uuid and id = ${scope.runId}::uuid
|
||||
and coalesce(context_snapshot->>'issueId', context_snapshot->>'taskId') 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 });
|
||||
@@ -31,10 +32,20 @@ export async function notifyIssueAttentionRetentionWork(transaction: object,
|
||||
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) seeds.push(incident.issueId, incident.leafIssueId);
|
||||
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);
|
||||
}
|
||||
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`, `)}])
|
||||
|
||||
@@ -4,7 +4,7 @@ import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notificatio
|
||||
export type RunDispatchScope = { companyId: string };
|
||||
|
||||
export async function notifyRunDispatch(transaction: object, scope?: RunDispatchScope) {
|
||||
await notifyIssueRecovery(transaction, scope);
|
||||
await notifyIssueRecovery(transaction, scope ? { companyId: scope.companyId } : undefined);
|
||||
const hint = scope ? JSON.stringify({ companyId: scope.companyId }) : "*";
|
||||
await notifyDeliveryWork(transaction, DELIVERY_QUEUES.scheduledRetryDispatch, hint);
|
||||
await notifyDeliveryWork(transaction, DELIVERY_QUEUES.queuedRunDispatch, hint);
|
||||
|
||||
@@ -109,21 +109,36 @@ export function statusCardFingerprintHash(fingerprint: StatusCardFingerprint) {
|
||||
return createHash("sha256").update(JSON.stringify(stable)).digest("hex");
|
||||
}
|
||||
|
||||
// These caches contain only timezone/policy calculations, never card or company
|
||||
// data. Bound both maps because timezone aliases and hour pairs are user input.
|
||||
const activeHoursFormatters = new Map<string, Intl.DateTimeFormat>();
|
||||
const upcomingActiveHours = new Map<string, { closedAt: number; opensAt: number }>();
|
||||
|
||||
function remember<K, V>(cache: Map<K, V>, key: K, value: V, limit: number): V {
|
||||
cache.delete(key);
|
||||
cache.set(key, value);
|
||||
if (cache.size > limit) cache.delete(cache.keys().next().value!);
|
||||
return value;
|
||||
}
|
||||
|
||||
function activeHoursFormatter(timezone: string) {
|
||||
const cached = activeHoursFormatters.get(timezone);
|
||||
return remember(activeHoursFormatters, timezone, cached ?? new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: timezone, hour: "2-digit", minute: "2-digit", hourCycle: "h23",
|
||||
}), 32);
|
||||
}
|
||||
|
||||
export function isWithinStatusCardActiveHours(policy: StatusCardRefreshPolicy, now: Date) {
|
||||
if (!policy.activeHours) return true;
|
||||
const parts = new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: policy.activeHours.timezone,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).formatToParts(now);
|
||||
const hour = Number(parts.find((part) => part.type === "hour")?.value ?? 0);
|
||||
const minute = Number(parts.find((part) => part.type === "minute")?.value ?? 0);
|
||||
const current = hour * 60 + minute;
|
||||
const [startHour, startMinute] = policy.activeHours.start.split(":").map(Number);
|
||||
const [endHour, endMinute] = policy.activeHours.end.split(":").map(Number);
|
||||
const start = startHour! * 60 + startMinute!;
|
||||
const end = endHour! * 60 + endMinute!;
|
||||
if (start === end) return false;
|
||||
const parts = activeHoursFormatter(policy.activeHours.timezone).formatToParts(now);
|
||||
const hour = Number(parts.find((part) => part.type === "hour")?.value ?? 0);
|
||||
const minute = Number(parts.find((part) => part.type === "minute")?.value ?? 0);
|
||||
const current = hour * 60 + minute;
|
||||
return start <= end ? current >= start && current < end : current >= start || current < end;
|
||||
}
|
||||
|
||||
@@ -181,6 +196,8 @@ export function nextStatusCardPolicyEligibility(input: {
|
||||
tokensToday: number;
|
||||
}): Date | null {
|
||||
if (input.policy.mode === "manual") return null;
|
||||
const hours = input.policy.activeHours;
|
||||
if (hours && hours.start === hours.end) return null;
|
||||
let at = input.now.getTime();
|
||||
if (input.tokensToday >= (input.policy.dailyTokenCap ?? 100_000)) {
|
||||
const day = new Date(input.now);
|
||||
@@ -194,13 +211,25 @@ export function nextStatusCardPolicyEligibility(input: {
|
||||
.filter(time => time > at - 3_600_000).sort((a, b) => a - b);
|
||||
if (recent.length >= cap) at = Math.max(at, recent[recent.length - cap]! + 3_600_000 + 1);
|
||||
}
|
||||
if (isWithinStatusCardActiveHours(input.policy, new Date(at))) return new Date(at);
|
||||
// Check the precise instant before reusing a minute-aligned opening. Some
|
||||
// historical timezone offsets include seconds, so a window may be open now.
|
||||
if (!hours || isWithinStatusCardActiveHours(input.policy, new Date(at))) return new Date(at);
|
||||
const key = JSON.stringify([hours.timezone, hours.start, hours.end]);
|
||||
const cached = upcomingActiveHours.get(key);
|
||||
if (cached && at >= cached.closedAt && at < cached.opensAt) {
|
||||
remember(upcomingActiveHours, key, cached, 256);
|
||||
return new Date(cached.opensAt);
|
||||
}
|
||||
const closedAt = at;
|
||||
// Active-hour precision is one local minute. Walking UTC minutes also honors
|
||||
// skipped/repeated local times at daylight-saving transitions.
|
||||
const end = at + 48 * 3_600_000;
|
||||
for (at = Math.floor(at / 60_000) * 60_000 + 60_000; at <= end; at += 60_000) {
|
||||
if (isWithinStatusCardActiveHours(input.policy, new Date(at))) return new Date(at);
|
||||
if (isWithinStatusCardActiveHours(input.policy, new Date(at))) {
|
||||
remember(upcomingActiveHours, key, { closedAt, opensAt: at }, 256);
|
||||
return new Date(at);
|
||||
}
|
||||
}
|
||||
// Equal start/end describes an empty window; retain changes, awaiting config.
|
||||
// No opening inside the existing bounded search horizon. Retain changes.
|
||||
return null;
|
||||
}
|
||||
Reference in new issue
Block a user