[codex] Add issue monitor liveness controls (#4988)

## Thinking Path

> - Paperclip is a control plane for autonomous AI companies where work
must stay observable, governable, and recoverable.
> - The task/heartbeat subsystem owns agent execution continuity, issue
state transitions, and visible recovery behavior.
> - Waiting on an external service is not the same as being blocked when
the assignee still owns a future check.
> - The gap was that agents had no first-class one-shot monitor state
for external-service waits, so recovery could look stalled or require ad
hoc comments.
> - This pull request adds bounded issue monitors that can wake the
owner, clear exhausted waits, and produce explicit recovery behavior.
> - It also surfaces monitor status in the board UI and documents when
to use monitors versus `blocked`.
> - The benefit is clearer liveness semantics for asynchronous waits
without weakening single-assignee task ownership.

## What Changed

- Added issue monitor fields, shared types, validators, constants, and
an idempotent `0075` migration for scheduled monitor state.
- Added server-side monitor scheduling, dispatch, recovery bounds,
activity logging, and external-ref redaction.
- Added board/agent route coverage for monitor permissions and child
monitor scheduling.
- Added issue detail/property UI for monitor state, a monitor activity
card, and Storybook stories for review surfaces.
- Documented monitor semantics and recovery policy behavior in
`doc/execution-semantics.md`.
- Addressed Greptile review feedback by preserving monitor state in
skipped-stage builders and making board monitor saves send `scheduledBy:
"board"`.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm run preflight:workspace-links && pnpm exec vitest run
server/src/__tests__/issue-execution-policy-routes.test.ts
server/src/__tests__/issue-execution-policy.test.ts
server/src/__tests__/issue-monitor-scheduler.test.ts
server/src/__tests__/recovery-classifiers.test.ts
ui/src/components/IssueMonitorActivityCard.test.tsx
ui/src/components/IssueProperties.test.tsx
ui/src/lib/activity-format.test.ts`
- First run passed 5 files and failed to collect 2 server suites because
the worktree was missing the optional `acpx/runtime` dependency.
- After `pnpm install --frozen-lockfile`, reran the 2 failed suites
successfully.
- `pnpm exec vitest run
server/src/__tests__/issue-monitor-scheduler.test.ts
server/src/__tests__/recovery-classifiers.test.ts`
- `pnpm --filter @paperclipai/shared typecheck && pnpm --filter
@paperclipai/db typecheck && pnpm --filter @paperclipai/server typecheck
&& pnpm --filter @paperclipai/ui typecheck`
- `pnpm exec vitest run
server/src/__tests__/issue-execution-policy.test.ts
ui/src/components/IssueProperties.test.tsx`
- `pnpm --filter @paperclipai/server typecheck && pnpm --filter
@paperclipai/ui typecheck`
- `pnpm exec vitest run
ui/src/components/IssueMonitorActivityCard.test.tsx
ui/src/components/IssueProperties.test.tsx`
- `pnpm --filter @paperclipai/ui typecheck`
- Storybook screenshot captured from
`http://127.0.0.1:6006/iframe.html?viewMode=story&id=product-issue-monitor-surfaces--monitor-surfaces`
with Playwright.

## Screenshots

![Issue monitor Storybook
surfaces](https://raw.githubusercontent.com/paperclipai/paperclip/PAP-2945-when-a-task-is-waiting-for-an-_external-service_-what-state-should-it-be-in-and-what-recovery-method-could-it-h/docs/pr-screenshots/pap-2945/monitor-surfaces.png)

## Risks

- Medium: this changes heartbeat recovery behavior for scheduled
external-service waits, so regressions could affect wake timing or
recovery issue creation.
- Migration risk is reduced by using `IF NOT EXISTS` for the new issue
monitor columns and index.
- External monitor references are treated as secret-adjacent and are
intentionally omitted from visible activity/wake payloads.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent with repository tool use and terminal
execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots or Storybook review surfaces
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-05-03 08:58:53 -05:00 committed by GitHub
parent 76f09c8eb6
commit 57229d0f24
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 19324 additions and 20 deletions

View file

@ -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(),
@ -16,21 +17,26 @@ const mockIssueService = vi.hoisted(() => ({
const mockHeartbeatService = vi.hoisted(() => ({
wakeup: vi.fn(async () => undefined),
triggerIssueMonitor: vi.fn(async () => ({ outcome: "triggered" as const })),
reportRunActivity: vi.fn(async () => undefined),
getRun: vi.fn(async () => null),
getActiveRunForAgent: vi.fn(async () => null),
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),
}),
@ -42,6 +48,9 @@ function registerModuleMocks() {
}),
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
environmentService: () => ({
getById: vi.fn(async () => null),
}),
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
@ -67,7 +76,7 @@ function registerModuleMocks() {
syncIssue: async () => undefined,
}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
@ -76,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"),
@ -84,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"],
@ -111,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 () => {
@ -162,4 +197,175 @@ describe("issue execution policy routes", () => {
expect(updatePatch.executionState).toBeUndefined();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("triggers a scheduled monitor immediately from the dedicated route", async () => {
const issue = {
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: "Manual monitor trigger",
executionPolicy: normalizeIssueExecutionPolicy({
monitor: {
nextCheckAt: "2026-04-11T12:30:00.000Z",
notes: "Check deployment",
scheduledBy: "board",
},
}),
executionState: null,
};
mockIssueService.getById.mockResolvedValue(issue);
const res = await request(await createApp())
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/monitor/check-now")
.send({});
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
expect(mockHeartbeatService.triggerIssueMonitor).toHaveBeenCalledWith(
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
expect.objectContaining({
actorType: "user",
actorId: "local-board",
agentId: null,
}),
);
});
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() }),
}),
);
});
});

View file

