diff --git a/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md b/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md
index e9096b92314..44cd202e5e4 100644
--- a/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md
+++ b/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md
@@ -147,7 +147,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
| `list_projects` | `optional_agent_tool` | `discovery:projects:read` | `standard`
`ask`
`planning`
`skill_test` | `read` | `none` | no | `mock_extension:discovery.projects` | `scenario` + `live`
`live_codex` | `PaperclipRunnerToolAuthority`
Authenticated project tools, persisted projects and repository workspaces, and run-bound activity.
catalog PRP status: `bound` |
| `list_routines` | `optional_agent_tool` | `routines:read` | `standard`
`skill_test` | `read` | `none` | no | `mock_extension:routines.list` | `scenario`
`scenario_mock` | `unbound`
read projection surfaced via a tool-result item event; no control-plane state diff
catalog PRP status: `audit_pending` |
| `list_secret_metadata` | `optional_agent_tool` | `secrets:metadata:read` | `standard`
`skill_test` | `read` | `none` | no | `mock_extension:secrets.metadata` | `scenario`
`scenario_mock` | `unbound`
read projection surfaced via a tool-result item event; no control-plane state diff
catalog PRP status: `audit_pending` |
-| `manage_routine` | `optional_agent_tool` | `routines:write` | `standard`
`skill_test` | `admin` | `required` | no | `mock_extension:routines.manage` | `scenario`
`scenario_mock` | `unbound`
company admin/portability item event plus audit record
catalog PRP status: `audit_pending` |
+| `manage_routine` | `optional_agent_tool` | `routines:write` | `standard`
`skill_test` | `admin` | `required` | no | `mock_extension:routines.manage` | `scenario` + `live`
`live_codex` | `routineService.create/update/createTrigger/updateTrigger`
company admin/portability item event plus audit record
catalog PRP status: `bound` |
| `read_agent_instructions` | `always_agent_tool` | none | `standard`
`ask`
`planning`
`skill_test` | `read` | `none` | no | inline/no mapping | `live`
`live_codex` | `agentInstructionRevisionService`
tool-result item event plus canonical revision receipt for writes; no scenario mock coverage
catalog PRP status: `bound` |
| `read_document` | `always_agent_tool` | none | `standard`
`ask`
`planning`
`skill_test` | `read` | `none` | no | `snapshot_read:active_task_document` | `scenario` + `live`
`live_codex` | `unbound`
read projection surfaced via a tool-result item event; no control-plane state diff
catalog PRP status: `audit_pending` |
| `read_secret_value` | `optional_agent_tool` | `secrets:values:read` | `standard`
`skill_test` | `secret_read` | `none` | yes | `mock_extension:secrets.value` | `scenario`
`scenario_mock` | `unbound`
redacted tool-result item event; secret value never reaches the wire
catalog PRP status: `audit_pending` |
@@ -469,9 +469,9 @@ Current responsibility-based paths are normative. Numbered `phase-*` or mileston
### Catalog split and deliberate replacement
- Scenario/eval catalog: **40** operations.
-- Live dispatcher catalog: **45** operations.
-- Shared: **28**; union/canonical authority: **57**.
-- Scenario-only: `administer_company`, `export_company`, `inspect_operation_result`, `list_cases`, `list_company_skills`, `list_goals`, `list_routines`, `list_secret_metadata`, `manage_routine`, `read_secret_value`, `sync_company_skills`, `upsert_case`.
+- Live dispatcher catalog: **46** operations.
+- Shared: **29**; union/canonical authority: **57**.
+- Scenario-only: `administer_company`, `export_company`, `inspect_operation_result`, `list_cases`, `list_company_skills`, `list_goals`, `list_routines`, `list_secret_metadata`, `read_secret_value`, `sync_company_skills`, `upsert_case`.
- Live-only: `call_api`, `create_project`, `get_agent`, `get_agent_instruction_history`, `get_approval`, `get_approval_context`, `hire_agent`, `list_project_repositories`, `read_agent_instructions`, `restore_agent_instructions`, `schedule_wake`, `search_api`, `set_task_monitor`, `set_task_title`, `submit_complaint`, `submit_suggestion`, `update_agent_instructions`.
- The generated provider contract contains exactly the live catalog; the canonical union remains the migration authority until all scenario-only operations are either implemented, deferred, or removed by an explicit reconciliation decision.
- `generic_api_request` stays exported only for controlled tests and cannot be cited as real-surface, mock-parity, or PRP product coverage.
diff --git a/packages/paperclip-runner/src/catalog/reconciliation.test.ts b/packages/paperclip-runner/src/catalog/reconciliation.test.ts
index 923fc036b33..15b38f14973 100644
--- a/packages/paperclip-runner/src/catalog/reconciliation.test.ts
+++ b/packages/paperclip-runner/src/catalog/reconciliation.test.ts
@@ -20,8 +20,8 @@ describe("canonical semantic-catalog reconciliation authority", () => {
it("pins the reconciled op-set relationship between the two catalogs", () => {
const summary = capabilityCatalogReconciliation();
expect(summary.scenarioCount).toBe(40);
- expect(summary.liveCount).toBe(45);
- expect(summary.sharedCount).toBe(28);
+ expect(summary.liveCount).toBe(46);
+ expect(summary.sharedCount).toBe(29);
expect(summary.unionCount).toBe(57);
// Any operation added to or removed from either catalog without a
// reconciliation decision changes these exact sets and fails the gate.
@@ -53,7 +53,6 @@ describe("canonical semantic-catalog reconciliation authority", () => {
"list_goals",
"list_routines",
"list_secret_metadata",
- "manage_routine",
"read_secret_value",
"sync_company_skills",
"upsert_case",
@@ -121,8 +120,8 @@ describe("canonical semantic-catalog reconciliation authority", () => {
it("classifies real binding status so generic_api_request is never product coverage", () => {
const summary = capabilityCatalogReconciliation();
expect(summary.byRealBindingStatus).toEqual({
- live_codex: 44,
- scenario_mock: 12,
+ live_codex: 45,
+ scenario_mock: 11,
test_only: 1,
});
expect(capabilityCanonicalOperation("generic_api_request")?.realBindingStatus).toBe("test_only");
diff --git a/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts b/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts
index 7dbc66243f6..bd53371761d 100644
--- a/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts
+++ b/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts
@@ -113,9 +113,8 @@ describe("Capability semantic catalog and authorization", () => {
it("publishes a stable narrow catalog without credentials or control-plane-owned tools", () => {
const names = CAPABILITY_SEMANTIC_TOOL_CATALOG.map((tool) => tool.operationId);
expect(new Set(names).size).toBe(names.length);
- expect(names).toHaveLength(45);
- expect(names).toContain("set_task_monitor");
- expect(names).toEqual(expect.arrayContaining(["submit_complaint", "submit_suggestion"]));
+ expect(names).toHaveLength(46);
+ expect(names).toEqual(expect.arrayContaining(["set_task_monitor", "submit_complaint", "submit_suggestion", "manage_routine"]));
expect(names).toContain("get_task_context");
expect(names).toContain("finish_task");
expect(names).not.toContain("checkout_task");
diff --git a/server/src/services/native-runtime/manage-routine.ts b/server/src/services/native-runtime/manage-routine.ts
index 8f384bc1bc1..7abba57ffc2 100644
--- a/server/src/services/native-runtime/manage-routine.ts
+++ b/server/src/services/native-runtime/manage-routine.ts
@@ -3,6 +3,7 @@ import { z } from "zod";
import { routines, routineTriggers, type Db } from "@paperclipai/db";
import { createRoutineSchema, updateRoutineSchema, createRoutineTriggerSchema, updateRoutineTriggerSchema } from "@paperclipai/shared";
import { routineService } from "../routines.js";
+import { documentAnnotationService } from "../document-annotations.js";
import { persistActivity } from "../activity-log.js";
import { forbidden, notFound } from "../../errors.js";
@@ -74,6 +75,22 @@ export async function manageRoutine(db: Db, binding: Binding, input: z.infer {
await db.update(issues).set({ responsibleUserId: null }).where(eq(issues.id, issueId));
});
+ it("updates descriptions and schedules atomically, remaps annotations, and rejects stale retries", async () => {
+ const userId = "routine-edit-owner";
+ await db.insert(authUsers).values({ id: userId, name: "Routine edit owner", email: "routine-edit-owner@example.test", createdAt: new Date(), updatedAt: new Date() });
+ await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "owner" });
+ await db.update(issues).set({ responsibleUserId: userId }).where(eq(issues.id, issueId));
+ try {
+ const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
+ const execute = (input: Record) => authority.execute({ tool: "manage_routine", callId: String(input.idempotencyKey), arguments: input });
+ const created = await execute({ idempotencyKey: "routine-edit-create", action: "create", title: "Before edit", description: "Alpha selected text omega",
+ schedule: { cronExpression: "0 9 * * 1-5", timezone: "America/Chicago" } }) as { routineId: string; baseRevisionId: string; schedules: { triggerId: string }[] };
+ const service = routineService(db);
+ const originalDoc = (await service.getDescriptionDocument(created.routineId))!;
+ const thread = await documentAnnotationService(db).createRoutineThread(created.routineId, "description", {
+ baseRevisionId: originalDoc.latestRevisionId!, baseRevisionNumber: originalDoc.latestRevisionNumber,
+ selector: { quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" },
+ position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 } },
+ body: "Keep this selection anchored",
+ }, { actorType: "user", actorId: userId, userId });
+ const update = { idempotencyKey: "routine-edit-update", action: "update", routineId: created.routineId, baseRevisionId: created.baseRevisionId,
+ title: "After edit", description: "Intro.\n\nAlpha selected text omega",
+ schedule: { triggerId: created.schedules[0]!.triggerId, cronExpression: "30 10 * * 1-5", timezone: "Europe/London" } };
+ const updated = await execute(update) as { baseRevisionId: string };
+ expect(updated).toMatchObject({ title: "After edit", schedules: [{ cronExpression: "30 10 * * 1-5", timezone: "Europe/London" }] });
+ const nextDoc = (await service.getDescriptionDocument(created.routineId))!;
+ expect(nextDoc.body).toBe(update.description);
+ const [remapped] = await db.select().from(documentAnnotationThreads).where(eq(documentAnnotationThreads.id, thread.id));
+ expect(remapped).toMatchObject({ currentRevisionId: nextDoc.latestRevisionId, currentRevisionNumber: nextDoc.latestRevisionNumber, anchorState: "active", selectedText: "selected text" });
+ expect(remapped!.normalizedStart).toBeGreaterThan(6);
+ const activities = () => db.select().from(activityLog).where(eq(activityLog.entityId, created.routineId));
+ expect(await activities()).toEqual(expect.arrayContaining([expect.objectContaining({ action: "routine.document_annotation_remapped", details: expect.objectContaining({ threadId: thread.id, documentId: nextDoc.id }) })]));
+ const before = await service.getDetail(created.routineId);
+ const beforeActivities = await activities();
+ expect(await execute(update)).toEqual(updated);
+ expect(await service.getDetail(created.routineId)).toEqual(before);
+ expect(await activities()).toEqual(beforeActivities);
+ await expect(execute({ ...update, idempotencyKey: "routine-edit-stale", title: "Stale description" })).rejects.toThrow();
+ await expect(execute({ ...update, idempotencyKey: "routine-edit-invalid", baseRevisionId: updated.baseRevisionId,
+ title: "Must roll back", description: "Changed selection", schedule: { ...update.schedule, cronExpression: "not a cron", timezone: "Europe/London" } })).rejects.toThrow();
+ expect(await service.getDetail(created.routineId)).toEqual(before);
+ expect(await service.getDescriptionDocument(created.routineId)).toEqual(nextDoc);
+ expect(await db.select().from(documentAnnotationThreads).where(eq(documentAnnotationThreads.id, thread.id))).toEqual([remapped]);
+ expect(await activities()).toEqual(beforeActivities);
+ const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
+ const receipts = (run!.resultJson as { semanticToolReceipts: Record }).semanticToolReceipts;
+ expect(receipts).not.toHaveProperty("routine-edit-stale");
+ expect(receipts).not.toHaveProperty("routine-edit-invalid");
+ expect(receipts).toHaveProperty("routine-edit-update");
+ } finally {
+ await db.update(issues).set({ responsibleUserId: null }).where(eq(issues.id, issueId));
+ }
+ });
+
it("advertises only real bindings and reads the bound task", async () => {
const authority = new PaperclipRunnerToolAuthority(db, {
companyId,