Harden issue monitor follow-up semantics

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-01 20:58:54 -05:00
1 parent 5ed2452e2d
commit 145afe7431
16 files changed
+1016 -57

No files matched your search

+7 -2
View File
@@ -222,12 +222,17 @@ Use a monitor when the current assignee owns a future check against an async sys
Monitor policy lives under `executionPolicy.monitor` and includes:
- `nextCheckAt`: when Paperclip should wake the assignee
- `notes`: what the assignee should check
- `serviceName` and `externalRef`: optional structured external-service context
- `notes`: non-secret instructions for what the assignee should check
- `serviceName`: optional non-secret external-service context
- `externalRef`: optional external-service reference input; Paperclip treats it as secret-adjacent, redacts it before persistence/visibility, and omits it from activity and wake payloads
- `timeoutAt`, `maxAttempts`, and `recoveryPolicy`: optional recovery hints for bounded waits
Monitors are not recurring intervals. When a monitor fires, Paperclip clears the scheduled monitor and queues an `issue_monitor_due` wake for the assignee. If the external service is still pending, the assignee must explicitly re-arm the monitor with a new `nextCheckAt`. If the issue moves to `done`, `cancelled`, an invalid status, or a human/unassigned owner, the monitor is cleared.
Because `serviceName` and `notes` remain visible in issue activity and wake context, operators should keep them short and non-secret. Put enough context for the assignee to know what to inspect, but do not include signed URLs, bearer tokens, customer secrets, tenant-private identifiers, or provider links with embedded credentials.
Monitor bounds are enforced. Paperclip rejects attempts to re-arm a monitor whose `timeoutAt` or `maxAttempts` is already exhausted. When a scheduled monitor reaches an exhausted bound at trigger time, Paperclip clears it and follows `recoveryPolicy`: `wake_owner` queues a bounded recovery wake for the assignee, `create_recovery_issue` opens visible recovery work, and `escalate_to_board` records a board-visible escalation comment/activity.
Use `blocked` instead of a monitor when no Paperclip assignee owns a responsible polling path. In that case, name the external owner/action or create first-class recovery/blocker work.
### `blocked`
+2
View File
@@ -249,6 +249,8 @@ export const ISSUE_EXECUTION_MONITOR_CLEAR_REASONS = [
"invalid_status",
"invalid_assignee",
"dispatch_skipped",
"timeout_exceeded",
"max_attempts_exhausted",
] as const;
export type IssueExecutionMonitorClearReason = (typeof ISSUE_EXECUTION_MONITOR_CLEAR_REASONS)[number];
@@ -7,6 +7,7 @@ const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
assertCheckoutOwner: vi.fn(),
update: vi.fn(),
createChild: vi.fn(),
addComment: vi.fn(),
findMentionedAgents: vi.fn(),
getRelationSummaries: vi.fn(),
@@ -23,15 +24,19 @@ const mockHeartbeatService = vi.hoisted(() => ({
cancelRun: vi.fn(async () => null),
}));
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(async () => false),
hasPermission: vi.fn(async () => false),
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
companyService: () => ({
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
}),
accessService: () => ({
canUser: vi.fn(async () => false),
hasPermission: vi.fn(async () => false),
}),
accessService: () => mockAccessService,
agentService: () => ({
getById: vi.fn(async () => null),
}),
@@ -43,6 +48,9 @@ function registerModuleMocks() {
}),
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
environmentService: () => ({
getById: vi.fn(async () => null),
}),
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
@@ -68,7 +76,7 @@ function registerModuleMocks() {
syncIssue: async () => undefined,
}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
@@ -77,7 +85,22 @@ function registerModuleMocks() {
}));
}
async function createApp() {
type TestActor =
| {
type: "board";
userId: string;
companyIds: string[];
source: "local_implicit";
isInstanceAdmin: boolean;
}
| {
type: "agent";
agentId: string;
companyId: string;
runId: string | null;
};
async function createApp(actor?: TestActor) {
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
import("../middleware/index.js"),
import("../routes/issues.js"),
@@ -85,7 +108,7 @@ async function createApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
(req as any).actor = actor ?? {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
@@ -112,6 +135,17 @@ describe("issue execution policy routes", () => {
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
mockIssueService.createChild.mockResolvedValue({
issue: {
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
companyId: "company-1",
identifier: "PAP-1002",
title: "Child issue",
},
parentBlockerAdded: false,
});
mockAccessService.canUser.mockResolvedValue(false);
mockAccessService.hasPermission.mockResolvedValue(false);
});
it("does not auto-start execution review when reviewers are added to an already in_review issue", async () => {
@@ -200,4 +234,138 @@ describe("issue execution policy routes", () => {
}),
);
});
it("lets a board user create a child issue with a scheduled monitor", async () => {
mockIssueService.getById.mockResolvedValue({
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: "company-1",
status: "in_progress",
assigneeAgentId: "11111111-1111-4111-8111-111111111111",
assigneeUserId: null,
createdByUserId: "local-board",
identifier: "PAP-1001",
title: "Parent issue",
executionPolicy: null,
executionState: null,
});
const res = await request(await createApp())
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/children")
.send({
title: "Child monitor",
status: "in_review",
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
executionPolicy: {
monitor: {
nextCheckAt: "2026-04-11T12:30:00.000Z",
scheduledBy: "assignee",
},
},
});
expect(res.status).toBe(201);
const createPayload = mockIssueService.createChild.mock.calls[0]?.[1] as {
executionPolicy: { monitor: { scheduledBy: string } };
};
expect(createPayload.executionPolicy.monitor.scheduledBy).toBe("board");
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.monitor_scheduled",
details: expect.objectContaining({
scheduledBy: "board",
}),
}),
);
});
it("rejects child monitor scheduling by a non-assignee agent even with task assignment permission", async () => {
mockAccessService.hasPermission.mockResolvedValue(true);
mockIssueService.getById.mockResolvedValue({
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: "company-1",
status: "in_progress",
assigneeAgentId: "11111111-1111-4111-8111-111111111111",
assigneeUserId: null,
createdByUserId: "local-board",
identifier: "PAP-1001",
title: "Parent issue",
executionPolicy: null,
executionState: null,
});
const res = await request(await createApp({
type: "agent",
agentId: "22222222-2222-4222-8222-222222222222",
companyId: "company-1",
runId: "run-1",
}))
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/children")
.send({
title: "Child monitor",
status: "in_review",
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
executionPolicy: {
monitor: {
nextCheckAt: "2026-04-11T12:30:00.000Z",
scheduledBy: "board",
},
},
});
expect(res.status).toBe(403);
expect(res.body.error).toBe("Only the assignee agent or a board user can manage issue monitors");
expect(mockIssueService.createChild).not.toHaveBeenCalled();
});
it("normalizes spoofed child monitor scheduledBy to the assignee actor", async () => {
mockAccessService.hasPermission.mockResolvedValue(true);
mockIssueService.getById.mockResolvedValue({
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: "company-1",
status: "in_progress",
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
assigneeUserId: null,
createdByUserId: "local-board",
identifier: "PAP-1001",
title: "Parent issue",
executionPolicy: null,
executionState: null,
});
const res = await request(await createApp({
type: "agent",
agentId: "33333333-3333-4333-8333-333333333333",
companyId: "company-1",
runId: "run-1",
}))
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/children")
.send({
title: "Child monitor",
status: "in_review",
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
executionPolicy: {
monitor: {
nextCheckAt: "2026-04-11T12:30:00.000Z",
scheduledBy: "board",
externalRef: "https://example.test/deploy?token=secret",
},
},
});
expect(res.status).toBe(201);
const createPayload = mockIssueService.createChild.mock.calls[0]?.[1] as {
executionPolicy: { monitor: { scheduledBy: string; externalRef: string | null } };
};
expect(createPayload.executionPolicy.monitor.scheduledBy).toBe("assignee");
expect(createPayload.executionPolicy.monitor.externalRef).toBe("[redacted]");
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.monitor_scheduled",
entityId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
details: expect.not.objectContaining({ externalRef: expect.anything() }),
}),
);
});
});
@@ -118,6 +118,7 @@ describe("normalizeIssueExecutionPolicy", () => {
monitor: {
nextCheckAt: "2026-04-11T12:30:00.000Z",
notes: "Check deployment",
externalRef: "https://example.test/deploy?token=secret",
},
stages: [],
});
@@ -127,6 +128,7 @@ describe("normalizeIssueExecutionPolicy", () => {
nextCheckAt: "2026-04-11T12:30:00.000Z",
notes: "Check deployment",
scheduledBy: "assignee",
externalRef: "[redacted]",
},
});
});
@@ -1410,5 +1412,38 @@ describe("issue execution policy transitions", () => {
}),
).toThrow("Monitor can only be scheduled");
});
it("rejects explicitly re-arming a monitor after max attempts are exhausted", () => {
const policy = normalizeIssueExecutionPolicy({
stages: [],
monitor: {
nextCheckAt: "2099-04-11T12:30:00.000Z",
maxAttempts: 1,
scheduledBy: "assignee",
},
})!;
expect(() =>
applyIssueExecutionPolicyTransition({
issue: {
status: "in_review",
assigneeAgentId: coderAgentId,
assigneeUserId: null,
executionPolicy: null,
executionState: null,
monitorAttemptCount: 1,
monitorNextCheckAt: null,
monitorLastTriggeredAt: null,
monitorNotes: null,
monitorScheduledBy: "assignee",
},
policy,
previousPolicy: null,
requestedAssigneePatch: {},
actor: { agentId: coderAgentId },
monitorExplicitlyUpdated: true,
}),
).toThrow("Monitor bounds are already exhausted");
});
});
});
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
@@ -9,9 +9,15 @@ import {
companies,
companySkills,
createDb,
documentRevisions,
documents,
environmentLeases,
heartbeatRunEvents,
heartbeatRuns,
issueComments,
issueDocuments,
issues,
workspaceRuntimeServices,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
@@ -39,22 +45,90 @@ describeEmbeddedPostgres("issue monitor scheduler", () => {
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
const heartbeat = heartbeatService(db);
for (const agentId of seededAgentIds) {
await heartbeat.cancelActiveForAgent(agentId).catch(() => undefined);
async function waitForHeartbeatIdle(timeoutMs = 3_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const active = await db
.select({ id: heartbeatRuns.id })
.from(heartbeatRuns)
.where(sql`${heartbeatRuns.status} in ('queued', 'running', 'scheduled_retry')`);
if (active.length === 0) return;
await new Promise((resolve) => setTimeout(resolve, 50));
}
seededAgentIds.clear();
await new Promise((resolve) => setTimeout(resolve, 50));
await db.delete(issues);
throw new Error("Timed out waiting for issue monitor heartbeat runs to settle");
}
async function heartbeatSideEffectFingerprint() {
const [active, events, activity, leases, runtimeServices] = await Promise.all([
db
.select({ count: sql<number>`count(*)` })
.from(heartbeatRuns)
.where(sql`${heartbeatRuns.status} in ('queued', 'running', 'scheduled_retry')`),
db.select({ count: sql<number>`count(*)` }).from(heartbeatRunEvents),
db.select({ count: sql<number>`count(*)` }).from(activityLog),
db.select({ count: sql<number>`count(*)` }).from(environmentLeases),
db.select({ count: sql<number>`count(*)` }).from(workspaceRuntimeServices),
]);
return [
active[0]?.count ?? 0,
events[0]?.count ?? 0,
activity[0]?.count ?? 0,
leases[0]?.count ?? 0,
runtimeServices[0]?.count ?? 0,
].join(":");
}
async function waitForHeartbeatSideEffectsSettled(timeoutMs = 5_000, quietMs = 500) {
const deadline = Date.now() + timeoutMs;
let previous = "";
let stableSince = Date.now();
while (Date.now() < deadline) {
const current = await heartbeatSideEffectFingerprint();
const activeCount = Number(current.split(":")[0] ?? 0);
if (current !== previous || activeCount > 0) {
previous = current;
stableSince = Date.now();
} else if (Date.now() - stableSince >= quietMs) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error("Timed out waiting for issue monitor heartbeat side effects to settle");
}
async function cleanupRows() {
await waitForHeartbeatSideEffectsSettled();
await db.delete(heartbeatRunEvents);
await db.delete(issueComments);
await db.delete(documentRevisions);
await db.delete(issueDocuments);
await db.delete(documents);
await db.delete(activityLog);
await db.delete(environmentLeases);
await db.delete(workspaceRuntimeServices);
await db.delete(issues);
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(agentRuntimeState);
await db.delete(agents);
await db.delete(companySkills);
await db.delete(companies);
}
afterEach(async () => {
seededAgentIds.clear();
let lastError: unknown = null;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
await cleanupRows();
return;
} catch (error) {
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
throw lastError;
});
afterAll(async () => {
@@ -64,6 +138,8 @@ describeEmbeddedPostgres("issue monitor scheduler", () => {
async function seedFixture(input?: {
agentStatus?: "active" | "paused";
issueStatus?: "in_progress" | "in_review";
monitorAttemptCount?: number;
monitor?: Record<string, unknown>;
}) {
const companyId = randomUUID();
const agentId = randomUUID();
@@ -71,6 +147,14 @@ describeEmbeddedPostgres("issue monitor scheduler", () => {
const nextCheckAt = new Date("2026-04-11T12:30:00.000Z");
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
const monitorAttemptCount = input?.monitorAttemptCount ?? 0;
const monitor = {
nextCheckAt: nextCheckAt.toISOString(),
notes: "Check deploy",
scheduledBy: "assignee",
...(input?.monitor ?? {}),
};
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
@@ -87,7 +171,7 @@ describeEmbeddedPostgres("issue monitor scheduler", () => {
adapterType: "process",
adapterConfig: {
command: process.execPath,
args: ["-e", "process.exit(0)"],
args: ["-e", ""],
cwd: process.cwd(),
},
runtimeConfig: {
@@ -113,11 +197,7 @@ describeEmbeddedPostgres("issue monitor scheduler", () => {
mode: "normal",
commentRequired: true,
stages: [],
monitor: {
nextCheckAt: nextCheckAt.toISOString(),
notes: "Check deploy",
scheduledBy: "assignee",
},
monitor,
},
executionState: {
status: "idle",
@@ -133,15 +213,20 @@ describeEmbeddedPostgres("issue monitor scheduler", () => {
status: "scheduled",
nextCheckAt: nextCheckAt.toISOString(),
lastTriggeredAt: null,
attemptCount: 0,
attemptCount: monitorAttemptCount,
notes: "Check deploy",
scheduledBy: "assignee",
serviceName: typeof monitor.serviceName === "string" ? monitor.serviceName : null,
externalRef: typeof monitor.externalRef === "string" ? monitor.externalRef : null,
timeoutAt: typeof monitor.timeoutAt === "string" ? monitor.timeoutAt : null,
maxAttempts: typeof monitor.maxAttempts === "number" ? monitor.maxAttempts : null,
recoveryPolicy: typeof monitor.recoveryPolicy === "string" ? monitor.recoveryPolicy : null,
clearedAt: null,
clearReason: null,
},
},
monitorNextCheckAt: nextCheckAt,
monitorAttemptCount: 0,
monitorAttemptCount,
monitorNotes: "Check deploy",
monitorScheduledBy: "assignee",
});
@@ -253,4 +338,111 @@ describeEmbeddedPostgres("issue monitor scheduler", () => {
.then((rows) => rows.map((row) => row.action));
expect(activity).toContain("issue.monitor_skipped");
});
it("clears exhausted monitors and queues bounded owner recovery instead of another due check", async () => {
const { issueId, agentId } = await seedFixture({
monitorAttemptCount: 1,
monitor: {
maxAttempts: 1,
recoveryPolicy: "wake_owner",
},
});
const heartbeat = heartbeatService(db);
const tickAt = new Date("2026-04-11T12:31:00.000Z");
const result = await heartbeat.tickTimers(tickAt);
expect(result.enqueued).toBe(0);
expect(result.skipped).toBe(1);
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]!);
expect(issue.monitorNextCheckAt).toBeNull();
expect(parseIssueExecutionState(issue.executionState)?.monitor).toMatchObject({
status: "cleared",
clearReason: "max_attempts_exhausted",
});
const wakeup = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, agentId))
.then((rows) => rows[0] ?? null);
expect(wakeup?.reason).toBe("issue_monitor_recovery");
expect(wakeup?.payload).toMatchObject({
issueId,
clearReason: "max_attempts_exhausted",
maxAttempts: 1,
});
const activity = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, issueId))
.then((rows) => rows.map((row) => row.action));
expect(activity).toContain("issue.monitor_exhausted");
expect(activity).toContain("issue.monitor_recovery_wake_queued");
expect(activity).not.toContain("issue.monitor_triggered");
});
it("clears timed-out monitors and creates a visible recovery issue when requested", async () => {
const { issueId, companyId } = await seedFixture({
monitor: {
timeoutAt: "2026-04-11T12:00:00.000Z",
recoveryPolicy: "create_recovery_issue",
},
});
const heartbeat = heartbeatService(db);
const tickAt = new Date("2026-04-11T12:31:00.000Z");
const result = await heartbeat.tickTimers(tickAt);
expect(result.enqueued).toBe(0);
expect(result.skipped).toBe(1);
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]!);
expect(issue.monitorNextCheckAt).toBeNull();
expect(parseIssueExecutionState(issue.executionState)?.monitor).toMatchObject({
status: "cleared",
clearReason: "timeout_exceeded",
});
const recoveryIssue = await db
.select()
.from(issues)
.where(eq(issues.originId, issueId))
.then((rows) => rows.find((row) => row.companyId === companyId && row.originKind === "stranded_issue_recovery") ?? null);
expect(recoveryIssue).toMatchObject({
parentId: issueId,
priority: "high",
});
expect(["todo", "in_progress"]).toContain(recoveryIssue?.status);
});
it("omits external monitor refs from wake payloads and activity details", async () => {
const { issueId, agentId } = await seedFixture({
monitor: {
serviceName: "Deploy provider",
externalRef: "https://provider.example/deploy/123?token=secret",
},
});
const heartbeat = heartbeatService(db);
const tickAt = new Date("2026-04-11T12:31:00.000Z");
await heartbeat.tickTimers(tickAt);
const wakeup = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, agentId))
.then((rows) => rows[0] ?? null);
expect(JSON.stringify(wakeup?.payload)).not.toContain("provider.example");
expect(wakeup?.payload).not.toHaveProperty("externalRef");
const activity = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, issueId));
expect(JSON.stringify(activity.map((row) => row.details))).not.toContain("provider.example");
expect(activity.find((row) => row.action === "issue.monitor_triggered")?.details).not.toHaveProperty("externalRef");
});
});
@@ -76,6 +76,7 @@ describe("recovery classifier boundary", () => {
it("treats a scheduled monitor as an explicit review action path", () => {
const findings = classifyIssueGraphLiveness({
now: "2026-04-30T18:00:00.000Z",
issues: [
{
id: issueId,
@@ -107,6 +108,66 @@ describe("recovery classifier boundary", () => {
expect(findings).toEqual([]);
});
it("does not treat overdue or exhausted monitors as explicit waiting paths", () => {
const baseIssue = {
id: issueId,
companyId,
identifier: "PAP-2945",
title: "Wait for external review",
status: "in_review",
assigneeAgentId: agentId,
assigneeUserId: null,
createdByAgentId: null,
createdByUserId: null,
};
const agents = [
{
id: agentId,
companyId,
name: "Coder",
role: "engineer",
status: "idle",
reportsTo: managerId,
},
];
const overdue = classifyIssueGraphLiveness({
now: "2026-04-30T20:00:00.000Z",
issues: [
{
...baseIssue,
executionState: null,
monitorNextCheckAt: "2026-04-30T19:00:00.000Z",
},
],
relations: [],
agents,
});
const exhausted = classifyIssueGraphLiveness({
now: "2026-04-30T18:00:00.000Z",
issues: [
{
...baseIssue,
executionPolicy: {
monitor: {
nextCheckAt: "2026-04-30T19:00:00.000Z",
maxAttempts: 1,
},
},
executionState: null,
monitorNextCheckAt: "2026-04-30T19:00:00.000Z",
monitorAttemptCount: 1,
},
],
relations: [],
agents,
});
expect(overdue[0]?.state).toBe("in_review_without_action_path");
expect(exhausted[0]?.state).toBe("in_review_without_action_path");
});
it("keeps run liveness continuation decision parity with the compatibility export", () => {
const input = {
run: {
+31 -4
View File
@@ -81,6 +81,7 @@ import {
applyIssueExecutionPolicyTransition,
normalizeIssueExecutionPolicy,
parseIssueExecutionState,
redactIssueMonitorExternalRef,
setIssueExecutionPolicyMonitorScheduledBy,
} from "../services/issue-execution-policy.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
@@ -204,7 +205,7 @@ function summarizeIssueMonitor(
scheduledBy: issue.monitorScheduledBy ?? policy?.monitor?.scheduledBy ?? state?.monitor?.scheduledBy ?? null,
kind: policy?.monitor?.kind ?? state?.monitor?.kind ?? null,
serviceName: policy?.monitor?.serviceName ?? state?.monitor?.serviceName ?? null,
externalRef: policy?.monitor?.externalRef ?? state?.monitor?.externalRef ?? null,
externalRef: redactIssueMonitorExternalRef(policy?.monitor?.externalRef ?? state?.monitor?.externalRef ?? null),
timeoutAt: policy?.monitor?.timeoutAt ?? state?.monitor?.timeoutAt ?? null,
maxAttempts: policy?.monitor?.maxAttempts ?? state?.monitor?.maxAttempts ?? null,
recoveryPolicy: policy?.monitor?.recoveryPolicy ?? state?.monitor?.recoveryPolicy ?? null,
@@ -1915,7 +1916,6 @@ export function issueRoutes(
notes: executionPolicy.monitor.notes,
scheduledBy: executionPolicy.monitor.scheduledBy,
serviceName: executionPolicy.monitor.serviceName ?? null,
externalRef: executionPolicy.monitor.externalRef ?? null,
timeoutAt: executionPolicy.monitor.timeoutAt ?? null,
maxAttempts: executionPolicy.monitor.maxAttempts ?? null,
recoveryPolicy: executionPolicy.monitor.recoveryPolicy ?? null,
@@ -1955,7 +1955,11 @@ export function issueRoutes(
await assertIssueEnvironmentSelection(parent.companyId, req.body.executionWorkspaceSettings?.environmentId);
const actor = getActorInfo(req);
const executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy);
const executionPolicy = applyActorMonitorScheduledBy(
normalizeIssueExecutionPolicy(req.body.executionPolicy),
actor.actorType,
);
assertCanManageIssueMonitor(req, req.body.assigneeAgentId ?? null, Boolean(executionPolicy?.monitor));
const { issue, parentBlockerAdded } = await svc.createChild(parent.id, {
...req.body,
executionPolicy,
@@ -1984,6 +1988,30 @@ export function issueRoutes(
},
});
if (executionPolicy?.monitor) {
await logActivity(db, {
companyId: parent.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.monitor_scheduled",
entityType: "issue",
entityId: issue.id,
details: {
identifier: issue.identifier,
parentId: parent.id,
nextCheckAt: executionPolicy.monitor.nextCheckAt,
notes: executionPolicy.monitor.notes,
scheduledBy: executionPolicy.monitor.scheduledBy,
serviceName: executionPolicy.monitor.serviceName ?? null,
timeoutAt: executionPolicy.monitor.timeoutAt ?? null,
maxAttempts: executionPolicy.monitor.maxAttempts ?? null,
recoveryPolicy: executionPolicy.monitor.recoveryPolicy ?? null,
},
});
}
void queueIssueAssignmentWakeup({
heartbeat,
issue,
@@ -2497,7 +2525,6 @@ export function issueRoutes(
notes: nextMonitor.notes,
scheduledBy: nextMonitor.scheduledBy,
serviceName: nextMonitor.serviceName,
externalRef: nextMonitor.externalRef,
timeoutAt: nextMonitor.timeoutAt,
maxAttempts: nextMonitor.maxAttempts,
recoveryPolicy: nextMonitor.recoveryPolicy,
+344 -1
View File
@@ -14,6 +14,9 @@ import {
type EnvironmentLeaseStatus,
type ExecutionWorkspace,
type ExecutionWorkspaceConfig,
type IssueExecutionMonitorClearReason,
type IssueExecutionMonitorPolicy,
type IssueExecutionMonitorRecoveryPolicy,
type ModelProfileKey,
type RunLivenessState,
} from "@paperclipai/shared";
@@ -2336,11 +2339,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
const issueMonitorDispatchColumns = {
id: issues.id,
companyId: issues.companyId,
projectId: issues.projectId,
goalId: issues.goalId,
identifier: issues.identifier,
title: issues.title,
status: issues.status,
priority: issues.priority,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
billingCode: issues.billingCode,
executionPolicy: issues.executionPolicy,
executionState: issues.executionState,
monitorNextCheckAt: issues.monitorNextCheckAt,
@@ -2354,11 +2361,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
interface IssueMonitorDispatchRow {
id: string;
companyId: string;
projectId: string | null;
goalId: string | null;
identifier: string | null;
title: string;
status: string;
priority: string;
assigneeAgentId: string | null;
assigneeUserId: string | null;
billingCode: string | null;
executionPolicy: Record<string, unknown> | null;
executionState: Record<string, unknown> | null;
monitorNextCheckAt: Date | null;
@@ -2369,6 +2380,319 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
monitorScheduledBy: string | null;
}
function parseMonitorDate(value: string | null | undefined) {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function issueMonitorLimitClearReason(input: {
monitor: IssueExecutionMonitorPolicy | null;
nextAttemptCount: number;
now: Date;
}): IssueExecutionMonitorClearReason | null {
const timeoutAt = parseMonitorDate(input.monitor?.timeoutAt ?? null);
if (timeoutAt && input.now.getTime() >= timeoutAt.getTime()) {
return "timeout_exceeded";
}
const maxAttempts = input.monitor?.maxAttempts ?? null;
if (maxAttempts !== null && input.nextAttemptCount > maxAttempts) {
return "max_attempts_exhausted";
}
return null;
}
function monitorRecoveryPolicy(
monitor: IssueExecutionMonitorPolicy | null,
): IssueExecutionMonitorRecoveryPolicy {
return monitor?.recoveryPolicy ?? "wake_owner";
}
function monitorRecoveryDetails(input: {
claimed: IssueMonitorDispatchRow;
scheduledAtIso: string;
nextAttemptCount: number;
clearReason: IssueExecutionMonitorClearReason;
recoveryPolicy: IssueExecutionMonitorRecoveryPolicy;
monitor: IssueExecutionMonitorPolicy | null;
source: "manual" | "scheduled";
}) {
return {
identifier: input.claimed.identifier,
nextCheckAt: input.scheduledAtIso,
attemptedAttemptCount: input.nextAttemptCount,
notes: input.claimed.monitorNotes ?? null,
serviceName: input.monitor?.serviceName ?? null,
timeoutAt: input.monitor?.timeoutAt ?? null,
maxAttempts: input.monitor?.maxAttempts ?? null,
clearReason: input.clearReason,
recoveryPolicy: input.recoveryPolicy,
source: input.source,
};
}
function formatIssueIdentifierLink(identifier: string | null, fallback: string) {
if (!identifier) return fallback;
const prefix = identifier.split("-")[0];
if (!prefix || !/^[A-Z][A-Z0-9]*-\d+$/.test(identifier)) return identifier;
return `[${identifier}](/${prefix}/issues/${identifier})`;
}
function monitorRecoveryComment(input: {
issue: IssueMonitorDispatchRow;
clearReason: IssueExecutionMonitorClearReason;
recoveryPolicy: IssueExecutionMonitorRecoveryPolicy;
nextAttemptCount: number;
}) {
const label = formatIssueIdentifierLink(input.issue.identifier, input.issue.id);
const reason =
input.clearReason === "timeout_exceeded"
? "its timeout was reached"
: "its maximum attempt count was reached";
return [
`Paperclip cleared the scheduled external-service monitor for ${label} because ${reason}.`,
"",
`- Attempt count: ${input.nextAttemptCount}`,
`- Recovery policy: ${input.recoveryPolicy}`,
"",
"Next action: inspect the external service state, record the result on this issue, and restore an explicit execution or waiting path if more work remains.",
].join("\n");
}
async function findOpenIssueMonitorRecoveryIssue(claimed: IssueMonitorDispatchRow) {
return db
.select()
.from(issues)
.where(
and(
eq(issues.companyId, claimed.companyId),
eq(issues.originKind, RECOVERY_ORIGIN_KINDS.strandedIssueRecovery),
eq(issues.originId, claimed.id),
isNull(issues.hiddenAt),
notInArray(issues.status, ["done", "cancelled"]),
),
)
.orderBy(desc(issues.createdAt))
.limit(1)
.then((rows) => rows[0] ?? null);
}
async function performIssueMonitorRecovery(input: {
claimed: IssueMonitorDispatchRow;
scheduledAtIso: string;
nextAttemptCount: number;
clearReason: IssueExecutionMonitorClearReason;
recoveryPolicy: IssueExecutionMonitorRecoveryPolicy;
monitor: IssueExecutionMonitorPolicy | null;
actorType: "user" | "agent" | "system";
actorId: string;
agentId: string | null;
runId: string | null;
activitySource: "manual" | "scheduled";
}) {
const details = monitorRecoveryDetails({
claimed: input.claimed,
scheduledAtIso: input.scheduledAtIso,
nextAttemptCount: input.nextAttemptCount,
clearReason: input.clearReason,
recoveryPolicy: input.recoveryPolicy,
monitor: input.monitor,
source: input.activitySource,
});
if (input.recoveryPolicy === "create_recovery_issue") {
let recoveryIssue = await findOpenIssueMonitorRecoveryIssue(input.claimed);
if (!recoveryIssue) {
recoveryIssue = await issuesSvc.create(input.claimed.companyId, {
title: `Recover external-service monitor for ${input.claimed.identifier ?? input.claimed.title}`,
description: monitorRecoveryComment({
issue: input.claimed,
clearReason: input.clearReason,
recoveryPolicy: input.recoveryPolicy,
nextAttemptCount: input.nextAttemptCount,
}),
status: "todo",
priority: "high",
parentId: input.claimed.id,
projectId: input.claimed.projectId,
goalId: input.claimed.goalId,
assigneeAgentId: input.claimed.assigneeAgentId,
originKind: RECOVERY_ORIGIN_KINDS.strandedIssueRecovery,
originId: input.claimed.id,
originFingerprint: `issue_monitor:${input.clearReason}`,
billingCode: input.claimed.billingCode,
});
}
if (recoveryIssue.assigneeAgentId) {
await enqueueWakeup(recoveryIssue.assigneeAgentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_monitor_recovery_issue",
idempotencyKey: `issue-monitor-recovery-issue:${input.claimed.id}:${input.clearReason}:${input.scheduledAtIso}`,
payload: { issueId: recoveryIssue.id, sourceIssueId: input.claimed.id },
requestedByActorType: input.actorType,
requestedByActorId: input.actorId,
contextSnapshot: {
issueId: recoveryIssue.id,
sourceIssueId: input.claimed.id,
source: "issue.monitor.recovery_issue",
wakeReason: "issue_monitor_recovery_issue",
},
});
}
await logActivity(db, {
companyId: input.claimed.companyId,
actorType: input.actorType,
actorId: input.actorId,
agentId: input.agentId,
runId: input.runId,
action: "issue.monitor_recovery_issue_created",
entityType: "issue",
entityId: input.claimed.id,
details: {
...details,
recoveryIssueId: recoveryIssue.id,
recoveryIdentifier: recoveryIssue.identifier,
},
});
return;
}
if (input.recoveryPolicy === "escalate_to_board") {
await db.insert(issueComments).values({
companyId: input.claimed.companyId,
issueId: input.claimed.id,
body: monitorRecoveryComment({
issue: input.claimed,
clearReason: input.clearReason,
recoveryPolicy: input.recoveryPolicy,
nextAttemptCount: input.nextAttemptCount,
}),
});
await logActivity(db, {
companyId: input.claimed.companyId,
actorType: input.actorType,
actorId: input.actorId,
agentId: input.agentId,
runId: input.runId,
action: "issue.monitor_escalated_to_board",
entityType: "issue",
entityId: input.claimed.id,
details,
});
return;
}
await enqueueWakeup(input.claimed.assigneeAgentId!, {
source: "automation",
triggerDetail: "system",
reason: "issue_monitor_recovery",
idempotencyKey: `issue-monitor-recovery:${input.claimed.id}:${input.clearReason}:${input.scheduledAtIso}`,
payload: {
issueId: input.claimed.id,
monitorAttemptCount: input.nextAttemptCount,
monitorNotes: input.claimed.monitorNotes ?? null,
clearReason: input.clearReason,
serviceName: input.monitor?.serviceName ?? null,
timeoutAt: input.monitor?.timeoutAt ?? null,
maxAttempts: input.monitor?.maxAttempts ?? null,
},
requestedByActorType: input.actorType,
requestedByActorId: input.actorId,
contextSnapshot: {
issueId: input.claimed.id,
source: "issue.monitor.recovery",
wakeReason: "issue_monitor_recovery",
monitorAttemptCount: input.nextAttemptCount,
monitorNotes: input.claimed.monitorNotes ?? null,
clearReason: input.clearReason,
serviceName: input.monitor?.serviceName ?? null,
timeoutAt: input.monitor?.timeoutAt ?? null,
maxAttempts: input.monitor?.maxAttempts ?? null,
},
});
await logActivity(db, {
companyId: input.claimed.companyId,
actorType: input.actorType,
actorId: input.actorId,
agentId: input.agentId,
runId: input.runId,
action: "issue.monitor_recovery_wake_queued",
entityType: "issue",
entityId: input.claimed.id,
details,
});
}
async function clearIssueMonitorAndRecover(input: {
claimed: IssueMonitorDispatchRow;
policy: ReturnType<typeof normalizeIssueExecutionPolicy>;
scheduledAtIso: string;
nextAttemptCount: number;
clearReason: IssueExecutionMonitorClearReason;
recoveryPolicy: IssueExecutionMonitorRecoveryPolicy;
monitor: IssueExecutionMonitorPolicy | null;
now: Date;
actorType: "user" | "agent" | "system";
actorId: string;
agentId: string | null;
runId: string | null;
activitySource: "manual" | "scheduled";
}) {
await db
.update(issues)
.set({
...buildIssueMonitorClearedPatch({
issue: input.claimed,
policy: input.policy,
clearReason: input.clearReason,
clearedAt: input.now,
}),
updatedAt: input.now,
})
.where(eq(issues.id, input.claimed.id));
await logActivity(db, {
companyId: input.claimed.companyId,
actorType: input.actorType,
actorId: input.actorId,
agentId: input.agentId,
runId: input.runId,
action: "issue.monitor_exhausted",
entityType: "issue",
entityId: input.claimed.id,
details: monitorRecoveryDetails({
claimed: input.claimed,
scheduledAtIso: input.scheduledAtIso,
nextAttemptCount: input.nextAttemptCount,
clearReason: input.clearReason,
recoveryPolicy: input.recoveryPolicy,
monitor: input.monitor,
source: input.activitySource,
}),
});
await performIssueMonitorRecovery({
claimed: input.claimed,
scheduledAtIso: input.scheduledAtIso,
nextAttemptCount: input.nextAttemptCount,
clearReason: input.clearReason,
recoveryPolicy: input.recoveryPolicy,
monitor: input.monitor,
actorType: input.actorType,
actorId: input.actorId,
agentId: input.agentId,
runId: input.runId,
activitySource: input.activitySource,
});
return { outcome: "skipped" as const, reason: input.clearReason };
}
async function dispatchClaimedIssueMonitor(
claimed: IssueMonitorDispatchRow,
input: {
@@ -2392,14 +2716,33 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
const nextAttemptCount = (claimed.monitorAttemptCount ?? 0) + 1;
const policy = normalizeIssueExecutionPolicy(claimed.executionPolicy ?? null);
const monitor = policy?.monitor ?? null;
const clearReason = issueMonitorLimitClearReason({ monitor, nextAttemptCount, now: input.now });
const recoveryPolicy = monitorRecoveryPolicy(monitor);
const monitorMetadata = {
serviceName: monitor?.serviceName ?? null,
externalRef: monitor?.externalRef ?? null,
timeoutAt: monitor?.timeoutAt ?? null,
maxAttempts: monitor?.maxAttempts ?? null,
recoveryPolicy: monitor?.recoveryPolicy ?? null,
};
if (clearReason) {
return clearIssueMonitorAndRecover({
claimed,
policy,
scheduledAtIso,
nextAttemptCount,
clearReason,
recoveryPolicy,
monitor,
now: input.now,
actorType: input.actorType,
actorId: input.actorId,
agentId: input.agentId,
runId: input.runId,
activitySource: input.activitySource,
});
}
try {
await enqueueWakeup(claimed.assigneeAgentId, {
source: input.source,
+63 -8
View File
@@ -62,6 +62,8 @@ const COMPLETED_STATUS: IssueExecutionState["status"] = "completed";
const PENDING_STATUS: IssueExecutionState["status"] = "pending";
const CHANGES_REQUESTED_STATUS: IssueExecutionState["status"] = "changes_requested";
const MONITOR_INVALID_MESSAGE = "Monitor can only be scheduled on issues assigned to an agent in in_progress or in_review";
const MONITOR_BOUNDS_EXHAUSTED_MESSAGE = "Monitor bounds are already exhausted";
export const REDACTED_ISSUE_MONITOR_EXTERNAL_REF = "[redacted]";
function normalizeMonitorNotes(notes: string | null | undefined) {
if (typeof notes !== "string") return null;
@@ -75,11 +77,15 @@ function normalizeMonitorText(value: string | null | undefined) {
return trimmed.length > 0 ? trimmed : null;
}
export function redactIssueMonitorExternalRef(value: string | null | undefined) {
return normalizeMonitorText(value) ? REDACTED_ISSUE_MONITOR_EXTERNAL_REF : null;
}
function monitorMetadataFromPolicy(monitor: IssueExecutionMonitorPolicy) {
return {
kind: monitor.kind ?? null,
serviceName: normalizeMonitorText(monitor.serviceName),
externalRef: normalizeMonitorText(monitor.externalRef),
externalRef: redactIssueMonitorExternalRef(monitor.externalRef),
timeoutAt: monitor.timeoutAt ?? null,
maxAttempts: monitor.maxAttempts ?? null,
recoveryPolicy: monitor.recoveryPolicy ?? null,
@@ -90,7 +96,7 @@ function monitorMetadataFromState(state: IssueExecutionMonitorState | null | und
return {
kind: state?.kind ?? null,
serviceName: normalizeMonitorText(state?.serviceName),
externalRef: normalizeMonitorText(state?.externalRef),
externalRef: redactIssueMonitorExternalRef(state?.externalRef),
timeoutAt: state?.timeoutAt ?? null,
maxAttempts: state?.maxAttempts ?? null,
recoveryPolicy: state?.recoveryPolicy ?? null,
@@ -263,6 +269,28 @@ function monitorClearReasonForIssue(
return null;
}
function parseMonitorDate(value: string | null | undefined) {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function exhaustedMonitorClearReason(input: {
monitor: IssueExecutionMonitorPolicy;
attemptCount: number;
now: Date;
}): IssueExecutionMonitorClearReason | null {
const timeoutAt = parseMonitorDate(input.monitor.timeoutAt ?? null);
if (timeoutAt && input.now.getTime() >= timeoutAt.getTime()) {
return "timeout_exceeded";
}
const maxAttempts = input.monitor.maxAttempts ?? null;
if (maxAttempts !== null && input.attemptCount >= maxAttempts) {
return "max_attempts_exhausted";
}
return null;
}
function nextAssigneeIds(input: {
issue: IssueLike;
requestedAssigneePatch: RequestedAssigneePatch;
@@ -352,7 +380,7 @@ export function normalizeIssueExecutionPolicy(input: unknown): IssueExecutionPol
scheduledBy: parsed.data.monitor.scheduledBy,
kind: parsed.data.monitor.kind ?? null,
serviceName: normalizeMonitorText(parsed.data.monitor.serviceName),
externalRef: normalizeMonitorText(parsed.data.monitor.externalRef),
externalRef: redactIssueMonitorExternalRef(parsed.data.monitor.externalRef),
timeoutAt: parsed.data.monitor.timeoutAt ?? null,
maxAttempts: parsed.data.monitor.maxAttempts ?? null,
recoveryPolicy: parsed.data.monitor.recoveryPolicy ?? null,
@@ -884,11 +912,30 @@ function applyMonitorTransition(input: TransitionInput, stagePatch: Record<strin
clearedAt: new Date(),
});
} else {
patch.monitorNextCheckAt = new Date(input.policy.monitor.nextCheckAt);
patch.monitorWakeRequestedAt = null;
patch.monitorNotes = input.policy.monitor.notes ?? null;
patch.monitorScheduledBy = input.policy.monitor.scheduledBy;
targetMonitorState = buildScheduledMonitorState(currentMonitorState, input.policy.monitor);
const exhaustedReason = exhaustedMonitorClearReason({
monitor: input.policy.monitor,
attemptCount: currentMonitorState?.attemptCount ?? 0,
now: new Date(),
});
if (exhaustedReason) {
if (input.monitorExplicitlyUpdated) {
throw unprocessable(MONITOR_BOUNDS_EXHAUSTED_MESSAGE, { clearReason: exhaustedReason });
}
patch.executionPolicy = stripMonitorFromExecutionPolicy(input.policy);
patch.monitorNextCheckAt = null;
patch.monitorWakeRequestedAt = null;
targetMonitorState = buildClearedMonitorState({
previous: currentMonitorState,
clearReason: exhaustedReason,
clearedAt: new Date(),
});
} else {
patch.monitorNextCheckAt = new Date(input.policy.monitor.nextCheckAt);
patch.monitorWakeRequestedAt = null;
patch.monitorNotes = input.policy.monitor.notes ?? null;
patch.monitorScheduledBy = input.policy.monitor.scheduledBy;
targetMonitorState = buildScheduledMonitorState(currentMonitorState, input.policy.monitor);
}
}
} else if (previousPolicy?.monitor) {
patch.monitorNextCheckAt = null;
@@ -920,6 +967,14 @@ export function buildInitialIssueMonitorFields(input: {
if (!issueAllowsMonitor(input.status, input.assigneeAgentId ?? null, input.assigneeUserId ?? null)) {
throw unprocessable(MONITOR_INVALID_MESSAGE);
}
const exhaustedReason = exhaustedMonitorClearReason({
monitor: input.policy.monitor,
attemptCount: 0,
now: new Date(),
});
if (exhaustedReason) {
throw unprocessable(MONITOR_BOUNDS_EXHAUSTED_MESSAGE, { clearReason: exhaustedReason });
}
const monitorState = buildScheduledMonitorState(null, input.policy.monitor);
return {
@@ -22,8 +22,10 @@ export interface IssueLivenessIssueInput {
assigneeUserId?: string | null;
createdByAgentId?: string | null;
createdByUserId?: string | null;
executionPolicy?: Record<string, unknown> | null;
executionState?: Record<string, unknown> | null;
monitorNextCheckAt?: Date | string | null;
monitorAttemptCount?: number | null;
}
export interface IssueLivenessRelationInput {
@@ -100,6 +102,7 @@ export interface IssueGraphLivenessInput {
pendingInteractions?: IssueLivenessWaitingPathInput[];
pendingApprovals?: IssueLivenessWaitingPathInput[];
openRecoveryIssues?: IssueLivenessWaitingPathInput[];
now?: Date | string;
}
const INVOKABLE_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]);
@@ -141,8 +144,43 @@ function hasWaitingPath(
return waitingPaths.some((entry) => entry.companyId === companyId && entry.issueId === issueId);
}
function hasScheduledMonitor(issue: IssueLivenessIssueInput) {
return Boolean(issue.monitorNextCheckAt);
function readRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function readPositiveInteger(value: unknown): number | null {
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
}
function readDateMs(value: unknown): number | null {
if (!(typeof value === "string" || value instanceof Date)) return null;
const date = value instanceof Date ? value : new Date(value);
const time = date.getTime();
return Number.isNaN(time) ? null : time;
}
function monitorFromIssue(issue: IssueLivenessIssueInput) {
const policyMonitor = readRecord(readRecord(issue.executionPolicy)?.monitor);
const stateMonitor = readRecord(readRecord(issue.executionState)?.monitor);
return { policyMonitor, stateMonitor };
}
function hasScheduledMonitor(issue: IssueLivenessIssueInput, nowMs: number) {
const nextCheckAtMs = readDateMs(issue.monitorNextCheckAt);
if (nextCheckAtMs === null || nextCheckAtMs <= nowMs) return false;
const { policyMonitor, stateMonitor } = monitorFromIssue(issue);
const timeoutAtMs = readDateMs(policyMonitor?.timeoutAt ?? stateMonitor?.timeoutAt);
if (timeoutAtMs !== null && timeoutAtMs <= nowMs) return false;
const maxAttempts = readPositiveInteger(policyMonitor?.maxAttempts ?? stateMonitor?.maxAttempts);
const stateAttemptCount = readPositiveInteger(stateMonitor?.attemptCount) ?? 0;
const attemptCount = issue.monitorAttemptCount ?? stateAttemptCount;
if (maxAttempts !== null && attemptCount >= maxAttempts) return false;
return true;
}
function readPrincipalAgentId(principal: unknown): string | null {
@@ -313,6 +351,7 @@ function finding(input: {
}
export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): IssueLivenessFinding[] {
const nowMs = readDateMs(input.now ?? new Date()) ?? Date.now();
const issuesById = new Map(input.issues.map((issue) => [issue.id, issue]));
const agentsById = new Map(input.agents.map((agent) => [agent.id, agent]));
const blockersByBlockedIssueId = new Map<string, IssueLivenessRelationInput[]>();
@@ -356,7 +395,7 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu
function hasExplicitWaitingPath(issue: IssueLivenessIssueInput) {
return Boolean(issue.assigneeUserId) ||
hasScheduledMonitor(issue) ||
hasScheduledMonitor(issue, nowMs) ||
hasActiveExecutionPath(issue.companyId, issue.id, activeRuns, queuedWakeRequests) ||
hasWaitingPath(issue.companyId, issue.id, pendingInteractions) ||
hasWaitingPath(issue.companyId, issue.id, pendingApprovals) ||
+3
View File
@@ -1836,8 +1836,10 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
assigneeUserId: issues.assigneeUserId,
createdByAgentId: issues.createdByAgentId,
createdByUserId: issues.createdByUserId,
executionPolicy: issues.executionPolicy,
executionState: issues.executionState,
monitorNextCheckAt: issues.monitorNextCheckAt,
monitorAttemptCount: issues.monitorAttemptCount,
})
.from(issues)
.where(
@@ -1967,6 +1969,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
pendingInteractions: interactionRows,
pendingApprovals: approvalRows,
openRecoveryIssues,
now: new Date(),
});
}
@@ -126,6 +126,37 @@ describe("IssueMonitorActivityCard", () => {
act(() => root.unmount());
});
it("does not render external references from monitor metadata", () => {
const root = createRoot(container);
act(() => {
root.render(
<IssueMonitorActivityCard
issue={createIssue({
executionPolicy: {
mode: "normal",
commentRequired: true,
stages: [],
monitor: {
nextCheckAt: "2026-04-11T12:30:00.000Z",
notes: "Check deployment health",
scheduledBy: "board",
serviceName: "Deploy provider",
externalRef: "https://provider.example/deploy/123?token=secret",
},
},
})}
/>,
);
});
expect(container.textContent).toContain("Deploy provider");
expect(container.textContent).not.toContain("provider.example");
expect(container.textContent).not.toContain("token=secret");
act(() => root.unmount());
});
it("renders nothing when the issue has no scheduled monitor", () => {
const root = createRoot(container);
@@ -28,7 +28,6 @@ function resolveScheduledMonitor(issue: Issue) {
notes: issue.executionPolicy?.monitor?.notes ?? issue.monitorNotes ?? issue.executionState?.monitor?.notes ?? null,
attemptCount: issue.monitorAttemptCount ?? issue.executionState?.monitor?.attemptCount ?? 0,
serviceName: issue.executionPolicy?.monitor?.serviceName ?? issue.executionState?.monitor?.serviceName ?? null,
externalRef: issue.executionPolicy?.monitor?.externalRef ?? issue.executionState?.monitor?.externalRef ?? null,
};
}
@@ -57,9 +56,9 @@ export function IssueMonitorActivityCard({
{monitor.notes ? (
<div className="mt-1 text-xs text-muted-foreground">{monitor.notes}</div>
) : null}
{monitor.serviceName || monitor.externalRef ? (
{monitor.serviceName ? (
<div className="mt-1 text-xs text-muted-foreground">
{[monitor.serviceName, monitor.externalRef].filter(Boolean).join(" - ")}
{monitor.serviceName}
</div>
) : null}
{monitor.attemptCount > 0 ? (
+2 -13
View File
@@ -248,7 +248,6 @@ export function IssueProperties({
const [monitorAtInput, setMonitorAtInput] = useState(() => toDateTimeLocalValue(issue.executionPolicy?.monitor?.nextCheckAt));
const [monitorNotesInput, setMonitorNotesInput] = useState(issue.executionPolicy?.monitor?.notes ?? "");
const [monitorServiceInput, setMonitorServiceInput] = useState(issue.executionPolicy?.monitor?.serviceName ?? "");
const [monitorExternalRefInput, setMonitorExternalRefInput] = useState(issue.executionPolicy?.monitor?.externalRef ?? "");
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
@@ -489,12 +488,10 @@ export function IssueProperties({
setMonitorAtInput(toDateTimeLocalValue(issue.executionPolicy?.monitor?.nextCheckAt));
setMonitorNotesInput(issue.executionPolicy?.monitor?.notes ?? "");
setMonitorServiceInput(issue.executionPolicy?.monitor?.serviceName ?? "");
setMonitorExternalRefInput(issue.executionPolicy?.monitor?.externalRef ?? "");
}, [
issue.executionPolicy?.monitor?.nextCheckAt,
issue.executionPolicy?.monitor?.notes,
issue.executionPolicy?.monitor?.serviceName,
issue.executionPolicy?.monitor?.externalRef,
]);
const updateMonitor = (nextMonitor: Issue["executionPolicy"] extends infer T
@@ -525,14 +522,13 @@ export function IssueProperties({
const nextCheckAt = new Date(monitorAtInput);
if (Number.isNaN(nextCheckAt.getTime())) return;
const serviceName = monitorServiceInput.trim() || null;
const externalRef = monitorExternalRefInput.trim() || null;
updateMonitor({
nextCheckAt: nextCheckAt.toISOString(),
notes: monitorNotesInput.trim() || null,
scheduledBy: issue.executionPolicy?.monitor?.scheduledBy ?? "assignee",
kind: serviceName || externalRef ? "external_service" : null,
kind: serviceName ? "external_service" : null,
serviceName,
externalRef,
externalRef: null,
});
setMonitorOpen(false);
};
@@ -604,13 +600,6 @@ export function IssueProperties({
value={monitorServiceInput}
onChange={(e) => setMonitorServiceInput(e.target.value)}
/>
<input
type="text"
className="min-w-0 flex-1 rounded-md border border-border bg-transparent px-2 py-1 text-xs"
placeholder="External ref or URL"
value={monitorExternalRefInput}
onChange={(e) => setMonitorExternalRefInput(e.target.value)}
/>
<div className="flex items-center gap-2">
<button
type="button"
+2
View File
@@ -60,7 +60,9 @@ describe("activity formatting", () => {
it("formats monitor activity with direct verbs", () => {
expect(formatActivityVerb("issue.monitor_scheduled")).toBe("scheduled monitor on");
expect(formatActivityVerb("issue.monitor_exhausted")).toBe("exhausted monitor on");
expect(formatIssueActivityAction("issue.monitor_triggered")).toBe("triggered a monitor");
expect(formatIssueActivityAction("issue.monitor_cleared")).toBe("cleared a monitor");
expect(formatIssueActivityAction("issue.monitor_recovery_issue_created")).toBe("created a monitor recovery issue");
});
});
+8
View File
@@ -37,6 +37,10 @@ const ACTIVITY_ROW_VERBS: Record<string, string> = {
"issue.monitor_triggered": "triggered monitor for",
"issue.monitor_cleared": "cleared monitor on",
"issue.monitor_skipped": "skipped monitor for",
"issue.monitor_exhausted": "exhausted monitor on",
"issue.monitor_recovery_wake_queued": "queued monitor recovery for",
"issue.monitor_recovery_issue_created": "created monitor recovery for",
"issue.monitor_escalated_to_board": "escalated monitor for",
"issue.commented": "commented on",
"issue.deleted": "deleted",
"agent.created": "created",
@@ -83,6 +87,10 @@ const ISSUE_ACTIVITY_LABELS: Record<string, string> = {
"issue.monitor_triggered": "triggered a monitor",
"issue.monitor_cleared": "cleared a monitor",
"issue.monitor_skipped": "skipped a monitor",
"issue.monitor_exhausted": "exhausted a monitor",
"issue.monitor_recovery_wake_queued": "queued a monitor recovery wake",
"issue.monitor_recovery_issue_created": "created a monitor recovery issue",
"issue.monitor_escalated_to_board": "escalated a monitor to the board",
"issue.deleted": "deleted the issue",
"agent.created": "created an agent",
"agent.updated": "updated the agent",