Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit b314d84

Browse files
jelscoclaude
andcommitted
fix(recovery): skip recovery for issues with scheduled future monitors
Both the post-run handoff decision (decideSuccessfulRunHandoff) and the periodic stranded-issue sweep (reconcileStrandedAssignedIssues) were classifying in_progress issues with succeeded runs as "missing disposition" even when the issue had a future monitorNextCheckAt. This caused false-positive recovery loops on deliberately-parked issues (e.g., LinkedIn content calendar posts waiting for a publish-runner routine). The issue-graph-liveness system already had hasScheduledMonitor() that correctly skipped such issues, but these two other recovery paths did not. Changes: - Add hasScheduledMonitor boolean to decideSuccessfulRunHandoff input type and skip when true - Select monitorNextCheckAt in handleSuccessfulRunHandoff issue query and pass it to the decision function - Add monitorNextCheckAt > now check in reconcileStrandedAssignedIssues after the hasActiveExecutionPath guard - Unit test for the new skip in successful-run-handoff.test.ts - Two integration tests in heartbeat-process-recovery.test.ts covering both the handoff and sweep paths Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent 333a16b commit b314d84

5 files changed

Lines changed: 73 additions & 0 deletions

File tree

server/src/__tests__/heartbeat-process-recovery.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2914,6 +2914,62 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
29142914
});
29152915
});
29162916

2917+
it("skips successful-run handoff when issue has a future monitorNextCheckAt", async () => {
2918+
const { companyId, agentId, runId, issueId } = await seedQueuedIssueRunFixture();
2919+
const futureDate = new Date(Date.now() + 60 * 60 * 1000);
2920+
await db.update(issues).set({ monitorNextCheckAt: futureDate }).where(eq(issues.id, issueId));
2921+
2922+
mockAdapterExecute.mockImplementationOnce(async (ctx: { runId: string }) => {
2923+
await db.insert(issueComments).values({
2924+
companyId,
2925+
issueId,
2926+
authorAgentId: agentId,
2927+
createdByRunId: ctx.runId,
2928+
body: "Parked post awaiting publish-runner routine.",
2929+
});
2930+
return {
2931+
exitCode: 0,
2932+
signal: null,
2933+
timedOut: false,
2934+
errorMessage: null,
2935+
summary: "Parked post awaiting publish-runner routine.",
2936+
provider: "test",
2937+
model: "test-model",
2938+
};
2939+
});
2940+
const heartbeat = heartbeatService(db);
2941+
2942+
await heartbeat.resumeQueuedRuns();
2943+
await waitForRunToSettle(heartbeat, runId, 5_000);
2944+
await waitForHeartbeatIdle(db, 5_000);
2945+
2946+
const handoffWakeups = await db
2947+
.select()
2948+
.from(agentWakeupRequests)
2949+
.where(eq(agentWakeupRequests.agentId, agentId))
2950+
.then((rows) => rows.filter((w) => w.reason === "finish_successful_run_handoff"));
2951+
expect(handoffWakeups).toHaveLength(0);
2952+
});
2953+
2954+
it("skips stranded sweep when issue has a future monitorNextCheckAt", async () => {
2955+
const { issueId } = await seedStrandedIssueFixture({
2956+
status: "in_progress",
2957+
runStatus: "succeeded",
2958+
livenessState: "advanced",
2959+
});
2960+
const futureDate = new Date(Date.now() + 60 * 60 * 1000);
2961+
await db.update(issues).set({ monitorNextCheckAt: futureDate }).where(eq(issues.id, issueId));
2962+
2963+
const heartbeat = heartbeatService(db);
2964+
const result = await heartbeat.reconcileStrandedAssignedIssues();
2965+
expect(result.continuationRequeued).toBe(0);
2966+
expect(result.successfulRunHandoffEscalated).toBe(0);
2967+
expect(result.escalated).toBe(0);
2968+
2969+
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
2970+
expect(issue?.status).toBe("in_progress");
2971+
});
2972+
29172973
it("does not reconcile user-assigned work through the agent stranded-work recovery path", async () => {
29182974
const { issueId, runId } = await seedStrandedIssueFixture({
29192975
status: "todo",

server/src/services/heartbeat.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4027,6 +4027,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
40274027
assigneeUserId: issues.assigneeUserId,
40284028
executionState: issues.executionState,
40294029
projectId: issues.projectId,
4030+
monitorNextCheckAt: issues.monitorNextCheckAt,
40304031
})
40314032
.from(issues)
40324033
.where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId)))
@@ -4186,6 +4187,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
41864187
taskKey,
41874188
hasActiveExecutionPath: Boolean(activeExecutionPath),
41884189
hasQueuedWake: Boolean(queuedWake),
4190+
hasScheduledMonitor: Boolean(issue?.monitorNextCheckAt && issue.monitorNextCheckAt.getTime() > Date.now()),
41894191
hasPendingInteractionOrApproval: Boolean(pendingInteraction || pendingApproval),
41904192
hasExplicitBlockerPath: Boolean(explicitBlocker),
41914193
hasOpenRecoveryIssue: Boolean(openRecoveryIssue),

server/src/services/recovery/service.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2015,6 +2015,11 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
20152015
continue;
20162016
}
20172017

2018+
if (issue.monitorNextCheckAt && issue.monitorNextCheckAt.getTime() > Date.now()) {
2019+
result.skipped += 1;
2020+
continue;
2021+
}
2022+
20182023
if (await isAutomaticRecoverySuppressedByPauseHold(db, issue.companyId, issue.id, treeControlSvc)) {
20192024
result.skipped += 1;
20202025
continue;

server/src/services/recovery/successful-run-handoff.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ function decide(overrides: Partial<Parameters<typeof decideSuccessfulRunHandoff>
4848
taskKey: "issue-1",
4949
hasActiveExecutionPath: false,
5050
hasQueuedWake: false,
51+
hasScheduledMonitor: false,
5152
hasPendingInteractionOrApproval: false,
5253
hasExplicitBlockerPath: false,
5354
hasOpenRecoveryIssue: false,
@@ -124,6 +125,13 @@ describe("successful run handoff decision", () => {
124125
});
125126
});
126127

128+
it("skips when issue has a scheduled future monitor", () => {
129+
expect(decide({ hasScheduledMonitor: true })).toEqual({
130+
kind: "skip",
131+
reason: "issue has a scheduled future monitor",
132+
});
133+
});
134+
127135
it("does not queue when a successful run has no progress signal", () => {
128136
expect(decide({ livenessState: null, detectedProgressSummary: null })).toEqual({
129137
kind: "skip",

server/src/services/recovery/successful-run-handoff.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,7 @@ export function decideSuccessfulRunHandoff(input: {
338338
taskKey: string | null;
339339
hasActiveExecutionPath: boolean;
340340
hasQueuedWake: boolean;
341+
hasScheduledMonitor: boolean;
341342
hasPendingInteractionOrApproval: boolean;
342343
hasExplicitBlockerPath: boolean;
343344
hasOpenRecoveryIssue: boolean;
@@ -372,6 +373,7 @@ export function decideSuccessfulRunHandoff(input: {
372373
}
373374
if (input.hasActiveExecutionPath) return { kind: "skip", reason: "issue already has an active execution path" };
374375
if (input.hasQueuedWake) return { kind: "skip", reason: "issue already has a queued or deferred wake" };
376+
if (input.hasScheduledMonitor) return { kind: "skip", reason: "issue has a scheduled future monitor" };
375377
if (input.hasPendingInteractionOrApproval) {
376378
return { kind: "skip", reason: "pending interaction or approval owns the next action" };
377379
}

0 commit comments

Comments
 (0)