From cf4f7619336a7a3ab9e622779273b89ebca089a0 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 30 Apr 2026 11:11:41 -0500 Subject: [PATCH] Harden issue recovery and execution safety Co-Authored-By: Paperclip --- .../__tests__/agent-live-run-routes.test.ts | 65 ++++++++- ...eartbeat-issue-liveness-escalation.test.ts | 17 ++- .../heartbeat-process-recovery.test.ts | 46 +++++++ ...ue-agent-mutation-ownership-routes.test.ts | 25 ++++ .../__tests__/issue-blocker-attention.test.ts | 52 +++++++ .../issue-execution-policy-routes.test.ts | 127 ++++++++++++++++-- .../src/__tests__/run-continuations.test.ts | 22 +++ server/src/routes/agents.ts | 4 +- server/src/routes/issues.ts | 69 +++++++++- server/src/services/issues.ts | 51 ++++--- server/src/services/recovery/service.ts | 8 +- 11 files changed, 447 insertions(+), 39 deletions(-) diff --git a/server/src/__tests__/agent-live-run-routes.test.ts b/server/src/__tests__/agent-live-run-routes.test.ts index 0bfa209caab..c8243370023 100644 --- a/server/src/__tests__/agent-live-run-routes.test.ts +++ b/server/src/__tests__/agent-live-run-routes.test.ts @@ -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 = {}) { const [{ agentRoutes }, { errorHandler }] = await Promise.all([ vi.importActual("../routes/agents.js"), vi.importActual("../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>) { + const limit = vi.fn(async (value: number) => rows.slice(0, value)); + const orderedQuery = { + limit, + then: (resolve: (value: Array>) => 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); + }); }); diff --git a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts index 369bb05c2b7..10ad103cd44 100644 --- a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts +++ b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts @@ -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 () => { diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index d66c2617ea4..a00cf6362e6 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -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", diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts index 211c952c908..76585768f8f 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -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()); diff --git a/server/src/__tests__/issue-blocker-attention.test.ts b/server/src/__tests__/issue-blocker-attention.test.ts index 611921911ad..66df6959376 100644 --- a/server/src/__tests__/issue-blocker-attention.test.ts +++ b/server/src/__tests__/issue-blocker-attention.test.ts @@ -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" }); diff --git a/server/src/__tests__/issue-execution-policy-routes.test.ts b/server/src/__tests__/issue-execution-policy-routes.test.ts index f21e7b95c4d..22d706a6639 100644 --- a/server/src/__tests__/issue-execution-policy-routes.test.ts +++ b/server/src/__tests__/issue-execution-policy-routes.test.ts @@ -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 = 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) => ({ + ...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(); + }); }); diff --git a/server/src/__tests__/run-continuations.test.ts b/server/src/__tests__/run-continuations.test.ts index a9e1e7b0006..0f0f0380040 100644 --- a/server/src/__tests__/run-continuations.test.ts +++ b/server/src/__tests__/run-continuations.test.ts @@ -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 }), diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index b3c7701b853..47d586687f5 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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, diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index ab6a4232665..dde8049e4aa 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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") { diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index cce66d09c95..b27920f0e62 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -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(); - 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,6 +1261,9 @@ 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); if (hasWaitingPath) { diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index fc9fb7e1f64..ec7213515e7 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -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 & { blockedByIssueIds: string[] } = { blockedByIssueIds: nextBlockerIds, }; - if (input.issue.status !== "blocked") { + if (!isAlreadyBlocked) { update.status = "blocked"; }