Compare commits

...

2 Commits

Author SHA1 Message Date
Dotta
0387282e18 Remove redundant waiting path check
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-30 11:23:53 -05:00
Dotta
cf4f761933 Harden issue recovery and execution safety
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-30 11:11:41 -05:00
11 changed files with 448 additions and 40 deletions

View File

@@ -10,7 +10,6 @@ const mockHeartbeatService = vi.hoisted(() => ({
buildRunOutputSilence: vi.fn(),
getRunIssueSummary: vi.fn(),
getActiveRunIssueSummaryForAgent: vi.fn(),
buildRunOutputSilence: vi.fn(),
getRunLogAccess: vi.fn(),
readLog: vi.fn(),
}));
@@ -71,7 +70,7 @@ function registerModuleMocks() {
}));
}
async function createApp() {
async function createApp(db: Record<string, unknown> = {}) {
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
@@ -88,11 +87,32 @@ async function createApp() {
};
next();
});
app.use("/api", agentRoutes({} as any));
app.use("/api", agentRoutes(db as any));
app.use(errorHandler);
return app;
}
function createLiveRunsDbStub(rows: Array<Record<string, unknown>>) {
const limit = vi.fn(async (value: number) => rows.slice(0, value));
const orderedQuery = {
limit,
then: (resolve: (value: Array<Record<string, unknown>>) => unknown) => Promise.resolve(rows).then(resolve),
};
const query = {
from: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnValue(orderedQuery),
};
return {
db: {
select: vi.fn().mockReturnValue(query),
},
limit,
};
}
async function requestApp(
app: express.Express,
buildRequest: (baseUrl: string) => request.Test,
@@ -284,4 +304,43 @@ describe("agent live run routes", () => {
nextOffset: 5,
});
});
it("caps company live run polling by default", async () => {
const rows = Array.from({ length: 75 }, (_, index) => ({
id: `run-${index}`,
companyId: "company-1",
status: "running",
invocationSource: "on_demand",
triggerDetail: "manual",
startedAt: new Date("2026-04-10T09:30:00.000Z"),
finishedAt: null,
createdAt: new Date(`2026-04-10T09:${String(index % 60).padStart(2, "0")}:00.000Z`),
agentId: "agent-1",
agentName: "Builder",
adapterType: "codex_local",
logBytes: 0,
livenessState: "healthy",
livenessReason: null,
continuationAttempt: 0,
lastUsefulActionAt: null,
nextAction: null,
lastOutputAt: null,
lastOutputSeq: null,
lastOutputStream: null,
lastOutputBytes: 0,
processStartedAt: null,
issueId: "issue-1",
}));
const { db, limit } = createLiveRunsDbStub(rows);
const res = await requestApp(
await createApp(db),
(baseUrl) => request(baseUrl).get("/api/companies/company-1/live-runs"),
);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(limit).toHaveBeenCalledWith(50);
expect(res.body).toHaveLength(50);
expect(mockHeartbeatService.buildRunOutputSilence).toHaveBeenCalledTimes(50);
});
});

View File

