mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
Harden issue monitor follow-up semantics
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
5ed2452e2d
commit
145afe7431
16 files changed
+1016
-57
No files matched your search
@@ -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`
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user