[codex] Add runtime lifecycle recovery and live issue visibility (#4419)

This commit is contained in:
Dotta 2026-04-24 15:50:32 -05:00 committed by GitHub
parent 9a8d219949
commit 5a0c1979cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
121 changed files with 9625 additions and 2044 deletions

View file

@ -2270,11 +2270,14 @@ export function setInviteResolutionNetworkForTest(
: defaultInviteResolutionNetwork;
}
async function lookupInviteResolutionHostname(hostname: string) {
async function lookupInviteResolutionHostname(
hostname: string,
network: InviteResolutionNetwork = inviteResolutionNetwork
) {
let timeout: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
inviteResolutionNetwork.lookup(hostname),
network.lookup(hostname),
new Promise<never>((_, reject) => {
timeout = setTimeout(
() =>
@ -2296,7 +2299,8 @@ async function lookupInviteResolutionHostname(hostname: string) {
}
async function resolveInviteResolutionTarget(
url: URL
url: URL,
network: InviteResolutionNetwork = inviteResolutionNetwork
): Promise<ResolvedInviteResolutionTarget> {
const hostname = hostnameForResolution(url);
if (parseIpv4Address(hostname)) {
@ -2328,7 +2332,7 @@ async function resolveInviteResolutionTarget(
tlsServername: undefined,
};
}
const results = await lookupInviteResolutionHostname(hostname);
const results = await lookupInviteResolutionHostname(hostname, network);
if (results.length === 0) {
throw badRequest("url hostname did not resolve to any addresses");
}
@ -2354,11 +2358,12 @@ async function resolveInviteResolutionTarget(
async function probeInviteResolutionTarget(
target: ResolvedInviteResolutionTarget,
timeoutMs: number
timeoutMs: number,
network: InviteResolutionNetwork = inviteResolutionNetwork
): Promise<InviteResolutionProbe> {
const startedAt = Date.now();
try {
const response = await inviteResolutionNetwork.requestHead(target, timeoutMs);
const response = await network.requestHead(target, timeoutMs);
const durationMs = Date.now() - startedAt;
if (
response.httpStatus !== null &&
@ -2421,12 +2426,16 @@ export function accessRoutes(
deploymentExposure: DeploymentExposure;
bindHost: string;
allowedHostnames: string[];
inviteResolutionNetwork?: Partial<InviteResolutionNetwork>;
}
) {
const router = Router();
const access = accessService(db);
const boardAuth = boardAuthService(db);
const agents = agentService(db);
const routeInviteResolutionNetwork = opts.inviteResolutionNetwork
? { ...defaultInviteResolutionNetwork, ...opts.inviteResolutionNetwork }
: inviteResolutionNetwork;
async function assertInstanceAdmin(req: Request) {
if (req.actor.type !== "board") throw unauthorized();
@ -3175,8 +3184,8 @@ export function accessRoutes(
const timeoutMs = Number.isFinite(parsedTimeoutMs)
? Math.max(1000, Math.min(15000, Math.floor(parsedTimeoutMs)))
: 5000;
const resolvedTarget = await resolveInviteResolutionTarget(target);
const probe = await probeInviteResolutionTarget(resolvedTarget, timeoutMs);
const resolvedTarget = await resolveInviteResolutionTarget(target, routeInviteResolutionNetwork);
const probe = await probeInviteResolutionTarget(resolvedTarget, timeoutMs, routeInviteResolutionNetwork);
res.json({
inviteId: invite.id,
testResolutionPath: `/api/invites/${token}/test-resolution`,

View file

@ -81,6 +81,7 @@ import {
} from "../services/default-agent-instructions.js";
import { getTelemetryClient } from "../telemetry.js";
import { assertEnvironmentSelectionForCompany } from "./environment-selection.js";
import { recoveryService } from "../services/recovery/service.js";
const RUN_LOG_DEFAULT_LIMIT_BYTES = 256_000;
const RUN_LOG_MAX_LIMIT_BYTES = 1024 * 1024;
@ -91,6 +92,12 @@ function readRunLogLimitBytes(value: unknown) {
return Math.max(1, Math.min(RUN_LOG_MAX_LIMIT_BYTES, Math.trunc(parsed)));
}
function readLiveRunsQueryInt(value: unknown, max: number, fallback = 0) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(0, Math.min(max, Math.trunc(parsed)));
}
export function agentRoutes(
db: Db,
options: { pluginWorkerManager?: PluginWorkerManager } = {},
@ -142,6 +149,7 @@ export function agentRoutes(
const heartbeat = heartbeatService(db, {
pluginWorkerManager: options.pluginWorkerManager,
});
const recovery = recoveryService(db, { enqueueWakeup: heartbeat.wakeup });
const issueApprovalsSvc = issueApprovalService(db);
const secretsSvc = secretService(db);
const instructions = agentInstructionsService();
@ -2532,11 +2540,12 @@ export function agentRoutes(
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const minCountParam = req.query.minCount as string | undefined;
const minCount = minCountParam ? Math.max(0, Math.min(20, parseInt(minCountParam, 10) || 0)) : 0;
const minCount = readLiveRunsQueryInt(req.query.minCount, 50);
const limit = readLiveRunsQueryInt(req.query.limit, 50);
const columns = {
id: heartbeatRuns.id,
companyId: heartbeatRuns.companyId,
status: heartbeatRuns.status,
invocationSource: heartbeatRuns.invocationSource,
triggerDetail: heartbeatRuns.triggerDetail,
@ -2546,15 +2555,21 @@ export function agentRoutes(
agentId: heartbeatRuns.agentId,
agentName: agentsTable.name,
adapterType: agentsTable.adapterType,
logBytes: heartbeatRuns.logBytes,
livenessState: heartbeatRuns.livenessState,
livenessReason: heartbeatRuns.livenessReason,
continuationAttempt: heartbeatRuns.continuationAttempt,
lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt,
nextAction: heartbeatRuns.nextAction,
lastOutputAt: heartbeatRuns.lastOutputAt,
lastOutputSeq: heartbeatRuns.lastOutputSeq,
lastOutputStream: heartbeatRuns.lastOutputStream,
lastOutputBytes: heartbeatRuns.lastOutputBytes,
processStartedAt: heartbeatRuns.processStartedAt,
issueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("issueId"),
};
const liveRuns = await db
const liveRunsQuery = db
.select(columns)
.from(heartbeatRuns)
.innerJoin(agentsTable, eq(heartbeatRuns.agentId, agentsTable.id))
@ -2566,7 +2581,10 @@ export function agentRoutes(
)
.orderBy(desc(heartbeatRuns.createdAt));
if (minCount > 0 && liveRuns.length < minCount) {
const liveRuns = limit > 0 ? await liveRunsQuery.limit(limit) : await liveRunsQuery;
const targetRunCount = limit > 0 ? Math.min(minCount, limit) : minCount;
if (targetRunCount > 0 && liveRuns.length < targetRunCount) {
const activeIds = liveRuns.map((r) => r.id);
const recentRuns = await db
.select(columns)
@ -2580,13 +2598,20 @@ export function agentRoutes(
),
)
.orderBy(desc(heartbeatRuns.createdAt))
.limit(minCount - liveRuns.length);
.limit(targetRunCount - liveRuns.length);
res.json([...liveRuns, ...recentRuns]);
const rows = [...liveRuns, ...recentRuns];
res.json(await Promise.all(rows.map(async (run) => ({
...run,
outputSilence: await heartbeat.buildRunOutputSilence(run),
}))));
return;
}
res.json(liveRuns);
res.json(await Promise.all(liveRuns.map(async (run) => ({
...run,
outputSilence: await heartbeat.buildRunOutputSilence(run),
}))));
});
router.get("/heartbeat-runs/:runId", async (req, res) => {
@ -2600,7 +2625,7 @@ export function agentRoutes(
const retryExhaustedReason = await heartbeat.getRetryExhaustedReason(runId);
res.json(
redactCurrentUserValue(
{ ...run, retryExhaustedReason },
{ ...run, retryExhaustedReason, outputSilence: await heartbeat.buildRunOutputSilence(run) },
await getCurrentUserRedactionOptions(),
),
);
@ -2630,6 +2655,42 @@ export function agentRoutes(
res.json(run);
});
router.post("/heartbeat-runs/:runId/watchdog-decisions", async (req, res) => {
const runId = req.params.runId as string;
const existing = await heartbeat.getRun(runId);
if (!existing) {
res.status(404).json({ error: "Heartbeat run not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const decision = typeof req.body?.decision === "string" ? req.body.decision : "";
if (!["snooze", "continue", "dismissed_false_positive"].includes(decision)) {
res.status(400).json({ error: "Unsupported watchdog decision" });
return;
}
const evaluationIssueId = typeof req.body?.evaluationIssueId === "string" ? req.body.evaluationIssueId : null;
const reason = typeof req.body?.reason === "string" ? req.body.reason.slice(0, 4000) : null;
const snoozedUntil = decision === "snooze"
? new Date(String(req.body?.snoozedUntil ?? ""))
: null;
if (decision === "snooze" && (!snoozedUntil || Number.isNaN(snoozedUntil.getTime()) || snoozedUntil <= new Date())) {
res.status(400).json({ error: "snoozedUntil must be a future ISO datetime" });
return;
}
const row = await recovery.recordWatchdogDecision({
runId: existing.id,
actor: req.actor,
decision: decision as "snooze" | "continue" | "dismissed_false_positive",
evaluationIssueId,
reason,
snoozedUntil,
createdByRunId: req.actor.runId ?? null,
});
res.json(row);
});
router.get("/heartbeat-runs/:runId/events", async (req, res) => {
const runId = req.params.runId as string;
const run = await heartbeat.getRun(runId);
@ -2730,11 +2791,17 @@ export function agentRoutes(
agentId: heartbeatRuns.agentId,
agentName: agentsTable.name,
adapterType: agentsTable.adapterType,
logBytes: heartbeatRuns.logBytes,
livenessState: heartbeatRuns.livenessState,
livenessReason: heartbeatRuns.livenessReason,
continuationAttempt: heartbeatRuns.continuationAttempt,
lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt,
nextAction: heartbeatRuns.nextAction,
lastOutputAt: heartbeatRuns.lastOutputAt,
lastOutputSeq: heartbeatRuns.lastOutputSeq,
lastOutputStream: heartbeatRuns.lastOutputStream,
lastOutputBytes: heartbeatRuns.lastOutputBytes,
processStartedAt: heartbeatRuns.processStartedAt,
})
.from(heartbeatRuns)
.innerJoin(agentsTable, eq(heartbeatRuns.agentId, agentsTable.id))
@ -2747,7 +2814,10 @@ export function agentRoutes(
)
.orderBy(desc(heartbeatRuns.createdAt));
res.json(liveRuns);
res.json(await Promise.all(liveRuns.map(async (run) => ({
...run,
outputSilence: await heartbeat.buildRunOutputSilence({ ...run, companyId: issue.companyId }),
}))));
});
router.get("/issues/:issueId/active-run", async (req, res) => {
@ -2795,6 +2865,7 @@ export function agentRoutes(
agentId: agent.id,
agentName: agent.name,
adapterType: agent.adapterType,
outputSilence: await heartbeat.buildRunOutputSilence({ ...run, companyId: issue.companyId }),
});
});

View file

@ -10,6 +10,26 @@ import { validate } from "../middleware/validate.js";
import { heartbeatService, issueService, issueTreeControlService, logActivity } from "../services/index.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
const TREE_RUN_CANCELLATION_RESPONSE_WAIT_MS = 1_000;
function errorToMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
async function waitForRunCancellationTasks(tasks: Promise<void>[]) {
let timeout: ReturnType<typeof setTimeout> | null = null;
try {
await Promise.race([
Promise.all(tasks),
new Promise((resolve) => {
timeout = setTimeout(resolve, TREE_RUN_CANCELLATION_RESPONSE_WAIT_MS);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
}
export function issueTreeControlRoutes(db: Db) {
const router = Router();
const issuesSvc = issueService(db);
@ -91,25 +111,48 @@ export function issueTreeControlRoutes(db: Db) {
},
});
const runCancellationTasks: Promise<void>[] = [];
if (result.hold.mode === "pause" || result.hold.mode === "cancel") {
const interruptedRunIds = [...new Set(result.preview.activeRuns.map((run) => run.id))];
for (const runId of interruptedRunIds) {
await heartbeat.cancelRun(runId);
await logActivity(db, {
companyId: root.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.tree_hold_run_interrupted",
entityType: "heartbeat_run",
entityId: runId,
details: {
holdId: result.hold.id,
rootIssueId: root.id,
reason: result.hold.mode === "pause" ? "active_subtree_pause_hold" : "subtree_cancel_operation",
},
});
for (const heartbeatRunId of interruptedRunIds) {
const cancellationTask = (async () => {
try {
await heartbeat.cancelRun(heartbeatRunId);
await logActivity(db, {
companyId: root.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.tree_hold_run_interrupted",
entityType: "heartbeat_run",
entityId: heartbeatRunId,
details: {
holdId: result.hold.id,
rootIssueId: root.id,
reason: result.hold.mode === "pause" ? "active_subtree_pause_hold" : "subtree_cancel_operation",
},
});
} catch (error) {
await Promise.resolve(logActivity(db, {
companyId: root.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.tree_hold_run_interrupt_failed",
entityType: "heartbeat_run",
entityId: heartbeatRunId,
details: {
holdId: result.hold.id,
rootIssueId: root.id,
reason: result.hold.mode === "pause" ? "active_subtree_pause_hold" : "subtree_cancel_operation",
error: errorToMessage(error),
},
})).catch(() => null);
}
})();
runCancellationTasks.push(cancellationTask);
}
const cancelledWakeups = await treeControlSvc.cancelUnclaimedWakeupsForTree(
@ -158,6 +201,10 @@ export function issueTreeControlRoutes(db: Db) {
});
}
if (runCancellationTasks.length > 0) {
await waitForRunCancellationTasks(runCancellationTasks);
}
if (result.hold.mode === "restore") {
let statusUpdate;
try {

View file

@ -35,6 +35,7 @@ import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry";
import { getTelemetryClient } from "../telemetry.js";
import type { StorageService } from "../storage/types.js";
import { validate } from "../middleware/validate.js";
import * as serviceIndex from "../services/index.js";
import {
accessService,
agentService,
@ -184,18 +185,24 @@ function isClosedIssueStatus(status: string | null | undefined): status is "done
return status === "done" || status === "cancelled";
}
function shouldImplicitlyMoveCommentedIssueToTodoForAgent(input: {
function shouldImplicitlyMoveCommentedIssueToTodo(input: {
issueStatus: string | null | undefined;
assigneeAgentId: string | null | undefined;
actorType: "agent" | "user";
actorId: string;
}) {
// Only human comments should implicitly reopen finished work.
// Agent-authored comments remain communicative unless reopen was explicit.
if (input.actorType !== "user") return false;
if (!isClosedIssueStatus(input.issueStatus) && input.issueStatus !== "blocked") return false;
if (typeof input.assigneeAgentId !== "string" || input.assigneeAgentId.length === 0) return false;
if (input.actorType === "agent" && input.actorId === input.assigneeAgentId) return false;
return true;
}
function isExplicitResumeCapableStatus(status: string | null | undefined) {
return status === "done" || status === "blocked" || status === "todo" || status === "in_progress";
}
function queueResolvedInteractionContinuationWakeup(input: {
heartbeat: ReturnType<typeof heartbeatService>;
issue: { id: string; assigneeAgentId: string | null; status: string };
@ -409,6 +416,15 @@ export function issueRoutes(
const routinesSvc = routineService(db, {
pluginWorkerManager: opts.pluginWorkerManager,
});
const issueTreeControlFactory = Object.prototype.hasOwnProperty.call(
serviceIndex,
"issueTreeControlService",
)
? serviceIndex.issueTreeControlService
: undefined;
const treeControlSvc = issueTreeControlFactory?.(db) ?? {
getActivePauseHoldGate: async () => null,
};
const feedbackExportService = opts?.feedbackExportService;
const environmentsSvc = environmentService(db);
const upload = multer({
@ -627,6 +643,90 @@ export function issueRoutes(
return true;
}
async function assertExplicitResumeIntentAllowed(
req: Request,
res: Response,
issue: { id: string; companyId: string; status: string; assigneeAgentId: string | null },
) {
if (issue.status === "cancelled") {
res.status(409).json({
error: "Cancelled issues must be restored through the dedicated restore flow",
details: {
issueId: issue.id,
status: issue.status,
securityPrinciples: ["Complete Mediation", "Fail Securely"],
},
});
return false;
}
if (!isExplicitResumeCapableStatus(issue.status)) {
res.status(409).json({
error: "Issue is not resumable through comment follow-up intent",
details: { issueId: issue.id, status: issue.status },
});
return false;
}
const activePauseHold = await treeControlSvc.getActivePauseHoldGate(issue.companyId, issue.id);
if (activePauseHold) {
res.status(409).json({
error: "Issue follow-up blocked by active subtree pause hold",
details: {
issueId: issue.id,
holdId: activePauseHold.holdId,
rootIssueId: activePauseHold.rootIssueId,
mode: activePauseHold.mode,
securityPrinciples: ["Complete Mediation", "Fail Securely", "Secure Defaults"],
},
});
return false;
}
if (issue.status === "blocked") {
const readiness = await svc.getDependencyReadiness(issue.id);
if (readiness.unresolvedBlockerCount > 0) {
res.status(409).json({
error: "Issue follow-up blocked by unresolved blockers",
details: {
issueId: issue.id,
unresolvedBlockerIssueIds: readiness.unresolvedBlockerIssueIds,
},
});
return false;
}
}
if (req.actor.type !== "agent") return true;
const actorAgentId = req.actor.agentId;
if (!actorAgentId) {
res.status(403).json({ error: "Agent authentication required" });
return false;
}
if (!issue.assigneeAgentId) {
res.status(409).json({
error: "Issue follow-up requires an assigned agent",
details: { issueId: issue.id, actorAgentId },
});
return false;
}
if (issue.assigneeAgentId === actorAgentId) return true;
if (await hasActiveCheckoutManagementOverride(actorAgentId, issue.companyId, issue.assigneeAgentId)) {
return true;
}
res.status(403).json({
error: "Agent cannot request follow-up for another agent's issue",
details: {
issueId: issue.id,
assigneeAgentId: issue.assigneeAgentId,
actorAgentId,
},
});
return false;
}
async function resolveActiveIssueRun(issue: {
id: string;
assigneeAgentId: string | null;
@ -932,6 +1032,7 @@ export function issueRoutes(
commentCursor,
wakeComment,
relations,
blockerAttention,
attachments,
continuationSummary,
currentExecutionWorkspace,
@ -942,6 +1043,7 @@ export function issueRoutes(
svc.getCommentCursor(issue.id),
wakeCommentId ? svc.getComment(wakeCommentId) : null,
svc.getRelationSummaries(issue.id),
svc.listBlockerAttention(issue.companyId, [issue]).then((map) => map.get(issue.id) ?? null),
svc.listAttachments(issue.id),
documentsSvc.getIssueDocumentByKey(issue.id, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY),
currentExecutionWorkspacePromise,
@ -954,6 +1056,7 @@ export function issueRoutes(
title: issue.title,
description: issue.description,
status: issue.status,
...(blockerAttention ? { blockerAttention } : {}),
priority: issue.priority,
projectId: issue.projectId,
goalId: goal?.id ?? issue.goalId,
@ -1023,12 +1126,13 @@ export function issueRoutes(
return;
}
assertCompanyAccess(req, issue.companyId);
const [{ project, goal }, ancestors, mentionedProjectIds, documentPayload, relations, referenceSummary] = await Promise.all([
const [{ project, goal }, ancestors, mentionedProjectIds, documentPayload, relations, blockerAttention, referenceSummary] = await Promise.all([
resolveIssueProjectAndGoal(issue),
svc.getAncestors(issue.id),
svc.findMentionedProjectIds(issue.id, { includeCommentBodies: false }),
documentsSvc.getIssueDocumentPayload(issue),
svc.getRelationSummaries(issue.id),
svc.listBlockerAttention(issue.companyId, [issue]).then((map) => map.get(issue.id) ?? null),
issueReferencesSvc.listIssueReferenceSummary(issue.id),
]);
const mentionedProjects = mentionedProjectIds.length > 0
@ -1042,6 +1146,7 @@ export function issueRoutes(
...issue,
goalId: goal?.id ?? issue.goalId,
ancestors,
...(blockerAttention ? { blockerAttention } : {}),
blockedBy: relations.blockedBy,
blocks: relations.blocks,
relatedWork: referenceSummary,
@ -1800,17 +1905,27 @@ export function issueRoutes(
comment: commentBody,
reviewRequest,
reopen: reopenRequested,
resume: resumeRequested,
interrupt: interruptRequested,
hiddenAt: hiddenAtRaw,
...updateFields
} = req.body;
if (resumeRequested === true && !commentBody) {
res.status(400).json({ error: "Follow-up intent requires a comment" });
return;
}
if (resumeRequested === true && !(await assertExplicitResumeIntentAllowed(req, res, existing))) return;
if (resumeRequested !== true && reopenRequested === true && req.actor.type === "agent") {
if (!(await assertExplicitResumeIntentAllowed(req, res, existing))) return;
}
await assertIssueEnvironmentSelection(existing.companyId, updateFields.executionWorkspaceSettings?.environmentId);
const requestedAssigneeAgentId =
normalizedAssigneeAgentId === undefined ? existing.assigneeAgentId : normalizedAssigneeAgentId;
const explicitMoveToTodoRequested = reopenRequested || resumeRequested === true;
const effectiveMoveToTodoRequested =
reopenRequested ||
explicitMoveToTodoRequested ||
(!!commentBody &&
shouldImplicitlyMoveCommentedIssueToTodoForAgent({
shouldImplicitlyMoveCommentedIssueToTodo({
issueStatus: existing.status,
assigneeAgentId: requestedAssigneeAgentId,
actorType: actor.actorType,
@ -1823,6 +1938,10 @@ export function issueRoutes(
isBlocked && effectiveMoveToTodoRequested
? (await svc.getDependencyReadiness(existing.id)).unresolvedBlockerCount > 0
: false;
if (resumeRequested === true && isBlocked && hasUnresolvedFirstClassBlockers) {
res.status(409).json({ error: "Issue follow-up blocked by unresolved blockers" });
return;
}
let interruptedRunId: string | null = null;
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(existing);
const isAgentWorkUpdate =
@ -2078,6 +2197,7 @@ export function issueRoutes(
...updateFields,
identifier: issue.identifier,
...(commentBody ? { source: "comment" } : {}),
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
_previous: hasFieldChanges ? previous : undefined,
@ -2220,6 +2340,7 @@ export function issueRoutes(
bodySnippet: comment.body.slice(0, 120),
identifier: issue.identifier,
issueTitle: issue.title,
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
...(hasFieldChanges ? { updated: true } : {}),
@ -2266,6 +2387,10 @@ export function issueRoutes(
existing.status === "blocked" &&
issue.status === "todo" &&
(req.body.status !== undefined || reopened);
const statusChangedFromClosedToTodo =
isClosedIssueStatus(existing.status) &&
issue.status === "todo" &&
req.body.status !== undefined;
const previousExecutionState = parseIssueExecutionState(existing.executionState);
const nextExecutionState = parseIssueExecutionState(issue.executionState);
const executionStageWakeup = buildExecutionStageWakeup({
@ -2300,6 +2425,7 @@ export function issueRoutes(
issueId: issue.id,
...(comment ? { commentId: comment.id } : {}),
mutation: "update",
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
},
requestedByActorType: actor.actorType,
@ -2314,12 +2440,17 @@ export function issueRoutes(
}
: {}),
source: "issue.update",
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
},
});
}
if (!assigneeChanged && (statusChangedFromBacklog || statusChangedFromBlockedToTodo) && issue.assigneeAgentId) {
if (
!assigneeChanged &&
(statusChangedFromBacklog || statusChangedFromBlockedToTodo || statusChangedFromClosedToTodo) &&
issue.assigneeAgentId
) {
addWakeup(issue.assigneeAgentId, {
source: "automation",
triggerDetail: "system",
@ -2327,6 +2458,7 @@ export function issueRoutes(
payload: {
issueId: issue.id,
mutation: "update",
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
},
requestedByActorType: actor.actorType,
@ -2334,6 +2466,7 @@ export function issueRoutes(
contextSnapshot: {
issueId: issue.id,
source: "issue.status_change",
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
},
});
@ -2355,6 +2488,7 @@ export function issueRoutes(
commentId: comment.id,
mutation: "comment",
...(reopened ? { reopenedFrom: reopenFromStatus } : {}),
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
},
requestedByActorType: actor.actorType,
@ -2367,6 +2501,7 @@ export function issueRoutes(
source: reopened ? "issue.comment.reopen" : "issue.comment",
wakeReason: reopened ? "issue_reopened_via_comment" : "issue_commented",
...(reopened ? { reopenedFrom: reopenFromStatus } : {}),
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
},
});
@ -3143,12 +3278,18 @@ export function issueRoutes(
const actor = getActorInfo(req);
const reopenRequested = req.body.reopen === true;
const resumeRequested = req.body.resume === true;
const interruptRequested = req.body.interrupt === true;
if (resumeRequested === true && !(await assertExplicitResumeIntentAllowed(req, res, issue))) return;
if (resumeRequested !== true && reopenRequested === true && req.actor.type === "agent") {
if (!(await assertExplicitResumeIntentAllowed(req, res, issue))) return;
}
const isClosed = isClosedIssueStatus(issue.status);
const isBlocked = issue.status === "blocked";
const explicitMoveToTodoRequested = reopenRequested || resumeRequested === true;
const effectiveMoveToTodoRequested =
reopenRequested ||
shouldImplicitlyMoveCommentedIssueToTodoForAgent({
explicitMoveToTodoRequested ||
shouldImplicitlyMoveCommentedIssueToTodo({
issueStatus: issue.status,
assigneeAgentId: issue.assigneeAgentId,
actorType: actor.actorType,
@ -3158,6 +3299,10 @@ export function issueRoutes(
isBlocked && effectiveMoveToTodoRequested
? (await svc.getDependencyReadiness(issue.id)).unresolvedBlockerCount > 0
: false;
if (resumeRequested === true && isBlocked && hasUnresolvedFirstClassBlockers) {
res.status(409).json({ error: "Issue follow-up blocked by unresolved blockers" });
return;
}
let reopened = false;
let reopenFromStatus: string | null = null;
let interruptedRunId: string | null = null;
@ -3188,6 +3333,7 @@ export function issueRoutes(
reopened: true,
reopenedFrom: reopenFromStatus,
source: "comment",
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
identifier: currentIssue.identifier,
},
});
@ -3250,6 +3396,7 @@ export function issueRoutes(
bodySnippet: comment.body.slice(0, 120),
identifier: currentIssue.identifier,
issueTitle: currentIssue.title,
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
...summarizeIssueReferenceActivityDetails({
@ -3293,6 +3440,7 @@ export function issueRoutes(
commentId: comment.id,
reopenedFrom: reopenFromStatus,
mutation: "comment",
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
},
requestedByActorType: actor.actorType,
@ -3305,6 +3453,7 @@ export function issueRoutes(
source: "issue.comment.reopen",
wakeReason: "issue_reopened_via_comment",
reopenedFrom: reopenFromStatus,
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
},
});
@ -3317,6 +3466,7 @@ export function issueRoutes(
issueId: currentIssue.id,
commentId: comment.id,
mutation: "comment",
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
},
requestedByActorType: actor.actorType,
@ -3328,6 +3478,7 @@ export function issueRoutes(
wakeCommentId: comment.id,
source: "issue.comment",
wakeReason: "issue_commented",
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
},
});