mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
chore(routines): sync lifecycle policy contracts
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
df07c3ca81
commit
8751ab5ad0
31 files changed
+133
-7
No files matched your search
+1
-1
@@ -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`:
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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([]),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user