chore(routines): sync lifecycle policy contracts

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-23 08:34:48 +00:00
1 parent df07c3ca81
commit 8751ab5ad0
31 files changed
+133 -7

No files matched your search

+1 -1
View File
@@ -233,7 +233,7 @@ the reusable company-level slot, declarations record where `user_secret_ref`
bindings are required, and the concrete value is selected later for the
responsible user.
Secret-aware env bindings are supported by agents, projects, and routines. Routine env lives in `routines.env`, is captured in `routine_revisions.snapshot`, and routine dispatches store `routine_runs.routine_revision_id` so runtime secret resolution uses the env snapshot that existed when the run was created. Routine secret refs bind with `target_type = 'routine'`, `target_id = routines.id`, and `config_path` values under `env.*`.
Secret-aware env bindings are supported by agents, projects, and routines. Routine env lives in `routines.env`, is captured in `routine_revisions.snapshot`, and routine dispatches store `routine_runs.routine_revision_id` so runtime secret resolution uses the env snapshot that existed when the run was created. The same immutable snapshot supplies `lifecycle_policy`; `latest_success_wins` records supersession with `issues.superseded_by_issue_id`, `routine_runs.status = 'superseded'`, and `routine_runs.superseded_by_run_id`, except that an older instance with any transitively non-terminal `issues.parent_id` descendant is left unchanged and receives an idempotent `issue.supersession_deferred` activity entry with the superseding issue/run and live descendant issue ids. Routine secret refs bind with `target_type = 'routine'`, `target_id = routines.id`, and `config_path` values under `env.*`.
For local/default installs, the active provider is `local_encrypted`:
+1 -1
View File
@@ -457,7 +457,7 @@ The current implementation includes additional V1-control-plane tables beyond th
- Issue structure and review: `issue_relations` for blockers, `labels`/`issue_labels`, `issue_thread_interactions`, `issue_approvals`, `issue_execution_decisions`, `issue_work_products`, `issue_inbox_archives`, `issue_read_states`, and issue reference mention indexes.
- Execution and workspace control: `execution_workspaces`, `project_workspaces`, `workspace_runtime_services`, `workspace_operations`, `environments`, `environment_leases`, `agent_task_sessions`, `agent_runtime_state`, `agent_wakeup_requests`, heartbeat events, and watchdog decision tables.
- Plugins and routines: `plugins`, plugin config/state/entities/jobs/logs/webhooks, plugin database namespaces/migrations, plugin company settings, `routines`, `routine_revisions`, `routine_triggers`, and `routine_runs`.
- Plugins and routines: `plugins`, plugin config/state/entities/jobs/logs/webhooks, plugin database namespaces/migrations, plugin company settings, `routines`, `routine_revisions`, `routine_triggers`, and `routine_runs`. Routine concurrency is a dispatch-time policy. Instance lifecycle is a separate, revision-snapshotted policy with safe default `independent`; opt-in `latest_success_wins` lets a newer successful execution cancel and link older visible non-terminal instances without making pause or archive destructive. An older instance with any transitively non-terminal `parent_id` descendant is skipped, leaving its issue and routine run unchanged, and records one `issue.supersession_deferred` activity entry per superseding run with the superseding issue/run ids and the live descendant issue ids.
- Access and operations: company memberships, instance roles, principal permission grants, invites, join requests, board API keys, CLI auth challenges, budget policies/incidents, feedback exports/votes, company skills, sidebar preferences, and company logos.
Decision-desk triage uses company-scoped sidecars rather than adding queue fields to every attention source:
+14 -1
View File
@@ -34,6 +34,7 @@ POST /api/companies/{companyId}/routines
"priority": "medium",
"status": "active",
"concurrencyPolicy": "coalesce_if_active",
"lifecyclePolicy": "independent",
"catchUpPolicy": "skip_missed"
}
```
@@ -53,6 +54,7 @@ Fields:
| `priority` | no | `critical`, `high`, `medium` (default), `low` |
| `status` | no | `active` (default), `paused`, `archived` |
| `concurrencyPolicy` | no | Behaviour when a run fires while a previous one is still active |
| `lifecyclePolicy` | no | Whether a later successful execution supersedes older unfinished executions |
| `catchUpPolicy` | no | Behaviour for missed scheduled runs |
**Concurrency policies:**
@@ -63,6 +65,17 @@ Fields:
| `skip_if_active` | Incoming run is immediately finalised as `skipped` and linked to the active run — no new issue is created |
| `always_enqueue` | Always create a new run regardless of active runs |
Concurrency is a dispatch-time rule. It determines whether a trigger creates a new execution while another execution is active.
**Instance lifecycle policies:**
| Value | Behaviour |
|-------|-----------|
| `independent` (default) | Every execution remains independent, including older unfinished executions after a newer success |
| `latest_success_wins` | After a newer execution reaches `done`, cancel and link older visible non-terminal executions that have no non-terminal descendants |
Lifecycle is a completion-time rule. It does not change trigger dispatch, and it is never inferred from `concurrencyPolicy`. Failed, blocked, or cancelled newer executions do not supersede anything. Pausing or archiving a routine also leaves existing executions untouched. If an older instance has any transitively non-terminal descendant through `parentId`, Paperclip leaves that instance and its routine run unchanged so delegated work stays owned and inspectable.
**Catch-up policies:**
| Value | Behaviour |
@@ -192,7 +205,7 @@ Fires a webhook trigger from an external system. Requires a valid `Authorization
GET /api/routines/{routineId}/runs?limit=50
```
Returns recent run history for the routine. Defaults to 50 most recent runs.
Returns recent run history for the routine. Defaults to 50 most recent runs. Superseded runs have status `superseded` and `supersededByRunId`; their execution issues expose `supersededByIssueId`.
## Agent Access Rules
+1
View File
@@ -189,6 +189,7 @@ cat > /tmp/drafting-routine.json <<JSON
"priority": "medium",
"status": "active",
"concurrencyPolicy": "always_enqueue",
"lifecyclePolicy": "independent",
"catchUpPolicy": "skip_missed"
${DRAFTING_AGENT_ID:+, "assigneeAgentId": "$DRAFTING_AGENT_ID"}
}
@@ -250,6 +250,7 @@ const manifest: PaperclipPluginManifestV1 = {
assigneeRef: { resourceKind: "agent", resourceKey: WIKI_MAINTAINER_AGENT_KEY },
projectRef: { resourceKind: "project", resourceKey: WIKI_PROJECT_KEY },
concurrencyPolicy: "skip_if_active",
lifecyclePolicy: "independent",
catchUpPolicy: "skip_missed",
triggers: [
{
@@ -277,6 +278,7 @@ const manifest: PaperclipPluginManifestV1 = {
assigneeRef: { resourceKind: "agent", resourceKey: WIKI_MAINTAINER_AGENT_KEY },
projectRef: { resourceKind: "project", resourceKey: WIKI_PROJECT_KEY },
concurrencyPolicy: "skip_if_active",
lifecyclePolicy: "independent",
catchUpPolicy: "skip_missed",
triggers: [
{
@@ -304,6 +306,7 @@ const manifest: PaperclipPluginManifestV1 = {
assigneeRef: { resourceKind: "agent", resourceKey: WIKI_MAINTAINER_AGENT_KEY },
projectRef: { resourceKind: "project", resourceKey: WIKI_PROJECT_KEY },
concurrencyPolicy: "skip_if_active",
lifecyclePolicy: "independent",
catchUpPolicy: "skip_missed",
triggers: [
{
@@ -167,6 +167,9 @@ function withManagedRoutineDefaultDrift(
if (routine.routine.concurrencyPolicy !== (declaration.concurrencyPolicy ?? "coalesce_if_active")) {
changedFields.push("concurrency policy");
}
if (routine.routine.lifecyclePolicy !== (declaration.lifecyclePolicy ?? "independent")) {
changedFields.push("lifecycle policy");
}
if (routine.routine.catchUpPolicy !== (declaration.catchUpPolicy ?? "skip_missed")) {
changedFields.push("catch-up policy");
}
+2
View File
@@ -1245,6 +1245,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
priority: declaration.priority ?? "medium",
status: declaration.status ?? (assigneeAgentId ? "active" : "paused"),
concurrencyPolicy: declaration.concurrencyPolicy ?? "coalesce_if_active",
lifecyclePolicy: declaration.lifecyclePolicy ?? "independent",
catchUpPolicy: declaration.catchUpPolicy ?? "skip_missed",
activityGatePolicy: declaration.activityGatePolicy ?? "always",
activityGateScope: declaration.activityGateScope ?? "company",
@@ -1329,6 +1330,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
dispatchFingerprint: null,
linkedIssueId: null,
coalescedIntoRunId: null,
supersededByRunId: null,
failureReason: null,
completedAt: null,
createdAt: now,
+4
View File
@@ -638,6 +638,9 @@ export type RoutineStatus = (typeof ROUTINE_STATUSES)[number];
export const ROUTINE_CONCURRENCY_POLICIES = ["coalesce_if_active", "always_enqueue", "skip_if_active"] as const;
export type RoutineConcurrencyPolicy = (typeof ROUTINE_CONCURRENCY_POLICIES)[number];
export const ROUTINE_LIFECYCLE_POLICIES = ["independent", "latest_success_wins"] as const;
export type RoutineLifecyclePolicy = (typeof ROUTINE_LIFECYCLE_POLICIES)[number];
export const ROUTINE_CATCH_UP_POLICIES = ["skip_missed", "enqueue_missed_with_cap"] as const;
export type RoutineCatchUpPolicy = (typeof ROUTINE_CATCH_UP_POLICIES)[number];
@@ -663,6 +666,7 @@ export const ROUTINE_RUN_STATUSES = [
"issue_created",
"completed",
"failed",
"superseded",
] as const;
export type RoutineRunStatus = (typeof ROUTINE_RUN_STATUSES)[number];
+1
View File
@@ -361,6 +361,7 @@ export {
ENVIRONMENT_CUSTOM_IMAGE_SETUP_CONNECTION_TYPES,
ROUTINE_STATUSES,
ROUTINE_CONCURRENCY_POLICIES,
ROUTINE_LIFECYCLE_POLICIES,
ROUTINE_CATCH_UP_POLICIES,
ROUTINE_TRIGGER_KINDS,
ROUTINE_TRIGGER_SIGNING_MODES,
@@ -125,6 +125,7 @@ export interface CompanyPortabilityIssueRoutineTriggerManifestEntry {
export interface CompanyPortabilityIssueRoutineManifestEntry {
concurrencyPolicy: string | null;
lifecyclePolicy: string | null;
catchUpPolicy: string | null;
variables?: RoutineVariable[] | null;
triggers: CompanyPortabilityIssueRoutineTriggerManifestEntry[];
+2
View File
@@ -814,6 +814,7 @@ export interface Issue {
originId?: string | null;
originRunId?: string | null;
originFingerprint?: string | null;
supersededByIssueId?: string | null;
requestDepth: number;
billingCode: string | null;
assigneeAdapterOverrides: IssueAssigneeAdapterOverrides | null;
@@ -897,6 +898,7 @@ export type CompactIssue = Pick<
| "originKind"
| "originId"
| "originRunId"
| "supersededByIssueId"
| "requestDepth"
| "billingCode"
| "executionWorkspaceId"
+3
View File
@@ -25,6 +25,7 @@ import type {
RoutineActivityGatePolicy,
RoutineActivityGateScope,
RoutineConcurrencyPolicy,
RoutineLifecyclePolicy,
RoutineStatus,
IssueSurfaceVisibility,
} from "../constants.js";
@@ -393,6 +394,8 @@ export interface PluginManagedRoutineDeclaration {
priority?: IssuePriority;
/** Suggested concurrency behavior. Defaults to core routine default. */
concurrencyPolicy?: RoutineConcurrencyPolicy;
/** Suggested instance lifecycle behavior. Defaults to independent instances. */
lifecyclePolicy?: RoutineLifecyclePolicy;
/** Suggested missed-trigger behavior. Defaults to core routine default. */
catchUpPolicy?: RoutineCatchUpPolicy;
/** Suggested external-activity gate behavior. Defaults to `always`. */
@@ -147,6 +147,7 @@ export const portabilityIssueRoutineTriggerManifestEntrySchema = z.object({
export const portabilityIssueRoutineManifestEntrySchema = z.object({
concurrencyPolicy: z.string().nullable(),
lifecyclePolicy: z.string().nullable().default(null),
catchUpPolicy: z.string().nullable(),
variables: z.array(routineVariableSchema).nullable().optional(),
triggers: z.array(portabilityIssueRoutineTriggerManifestEntrySchema).default([]),
+2
View File
@@ -21,6 +21,7 @@ import {
ROUTINE_ACTIVITY_GATE_POLICIES,
ROUTINE_ACTIVITY_GATE_SCOPES,
ROUTINE_CONCURRENCY_POLICIES,
ROUTINE_LIFECYCLE_POLICIES,
ROUTINE_STATUSES,
ROUTINE_TRIGGER_KINDS,
ROUTINE_TRIGGER_SIGNING_MODES,
@@ -291,6 +292,7 @@ export const pluginManagedRoutineDeclarationSchema = z.object({
status: z.enum(ROUTINE_STATUSES).optional(),
priority: z.enum(ISSUE_PRIORITIES).optional(),
concurrencyPolicy: z.enum(ROUTINE_CONCURRENCY_POLICIES).optional(),
lifecyclePolicy: z.enum(ROUTINE_LIFECYCLE_POLICIES).optional(),
catchUpPolicy: z.enum(ROUTINE_CATCH_UP_POLICIES).optional(),
activityGatePolicy: z.enum(ROUTINE_ACTIVITY_GATE_POLICIES).optional(),
activityGateScope: z.enum(ROUTINE_ACTIVITY_GATE_SCOPES).optional(),
@@ -194,6 +194,7 @@ routine_payload="$(jq -cn --arg agentId "$agent_id" '{
priority: "medium",
status: "active",
concurrencyPolicy: "always_enqueue",
lifecyclePolicy: "independent",
catchUpPolicy: "skip_missed"
} + (if $agentId != "" then { assigneeAgentId: $agentId } else {} end)')"
routine="$(pc_json routine create --payload-json "$routine_payload")"
@@ -8,6 +8,7 @@ assigneeRef:
status: paused
priority: medium
concurrencyPolicy: coalesce_if_active
lifecyclePolicy: independent
catchUpPolicy: skip_missed
variables:
- name: lookbackDays
@@ -8,6 +8,7 @@ assigneeRef:
status: paused
priority: medium
concurrencyPolicy: coalesce_if_active
lifecyclePolicy: independent
catchUpPolicy: skip_missed
variables:
- name: staleAfterHours
+9 -1
View File
@@ -116,6 +116,7 @@ export interface BuiltInAgentBundleDefinition {
status: "active" | "paused";
priority: "critical" | "high" | "medium" | "low";
concurrencyPolicy: "always_enqueue" | "coalesce_if_active" | "skip_if_active";
lifecyclePolicy: "independent" | "latest_success_wins";
catchUpPolicy: "enqueue_missed_with_cap" | "skip_missed";
variables: RoutineVariable[];
triggers: Array<{
@@ -370,6 +371,7 @@ const DEFINITIONS = validateBuiltInAgentDefinitions([
status: "paused",
priority: "medium",
concurrencyPolicy: "coalesce_if_active",
lifecyclePolicy: "independent",
catchUpPolicy: "skip_missed",
variables: [
{ name: "lookbackDays", label: "Lookback days", type: "number", defaultValue: 7, required: true, options: [] },
@@ -442,6 +444,7 @@ const DEFINITIONS = validateBuiltInAgentDefinitions([
status: "paused",
priority: "medium",
concurrencyPolicy: "coalesce_if_active",
lifecyclePolicy: "independent",
catchUpPolicy: "skip_missed",
variables: [
{ name: "staleAfterHours", label: "Refresh slots older than (hours)", type: "number", defaultValue: 24, required: true, options: [] },
@@ -1168,7 +1171,7 @@ export function builtInAgentService(db: Db) {
function routineDefaultsHash(
routine: Pick<
BuiltInAgentBundleDefinition["routine"],
"title" | "description" | "priority" | "concurrencyPolicy" | "catchUpPolicy" | "variables"
"title" | "description" | "priority" | "concurrencyPolicy" | "lifecyclePolicy" | "catchUpPolicy" | "variables"
>,
triggers: Array<{
kind: string;
@@ -1182,6 +1185,7 @@ export function builtInAgentService(db: Db) {
description: routine.description ?? "",
priority: routine.priority,
concurrencyPolicy: routine.concurrencyPolicy,
lifecyclePolicy: routine.lifecyclePolicy,
catchUpPolicy: routine.catchUpPolicy,
variables: normalizeRoutineVariablesForHash(routine),
triggers: normalizeRoutineTriggersForHash(triggers),
@@ -1293,6 +1297,7 @@ export function builtInAgentService(db: Db) {
priority: routine.priority,
status: routine.status,
concurrencyPolicy: routine.concurrencyPolicy,
lifecyclePolicy: routine.lifecyclePolicy,
catchUpPolicy: routine.catchUpPolicy,
variables: routine.variables,
}, actor)
@@ -1303,6 +1308,7 @@ export function builtInAgentService(db: Db) {
priority: routine.priority,
status: routine.status,
concurrencyPolicy: routine.concurrencyPolicy,
lifecyclePolicy: routine.lifecyclePolicy,
catchUpPolicy: routine.catchUpPolicy,
variables: routine.variables,
}, actor);
@@ -1364,6 +1370,7 @@ export function builtInAgentService(db: Db) {
description: routine.description ?? "",
priority: routine.priority as "critical" | "high" | "medium" | "low",
concurrencyPolicy: routine.concurrencyPolicy as "always_enqueue" | "coalesce_if_active" | "skip_if_active",
lifecyclePolicy: routine.lifecyclePolicy as "independent" | "latest_success_wins",
catchUpPolicy: routine.catchUpPolicy as "enqueue_missed_with_cap" | "skip_missed",
variables: routine.variables ?? [],
}, triggers) : null;
@@ -1439,6 +1446,7 @@ export function builtInAgentService(db: Db) {
description: routine.description ?? "",
priority: routine.priority as "critical" | "high" | "medium" | "low",
concurrencyPolicy: routine.concurrencyPolicy as "always_enqueue" | "coalesce_if_active" | "skip_if_active",
lifecyclePolicy: routine.lifecyclePolicy as "independent" | "latest_success_wins",
catchUpPolicy: routine.catchUpPolicy as "enqueue_missed_with_cap" | "skip_missed",
variables: routine.variables ?? [],
}, triggers) : null;
+2
View File
@@ -1174,6 +1174,7 @@ function routineRevisionSnapshotRoutine(routine: typeof routines.$inferSelect):
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"],
@@ -2802,6 +2803,7 @@ export function pipelineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeu
status: "active",
priority: "medium",
concurrencyPolicy: "coalesce_if_active",
lifecyclePolicy: "independent",
catchUpPolicy: "skip_missed",
originKind: "pipeline_automation",
originId: input.pipelineId,
@@ -48,6 +48,7 @@ function buildRoutineDefaults(declaration: PluginManagedRoutineDeclaration) {
status: declaration.status ?? null,
priority: declaration.priority ?? "medium",
concurrencyPolicy: declaration.concurrencyPolicy ?? "coalesce_if_active",
lifecyclePolicy: declaration.lifecyclePolicy ?? "independent",
catchUpPolicy: declaration.catchUpPolicy ?? "skip_missed",
activityGatePolicy: declaration.activityGatePolicy ?? "always",
activityGateScope: declaration.activityGateScope ?? "company",
@@ -371,6 +372,7 @@ export function pluginManagedRoutineService(
priority: declaration.priority ?? "medium",
status: declaration.status ?? (refs.assigneeAgentId ? "active" : "paused"),
concurrencyPolicy: declaration.concurrencyPolicy ?? "coalesce_if_active",
lifecyclePolicy: declaration.lifecyclePolicy ?? "independent",
catchUpPolicy: declaration.catchUpPolicy ?? "skip_missed",
activityGatePolicy: declaration.activityGatePolicy ?? "always",
activityGateScope: declaration.activityGateScope ?? "company",
@@ -433,6 +435,7 @@ export function pluginManagedRoutineService(
priority: declaration.priority ?? "medium",
status: declaration.status ?? (refs.assigneeAgentId ? "active" : "paused"),
concurrencyPolicy: declaration.concurrencyPolicy ?? "coalesce_if_active",
lifecyclePolicy: declaration.lifecyclePolicy ?? "independent",
catchUpPolicy: declaration.catchUpPolicy ?? "skip_missed",
activityGatePolicy: declaration.activityGatePolicy ?? "always",
activityGateScope: declaration.activityGateScope ?? "company",
+1 -1
View File
@@ -438,7 +438,7 @@ Routines are recurring tasks. Each time a routine fires it creates an execution
- Create and manage routines with the routines API — agents can only manage routines assigned to themselves.
- Add triggers per routine: `schedule` (cron), `webhook`, or `api` (manual).
- Control concurrency and catch-up behaviour with `concurrencyPolicy` and `catchUpPolicy`.
- Control trigger-time concurrency, success-time instance lifecycle, and catch-up behaviour with `concurrencyPolicy`, `lifecyclePolicy`, and `catchUpPolicy`.
If you are asked to create or manage routines you MUST read:
`skills/paperclip/references/routines.md`
+20
View File
@@ -6,6 +6,7 @@ A routine has:
- One assigned agent and one project
- One or more triggers (`schedule`, `webhook`, or `api`)
- A concurrency policy (what to do when a previous run is still active)
- An instance lifecycle policy (what a later successful run does to older unfinished instances)
- A catch-up policy (what to do with missed scheduled runs)
- An activity gate policy (whether quiet scheduled ticks should be skipped)
@@ -38,6 +39,7 @@ POST /api/companies/{companyId}/routines
"priority": "medium",
"status": "active",
"concurrencyPolicy": "coalesce_if_active",
"lifecyclePolicy": "independent",
"catchUpPolicy": "skip_missed",
"activityGatePolicy": "always",
"activityGateScope": "company"
@@ -55,6 +57,7 @@ POST /api/companies/{companyId}/routines
| `priority` | no | `critical` `high` `medium` (default) `low` |
| `status` | no | `active` (default) `paused` `archived` |
| `concurrencyPolicy` | no | See below |
| `lifecyclePolicy` | no | See below |
| `catchUpPolicy` | no | See below |
| `activityGatePolicy` | no | `always` (default) or `require_external_activity`; see below |
| `activityGateScope` | no | `company` (default) or `project`; see below |
@@ -71,6 +74,23 @@ Controls what happens when a trigger fires while the previous run issue is still
| `skip_if_active` | New run is marked `skipped` and linked to the existing active run — no new issue created |
| `always_enqueue` | Always create a new issue regardless of active runs |
Concurrency applies when a trigger arrives. It does not decide whether one completed execution subsumes another.
---
## Instance Lifecycle Policies
Controls what happens to older execution instances after a newer instance succeeds.
| Policy | Behaviour |
|--------|-----------|
| `independent` **(default)** | Keep every execution independent, even after a newer run succeeds |
| `latest_success_wins` | When a newer execution issue reaches `done`, cancel and link each older visible non-terminal execution that has no non-terminal descendants |
The run's dispatch-time routine revision supplies this policy. Paperclip never infers it from `concurrencyPolicy`. A failed, blocked, or cancelled newer execution does not supersede older work. Pausing or archiving a routine does not cancel existing execution issues. Paperclip recursively follows `parentId`; if any descendant is non-terminal, it skips that older instance and leaves both its issue and routine run unchanged.
Superseded execution issues expose `supersededByIssueId`. Their routine runs have terminal status `superseded` and expose `supersededByRunId`, so history retains both sides of the link.
---
## Catch-Up Policies
@@ -96,6 +96,7 @@ function snapshotV1(overrides?: Partial<RoutineRevisionSnapshotV1["routine"]>):
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
lifecyclePolicy: "independent",
catchUpPolicy: "skip_missed",
activityGatePolicy: "always",
activityGateScope: "company",
@@ -140,6 +141,7 @@ function createRoutine(overrides: Partial<Routine> = {}): Routine {
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
lifecyclePolicy: "independent",
catchUpPolicy: "skip_missed",
activityGatePolicy: "always",
activityGateScope: "company",
@@ -45,7 +45,7 @@ export const SECTION_FIELD_KEYS: Record<string, string[]> = {
overview: ["title", "description", "projectId", "assigneeAgentId", "priority"],
variables: ["variables"],
secrets: ["env"],
delivery: ["concurrencyPolicy", "catchUpPolicy", "activityGatePolicy", "activityGateScope"],
delivery: ["concurrencyPolicy", "lifecyclePolicy", "catchUpPolicy", "activityGatePolicy", "activityGateScope"],
};
export type RoutineEditDraft = {
@@ -55,6 +55,7 @@ export type RoutineEditDraft = {
assigneeAgentId: string;
priority: string;
concurrencyPolicy: string;
lifecyclePolicy: string;
catchUpPolicy: string;
activityGatePolicy: string;
activityGateScope: string;
@@ -55,6 +55,19 @@ const concurrencyPolicyOptions = [
},
];
const lifecyclePolicyOptions = [
{
value: "independent",
title: "Independent",
description: "Keep every execution instance separate, regardless of later results.",
},
{
value: "latest_success_wins",
title: "Latest success wins",
description: "When a newer instance succeeds, cancel older unfinished instances unless they own live descendant work.",
},
];
const catchUpPolicyOptions = [
{
value: "skip_missed",
@@ -713,6 +726,22 @@ export function DeliverySection() {
options={concurrencyPolicyOptions}
/>
</div>
<div className="space-y-3">
<p className="text-xs font-medium uppercase tracking-(--tracking-caps) text-muted-foreground">
Instance lifecycle
</p>
<RadioCardGroup
ariaLabel="Instance lifecycle policy"
value={editDraft.lifecyclePolicy}
onValueChange={(lifecyclePolicy) =>
setEditDraft((current) => ({ ...current, lifecyclePolicy }))
}
options={lifecyclePolicyOptions}
/>
<p className="text-sm text-muted-foreground">
Lifecycle applies after a successful run. Concurrency controls what happens when a trigger arrives.
</p>
</div>
<div className="space-y-3">
<p className="text-xs font-medium uppercase tracking-(--tracking-caps) text-muted-foreground">
Catch-up
+9
View File
@@ -36,6 +36,15 @@ describe("runRowSubtitle", () => {
expect(subtitle).toBe("Cron timed out");
});
it("links superseded runs to the newer successful run", () => {
expect(runRowSubtitle({
status: "superseded",
supersededByRunId: "12345678-1234-4123-8123-123456789abc",
failureReason: null,
triggerPayload: null,
}, variables)).toBe("Superseded by run 12345678");
});
it("falls back to a generic label when a failed run has no reason", () => {
const subtitle = runRowSubtitle(
{ status: "failed", failureReason: null, triggerPayload: null },
+7 -1
View File
@@ -50,9 +50,15 @@ const SKIP_REASON_LABELS: Record<string, string> = {
* Returns an empty string when there is nothing meaningful to show.
*/
export function runRowSubtitle(
run: Pick<RoutineRunSummary, "status" | "failureReason" | "triggerPayload">,
run: Pick<RoutineRunSummary, "status" | "failureReason" | "triggerPayload">
& Partial<Pick<RoutineRunSummary, "supersededByRunId">>,
variables: readonly RoutineVariable[] | null | undefined,
): string {
if (run.status === "superseded") {
return run.supersededByRunId
? `Superseded by run ${run.supersededByRunId.slice(0, 8)}`
: "Superseded by a newer successful run";
}
if (run.status === "failed") {
return run.failureReason?.trim() || "Run failed";
}
+1
View File
@@ -20,6 +20,7 @@ function createRoutine(overrides: Partial<RoutineListItem> = {}): RoutineListIte
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
lifecyclePolicy: "independent",
catchUpPolicy: "skip_missed",
activityGatePolicy: "always",
activityGateScope: "company",
+2
View File
@@ -298,6 +298,7 @@ function createRoutine(overrides: Partial<RoutineListItem>): RoutineListItem {
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
lifecyclePolicy: "independent",
catchUpPolicy: "skip_missed",
activityGatePolicy: "always",
activityGateScope: "company",
@@ -537,6 +538,7 @@ describe("Routines page", () => {
dispatchFingerprint: null,
linkedIssueId: null,
coalescedIntoRunId: null,
supersededByRunId: null,
failureReason: null,
completedAt: null,
createdAt: new Date("2026-04-02T00:00:00.000Z"),
@@ -86,6 +86,7 @@ const routine: RoutineDetailType = {
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
lifecyclePolicy: "independent",
catchUpPolicy: "skip_missed",
activityGatePolicy: "always",
activityGateScope: "company",
@@ -251,6 +252,7 @@ function makeContext(
assigneeAgentId: routineDetail.assigneeAgentId ?? "",
priority: routineDetail.priority,
concurrencyPolicy: routineDetail.concurrencyPolicy,
lifecyclePolicy: routineDetail.lifecyclePolicy,
catchUpPolicy: routineDetail.catchUpPolicy,
activityGatePolicy: "always",
activityGateScope: "company",
@@ -156,6 +156,7 @@ function makeSnapshot(env: RoutineEnvConfig | null): RoutineRevisionSnapshotV1 {
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
lifecyclePolicy: "independent",
catchUpPolicy: "skip_missed",
activityGatePolicy: "always",
activityGateScope: "company",
@@ -180,6 +181,7 @@ function makeRoutine(latestRevisionId: string, latestRevisionNumber: number): Ro
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
lifecyclePolicy: "independent",
catchUpPolicy: "skip_missed",
activityGatePolicy: "always",
activityGateScope: "company",