feat(routines): add latest-success lifecycle supersession

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-23 08:32:31 +00:00
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");
File diff suppressed because it is too large. Load diff
+7
View File
@@ -1576,6 +1576,13 @@
"when": 1787261199301,
"tag": "0226_tan_colossus",
"breakpoints": true
},
{
"idx": 227,
"version": "7",
"when": 1787470797345,
"tag": "0227_zippy_blockbuster",
"breakpoints": true
}
]
}
+2
View File
@@ -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),
+6
View File
@@ -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
View File
@@ -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(
+450 -1
View File
@@ -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();
+1
View File
@@ -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]
+1
View File
@@ -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,
+362 -5
View File
@@ -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);
+7
View File
@@ -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)) {
+6
View File
@@ -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,
+26
View File
@@ -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