Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 228 additions & 0 deletions server/src/__tests__/issue-recovery-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 5 additions & 5 deletions server/src/services/heartbeat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { issueRecoveryActionService } from "./issue-recovery-actions.js";
import { productivityReviewService } from "./productivity-review.js";
import { withAgentStartLock } from "./agent-start-lock.js";
Expand Down Expand Up @@ -9077,7 +9077,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
return {
kind: "blocked_recovery_in_place" as const,
issue,
previousStatus: issue.status,
previousStatus: asStrandedAssignedPreviousStatus(issue.status),
};
}

Expand All @@ -9093,7 +9093,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
return {
kind: "blocked" as const,
issue,
previousStatus: issue.status,
previousStatus: asStrandedAssignedPreviousStatus(issue.status),
comment,
};
}
Expand Down Expand Up @@ -9174,7 +9174,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,
});
Expand All @@ -9184,7 +9184,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;
Expand Down
Loading
Loading