mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 17:51:58 +01:00
Integrate routine status with scoped execution and source notifications
Keep all owner subscriptions, restore issue identity, and remove only the replaced routine and retention sweeps. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
bc3559eb5e
23 files changed
+696
-67
No files matched your search
@@ -118,6 +118,7 @@ export const DELIVERY_QUEUES = {
|
||||
pluginLifecycle: "plugin-resource-lifecycle",
|
||||
toolAction: "tool-action-receipts",
|
||||
terminalWorkspaces: "terminal-workspaces",
|
||||
routineExecutionStatus: "routine-execution-status",
|
||||
routineSchedules: "routine-schedules",
|
||||
issueMonitors: "issue-monitors",
|
||||
agentTimers: "agent-timers",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { notifyAttentionRetentionWork } from "./retention-work-notifications.js";
|
||||
import { notifyRunDispatch } from "./run-dispatch-notifications.js";
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { notifyRoutineExecutionStatus } from "./routine-execution-status-notifications.js";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork, type DeliveryQueue } from "./delivery-work-notifications.js";
|
||||
|
||||
@@ -12,6 +13,7 @@ export async function notifyExecutionRunWork(transaction: object, scope?: Execut
|
||||
await notifyRunDispatch(transaction, scope);
|
||||
await notifyStatusCardIssueChange(transaction, scope.companyId);
|
||||
}
|
||||
if (scope) await notifyRoutineExecutionStatus(transaction, scope.issueId, scope.companyId);
|
||||
for (const queue of [DELIVERY_QUEUES.executionStatus, DELIVERY_QUEUES.executionFinalization,
|
||||
DELIVERY_QUEUES.executionReplacement, DELIVERY_QUEUES.executionDisposition, DELIVERY_QUEUES.issueMonitors]) {
|
||||
await notifyDeliveryWork(transaction, queue);
|
||||
@@ -28,6 +30,7 @@ export function writeExecutionControl<T>(
|
||||
await notifyRunDispatch(transaction, scope);
|
||||
if (scope) await notifyStatusCardIssueChange(transaction, scope.companyId);
|
||||
if (scope) await notifyAttentionRetentionWork(transaction, scope);
|
||||
if (scope) await notifyRoutineExecutionStatus(transaction, scope.issueId, scope.companyId);
|
||||
await notifyDeliveryWork(transaction, DELIVERY_QUEUES.issueMonitors);
|
||||
for (const topic of typeof queue === "string" ? [queue] : queue) await notifyDeliveryWork(transaction, topic);
|
||||
return write(transaction as unknown as Db);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyRoutineExecutionStatus } from "./routine-execution-status-notifications.js";
|
||||
import { notifyNativeStatusReassessment } from "./native-status-reassessment-notifications.js";
|
||||
import { notifyUnsafeWorkspaceExportRecovery } from "./unsafe-workspace-export-notifications.js";
|
||||
import { notifyChatResponseAvailability } from "./chat-response-availability.js";
|
||||
@@ -596,6 +597,7 @@ export async function settleUnrecoverableExecutions(
|
||||
if (task.status !== "blocked") await notifyChatResponseAvailability(tx, { companyId: task.companyId, issueId: task.id });
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
|
||||
await notifyRoutineExecutionStatus(tx, task.id, task.companyId);
|
||||
const [projected] = await tx
|
||||
.update(issues)
|
||||
.set({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { notifyRunDispatch } from "../run-dispatch-notifications.js";
|
||||
import { notifyStatusCardIssueChange } from "../status-card-notifications.js";
|
||||
import { notifyRoutineExecutionStatus } from "../routine-execution-status-notifications.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "../delivery-work-notifications.js";
|
||||
import { notifyExternalAgentSettlement } from "../../modules/external-agents/index.js";
|
||||
import { notifyNativeStatusReassessment } from "../native-status-reassessment-notifications.js";
|
||||
@@ -3924,6 +3925,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
|
||||
if (issue.status !== "blocked") await notifyChatResponseAvailability(tx, { companyId: issue.companyId, issueId: issue.id });
|
||||
await notifyRoutineExecutionStatus(tx, issue.id, issue.companyId);
|
||||
await tx
|
||||
.update(issues)
|
||||
.set({
|
||||
|
||||
@@ -1045,7 +1045,8 @@ export function createHeartbeatLifecycle(db: Db, dependencies: HeartbeatLifecycl
|
||||
if (previous && isHeartbeatRunTerminalStatus(status))
|
||||
await notifyRetainedNativeSessionCleanup(tx, { companyId: previous.companyId, runId: previous.id });
|
||||
if (isHeartbeatRunTerminalStatus(status)) await notifyDeliveryWork(tx, DELIVERY_QUEUES.accounting, companyId);
|
||||
await notifyExecutionRunWork(tx, companyId ? { companyId } : undefined);
|
||||
await notifyExecutionRunWork(tx, previous ? { companyId: previous.companyId, runId: previous.id,
|
||||
issueId: previous.nativeIssueId ?? previous.issueId ?? readNonEmptyString(parseObject(previous.contextSnapshot).issueId) ?? undefined } : undefined);
|
||||
if (previous) await notifyChatRunMilestones(tx, { companyId: previous.companyId, runId: previous.id,
|
||||
issueId: previous.nativeIssueId ?? previous.issueId ?? readNonEmptyString(parseObject(previous.contextSnapshot).issueId) ?? undefined });
|
||||
return write(tx);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { notifyRunDispatch } from "./run-dispatch-notifications.js";
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { notifyAttentionRetentionWork } from "./retention-work-notifications.js";
|
||||
import { notifyRoutineExecutionStatus } from "./routine-execution-status-notifications.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
import { notifyExternalConversationSettlement } from "./external-conversation-settlement-notifications.js";
|
||||
import { notifyNativeReviewResponse } from "./native-review-response-notifications.js";
|
||||
@@ -10,6 +11,7 @@ export async function notifyIssueReviewState(tx: object, scope: { companyId: str
|
||||
await notifyRunDispatch(tx, scope);
|
||||
await notifyStatusCardIssueChange(tx, scope.companyId);
|
||||
await notifyAttentionRetentionWork(tx, { companyId: scope.companyId });
|
||||
await notifyRoutineExecutionStatus(tx, scope.issueId, scope.companyId);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
await notifyExternalConversationSettlement(tx, scope);
|
||||
await notifyNativeReviewResponse(tx, scope);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { notifyRoutineExecutionStatus } from "./routine-execution-status-notifications.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
import { notifyNativeStatusReassessment } from "./native-status-reassessment-notifications.js";
|
||||
import { notifyUnsafeWorkspaceExportRecovery } from "./unsafe-workspace-export-notifications.js";
|
||||
@@ -1007,6 +1008,7 @@ export function issueTreeControlService(db: Db) {
|
||||
for (const [status, issueIdsForStatus] of issueIdsByStatus) {
|
||||
if (issueIdsForStatus.length === 0) continue;
|
||||
for (const issueId of issueIdsForStatus) {
|
||||
await notifyRoutineExecutionStatus(tx, issueId, companyId);
|
||||
await notifyNativeStatusReassessment(tx, { companyId, issueId });
|
||||
await notifyTerminalWorkspaceWork(tx, `issue:${issueId}`);
|
||||
await notifyChatResponseAvailability(tx, { companyId, issueId });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { notifyRunDispatch } from "./run-dispatch-notifications.js";
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { notifyAttentionRetentionWork } from "./retention-work-notifications.js";
|
||||
import { notifyRoutineExecutionStatus } from "./routine-execution-status-notifications.js";
|
||||
import { notifyExternalConversationSettlement } from "./external-conversation-settlement-notifications.js";
|
||||
import { notifyNativeRecoveryNotices } from "./native-recovery-notice-notifications.js";
|
||||
import { notifyNativeRunProjection } from "./native-run-projection-notifications.js";
|
||||
@@ -10620,6 +10621,7 @@ export function issueService(db: Db) {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
|
||||
await notifyStatusCardIssueChange(tx, companyId);
|
||||
await notifyAttentionRetentionWork(tx, { companyId });
|
||||
if (values.originKind === "routine_execution") await notifyRoutineExecutionStatus(tx, values.id, companyId);
|
||||
const [issue] = await tx.insert(issues).values(values).returning();
|
||||
await attachOwnedDraftImages(tx, issue, issue.description, { userId: issueData.createdByUserId, agentId: issueData.createdByAgentId });
|
||||
if (issue.visibility === "private" && !inheritedPrivateSubtree && issueData.createdByAgentId) {
|
||||
@@ -11513,6 +11515,7 @@ export function issueService(db: Db) {
|
||||
|| patch.hiddenAt !== undefined || patch.conversationAgentId !== undefined) {
|
||||
await notifyRunDispatch(tx, { companyId: receiptExisting.companyId });
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
|
||||
await notifyRoutineExecutionStatus(tx, id);
|
||||
}
|
||||
if (patch.monitorNextCheckAt !== undefined || (receiptExisting.monitorNextCheckAt &&
|
||||
(patch.status !== undefined || patch.assigneeAgentId !== undefined || patch.assigneeUserId !== undefined))) {
|
||||
@@ -11561,6 +11564,9 @@ export function issueService(db: Db) {
|
||||
await notifyNativeWorkspaceFinalization(tx, { companyId: receiptExisting.companyId, issueId: id });
|
||||
await notifyNativeStatusReassessment(tx, { companyId: receiptExisting.companyId, issueId: id });
|
||||
}
|
||||
if (patch.originKind !== undefined || patch.originRunId !== undefined || patch.originId !== undefined) {
|
||||
await notifyRoutineExecutionStatus(tx, id);
|
||||
}
|
||||
const updated = await tx
|
||||
.update(issues)
|
||||
.set(patch)
|
||||
@@ -11983,6 +11989,7 @@ export function issueService(db: Db) {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
await notifyRunDispatch(tx, owner ? { companyId: owner.companyId } : undefined);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
|
||||
await notifyRoutineExecutionStatus(tx, id);
|
||||
removedIssue = await tx
|
||||
.delete(issues)
|
||||
.where(eq(issues.id, id))
|
||||
@@ -12114,6 +12121,7 @@ export function issueService(db: Db) {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
await notifyRunDispatch(tx, { companyId: issueCompany.companyId });
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
|
||||
await notifyRoutineExecutionStatus(tx, id);
|
||||
return tx
|
||||
.update(issues)
|
||||
.set({
|
||||
@@ -12249,6 +12257,7 @@ export function issueService(db: Db) {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
await notifyRunDispatch(tx, { companyId: issueCompany.companyId });
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
|
||||
await notifyRoutineExecutionStatus(tx, id);
|
||||
return tx
|
||||
.update(issues)
|
||||
.set(adoptionSet)
|
||||
@@ -12506,6 +12515,7 @@ export function issueService(db: Db) {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
await notifyRunDispatch(tx, { companyId: existing.companyId });
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
|
||||
await notifyRoutineExecutionStatus(tx, id);
|
||||
const updated = await tx
|
||||
.update(issues)
|
||||
.set({
|
||||
@@ -12569,6 +12579,7 @@ export function issueService(db: Db) {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
await notifyRunDispatch(tx, { companyId: existing.companyId });
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
|
||||
await notifyRoutineExecutionStatus(tx, id);
|
||||
const updated = await tx
|
||||
.update(issues)
|
||||
.set(patch)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveExternalTestDatabaseUrl } from "../../__tests__/helpers/external-test-database.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
@@ -8,13 +9,14 @@ import { routineService } from "../routines.js";
|
||||
import { commitNativeStatusDecision } from "./status-decision-committer.js";
|
||||
import { NATIVE_STATUS_ARBITER_POLICY_VERSION, type NativeStatusDecision } from "./status-arbiter.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_NATIVE_ROUTINE_STATUS_TEST_DATABASE_URL");
|
||||
const support = external ? { supported: true, reason: undefined } : await getEmbeddedPostgresTestSupport();
|
||||
if (!support.supported) console.warn(`Skipping native routine status tests: ${support.reason}`);
|
||||
(support.supported ? describe : describe.skip)("native routine history status projection", () => {
|
||||
let db: ReturnType<typeof createDb>, database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
const companyId = randomUUID(), agentId = randomUUID();
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("native-routine-status-"); db = createDb(database.connectionString);
|
||||
if (!external) database = await startEmbeddedPostgresTestDatabase("native-routine-status-"); db = createDb(external ?? database.connectionString, { maxConnections: 6 });
|
||||
await db.insert(companies).values({ id: companyId, name: "Routine status fixture", issuePrefix: "NRS" });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Routine worker", adapterType: "paperclip_runner", status: "running" });
|
||||
}, 60_000);
|
||||
|
||||
@@ -38,7 +38,7 @@ describe("reconstruction after confirmed native workspace loss", () => {
|
||||
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_NATIVE_RECONSTRUCTION_TEST_DATABASE_URL");
|
||||
if (external) {
|
||||
await applyPendingMigrations(external);
|
||||
db = createDb(external);
|
||||
db = createDb(external, { maxConnections: 6 });
|
||||
return;
|
||||
}
|
||||
temporary = await startEmbeddedPostgresTestDatabase("workspace-reconstruction-");
|
||||
@@ -144,17 +144,21 @@ describe("reconstruction after confirmed native workspace loss", () => {
|
||||
expect(await queue.nextTimerHeartbeatAt(agent)).toBeNull();
|
||||
const events: string[] = [];
|
||||
const unsubscribe = subscribeDatabaseWork(db, DELIVERY_QUEUES.agentTimers, event => events.push(event));
|
||||
const routineEvents: string[] = [];
|
||||
const unsubscribeRoutine = subscribeDatabaseWork(db, DELIVERY_QUEUES.routineExecutionStatus, event => routineEvents.push(event));
|
||||
try {
|
||||
await db.transaction(async tx => {
|
||||
await reconstructNativeWorkspace({ ...f, db: tx as unknown as Db });
|
||||
expect(events).toContain("intent");
|
||||
expect(events).not.toContain("settled");
|
||||
expect(routineEvents).toContain("intent"); expect(routineEvents).not.toContain("settled");
|
||||
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0].status).toBe("blocked");
|
||||
});
|
||||
expect(events).toContain("settled");
|
||||
expect(routineEvents).toContain("settled");
|
||||
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0].status).toBe("todo");
|
||||
expect(await queue.nextTimerHeartbeatAt(agent)).toBe(agent.createdAt.getTime() + 600_000);
|
||||
} finally { unsubscribe(); }
|
||||
} finally { unsubscribe(); unsubscribeRoutine(); }
|
||||
});
|
||||
|
||||
it("pins a historical cancelled workspace through public heartbeat startup without replaying its turn", async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { notifyStatusCardIssueChange } from "../status-card-notifications.js";
|
||||
import { notifyAttentionRetentionWork } from "../retention-work-notifications.js";
|
||||
import { notifyRoutineExecutionStatus } from "../routine-execution-status-notifications.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "../delivery-work-notifications.js";
|
||||
import { notifyUnsafeWorkspaceExportRecovery } from "../unsafe-workspace-export-notifications.js";
|
||||
import { and, eq, gt, inArray, ne, notInArray, or, sql } from "drizzle-orm";
|
||||
@@ -191,6 +192,7 @@ export async function reconstructNativeWorkspace(input: {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
|
||||
await notifyStatusCardIssueChange(tx, input.companyId);
|
||||
await notifyRoutineExecutionStatus(tx, input.issueId, input.companyId);
|
||||
await tx.update(issues).set({ status: "todo", unblockDescriptor: null, blockedTransitionAt: null,
|
||||
blockedOwnerNotifiedAt: null, updatedAt: now }).where(eq(issues.id, issue.id));
|
||||
const publication = await persistActivity(db, { companyId: input.companyId, actorType: "user", actorId: input.actorId,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
|
||||
export type RoutineExecutionStatusScope = { issueId?: string; companyId?: string };
|
||||
|
||||
export function notifyRoutineExecutionStatus(tx: object, issueId?: string, companyId?: string) {
|
||||
return notifyDeliveryWork(tx, DELIVERY_QUEUES.routineExecutionStatus,
|
||||
issueId || companyId ? JSON.stringify({ issueId, companyId } satisfies RoutineExecutionStatusScope) : "*");
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
|
||||
import { DELIVERY_QUEUES } from "./delivery-work-notifications.js";
|
||||
import { registerScopedDeadlineWork } from "./scoped-deadline-work.js";
|
||||
import type { RoutineExecutionStatusScope } from "./routine-execution-status-notifications.js";
|
||||
import { ROUTINE_EXECUTION_STATUS_PAGE_SIZE, type routineExecutionStatusService } from "./routine-run-status.js";
|
||||
|
||||
export function registerRoutineExecutionStatusWork(
|
||||
coordinator: ReturnType<typeof createDeliveryWorkCoordinator>,
|
||||
service: ReturnType<typeof routineExecutionStatusService>,
|
||||
canRun: () => boolean,
|
||||
) {
|
||||
const cursors = new Map<string, string>();
|
||||
return registerScopedDeadlineWork(coordinator, {
|
||||
queue: DELIVERY_QUEUES.routineExecutionStatus,
|
||||
decodeScope: hint => hint === "*" ? undefined : JSON.parse(hint) as RoutineExecutionStatusScope,
|
||||
matches: (entry, scope?: RoutineExecutionStatusScope) => (!scope?.issueId || entry.issueId === scope.issueId)
|
||||
&& (!scope?.companyId || entry.companyId === scope.companyId),
|
||||
load: async (scope?: RoutineExecutionStatusScope) => {
|
||||
const key = JSON.stringify(scope) ?? "*";
|
||||
const rows = await service.load(scope, cursors.get(key));
|
||||
const complete = rows.length < ROUTINE_EXECUTION_STATUS_PAGE_SIZE;
|
||||
if (complete) cursors.delete(key); else cursors.set(key, rows.at(-1)!.id);
|
||||
return { rows: rows.map(row => ({ ...row, dueAt: Date.now() })), complete };
|
||||
},
|
||||
check: async entry => { await service.settle(entry); return null; },
|
||||
}, canRun);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { notifyRoutineExecutionStatus, type RoutineExecutionStatusScope } from "./routine-execution-status-notifications.js";
|
||||
import { and, eq, inArray, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { issues, routineRuns, routines } from "@paperclipai/db";
|
||||
|
||||
@@ -37,6 +38,7 @@ function legacyExecutionIssueTransientFailureStatus(
|
||||
}
|
||||
|
||||
export async function finalizeRoutineRun(executor: Db, runId: string, patch: Partial<typeof routineRuns.$inferInsert>) {
|
||||
if (patch.linkedIssueId) await notifyRoutineExecutionStatus(executor, patch.linkedIssueId);
|
||||
return executor
|
||||
.update(routineRuns)
|
||||
.set({
|
||||
@@ -49,6 +51,10 @@ export async function finalizeRoutineRun(executor: Db, runId: string, patch: Par
|
||||
}
|
||||
|
||||
export async function syncRoutineRunStatusForIssue(executor: Db, issueId: string) {
|
||||
return executor.transaction(tx => syncLockedRoutineRunStatusForIssue(tx as unknown as Db, issueId));
|
||||
}
|
||||
|
||||
async function syncLockedRoutineRunStatusForIssue(executor: Db, issueId: string) {
|
||||
const issue = await executor
|
||||
.select({
|
||||
id: issues.id,
|
||||
@@ -58,7 +64,8 @@ export async function syncRoutineRunStatusForIssue(executor: Db, issueId: string
|
||||
originRunId: issues.originRunId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.where(and(eq(issues.id, issueId), eq(issues.originKind, "routine_execution")))
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!issue || issue.originKind !== "routine_execution" || !issue.originRunId) return null;
|
||||
const run = await executor
|
||||
@@ -67,14 +74,17 @@ export async function syncRoutineRunStatusForIssue(executor: Db, issueId: string
|
||||
status: routineRuns.status,
|
||||
failureReason: routineRuns.failureReason,
|
||||
triggerPayload: routineRuns.triggerPayload,
|
||||
completedAt: routineRuns.completedAt,
|
||||
})
|
||||
.from(routineRuns)
|
||||
.innerJoin(routines, and(eq(routines.id, routineRuns.routineId), eq(routines.companyId, issue.companyId)))
|
||||
.where(and(eq(routineRuns.id, issue.originRunId), eq(routineRuns.companyId, issue.companyId),
|
||||
eq(routineRuns.linkedIssueId, issue.id)))
|
||||
.for("update", { of: routineRuns })
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!run) return null;
|
||||
if (!run || ["received", "skipped", "coalesced"].includes(run.status)) return null;
|
||||
if (issue.status === "done") {
|
||||
if (run.status === "completed" && run.failureReason === null && run.completedAt) return null;
|
||||
const transientFailureStatus = executionIssueTransientFailureStatusFromPayload(run.triggerPayload)
|
||||
?? legacyExecutionIssueTransientFailureStatus(run.failureReason);
|
||||
const transientFailureClearedAt = executionIssueTransientFailureClearedAtFromPayload(run.triggerPayload);
|
||||
@@ -99,6 +109,7 @@ export async function syncRoutineRunStatusForIssue(executor: Db, issueId: string
|
||||
}
|
||||
if (issue.status === "blocked" || issue.status === "cancelled") {
|
||||
const failureReason = executionIssueTransientFailureReason(issue.status);
|
||||
if (run.status === "failed" && run.failureReason === failureReason && run.completedAt) return null;
|
||||
return finalizeRoutineRun(executor, issue.originRunId, {
|
||||
status: "failed",
|
||||
failureReason,
|
||||
@@ -135,3 +146,57 @@ export async function syncRoutineRunStatusForIssue(executor: Db, issueId: string
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
export const ROUTINE_EXECUTION_STATUS_PAGE_SIZE = 50;
|
||||
|
||||
export function routineExecutionStatusDebtCondition() {
|
||||
const transientFailure = sql`((${routineRuns.triggerPayload} -> 'transientFailure' ->> 'code' = ${EXECUTION_ISSUE_TRANSIENT_FAILURE_CODE}
|
||||
and ${routineRuns.triggerPayload} -> 'transientFailure' ->> 'status' in ('blocked', 'cancelled'))
|
||||
or ${routineRuns.failureReason} in ('Execution issue moved to blocked', 'Execution issue moved to cancelled'))`;
|
||||
return or(
|
||||
and(eq(routineRuns.status, "issue_created"), inArray(issues.status, ["done", "blocked", "cancelled"])),
|
||||
and(eq(routineRuns.status, "completed"), or(inArray(issues.status, ["blocked", "cancelled"]),
|
||||
and(eq(issues.status, "done"), sql`(${routineRuns.completedAt} is null or ${routineRuns.failureReason} is not null)`))),
|
||||
and(eq(routineRuns.status, "failed"), transientFailure,
|
||||
sql`(${issues.status} not in ('blocked', 'cancelled')
|
||||
or ${routineRuns.completedAt} is null
|
||||
or ${routineRuns.failureReason} is distinct from 'Execution issue moved to ' || ${issues.status})`),
|
||||
);
|
||||
}
|
||||
|
||||
export function routineExecutionStatusService(db: Db) {
|
||||
async function load(scope?: RoutineExecutionStatusScope, afterId?: string) {
|
||||
return db.select({ id: routineRuns.id, companyId: routineRuns.companyId,
|
||||
issueId: issues.id, routineId: routineRuns.routineId })
|
||||
.from(routineRuns)
|
||||
.innerJoin(routines, and(eq(routines.id, routineRuns.routineId), eq(routines.companyId, routineRuns.companyId)))
|
||||
.innerJoin(issues, and(eq(issues.id, routineRuns.linkedIssueId), eq(issues.companyId, routineRuns.companyId),
|
||||
eq(issues.originKind, "routine_execution"), sql`${issues.originRunId} = ${routineRuns.id}::text`))
|
||||
.where(and(routineExecutionStatusDebtCondition(),
|
||||
...(scope?.issueId ? [eq(issues.id, scope.issueId)] : []),
|
||||
...(scope?.companyId ? [eq(routineRuns.companyId, scope.companyId)] : []),
|
||||
...(afterId ? [sql`${routineRuns.id} > ${afterId}::uuid`] : [])))
|
||||
.orderBy(routineRuns.id).limit(ROUTINE_EXECUTION_STATUS_PAGE_SIZE);
|
||||
}
|
||||
async function settle(candidate: Awaited<ReturnType<typeof load>>[number]) {
|
||||
return db.transaction(async tx => {
|
||||
await tx.execute(sql`SET LOCAL lock_timeout = '500ms'`);
|
||||
await tx.execute(sql`SET LOCAL statement_timeout = '5s'`);
|
||||
const [routine] = await tx.select({ id: routines.id }).from(routines).where(and(
|
||||
eq(routines.id, candidate.routineId), eq(routines.companyId, candidate.companyId))).for("share");
|
||||
if (!routine) return false;
|
||||
const [issue] = await tx.select().from(issues).where(and(eq(issues.id, candidate.issueId),
|
||||
eq(issues.companyId, candidate.companyId), eq(issues.originKind, "routine_execution"),
|
||||
eq(issues.originRunId, candidate.id))).for("update");
|
||||
if (!issue) return false;
|
||||
const [run] = await tx.select({ id: routineRuns.id }).from(routineRuns)
|
||||
.innerJoin(issues, eq(issues.id, routineRuns.linkedIssueId))
|
||||
.where(and(eq(routineRuns.id, candidate.id), eq(routineRuns.companyId, candidate.companyId),
|
||||
eq(routineRuns.routineId, candidate.routineId), eq(routineRuns.linkedIssueId, issue.id),
|
||||
routineExecutionStatusDebtCondition())).for("update", { of: routineRuns });
|
||||
if (!run) return false;
|
||||
return (await syncRoutineRunStatusForIssue(tx as unknown as Db, issue.id)) !== null;
|
||||
});
|
||||
}
|
||||
return { load, settle };
|
||||
}
|
||||
@@ -74,7 +74,7 @@ import { secretService } from "./secrets.js";
|
||||
import { getSecretProvider } from "../secrets/provider-registry.js";
|
||||
import { parseCron, validateCron } from "./cron.js";
|
||||
import { heartbeatService } from "./heartbeat.js";
|
||||
import { finalizeRoutineRun, syncRoutineRunStatusForIssue } from "./routine-run-status.js";
|
||||
import { finalizeRoutineRun, routineExecutionStatusService, syncRoutineRunStatusForIssue } from "./routine-run-status.js";
|
||||
import {
|
||||
instanceSettingsService,
|
||||
isTruthyRuntimeEnvValue,
|
||||
@@ -2003,7 +2003,8 @@ export function routineService(
|
||||
return run;
|
||||
}
|
||||
|
||||
let statusReconcileCursor: string | null = null;
|
||||
|
||||
let statusReconcileCursor: string | undefined;
|
||||
|
||||
async function syncRunStatusForIssue(issueId: string, executor: Db = db) {
|
||||
return syncRoutineRunStatusForIssue(executor, issueId);
|
||||
@@ -3247,41 +3248,14 @@ export function routineService(
|
||||
return { triggered };
|
||||
},
|
||||
|
||||
// Repair a missed issue-status hook through the same status transition as
|
||||
// normal issue writes. Never discard queued input or infer run completion
|
||||
// from age. Lock both owners and re-read the issue before applying repair.
|
||||
reconcileExecutionRunStatuses: async (): Promise<{ scanned: number; settled: number; deferred: number }> => {
|
||||
const candidates = await db.select({ id: routineRuns.id, companyId: routineRuns.companyId,
|
||||
issueId: issues.id, routineId: routineRuns.routineId })
|
||||
.from(routineRuns).innerJoin(routines, and(eq(routines.id, routineRuns.routineId), eq(routines.companyId, routineRuns.companyId)))
|
||||
.innerJoin(issues, and(eq(issues.id, routineRuns.linkedIssueId),
|
||||
eq(issues.companyId, routineRuns.companyId), eq(issues.originKind, "routine_execution"),
|
||||
sql`${issues.originRunId} = ${routineRuns.id}::text`, sql`${issues.originId} = ${routineRuns.routineId}::text`))
|
||||
.where(and(eq(routineRuns.status, "issue_created"), inArray(issues.status, ["done", "cancelled", "blocked"]),
|
||||
...(statusReconcileCursor ? [sql`${routineRuns.id}::text > ${statusReconcileCursor}`] : [])))
|
||||
.orderBy(asc(routineRuns.id)).limit(50);
|
||||
statusReconcileCursor = candidates.length === 50 ? candidates.at(-1)!.id : null;
|
||||
let settled = 0;
|
||||
let deferred = 0;
|
||||
const service = routineExecutionStatusService(db);
|
||||
const candidates = await service.load(undefined, statusReconcileCursor);
|
||||
statusReconcileCursor = candidates.length === 50 ? candidates.at(-1)!.id : undefined;
|
||||
let settled = 0, deferred = 0;
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const repaired = await db.transaction(async tx => {
|
||||
await tx.execute(sql`SET LOCAL lock_timeout = '500ms'`);
|
||||
await tx.execute(sql`SET LOCAL statement_timeout = '5s'`);
|
||||
const [routine] = await tx.select({ id: routines.id }).from(routines).where(and(
|
||||
eq(routines.id, candidate.routineId), eq(routines.companyId, candidate.companyId))).for("share");
|
||||
if (!routine) return false;
|
||||
const [issue] = await tx.select().from(issues).where(and(eq(issues.id, candidate.issueId),
|
||||
eq(issues.companyId, candidate.companyId), eq(issues.originKind, "routine_execution"),
|
||||
eq(issues.originRunId, candidate.id), eq(issues.originId, candidate.routineId))).for("update");
|
||||
if (!issue || !["done", "cancelled", "blocked"].includes(issue.status)) return false;
|
||||
const [run] = await tx.select().from(routineRuns).where(and(eq(routineRuns.id, candidate.id),
|
||||
eq(routineRuns.companyId, candidate.companyId), eq(routineRuns.routineId, candidate.routineId),
|
||||
eq(routineRuns.linkedIssueId, issue.id), eq(routineRuns.status, "issue_created"))).for("update");
|
||||
if (!run) return false;
|
||||
return (await syncRunStatusForIssue(issue.id, tx as unknown as Db)) !== null;
|
||||
});
|
||||
if (repaired) settled += 1;
|
||||
if (await service.settle(candidate)) settled++;
|
||||
} catch (error) {
|
||||
let current: unknown = error;
|
||||
let retryable = false;
|
||||
@@ -3291,7 +3265,7 @@ export function routineService(
|
||||
current = cause.cause;
|
||||
}
|
||||
if (!retryable) throw error;
|
||||
deferred += 1;
|
||||
deferred++;
|
||||
}
|
||||
}
|
||||
return { scanned: candidates.length, settled, deferred };
|
||||
|
||||
Reference in new issue
Block a user