feat(routines): activity-gated advanced run policy (editor + run rows) (#10225)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Scheduled routines provide recurring control-plane work without
manual intervention.
> - The activity gate (`activity_gate_policy` / `activity_gate_scope`)
lets a scheduled routine skip a tick when nothing has happened since its
last run, so watcher-style routines stay asleep while the system is
settled instead of burning tokens every tick.
> - The scheduler, database columns, and the create/update API for those
fields already landed (see #9438), but there was no way for an operator
to actually set the policy from the routine editor, and the runs list
rendered gated skips as bare "skipped" rows with no "why".
> - This pull request adds the editor control and the run-row labels:
the Delivery section gets an "Advanced run policy" picker plus a scope
selector, and skipped runs explain why they were skipped.
> - The benefit is that the activity gate becomes discoverable and
usable end-to-end from the UI, closing the loop on the feature the API
already supports.

## Linked Issues or Issue Description

- Refs #8534 — activity gate for scheduled routines.
- Builds on #9438 (merged) which exposed the activity-gate create/update
API and the `Routine` response fields this UI reads and writes. This PR
is the editor/UI companion to that API work.

## What Changed

- **Routine editor — Advanced run policy control**
(`ui/src/components/routine-sections/editable-sections.tsx`): the
Delivery section gains a `RadioCardGroup` to choose between *Run on
every scheduled tick* (default) and *Skip when there's been no activity
since the last run*. When gating is enabled, a second scope picker
(*Company-wide* / *This project*) appears. The control is **disabled
with an explanatory hint** — rather than hidden — when the routine has
no schedule trigger, since the gate only affects scheduled ticks
(webhook/manual/API fires are themselves activity and always run). This
keeps the capability discoverable.
- **Edit-draft plumbing**
(`ui/src/components/routine-sections/context.tsx`,
`ui/src/pages/RoutineDetail.tsx`): `activityGatePolicy` /
`activityGateScope` flow through the edit draft, the delivery section's
dirty-field detection, the save payload, and revision restore, matching
how `concurrencyPolicy` / `catchUpPolicy` are handled.
- **Run-history skip reasons** (`ui/src/lib/routine-run-display.ts`):
skipped run rows now render a human-readable "why" —
`no_external_activity` → "Skipped — no activity since last run", plus
labels for `paused` and `worktree_execution_cutoff` — instead of a bare
status.
- Storybook fixture updates for the new routine fields, plus focused
run-display coverage for the skipped-run labels.

## Verification

- `pnpm exec vitest run ui/src/lib/routine-run-display.test.ts` — 11
tests passing.
- `pnpm --filter @paperclipai/ui typecheck` — clean.
- `pnpm check:token-gates` — all token gates clean.
- `pnpm --filter @paperclipai/ui build` — production build succeeds.
- Current-head GitHub CI for `9f9af8ecccfadcdc4a3afab313aafad81cbd1112`
— all checks pass (Storybook visual check skipped by workflow policy).

## Risks

- **Low risk.** UI-only change; no schema/migration and no server
changes (the API and columns already shipped in #9438). Fields are
optional and default to the pre-feature behavior (`always` / `company`),
so existing routines are unaffected. The scope picker only renders when
gating is turned on, and the whole control is inert without a schedule
trigger.

## Model Used

- Claude Opus 4.8 (`claude-opus-4-8`, 1M context), extended thinking,
with tool use (repo edit + shell verification).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-27 19:05:44 -05:00 committed by GitHub
parent f6ab82d490
commit ab2bdfeebb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 122 additions and 1 deletions

View File

@ -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"],
delivery: ["concurrencyPolicy", "catchUpPolicy", "activityGatePolicy", "activityGateScope"],
};
export type RoutineEditDraft = {
@ -56,6 +56,8 @@ export type RoutineEditDraft = {
priority: string;
concurrencyPolicy: string;
catchUpPolicy: string;
activityGatePolicy: string;
activityGateScope: string;
variables: RoutineVariable[];
env: RoutineEnvConfig | null;
};

View File

@ -68,6 +68,33 @@ const catchUpPolicyOptions = [
},
];
const activityGatePolicyOptions = [
{
value: "always",
title: "Run on every scheduled tick",
description: "Fire on the schedule no matter what — the default behavior.",
},
{
value: "require_external_activity",
title: "Skip when there's been no activity since the last run",
description:
"On a scheduled tick, only run if something happened since the last run that finished. Lets a watcher-style routine stay asleep while the system is settled instead of burning tokens.",
},
];
const activityGateScopeOptions = [
{
value: "company",
title: "Company-wide",
description: "Any activity across the company counts as a reason to run.",
},
{
value: "project",
title: "This project",
description: "Only activity in the routine's project counts as a reason to run.",
},
];
const triggerKinds = ["schedule", "webhook"];
const signingModes = ["bearer", "hmac_sha256", "github_hmac", "none"];
const signingModeDescriptions: Record<string, string> = {
@ -664,6 +691,13 @@ export function DeliverySection() {
const ctx = useRoutineDetail();
const { editDraft, setEditDraft, routine } = ctx;
// The activity gate only affects schedule ticks (webhook/manual/API fires are
// themselves activity and always run), so the control is only meaningful for
// routines that have a schedule trigger. Disable — rather than hide — it
// elsewhere so the capability stays discoverable.
const hasScheduleTrigger = routine.triggers.some((trigger) => trigger.kind === "schedule");
const gateEnabled = editDraft.activityGatePolicy === "require_external_activity";
return (
<div className="space-y-6">
<div className="space-y-3">
@ -692,6 +726,38 @@ export function DeliverySection() {
options={catchUpPolicyOptions}
/>
</div>
<div className="space-y-3">
<p className="text-xs font-medium uppercase tracking-(--tracking-caps) text-muted-foreground">
Advanced run policy
</p>
<RadioCardGroup
ariaLabel="Advanced run policy"
value={editDraft.activityGatePolicy}
onValueChange={(activityGatePolicy) =>
setEditDraft((current) => ({ ...current, activityGatePolicy }))
}
options={activityGatePolicyOptions}
disabled={!hasScheduleTrigger}
/>
{!hasScheduleTrigger ? (
<p className="text-xs text-muted-foreground">
Add a schedule trigger to gate runs on activity. Webhook, manual, and API fires always
run.
</p>
) : gateEnabled ? (
<div className="space-y-2 rounded-lg border border-border p-3">
<Label className="text-xs font-medium">Activity scope</Label>
<RadioCardGroup
ariaLabel="Activity gate scope"
value={editDraft.activityGateScope}
onValueChange={(activityGateScope) =>
setEditDraft((current) => ({ ...current, activityGateScope }))
}
options={activityGateScopeOptions}
/>
</div>
) : null}
</div>
<NextFiresPreview
triggers={routine.triggers}
concurrencyPolicy={editDraft.concurrencyPolicy}

View File

@ -49,6 +49,28 @@ describe("runRowSubtitle", () => {
runRowSubtitle({ status: "succeeded", failureReason: null, triggerPayload: null }, variables),
).toBe("");
});
it("labels an activity-gated skip", () => {
const subtitle = runRowSubtitle(
{ status: "skipped", failureReason: "no_external_activity", triggerPayload: null },
variables,
);
expect(subtitle).toBe("Skipped — no activity since last run");
});
it("labels other known skip reasons", () => {
expect(
runRowSubtitle({ status: "skipped", failureReason: "paused", triggerPayload: null }, variables),
).toBe("Skipped — routine paused");
});
it("falls back to variable values for a skip with no known reason", () => {
const subtitle = runRowSubtitle(
{ status: "skipped", failureReason: null, triggerPayload: { customer: "Acme" } },
variables,
);
expect(subtitle).toBe('customer="Acme"');
});
});
describe("dedupedTriggerLabel", () => {

View File

@ -30,9 +30,22 @@ export function dedupedTriggerLabel(
return label;
}
/**
* Human-readable labels for the reasons a scheduled run was skipped rather than
* dispatched. `failureReason` on a skipped run carries the machine reason; these
* turn it into a one-line "why" for the runs list.
*/
const SKIP_REASON_LABELS: Record<string, string> = {
no_external_activity: "Skipped — no activity since last run",
paused: "Skipped — routine paused",
worktree_execution_cutoff: "Skipped — worktree execution cutoff",
};
/**
* Subtitle line for a run row (§3.6):
* - failed runs show the failure reason ("why" without clicking through);
* - skipped runs show why the scheduled tick didn't dispatch (e.g. the activity
* gate found the system settled);
* - other runs show the inline resolved variable values (e.g. `customer="Acme"`).
* Returns an empty string when there is nothing meaningful to show.
*/
@ -43,6 +56,10 @@ export function runRowSubtitle(
if (run.status === "failed") {
return run.failureReason?.trim() || "Run failed";
}
if (run.status === "skipped") {
const reason = run.failureReason?.trim();
if (reason && SKIP_REASON_LABELS[reason]) return SKIP_REASON_LABELS[reason];
}
const payload = run.triggerPayload;
if (!payload || typeof payload !== "object") return "";
const parts: string[] = [];

View File

@ -176,6 +176,8 @@ export function RoutineDetail() {
priority: "medium",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
activityGatePolicy: "always",
activityGateScope: "company",
variables: [],
env: null,
});
@ -268,6 +270,8 @@ export function RoutineDetail() {
priority: routine.priority,
concurrencyPolicy: routine.concurrencyPolicy,
catchUpPolicy: routine.catchUpPolicy,
activityGatePolicy: routine.activityGatePolicy,
activityGateScope: routine.activityGateScope,
variables: routine.variables,
env: routine.env ?? null,
}
@ -296,6 +300,12 @@ export function RoutineDetail() {
if (editDraft.catchUpPolicy !== routineDefaults.catchUpPolicy) {
result.push({ key: "catchUpPolicy", label: "the catch-up policy" });
}
if (editDraft.activityGatePolicy !== routineDefaults.activityGatePolicy) {
result.push({ key: "activityGatePolicy", label: "the advanced run policy" });
}
if (editDraft.activityGateScope !== routineDefaults.activityGateScope) {
result.push({ key: "activityGateScope", label: "the activity gate scope" });
}
if (JSON.stringify(editDraft.variables) !== JSON.stringify(routineDefaults.variables)) {
result.push({ key: "variables", label: "the variables" });
}
@ -652,6 +662,8 @@ export function RoutineDetail() {
priority: response.routine.priority,
concurrencyPolicy: response.routine.concurrencyPolicy,
catchUpPolicy: response.routine.catchUpPolicy,
activityGatePolicy: response.routine.activityGatePolicy,
activityGateScope: response.routine.activityGateScope,
variables: response.routine.variables as RoutineVariable[],
env: (response.routine.env ?? null) as RoutineEnvConfig | null,
});

View File

@ -252,6 +252,8 @@ function makeContext(
priority: routineDetail.priority,
concurrencyPolicy: routineDetail.concurrencyPolicy,
catchUpPolicy: routineDetail.catchUpPolicy,
activityGatePolicy: "always",
activityGateScope: "company",
variables: routineDetail.variables,
env: routineDetail.env ?? null,
};