mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30:39 +09:00
[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  ## 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:
parent
76f09c8eb6
commit
57229d0f24
32 changed files with 19324 additions and 20 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) ||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue