mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 02:00:52 +01:00
feat(routines): add latest-success lifecycle supersession
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
05b35d4669
commit
df07c3ca81
17 files changed
+40607
-6
No files matched your search
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE "issues" ADD COLUMN "superseded_by_issue_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "routine_runs" ADD COLUMN "superseded_by_run_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "routines" ADD COLUMN "lifecycle_policy" text DEFAULT 'independent' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD CONSTRAINT "issues_superseded_by_issue_id_issues_id_fk" FOREIGN KEY ("superseded_by_issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "routine_runs" ADD CONSTRAINT "routine_runs_superseded_by_run_id_routine_runs_id_fk" FOREIGN KEY ("superseded_by_run_id") REFERENCES "public"."routine_runs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "issues_superseded_by_issue_idx" ON "issues" USING btree ("superseded_by_issue_id");--> statement-breakpoint
|
||||
CREATE INDEX "routine_runs_superseded_by_run_idx" ON "routine_runs" USING btree ("superseded_by_run_id");
|
||||
+39697
File diff suppressed because it is too large.
Load diff
+7
@@ -1576,6 +1576,13 @@
|
||||
"when": 1787261199301,
|
||||
"tag": "0226_tan_colossus",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 227,
|
||||
"version": "7",
|
||||
"when": 1787470797345,
|
||||
"tag": "0227_zippy_blockbuster",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -50,6 +50,7 @@ export const issues = pgTable(
|
||||
originId: text("origin_id"),
|
||||
originRunId: text("origin_run_id"),
|
||||
originFingerprint: text("origin_fingerprint").notNull().default("default"),
|
||||
supersededByIssueId: uuid("superseded_by_issue_id").references((): AnyPgColumn => issues.id, { onDelete: "set null" }),
|
||||
requestDepth: integer("request_depth").notNull().default(0),
|
||||
billingCode: text("billing_code"),
|
||||
assigneeAdapterOverrides: jsonb("assignee_adapter_overrides").$type<Record<string, unknown>>(),
|
||||
@@ -93,6 +94,7 @@ export const issues = pgTable(
|
||||
parentIdx: index("issues_company_parent_idx").on(table.companyId, table.parentId),
|
||||
projectIdx: index("issues_company_project_idx").on(table.companyId, table.projectId),
|
||||
originIdx: index("issues_company_origin_idx").on(table.companyId, table.originKind, table.originId),
|
||||
supersededByIssueIdx: index("issues_superseded_by_issue_idx").on(table.supersededByIssueId),
|
||||
projectWorkspaceIdx: index("issues_company_project_workspace_idx").on(table.companyId, table.projectWorkspaceId),
|
||||
executionWorkspaceIdx: index("issues_company_execution_workspace_idx").on(table.companyId, table.executionWorkspaceId),
|
||||
dueMonitorIdx: index("issues_company_monitor_due_idx").on(table.companyId, table.monitorNextCheckAt),
|
||||
|
||||
@@ -35,6 +35,7 @@ export const routines = pgTable(
|
||||
priority: text("priority").notNull().default("medium"),
|
||||
status: text("status").notNull().default("active"),
|
||||
concurrencyPolicy: text("concurrency_policy").notNull().default("coalesce_if_active"),
|
||||
lifecyclePolicy: text("lifecycle_policy").notNull().default("independent"),
|
||||
catchUpPolicy: text("catch_up_policy").notNull().default("skip_missed"),
|
||||
activityGatePolicy: text("activity_gate_policy").notNull().default("always"),
|
||||
activityGateScope: text("activity_gate_scope").notNull().default("company"),
|
||||
@@ -155,6 +156,10 @@ export const routineRuns = pgTable(
|
||||
dispatchFingerprint: text("dispatch_fingerprint"),
|
||||
linkedIssueId: uuid("linked_issue_id").references(() => issues.id, { onDelete: "set null" }),
|
||||
coalescedIntoRunId: uuid("coalesced_into_run_id"),
|
||||
supersededByRunId: uuid("superseded_by_run_id").references(
|
||||
(): AnyPgColumn => routineRuns.id,
|
||||
{ onDelete: "set null" },
|
||||
),
|
||||
failureReason: text("failure_reason"),
|
||||
completedAt: timestamp("completed_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
@@ -171,6 +176,7 @@ export const routineRuns = pgTable(
|
||||
triggerIdx: index("routine_runs_trigger_idx").on(table.triggerId, table.createdAt),
|
||||
dispatchFingerprintIdx: index("routine_runs_dispatch_fingerprint_idx").on(table.routineId, table.dispatchFingerprint),
|
||||
linkedIssueIdx: index("routine_runs_linked_issue_idx").on(table.linkedIssueId),
|
||||
supersededByRunIdx: index("routine_runs_superseded_by_run_idx").on(table.supersededByRunId),
|
||||
idempotencyIdx: index("routine_runs_trigger_idempotency_idx").on(table.triggerId, table.idempotencyKey),
|
||||
}),
|
||||
);
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
RoutineActivityGateScope,
|
||||
RoutineCatchUpPolicy,
|
||||
RoutineConcurrencyPolicy,
|
||||
RoutineLifecyclePolicy,
|
||||
RoutineStatus,
|
||||
RoutineTriggerKind,
|
||||
RoutineTriggerSigningMode,
|
||||
@@ -53,6 +54,7 @@ export interface RoutineIssueSummary {
|
||||
title: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
supersededByIssueId?: string | null;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@@ -82,6 +84,7 @@ export interface Routine {
|
||||
priority: string;
|
||||
status: string;
|
||||
concurrencyPolicy: string;
|
||||
lifecyclePolicy: string;
|
||||
catchUpPolicy: string;
|
||||
activityGatePolicy: string;
|
||||
activityGateScope: string;
|
||||
@@ -127,6 +130,7 @@ export interface RoutineRevisionSnapshotRoutineV1 {
|
||||
priority: IssuePriority;
|
||||
status: RoutineStatus;
|
||||
concurrencyPolicy: RoutineConcurrencyPolicy;
|
||||
lifecyclePolicy: RoutineLifecyclePolicy;
|
||||
catchUpPolicy: RoutineCatchUpPolicy;
|
||||
activityGatePolicy: RoutineActivityGatePolicy;
|
||||
activityGateScope: RoutineActivityGateScope;
|
||||
@@ -212,6 +216,7 @@ export interface RoutineRun {
|
||||
dispatchFingerprint: string | null;
|
||||
linkedIssueId: string | null;
|
||||
coalescedIntoRunId: string | null;
|
||||
supersededByRunId: string | null;
|
||||
failureReason: string | null;
|
||||
completedAt: Date | null;
|
||||
createdAt: Date;
|
||||
|
||||
@@ -45,6 +45,7 @@ describe("routine validators", () => {
|
||||
expect(parsed.triggers[0]?.publicId).toBe("routine_webhook_123");
|
||||
expect(parsed.routine.activityGatePolicy).toBe("always");
|
||||
expect(parsed.routine.activityGateScope).toBe("company");
|
||||
expect(parsed.routine.lifecyclePolicy).toBe("independent");
|
||||
});
|
||||
|
||||
it("rejects secret-bearing trigger fields in routine revision snapshots", () => {
|
||||
@@ -100,6 +101,13 @@ describe("routine validators", () => {
|
||||
expect(() => updateRoutineSchema.parse({ activityGateScope: "agent" })).toThrow();
|
||||
});
|
||||
|
||||
it("validates explicit routine instance lifecycle values", () => {
|
||||
expect(updateRoutineSchema.parse({ lifecyclePolicy: "latest_success_wins" })).toEqual({
|
||||
lifecyclePolicy: "latest_success_wins",
|
||||
});
|
||||
expect(() => updateRoutineSchema.parse({ lifecyclePolicy: "infer_from_concurrency" })).toThrow();
|
||||
});
|
||||
|
||||
it("accepts date variables with valid YYYY-MM-DD defaults", () => {
|
||||
expect(routineVariableSchema.parse({
|
||||
name: "startDate",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ROUTINE_ACTIVITY_GATE_SCOPES,
|
||||
ROUTINE_CATCH_UP_POLICIES,
|
||||
ROUTINE_CONCURRENCY_POLICIES,
|
||||
ROUTINE_LIFECYCLE_POLICIES,
|
||||
ROUTINE_STATUSES,
|
||||
ROUTINE_TRIGGER_KINDS,
|
||||
ROUTINE_TRIGGER_SIGNING_MODES,
|
||||
@@ -73,6 +74,7 @@ export const createRoutineSchema = z.object({
|
||||
priority: z.enum(ISSUE_PRIORITIES).optional().default("medium"),
|
||||
status: z.enum(ROUTINE_STATUSES).optional().default("active"),
|
||||
concurrencyPolicy: z.enum(ROUTINE_CONCURRENCY_POLICIES).optional().default("coalesce_if_active"),
|
||||
lifecyclePolicy: z.enum(ROUTINE_LIFECYCLE_POLICIES).optional().default("independent"),
|
||||
catchUpPolicy: z.enum(ROUTINE_CATCH_UP_POLICIES).optional().default("skip_missed"),
|
||||
activityGatePolicy: z.enum(ROUTINE_ACTIVITY_GATE_POLICIES).optional(),
|
||||
activityGateScope: z.enum(ROUTINE_ACTIVITY_GATE_SCOPES).optional(),
|
||||
@@ -100,6 +102,7 @@ export const routineRevisionSnapshotRoutineV1Schema = z.object({
|
||||
priority: z.enum(ISSUE_PRIORITIES),
|
||||
status: z.enum(ROUTINE_STATUSES),
|
||||
concurrencyPolicy: z.enum(ROUTINE_CONCURRENCY_POLICIES),
|
||||
lifecyclePolicy: z.enum(ROUTINE_LIFECYCLE_POLICIES).default("independent"),
|
||||
catchUpPolicy: z.enum(ROUTINE_CATCH_UP_POLICIES),
|
||||
activityGatePolicy: z.enum(ROUTINE_ACTIVITY_GATE_POLICIES).default("always"),
|
||||
activityGateScope: z.enum(ROUTINE_ACTIVITY_GATE_SCOPES).default("company"),
|
||||
|
||||
@@ -309,6 +309,7 @@ describe("company portability", () => {
|
||||
priority: input.priority ?? "medium",
|
||||
status: input.status ?? "active",
|
||||
concurrencyPolicy: input.concurrencyPolicy ?? "coalesce_if_active",
|
||||
lifecyclePolicy: input.lifecyclePolicy ?? "independent",
|
||||
catchUpPolicy: input.catchUpPolicy ?? "skip_missed",
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
@@ -2265,6 +2266,7 @@ describe("company portability", () => {
|
||||
priority: "high",
|
||||
status: "paused",
|
||||
concurrencyPolicy: "always_enqueue",
|
||||
lifecyclePolicy: "latest_success_wins",
|
||||
catchUpPolicy: "enqueue_missed_with_cap",
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
@@ -2355,6 +2357,7 @@ describe("company portability", () => {
|
||||
priority: "high",
|
||||
routine: expect.objectContaining({
|
||||
concurrencyPolicy: "always_enqueue",
|
||||
lifecyclePolicy: "latest_success_wins",
|
||||
catchUpPolicy: "enqueue_missed_with_cap",
|
||||
triggers: expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "schedule", cronExpression: "0 9 * * 1", timezone: "America/Chicago" }),
|
||||
@@ -2542,6 +2545,7 @@ describe("company portability", () => {
|
||||
' status: "paused"',
|
||||
' priority: "high"',
|
||||
' concurrencyPolicy: "always_enqueue"',
|
||||
' lifecyclePolicy: "latest_success_wins"',
|
||||
' catchUpPolicy: "enqueue_missed_with_cap"',
|
||||
" triggers:",
|
||||
" - kind: schedule",
|
||||
@@ -2586,6 +2590,7 @@ describe("company portability", () => {
|
||||
priority: "high",
|
||||
status: "paused",
|
||||
concurrencyPolicy: "always_enqueue",
|
||||
lifecyclePolicy: "latest_success_wins",
|
||||
catchUpPolicy: "enqueue_missed_with_cap",
|
||||
}), expect.any(Object));
|
||||
expect(result.warnings).not.toContain(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHmac, randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
@@ -13,9 +13,11 @@ import {
|
||||
documents,
|
||||
executionWorkspaces,
|
||||
folders,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
instanceSettings,
|
||||
issueInboxArchives,
|
||||
issueRelations,
|
||||
issues,
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
@@ -74,6 +76,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||
await db.delete(documentRevisions);
|
||||
await db.delete(companySecretVersions);
|
||||
await db.delete(companySecrets);
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(issues);
|
||||
await db.delete(executionWorkspaces);
|
||||
@@ -239,6 +242,452 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||
.then((rows) => rows[0]!);
|
||||
}
|
||||
|
||||
async function createExecutionInstance(input: {
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
routine: Awaited<ReturnType<ReturnType<typeof routineService>["create"]>>;
|
||||
issueSvc: ReturnType<typeof issueService>;
|
||||
triggeredAt: Date;
|
||||
routineRevisionId: string;
|
||||
status?: "todo" | "in_progress" | "blocked";
|
||||
withLiveHeartbeat?: boolean;
|
||||
}) {
|
||||
const runId = randomUUID();
|
||||
const issue = await input.issueSvc.create(input.companyId, {
|
||||
projectId: input.routine.projectId,
|
||||
title: input.routine.title,
|
||||
description: input.routine.description,
|
||||
status: input.status ?? "todo",
|
||||
priority: input.routine.priority,
|
||||
assigneeAgentId: input.routine.assigneeAgentId,
|
||||
originKind: "routine_execution",
|
||||
originId: input.routine.id,
|
||||
originRunId: runId,
|
||||
});
|
||||
await db.insert(routineRuns).values({
|
||||
id: runId,
|
||||
companyId: input.companyId,
|
||||
routineId: input.routine.id,
|
||||
routineRevisionId: input.routineRevisionId,
|
||||
source: "manual",
|
||||
status: "issue_created",
|
||||
triggeredAt: input.triggeredAt,
|
||||
linkedIssueId: issue.id,
|
||||
});
|
||||
|
||||
let heartbeatRunId: string | null = null;
|
||||
if (input.withLiveHeartbeat) {
|
||||
heartbeatRunId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: heartbeatRunId,
|
||||
companyId: input.companyId,
|
||||
agentId: input.agentId,
|
||||
invocationSource: "assignment",
|
||||
triggerDetail: "routine",
|
||||
status: "running",
|
||||
contextSnapshot: { issueId: issue.id },
|
||||
startedAt: input.triggeredAt,
|
||||
});
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
checkoutRunId: heartbeatRunId,
|
||||
executionRunId: heartbeatRunId,
|
||||
executionLockedAt: input.triggeredAt,
|
||||
})
|
||||
.where(eq(issues.id, issue.id));
|
||||
}
|
||||
|
||||
return { issue, runId, heartbeatRunId };
|
||||
}
|
||||
|
||||
it("supersedes and links every older unfinished instance after an opted-in newer success", async () => {
|
||||
const { agentId, companyId, issueSvc, routine, svc } = await seedFixture();
|
||||
const optedInRoutine = await svc.update(routine.id, { lifecyclePolicy: "latest_success_wins" }, {});
|
||||
expect(optedInRoutine?.latestRevisionId).toBeTruthy();
|
||||
const revisionId = optedInRoutine!.latestRevisionId!;
|
||||
const first = await createExecutionInstance({
|
||||
companyId,
|
||||
agentId,
|
||||
routine: optedInRoutine!,
|
||||
issueSvc,
|
||||
triggeredAt: new Date("2026-08-23T10:00:00.000Z"),
|
||||
routineRevisionId: revisionId,
|
||||
});
|
||||
const second = await createExecutionInstance({
|
||||
companyId,
|
||||
agentId,
|
||||
routine: optedInRoutine!,
|
||||
issueSvc,
|
||||
triggeredAt: new Date("2026-08-23T10:30:00.000Z"),
|
||||
routineRevisionId: revisionId,
|
||||
status: "in_progress",
|
||||
withLiveHeartbeat: true,
|
||||
});
|
||||
const winner = await createExecutionInstance({
|
||||
companyId,
|
||||
agentId,
|
||||
routine: optedInRoutine!,
|
||||
issueSvc,
|
||||
triggeredAt: new Date("2026-08-23T11:00:00.000Z"),
|
||||
routineRevisionId: revisionId,
|
||||
});
|
||||
|
||||
await issueSvc.update(winner.issue.id, { status: "done" });
|
||||
await svc.syncRunStatusForIssue(winner.issue.id);
|
||||
|
||||
const supersededIssues = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(inArray(issues.id, [first.issue.id, second.issue.id]));
|
||||
expect(supersededIssues).toHaveLength(2);
|
||||
expect(supersededIssues).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: first.issue.id,
|
||||
status: "cancelled",
|
||||
supersededByIssueId: winner.issue.id,
|
||||
cancelledAt: expect.any(Date),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: second.issue.id,
|
||||
status: "cancelled",
|
||||
supersededByIssueId: winner.issue.id,
|
||||
executionRunId: null,
|
||||
checkoutRunId: null,
|
||||
cancelledAt: expect.any(Date),
|
||||
}),
|
||||
]));
|
||||
|
||||
const runRows = await db
|
||||
.select()
|
||||
.from(routineRuns)
|
||||
.where(inArray(routineRuns.id, [first.runId, second.runId, winner.runId]));
|
||||
expect(runRows).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: first.runId, status: "superseded", supersededByRunId: winner.runId }),
|
||||
expect.objectContaining({ id: second.runId, status: "superseded", supersededByRunId: winner.runId }),
|
||||
expect.objectContaining({ id: winner.runId, status: "completed", supersededByRunId: null }),
|
||||
]));
|
||||
|
||||
const heartbeat = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, second.heartbeatRunId!))
|
||||
.then((rows) => rows[0]);
|
||||
expect(heartbeat).toMatchObject({ status: "cancelled", errorCode: "routine_instance_superseded" });
|
||||
|
||||
const activities = await db
|
||||
.select()
|
||||
.from(activityLog)
|
||||
.where(eq(activityLog.action, "issue.superseded"));
|
||||
expect(activities).toHaveLength(2);
|
||||
expect(activities).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
companyId,
|
||||
entityId: first.issue.id,
|
||||
details: expect.objectContaining({
|
||||
routineId: routine.id,
|
||||
supersededRunId: first.runId,
|
||||
supersedingIssueId: winner.issue.id,
|
||||
supersedingRunId: winner.runId,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({ entityId: second.issue.id }),
|
||||
]));
|
||||
await expect(
|
||||
db.select().from(activityLog).where(eq(activityLog.action, "issue.supersession_deferred")),
|
||||
).resolves.toHaveLength(0);
|
||||
|
||||
const listed = await svc.listRuns(routine.id);
|
||||
expect(listed.find((run) => run.id === first.runId)).toMatchObject({
|
||||
status: "superseded",
|
||||
supersededByRunId: winner.runId,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an older instance with a live grandchild while superseding instances without live descendants", async () => {
|
||||
const { agentId, companyId, issueSvc, routine, svc } = await seedFixture();
|
||||
const optedInRoutine = await svc.update(routine.id, { lifecyclePolicy: "latest_success_wins" }, {});
|
||||
const revisionId = optedInRoutine!.latestRevisionId!;
|
||||
const protectedInstance = await createExecutionInstance({
|
||||
companyId,
|
||||
agentId,
|
||||
routine: optedInRoutine!,
|
||||
issueSvc,
|
||||
triggeredAt: new Date("2026-08-23T09:00:00.000Z"),
|
||||
routineRevisionId: revisionId,
|
||||
status: "blocked",
|
||||
});
|
||||
const terminalChild = await issueSvc.create(companyId, {
|
||||
projectId: routine.projectId,
|
||||
parentId: protectedInstance.issue.id,
|
||||
title: "Delegated work container",
|
||||
status: "done",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
const liveGrandchild = await issueSvc.create(companyId, {
|
||||
projectId: routine.projectId,
|
||||
parentId: terminalChild.id,
|
||||
title: "In-review delegated work",
|
||||
status: "in_review",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
const terminalChildDepthThree = await issueSvc.create(companyId, {
|
||||
projectId: routine.projectId,
|
||||
parentId: protectedInstance.issue.id,
|
||||
title: "Deeper delegated work container",
|
||||
status: "done",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
const terminalGrandchildDepthThree = await issueSvc.create(companyId, {
|
||||
projectId: routine.projectId,
|
||||
parentId: terminalChildDepthThree.id,
|
||||
title: "Nested delegated work container",
|
||||
status: "done",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
const liveGreatGrandchild = await issueSvc.create(companyId, {
|
||||
projectId: routine.projectId,
|
||||
parentId: terminalGrandchildDepthThree.id,
|
||||
title: "In-progress deeply delegated work",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
const terminalOnlyInstance = await createExecutionInstance({
|
||||
companyId,
|
||||
agentId,
|
||||
routine: optedInRoutine!,
|
||||
issueSvc,
|
||||
triggeredAt: new Date("2026-08-23T09:30:00.000Z"),
|
||||
routineRevisionId: revisionId,
|
||||
});
|
||||
const terminalOnlyChild = await issueSvc.create(companyId, {
|
||||
projectId: routine.projectId,
|
||||
parentId: terminalOnlyInstance.issue.id,
|
||||
title: "Completed delegated work",
|
||||
status: "done",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
const unprotectedInstance = await createExecutionInstance({
|
||||
companyId,
|
||||
agentId,
|
||||
routine: optedInRoutine!,
|
||||
issueSvc,
|
||||
triggeredAt: new Date("2026-08-23T10:00:00.000Z"),
|
||||
routineRevisionId: revisionId,
|
||||
});
|
||||
const winner = await createExecutionInstance({
|
||||
companyId,
|
||||
agentId,
|
||||
routine: optedInRoutine!,
|
||||
issueSvc,
|
||||
triggeredAt: new Date("2026-08-23T11:00:00.000Z"),
|
||||
routineRevisionId: revisionId,
|
||||
});
|
||||
|
||||
// The fixture intentionally has no blocker edges or populated child projection.
|
||||
// Only the parent_id chain connects the protected instance to its live work.
|
||||
await expect(db.select().from(issueRelations)).resolves.toHaveLength(0);
|
||||
|
||||
await issueSvc.update(winner.issue.id, { status: "done" });
|
||||
await svc.syncRunStatusForIssue(winner.issue.id);
|
||||
await svc.syncRunStatusForIssue(winner.issue.id);
|
||||
|
||||
await expect(issueSvc.getById(protectedInstance.issue.id)).resolves.toMatchObject({
|
||||
status: "blocked",
|
||||
supersededByIssueId: null,
|
||||
});
|
||||
await expect(issueSvc.getById(terminalChild.id)).resolves.toMatchObject({ status: "done" });
|
||||
await expect(issueSvc.getById(liveGrandchild.id)).resolves.toMatchObject({ status: "in_review" });
|
||||
await expect(issueSvc.getById(terminalChildDepthThree.id)).resolves.toMatchObject({ status: "done" });
|
||||
await expect(issueSvc.getById(terminalGrandchildDepthThree.id)).resolves.toMatchObject({ status: "done" });
|
||||
await expect(issueSvc.getById(liveGreatGrandchild.id)).resolves.toMatchObject({ status: "in_progress" });
|
||||
await expect(db.select().from(routineRuns).where(eq(routineRuns.id, protectedInstance.runId))).resolves.toEqual([
|
||||
expect.objectContaining({ status: "issue_created", supersededByRunId: null }),
|
||||
]);
|
||||
|
||||
await expect(issueSvc.getById(terminalOnlyInstance.issue.id)).resolves.toMatchObject({
|
||||
status: "cancelled",
|
||||
supersededByIssueId: winner.issue.id,
|
||||
});
|
||||
await expect(issueSvc.getById(terminalOnlyChild.id)).resolves.toMatchObject({ status: "done" });
|
||||
await expect(db.select().from(routineRuns).where(eq(routineRuns.id, terminalOnlyInstance.runId))).resolves.toEqual([
|
||||
expect.objectContaining({ status: "superseded", supersededByRunId: winner.runId }),
|
||||
]);
|
||||
|
||||
await expect(issueSvc.getById(unprotectedInstance.issue.id)).resolves.toMatchObject({
|
||||
status: "cancelled",
|
||||
supersededByIssueId: winner.issue.id,
|
||||
});
|
||||
await expect(db.select().from(routineRuns).where(eq(routineRuns.id, unprotectedInstance.runId))).resolves.toEqual([
|
||||
expect.objectContaining({ status: "superseded", supersededByRunId: winner.runId }),
|
||||
]);
|
||||
|
||||
const deferredActivities = await db
|
||||
.select()
|
||||
.from(activityLog)
|
||||
.where(eq(activityLog.action, "issue.supersession_deferred"));
|
||||
expect(deferredActivities).toHaveLength(1);
|
||||
expect(deferredActivities[0]).toMatchObject({
|
||||
companyId,
|
||||
entityId: protectedInstance.issue.id,
|
||||
details: expect.objectContaining({
|
||||
routineId: routine.id,
|
||||
deferredRunId: protectedInstance.runId,
|
||||
supersedingIssueId: winner.issue.id,
|
||||
supersedingRunId: winner.runId,
|
||||
descendantIssueIds: expect.arrayContaining([liveGrandchild.id, liveGreatGrandchild.id]),
|
||||
}),
|
||||
});
|
||||
const deferredDetails = deferredActivities[0]!.details as { descendantIssueIds: string[] };
|
||||
expect(deferredDetails.descendantIssueIds).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not supersede older work after blocked or cancelled newer executions", async () => {
|
||||
const { agentId, companyId, issueSvc, routine, svc } = await seedFixture();
|
||||
const optedInRoutine = await svc.update(routine.id, { lifecyclePolicy: "latest_success_wins" }, {});
|
||||
const revisionId = optedInRoutine!.latestRevisionId!;
|
||||
const older = await createExecutionInstance({
|
||||
companyId,
|
||||
agentId,
|
||||
routine: optedInRoutine!,
|
||||
issueSvc,
|
||||
triggeredAt: new Date("2026-08-23T10:00:00.000Z"),
|
||||
routineRevisionId: revisionId,
|
||||
});
|
||||
|
||||
for (const [index, status] of (["blocked", "cancelled"] as const).entries()) {
|
||||
const newer = await createExecutionInstance({
|
||||
companyId,
|
||||
agentId,
|
||||
routine: optedInRoutine!,
|
||||
issueSvc,
|
||||
triggeredAt: new Date(`2026-08-23T1${index + 1}:00:00.000Z`),
|
||||
routineRevisionId: revisionId,
|
||||
});
|
||||
await issueSvc.update(newer.issue.id, { status });
|
||||
await svc.syncRunStatusForIssue(newer.issue.id);
|
||||
const newerRun = await db.select().from(routineRuns).where(eq(routineRuns.id, newer.runId)).then((rows) => rows[0]);
|
||||
expect(newerRun.status).toBe("failed");
|
||||
}
|
||||
|
||||
const olderIssue = await issueSvc.getById(older.issue.id);
|
||||
const olderRun = await db.select().from(routineRuns).where(eq(routineRuns.id, older.runId)).then((rows) => rows[0]);
|
||||
expect(olderIssue).toMatchObject({ status: "todo", supersededByIssueId: null });
|
||||
expect(olderRun).toMatchObject({ status: "issue_created", supersededByRunId: null });
|
||||
await expect(db.select().from(activityLog).where(eq(activityLog.action, "issue.superseded"))).resolves.toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps execution instances independent by default", async () => {
|
||||
const { agentId, companyId, issueSvc, routine, svc } = await seedFixture();
|
||||
const revisionId = routine.latestRevisionId!;
|
||||
const older = await createExecutionInstance({
|
||||
companyId,
|
||||
agentId,
|
||||
routine,
|
||||
issueSvc,
|
||||
triggeredAt: new Date("2026-08-23T10:00:00.000Z"),
|
||||
routineRevisionId: revisionId,
|
||||
});
|
||||
const newer = await createExecutionInstance({
|
||||
companyId,
|
||||
agentId,
|
||||
routine,
|
||||
issueSvc,
|
||||
triggeredAt: new Date("2026-08-23T11:00:00.000Z"),
|
||||
routineRevisionId: revisionId,
|
||||
});
|
||||
|
||||
await svc.update(routine.id, { lifecyclePolicy: "latest_success_wins" }, {});
|
||||
|
||||
await issueSvc.update(newer.issue.id, { status: "done" });
|
||||
await svc.syncRunStatusForIssue(newer.issue.id);
|
||||
|
||||
await expect(issueSvc.getById(older.issue.id)).resolves.toMatchObject({
|
||||
status: "todo",
|
||||
supersededByIssueId: null,
|
||||
});
|
||||
await expect(db.select().from(routineRuns).where(eq(routineRuns.id, older.runId))).resolves.toEqual([
|
||||
expect.objectContaining({ status: "issue_created", supersededByRunId: null }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("converges concurrent successes on the newest trigger order without cross-company effects", async () => {
|
||||
const firstCompany = await seedFixture();
|
||||
const secondCompany = await seedFixture();
|
||||
const firstRoutine = await firstCompany.svc.update(
|
||||
firstCompany.routine.id,
|
||||
{ lifecyclePolicy: "latest_success_wins" },
|
||||
{},
|
||||
);
|
||||
const secondRoutine = await secondCompany.svc.update(
|
||||
secondCompany.routine.id,
|
||||
{ lifecyclePolicy: "latest_success_wins" },
|
||||
{},
|
||||
);
|
||||
const firstRevisionId = firstRoutine!.latestRevisionId!;
|
||||
const secondRevisionId = secondRoutine!.latestRevisionId!;
|
||||
const oldest = await createExecutionInstance({
|
||||
companyId: firstCompany.companyId,
|
||||
agentId: firstCompany.agentId,
|
||||
routine: firstRoutine!,
|
||||
issueSvc: firstCompany.issueSvc,
|
||||
triggeredAt: new Date("2026-08-23T10:00:00.000Z"),
|
||||
routineRevisionId: firstRevisionId,
|
||||
});
|
||||
const middle = await createExecutionInstance({
|
||||
companyId: firstCompany.companyId,
|
||||
agentId: firstCompany.agentId,
|
||||
routine: firstRoutine!,
|
||||
issueSvc: firstCompany.issueSvc,
|
||||
triggeredAt: new Date("2026-08-23T11:00:00.000Z"),
|
||||
routineRevisionId: firstRevisionId,
|
||||
});
|
||||
const newest = await createExecutionInstance({
|
||||
companyId: firstCompany.companyId,
|
||||
agentId: firstCompany.agentId,
|
||||
routine: firstRoutine!,
|
||||
issueSvc: firstCompany.issueSvc,
|
||||
triggeredAt: new Date("2026-08-23T12:00:00.000Z"),
|
||||
routineRevisionId: firstRevisionId,
|
||||
});
|
||||
const otherCompanyOlder = await createExecutionInstance({
|
||||
companyId: secondCompany.companyId,
|
||||
agentId: secondCompany.agentId,
|
||||
routine: secondRoutine!,
|
||||
issueSvc: secondCompany.issueSvc,
|
||||
triggeredAt: new Date("2026-08-23T09:00:00.000Z"),
|
||||
routineRevisionId: secondRevisionId,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
firstCompany.issueSvc.update(middle.issue.id, { status: "done" }),
|
||||
firstCompany.issueSvc.update(newest.issue.id, { status: "done" }),
|
||||
]);
|
||||
await Promise.all([
|
||||
firstCompany.svc.syncRunStatusForIssue(middle.issue.id),
|
||||
firstCompany.svc.syncRunStatusForIssue(newest.issue.id),
|
||||
]);
|
||||
await firstCompany.svc.syncRunStatusForIssue(middle.issue.id);
|
||||
|
||||
await expect(firstCompany.issueSvc.getById(oldest.issue.id)).resolves.toMatchObject({
|
||||
status: "cancelled",
|
||||
supersededByIssueId: newest.issue.id,
|
||||
});
|
||||
await expect(db.select().from(routineRuns).where(eq(routineRuns.id, oldest.runId))).resolves.toEqual([
|
||||
expect.objectContaining({ status: "superseded", supersededByRunId: newest.runId }),
|
||||
]);
|
||||
await expect(secondCompany.issueSvc.getById(otherCompanyOlder.issue.id)).resolves.toMatchObject({
|
||||
status: "todo",
|
||||
supersededByIssueId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears transient routine run failures when execution issues resume", async () => {
|
||||
const { companyId, issueSvc, routine, svc } = await seedFixture();
|
||||
const runId = randomUUID();
|
||||
|
||||
@@ -2376,6 +2376,7 @@ function toCompactIssue(issue: any): CompactIssue {
|
||||
originKind: issue.originKind,
|
||||
originId: issue.originId,
|
||||
originRunId: issue.originRunId,
|
||||
supersededByIssueId: issue.supersededByIssueId,
|
||||
requestDepth: issue.requestDepth,
|
||||
billingCode: issue.billingCode,
|
||||
executionWorkspaceId: issue.executionWorkspaceId,
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
PROJECT_STATUSES,
|
||||
ROUTINE_CATCH_UP_POLICIES,
|
||||
ROUTINE_CONCURRENCY_POLICIES,
|
||||
ROUTINE_LIFECYCLE_POLICIES,
|
||||
ROUTINE_STATUSES,
|
||||
ROUTINE_TRIGGER_KINDS,
|
||||
ROUTINE_TRIGGER_SIGNING_MODES,
|
||||
@@ -1231,6 +1232,7 @@ function normalizeRoutineExtension(value: unknown): CompanyPortabilityIssueRouti
|
||||
: null;
|
||||
const routine = {
|
||||
concurrencyPolicy: asString(value.concurrencyPolicy),
|
||||
lifecyclePolicy: asString(value.lifecyclePolicy),
|
||||
catchUpPolicy: asString(value.catchUpPolicy),
|
||||
variables,
|
||||
triggers,
|
||||
@@ -1241,6 +1243,7 @@ function normalizeRoutineExtension(value: unknown): CompanyPortabilityIssueRouti
|
||||
function buildRoutineManifestFromLiveRoutine(routine: RoutineLike): CompanyPortabilityIssueRoutineManifestEntry {
|
||||
return {
|
||||
concurrencyPolicy: routine.concurrencyPolicy,
|
||||
lifecyclePolicy: routine.lifecyclePolicy,
|
||||
catchUpPolicy: routine.catchUpPolicy,
|
||||
variables: routine.variables,
|
||||
triggers: routine.triggers.map((trigger) => ({
|
||||
@@ -1751,12 +1754,14 @@ function resolvePortableRoutineDefinition(
|
||||
const routine = issue.routine
|
||||
? {
|
||||
concurrencyPolicy: issue.routine.concurrencyPolicy,
|
||||
lifecyclePolicy: issue.routine.lifecyclePolicy,
|
||||
catchUpPolicy: issue.routine.catchUpPolicy,
|
||||
variables: issue.routine.variables ?? null,
|
||||
triggers: [...issue.routine.triggers],
|
||||
}
|
||||
: {
|
||||
concurrencyPolicy: null,
|
||||
lifecyclePolicy: null,
|
||||
catchUpPolicy: null,
|
||||
variables: null,
|
||||
triggers: [] as CompanyPortabilityIssueRoutineTriggerManifestEntry[],
|
||||
@@ -1765,6 +1770,9 @@ function resolvePortableRoutineDefinition(
|
||||
if (routine.concurrencyPolicy && !ROUTINE_CONCURRENCY_POLICIES.includes(routine.concurrencyPolicy as any)) {
|
||||
errors.push(`Recurring task ${issue.slug} uses unsupported routine concurrencyPolicy "${routine.concurrencyPolicy}".`);
|
||||
}
|
||||
if (routine.lifecyclePolicy && !ROUTINE_LIFECYCLE_POLICIES.includes(routine.lifecyclePolicy as any)) {
|
||||
errors.push(`Recurring task ${issue.slug} uses unsupported routine lifecyclePolicy "${routine.lifecyclePolicy}".`);
|
||||
}
|
||||
if (routine.catchUpPolicy && !ROUTINE_CATCH_UP_POLICIES.includes(routine.catchUpPolicy as any)) {
|
||||
errors.push(`Recurring task ${issue.slug} uses unsupported routine catchUpPolicy "${routine.catchUpPolicy}".`);
|
||||
}
|
||||
@@ -4548,6 +4556,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
status: routine.status !== "active" ? routine.status : undefined,
|
||||
priority: routine.priority !== "medium" ? routine.priority : undefined,
|
||||
concurrencyPolicy: routine.concurrencyPolicy !== "coalesce_if_active" ? routine.concurrencyPolicy : undefined,
|
||||
lifecyclePolicy: routine.lifecyclePolicy !== "independent" ? routine.lifecyclePolicy : undefined,
|
||||
catchUpPolicy: routine.catchUpPolicy !== "skip_missed" ? routine.catchUpPolicy : undefined,
|
||||
variables: (routine.variables ?? []).length > 0 ? routine.variables : undefined,
|
||||
triggers: routine.triggers.map((trigger) => stripEmptyValues({
|
||||
@@ -5917,6 +5926,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
warnings.push(...resolvedRoutine.warnings);
|
||||
const routineDefinition = resolvedRoutine.routine ?? {
|
||||
concurrencyPolicy: null,
|
||||
lifecyclePolicy: null,
|
||||
catchUpPolicy: null,
|
||||
variables: null,
|
||||
triggers: [],
|
||||
@@ -5940,6 +5950,10 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
routineDefinition.concurrencyPolicy && ROUTINE_CONCURRENCY_POLICIES.includes(routineDefinition.concurrencyPolicy as any)
|
||||
? routineDefinition.concurrencyPolicy as typeof ROUTINE_CONCURRENCY_POLICIES[number]
|
||||
: "coalesce_if_active",
|
||||
lifecyclePolicy:
|
||||
routineDefinition.lifecyclePolicy && ROUTINE_LIFECYCLE_POLICIES.includes(routineDefinition.lifecyclePolicy as any)
|
||||
? routineDefinition.lifecyclePolicy as typeof ROUTINE_LIFECYCLE_POLICIES[number]
|
||||
: "independent",
|
||||
catchUpPolicy:
|
||||
routineDefinition.catchUpPolicy && ROUTINE_CATCH_UP_POLICIES.includes(routineDefinition.catchUpPolicy as any)
|
||||
? routineDefinition.catchUpPolicy as typeof ROUTINE_CATCH_UP_POLICIES[number]
|
||||
|
||||
@@ -3221,6 +3221,7 @@ const issueListSelect = {
|
||||
originKind: issues.originKind,
|
||||
originId: issues.originId,
|
||||
originRunId: issues.originRunId,
|
||||
supersededByIssueId: issues.supersededByIssueId,
|
||||
originFingerprint: issues.originFingerprint,
|
||||
requestDepth: issues.requestDepth,
|
||||
billingCode: issues.billingCode,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import crypto from "node:crypto";
|
||||
import { and, asc, desc, eq, gt, inArray, isNotNull, isNull, lte, ne, not, or, sql } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, gt, inArray, isNotNull, isNull, lt, lte, ne, not, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
@@ -175,6 +175,7 @@ async function resolveRoutineResponsibleUserId(db: Db, companyId: string, actorU
|
||||
type Actor = { agentId?: string | null; userId?: string | null; runId?: string | null };
|
||||
type RoutineRow = typeof routines.$inferSelect;
|
||||
type RoutineTriggerRow = typeof routineTriggers.$inferSelect;
|
||||
type RoutineHeartbeatDeps = IssueAssignmentWakeupDeps & Partial<Pick<ReturnType<typeof heartbeatService>, "cancelRun">>;
|
||||
|
||||
const ROUTINE_DESCRIPTION_DOCUMENT_KEY = "description" as const;
|
||||
|
||||
@@ -556,6 +557,7 @@ function routineRevisionSnapshotRoutine(routine: RoutineRow): RoutineRevisionSna
|
||||
priority: routine.priority as RoutineRevisionSnapshotV1["routine"]["priority"],
|
||||
status: routine.status as RoutineRevisionSnapshotV1["routine"]["status"],
|
||||
concurrencyPolicy: routine.concurrencyPolicy as RoutineRevisionSnapshotV1["routine"]["concurrencyPolicy"],
|
||||
lifecyclePolicy: routine.lifecyclePolicy as RoutineRevisionSnapshotV1["routine"]["lifecyclePolicy"],
|
||||
catchUpPolicy: routine.catchUpPolicy as RoutineRevisionSnapshotV1["routine"]["catchUpPolicy"],
|
||||
activityGatePolicy: routine.activityGatePolicy as RoutineRevisionSnapshotV1["routine"]["activityGatePolicy"],
|
||||
activityGateScope: routine.activityGateScope as RoutineRevisionSnapshotV1["routine"]["activityGateScope"],
|
||||
@@ -614,7 +616,7 @@ function routineCurrentFieldsMatch(left: RoutineRow, right: RoutineRow) {
|
||||
function mapRoutineRevision(row: typeof routineRevisions.$inferSelect): RoutineRevision {
|
||||
return {
|
||||
...row,
|
||||
snapshot: row.snapshot as RoutineRevisionSnapshotV1,
|
||||
snapshot: routineRevisionSnapshotSchema.parse(row.snapshot) as RoutineRevisionSnapshotV1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -657,7 +659,7 @@ function mapRoutineDescriptionDocument(row: {
|
||||
export function routineService(
|
||||
db: Db,
|
||||
deps: {
|
||||
heartbeat?: IssueAssignmentWakeupDeps;
|
||||
heartbeat?: RoutineHeartbeatDeps;
|
||||
pluginWorkerManager?: PluginWorkerManager;
|
||||
runtimeEnv?: Record<string, string | undefined>;
|
||||
} = {},
|
||||
@@ -666,9 +668,11 @@ export function routineService(
|
||||
const secretsSvc = secretService(db);
|
||||
const instanceSettings = instanceSettingsService(db);
|
||||
const runtimeEnv = deps.runtimeEnv ?? process.env;
|
||||
const heartbeat = deps.heartbeat ?? heartbeatService(db, {
|
||||
const defaultHeartbeat = heartbeatService(db, {
|
||||
pluginWorkerManager: deps.pluginWorkerManager,
|
||||
});
|
||||
const heartbeat = deps.heartbeat ?? defaultHeartbeat;
|
||||
const cancelHeartbeatRun = deps.heartbeat?.cancelRun ?? defaultHeartbeat.cancelRun;
|
||||
|
||||
async function getRoutineById(id: string) {
|
||||
return db
|
||||
@@ -1075,6 +1079,7 @@ export function routineService(
|
||||
routineRevisionId: routineRuns.routineRevisionId,
|
||||
linkedIssueId: routineRuns.linkedIssueId,
|
||||
coalescedIntoRunId: routineRuns.coalescedIntoRunId,
|
||||
supersededByRunId: routineRuns.supersededByRunId,
|
||||
failureReason: routineRuns.failureReason,
|
||||
completedAt: routineRuns.completedAt,
|
||||
createdAt: routineRuns.createdAt,
|
||||
@@ -1085,6 +1090,7 @@ export function routineService(
|
||||
issueTitle: issues.title,
|
||||
issueStatus: issues.status,
|
||||
issuePriority: issues.priority,
|
||||
issueSupersededByIssueId: issues.supersededByIssueId,
|
||||
issueUpdatedAt: issues.updatedAt,
|
||||
})
|
||||
.from(routineRuns)
|
||||
@@ -1109,6 +1115,7 @@ export function routineService(
|
||||
routineRevisionId: row.routineRevisionId,
|
||||
linkedIssueId: row.linkedIssueId,
|
||||
coalescedIntoRunId: row.coalescedIntoRunId,
|
||||
supersededByRunId: row.supersededByRunId,
|
||||
failureReason: row.failureReason,
|
||||
completedAt: row.completedAt,
|
||||
createdAt: row.createdAt,
|
||||
@@ -1120,6 +1127,7 @@ export function routineService(
|
||||
title: row.issueTitle ?? "Routine execution",
|
||||
status: row.issueStatus ?? "todo",
|
||||
priority: row.issuePriority ?? "medium",
|
||||
supersededByIssueId: row.issueSupersededByIssueId,
|
||||
updatedAt: row.issueUpdatedAt ?? row.updatedAt,
|
||||
}
|
||||
: null,
|
||||
@@ -1564,6 +1572,324 @@ export function routineService(
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function supersedeOlderRoutineExecutions(input: {
|
||||
issueId: string;
|
||||
companyId: string;
|
||||
routineId: string;
|
||||
runId: string;
|
||||
}) {
|
||||
const heartbeatRunIds = await db.transaction(async (tx) => {
|
||||
const txDb = tx as unknown as Db;
|
||||
await tx.execute(sql`
|
||||
select id
|
||||
from ${routines}
|
||||
where ${routines.companyId} = ${input.companyId}
|
||||
and ${routines.id} = ${input.routineId}
|
||||
for update
|
||||
`);
|
||||
|
||||
const currentRun = await txDb
|
||||
.select({
|
||||
id: routineRuns.id,
|
||||
companyId: routineRuns.companyId,
|
||||
routineId: routineRuns.routineId,
|
||||
status: routineRuns.status,
|
||||
routineRevisionId: routineRuns.routineRevisionId,
|
||||
triggeredAt: routineRuns.triggeredAt,
|
||||
createdAt: routineRuns.createdAt,
|
||||
})
|
||||
.from(routineRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(routineRuns.id, input.runId),
|
||||
eq(routineRuns.companyId, input.companyId),
|
||||
eq(routineRuns.routineId, input.routineId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!currentRun || currentRun.status !== "completed" || !currentRun.routineRevisionId) return [];
|
||||
|
||||
const revision = await txDb
|
||||
.select({ snapshot: routineRevisions.snapshot })
|
||||
.from(routineRevisions)
|
||||
.where(
|
||||
and(
|
||||
eq(routineRevisions.id, currentRun.routineRevisionId),
|
||||
eq(routineRevisions.companyId, input.companyId),
|
||||
eq(routineRevisions.routineId, input.routineId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const parsedRevision = routineRevisionSnapshotSchema.safeParse(revision?.snapshot);
|
||||
if (!parsedRevision.success || parsedRevision.data.routine.lifecyclePolicy !== "latest_success_wins") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const olderRunCondition = or(
|
||||
lt(routineRuns.triggeredAt, currentRun.triggeredAt),
|
||||
and(
|
||||
eq(routineRuns.triggeredAt, currentRun.triggeredAt),
|
||||
lt(routineRuns.createdAt, currentRun.createdAt),
|
||||
),
|
||||
and(
|
||||
eq(routineRuns.triggeredAt, currentRun.triggeredAt),
|
||||
eq(routineRuns.createdAt, currentRun.createdAt),
|
||||
lt(routineRuns.id, currentRun.id),
|
||||
),
|
||||
);
|
||||
const candidates = await txDb
|
||||
.select({
|
||||
issueId: issues.id,
|
||||
issueIdentifier: issues.identifier,
|
||||
issueStatus: issues.status,
|
||||
supersededByIssueId: issues.supersededByIssueId,
|
||||
executionRunId: issues.executionRunId,
|
||||
routineRunId: routineRuns.id,
|
||||
})
|
||||
.from(issues)
|
||||
.innerJoin(
|
||||
routineRuns,
|
||||
and(
|
||||
eq(routineRuns.linkedIssueId, issues.id),
|
||||
eq(routineRuns.companyId, issues.companyId),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, input.companyId),
|
||||
eq(issues.originKind, "routine_execution"),
|
||||
eq(issues.originId, input.routineId),
|
||||
or(
|
||||
inArray(issues.status, OPEN_ISSUE_STATUSES),
|
||||
and(eq(issues.status, "cancelled"), isNotNull(issues.supersededByIssueId)),
|
||||
),
|
||||
visibleIssueCondition(),
|
||||
eq(routineRuns.routineId, input.routineId),
|
||||
ne(routineRuns.id, currentRun.id),
|
||||
olderRunCondition,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(routineRuns.triggeredAt), asc(routineRuns.createdAt), asc(routineRuns.id));
|
||||
|
||||
if (candidates.length === 0) return [];
|
||||
|
||||
const rootValues = sql.join(
|
||||
candidates.map((candidate) => sql`(${candidate.issueId}::uuid)`),
|
||||
sql`, `,
|
||||
);
|
||||
const liveStatusValues = sql.join(OPEN_ISSUE_STATUSES.map((status) => sql`${status}`), sql`, `);
|
||||
const liveDescendants = Array.from(await txDb.execute(sql`
|
||||
with recursive roots(root_id) as (
|
||||
values ${rootValues}
|
||||
),
|
||||
descendants(root_id, id, status) as (
|
||||
select roots.root_id, child.id, child.status
|
||||
from roots
|
||||
join ${issues} child
|
||||
on child.parent_id = roots.root_id
|
||||
and child.company_id = ${input.companyId}
|
||||
union
|
||||
select descendants.root_id, child.id, child.status
|
||||
from descendants
|
||||
join ${issues} child
|
||||
on child.parent_id = descendants.id
|
||||
and child.company_id = ${input.companyId}
|
||||
)
|
||||
select root_id, id as descendant_id
|
||||
from descendants
|
||||
where status in (${liveStatusValues})
|
||||
order by root_id, id
|
||||
`)) as Array<{ root_id: string; descendant_id: string }>;
|
||||
const liveDescendantIssueIdsByRoot = new Map<string, string[]>();
|
||||
for (const descendant of liveDescendants) {
|
||||
const descendantIssueIds = liveDescendantIssueIdsByRoot.get(descendant.root_id) ?? [];
|
||||
descendantIssueIds.push(descendant.descendant_id);
|
||||
liveDescendantIssueIdsByRoot.set(descendant.root_id, descendantIssueIds);
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const descendantIssueIds = liveDescendantIssueIdsByRoot.get(candidate.issueId);
|
||||
if (!descendantIssueIds?.length) continue;
|
||||
|
||||
const existingDeferredActivity = await txDb
|
||||
.select({ id: activityLog.id })
|
||||
.from(activityLog)
|
||||
.where(
|
||||
and(
|
||||
eq(activityLog.companyId, input.companyId),
|
||||
eq(activityLog.action, "issue.supersession_deferred"),
|
||||
eq(activityLog.entityType, "issue"),
|
||||
eq(activityLog.entityId, candidate.issueId),
|
||||
sql`${activityLog.details} ->> 'supersedingRunId' = ${currentRun.id}`,
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (existingDeferredActivity) continue;
|
||||
|
||||
await logActivity(txDb, {
|
||||
companyId: input.companyId,
|
||||
actorType: "system",
|
||||
actorId: "routine-supersession",
|
||||
action: "issue.supersession_deferred",
|
||||
entityType: "issue",
|
||||
entityId: candidate.issueId,
|
||||
issueId: candidate.issueId,
|
||||
details: {
|
||||
issueIdentifier: candidate.issueIdentifier,
|
||||
routineId: input.routineId,
|
||||
deferredRunId: candidate.routineRunId,
|
||||
supersedingIssueId: input.issueId,
|
||||
supersedingRunId: currentRun.id,
|
||||
descendantIssueIds,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const eligibleCandidates = candidates.filter(
|
||||
(candidate) => !liveDescendantIssueIdsByRoot.has(candidate.issueId),
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
const appliedCandidates: typeof candidates = [];
|
||||
for (const candidate of eligibleCandidates) {
|
||||
if (candidate.supersededByIssueId === input.issueId) continue;
|
||||
if (candidate.supersededByIssueId) {
|
||||
const existingSupersedingRun = await txDb
|
||||
.select({
|
||||
id: routineRuns.id,
|
||||
triggeredAt: routineRuns.triggeredAt,
|
||||
createdAt: routineRuns.createdAt,
|
||||
})
|
||||
.from(routineRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(routineRuns.companyId, input.companyId),
|
||||
eq(routineRuns.routineId, input.routineId),
|
||||
eq(routineRuns.linkedIssueId, candidate.supersededByIssueId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const existingSupersederIsNewer = existingSupersedingRun
|
||||
&& (
|
||||
existingSupersedingRun.triggeredAt.getTime() > currentRun.triggeredAt.getTime()
|
||||
|| (
|
||||
existingSupersedingRun.triggeredAt.getTime() === currentRun.triggeredAt.getTime()
|
||||
&& existingSupersedingRun.createdAt.getTime() > currentRun.createdAt.getTime()
|
||||
)
|
||||
|| (
|
||||
existingSupersedingRun.triggeredAt.getTime() === currentRun.triggeredAt.getTime()
|
||||
&& existingSupersedingRun.createdAt.getTime() === currentRun.createdAt.getTime()
|
||||
&& existingSupersedingRun.id.localeCompare(currentRun.id) >= 0
|
||||
)
|
||||
);
|
||||
if (existingSupersederIsNewer) continue;
|
||||
}
|
||||
|
||||
const updatedIssue = await issueSvc.update(
|
||||
candidate.issueId,
|
||||
candidate.issueStatus === "cancelled"
|
||||
? { supersededByIssueId: input.issueId }
|
||||
: { status: "cancelled", supersededByIssueId: input.issueId },
|
||||
tx,
|
||||
);
|
||||
if (!updatedIssue) continue;
|
||||
appliedCandidates.push(candidate);
|
||||
|
||||
await txDb
|
||||
.update(routineRuns)
|
||||
.set({
|
||||
status: "superseded",
|
||||
supersededByRunId: currentRun.id,
|
||||
failureReason: null,
|
||||
completedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(routineRuns.id, candidate.routineRunId),
|
||||
eq(routineRuns.companyId, input.companyId),
|
||||
eq(routineRuns.routineId, input.routineId),
|
||||
),
|
||||
);
|
||||
|
||||
await logActivity(txDb, {
|
||||
companyId: input.companyId,
|
||||
actorType: "system",
|
||||
actorId: "routine-supersession",
|
||||
action: "issue.superseded",
|
||||
entityType: "issue",
|
||||
entityId: candidate.issueId,
|
||||
issueId: candidate.issueId,
|
||||
details: {
|
||||
issueIdentifier: candidate.issueIdentifier,
|
||||
routineId: input.routineId,
|
||||
supersededRunId: candidate.routineRunId,
|
||||
previousSupersedingIssueId: candidate.supersededByIssueId,
|
||||
supersedingIssueId: input.issueId,
|
||||
supersedingRunId: currentRun.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const supersededIssues = await txDb
|
||||
.select({ id: issues.id })
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, input.companyId),
|
||||
eq(issues.originKind, "routine_execution"),
|
||||
eq(issues.originId, input.routineId),
|
||||
eq(issues.supersededByIssueId, input.issueId),
|
||||
),
|
||||
);
|
||||
const supersededIssueIds = supersededIssues.map((row) => row.id);
|
||||
if (supersededIssueIds.length === 0) return [];
|
||||
|
||||
const directHeartbeatRunIds = appliedCandidates
|
||||
.map((candidate) => candidate.executionRunId)
|
||||
.filter((runId): runId is string => Boolean(runId));
|
||||
const heartbeatConditions = [
|
||||
inArray(sql<string>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, supersededIssueIds),
|
||||
];
|
||||
if (directHeartbeatRunIds.length > 0) {
|
||||
heartbeatConditions.push(inArray(heartbeatRuns.id, directHeartbeatRunIds));
|
||||
}
|
||||
return txDb
|
||||
.select({ id: heartbeatRuns.id })
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, input.companyId),
|
||||
inArray(heartbeatRuns.status, LIVE_HEARTBEAT_RUN_STATUSES),
|
||||
or(...heartbeatConditions),
|
||||
),
|
||||
)
|
||||
.then((rows) => [...new Set(rows.map((row) => row.id))]);
|
||||
});
|
||||
|
||||
for (const heartbeatRunId of heartbeatRunIds) {
|
||||
await cancelHeartbeatRun(
|
||||
heartbeatRunId,
|
||||
"Cancelled because a newer successful routine execution superseded this instance",
|
||||
{
|
||||
errorCode: "routine_instance_superseded",
|
||||
resultJson: {
|
||||
routineId: input.routineId,
|
||||
supersedingIssueId: input.issueId,
|
||||
supersedingRunId: input.runId,
|
||||
},
|
||||
eventMessage: "Routine execution superseded",
|
||||
eventPayload: {
|
||||
routineId: input.routineId,
|
||||
supersedingIssueId: input.issueId,
|
||||
supersedingRunId: input.runId,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function createWebhookSecret(
|
||||
companyId: string,
|
||||
routineId: string,
|
||||
@@ -2089,6 +2415,7 @@ export function routineService(
|
||||
routineRevisionId: routineRuns.routineRevisionId,
|
||||
linkedIssueId: routineRuns.linkedIssueId,
|
||||
coalescedIntoRunId: routineRuns.coalescedIntoRunId,
|
||||
supersededByRunId: routineRuns.supersededByRunId,
|
||||
failureReason: routineRuns.failureReason,
|
||||
completedAt: routineRuns.completedAt,
|
||||
createdAt: routineRuns.createdAt,
|
||||
@@ -2099,6 +2426,7 @@ export function routineService(
|
||||
issueTitle: issues.title,
|
||||
issueStatus: issues.status,
|
||||
issuePriority: issues.priority,
|
||||
issueSupersededByIssueId: issues.supersededByIssueId,
|
||||
issueUpdatedAt: issues.updatedAt,
|
||||
})
|
||||
.from(routineRuns)
|
||||
@@ -2122,6 +2450,7 @@ export function routineService(
|
||||
routineRevisionId: run.routineRevisionId,
|
||||
linkedIssueId: run.linkedIssueId,
|
||||
coalescedIntoRunId: run.coalescedIntoRunId,
|
||||
supersededByRunId: run.supersededByRunId,
|
||||
failureReason: run.failureReason,
|
||||
completedAt: run.completedAt,
|
||||
createdAt: run.createdAt,
|
||||
@@ -2133,6 +2462,7 @@ export function routineService(
|
||||
title: run.issueTitle ?? "Routine execution",
|
||||
status: run.issueStatus ?? "todo",
|
||||
priority: run.issuePriority ?? "medium",
|
||||
supersededByIssueId: run.issueSupersededByIssueId,
|
||||
updatedAt: run.issueUpdatedAt ?? run.updatedAt,
|
||||
}
|
||||
: null,
|
||||
@@ -2202,6 +2532,7 @@ export function routineService(
|
||||
priority: input.priority,
|
||||
status,
|
||||
concurrencyPolicy: input.concurrencyPolicy,
|
||||
lifecyclePolicy: input.lifecyclePolicy,
|
||||
catchUpPolicy: input.catchUpPolicy,
|
||||
activityGatePolicy: input.activityGatePolicy ?? "always",
|
||||
activityGateScope: input.activityGateScope ?? "company",
|
||||
@@ -2317,6 +2648,7 @@ export function routineService(
|
||||
priority: patch.priority ?? locked.priority,
|
||||
status: nextStatus,
|
||||
concurrencyPolicy: patch.concurrencyPolicy ?? locked.concurrencyPolicy,
|
||||
lifecyclePolicy: patch.lifecyclePolicy ?? locked.lifecyclePolicy,
|
||||
catchUpPolicy: patch.catchUpPolicy ?? locked.catchUpPolicy,
|
||||
activityGatePolicy: patch.activityGatePolicy ?? locked.activityGatePolicy,
|
||||
activityGateScope: patch.activityGateScope ?? locked.activityGateScope,
|
||||
@@ -2382,6 +2714,7 @@ export function routineService(
|
||||
priority: candidate.priority,
|
||||
status: candidate.status,
|
||||
concurrencyPolicy: candidate.concurrencyPolicy,
|
||||
lifecyclePolicy: candidate.lifecyclePolicy,
|
||||
catchUpPolicy: candidate.catchUpPolicy,
|
||||
activityGatePolicy: candidate.activityGatePolicy,
|
||||
activityGateScope: candidate.activityGateScope,
|
||||
@@ -2720,6 +3053,7 @@ export function routineService(
|
||||
priority: routineSnapshot.priority,
|
||||
status: routineSnapshot.status,
|
||||
concurrencyPolicy: routineSnapshot.concurrencyPolicy,
|
||||
lifecyclePolicy: routineSnapshot.lifecyclePolicy,
|
||||
catchUpPolicy: routineSnapshot.catchUpPolicy,
|
||||
activityGatePolicy: routineSnapshot.activityGatePolicy,
|
||||
activityGateScope: routineSnapshot.activityGateScope,
|
||||
@@ -2989,6 +3323,7 @@ export function routineService(
|
||||
routineRevisionId: routineRuns.routineRevisionId,
|
||||
linkedIssueId: routineRuns.linkedIssueId,
|
||||
coalescedIntoRunId: routineRuns.coalescedIntoRunId,
|
||||
supersededByRunId: routineRuns.supersededByRunId,
|
||||
failureReason: routineRuns.failureReason,
|
||||
completedAt: routineRuns.completedAt,
|
||||
createdAt: routineRuns.createdAt,
|
||||
@@ -2999,6 +3334,7 @@ export function routineService(
|
||||
issueTitle: issues.title,
|
||||
issueStatus: issues.status,
|
||||
issuePriority: issues.priority,
|
||||
issueSupersededByIssueId: issues.supersededByIssueId,
|
||||
issueUpdatedAt: issues.updatedAt,
|
||||
})
|
||||
.from(routineRuns)
|
||||
@@ -3022,6 +3358,7 @@ export function routineService(
|
||||
routineRevisionId: row.routineRevisionId,
|
||||
linkedIssueId: row.linkedIssueId,
|
||||
coalescedIntoRunId: row.coalescedIntoRunId,
|
||||
supersededByRunId: row.supersededByRunId,
|
||||
failureReason: row.failureReason,
|
||||
completedAt: row.completedAt,
|
||||
createdAt: row.createdAt,
|
||||
@@ -3033,6 +3370,7 @@ export function routineService(
|
||||
title: row.issueTitle ?? "Routine execution",
|
||||
status: row.issueStatus ?? "todo",
|
||||
priority: row.issuePriority ?? "medium",
|
||||
supersededByIssueId: row.issueSupersededByIssueId,
|
||||
updatedAt: row.issueUpdatedAt ?? row.updatedAt,
|
||||
}
|
||||
: null,
|
||||
@@ -3169,8 +3507,10 @@ export function routineService(
|
||||
const issue = await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
status: issues.status,
|
||||
originKind: issues.originKind,
|
||||
originId: issues.originId,
|
||||
originRunId: issues.originRunId,
|
||||
})
|
||||
.from(issues)
|
||||
@@ -3180,6 +3520,8 @@ export function routineService(
|
||||
const run = await db
|
||||
.select({
|
||||
id: routineRuns.id,
|
||||
companyId: routineRuns.companyId,
|
||||
routineId: routineRuns.routineId,
|
||||
status: routineRuns.status,
|
||||
failureReason: routineRuns.failureReason,
|
||||
triggerPayload: routineRuns.triggerPayload,
|
||||
@@ -3188,11 +3530,12 @@ export function routineService(
|
||||
.where(eq(routineRuns.id, issue.originRunId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!run) return null;
|
||||
if (run.status === "superseded") return run;
|
||||
if (issue.status === "done") {
|
||||
const transientFailureStatus = executionIssueTransientFailureStatusFromPayload(run.triggerPayload)
|
||||
?? legacyExecutionIssueTransientFailureStatus(run.failureReason);
|
||||
const transientFailureClearedAt = executionIssueTransientFailureClearedAtFromPayload(run.triggerPayload);
|
||||
return finalizeRun(issue.originRunId, {
|
||||
const completedRun = await finalizeRun(issue.originRunId, {
|
||||
status: "completed",
|
||||
failureReason: null,
|
||||
completedAt: new Date(),
|
||||
@@ -3210,6 +3553,20 @@ export function routineService(
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
if (
|
||||
completedRun
|
||||
&& issue.originId
|
||||
&& run.companyId === issue.companyId
|
||||
&& run.routineId === issue.originId
|
||||
) {
|
||||
await supersedeOlderRoutineExecutions({
|
||||
issueId: issue.id,
|
||||
companyId: issue.companyId,
|
||||
routineId: issue.originId,
|
||||
runId: run.id,
|
||||
});
|
||||
}
|
||||
return completedRun;
|
||||
}
|
||||
if (issue.status === "blocked" || issue.status === "cancelled") {
|
||||
const failureReason = executionIssueTransientFailureReason(issue.status);
|
||||
|
||||
@@ -557,6 +557,12 @@ function RevisionPreview({
|
||||
value: snapshot.concurrencyPolicy.replaceAll("_", " "),
|
||||
differs: !!currentSnapshot && currentSnapshot.concurrencyPolicy !== snapshot.concurrencyPolicy,
|
||||
},
|
||||
{
|
||||
key: "lifecyclePolicy",
|
||||
label: "Instance lifecycle",
|
||||
value: snapshot.lifecyclePolicy.replaceAll("_", " "),
|
||||
differs: !!currentSnapshot && currentSnapshot.lifecyclePolicy !== snapshot.lifecyclePolicy,
|
||||
},
|
||||
{
|
||||
key: "catchUpPolicy",
|
||||
label: "Catch-up",
|
||||
@@ -1072,6 +1078,7 @@ function computeFieldChanges(
|
||||
resolveProjectName(newRoutine.projectId, projects),
|
||||
);
|
||||
compareScalar("concurrencyPolicy", "Concurrency", oldRoutine.concurrencyPolicy, newRoutine.concurrencyPolicy);
|
||||
compareScalar("lifecyclePolicy", "Instance lifecycle", oldRoutine.lifecyclePolicy, newRoutine.lifecyclePolicy);
|
||||
compareScalar("catchUpPolicy", "Catch-up", oldRoutine.catchUpPolicy, newRoutine.catchUpPolicy);
|
||||
compareScalar("status", "Status", oldRoutine.status, newRoutine.status);
|
||||
if (JSON.stringify(oldRoutine.variables) !== JSON.stringify(newRoutine.variables)) {
|
||||
|
||||
@@ -176,6 +176,7 @@ export function RoutineDetail() {
|
||||
assigneeAgentId: "",
|
||||
priority: "medium",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
lifecyclePolicy: "independent",
|
||||
catchUpPolicy: "skip_missed",
|
||||
activityGatePolicy: "always",
|
||||
activityGateScope: "company",
|
||||
@@ -270,6 +271,7 @@ export function RoutineDetail() {
|
||||
assigneeAgentId: routine.assigneeAgentId ?? "",
|
||||
priority: routine.priority,
|
||||
concurrencyPolicy: routine.concurrencyPolicy,
|
||||
lifecyclePolicy: routine.lifecyclePolicy,
|
||||
catchUpPolicy: routine.catchUpPolicy,
|
||||
activityGatePolicy: routine.activityGatePolicy,
|
||||
activityGateScope: routine.activityGateScope,
|
||||
@@ -298,6 +300,9 @@ export function RoutineDetail() {
|
||||
if (editDraft.concurrencyPolicy !== routineDefaults.concurrencyPolicy) {
|
||||
result.push({ key: "concurrencyPolicy", label: "the concurrency policy" });
|
||||
}
|
||||
if (editDraft.lifecyclePolicy !== routineDefaults.lifecyclePolicy) {
|
||||
result.push({ key: "lifecyclePolicy", label: "the instance lifecycle policy" });
|
||||
}
|
||||
if (editDraft.catchUpPolicy !== routineDefaults.catchUpPolicy) {
|
||||
result.push({ key: "catchUpPolicy", label: "the catch-up policy" });
|
||||
}
|
||||
@@ -662,6 +667,7 @@ export function RoutineDetail() {
|
||||
assigneeAgentId: response.routine.assigneeAgentId ?? "",
|
||||
priority: response.routine.priority,
|
||||
concurrencyPolicy: response.routine.concurrencyPolicy,
|
||||
lifecyclePolicy: response.routine.lifecyclePolicy,
|
||||
catchUpPolicy: response.routine.catchUpPolicy,
|
||||
activityGatePolicy: response.routine.activityGatePolicy,
|
||||
activityGateScope: response.routine.activityGateScope,
|
||||
|
||||
@@ -66,12 +66,17 @@ import {
|
||||
} from "../components/folders/FolderControls";
|
||||
|
||||
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
|
||||
const lifecyclePolicies = ["independent", "latest_success_wins"];
|
||||
const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"];
|
||||
const concurrencyPolicyDescriptions: Record<string, string> = {
|
||||
coalesce_if_active: "If a run is already active, keep just one follow-up run queued.",
|
||||
always_enqueue: "Queue every trigger occurrence, even if the routine is already running.",
|
||||
skip_if_active: "Drop new trigger occurrences while a run is still active.",
|
||||
};
|
||||
const lifecyclePolicyDescriptions: Record<string, string> = {
|
||||
independent: "Keep every execution instance separate, even after a newer run succeeds.",
|
||||
latest_success_wins: "After a newer run succeeds, cancel older unfinished instances unless they own live descendant work.",
|
||||
};
|
||||
const catchUpPolicyDescriptions: Record<string, string> = {
|
||||
skip_missed: "Ignore windows that were missed while the scheduler or routine was paused.",
|
||||
enqueue_missed_with_cap: "Catch up missed schedule windows after recovery; sub-hourly schedules are combined into one catch-up run, slower schedules replay each missed window up to a cap.",
|
||||
@@ -144,6 +149,7 @@ function buildRoutineMutationPayload(input: {
|
||||
assigneeAgentId: string;
|
||||
priority: string;
|
||||
concurrencyPolicy: string;
|
||||
lifecyclePolicy: string;
|
||||
catchUpPolicy: string;
|
||||
variables: RoutineVariable[];
|
||||
}) {
|
||||
@@ -348,6 +354,7 @@ export function Routines() {
|
||||
assigneeAgentId: string;
|
||||
priority: string;
|
||||
concurrencyPolicy: string;
|
||||
lifecyclePolicy: string;
|
||||
catchUpPolicy: string;
|
||||
variables: RoutineVariable[];
|
||||
}>({
|
||||
@@ -358,6 +365,7 @@ export function Routines() {
|
||||
assigneeAgentId: "",
|
||||
priority: "medium",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
lifecyclePolicy: "independent",
|
||||
catchUpPolicy: "skip_missed",
|
||||
variables: [],
|
||||
});
|
||||
@@ -447,6 +455,7 @@ export function Routines() {
|
||||
assigneeAgentId: "",
|
||||
priority: "medium",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
lifecyclePolicy: "independent",
|
||||
catchUpPolicy: "skip_missed",
|
||||
variables: [],
|
||||
});
|
||||
@@ -1174,6 +1183,23 @@ export function Routines() {
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{concurrencyPolicyDescriptions[draft.concurrencyPolicy]}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-(--tracking-caps) text-muted-foreground">Instance lifecycle</p>
|
||||
<Select
|
||||
value={draft.lifecyclePolicy}
|
||||
onValueChange={(lifecyclePolicy) => setDraft((current) => ({ ...current, lifecyclePolicy }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{lifecyclePolicies.map((value) => (
|
||||
<SelectItem key={value} value={value}>{value.replaceAll("_", " ")}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{lifecyclePolicyDescriptions[draft.lifecyclePolicy]}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-(--tracking-caps) text-muted-foreground">Catch-up</p>
|
||||
<Select
|
||||
|
||||
Reference in new issue
Block a user