From 25e1718d0e2dd2675cf5d9e579a8b3fe0aa34b2f Mon Sep 17 00:00:00 2001 From: Cavas AI <279933686+cavas82ai@users.noreply.github.com> Date: Sun, 31 May 2026 06:25:36 +1000 Subject: [PATCH 1/4] Fix child-covered hub recovery assignee misroute to CEO When source_scoped_recovery_action fires on a program hub with active children and no blockers, keep the hub in_progress on the monitor owner (CTO) instead of escalating to CEO via the reportsTo chain. Co-Authored-By: Paperclip Co-authored-by: Cursor --- .../__tests__/issue-recovery-actions.test.ts | 228 ++++++++++++++++++ server/src/services/recovery/service.ts | 88 ++++++- 2 files changed, 311 insertions(+), 5 deletions(-) diff --git a/server/src/__tests__/issue-recovery-actions.test.ts b/server/src/__tests__/issue-recovery-actions.test.ts index dd15e7c0cdd..7f54ff141c1 100644 --- a/server/src/__tests__/issue-recovery-actions.test.ts +++ b/server/src/__tests__/issue-recovery-actions.test.ts @@ -25,6 +25,10 @@ import { errorHandler } from "../middleware/index.js"; import { issueRoutes } from "../routes/issues.js"; import { issueRecoveryActionService } from "../services/issue-recovery-actions.js"; import { recoveryService } from "../services/recovery/service.js"; +import { + FINISH_SUCCESSFUL_RUN_HANDOFF_REASON, + SUCCESSFUL_RUN_MISSING_STATE_REASON, +} from "../services/recovery/successful-run-handoff.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -265,6 +269,230 @@ describeEmbeddedPostgres("issue recovery actions", () => { expect(await svc.getActiveForIssue(randomUUID(), sourceIssueId)).toBeNull(); }); + it("keeps child-covered program hubs on the hub owner instead of escalating to CEO", async () => { + const companyId = randomUUID(); + const ceoId = randomUUID(); + const ctoId = randomUUID(); + const hubIssueId = randomUUID(); + const childIssueId = randomUUID(); + const prefix = `RH${companyId.replaceAll("-", "").slice(0, 6).toUpperCase()}`; + + await db.insert(companies).values({ + id: companyId, + name: "Hub Recovery Co", + issuePrefix: prefix, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values([ + { + id: ceoId, + companyId, + name: "CEO", + role: "ceo", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + id: ctoId, + companyId, + name: "CTO", + role: "cto", + status: "idle", + reportsTo: ceoId, + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + ]); + await db.insert(issues).values([ + { + id: hubIssueId, + companyId, + title: "Program hub epic", + status: "in_progress", + priority: "medium", + assigneeAgentId: ctoId, + createdByAgentId: ctoId, + issueNumber: 1, + identifier: `${prefix}-1`, + }, + { + id: childIssueId, + companyId, + title: "Active child slice", + status: "in_progress", + priority: "medium", + parentId: hubIssueId, + assigneeAgentId: ctoId, + issueNumber: 2, + identifier: `${prefix}-2`, + }, + ]); + + const enqueueWakeup = vi.fn(async () => null); + const recovery = recoveryService(db, { enqueueWakeup }); + const [hubIssue] = await db.select().from(issues).where(eq(issues.id, hubIssueId)); + const latestRun = { + id: randomUUID(), + agentId: ctoId, + status: "succeeded", + error: null, + errorCode: null, + contextSnapshot: { retryReason: FINISH_SUCCESSFUL_RUN_HANDOFF_REASON }, + livenessState: "needs_followup", + } as const; + + await recovery.escalateStrandedAssignedIssue({ + issue: hubIssue!, + previousStatus: "in_progress", + latestRun, + recoveryCause: SUCCESSFUL_RUN_MISSING_STATE_REASON, + successfulRunHandoffEvidence: { + sourceRunId: latestRun.id, + correctiveRunId: latestRun.id, + missingDisposition: "clear_next_step", + handoffAttempt: 3, + maxHandoffAttempts: 3, + exhausted: true, + }, + }); + + const [updatedHub] = await db.select().from(issues).where(eq(issues.id, hubIssueId)); + expect(updatedHub).toMatchObject({ + status: "in_progress", + assigneeAgentId: ctoId, + }); + + const [action] = await db + .select() + .from(issueRecoveryActions) + .where(eq(issueRecoveryActions.sourceIssueId, hubIssueId)); + expect(action).toMatchObject({ + ownerAgentId: ctoId, + previousOwnerAgentId: ctoId, + returnOwnerAgentId: ctoId, + kind: "missing_disposition", + }); + expect(enqueueWakeup).toHaveBeenCalledTimes(1); + expect(enqueueWakeup.mock.calls[0]?.[0]).toBe(ctoId); + }); + + it("restores CTO hub monitor owner when a child-covered hub was misrouted to CEO", async () => { + const companyId = randomUUID(); + const ceoId = randomUUID(); + const ctoId = randomUUID(); + const hubIssueId = randomUUID(); + const childIssueId = randomUUID(); + const prefix = `RH${companyId.replaceAll("-", "").slice(0, 6).toUpperCase()}`; + + await db.insert(companies).values({ + id: companyId, + name: "Misrouted Hub Recovery Co", + issuePrefix: prefix, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values([ + { + id: ceoId, + companyId, + name: "CEO", + role: "ceo", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + id: ctoId, + companyId, + name: "CTO", + role: "cto", + status: "idle", + reportsTo: ceoId, + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + ]); + await db.insert(issues).values([ + { + id: hubIssueId, + companyId, + title: "Misrouted program hub epic", + status: "blocked", + priority: "medium", + assigneeAgentId: ceoId, + createdByAgentId: ctoId, + issueNumber: 1, + identifier: `${prefix}-1`, + }, + { + id: childIssueId, + companyId, + title: "Active child slice", + status: "in_progress", + priority: "medium", + parentId: hubIssueId, + assigneeAgentId: ctoId, + issueNumber: 2, + identifier: `${prefix}-2`, + }, + ]); + + const enqueueWakeup = vi.fn(async () => null); + const recovery = recoveryService(db, { enqueueWakeup }); + const [hubIssue] = await db.select().from(issues).where(eq(issues.id, hubIssueId)); + const latestRun = { + id: randomUUID(), + agentId: ceoId, + status: "succeeded", + error: null, + errorCode: null, + contextSnapshot: { retryReason: FINISH_SUCCESSFUL_RUN_HANDOFF_REASON }, + livenessState: "needs_followup", + } as const; + + await recovery.escalateStrandedAssignedIssue({ + issue: hubIssue!, + previousStatus: "blocked", + latestRun, + recoveryCause: SUCCESSFUL_RUN_MISSING_STATE_REASON, + successfulRunHandoffEvidence: { + sourceRunId: latestRun.id, + correctiveRunId: latestRun.id, + missingDisposition: "clear_next_step", + handoffAttempt: 3, + maxHandoffAttempts: 3, + exhausted: true, + }, + }); + + const [updatedHub] = await db.select().from(issues).where(eq(issues.id, hubIssueId)); + expect(updatedHub).toMatchObject({ + status: "in_progress", + assigneeAgentId: ctoId, + }); + + const [action] = await db + .select() + .from(issueRecoveryActions) + .where(eq(issueRecoveryActions.sourceIssueId, hubIssueId)); + expect(action).toMatchObject({ + ownerAgentId: ctoId, + previousOwnerAgentId: ceoId, + returnOwnerAgentId: ctoId, + kind: "missing_disposition", + }); + expect(enqueueWakeup).toHaveBeenCalledTimes(1); + expect(enqueueWakeup.mock.calls[0]?.[0]).toBe(ctoId); + }); + it("escalates stranded assigned work into a source action instead of a recovery issue", async () => { const { companyId, managerId, coderId, sourceIssue } = await seedCompany(); const enqueueWakeup = vi.fn(async () => null); diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 77e9d3fbdfd..aefaa7bf29d 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -1839,7 +1839,67 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) ].join("\n"); } + const CHILD_COVERED_HUB_CHILD_STATUSES = ["todo", "in_progress", "in_review", "blocked", "backlog"] as const; + + async function isChildCoveredProgramHub(issue: typeof issues.$inferSelect) { + const [childRow] = await db + .select({ childCount: sql`count(*)::int` }) + .from(issues) + .where( + and( + eq(issues.companyId, issue.companyId), + eq(issues.parentId, issue.id), + isNull(issues.hiddenAt), + inArray(issues.status, [...CHILD_COVERED_HUB_CHILD_STATUSES]), + ), + ); + if ((childRow?.childCount ?? 0) === 0) return false; + + const unresolvedBlockerCount = await db + .select({ blockerIssueId: issueRelations.issueId }) + .from(issueRelations) + .innerJoin(issues, and(eq(issues.companyId, issueRelations.companyId), eq(issues.id, issueRelations.issueId))) + .where( + and( + eq(issueRelations.companyId, issue.companyId), + eq(issueRelations.relatedIssueId, issue.id), + eq(issueRelations.type, "blocks"), + notInArray(issues.status, ["done", "cancelled"]), + ), + ) + .then((rows) => rows.length); + + return unresolvedBlockerCount === 0; + } + + async function resolveChildCoveredHubMonitorOwnerAgentId(issue: typeof issues.$inferSelect) { + if (!issue.assigneeAgentId) return null; + const assignee = await getAgent(issue.assigneeAgentId); + if (!assignee || assignee.companyId !== issue.companyId) return null; + if (assignee.role !== "ceo") return assignee.id; + + if (issue.createdByAgentId) { + const creator = await getAgent(issue.createdByAgentId); + if (creator && creator.companyId === issue.companyId && creator.role !== "ceo") { + return creator.id; + } + } + + const [cto] = await db + .select() + .from(agents) + .where(and(eq(agents.companyId, issue.companyId), eq(agents.role, "cto"))) + .orderBy(asc(agents.createdAt)) + .limit(1); + return cto?.id ?? null; + } + async function resolveStrandedIssueRecoveryOwnerAgentId(issue: typeof issues.$inferSelect) { + const childCoveredHub = await isChildCoveredProgramHub(issue); + if (childCoveredHub) { + return resolveChildCoveredHubMonitorOwnerAgentId(issue); + } + const candidateIds: string[] = []; if (issue.assigneeAgentId) { const assignee = await getAgent(issue.assigneeAgentId); @@ -2085,6 +2145,10 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null; }) { const recoveryCause = input.recoveryCause ?? "stranded_assigned_issue"; + const childCoveredHub = await isChildCoveredProgramHub(input.issue); + const hubMonitorOwnerAgentId = childCoveredHub + ? await resolveChildCoveredHubMonitorOwnerAgentId(input.issue) + : null; const ownerAgentId = await resolveStrandedIssueRecoveryOwnerAgentId(input.issue); const now = new Date(); const action = await recoveryActionsSvc.upsertSourceScoped({ @@ -2094,7 +2158,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) ownerType: ownerAgentId ? "agent" : "board", ownerAgentId, previousOwnerAgentId: input.issue.assigneeAgentId, - returnOwnerAgentId: input.issue.assigneeAgentId, + returnOwnerAgentId: hubMonitorOwnerAgentId ?? input.issue.assigneeAgentId, cause: recoveryCause, fingerprint: strandedRecoveryActionFingerprint({ issue: input.issue, @@ -2296,10 +2360,19 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) successfulRunHandoffEvidence: input.successfulRunHandoffEvidence, }); const blockerIds = await existingUnresolvedBlockerIssueIds(input.issue.companyId, input.issue.id); + const childCoveredHub = await isChildCoveredProgramHub(input.issue); + const preserveHubMonitorDisposition = childCoveredHub && blockerIds.length === 0; + const hubOwnerAgentId = + (childCoveredHub ? await resolveChildCoveredHubMonitorOwnerAgentId(input.issue) : null) ?? + input.issue.assigneeAgentId ?? + recoveryAction.returnOwnerAgentId ?? + recoveryAction.ownerAgentId; const updated = await issuesSvc.update(input.issue.id, { - status: "blocked", + status: preserveHubMonitorDisposition ? "in_progress" : "blocked", blockedByIssueIds: blockerIds, - assigneeAgentId: recoveryAction.ownerAgentId ?? input.issue.assigneeAgentId, + assigneeAgentId: preserveHubMonitorDisposition + ? hubOwnerAgentId + : recoveryAction.ownerAgentId ?? input.issue.assigneeAgentId, }); if (!updated) return null; @@ -2384,12 +2457,13 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) entityId: input.issue.id, details: { identifier: input.issue.identifier, - status: "blocked", + status: preserveHubMonitorDisposition ? "in_progress" : "blocked", previousStatus: input.previousStatus, source: input.recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON ? "recovery.reconcile_successful_run_handoff_missing_state" : "recovery.reconcile_stranded_assigned_issue", recoveryCause: input.recoveryCause ?? "stranded_assigned_issue", + childCoveredHub: preserveHubMonitorDisposition, latestRunId: input.latestRun?.id ?? null, latestRunStatus: input.latestRun?.status ?? null, latestRunErrorCode: input.latestRun?.errorCode ?? null, @@ -2408,7 +2482,11 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) recoveryCause, }); - if (recoveryAction.ownerAgentId && recoveryAction.ownerAgentId === input.issue.assigneeAgentId) { + if ( + !preserveHubMonitorDisposition && + recoveryAction.ownerAgentId && + recoveryAction.ownerAgentId === input.issue.assigneeAgentId + ) { const [currentIssue] = await db .select({ status: issues.status, From 43fd709855f394ec721c6fb20de7104333f983ae Mon Sep 17 00:00:00 2001 From: Cavas AI <279933686+cavas82ai@users.noreply.github.com> Date: Sun, 31 May 2026 06:58:57 +1000 Subject: [PATCH 2/4] Address Greptile review on child-covered hub recovery PR Widen stranded previousStatus to include blocked, remove heartbeat cast, and reuse child-covered hub detection within escalateStrandedAssignedIssue. Co-Authored-By: Paperclip Co-authored-by: Cursor --- server/src/services/heartbeat.ts | 4 ++-- server/src/services/recovery/service.ts | 24 +++++++++++++++--------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 09a1e5fe7cb..5dc53becd68 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -8945,7 +8945,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) if (promotionResult?.kind === "blocked") { await recovery.escalateStrandedAssignedIssue({ issue: promotionResult.issue, - previousStatus: promotionResult.previousStatus as "todo" | "in_progress", + previousStatus: promotionResult.previousStatus, latestRun: run, comment: promotionResult.comment, }); @@ -8955,7 +8955,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) if (promotionResult?.kind === "blocked_recovery_in_place") { await recovery.escalateStrandedRecoveryIssueInPlace({ issue: promotionResult.issue, - previousStatus: promotionResult.previousStatus as "todo" | "in_progress", + previousStatus: promotionResult.previousStatus, latestRun: run, }); return; diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index aefaa7bf29d..0b0a68216c5 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -103,6 +103,7 @@ type LatestIssueRun = Pick< type SuccessfulLatestIssueRun = NonNullable & { status: "succeeded" }; type StrandedRecoveryCause = "stranded_assigned_issue" | typeof SUCCESSFUL_RUN_MISSING_STATE_REASON; +type StrandedAssignedPreviousStatus = "todo" | "in_progress" | "blocked"; type SuccessfulRunHandoffRecoveryEvidence = { sourceRunId: string | null; @@ -1894,9 +1895,12 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) return cto?.id ?? null; } - async function resolveStrandedIssueRecoveryOwnerAgentId(issue: typeof issues.$inferSelect) { - const childCoveredHub = await isChildCoveredProgramHub(issue); - if (childCoveredHub) { + async function resolveStrandedIssueRecoveryOwnerAgentId( + issue: typeof issues.$inferSelect, + childCoveredHub?: boolean, + ) { + const isChildCovered = childCoveredHub ?? await isChildCoveredProgramHub(issue); + if (isChildCovered) { return resolveChildCoveredHubMonitorOwnerAgentId(issue); } @@ -2114,7 +2118,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) function buildStrandedRecoveryActionEvidence(input: { issue: typeof issues.$inferSelect; latestRun: LatestIssueRun; - previousStatus: "todo" | "in_progress"; + previousStatus: StrandedAssignedPreviousStatus; recoveryCause: StrandedRecoveryCause; successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null; }) { @@ -2140,16 +2144,17 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) async function ensureSourceScopedStrandedRecoveryAction(input: { issue: typeof issues.$inferSelect; latestRun: LatestIssueRun; - previousStatus: "todo" | "in_progress"; + previousStatus: StrandedAssignedPreviousStatus; recoveryCause?: StrandedRecoveryCause; successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null; + childCoveredHub?: boolean; }) { const recoveryCause = input.recoveryCause ?? "stranded_assigned_issue"; - const childCoveredHub = await isChildCoveredProgramHub(input.issue); + const childCoveredHub = input.childCoveredHub ?? await isChildCoveredProgramHub(input.issue); const hubMonitorOwnerAgentId = childCoveredHub ? await resolveChildCoveredHubMonitorOwnerAgentId(input.issue) : null; - const ownerAgentId = await resolveStrandedIssueRecoveryOwnerAgentId(input.issue); + const ownerAgentId = await resolveStrandedIssueRecoveryOwnerAgentId(input.issue, childCoveredHub); const now = new Date(); const action = await recoveryActionsSvc.upsertSourceScoped({ companyId: input.issue.companyId, @@ -2337,7 +2342,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) async function escalateStrandedAssignedIssue(input: { issue: typeof issues.$inferSelect; - previousStatus: "todo" | "in_progress"; + previousStatus: StrandedAssignedPreviousStatus; latestRun: LatestIssueRun; comment?: string; recoveryCause?: StrandedRecoveryCause; @@ -2352,15 +2357,16 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) } const recoveryCause = input.recoveryCause ?? "stranded_assigned_issue"; + const childCoveredHub = await isChildCoveredProgramHub(input.issue); const recoveryAction = await ensureSourceScopedStrandedRecoveryAction({ issue: input.issue, previousStatus: input.previousStatus, latestRun: input.latestRun, recoveryCause, successfulRunHandoffEvidence: input.successfulRunHandoffEvidence, + childCoveredHub, }); const blockerIds = await existingUnresolvedBlockerIssueIds(input.issue.companyId, input.issue.id); - const childCoveredHub = await isChildCoveredProgramHub(input.issue); const preserveHubMonitorDisposition = childCoveredHub && blockerIds.length === 0; const hubOwnerAgentId = (childCoveredHub ? await resolveChildCoveredHubMonitorOwnerAgentId(input.issue) : null) ?? From a0a49244d682d06172b948666d59b4666c78ed50 Mon Sep 17 00:00:00 2001 From: Cavas AI <279933686+cavas82ai@users.noreply.github.com> Date: Sun, 31 May 2026 09:00:41 +1000 Subject: [PATCH 3/4] fix(recovery): widen in-place escalation previousStatus type Align escalateStrandedRecoveryIssueInPlace with StrandedAssignedPreviousStatus so blocked heartbeats typecheck after Greptile widen. Co-authored-by: Cursor --- server/src/services/recovery/service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 0b0a68216c5..098084ec69b 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -2234,7 +2234,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) function buildRecoveryIssueInPlaceEscalationComment(input: { issue: typeof issues.$inferSelect; - previousStatus: "todo" | "in_progress"; + previousStatus: StrandedAssignedPreviousStatus; latestRun: LatestIssueRun; prefix: string; }) { @@ -2261,7 +2261,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) async function escalateStrandedRecoveryIssueInPlace(input: { issue: typeof issues.$inferSelect; - previousStatus: "todo" | "in_progress"; + previousStatus: StrandedAssignedPreviousStatus; latestRun: LatestIssueRun; }) { const updated = await issuesSvc.update(input.issue.id, { status: "blocked" }); From e17e89757836cf0db8b1e2ea3f702aa97afbf942 Mon Sep 17 00:00:00 2001 From: Cavas AI <279933686+cavas82ai@users.noreply.github.com> Date: Sun, 31 May 2026 09:03:50 +1000 Subject: [PATCH 4/4] fix(recovery): narrow heartbeat stranded previousStatus for typecheck Export StrandedAssignedPreviousStatus helper and use it when promotion results pass previousStatus into recovery escalation. Co-Authored-By: Paperclip Co-authored-by: Cursor --- server/src/services/heartbeat.ts | 6 +++--- server/src/services/recovery/service.ts | 7 ++++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 5dc53becd68..36867bae1d7 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -149,7 +149,7 @@ import { recoveryAssigneeAdapterOverrides, withRecoveryModelProfileHint, } from "./recovery/model-profile-hint.js"; -import { recoveryService } from "./recovery/service.js"; +import { asStrandedAssignedPreviousStatus, recoveryService } from "./recovery/service.js"; import { productivityReviewService } from "./productivity-review.js"; import { withAgentStartLock } from "./agent-start-lock.js"; import { @@ -8848,7 +8848,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) return { kind: "blocked_recovery_in_place" as const, issue, - previousStatus: issue.status, + previousStatus: asStrandedAssignedPreviousStatus(issue.status), }; } @@ -8864,7 +8864,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) return { kind: "blocked" as const, issue, - previousStatus: issue.status, + previousStatus: asStrandedAssignedPreviousStatus(issue.status), comment, }; } diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 098084ec69b..bb77d478742 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -103,7 +103,12 @@ type LatestIssueRun = Pick< type SuccessfulLatestIssueRun = NonNullable & { status: "succeeded" }; type StrandedRecoveryCause = "stranded_assigned_issue" | typeof SUCCESSFUL_RUN_MISSING_STATE_REASON; -type StrandedAssignedPreviousStatus = "todo" | "in_progress" | "blocked"; +export type StrandedAssignedPreviousStatus = "todo" | "in_progress" | "blocked"; + +export function asStrandedAssignedPreviousStatus(status: string): StrandedAssignedPreviousStatus { + if (status === "todo" || status === "in_progress" || status === "blocked") return status; + return "in_progress"; +} type SuccessfulRunHandoffRecoveryEvidence = { sourceRunId: string | null;