@@ -289,10 +289,23 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
const heartbeat = heartbeatService(db);
const first = await heartbeat.reconcileIssueGraphLiveness();
const second = await heartbeat.reconcileIssueGraphLiveness();
expect(first.escalationsCreated).toBe(1);
const [sourceAfterFirst] = await db
.select({ updatedAt: issues.updatedAt })
.from(issues)
.where(eq(issues.id, blockedIssueId));
const eventsAfterFirst = await db.select().from(activityLog).where(eq(activityLog.companyId, companyId));
expect(eventsAfterFirst.filter((event) => event.action === "issue.blockers.updated")).toHaveLength(1);
const second = await heartbeat.reconcileIssueGraphLiveness();
expect(second.escalationsCreated).toBe(0);
const [sourceAfterSecond] = await db
.select({ updatedAt: issues.updatedAt })
.from(issues)
.where(eq(issues.id, blockedIssueId));
expect(sourceAfterSecond?.updatedAt.getTime()).toBe(sourceAfterFirst?.updatedAt.getTime());
const escalations = await db
.select()
@@ -345,7 +358,7 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
projectWorkspaceSourceIssueId: blockerIssueId,
},
});
expect(events.some((event) => event.action === "issue.blockers.updated")).toBe(true);
expect(events.filter((event) => event.action === "issue.blockers.updated")).toHaveLength(1);
});
it("skips budget-blocked direct owners and assigns recovery to the manager fallback", async () => {

View File

@@ -2212,6 +2212,52 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(wakeups).toHaveLength(1);
});
it.fails("does not treat a productive terminal run as healthy when in-progress work has no live path", async () => {
const { companyId, agentId, issueId } = await seedStrandedIssueFixture({
status: "in_progress",
runStatus: "succeeded",
retryReason: "issue_continuation_needed",
livenessState: "advanced",
});
const heartbeat = heartbeatService(db);
const sourceIssue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
expect(sourceIssue).toMatchObject({
status: "in_progress",
assigneeAgentId: agentId,
assigneeUserId: null,
executionRunId: null,
});
const activeRuns = await db
.select()
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.status, ["queued", "running"])));
expect(activeRuns).toHaveLength(0);
const liveWakeups = await db
.select()
.from(agentWakeupRequests)
.where(and(eq(agentWakeupRequests.companyId, companyId), inArray(agentWakeupRequests.status, ["queued", "deferred_issue_execution"])));
expect(liveWakeups).toHaveLength(0);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.productiveContinuationObserved).toBe(0);
expect(result.continuationRequeued + result.escalated).toBe(1);
expect(result.issueIds).toEqual([issueId]);
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
const recoveryIssues = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stranded_issue_recovery")));
const followupWakeups = await db
.select()
.from(agentWakeupRequests)
.where(and(eq(agentWakeupRequests.companyId, companyId), inArray(agentWakeupRequests.status, ["queued", "deferred_issue_execution"])));
expect(comments.length + recoveryIssues.length + followupWakeups.length).toBeGreaterThan(0);
});
it("does not reconcile user-assigned work through the agent stranded-work recovery path", async () => {
const { issueId, runId } = await seedStrandedIssueFixture({
status: "todo",

View File

@@ -387,6 +387,31 @@ describe("agent issue mutation checkout ownership", () => {
expect(mockStorageService.deleteObject).not.toHaveBeenCalled();
});
it.each([
["patch", (app: express.Express) => request(app).patch(`/api/issues/${issueId}`).send({ title: "Blocked" })],
["comment", (app: express.Express) => request(app).post(`/api/issues/${issueId}/comments`).send({ body: "blocked" })],
[
"document upsert",
(app: express.Express) =>
request(app).put(`/api/issues/${issueId}/documents/plan`).send({ format: "markdown", body: "# blocked" }),
],
])("rejects peer agent %s mutations on user-assigned issues", async (_name, sendRequest) => {
mockIssueService.getById.mockResolvedValue(makeIssue({
status: "in_review",
assigneeAgentId: null,
assigneeUserId: "board-user",
}));
const res = await sendRequest(await createApp(peerActor()));
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toBe("Agent cannot mutate a user-assigned issue");
expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled();
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueService.addComment).not.toHaveBeenCalled();
expect(mockDocumentService.upsertIssueDocument).not.toHaveBeenCalled();
});
it("allows the checked-out owner with the matching run id to patch and update documents", async () => {
const app = await createApp(ownerActor());

View File

@@ -76,6 +76,9 @@ describeEmbeddedPostgres("issue blocker attention", () => {
status: string;
parentId?: string | null;
assigneeAgentId?: string | null;
originKind?: string | null;
originId?: string | null;
originFingerprint?: string | null;
}) {
const id = input.id ?? randomUUID();
await db.insert(issues).values({
@@ -87,6 +90,9 @@ describeEmbeddedPostgres("issue blocker attention", () => {
priority: "medium",
parentId: input.parentId ?? null,
assigneeAgentId: input.assigneeAgentId ?? null,
originKind: input.originKind ?? "manual",
originId: input.originId ?? null,
originFingerprint: input.originFingerprint ?? "default",
});
return id;
}
@@ -356,6 +362,52 @@ describeEmbeddedPostgres("issue blocker attention", () => {
});
});
it("treats open liveness escalation blockers as covered waiting paths", async () => {
const { companyId, agentId } = await createCompany("PBL");
const parentId = await insertIssue({ companyId, identifier: "PBL-1", title: "Parent", status: "blocked" });
const cancelledLeafId = await insertIssue({
companyId,
identifier: "PBL-2",
title: "Cancelled blocker",
status: "cancelled",
assigneeAgentId: agentId,
});
const incidentKey = [
"harness_liveness",
companyId,
parentId,
"blocked_by_cancelled_issue",
cancelledLeafId,
].join(":");
const escalationId = await insertIssue({
companyId,
identifier: "PBL-3",
title: "Liveness escalation",
status: "todo",
assigneeAgentId: agentId,
originKind: "harness_liveness_escalation",
originId: incidentKey,
originFingerprint: [
"harness_liveness_leaf",
companyId,
"blocked_by_cancelled_issue",
cancelledLeafId,
].join(":"),
});
await block({ companyId, blockerIssueId: cancelledLeafId, blockedIssueId: parentId });
await block({ companyId, blockerIssueId: escalationId, blockedIssueId: parentId });
const parent = (await svc.list(companyId, { status: "blocked,todo" })).find((issue) => issue.id === parentId);
expect(parent?.blockerAttention).toMatchObject({
state: "covered",
reason: "active_dependency",
unresolvedBlockerCount: 2,
coveredBlockerCount: 2,
attentionBlockerCount: 0,
});
});
it("does not treat a scheduled retry as actively covered work", async () => {
const { companyId, agentId } = await createCompany("PBY");
const parentId = await insertIssue({ companyId, identifier: "PBY-1", title: "Parent", status: "blocked" });

View File

@@ -34,6 +34,9 @@ function registerModuleMocks() {
agentService: () => ({
getById: vi.fn(async () => null),
}),
companyService: () => ({
getById: vi.fn(async () => ({ id: "company-1", issuePrefix: "PAP" })),
}),
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => ({
@@ -76,7 +79,27 @@ function registerModuleMocks() {
}));
}
async function createApp() {
function boardActor() {
return {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
};
}
function agentActor() {
return {
type: "agent",
agentId: "22222222-2222-4222-8222-222222222222",
companyId: "company-1",
source: "agent_key",
runId: "33333333-3333-4333-8333-333333333333",
};
}
async function createApp(actor: Record<string, unknown> = boardActor()) {
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
import("../middleware/index.js"),
import("../routes/issues.js"),
@@ -84,13 +107,7 @@ async function createApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
};
(req as any).actor = actor;
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
@@ -162,4 +179,98 @@ describe("issue execution policy routes", () => {
expect(updatePatch.executionState).toBeUndefined();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("rejects agent-authored execution policies with user participants", async () => {
const policy = normalizeIssueExecutionPolicy({
stages: [
{
id: "11111111-1111-4111-8111-111111111111",
type: "approval",
participants: [{ type: "user", userId: "local-board" }],
},
],
})!;
const issue = {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: "company-1",
status: "in_progress",
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
assigneeUserId: null,
createdByUserId: "local-board",
identifier: "PAP-999",
title: "Execution policy escalation",
executionPolicy: null,
executionState: null,
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}));
const res = await request(await createApp(agentActor()))
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.send({
executionPolicy: policy,
status: "in_review",
comment: "Escalate to the board.",
});
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toBe("Agents cannot author execution policies with user participants");
expect(mockIssueService.assertCheckoutOwner).toHaveBeenCalledWith(
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"22222222-2222-4222-8222-222222222222",
"33333333-3333-4333-8333-333333333333",
);
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("rejects agent-created issues with user-participant execution policies", async () => {
const policy = normalizeIssueExecutionPolicy({
stages: [
{
id: "11111111-1111-4111-8111-111111111111",
type: "approval",
participants: [{ type: "user", userId: "local-board" }],
},
],
})!;
const res = await request(await createApp(agentActor()))
.post("/api/companies/company-1/issues")
.send({
title: "Escalation issue",
executionPolicy: policy,
});
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toBe("Agents cannot author execution policies with user participants");
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("rejects agent PATCH attempts to assign issues directly to users", async () => {
const issue = {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: "company-1",
status: "in_progress",
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
assigneeUserId: null,
createdByUserId: "local-board",
identifier: "PAP-999",
title: "Direct user assignment",
executionPolicy: null,
executionState: null,
};
mockIssueService.getById.mockResolvedValue(issue);
const res = await request(await createApp(agentActor()))
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.send({ assigneeAgentId: null, assigneeUserId: "local-board" });
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toBe("Agents cannot assign issues to users");
expect(mockIssueService.update).not.toHaveBeenCalled();
});
});

View File

@@ -106,6 +106,28 @@ describe("run liveness continuations", () => {
expect(decision.nextAttempt).toBe(2);
});
it.fails("treats an advanced terminal run as progress evidence, not a live continuation path", () => {
const decision = decideRunLivenessContinuation({
run: run(),
issue: issue(),
agent: agent(),
livenessState: "advanced",
livenessReason: "Run produced concrete action evidence: created an issue comment",
nextAction: "Resume the implementation from the remaining acceptance criteria.",
budgetBlocked: false,
idempotentWakeExists: false,
});
expect(decision.kind).toBe("enqueue");
if (decision.kind !== "enqueue") return;
expect(decision.payload).toMatchObject({
issueId,
sourceRunId: runId,
livenessState: "advanced",
instruction: "Resume the implementation from the remaining acceptance criteria.",
});
});
it("does not enqueue a third continuation and returns an exhaustion comment", () => {
const decision = decideRunLivenessContinuation({
run: run({ continuationAttempt: 2 }),

View File

@@ -2703,8 +2703,8 @@ export function agentRoutes(
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const minCount = readLiveRunsQueryInt(req.query.minCount, 50);
const limit = readLiveRunsQueryInt(req.query.limit, 50);
const minCount = readLiveRunsQueryInt(req.query.minCount, 50, 50);
const limit = readLiveRunsQueryInt(req.query.limit, 50, 50);
const columns = {
id: heartbeatRuns.id,

View File

@@ -110,6 +110,10 @@ type ExecutionStageWakeContext = {
allowedActions: string[];
};
function executionPolicyHasUserParticipants(policy: NormalizedExecutionPolicy | null): boolean {
return Boolean(policy?.stages.some((stage) => stage.participants.some((participant) => participant.type === "user")));
}
function executionPrincipalsEqual(
left: ParsedExecutionState["currentParticipant"] | null,
right: ParsedExecutionState["currentParticipant"] | null,
@@ -595,7 +599,7 @@ export function issueRoutes(
async function assertAgentIssueMutationAllowed(
req: Request,
res: Response,
issue: { id: string; companyId: string; status: string; assigneeAgentId: string | null },
issue: { id: string; companyId: string; status: string; assigneeAgentId: string | null; assigneeUserId: string | null },
) {
if (req.actor.type !== "agent") return true;
const actorAgentId = req.actor.agentId;
@@ -603,6 +607,19 @@ export function issueRoutes(
res.status(403).json({ error: "Agent authentication required" });
return false;
}
if (issue.assigneeUserId) {
res.status(403).json({
error: "Agent cannot mutate a user-assigned issue",
details: {
issueId: issue.id,
assigneeUserId: issue.assigneeUserId,
actorAgentId,
status: issue.status,
securityPrinciples: ["Least Privilege", "Complete Mediation", "Fail Securely"],
},
});
return false;
}
if (issue.assigneeAgentId === null) {
return true;
}
@@ -1812,6 +1829,16 @@ export function issueRoutes(
const actor = getActorInfo(req);
const executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy);
if (req.actor.type === "agent" && executionPolicyHasUserParticipants(executionPolicy)) {
res.status(403).json({
error: "Agents cannot author execution policies with user participants",
details: {
companyId,
securityPrinciples: ["Least Privilege", "Complete Mediation", "Fail Securely"],
},
});
return;
}
const issue = await svc.create(companyId, {
...req.body,
executionPolicy,
@@ -1879,6 +1906,16 @@ export function issueRoutes(
const actor = getActorInfo(req);
const executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy);
if (req.actor.type === "agent" && executionPolicyHasUserParticipants(executionPolicy)) {
res.status(403).json({
error: "Agents cannot author execution policies with user participants",
details: {
parentIssueId: parent.id,
securityPrinciples: ["Least Privilege", "Complete Mediation", "Fail Securely"],
},
});
return;
}
const { issue, parentBlockerAdded } = await svc.createChild(parent.id, {
...req.body,
executionPolicy,
@@ -2049,6 +2086,20 @@ export function issueRoutes(
updateFields.executionPolicy !== undefined
? (updateFields.executionPolicy as NormalizedExecutionPolicy | null)
: previousExecutionPolicy;
if (
req.actor.type === "agent" &&
req.body.executionPolicy !== undefined &&
executionPolicyHasUserParticipants(nextExecutionPolicy)
) {
res.status(403).json({
error: "Agents cannot author execution policies with user participants",
details: {
issueId: existing.id,
securityPrinciples: ["Least Privilege", "Complete Mediation", "Fail Securely"],
},
});
return;
}
if (normalizedAssigneeAgentId !== undefined) {
updateFields.assigneeAgentId = normalizedAssigneeAgentId;
}
@@ -2081,6 +2132,22 @@ export function issueRoutes(
};
}
Object.assign(updateFields, transition.patch);
if (
req.actor.type === "agent" &&
req.body.assigneeUserId !== undefined &&
req.body.assigneeUserId !== existing.assigneeUserId &&
!transition.workflowControlledAssignment
) {
res.status(403).json({
error: "Agents cannot assign issues to users",
details: {
issueId: existing.id,
assigneeUserId: req.body.assigneeUserId ?? null,
securityPrinciples: ["Least Privilege", "Complete Mediation", "Fail Securely"],
},
});
return;
}
if (reviewRequest !== undefined && transition.patch.executionState === undefined) {
const existingExecutionState = parseIssueExecutionState(existing.executionState);
if (!existingExecutionState || existingExecutionState.status !== "pending") {

View File

@@ -52,6 +52,7 @@ import {
issueTreeControlService,
type ActiveIssueTreePauseHoldGate,
} from "./issue-tree-control.js";
import { parseIssueGraphLivenessIncidentKey } from "./recovery/origins.js";
const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"];
const MAX_ISSUE_COMMENT_PAGE_LIMIT = 500;
@@ -996,9 +997,9 @@ async function listIssueProductivityReviewMap(
}
}
for (const row of reviewRows) {
if (!row.sourceIssueId) continue;
if (map.has(row.sourceIssueId)) continue;
for (const row of reviewRows) {
if (!row.sourceIssueId) continue;
if (map.has(row.sourceIssueId)) continue;
const detail = triggerByReviewIssueId.get(row.reviewIssueId);
map.set(row.sourceIssueId, {
reviewIssueId: row.reviewIssueId,
@@ -1174,12 +1175,12 @@ async function listIssueBlockerAttentionMap(
}
}
const reviewNodeIds = [...nodesById.values()]
.filter((node) => node.status === "in_review")
const explicitWaitCandidateIds = [...nodesById.values()]
.filter((node) => node.status !== "done")
.map((node) => node.id);
const explicitWaitingIssueIds = new Set<string>();
if (reviewNodeIds.length > 0) {
for (const chunk of chunkList(reviewNodeIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
if (explicitWaitCandidateIds.length > 0) {
for (const chunk of chunkList(explicitWaitCandidateIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
const interactionRows: Array<{ issueId: string }> = await dbOrTx
.select({ issueId: issueThreadInteractions.issueId })
.from(issueThreadInteractions)
@@ -1204,22 +1205,25 @@ async function listIssueBlockerAttentionMap(
),
);
for (const row of approvalRows) explicitWaitingIssueIds.add(row.issueId);
}
const recoveryRows: Array<{ originId: string | null }> = await dbOrTx
.select({ originId: issues.originId })
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
eq(issues.originKind, BLOCKER_ATTENTION_OPEN_RECOVERY_ORIGIN_KIND),
isNull(issues.hiddenAt),
inArray(issues.originId, chunk),
notInArray(issues.status, BLOCKER_ATTENTION_OPEN_RECOVERY_TERMINAL_STATUSES),
),
);
for (const row of recoveryRows) {
if (row.originId) explicitWaitingIssueIds.add(row.originId);
}
const recoveryRows: Array<{ id: string; originId: string | null }> = await dbOrTx
.select({ id: issues.id, originId: issues.originId })
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
eq(issues.originKind, BLOCKER_ATTENTION_OPEN_RECOVERY_ORIGIN_KIND),
isNull(issues.hiddenAt),
notInArray(issues.status, BLOCKER_ATTENTION_OPEN_RECOVERY_TERMINAL_STATUSES),
),
);
for (const row of recoveryRows) {
const parsed = parseIssueGraphLivenessIncidentKey(row.originId);
if (!parsed || parsed.companyId !== companyId) continue;
explicitWaitingIssueIds.add(row.id);
explicitWaitingIssueIds.add(parsed.issueId);
explicitWaitingIssueIds.add(parsed.leafIssueId);
}
}
@@ -1257,8 +1261,11 @@ async function listIssueBlockerAttentionMap(
if (node.status === "done") {
return { covered: true, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
}
if (explicitWaitingIssueIds.has(node.id)) {
return { covered: true, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
}
if (node.status === "in_review") {
const hasWaitingPath = activeIssueIds.has(node.id) || Boolean(node.assigneeUserId) || explicitWaitingIssueIds.has(node.id);
const hasWaitingPath = activeIssueIds.has(node.id) || Boolean(node.assigneeUserId);
if (hasWaitingPath) {
return { covered: true, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
}

View File

@@ -2250,10 +2250,16 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
}) {
const blockerIds = await existingBlockerIssueIds(input.issue.companyId, input.issue.id);
const nextBlockerIds = [...new Set([...blockerIds, input.escalationIssueId])];
const isAlreadyBlockedByEscalation = blockerIds.includes(input.escalationIssueId);
const isAlreadyBlocked = input.issue.status === "blocked";
if (isAlreadyBlockedByEscalation && isAlreadyBlocked) {
return input.issue;
}
const update: Partial<typeof issues.$inferInsert> & { blockedByIssueIds: string[] } = {
blockedByIssueIds: nextBlockerIds,
};
if (input.issue.status !== "blocked") {
if (!isAlreadyBlocked) {
update.status = "blocked";
}