@ -112,6 +112,26 @@ describe("normalizeIssueExecutionPolicy", () => {
it("throws for invalid input", () => {
expect(() => normalizeIssueExecutionPolicy({ stages: [{ type: "invalid_type" }] })).toThrow();
});
it("keeps monitor-only policies", () => {
const result = normalizeIssueExecutionPolicy({
monitor: {
nextCheckAt: "2026-04-11T12:30:00.000Z",
notes: "Check deployment",
externalRef: "https://example.test/deploy?token=secret",
},
stages: [],
});
expect(result).toMatchObject({
stages: [],
monitor: {
nextCheckAt: "2026-04-11T12:30:00.000Z",
notes: "Check deployment",
scheduledBy: "assignee",
externalRef: "[redacted]",
},
});
});
});
describe("parseIssueExecutionState", () => {
@ -1261,4 +1281,169 @@ describe("issue execution policy transitions", () => {
});
});
});
describe("monitor policy", () => {
it("schedules a one-shot monitor on an active agent-owned issue", () => {
const policy = normalizeIssueExecutionPolicy({
stages: [],
monitor: {
nextCheckAt: "2026-04-11T12:30:00.000Z",
notes: "Check deployment",
scheduledBy: "board",
},
})!;
const result = applyIssueExecutionPolicyTransition({
issue: {
status: "in_progress",
assigneeAgentId: coderAgentId,
assigneeUserId: null,
executionPolicy: null,
executionState: null,
monitorAttemptCount: 0,
monitorNextCheckAt: null,
monitorLastTriggeredAt: null,
monitorNotes: null,
monitorScheduledBy: null,
},
policy,
previousPolicy: null,
requestedAssigneePatch: {},
actor: { userId: boardUserId },
monitorExplicitlyUpdated: true,
});
expect(result.patch.monitorNextCheckAt).toEqual(new Date("2026-04-11T12:30:00.000Z"));
expect(result.patch.monitorScheduledBy).toBe("board");
expect(result.patch.executionState).toMatchObject({
status: "idle",
monitor: {
status: "scheduled",
nextCheckAt: "2026-04-11T12:30:00.000Z",
notes: "Check deployment",
scheduledBy: "board",
},
});
});
it("auto-clears a scheduled monitor when the issue moves to done", () => {
const policy = normalizeIssueExecutionPolicy({
stages: [],
monitor: {
nextCheckAt: "2026-04-11T12:30:00.000Z",
notes: "Check deployment",
scheduledBy: "assignee",
},
})!;
const result = applyIssueExecutionPolicyTransition({
issue: {
status: "in_progress",
assigneeAgentId: coderAgentId,
assigneeUserId: null,
executionPolicy: policy,
executionState: {
status: "idle",
currentStageId: null,
currentStageIndex: null,
currentStageType: null,
currentParticipant: null,
returnAssignee: null,
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
monitor: {
status: "scheduled",
nextCheckAt: "2026-04-11T12:30:00.000Z",
lastTriggeredAt: null,
attemptCount: 0,
notes: "Check deployment",
scheduledBy: "assignee",
clearedAt: null,
clearReason: null,
},
},
monitorAttemptCount: 0,
monitorNextCheckAt: new Date("2026-04-11T12:30:00.000Z"),
monitorLastTriggeredAt: null,
monitorNotes: "Check deployment",
monitorScheduledBy: "assignee",
},
policy,
previousPolicy: policy,
requestedStatus: "done",
requestedAssigneePatch: {},
actor: { agentId: coderAgentId },
});
expect(result.patch.executionPolicy).toBeNull();
expect(result.patch.monitorNextCheckAt).toBeNull();
expect(result.patch.executionState).toMatchObject({
monitor: {
status: "cleared",
clearReason: "done",
},
});
});
it("rejects explicitly scheduling a monitor on an invalid issue state", () => {
const policy = normalizeIssueExecutionPolicy({
stages: [],
monitor: {
nextCheckAt: "2026-04-11T12:30:00.000Z",
notes: "Check deployment",
},
})!;
expect(() =>
applyIssueExecutionPolicyTransition({
issue: {
status: "blocked",
assigneeAgentId: coderAgentId,
assigneeUserId: null,
executionPolicy: null,
executionState: null,
},
policy,
previousPolicy: null,
requestedAssigneePatch: {},
actor: { agentId: coderAgentId },
monitorExplicitlyUpdated: true,
}),
).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");
});
});
});

View file

@ -0,0 +1,448 @@
import { randomUUID } from "node:crypto";
import { eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agentRuntimeState,
agentWakeupRequests,
agents,
companies,
companySkills,
createDb,
documentRevisions,
documents,
environmentLeases,
heartbeatRunEvents,
heartbeatRuns,
issueComments,
issueDocuments,
issues,
workspaceRuntimeServices,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { heartbeatService } from "../services/heartbeat.ts";
import { normalizeIssueExecutionPolicy, parseIssueExecutionState } from "../services/issue-execution-policy.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres issue monitor scheduler tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("issue monitor scheduler", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const seededAgentIds = new Set<string>();
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-monitor-");
db = createDb(tempDb.connectionString);
}, 20_000);
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));
}
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 () => {
await tempDb?.cleanup();
});
async function seedFixture(input?: {
agentStatus?: "active" | "paused";
issueStatus?: "in_progress" | "in_review";
monitorAttemptCount?: number;
monitor?: Record<string, unknown>;
}) {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
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",
issuePrefix,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Monitor Bot",
role: "engineer",
status: input?.agentStatus ?? "active",
adapterType: "process",
adapterConfig: {
command: process.execPath,
args: ["-e", ""],
cwd: process.cwd(),
},
runtimeConfig: {
heartbeat: {
enabled: false,
wakeOnDemand: true,
},
},
permissions: {},
});
seededAgentIds.add(agentId);
await db.insert(issues).values({
id: issueId,
companyId,
title: "Watch external deploy",
status: input?.issueStatus ?? "in_progress",
priority: "medium",
assigneeAgentId: agentId,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
executionPolicy: {
mode: "normal",
commentRequired: true,
stages: [],
monitor,
},
executionState: {
status: "idle",
currentStageId: null,
currentStageIndex: null,
currentStageType: null,
currentParticipant: null,
returnAssignee: null,
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
monitor: {
status: "scheduled",
nextCheckAt: nextCheckAt.toISOString(),
lastTriggeredAt: null,
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,
monitorNotes: "Check deploy",
monitorScheduledBy: "assignee",
});
return { companyId, agentId, issueId, nextCheckAt };
}
it("triggers due issue monitors once and clears the one-shot schedule", async () => {
const { issueId, agentId } = await seedFixture();
const heartbeat = heartbeatService(db);
const tickAt = new Date("2026-04-11T12:31:00.000Z");
const result = await heartbeat.tickTimers(tickAt);
expect(result.enqueued).toBe(1);
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]!);
expect(issue.monitorNextCheckAt).toBeNull();
expect(issue.monitorAttemptCount).toBe(1);
expect(issue.monitorLastTriggeredAt?.toISOString()).toBe(tickAt.toISOString());
expect(normalizeIssueExecutionPolicy(issue.executionPolicy ?? null)?.monitor ?? null).toBeNull();
expect(parseIssueExecutionState(issue.executionState)?.monitor).toMatchObject({
status: "triggered",
lastTriggeredAt: tickAt.toISOString(),
attemptCount: 1,
});
const wakeup = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, agentId))
.then((rows) => rows[0] ?? null);
expect(wakeup?.reason).toBe("issue_monitor_due");
const activity = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, issueId))
.then((rows) => rows.map((row) => row.action));
expect(activity).toContain("issue.monitor_triggered");
});
it("lets the board trigger a scheduled issue monitor immediately", async () => {
const { issueId, agentId, nextCheckAt } = await seedFixture();
const heartbeat = heartbeatService(db);
const triggeredAt = new Date("2026-04-11T12:00:00.000Z");
const result = await heartbeat.triggerIssueMonitor(issueId, {
now: triggeredAt,
actorType: "user",
actorId: "local-board",
});
expect(result.outcome).toBe("triggered");
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]!);
expect(issue.monitorNextCheckAt).toBeNull();
expect(issue.monitorLastTriggeredAt?.toISOString()).toBe(triggeredAt.toISOString());
expect(issue.monitorAttemptCount).toBe(1);
expect(normalizeIssueExecutionPolicy(issue.executionPolicy ?? null)?.monitor ?? null).toBeNull();
const wakeup = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, agentId))
.then((rows) => rows[0] ?? null);
expect(wakeup?.reason).toBe("issue_monitor_due");
expect(wakeup?.payload).toMatchObject({
issueId,
nextCheckAt: nextCheckAt.toISOString(),
source: "manual",
});
const activity = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, issueId))
.orderBy(activityLog.createdAt);
expect(activity.map((row) => row.action)).toContain("issue.monitor_triggered");
const triggerEvent = activity.find((row) => row.action === "issue.monitor_triggered");
expect(triggerEvent?.actorType).toBe("user");
expect(triggerEvent?.actorId).toBe("local-board");
expect(triggerEvent?.details).toMatchObject({
nextCheckAt: nextCheckAt.toISOString(),
source: "manual",
});
});
it("clears due monitors that cannot be dispatched and records a skip", async () => {
const { issueId } = await seedFixture({ agentStatus: "paused" });
const heartbeat = heartbeatService(db);
const tickAt = new Date("2026-04-11T12:31:00.000Z");
const result = await heartbeat.tickTimers(tickAt);
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: "dispatch_skipped",
});
const activity = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, issueId))
.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");
});
});

