[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
32 changed files with 19324 additions and 20 deletions
+29 -2
View File
@@ -67,13 +67,15 @@ This is the right state for:
- waiting on another issue
- waiting on a human decision
- waiting on an external dependency or system
- waiting on an external dependency or system when Paperclip does not own a scheduled re-check
- work that automatic recovery could not safely continue
### `in_review`
Execution work is paused because the next move belongs to a reviewer or approver, not the current executor.
An external review service can also be a valid review path when the issue keeps an agent assignee and has an active one-shot monitor that will wake that assignee to check the service later.
### `done`
The work is complete and terminal.
@@ -164,6 +166,7 @@ The valid action-path primitives are:
- a queued wake or continuation that can be delivered to the responsible agent
- a typed execution-policy participant, such as `executionState.currentParticipant`
- a pending issue-thread interaction or linked approval that is waiting for a specific responder
- a one-shot issue monitor (`executionPolicy.monitor.nextCheckAt`) that will wake the assignee for a future check
- a human owner via `assigneeUserId`
- a first-class blocker chain whose unresolved leaf issues are themselves healthy
- an open explicit recovery issue that names the owner and action needed to restore liveness
@@ -188,6 +191,7 @@ A healthy active-work state means at least one of these is true:
- there is an active run for the issue
- there is already a queued continuation wake
- there is an active one-shot monitor that will wake the assignee for a future check
- there is an open explicit recovery issue for the lost execution path
An agent-owned `in_progress` issue is stalled when it has no active run, no queued continuation, and no explicit recovery surface. A still-running but silent process is not automatically stalled; it is handled by the active-run watchdog contract.
@@ -202,11 +206,34 @@ A healthy `in_review` issue has at least one valid action path:
- a pending issue-thread interaction or linked approval waiting for a named responder
- a human owner via `assigneeUserId`
- an active run or queued wake that is expected to process the review state
- an active one-shot monitor for an external service or async review loop that the assignee owns
- an open explicit recovery issue for an ambiguous review handoff
Agent-assigned `in_review` with no typed participant is only healthy when one of the other paths exists. Assignment to the same agent that produced the handoff is not, by itself, a review path.
An `in_review` issue is stalled when it has no typed participant, no pending interaction or approval, no user owner, no active run, no queued wake, and no explicit recovery issue. Paperclip should surface that state as recovery work rather than silently completing the issue or leaving blocker chains parked indefinitely.
An `in_review` issue is stalled when it has no typed participant, no pending interaction or approval, no user owner, no active monitor, no active run, no queued wake, and no explicit recovery issue. Paperclip should surface that state as recovery work rather than silently completing the issue or leaving blocker chains parked indefinitely.
### Issue monitors
An issue monitor is a one-shot deferred action path for agent-owned issues in `in_progress` or `in_review`.
Use a monitor when the current assignee owns a future check against an async system or external service. Examples include Greptile review loops, GitHub checks, Vercel deployments, or provider jobs where the agent should come back later and decide what happens next.
Monitor policy lives under `executionPolicy.monitor` and includes:
- `nextCheckAt`: when Paperclip should wake the assignee
- `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`
Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

@@ -0,0 +1,7 @@
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "monitor_next_check_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "monitor_wake_requested_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "monitor_last_triggered_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "monitor_attempt_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "monitor_notes" text;--> statement-breakpoint
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "monitor_scheduled_by" text;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "issues_company_monitor_due_idx" ON "issues" USING btree ("company_id","monitor_next_check_at");
File diff suppressed because it is too large Load Diff
@@ -526,6 +526,13 @@
"when": 1777384535070,
"tag": "0074_striped_genesis",
"breakpoints": true
},
{
"idx": 75,
"version": "7",
"when": 1777572332006,
"tag": "0075_cultured_sebastian_shaw",
"breakpoints": true
}
]
}
+7
View File
@@ -50,6 +50,12 @@ export const issues = pgTable(
assigneeAdapterOverrides: jsonb("assignee_adapter_overrides").$type<Record<string, unknown>>(),
executionPolicy: jsonb("execution_policy").$type<Record<string, unknown>>(),
executionState: jsonb("execution_state").$type<Record<string, unknown>>(),
monitorNextCheckAt: timestamp("monitor_next_check_at", { withTimezone: true }),
monitorWakeRequestedAt: timestamp("monitor_wake_requested_at", { withTimezone: true }),
monitorLastTriggeredAt: timestamp("monitor_last_triggered_at", { withTimezone: true }),
monitorAttemptCount: integer("monitor_attempt_count").notNull().default(0),
monitorNotes: text("monitor_notes"),
monitorScheduledBy: text("monitor_scheduled_by"),
executionWorkspaceId: uuid("execution_workspace_id")
.references((): AnyPgColumn => executionWorkspaces.id, { onDelete: "set null" }),
executionWorkspacePreference: text("execution_workspace_preference"),
@@ -78,6 +84,7 @@ export const issues = pgTable(
originIdx: index("issues_company_origin_idx").on(table.companyId, table.originKind, table.originId),
projectWorkspaceIdx: index("issues_company_project_workspace_idx").on(table.companyId, table.projectWorkspaceId),
executionWorkspaceIdx: index("issues_company_execution_workspace_idx").on(table.companyId, table.executionWorkspaceId),
dueMonitorIdx: index("issues_company_monitor_due_idx").on(table.companyId, table.monitorNextCheckAt),
identifierIdx: uniqueIndex("issues_identifier_idx").on(table.identifier),
titleSearchIdx: index("issues_title_search_idx").using("gin", table.title.op("gin_trgm_ops")),
identifierSearchIdx: index("issues_identifier_search_idx").using("gin", table.identifier.op("gin_trgm_ops")),
+30
View File
@@ -221,9 +221,39 @@ export type IssueExecutionPolicyMode = (typeof ISSUE_EXECUTION_POLICY_MODES)[num
export const ISSUE_EXECUTION_STAGE_TYPES = ["review", "approval"] as const;
export type IssueExecutionStageType = (typeof ISSUE_EXECUTION_STAGE_TYPES)[number];
export const ISSUE_MONITOR_SCHEDULED_BY = ["assignee", "board"] as const;
export type IssueMonitorScheduledBy = (typeof ISSUE_MONITOR_SCHEDULED_BY)[number];
export const ISSUE_EXECUTION_MONITOR_KINDS = ["external_service"] as const;
export type IssueExecutionMonitorKind = (typeof ISSUE_EXECUTION_MONITOR_KINDS)[number];
export const ISSUE_EXECUTION_MONITOR_RECOVERY_POLICIES = [
"wake_owner",
"create_recovery_issue",
"escalate_to_board",
] as const;
export type IssueExecutionMonitorRecoveryPolicy =
(typeof ISSUE_EXECUTION_MONITOR_RECOVERY_POLICIES)[number];
export const ISSUE_EXECUTION_STATE_STATUSES = ["idle", "pending", "changes_requested", "completed"] as const;
export type IssueExecutionStateStatus = (typeof ISSUE_EXECUTION_STATE_STATUSES)[number];
export const ISSUE_EXECUTION_MONITOR_STATE_STATUSES = ["scheduled", "triggered", "cleared"] as const;
export type IssueExecutionMonitorStateStatus = (typeof ISSUE_EXECUTION_MONITOR_STATE_STATUSES)[number];
export const ISSUE_EXECUTION_MONITOR_CLEAR_REASONS = [
"manual",
"triggered",
"done",
"cancelled",
"invalid_status",
"invalid_assignee",
"dispatch_skipped",
"timeout_exceeded",
"max_attempts_exhausted",
] as const;
export type IssueExecutionMonitorClearReason = (typeof ISSUE_EXECUTION_MONITOR_CLEAR_REASONS)[number];
export const ISSUE_EXECUTION_DECISION_OUTCOMES = ["approved", "changes_requested"] as const;
export type IssueExecutionDecisionOutcome = (typeof ISSUE_EXECUTION_DECISION_OUTCOMES)[number];
+12
View File
@@ -35,7 +35,12 @@ export {
ISSUE_REFERENCE_SOURCE_KINDS,
ISSUE_EXECUTION_POLICY_MODES,
ISSUE_EXECUTION_STAGE_TYPES,
ISSUE_MONITOR_SCHEDULED_BY,
ISSUE_EXECUTION_MONITOR_KINDS,
ISSUE_EXECUTION_MONITOR_RECOVERY_POLICIES,
ISSUE_EXECUTION_STATE_STATUSES,
ISSUE_EXECUTION_MONITOR_STATE_STATUSES,
ISSUE_EXECUTION_MONITOR_CLEAR_REASONS,
ISSUE_EXECUTION_DECISION_OUTCOMES,
GOAL_LEVELS,
GOAL_STATUSES,
@@ -136,7 +141,12 @@ export {
type IssueReferenceSourceKind,
type IssueExecutionPolicyMode,
type IssueExecutionStageType,
type IssueMonitorScheduledBy,
type IssueExecutionMonitorKind,
type IssueExecutionMonitorRecoveryPolicy,
type IssueExecutionStateStatus,
type IssueExecutionMonitorStateStatus,
type IssueExecutionMonitorClearReason,
type IssueExecutionDecisionOutcome,
type GoalLevel,
type GoalStatus,
@@ -340,6 +350,8 @@ export type {
IssueReferenceSource,
IssueRelatedWorkItem,
IssueRelatedWorkSummary,
IssueExecutionMonitorPolicy,
IssueExecutionMonitorState,
IssueRelation,
IssueRelationIssueSummary,
IssueExecutionPolicy,
+2
View File
@@ -145,6 +145,8 @@ export type {
IssueRelatedWorkSummary,
IssueRelation,
IssueRelationIssueSummary,
IssueExecutionMonitorPolicy,
IssueExecutionMonitorState,
IssueExecutionPolicy,
IssueExecutionState,
IssueExecutionStage,
+41
View File
@@ -1,5 +1,10 @@
import type {
IssueExecutionMonitorClearReason,
IssueExecutionMonitorKind,
IssueExecutionMonitorRecoveryPolicy,
IssueExecutionMonitorStateStatus,
IssueExecutionDecisionOutcome,
IssueMonitorScheduledBy,
IssueExecutionPolicyMode,
IssueReferenceSourceKind,
IssueExecutionStageType,
@@ -201,10 +206,40 @@ export interface IssueExecutionStage {
participants: IssueExecutionStageParticipant[];
}
export interface IssueExecutionMonitorPolicy {
nextCheckAt: string;
notes: string | null;
scheduledBy: IssueMonitorScheduledBy;
kind?: IssueExecutionMonitorKind | null;
serviceName?: string | null;
externalRef?: string | null;
timeoutAt?: string | null;
maxAttempts?: number | null;
recoveryPolicy?: IssueExecutionMonitorRecoveryPolicy | null;
}
export interface IssueExecutionPolicy {
mode: IssueExecutionPolicyMode;
commentRequired: boolean;
stages: IssueExecutionStage[];
monitor?: IssueExecutionMonitorPolicy | null;
}
export interface IssueExecutionMonitorState {
status: IssueExecutionMonitorStateStatus;
nextCheckAt: string | null;
lastTriggeredAt: string | null;
attemptCount: number;
notes: string | null;
scheduledBy: IssueMonitorScheduledBy | null;
kind?: IssueExecutionMonitorKind | null;
serviceName?: string | null;
externalRef?: string | null;
timeoutAt?: string | null;
maxAttempts?: number | null;
recoveryPolicy?: IssueExecutionMonitorRecoveryPolicy | null;
clearedAt: string | null;
clearReason: IssueExecutionMonitorClearReason | null;
}
export interface IssueReviewRequest {
@@ -222,6 +257,7 @@ export interface IssueExecutionState {
completedStageIds: string[];
lastDecisionId: string | null;
lastDecisionOutcome: IssueExecutionDecisionOutcome | null;
monitor?: IssueExecutionMonitorState | null;
}
export interface IssueExecutionDecision {
@@ -270,6 +306,11 @@ export interface Issue {
assigneeAdapterOverrides: IssueAssigneeAdapterOverrides | null;
executionPolicy?: IssueExecutionPolicy | null;
executionState?: IssueExecutionState | null;
monitorNextCheckAt?: Date | null;
monitorLastTriggeredAt?: Date | null;
monitorAttemptCount?: number;
monitorNotes?: string | null;
monitorScheduledBy?: IssueMonitorScheduledBy | null;
executionWorkspaceId: string | null;
executionWorkspacePreference: string | null;
executionWorkspaceSettings: IssueExecutionWorkspaceSettings | null;
+36
View File
@@ -1,9 +1,14 @@
import { z } from "zod";
import {
ISSUE_EXECUTION_DECISION_OUTCOMES,
ISSUE_EXECUTION_MONITOR_CLEAR_REASONS,
ISSUE_EXECUTION_MONITOR_KINDS,
ISSUE_EXECUTION_MONITOR_RECOVERY_POLICIES,
ISSUE_EXECUTION_MONITOR_STATE_STATUSES,
ISSUE_EXECUTION_POLICY_MODES,
ISSUE_EXECUTION_STAGE_TYPES,
ISSUE_EXECUTION_STATE_STATUSES,
ISSUE_MONITOR_SCHEDULED_BY,
ISSUE_PRIORITIES,
clampIssueRequestDepth,
ISSUE_STATUSES,
@@ -103,10 +108,40 @@ export const issueExecutionStageSchema = z.object({
participants: z.array(issueExecutionStageParticipantSchema).default([]),
});
export const issueExecutionMonitorPolicySchema = z.object({
nextCheckAt: z.string().datetime(),
notes: z.string().max(500).optional().nullable().default(null),
scheduledBy: z.enum(ISSUE_MONITOR_SCHEDULED_BY).optional().default("assignee"),
kind: z.enum(ISSUE_EXECUTION_MONITOR_KINDS).optional().nullable().default(null),
serviceName: z.string().trim().min(1).max(120).optional().nullable().default(null),
externalRef: z.string().trim().min(1).max(500).optional().nullable().default(null),
timeoutAt: z.string().datetime().optional().nullable().default(null),
maxAttempts: z.number().int().positive().max(100).optional().nullable().default(null),
recoveryPolicy: z.enum(ISSUE_EXECUTION_MONITOR_RECOVERY_POLICIES).optional().nullable().default(null),
});
export const issueExecutionPolicySchema = z.object({
mode: z.enum(ISSUE_EXECUTION_POLICY_MODES).optional().default("normal"),
commentRequired: z.boolean().optional().default(true),
stages: z.array(issueExecutionStageSchema).default([]),
monitor: issueExecutionMonitorPolicySchema.optional().nullable(),
});
export const issueExecutionMonitorStateSchema = z.object({
status: z.enum(ISSUE_EXECUTION_MONITOR_STATE_STATUSES),
nextCheckAt: z.string().datetime().nullable(),
lastTriggeredAt: z.string().datetime().nullable(),
attemptCount: z.number().int().nonnegative().default(0),
notes: z.string().max(500).nullable(),
scheduledBy: z.enum(ISSUE_MONITOR_SCHEDULED_BY).nullable(),
kind: z.enum(ISSUE_EXECUTION_MONITOR_KINDS).nullable().optional().default(null),
serviceName: z.string().trim().min(1).max(120).nullable().optional().default(null),
externalRef: z.string().trim().min(1).max(500).nullable().optional().default(null),
timeoutAt: z.string().datetime().nullable().optional().default(null),
maxAttempts: z.number().int().positive().max(100).nullable().optional().default(null),
recoveryPolicy: z.enum(ISSUE_EXECUTION_MONITOR_RECOVERY_POLICIES).nullable().optional().default(null),
clearedAt: z.string().datetime().nullable(),
clearReason: z.enum(ISSUE_EXECUTION_MONITOR_CLEAR_REASONS).nullable(),
});
export const issueReviewRequestSchema = z.object({
@@ -124,6 +159,7 @@ export const issueExecutionStateSchema = z.object({
completedStageIds: z.array(z.string().uuid()).default([]),
lastDecisionId: z.string().uuid().nullable(),
lastDecisionOutcome: z.enum(ISSUE_EXECUTION_DECISION_OUTCOMES).nullable(),
monitor: issueExecutionMonitorStateSchema.optional().nullable(),
});
const issueRequestDepthInputSchema = z
@@ -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() }),
}),
);
});
});
@@ -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");
});
});
});
@@ -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");
});
});
@@ -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: {
+180 -3
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) {
+701 -3
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),
+491 -3
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;
}
+16
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) {
@@ -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) ||
+4
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(),
});
}
+1
View File
@@ -126,6 +126,7 @@ export const issuesApi = {
}>(`/issues/${id}/tree-control/state`),
releaseTreeHold: (id: string, holdId: string, data: ReleaseIssueTreeHold) =>
api.post<IssueTreeHold>(`/issues/${id}/tree-holds/${holdId}/release`, data),
checkMonitorNow: (id: string) => api.post<{ ok: true }>(`/issues/${id}/monitor/check-now`, {}),
remove: (id: string) => api.delete<Issue>(`/issues/${id}`),
checkout: (id: string, agentId: string) =>
api.post<Issue>(`/issues/${id}/checkout`, {
@@ -0,0 +1,196 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Issue } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssueMonitorActivityCard } from "./IssueMonitorActivityCard";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function createIssue(overrides: Partial<Issue> = {}): Issue {
return {
id: "issue-1",
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: "Watch deploy",
description: null,
status: "in_progress",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: "local-board",
issueNumber: 1,
identifier: "PAP-1",
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionPolicy: {
mode: "normal",
commentRequired: true,
stages: [],
monitor: {
nextCheckAt: "2026-04-11T12:30:00.000Z",
notes: "Check deployment health",
scheduledBy: "board",
},
},
executionState: {
status: "idle",
currentStageId: null,
currentStageIndex: null,
currentStageType: null,
currentParticipant: null,
returnAssignee: null,
reviewRequest: null,
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
monitor: {
status: "scheduled",
nextCheckAt: "2026-04-11T12:30:00.000Z",
lastTriggeredAt: null,
attemptCount: 0,
notes: "Check deployment health",
scheduledBy: "board",
clearedAt: null,
clearReason: null,
},
},
monitorNextCheckAt: new Date("2026-04-11T12:30:00.000Z"),
monitorLastTriggeredAt: null,
monitorAttemptCount: 0,
monitorNotes: "Check deployment health",
monitorScheduledBy: "board",
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
createdAt: new Date("2026-04-11T10:00:00.000Z"),
updatedAt: new Date("2026-04-11T10:00:00.000Z"),
...overrides,
};
}
describe("IssueMonitorActivityCard", () => {
let container: HTMLDivElement;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-11T12:00:00.000Z"));
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
vi.useRealTimers();
container.remove();
});
it("renders the scheduled monitor details and check-now action", () => {
const onCheckNow = vi.fn();
const root = createRoot(container);
act(() => {
root.render(<IssueMonitorActivityCard issue={createIssue()} onCheckNow={onCheckNow} />);
});
expect(container.textContent).toContain("Monitor scheduled");
expect(container.textContent).toContain("Next check");
expect(container.textContent).toContain("in 30m");
expect(container.textContent).toContain("Check deployment health");
const button = Array.from(container.querySelectorAll("button")).find((candidate) =>
candidate.textContent?.includes("Check now"),
);
expect(button).toBeTruthy();
act(() => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onCheckNow).toHaveBeenCalledTimes(1);
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);
act(() => {
root.render(
<IssueMonitorActivityCard
issue={createIssue({
executionPolicy: {
mode: "normal",
commentRequired: true,
stages: [],
},
executionState: {
status: "idle",
currentStageId: null,
currentStageIndex: null,
currentStageType: null,
currentParticipant: null,
returnAssignee: null,
reviewRequest: null,
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
monitor: null,
},
monitorNextCheckAt: null,
monitorNotes: null,
})}
/>,
);
});
expect(container.textContent).toBe("");
act(() => root.unmount());
});
});
@@ -0,0 +1,71 @@
import type { Issue } from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { formatMonitorOffset } from "@/lib/issue-monitor";
import { formatDateTime } from "@/lib/utils";
function resolveScheduledMonitor(issue: Issue) {
const nextCheckAt =
issue.monitorNextCheckAt?.toISOString() ??
issue.executionPolicy?.monitor?.nextCheckAt ??
issue.executionState?.monitor?.nextCheckAt ??
null;
if (!nextCheckAt) return null;
return {
nextCheckAt,
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,
};
}
interface IssueMonitorActivityCardProps {
issue: Issue;
onCheckNow?: (() => void) | null;
checkingNow?: boolean;
}
export function IssueMonitorActivityCard({
issue,
onCheckNow = null,
checkingNow = false,
}: IssueMonitorActivityCardProps) {
const monitor = resolveScheduledMonitor(issue);
if (!monitor) return null;
return (
<div className="mb-3 rounded-lg border border-border bg-muted/30 px-3 py-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">Monitor scheduled</div>
<div className="text-xs text-muted-foreground">
Next check {formatDateTime(monitor.nextCheckAt)} ({formatMonitorOffset(monitor.nextCheckAt)})
</div>
{monitor.notes ? (
<div className="mt-1 text-xs text-muted-foreground">{monitor.notes}</div>
) : null}
{monitor.serviceName ? (
<div className="mt-1 text-xs text-muted-foreground">
{monitor.serviceName}
</div>
) : null}
{monitor.attemptCount > 0 ? (
<div className="mt-1 text-xs text-muted-foreground">Attempt {monitor.attemptCount}</div>
) : null}
</div>
{onCheckNow ? (
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0 shadow-none"
onClick={onCheckNow}
disabled={checkingNow}
>
{checkingNow ? "Checking..." : "Check now"}
</Button>
) : null}
</div>
</div>
);
}
@@ -969,4 +969,84 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("renders monitor controls and clears an existing monitor", async () => {
const onUpdate = vi.fn();
const root = renderProperties(container, {
issue: createIssue({
status: "in_progress",
assigneeAgentId: "agent-1",
executionPolicy: createExecutionPolicy({
monitor: {
nextCheckAt: "2026-04-11T12:30:00.000Z",
notes: "Check deployment",
scheduledBy: "board",
},
}),
executionState: createExecutionState({
status: "idle",
currentStageId: null,
currentStageIndex: null,
currentStageType: null,
currentParticipant: null,
returnAssignee: null,
lastDecisionOutcome: null,
monitor: {
status: "scheduled",
nextCheckAt: "2026-04-11T12:30:00.000Z",
lastTriggeredAt: null,
attemptCount: 0,
notes: "Check deployment",
scheduledBy: "board",
clearedAt: null,
clearReason: null,
},
}),
}),
childIssues: [],
onUpdate,
inline: true,
});
await flush();
expect(container.textContent).toContain("Monitor");
expect(container.textContent).toContain("Next check");
expect(container.querySelector('input[type="datetime-local"]')).toBeNull();
expect(container.querySelector('input[placeholder="What should the agent re-check?"]')).toBeNull();
const monitorTrigger = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Next check"));
expect(monitorTrigger).not.toBeUndefined();
await act(async () => {
monitorTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const inputs = Array.from(container.querySelectorAll("input"));
const datetimeInput = inputs.find((input) => input.getAttribute("type") === "datetime-local");
const textInput = inputs.find((input) => input.getAttribute("placeholder") === "What should the agent re-check?");
const clearButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Clear"));
expect(datetimeInput).toBeTruthy();
expect(textInput).toBeTruthy();
expect(clearButton).toBeTruthy();
expect(datetimeInput!.value).toBeTruthy();
expect(textInput!.value).toBe("Check deployment");
act(() => {
clearButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({
executionPolicy: {
mode: "normal",
commentRequired: true,
stages: [],
},
});
act(() => root.unmount());
});
});
+166 -1
View File
@@ -25,6 +25,7 @@ import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects"
import { orderItemsBySelectedAndRecent } from "../lib/recent-selections";
import { formatAssigneeUserLabel } from "../lib/assignees";
import { buildExecutionPolicy, stageParticipantValues } from "../lib/issue-execution-policy";
import { formatMonitorOffset } from "../lib/issue-monitor";
import { StatusIcon } from "./StatusIcon";
import { PriorityIcon } from "./PriorityIcon";
import { Identity } from "./Identity";
@@ -33,7 +34,7 @@ import { formatDate, cn, projectUrl } from "../lib/utils";
import { timeAgo } from "../lib/timeAgo";
import { Separator } from "@/components/ui/separator";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Check, ExternalLink } from "lucide-react";
import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Check, ExternalLink, Clock } from "lucide-react";
import { AgentIcon } from "./AgentIconPicker";
function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.ComponentType<{ className?: string }> }) {
@@ -118,6 +119,14 @@ function issuesWorkspaceFilterHref(workspaceId: string) {
return `/issues?${params.toString()}`;
}
function toDateTimeLocalValue(value: string | null | undefined) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
const offsetMs = date.getTimezoneOffset() * 60_000;
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16);
}
interface IssuePropertiesProps {
issue: Issue;
childIssues?: Issue[];
@@ -219,10 +228,14 @@ export function IssueProperties({
const [reviewerSearch, setReviewerSearch] = useState("");
const [approversOpen, setApproversOpen] = useState(false);
const [approverSearch, setApproverSearch] = useState("");
const [monitorOpen, setMonitorOpen] = useState(false);
const [labelsOpen, setLabelsOpen] = useState(false);
const [labelSearch, setLabelSearch] = useState("");
const [newLabelName, setNewLabelName] = useState("");
const [newLabelColor, setNewLabelColor] = useState("#6366f1");
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 { data: session } = useQuery({
queryKey: queryKeys.auth.session,
@@ -459,6 +472,145 @@ export function IssueProperties({
}
return `${stageLabel} pending${participantLabel ? ` with ${participantLabel}` : ""}`;
})();
useEffect(() => {
setMonitorAtInput(toDateTimeLocalValue(issue.executionPolicy?.monitor?.nextCheckAt));
setMonitorNotesInput(issue.executionPolicy?.monitor?.notes ?? "");
setMonitorServiceInput(issue.executionPolicy?.monitor?.serviceName ?? "");
}, [
issue.executionPolicy?.monitor?.nextCheckAt,
issue.executionPolicy?.monitor?.notes,
issue.executionPolicy?.monitor?.serviceName,
]);
const updateMonitor = (nextMonitor: Issue["executionPolicy"] extends infer T
? T extends { monitor?: infer M | null } | null | undefined
? M | null
: never
: never) => {
const basePolicy = buildExecutionPolicy({
existingPolicy: issue.executionPolicy ?? null,
reviewerValues,
approverValues,
});
if (!basePolicy && !nextMonitor) {
onUpdate({ executionPolicy: null });
return;
}
onUpdate({
executionPolicy: {
mode: basePolicy?.mode ?? issue.executionPolicy?.mode ?? "normal",
commentRequired: true,
stages: basePolicy?.stages ?? [],
...(nextMonitor ? { monitor: nextMonitor } : {}),
},
});
};
const saveMonitor = () => {
if (!monitorAtInput) return;
const nextCheckAt = new Date(monitorAtInput);
if (Number.isNaN(nextCheckAt.getTime())) return;
const serviceName = monitorServiceInput.trim() || null;
updateMonitor({
nextCheckAt: nextCheckAt.toISOString(),
notes: monitorNotesInput.trim() || null,
scheduledBy: "board",
kind: serviceName ? "external_service" : null,
serviceName,
externalRef: null,
});
setMonitorOpen(false);
};
const clearMonitor = () => {
updateMonitor(null);
setMonitorOpen(false);
};
const currentMonitorLabel = (() => {
if (issue.executionPolicy?.monitor?.nextCheckAt) {
return `Next check ${formatDate(new Date(issue.executionPolicy.monitor.nextCheckAt))}`;
}
if (issue.executionState?.monitor?.status === "cleared") {
return "Cleared";
}
if (issue.monitorLastTriggeredAt) {
return `Last triggered ${timeAgo(issue.monitorLastTriggeredAt)}`;
}
return "Not scheduled";
})();
const monitorNextCheckAt = issue.executionPolicy?.monitor?.nextCheckAt ?? null;
const monitorTrigger = (
<span className="inline-flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5">
{monitorNextCheckAt ? (
<Clock className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
) : null}
<span
className={cn(
"min-w-0 text-sm break-words",
monitorNextCheckAt ? "text-foreground" : "text-muted-foreground",
)}
title={monitorNextCheckAt ? currentMonitorLabel : undefined}
>
{monitorNextCheckAt ? `Next check ${formatMonitorOffset(monitorNextCheckAt)}` : currentMonitorLabel}
</span>
{monitorNextCheckAt ? (
<span className="text-xs text-muted-foreground" title={currentMonitorLabel}>
{formatDate(new Date(monitorNextCheckAt))}
</span>
) : null}
</span>
);
const monitorAttemptBadge = issue.monitorAttemptCount && issue.monitorAttemptCount > 0 ? (
<span className="text-xs text-muted-foreground">
Attempt {issue.monitorAttemptCount}
</span>
) : null;
const monitorContent = (
<div className="flex w-full flex-col gap-2">
<div className="flex flex-col gap-2 md:flex-row">
<input
type="datetime-local"
className="rounded-md border border-border bg-transparent px-2 py-1 text-xs"
value={monitorAtInput}
onChange={(e) => setMonitorAtInput(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="What should the agent re-check?"
value={monitorNotesInput}
onChange={(e) => setMonitorNotesInput(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2 md:flex-row">
<input
type="text"
className="min-w-0 flex-1 rounded-md border border-border bg-transparent px-2 py-1 text-xs"
placeholder="External service"
value={monitorServiceInput}
onChange={(e) => setMonitorServiceInput(e.target.value)}
/>
<div className="flex items-center gap-2">
<button
type="button"
className="inline-flex items-center rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:opacity-50"
disabled={!monitorAtInput}
onClick={saveMonitor}
>
Schedule
</button>
{issue.executionPolicy?.monitor ? (
<button
type="button"
className="inline-flex items-center rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
onClick={clearMonitor}
>
Clear
</button>
) : null}
</div>
</div>
</div>
);
const selectedIssueLabels = useMemo(() => {
const selectedIds = issue.labelIds ?? [];
if (selectedIds.length === 0) return issue.labels ?? [];
@@ -1248,6 +1400,19 @@ export function IssueProperties({
</PropertyRow>
)}
<PropertyPicker
inline={inline}
label="Monitor"
open={monitorOpen}
onOpenChange={setMonitorOpen}
triggerContent={monitorTrigger}
triggerClassName="min-w-0 max-w-full"
popoverClassName={cn("max-w-full", inline ? "w-full" : "w-80 sm:w-[32rem]")}
extra={monitorAttemptBadge}
>
{monitorContent}
</PropertyPicker>
{issue.requestDepth > 0 && (
<PropertyRow label="Depth">
<span className="text-sm font-mono">{issue.requestDepth}</span>
+8
View File
@@ -57,4 +57,12 @@ describe("activity formatting", () => {
expect(formatActivityVerb("issue.reviewers_updated", details, { agentMap })).toBe("updated reviewers on");
expect(formatIssueActivityAction("issue.reviewers_updated", details, { agentMap })).toBe("updated reviewers");
});
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");
});
});
+24
View File
@@ -33,6 +33,14 @@ const ACTIVITY_ROW_VERBS: Record<string, string> = {
"issue.document_created": "created document for",
"issue.document_updated": "updated document on",
"issue.document_deleted": "deleted document from",
"issue.monitor_scheduled": "scheduled monitor on",
"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",
@@ -75,6 +83,14 @@ const ISSUE_ACTIVITY_LABELS: Record<string, string> = {
"issue.document_created": "created a document",
"issue.document_updated": "updated a document",
"issue.document_deleted": "deleted a document",
"issue.monitor_scheduled": "scheduled a monitor",
"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",
@@ -296,6 +312,14 @@ export function formatIssueActivityAction(
});
if (structuredChange) return structuredChange;
if (action.startsWith("issue.monitor_") && details) {
const serviceName = typeof details.serviceName === "string" && details.serviceName.trim()
? details.serviceName.trim()
: null;
const base = ISSUE_ACTIVITY_LABELS[action] ?? action.replace(/[._]/g, " ");
return serviceName ? `${base} for ${serviceName}` : base;
}
if (
(action === "issue.document_created" || action === "issue.document_updated" || action === "issue.document_deleted") &&
details
+3 -1
View File
@@ -62,6 +62,7 @@ export function buildExecutionPolicy(input: {
}): IssueExecutionPolicy | null {
const mode = input.existingPolicy?.mode ?? "normal";
const stages: IssueExecutionPolicy["stages"] = [];
const monitor = input.existingPolicy?.monitor ?? null;
const existingReviewStage = input.existingPolicy?.stages.find((stage) => stage.type === "review");
const reviewParticipants = mergeParticipants(existingReviewStage?.participants, input.reviewerValues);
@@ -85,11 +86,12 @@ export function buildExecutionPolicy(input: {
});
}
if (stages.length === 0) return null;
if (stages.length === 0 && !monitor) return null;
return {
mode,
commentRequired: true,
stages,
...(monitor ? { monitor } : {}),
};
}
+12
View File
@@ -0,0 +1,12 @@
export function formatMonitorOffset(nextCheckAt: Date | string): string {
const deltaMs = new Date(nextCheckAt).getTime() - Date.now();
const absMinutes = Math.round(Math.abs(deltaMs) / 60_000);
if (absMinutes <= 0) return "now";
if (absMinutes < 60) return deltaMs >= 0 ? `in ${absMinutes}m` : `${absMinutes}m ago`;
const absHours = Math.round(absMinutes / 60);
if (absHours < 24) return deltaMs >= 0 ? `in ${absHours}h` : `${absHours}h ago`;
const absDays = Math.round(absHours / 24);
return deltaMs >= 0 ? `in ${absDays}d` : `${absDays}d ago`;
}
+35
View File
@@ -70,6 +70,7 @@ import { IssuesList } from "../components/IssuesList";
import { AgentIcon } from "../components/AgentIconPicker";
import { IssueReferenceActivitySummary } from "../components/IssueReferenceActivitySummary";
import { IssueRelatedWorkPanel } from "../components/IssueRelatedWorkPanel";
import { IssueMonitorActivityCard } from "../components/IssueMonitorActivityCard";
import { IssueProperties } from "../components/IssueProperties";
import { IssueRunLedger } from "../components/IssueRunLedger";
import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard";
@@ -881,6 +882,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
});
type IssueDetailActivityTabProps = {
issue: Issue;
issueId: string;
companyId: string;
issueStatus: Issue["status"];
@@ -891,10 +893,13 @@ type IssueDetailActivityTabProps = {
userProfileMap: Map<string, import("../lib/company-members").CompanyUserProfile>;
pendingApprovalAction: { approvalId: string; action: "approve" | "reject" } | null;
onApprovalAction: (approvalId: string, action: "approve" | "reject") => void;
onCheckMonitorNow: () => void;
checkingMonitorNow: boolean;
handoffFocusSignal?: number;
};
function IssueDetailActivityTab({
issue,
issueId,
companyId,
issueStatus,
@@ -905,6 +910,8 @@ function IssueDetailActivityTab({
userProfileMap,
pendingApprovalAction,
onApprovalAction,
onCheckMonitorNow,
checkingMonitorNow,
handoffFocusSignal = 0,
}: IssueDetailActivityTabProps) {
const { data: activity, isLoading: activityLoading } = useQuery({
@@ -1091,6 +1098,11 @@ function IssueDetailActivityTab({
</div>
)}
<IssueContinuationHandoff document={continuationHandoff} focusSignal={handoffFocusSignal} />
<IssueMonitorActivityCard
issue={issue}
onCheckNow={onCheckMonitorNow}
checkingNow={checkingMonitorNow}
/>
</>
);
}
@@ -1754,6 +1766,26 @@ export function IssueDetail() {
updateChildIssue.mutate({ id, data });
}, [updateChildIssue]);
const checkIssueMonitorNow = useMutation({
mutationFn: () => issuesApi.checkMonitorNow(issueId!),
onSuccess: () => {
invalidateIssueDetail();
invalidateIssueRunState();
invalidateIssueCollections();
pushToast({
title: "Monitor check queued",
tone: "success",
});
},
onError: (err) => {
pushToast({
title: "Monitor check failed",
body: err instanceof Error ? err.message : "Unable to trigger the monitor right now",
tone: "error",
});
},
});
const approvalDecision = useMutation({
mutationFn: async ({ approvalId, action }: { approvalId: string; action: "approve" | "reject" }) => {
if (action === "approve") {
@@ -3676,6 +3708,7 @@ export function IssueDetail() {
<TabsContent value="activity">
{detailTab === "activity" ? (
<IssueDetailActivityTab
issue={issue}
issueId={issue.id}
companyId={issue.companyId}
issueStatus={issue.status}
@@ -3689,6 +3722,8 @@ export function IssueDetail() {
onApprovalAction={(approvalId, action) => {
approvalDecision.mutate({ approvalId, action });
}}
onCheckMonitorNow={() => checkIssueMonitorNow.mutate()}
checkingMonitorNow={checkIssueMonitorNow.isPending}
/>
) : null}
</TabsContent>
@@ -0,0 +1,235 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { Issue } from "@paperclipai/shared";
import { IssueMonitorActivityCard } from "@/components/IssueMonitorActivityCard";
import { IssueProperties } from "@/components/IssueProperties";
import {
storybookExecutionWorkspaces,
storybookIssueDocuments,
storybookIssues,
} from "../fixtures/paperclipData";
const issueDocumentSummaries = storybookIssueDocuments.map(({ body: _body, ...summary }) => summary);
const baseIssue: Issue = {
...storybookIssues[0]!,
planDocument: storybookIssueDocuments.find((document) => document.key === "plan") ?? null,
documentSummaries: issueDocumentSummaries,
currentExecutionWorkspace: storybookExecutionWorkspaces[0]!,
};
const inFiveMinutes = () => new Date(Date.now() + 5 * 60_000);
const inTwoHours = () => new Date(Date.now() + 2 * 60 * 60_000);
const monitoredIssue: Issue = {
...baseIssue,
monitorNextCheckAt: inFiveMinutes(),
monitorNotes: "Polling Greptile for completed analysis.",
monitorAttemptCount: 2,
executionPolicy: {
...(baseIssue.executionPolicy ?? { mode: "normal", commentRequired: true, stages: [] }),
monitor: {
nextCheckAt: inFiveMinutes().toISOString(),
notes: "Polling Greptile for completed analysis.",
kind: "external_service",
scheduledBy: "assignee",
serviceName: "Greptile",
externalRef: "https://app.greptile.com/runs/abc123",
},
},
};
const longerWaitIssue: Issue = {
...baseIssue,
monitorNextCheckAt: inTwoHours(),
monitorNotes: null,
monitorAttemptCount: 0,
executionPolicy: {
...(baseIssue.executionPolicy ?? { mode: "normal", commentRequired: true, stages: [] }),
monitor: {
nextCheckAt: inTwoHours().toISOString(),
notes: null,
kind: null,
scheduledBy: "assignee",
serviceName: null,
externalRef: null,
},
},
};
const triggeredIssue: Issue = {
...baseIssue,
monitorNextCheckAt: null,
monitorLastTriggeredAt: new Date(Date.now() - 3 * 60_000),
monitorAttemptCount: 3,
monitorNotes: "Greptile review was checked and needs another pass.",
executionPolicy: {
...(baseIssue.executionPolicy ?? { mode: "normal", commentRequired: true, stages: [] }),
},
executionState: {
...(baseIssue.executionState ?? {
status: "pending",
currentStageId: null,
currentStageIndex: null,
currentStageType: null,
currentParticipant: null,
returnAssignee: null,
reviewRequest: null,
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
}),
monitor: null,
},
};
const clearedIssue: Issue = {
...baseIssue,
monitorNextCheckAt: null,
monitorLastTriggeredAt: null,
monitorAttemptCount: 0,
monitorNotes: null,
executionPolicy: {
...(baseIssue.executionPolicy ?? { mode: "normal", commentRequired: true, stages: [] }),
},
executionState: {
...(baseIssue.executionState ?? {
status: "pending",
currentStageId: null,
currentStageIndex: null,
currentStageType: null,
currentParticipant: null,
returnAssignee: null,
reviewRequest: null,
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
}),
monitor: {
status: "cleared",
nextCheckAt: null,
lastTriggeredAt: null,
attemptCount: 0,
notes: null,
scheduledBy: "board",
kind: null,
serviceName: null,
externalRef: null,
timeoutAt: null,
maxAttempts: null,
recoveryPolicy: null,
clearedAt: new Date(Date.now() - 60_000).toISOString(),
clearReason: "manual",
},
},
};
function MonitorSurfaceStories() {
return (
<div className="space-y-8 p-6">
<section className="space-y-2">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
IssueMonitorActivityCard - external service (Greptile)
</div>
<IssueMonitorActivityCard
issue={monitoredIssue}
onCheckNow={() => undefined}
checkingNow={false}
/>
</section>
<section className="space-y-2">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
IssueMonitorActivityCard - generic 2h wait, no service metadata
</div>
<IssueMonitorActivityCard
issue={longerWaitIssue}
onCheckNow={() => undefined}
checkingNow={false}
/>
</section>
<section className="space-y-2">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
IssueMonitorActivityCard - returns null when no monitor is set
</div>
<div className="rounded-lg border border-dashed border-border px-3 py-2 text-xs text-muted-foreground">
(intentionally renders nothing for issues without a scheduled monitor)
</div>
<IssueMonitorActivityCard issue={baseIssue} onCheckNow={() => undefined} />
</section>
<div className="grid gap-6 lg:grid-cols-2">
<section className="space-y-2">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
IssueProperties Monitor row - Not scheduled (default state)
</div>
<div className="rounded-lg border border-border bg-background/70 p-4">
<IssueProperties
issue={baseIssue}
onUpdate={() => undefined}
inline
/>
</div>
</section>
<section className="space-y-2">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
IssueProperties Monitor row - Scheduled (Greptile, in 5m)
</div>
<div className="rounded-lg border border-border bg-background/70 p-4">
<IssueProperties
issue={monitoredIssue}
onUpdate={() => undefined}
inline
/>
</div>
</section>
<section className="space-y-2">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
IssueProperties Monitor row - Triggered recently
</div>
<div className="rounded-lg border border-border bg-background/70 p-4">
<IssueProperties
issue={triggeredIssue}
onUpdate={() => undefined}
inline
/>
</div>
</section>
<section className="space-y-2">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
IssueProperties Monitor row - Cleared
</div>
<div className="rounded-lg border border-border bg-background/70 p-4">
<IssueProperties
issue={clearedIssue}
onUpdate={() => undefined}
inline
/>
</div>
</section>
</div>
</div>
);
}
const meta = {
title: "Product/Issue Monitor surfaces",
component: MonitorSurfaceStories,
parameters: {
docs: {
description: {
component:
"Surfaces the IssueMonitorActivityCard and IssueProperties Monitor row in scheduled / not-scheduled / external-service variants for UX review.",
},
},
},
} satisfies Meta<typeof MonitorSurfaceStories>;
export default meta;
type Story = StoryObj<typeof meta>;
export const MonitorSurfaces: Story = {};