mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 07:55:47 +01:00
## Thinking Path > - Paperclip manages AI agents and their task execution. > - Agents need a durable way to return to work after a delayed check. > - The issue monitor scheduler already provides a one-shot wake for an assignee. > - Native runners reject generic execution-policy writes and had no bound monitor tool. > - Scheduling alone is insufficient because native completion also needs to accept a timed wait. > - This pull request adds an authorized monitor tool and connects it to completion and the existing scheduler. > - An agent can now schedule its next check, end the run, and resume on the same task. ## Linked Issues or Issue Description **What happened?** A native runner could not set its own task monitor. `call_api` correctly rejected execution-policy writes, while `schedule_wake` had no production binding. `paperclip_finish` also rejected monitor waits. **Expected behavior** A standard native run can set a one-shot monitor on its current task or another accessible task assigned to the same agent. After a confirmed schedule on the current task, it can yield. The scheduler later delivers `issue_monitor_due`. **Steps to reproduce** 1. Start a standard native task. 2. Ask the agent to check the task again later and end its current run. 3. Inspect available tools and try the generic issue execution-policy update. 4. Observe the missing native tool and the lifecycle-write denial. Related PRs: #14680 concerns monitor notes in the shared wake prompt. #11919 changes attempt-limit scope. This PR adds native scheduling and completion authority and retains the existing cumulative attempt bounds. It does not depend on either PR. ## What Changed - Add provider-neutral `set_task_monitor` with a default current-task target, future timestamp, required notes, existing bounds, and explicit clearing. - Check company, task visibility, ownership, runtime permissions, work mode, and active-run authority. Preserve review-only restrictions. Reject the reserved server-owned quota-recovery name before saving or accepting a native wait. - Commit the monitor, audit event, and retry receipt together. Retry receipts survive a successor run without re-arming cleared or consumed timers. - Permit `paperclip_finish` to yield to a persisted monitor. Recheck ownership and the schedule when committing final disposition. Release execution without an immediate continuation. - Preserve due monitors during native execution. Fence wake admission and consumption against replacement, clearing, reassignment, and completion. Preserve unrelated review policy. - Expose scheduled and consumed monitor instructions in task context. Update provider schemas, Rust validation, generated contracts, and execution documentation. - Add an opt-in live Codex smoke script with isolated data and explicit run/session/runner/process evidence. ## Verification - Repository `pnpm -r typecheck` and `pnpm build` passed after rebase. Server typecheck passed again after review fixes. All CI test shards pass on `3def77b1b`, including runner TypeScript/Rust, server, serialized server, workspace, and browser tests. All CI gates are green, including the canary dry run. Greptile is 5/5 on the same commit with zero unresolved threads. - The local monolithic `pnpm test:run`, started before the rebase, was interrupted after current-head CI test coverage passed. It is not counted as a standalone full-suite pass; the focused local regression suites passed. - Targeted server tests cover scheduling, replacement, clearing, policy preservation, cumulative bounds, cross-run retries, permissions, provider-neutral discovery, review restrictions, completion authority, and scheduler/finalizer races. - Runner contract/catalog/semantic tests and Rust terminal-tool tests cover the new operation and monitor completion. - Live Codex test passed twice (latest live run on `e0bcd63e6`) in a temporary database and workspace, with a 300,000 ms warm window. First run `67bd7709-c089-4d4a-9d2b-0d6b618a34b0` yielded at `2026-10-07T13:15:03.274Z`. Second run `97c323f9-595a-4cc5-a007-db5a2fbb937c` started at `13:15:30.952Z`, received `issue_monitor_due`, and completed the same task. Exactly one monitor wake was recorded. - Both live runs used native session `7b1dd753-1c9b-4e7a-b22f-a125dbc3748c`, runner `e90d9a1b-3502-4ee3-b15e-edc024c555d4`, provider session `01a11680-6d01-70c0-9a55-db7246ed66c3`, and PID `64218` with the same process start time. This proves warm reuse for that local Codex test, not only successful scheduling. - Reproduce the paid live test with `node --import ./server/node_modules/tsx/dist/loader.mjs server/scripts/smoke-native-task-monitor.ts --run`, with the installed Codex binary on `PATH` and a valid local login. ## Risks - The scheduler now defers monitor dispatch while the task has an active native run. A stuck run still depends on the existing recovery lifecycle. - Idempotency uses the existing run ledger; no table or migration is added. - Other providers share the tested tool and completion contracts. Only Codex received a live model test. - Existing `call_api` lifecycle restrictions remain enforced. Monitor waits do not bypass task blockers, reviews, or approvals. ## Model Used OpenAI Codex, GPT-6 family, with tool use, code execution, and TypeScript/Rust editing. The session does not expose the exact deployed model ID or context-window size. ## 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>
205 lines
15 KiB
TypeScript
205 lines
15 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { and, eq } from "drizzle-orm";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { activityLog, agents, authUsers, companyMemberships, companies, createDb, heartbeatRuns, issues } from "@paperclipai/db";
|
|
import { PROVIDER_QUOTA_MONITOR_SERVICE_NAME } from "@paperclipai/shared";
|
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
|
|
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
|
|
import { buildIssueMonitorTriggeredPatch, normalizeIssueExecutionPolicy } from "../issue-execution-policy.js";
|
|
import { nativeCompletionFeedback } from "./native-completion-feedback.js";
|
|
import type { PrpStructuredRunResult } from "../../vendor/paperclip-runner/index.js";
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
if (!embeddedPostgresSupport.supported) {
|
|
console.warn(`Skipping embedded Postgres native task monitor tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`);
|
|
}
|
|
|
|
describeEmbeddedPostgres("native task monitors", () => {
|
|
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: ReturnType<typeof createDb>;
|
|
beforeAll(async () => {
|
|
temporary = await startEmbeddedPostgresTestDatabase("native-task-monitor-");
|
|
db = createDb(temporary.connectionString);
|
|
});
|
|
afterAll(async () => { await temporary?.cleanup(); });
|
|
|
|
async function fixture() {
|
|
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Monitors", issuePrefix: `M${companyId.slice(0, 7)}` });
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Worker", adapterType: "paperclip_runner", status: "active" });
|
|
await db.insert(issues).values({ id: issueId, companyId, title: "Deferred check", status: "in_progress", assigneeAgentId: agentId });
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, nativeIssueId: issueId, runtimeMode: "native", status: "running", contextSnapshot: { issueId } });
|
|
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
|
|
const binding = { companyId, agentId, issueId, runId };
|
|
const authority = new PaperclipRunnerToolAuthority(db, binding);
|
|
const monitor = { nextCheckAt: new Date(Date.now() + 60_000).toISOString(), notes: "Compare this task's run and session identities." };
|
|
const call = (args: Record<string, unknown>) => authority.execute({ tool: "set_task_monitor", callId: randomUUID(), arguments: args });
|
|
const read = async () => (await db.select().from(issues).where(eq(issues.id, issueId)))[0]!;
|
|
return { ...binding, binding, authority, monitor, call, read };
|
|
}
|
|
|
|
it("advertises a real binding only in standard execution", async () => {
|
|
const value = await fixture();
|
|
expect(value.authority.definitions().some(tool => tool.name === "set_task_monitor")).toBe(true);
|
|
for (const workMode of ["planning", "ask"] as const) {
|
|
const authority = new PaperclipRunnerToolAuthority(db, { ...value.binding, workMode });
|
|
expect(authority.definitions().some(tool => tool.name === "set_task_monitor")).toBe(false);
|
|
await db.update(issues).set({ workMode }).where(eq(issues.id, value.issueId));
|
|
await expect(authority.execute({ tool: "set_task_monitor", callId: randomUUID(), arguments: { idempotencyKey: "forbidden", monitor: value.monitor } })).rejects.toThrow();
|
|
}
|
|
});
|
|
|
|
it("keeps provider-neutral discovery and review-only restrictions", async () => {
|
|
const value = await fixture();
|
|
for (const provider of ["codex", "opencode", "claude_managed", "aws_agentcore", "acpx"]) {
|
|
await db.update(agents).set({ adapterConfig: { provider } }).where(eq(agents.id, value.agentId));
|
|
expect(value.authority.definitions().map(tool => tool.name)).toContain("set_task_monitor");
|
|
}
|
|
const authority = new PaperclipRunnerToolAuthority(db, { ...value.binding,
|
|
nativeReview: { nativeReviewInteractionId: randomUUID(), nativeReviewDecisionId: randomUUID() } });
|
|
expect(authority.definitions().map(tool => tool.name)).not.toContain("set_task_monitor");
|
|
await expect(authority.execute({ tool: "set_task_monitor", callId: randomUUID(), arguments: { idempotencyKey: "review", monitor: value.monitor } })).rejects.toThrow("review run");
|
|
});
|
|
|
|
it("rechecks the responsible user's permissions before mutation and replay", async () => {
|
|
const value = await fixture(), userId = randomUUID();
|
|
await db.insert(authUsers).values({ id: userId, name: "Owner", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
|
await db.insert(companyMemberships).values({ companyId: value.companyId, principalType: "user", principalId: userId, membershipRole: "owner" });
|
|
await db.update(heartbeatRuns).set({ responsibleUserId: userId }).where(eq(heartbeatRuns.id, value.runId));
|
|
const original = { idempotencyKey: "authorized", monitor: value.monitor };
|
|
await value.call(original);
|
|
await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(eq(companyMemberships.companyId, value.companyId));
|
|
await expect(value.call(original)).rejects.toThrow();
|
|
await expect(value.call({ idempotencyKey: "clear", monitor: null })).rejects.toThrow();
|
|
expect((await value.read()).monitorNextCheckAt?.toISOString()).toBe(value.monitor.nextCheckAt);
|
|
});
|
|
|
|
it("persists a monitor and its audit atomically while preserving review policy", async () => {
|
|
const value = await fixture();
|
|
const policy = { mode: "normal", commentRequired: false, stages: [{ id: randomUUID(), type: "review", approvalsNeeded: 1, participants: [{ type: "user", userId: "reviewer" }] }] };
|
|
await db.update(issues).set({ executionPolicy: policy }).where(eq(issues.id, value.issueId));
|
|
const result = await value.call({ idempotencyKey: "schedule", monitor: value.monitor });
|
|
expect(result).toMatchObject({ taskId: value.issueId, monitor: { ...value.monitor, scheduledBy: "assignee", status: "scheduled" }, replayed: false });
|
|
const saved = await value.read();
|
|
expect(saved.executionPolicy).toMatchObject(policy);
|
|
expect(saved.monitorNextCheckAt?.toISOString()).toBe(value.monitor.nextCheckAt);
|
|
expect(saved.status).toBe("in_progress");
|
|
const audits = await db.select().from(activityLog).where(and(eq(activityLog.entityId, value.issueId), eq(activityLog.action, "issue.monitor_scheduled")));
|
|
expect(audits).toHaveLength(1);
|
|
expect(audits[0]).toMatchObject({ runId: value.runId, agentId: value.agentId });
|
|
});
|
|
|
|
it("replaces and clears schedules without re-arming a receipt on replay", async () => {
|
|
const value = await fixture();
|
|
const original = { idempotencyKey: "first", monitor: value.monitor };
|
|
await value.call(original);
|
|
const replacement = { ...value.monitor, nextCheckAt: new Date(Date.now() + 120_000).toISOString() };
|
|
await value.call({ idempotencyKey: "replace", monitor: replacement });
|
|
expect(await value.call(original)).toMatchObject({ monitor: { nextCheckAt: replacement.nextCheckAt }, replayed: true });
|
|
await value.call({ idempotencyKey: "clear", monitor: null });
|
|
expect(await value.call(original)).toMatchObject({ monitor: { nextCheckAt: null, status: "cleared" }, replayed: true });
|
|
expect((await value.read()).monitorNextCheckAt).toBeNull();
|
|
await expect(value.call({ ...original, monitor: replacement })).rejects.toThrow("idempotency_conflict");
|
|
const audits = await db.select().from(activityLog).where(eq(activityLog.entityId, value.issueId));
|
|
expect(audits.filter(row => row.action.startsWith("issue.monitor_"))).toHaveLength(3);
|
|
});
|
|
|
|
it("exposes consumed monitor instructions to a resumed task and does not re-arm them on retry", async () => {
|
|
const value = await fixture();
|
|
const input = { idempotencyKey: "consumed", monitor: value.monitor };
|
|
await value.call(input);
|
|
const scheduled = await value.read();
|
|
await db.update(issues).set(buildIssueMonitorTriggeredPatch({ issue: scheduled,
|
|
policy: normalizeIssueExecutionPolicy(scheduled.executionPolicy), triggeredAt: new Date() })).where(eq(issues.id, value.issueId));
|
|
expect(await value.authority.execute({ tool: "get_task_context", callId: randomUUID(), arguments: {} }))
|
|
.toMatchObject({ activeTask: { monitor: { status: "triggered", nextCheckAt: null, notes: value.monitor.notes, attemptCount: 1 } } });
|
|
expect(await value.call(input)).toMatchObject({ replayed: true, monitor: { nextCheckAt: null, status: "triggered" } });
|
|
expect((await value.read()).monitorNextCheckAt).toBeNull();
|
|
});
|
|
|
|
it("keeps a cleared schedule cleared when a successor run retries the original key", async () => {
|
|
const value = await fixture();
|
|
const original = { idempotencyKey: "durable-schedule", monitor: value.monitor };
|
|
await value.call(original);
|
|
await value.call({ idempotencyKey: "durable-clear", monitor: null });
|
|
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, value.runId));
|
|
const nextRunId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({ id: nextRunId, companyId: value.companyId, agentId: value.agentId,
|
|
nativeIssueId: value.issueId, runtimeMode: "native", status: "running", contextSnapshot: { issueId: value.issueId } });
|
|
await db.update(issues).set({ executionRunId: nextRunId }).where(eq(issues.id, value.issueId));
|
|
const authority = new PaperclipRunnerToolAuthority(db, { ...value.binding, runId: nextRunId });
|
|
expect(await authority.execute({ tool: "set_task_monitor", callId: randomUUID(), arguments: original }))
|
|
.toMatchObject({ replayed: true, monitor: { nextCheckAt: null, status: "cleared" } });
|
|
expect((await value.read()).monitorNextCheckAt).toBeNull();
|
|
await expect(value.call(original)).rejects.toThrow();
|
|
});
|
|
|
|
it("allows another owned task but rejects foreign ownership and company scope", async () => {
|
|
const value = await fixture(), other = await fixture(), targetId = randomUUID();
|
|
await db.insert(issues).values({ id: targetId, companyId: value.companyId, title: "Another owned check", status: "in_review", assigneeAgentId: value.agentId });
|
|
expect(await value.call({ taskId: targetId, idempotencyKey: "other", monitor: value.monitor })).toMatchObject({ taskId: targetId });
|
|
await expect(value.call({ taskId: other.issueId, idempotencyKey: "foreign", monitor: value.monitor })).rejects.toThrow("Task not found");
|
|
const otherAgent = randomUUID();
|
|
await db.insert(agents).values({ id: otherAgent, companyId: value.companyId, name: "Other", adapterType: "paperclip_runner" });
|
|
await db.update(issues).set({ assigneeAgentId: otherAgent }).where(eq(issues.id, targetId));
|
|
await expect(value.call({ taskId: targetId, idempotencyKey: "other", monitor: value.monitor })).rejects.toThrow();
|
|
});
|
|
|
|
it("rejects invalid schedules, exhausted bounds and ineligible tasks without writes", async () => {
|
|
const value = await fixture();
|
|
for (const monitor of [
|
|
{ ...value.monitor, notes: "" },
|
|
{ ...value.monitor, nextCheckAt: new Date(0).toISOString() },
|
|
{ ...value.monitor, scheduledBy: "board" },
|
|
{ ...value.monitor, timeoutAt: value.monitor.nextCheckAt },
|
|
{ ...value.monitor, maxAttempts: 101 },
|
|
]) await expect(value.call({ idempotencyKey: randomUUID(), monitor })).rejects.toThrow();
|
|
await db.update(issues).set({ monitorAttemptCount: 1 }).where(eq(issues.id, value.issueId));
|
|
await expect(value.call({ idempotencyKey: "exhausted", monitor: { ...value.monitor, maxAttempts: 1 } })).rejects.toThrow();
|
|
for (const status of ["blocked", "backlog", "done", "cancelled"] as const) {
|
|
await db.update(issues).set({ status }).where(eq(issues.id, value.issueId));
|
|
await expect(value.call({ idempotencyKey: randomUUID(), monitor: value.monitor })).rejects.toThrow();
|
|
}
|
|
expect((await value.read()).monitorNextCheckAt).toBeNull();
|
|
});
|
|
|
|
it("accepts unfinished monitor waits only after scheduling the current task", async () => {
|
|
const value = await fixture();
|
|
const result: PrpStructuredRunResult = {
|
|
schema: "paperclip.run_result.v1", reportedWorkDisposition: "yielded", summary: "I will compare the next run.",
|
|
completionClaim: { contractRevision: "test", objectiveSatisfied: false, criteria: [], remainingWork: [{ description: "Compare next run", blocksCompletion: true }] },
|
|
continuation: { kind: "monitor", summary: "Wait for the scheduled check", idempotencyKey: "wait" },
|
|
evidence: [], verification: [], attentionRequests: [], artifacts: [],
|
|
};
|
|
await expect(nativeCompletionFeedback(db, value.runId, result)).rejects.toThrow("persisted, eligible monitor");
|
|
const otherId = randomUUID();
|
|
await db.insert(issues).values({ id: otherId, companyId: value.companyId, title: "Other", status: "in_progress", assigneeAgentId: value.agentId });
|
|
await value.call({ taskId: otherId, idempotencyKey: "other", monitor: value.monitor });
|
|
await expect(nativeCompletionFeedback(db, value.runId, result)).rejects.toThrow("persisted, eligible monitor");
|
|
await value.call({ idempotencyKey: "self", monitor: value.monitor });
|
|
await expect(nativeCompletionFeedback(db, value.runId, result)).resolves.toContain("issue_monitor_due");
|
|
// Legacy APIs may persist a server-owned quota monitor. It cannot justify
|
|
// a native wait because its dispatcher explicitly excludes native runs.
|
|
await db.update(issues).set({ executionPolicy: { monitor: { ...value.monitor, serviceName: PROVIDER_QUOTA_MONITOR_SERVICE_NAME } } }).where(eq(issues.id, value.issueId));
|
|
await expect(nativeCompletionFeedback(db, value.runId, result)).rejects.toThrow("persisted, eligible monitor");
|
|
await value.call({ idempotencyKey: "clear", monitor: null });
|
|
await expect(nativeCompletionFeedback(db, value.runId, result)).rejects.toThrow("persisted, eligible monitor");
|
|
});
|
|
|
|
it("rejects the server-owned quota recovery name without promising a wake", async () => {
|
|
const value = await fixture();
|
|
await expect(value.call({ idempotencyKey: "quota", monitor: {
|
|
...value.monitor, serviceName: PROVIDER_QUOTA_MONITOR_SERVICE_NAME, externalRef: value.runId,
|
|
} })).rejects.toThrow("reserved for server-owned quota recovery");
|
|
expect((await value.read()).monitorNextCheckAt).toBeNull();
|
|
const audits = await db.select().from(activityLog).where(eq(activityLog.entityId, value.issueId));
|
|
expect(audits).toHaveLength(0);
|
|
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, value.runId));
|
|
expect(run.resultJson?.semanticToolReceipts).toBeUndefined();
|
|
await expect(value.call({ idempotencyKey: "quota", monitor: {
|
|
...value.monitor, serviceName: "Provider usage dashboard",
|
|
} })).resolves.toMatchObject({ monitor: { status: "scheduled" }, replayed: false });
|
|
});
|
|
});
|