View file

@ -74,6 +74,100 @@ describe("recovery classifier boundary", () => {
expect(classifyIssueGraphLiveness(input)).toEqual(classifyIssueGraphLivenessCompat(input));
});
it("treats a scheduled monitor as an explicit review action path", () => {
const findings = classifyIssueGraphLiveness({
now: "2026-04-30T18:00:00.000Z",
issues: [
{
id: issueId,
companyId,
identifier: "PAP-2945",
title: "Wait for external review",
status: "in_review",
assigneeAgentId: agentId,
assigneeUserId: null,
createdByAgentId: null,
createdByUserId: null,
executionState: null,
monitorNextCheckAt: "2026-04-30T19:00:00.000Z",
},
],
relations: [],
agents: [
{
id: agentId,
companyId,
name: "Coder",
role: "engineer",
status: "idle",
reportsTo: managerId,
},
],
});
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: {

View file

@ -81,6 +81,8 @@ import {
applyIssueExecutionPolicyTransition,
normalizeIssueExecutionPolicy,
parseIssueExecutionState,
redactIssueMonitorExternalRef,
setIssueExecutionPolicyMonitorScheduledBy,
} from "../services/issue-execution-policy.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
@ -165,6 +167,53 @@ function summarizeIssueReferenceActivityDetails(input:
};
}
function monitorPoliciesEqual(left: NormalizedExecutionPolicy | null, right: NormalizedExecutionPolicy | null) {
return JSON.stringify(left?.monitor ?? null) === JSON.stringify(right?.monitor ?? null);
}
function applyActorMonitorScheduledBy(
policy: NormalizedExecutionPolicy | null,
actorType: "agent" | "user",
) {
return setIssueExecutionPolicyMonitorScheduledBy(policy, actorType === "user" ? "board" : "assignee");
}
function assertCanManageIssueMonitor(req: Request, assigneeAgentId: string | null, monitorChanged: boolean) {
if (!monitorChanged) return;
if (req.actor.type === "board") return;
if (req.actor.type === "agent" && req.actor.agentId && req.actor.agentId === assigneeAgentId) return;
throw forbidden("Only the assignee agent or a board user can manage issue monitors");
}
function summarizeIssueMonitor(
issue: {
monitorNextCheckAt?: Date | null;
monitorLastTriggeredAt?: Date | null;
monitorAttemptCount?: number | null;
monitorNotes?: string | null;
monitorScheduledBy?: string | null;
executionState?: unknown;
},
policy: NormalizedExecutionPolicy | null,
) {
const state = parseIssueExecutionState(issue.executionState);
return {
nextCheckAt: issue.monitorNextCheckAt?.toISOString() ?? policy?.monitor?.nextCheckAt ?? null,
lastTriggeredAt: issue.monitorLastTriggeredAt?.toISOString() ?? state?.monitor?.lastTriggeredAt ?? null,
attemptCount: issue.monitorAttemptCount ?? state?.monitor?.attemptCount ?? 0,
notes: policy?.monitor?.notes ?? issue.monitorNotes ?? state?.monitor?.notes ?? null,
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: 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,
status: state?.monitor?.status ?? (policy?.monitor ? "scheduled" : null),
clearReason: state?.monitor?.clearReason ?? null,
};
}
function activityExecutionParticipantKey(participant: ActivityExecutionParticipant): string {
return participant.type === "agent" ? `agent:${participant.agentId}` : `user:${participant.userId}`;
}
@ -1812,7 +1861,11 @@ export function issueRoutes(
await assertIssueEnvironmentSelection(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 = await svc.create(companyId, {
...req.body,
executionPolicy,
@ -1847,6 +1900,29 @@ export function issueRoutes(
},
});
if (executionPolicy?.monitor) {
await logActivity(db, {
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,
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,
@ -1879,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,
@ -1908,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,
@ -1921,6 +2025,27 @@ export function issueRoutes(
res.status(201).json(issue);
});
router.post("/issues/:id/monitor/check-now", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
assertCanManageIssueMonitor(req, issue.assigneeAgentId, true);
const actor = getActorInfo(req);
await heartbeat.triggerIssueMonitor(issue.id, {
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId ?? null,
runId: actor.runId ?? null,
});
res.json({ ok: true });
});
router.patch("/issues/:id", validate(updateIssueRouteSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
@ -2043,7 +2168,10 @@ export function issueRoutes(
updateFields.status = "todo";
}
if (req.body.executionPolicy !== undefined) {
updateFields.executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy);
updateFields.executionPolicy = applyActorMonitorScheduledBy(
normalizeIssueExecutionPolicy(req.body.executionPolicy),
actor.actorType,
);
}
const previousExecutionPolicy = normalizeIssueExecutionPolicy(existing.executionPolicy ?? null);
const nextExecutionPolicy =
@ -2053,10 +2181,13 @@ export function issueRoutes(
if (normalizedAssigneeAgentId !== undefined) {
updateFields.assigneeAgentId = normalizedAssigneeAgentId;
}
const monitorChanged = monitorPoliciesEqual(previousExecutionPolicy, nextExecutionPolicy) === false;
assertCanManageIssueMonitor(req, existing.assigneeAgentId, req.body.executionPolicy !== undefined && monitorChanged);
const transition = applyIssueExecutionPolicyTransition({
issue: existing,
policy: nextExecutionPolicy,
previousPolicy: previousExecutionPolicy,
requestedStatus: typeof updateFields.status === "string" ? updateFields.status : undefined,
requestedAssigneePatch: {
assigneeAgentId: normalizedAssigneeAgentId,
@ -2069,6 +2200,7 @@ export function issueRoutes(
},
commentBody,
reviewRequest: reviewRequest === undefined ? undefined : reviewRequest,
monitorExplicitlyUpdated: req.body.executionPolicy !== undefined && monitorChanged,
});
const decisionId = transition.decision ? randomUUID() : null;
if (decisionId) {
@ -2372,6 +2504,51 @@ export function issueRoutes(
});
}
const nextStoredExecutionPolicy = normalizeIssueExecutionPolicy(issue.executionPolicy ?? null);
const previousMonitor = summarizeIssueMonitor(existing, previousExecutionPolicy);
const nextMonitor = summarizeIssueMonitor(issue, nextStoredExecutionPolicy);
const monitorScheduledChanged = previousMonitor.nextCheckAt !== nextMonitor.nextCheckAt;
if (nextMonitor.nextCheckAt && (monitorScheduledChanged || previousMonitor.notes !== nextMonitor.notes)) {
await logActivity(db, {
companyId: issue.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,
nextCheckAt: nextMonitor.nextCheckAt,
previousNextCheckAt: previousMonitor.nextCheckAt,
notes: nextMonitor.notes,
scheduledBy: nextMonitor.scheduledBy,
serviceName: nextMonitor.serviceName,
timeoutAt: nextMonitor.timeoutAt,
maxAttempts: nextMonitor.maxAttempts,
recoveryPolicy: nextMonitor.recoveryPolicy,
},
});
} else if (!nextMonitor.nextCheckAt && previousMonitor.nextCheckAt) {
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.monitor_cleared",
entityType: "issue",
entityId: issue.id,
details: {
identifier: issue.identifier,
previousNextCheckAt: previousMonitor.nextCheckAt,
reason: nextMonitor.clearReason ?? "manual",
notes: previousMonitor.notes,
},
});
}
if (issue.status === "done" && existing.status !== "done") {
const tc = getTelemetryClient();
if (tc && actor.agentId) {

View file

@ -3,7 +3,7 @@ import path from "node:path";
import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";
import { randomUUID } from "node:crypto";
import { and, asc, desc, eq, getTableColumns, gt, inArray, isNull, lte, notInArray, or, sql } from "drizzle-orm";
import { and, asc, desc, eq, getTableColumns, gt, inArray, isNull, lt, lte, notInArray, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
@ -14,6 +14,9 @@ import {
type EnvironmentLeaseStatus,
type ExecutionWorkspace,
type ExecutionWorkspaceConfig,
type IssueExecutionMonitorClearReason,
type IssueExecutionMonitorPolicy,
type IssueExecutionMonitorRecoveryPolicy,
type ModelProfileKey,
type RunLivenessState,
} from "@paperclipai/shared";
@ -85,7 +88,12 @@ import {
sanitizeRuntimeServiceBaseEnv,
} from "./workspace-runtime.js";
import { issueService } from "./issues.js";
import { parseIssueExecutionState } from "./issue-execution-policy.js";
import {
buildIssueMonitorClearedPatch,
buildIssueMonitorTriggeredPatch,
normalizeIssueExecutionPolicy,
parseIssueExecutionState,
} from "./issue-execution-policy.js";
import {
ISSUE_TREE_CONTROL_INTERACTION_WAKE_REASONS,
isVerifiedIssueTreeControlInteractionWake,
@ -2328,6 +2336,689 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
.then((rows) => rows[0] ?? null);
}
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,
monitorWakeRequestedAt: issues.monitorWakeRequestedAt,
monitorLastTriggeredAt: issues.monitorLastTriggeredAt,
monitorAttemptCount: issues.monitorAttemptCount,
monitorNotes: issues.monitorNotes,
monitorScheduledBy: issues.monitorScheduledBy,
};
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;
monitorWakeRequestedAt: Date | null;
monitorLastTriggeredAt: Date | null;
monitorAttemptCount: number | null;
monitorNotes: string | null;
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: {
now: Date;
source: "automation" | "on_demand";
triggerDetail: "manual" | "system";
wakeReason: string;
actorType: "user" | "agent" | "system";
actorId: string;
agentId: string | null;
runId: string | null;
clearOnClientError: boolean;
activitySource: "manual" | "scheduled";
},
) {
if (!claimed.assigneeAgentId || !claimed.monitorNextCheckAt) {
throw conflict("Issue monitor is not ready to dispatch");
}
const scheduledAtIso = claimed.monitorNextCheckAt.toISOString();
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,
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,
triggerDetail: input.triggerDetail,
reason: input.wakeReason,
idempotencyKey: `issue-monitor:${claimed.id}:${scheduledAtIso}`,
payload: {
issueId: claimed.id,
nextCheckAt: scheduledAtIso,
monitorAttemptCount: nextAttemptCount,
monitorNotes: claimed.monitorNotes ?? null,
...monitorMetadata,
source: input.activitySource,
},
requestedByActorType: input.actorType,
requestedByActorId: input.actorId,
contextSnapshot: {
issueId: claimed.id,
source: "issue.monitor",
wakeReason: input.wakeReason,
nextCheckAt: scheduledAtIso,
monitorAttemptCount: nextAttemptCount,
monitorNotes: claimed.monitorNotes ?? null,
...monitorMetadata,
manualTrigger: input.activitySource === "manual",
},
});
await db
.update(issues)
.set({
...buildIssueMonitorTriggeredPatch({
issue: claimed,
policy,
triggeredAt: input.now,
}),
updatedAt: new Date(),
})
.where(eq(issues.id, claimed.id));
await logActivity(db, {
companyId: claimed.companyId,
actorType: input.actorType,
actorId: input.actorId,
agentId: input.agentId,
runId: input.runId,
action: "issue.monitor_triggered",
entityType: "issue",
entityId: claimed.id,
details: {
identifier: claimed.identifier,
nextCheckAt: scheduledAtIso,
lastTriggeredAt: input.now.toISOString(),
attemptCount: nextAttemptCount,
notes: claimed.monitorNotes ?? null,
...monitorMetadata,
source: input.activitySource,
},
});
return { outcome: "triggered" as const };
} catch (err) {
if (err instanceof HttpError && err.status >= 400 && err.status < 500) {
if (input.clearOnClientError) {
await db
.update(issues)
.set({
...buildIssueMonitorClearedPatch({
issue: claimed,
policy,
clearReason: "dispatch_skipped",
clearedAt: input.now,
}),
updatedAt: new Date(),
})
.where(eq(issues.id, claimed.id));
await logActivity(db, {
companyId: claimed.companyId,
actorType: input.actorType,
actorId: input.actorId,
agentId: input.agentId,
runId: input.runId,
action: "issue.monitor_skipped",
entityType: "issue",
entityId: claimed.id,
details: {
identifier: claimed.identifier,
nextCheckAt: scheduledAtIso,
attemptCount: nextAttemptCount,
notes: claimed.monitorNotes ?? null,
reason: err.message,
source: input.activitySource,
},
});
return { outcome: "skipped" as const, reason: err.message };
}
await db
.update(issues)
.set({
monitorWakeRequestedAt: null,
updatedAt: new Date(),
})
.where(eq(issues.id, claimed.id));
} else {
await db
.update(issues)
.set({
monitorWakeRequestedAt: null,
updatedAt: new Date(),
})
.where(eq(issues.id, claimed.id));
}
throw err;
}
}
async function triggerIssueMonitor(issueId: string, input?: {
now?: Date;
actorType?: "user" | "agent" | "system";
actorId?: string | null;
agentId?: string | null;
runId?: string | null;
wakeReason?: string;
}) {
const now = input?.now ?? new Date();
const actorType = input?.actorType ?? "system";
const actorId = input?.actorId ?? (actorType === "system" ? "heartbeat_scheduler" : null);
if (!actorId) {
throw conflict("Issue monitor trigger requires an actor");
}
const issue = await db
.select(issueMonitorDispatchColumns)
.from(issues)
.where(eq(issues.id, issueId))
.limit(1)
.then((rows) => rows[0] ?? null);
if (!issue) {
throw notFound("Issue not found");
}
if (!issue.monitorNextCheckAt) {
throw conflict("Issue has no scheduled monitor");
}
if (!issue.assigneeAgentId || issue.assigneeUserId) {
throw conflict("Issue monitor requires an agent assignee");
}
if (!["in_progress", "in_review"].includes(issue.status)) {
throw conflict("Issue monitor can only run while the issue is in progress or in review");
}
const staleClaimThreshold = new Date(now.getTime() - 5 * 60 * 1000);
const claimed = await db.transaction(async (tx) => {
const [updated] = await tx
.update(issues)
.set({
monitorWakeRequestedAt: now,
updatedAt: now,
})
.where(
and(
eq(issues.id, issueId),
sql`${issues.monitorNextCheckAt} is not null`,
isNull(issues.assigneeUserId),
sql`${issues.assigneeAgentId} is not null`,
inArray(issues.status, ["in_progress", "in_review"]),
or(
isNull(issues.monitorWakeRequestedAt),
lt(issues.monitorWakeRequestedAt, staleClaimThreshold),
),
),
)
.returning();
return (updated ?? null) as IssueMonitorDispatchRow | null;
});
if (!claimed) {
throw conflict("Issue monitor check is already in progress");
}
return dispatchClaimedIssueMonitor(claimed, {
now,
source: "on_demand",
triggerDetail: "manual",
wakeReason: input?.wakeReason ?? "issue_monitor_due",
actorType,
actorId,
agentId: input?.agentId ?? null,
runId: input?.runId ?? null,
clearOnClientError: false,
activitySource: "manual",
});
}
async function tickDueIssueMonitors(now = new Date()) {
const staleClaimThreshold = new Date(now.getTime() - 5 * 60 * 1000);
const dueMonitors = await db
.select(issueMonitorDispatchColumns)
.from(issues)
.where(
and(
sql`${issues.monitorNextCheckAt} is not null`,
lte(issues.monitorNextCheckAt, now),
isNull(issues.assigneeUserId),
sql`${issues.assigneeAgentId} is not null`,
inArray(issues.status, ["in_progress", "in_review"]),
or(
isNull(issues.monitorWakeRequestedAt),
lt(issues.monitorWakeRequestedAt, staleClaimThreshold),
),
),
)
.orderBy(asc(issues.monitorNextCheckAt), asc(issues.updatedAt))
.limit(50);
let triggered = 0;
let skipped = 0;
for (const due of dueMonitors) {
const claimed = await db.transaction(async (tx) => {
const [updated] = await tx
.update(issues)
.set({
monitorWakeRequestedAt: now,
updatedAt: now,
})
.where(
and(
eq(issues.id, due.id),
sql`${issues.monitorNextCheckAt} is not null`,
lte(issues.monitorNextCheckAt, now),
isNull(issues.assigneeUserId),
sql`${issues.assigneeAgentId} is not null`,
inArray(issues.status, ["in_progress", "in_review"]),
or(
isNull(issues.monitorWakeRequestedAt),
lt(issues.monitorWakeRequestedAt, staleClaimThreshold),
),
),
)
.returning();
return (updated ?? null) as IssueMonitorDispatchRow | null;
});
if (!claimed) continue;
try {
const result = await dispatchClaimedIssueMonitor(claimed, {
now,
source: "automation",
triggerDetail: "system",
wakeReason: "issue_monitor_due",
actorType: "system",
actorId: "heartbeat_scheduler",
agentId: null,
runId: null,
clearOnClientError: true,
activitySource: "scheduled",
});
if (result.outcome === "triggered") triggered += 1;
if (result.outcome === "skipped") skipped += 1;
} catch (err) {
logger.error({ err, issueId: claimed.id }, "issue monitor tick failed");
}
}
return {
checked: dueMonitors.length,
triggered,
skipped,
};
}
async function getOldestRunForSession(agentId: string, sessionId: string) {
return db
.select({
@ -7735,6 +8426,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
}),
wakeup: enqueueWakeup,
triggerIssueMonitor,
reportRunActivity: clearDetachedRunWarning,
@ -7804,7 +8496,13 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
else skipped += 1;
}
return { checked, enqueued, skipped };
const issueMonitors = await tickDueIssueMonitors(now);
return {
checked: checked + issueMonitors.checked,
enqueued: enqueued + issueMonitors.triggered,
skipped: skipped + issueMonitors.skipped,
};
},
cancelRun: (runId: string) => cancelRunInternal(runId),

View file

@ -1,5 +1,15 @@
import { randomUUID } from "node:crypto";
import type { IssueExecutionDecision, IssueExecutionPolicy, IssueExecutionStage, IssueExecutionStagePrincipal, IssueExecutionState } from "@paperclipai/shared";
import type {
IssueExecutionDecision,
IssueExecutionMonitorClearReason,
IssueExecutionMonitorPolicy,
IssueExecutionMonitorState,
IssueExecutionPolicy,
IssueExecutionStage,
IssueExecutionStagePrincipal,
IssueExecutionState,
IssueMonitorScheduledBy,
} from "@paperclipai/shared";
import { issueExecutionPolicySchema, issueExecutionStateSchema } from "@paperclipai/shared";
import { unprocessable } from "../errors.js";
@ -12,6 +22,12 @@ type IssueLike = AssigneeLike & {
status: string;
executionPolicy?: IssueExecutionPolicy | Record<string, unknown> | null;
executionState?: IssueExecutionState | Record<string, unknown> | null;
monitorNextCheckAt?: Date | null;
monitorWakeRequestedAt?: Date | null;
monitorLastTriggeredAt?: Date | null;
monitorAttemptCount?: number | null;
monitorNotes?: string | null;
monitorScheduledBy?: string | null;
};
type ActorLike = {
@ -27,11 +43,13 @@ type RequestedAssigneePatch = {
type TransitionInput = {
issue: IssueLike;
policy: IssueExecutionPolicy | null;
previousPolicy?: IssueExecutionPolicy | null;
requestedStatus?: string;
requestedAssigneePatch: RequestedAssigneePatch;
actor: ActorLike;
commentBody?: string | null;
reviewRequest?: IssueExecutionState["reviewRequest"] | null;
monitorExplicitlyUpdated?: boolean;
};
type TransitionResult = {
@ -43,6 +61,280 @@ type TransitionResult = {
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;
const trimmed = notes.trim();
return trimmed.length > 0 ? trimmed : null;
}
function normalizeMonitorText(value: string | null | undefined) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
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: redactIssueMonitorExternalRef(monitor.externalRef),
timeoutAt: monitor.timeoutAt ?? null,
maxAttempts: monitor.maxAttempts ?? null,
recoveryPolicy: monitor.recoveryPolicy ?? null,
};
}
function monitorMetadataFromState(state: IssueExecutionMonitorState | null | undefined) {
return {
kind: state?.kind ?? null,
serviceName: normalizeMonitorText(state?.serviceName),
externalRef: redactIssueMonitorExternalRef(state?.externalRef),
timeoutAt: state?.timeoutAt ?? null,
maxAttempts: state?.maxAttempts ?? null,
recoveryPolicy: state?.recoveryPolicy ?? null,
};
}
function blankExecutionState(): IssueExecutionState {
return {
status: "idle",
currentStageId: null,
currentStageIndex: null,
currentStageType: null,
currentParticipant: null,
returnAssignee: null,
reviewRequest: null,
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
monitor: null,
};
}
function isoString(value: Date | string | null | undefined): string | null {
if (!value) return null;
if (value instanceof Date) return value.toISOString();
return value;
}
function monitorStatesEqual(left: IssueExecutionMonitorState | null, right: IssueExecutionMonitorState | null): boolean {
return JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
}
function executionStateWithMonitor(
stageState: IssueExecutionState | null,
monitorState: IssueExecutionMonitorState | null,
): IssueExecutionState | null {
if (!stageState && !monitorState) return null;
const base = stageState ? { ...stageState } : blankExecutionState();
return {
...base,
monitor: monitorState,
};
}
function derivePersistedMonitorState(input: {
issue: IssueLike;
state: IssueExecutionState | null;
policy: IssueExecutionPolicy | null;
}): IssueExecutionMonitorState | null {
const fromState = input.state?.monitor ?? null;
const scheduledMonitor = input.policy?.monitor ?? null;
const nextCheckAt = isoString(input.issue.monitorNextCheckAt) ?? scheduledMonitor?.nextCheckAt ?? fromState?.nextCheckAt ?? null;
const lastTriggeredAt = isoString(input.issue.monitorLastTriggeredAt) ?? fromState?.lastTriggeredAt ?? null;
const attemptCount = input.issue.monitorAttemptCount ?? fromState?.attemptCount ?? 0;
const notes = scheduledMonitor?.notes ?? normalizeMonitorNotes(input.issue.monitorNotes) ?? fromState?.notes ?? null;
const scheduledByRaw = input.issue.monitorScheduledBy ?? scheduledMonitor?.scheduledBy ?? fromState?.scheduledBy ?? null;
const scheduledBy =
scheduledByRaw === "assignee" || scheduledByRaw === "board" ? scheduledByRaw : null;
const metadata = scheduledMonitor ? monitorMetadataFromPolicy(scheduledMonitor) : monitorMetadataFromState(fromState);
if (nextCheckAt) {
return {
status: "scheduled",
nextCheckAt,
lastTriggeredAt,
attemptCount,
notes,
scheduledBy,
...metadata,
clearedAt: null,
clearReason: null,
};
}
if (fromState?.status === "cleared") {
return {
...fromState,
notes,
scheduledBy,
attemptCount,
lastTriggeredAt,
...metadata,
};
}
if (fromState?.status === "triggered" || lastTriggeredAt || attemptCount > 0) {
return {
status: "triggered",
nextCheckAt: null,
lastTriggeredAt,
attemptCount,
notes,
scheduledBy,
...metadata,
clearedAt: null,
clearReason: null,
};
}
return null;
}
function buildScheduledMonitorState(
previous: IssueExecutionMonitorState | null,
monitor: IssueExecutionMonitorPolicy,
): IssueExecutionMonitorState {
return {
status: "scheduled",
nextCheckAt: monitor.nextCheckAt,
lastTriggeredAt: previous?.lastTriggeredAt ?? null,
attemptCount: previous?.attemptCount ?? 0,
notes: monitor.notes ?? null,
scheduledBy: monitor.scheduledBy,
...monitorMetadataFromPolicy(monitor),
clearedAt: null,
clearReason: null,
};
}
function buildTriggeredMonitorState(input: {
previous: IssueExecutionMonitorState | null;
triggeredAt: Date;
}): IssueExecutionMonitorState {
return {
status: "triggered",
nextCheckAt: null,
lastTriggeredAt: input.triggeredAt.toISOString(),
attemptCount: (input.previous?.attemptCount ?? 0) + 1,
notes: input.previous?.notes ?? null,
scheduledBy: input.previous?.scheduledBy ?? null,
...monitorMetadataFromState(input.previous),
clearedAt: null,
clearReason: null,
};
}
function buildClearedMonitorState(input: {
previous: IssueExecutionMonitorState | null;
clearReason: IssueExecutionMonitorClearReason;
clearedAt: Date;
}): IssueExecutionMonitorState {
return {
status: "cleared",
nextCheckAt: null,
lastTriggeredAt: input.previous?.lastTriggeredAt ?? null,
attemptCount: input.previous?.attemptCount ?? 0,
notes: input.previous?.notes ?? null,
scheduledBy: input.previous?.scheduledBy ?? null,
...monitorMetadataFromState(input.previous),
clearedAt: input.clearedAt.toISOString(),
clearReason: input.clearReason,
};
}
function issueAllowsMonitor(status: string, assigneeAgentId: string | null, assigneeUserId: string | null) {
return Boolean(assigneeAgentId) && !assigneeUserId && (status === "in_progress" || status === "in_review");
}
function monitorClearReasonForIssue(
status: string,
assigneeAgentId: string | null,
assigneeUserId: string | null,
): IssueExecutionMonitorClearReason | null {
if (status === "done") return "done";
if (status === "cancelled") return "cancelled";
if (!issueAllowsMonitor(status, assigneeAgentId, assigneeUserId)) {
if (assigneeUserId || !assigneeAgentId) return "invalid_assignee";
return "invalid_status";
}
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;
stagePatch: Record<string, unknown>;
}) {
const assigneeAgentId =
input.stagePatch.assigneeAgentId !== undefined
? (input.stagePatch.assigneeAgentId as string | null)
: input.requestedAssigneePatch.assigneeAgentId !== undefined
? input.requestedAssigneePatch.assigneeAgentId ?? null
: input.issue.assigneeAgentId ?? null;
const assigneeUserId =
input.stagePatch.assigneeUserId !== undefined
? (input.stagePatch.assigneeUserId as string | null)
: input.requestedAssigneePatch.assigneeUserId !== undefined
? input.requestedAssigneePatch.assigneeUserId ?? null
: input.issue.assigneeUserId ?? null;
return { assigneeAgentId, assigneeUserId };
}
export function stripMonitorFromExecutionPolicy(policy: IssueExecutionPolicy | null): IssueExecutionPolicy | null {
if (!policy) return null;
if (!policy.monitor) return policy;
if (policy.stages.length === 0) return null;
return {
mode: policy.mode,
commentRequired: policy.commentRequired,
stages: policy.stages,
};
}
export function setIssueExecutionPolicyMonitorScheduledBy(
policy: IssueExecutionPolicy | null,
scheduledBy: IssueMonitorScheduledBy,
): IssueExecutionPolicy | null {
if (!policy?.monitor) return policy;
return {
...policy,
monitor: {
...policy.monitor,
scheduledBy,
},
};
}
export function normalizeIssueExecutionPolicy(input: unknown): IssueExecutionPolicy | null {
if (input == null) return null;
@ -81,12 +373,27 @@ export function normalizeIssueExecutionPolicy(input: unknown): IssueExecutionPol
})
.filter((stage): stage is NonNullable<typeof stage> => stage !== null);
if (stages.length === 0) return null;
const monitor = parsed.data.monitor
? {
nextCheckAt: parsed.data.monitor.nextCheckAt,
notes: normalizeMonitorNotes(parsed.data.monitor.notes),
scheduledBy: parsed.data.monitor.scheduledBy,
kind: parsed.data.monitor.kind ?? null,
serviceName: normalizeMonitorText(parsed.data.monitor.serviceName),
externalRef: redactIssueMonitorExternalRef(parsed.data.monitor.externalRef),
timeoutAt: parsed.data.monitor.timeoutAt ?? null,
maxAttempts: parsed.data.monitor.maxAttempts ?? null,
recoveryPolicy: parsed.data.monitor.recoveryPolicy ?? null,
}
: null;
if (stages.length === 0 && !monitor) return null;
return {
mode: parsed.data.mode ?? "normal",
commentRequired: true,
stages,
...(monitor ? { monitor } : {}),
};
}
@ -173,6 +480,7 @@ function buildCompletedState(previous: IssueExecutionState | null, currentStage:
completedStageIds,
lastDecisionId: previous?.lastDecisionId ?? null,
lastDecisionOutcome: "approved",
monitor: previous?.monitor ?? null,
};
}
@ -192,6 +500,7 @@ function buildStateWithCompletedStages(input: {
completedStageIds: input.completedStageIds,
lastDecisionId: input.previous?.lastDecisionId ?? null,
lastDecisionOutcome: input.previous?.lastDecisionOutcome ?? null,
monitor: input.previous?.monitor ?? null,
};
}
@ -211,6 +520,7 @@ function buildSkippedStageCompletedState(input: {
completedStageIds: input.completedStageIds,
lastDecisionId: input.previous?.lastDecisionId ?? null,
lastDecisionOutcome: input.previous?.lastDecisionOutcome ?? null,
monitor: input.previous?.monitor ?? null,
};
}
@ -233,6 +543,7 @@ function buildPendingState(input: {
completedStageIds: input.previous?.completedStageIds ?? [],
lastDecisionId: input.previous?.lastDecisionId ?? null,
lastDecisionOutcome: input.previous?.lastDecisionOutcome ?? null,
monitor: input.previous?.monitor ?? null,
};
}
@ -293,7 +604,7 @@ function canAutoSkipPendingStage(input: {
input.stage.participants.every((participant) => principalsEqual(participant, input.returnAssignee));
}
export function applyIssueExecutionPolicyTransition(input: TransitionInput): TransitionResult {
function applyIssueExecutionStageTransition(input: TransitionInput): TransitionResult {
const patch: Record<string, unknown> = {};
const existingState = parseIssueExecutionState(input.issue.executionState);
const currentAssignee = assigneePrincipal(input.issue);
@ -560,3 +871,180 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra
workflowControlledAssignment: true,
};
}
function applyMonitorTransition(input: TransitionInput, stagePatch: Record<string, unknown>) {
const patch: Record<string, unknown> = {};
const previousPolicy = input.previousPolicy ?? normalizeIssueExecutionPolicy(input.issue.executionPolicy ?? null);
const existingState = parseIssueExecutionState(input.issue.executionState);
const currentMonitorState = derivePersistedMonitorState({
issue: input.issue,
state: existingState,
policy: previousPolicy,
});
const nextStatus =
typeof stagePatch.status === "string"
? (stagePatch.status as string)
: input.requestedStatus ?? input.issue.status;
const { assigneeAgentId, assigneeUserId } = nextAssigneeIds({
issue: input.issue,
requestedAssigneePatch: input.requestedAssigneePatch,
stagePatch,
});
const stageState =
stagePatch.executionState !== undefined
? parseIssueExecutionState(stagePatch.executionState)
: existingState;
const invalidReason = input.policy?.monitor
? monitorClearReasonForIssue(nextStatus, assigneeAgentId, assigneeUserId)
: null;
let targetMonitorState = currentMonitorState;
if (input.policy?.monitor) {
if (invalidReason) {
if (input.monitorExplicitlyUpdated) {
throw unprocessable(MONITOR_INVALID_MESSAGE);
}
patch.executionPolicy = stripMonitorFromExecutionPolicy(input.policy);
patch.monitorNextCheckAt = null;
patch.monitorWakeRequestedAt = null;
targetMonitorState = buildClearedMonitorState({
previous: currentMonitorState,
clearReason: invalidReason,
clearedAt: new Date(),
});
} else {
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;
patch.monitorWakeRequestedAt = null;
targetMonitorState = buildClearedMonitorState({
previous: currentMonitorState,
clearReason:
input.monitorExplicitlyUpdated
? "manual"
: monitorClearReasonForIssue(nextStatus, assigneeAgentId, assigneeUserId) ?? "manual",
clearedAt: new Date(),
});
}
if (stagePatch.executionState !== undefined || !monitorStatesEqual(currentMonitorState, targetMonitorState)) {
patch.executionState = executionStateWithMonitor(stageState, targetMonitorState);
}
return patch;
}
export function buildInitialIssueMonitorFields(input: {
policy: IssueExecutionPolicy | null;
status: string;
assigneeAgentId?: string | null;
assigneeUserId?: string | null;
}) {
if (!input.policy?.monitor) return {};
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 {
monitorNextCheckAt: new Date(input.policy.monitor.nextCheckAt),
monitorWakeRequestedAt: null,
monitorNotes: input.policy.monitor.notes ?? null,
monitorScheduledBy: input.policy.monitor.scheduledBy,
executionState: executionStateWithMonitor(null, monitorState) as Record<string, unknown> | null,
};
}
export function buildIssueMonitorTriggeredPatch(input: {
issue: IssueLike;
policy: IssueExecutionPolicy | null;
triggeredAt: Date;
}) {
const existingState = parseIssueExecutionState(input.issue.executionState);
const currentMonitorState = derivePersistedMonitorState({
issue: input.issue,
state: existingState,
policy: input.policy,
});
const nextMonitorState = buildTriggeredMonitorState({
previous: currentMonitorState,
triggeredAt: input.triggeredAt,
});
return {
executionPolicy: stripMonitorFromExecutionPolicy(input.policy) as Record<string, unknown> | null,
executionState: executionStateWithMonitor(existingState, nextMonitorState) as Record<string, unknown> | null,
monitorNextCheckAt: null,
monitorWakeRequestedAt: null,
monitorLastTriggeredAt: input.triggeredAt,
monitorAttemptCount: nextMonitorState.attemptCount,
monitorNotes: nextMonitorState.notes,
monitorScheduledBy: nextMonitorState.scheduledBy,
};
}
export function buildIssueMonitorClearedPatch(input: {
issue: IssueLike;
policy: IssueExecutionPolicy | null;
clearReason: IssueExecutionMonitorClearReason;
clearedAt?: Date;
}) {
const existingState = parseIssueExecutionState(input.issue.executionState);
const currentMonitorState = derivePersistedMonitorState({
issue: input.issue,
state: existingState,
policy: input.policy,
});
const nextMonitorState = buildClearedMonitorState({
previous: currentMonitorState,
clearReason: input.clearReason,
clearedAt: input.clearedAt ?? new Date(),
});
return {
executionPolicy: stripMonitorFromExecutionPolicy(input.policy) as Record<string, unknown> | null,
executionState: executionStateWithMonitor(existingState, nextMonitorState) as Record<string, unknown> | null,
monitorNextCheckAt: null,
monitorWakeRequestedAt: null,
};
}
export function applyIssueExecutionPolicyTransition(input: TransitionInput): TransitionResult {
const stageResult = applyIssueExecutionStageTransition(input);
const monitorPatch = applyMonitorTransition(input, stageResult.patch);
Object.assign(stageResult.patch, monitorPatch);
return stageResult;
}

View file

@ -43,6 +43,7 @@ import {
parseProjectExecutionWorkspacePolicy,
} from "./execution-workspace-policy.js";
import { mergeExecutionWorkspaceConfig } from "./execution-workspaces.js";
import { buildInitialIssueMonitorFields, normalizeIssueExecutionPolicy } from "./issue-execution-policy.js";
import { instanceSettingsService } from "./instance-settings.js";
import { redactCurrentUserText } from "../log-redaction.js";
import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallback.js";
@ -1421,6 +1422,12 @@ const issueListSelect = {
assigneeAdapterOverrides: issues.assigneeAdapterOverrides,
executionPolicy: sql<null>`null`,
executionState: sql<null>`null`,
monitorNextCheckAt: issues.monitorNextCheckAt,
monitorWakeRequestedAt: issues.monitorWakeRequestedAt,
monitorLastTriggeredAt: issues.monitorLastTriggeredAt,
monitorAttemptCount: issues.monitorAttemptCount,
monitorNotes: issues.monitorNotes,
monitorScheduledBy: issues.monitorScheduledBy,
executionWorkspaceId: issues.executionWorkspaceId,
executionWorkspacePreference: issues.executionWorkspacePreference,
executionWorkspaceSettings: sql<null>`null`,
@ -2815,6 +2822,15 @@ export function issueService(db: Db) {
if (values.status === "cancelled") {
values.cancelledAt = new Date();
}
Object.assign(
values,
buildInitialIssueMonitorFields({
policy: normalizeIssueExecutionPolicy(issueData.executionPolicy ?? null),
status: values.status ?? "backlog",
assigneeAgentId: values.assigneeAgentId ?? null,
assigneeUserId: values.assigneeUserId ?? null,
}),
);
const [issue] = await tx.insert(issues).values(values).returning();
if (inputLabelIds) {

View file

@ -22,7 +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 {
@ -99,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"]);
@ -140,6 +144,45 @@ function hasWaitingPath(
return waitingPaths.some((entry) => entry.companyId === companyId && entry.issueId === issueId);
}
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 {
if (!principal || typeof principal !== "object") return null;
const value = principal as Record<string, unknown>;
@ -308,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[]>();
@ -351,6 +395,7 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu
function hasExplicitWaitingPath(issue: IssueLivenessIssueInput) {
return Boolean(issue.assigneeUserId) ||
hasScheduledMonitor(issue, nowMs) ||
hasActiveExecutionPath(issue.companyId, issue.id, activeRuns, queuedWakeRequests) ||
hasWaitingPath(issue.companyId, issue.id, pendingInteractions) ||
hasWaitingPath(issue.companyId, issue.id, pendingApprovals) ||

View file

@ -1836,7 +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(
@ -1966,6 +1969,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
pendingInteractions: interactionRows,
pendingApprovals: approvalRows,
openRecoveryIssues,
now: new Date(),
});
}