Files
PaperClipAI/server/src/services/native-runtime/task-monitor.integration.test.ts
T
DottaandPaperclip 8cfedd7df8 fix(runner): restore task monitors and durable timed waits (#15446)
## 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>
2026-10-07 08:41:24 -05:00

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 });
});
});