mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 10:50:38 +09:00
[codex] Add runtime lifecycle recovery and live issue visibility (#4419)
This commit is contained in:
parent
9a8d219949
commit
5a0c1979cf
121 changed files with 9625 additions and 2044 deletions
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue