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

## Thinking Path

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

## What Changed

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

## Verification

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

## Screenshots

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

## Risks

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

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

## Model Used

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

## Checklist

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

---------

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

View file

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