Honor operator cancellation before immediate task recovery

Reuse the periodic recovery policy so an operator-cancelled run does not immediately dispatch a replacement and resume its sandbox. Keep explicit deferred wake promotion intact.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-10 02:50:57 -05:00
parent 1caad1bf9e
commit 5acb4274cb
4 changed files with 29 additions and 1 deletions

View File

@ -431,6 +431,9 @@ before acknowledgement, preventing cancelled startup work from dispatching.
The supervisor preserves externally delivered child termination signals. Failed stop
requests remain visible and can be retried explicitly. Local and native runner
cancellation retain their existing authorities.
Immediate recovery honors the same operator-cancellation attribution as periodic
recovery, so cancelling a run does not synthesize a continuation that restarts its
sandbox. Explicitly queued work can still run through normal promotion.
Automated tests do not qualify a deployed runner image. Before merging, use a
new pinned staging stack with the branch's Cloud image and matching migrator.

View File

@ -6009,6 +6009,25 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(runningProcesses.has(runId)).toBe(false);
});
it.each(["user", "board", "interrupt"])("does not immediately recover an operator-cancelled run (%s)", async (actor) => {
const { companyId, agentId, runId, issueId, wakeupRequestId } = await seedRunFixture({
agentStatus: "running", includeIssue: true,
});
const heartbeat = heartbeatService(db);
try {
const cancelled = await heartbeat.cancelRun(runId, "Operator stopped this run", actor === "interrupt"
? { errorCode: "operator_interrupted" }
: { resultJson: { cancelledByActorType: actor } });
expect(cancelled?.status).toBe("cancelled");
const wakeups = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, companyId));
expect(wakeups.map((wake) => wake.id)).toEqual([wakeupRequestId]);
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId));
expect(runs.map((run) => run.id)).toEqual([runId]);
const [issue] = await db.select().from(issues).where(eq(issues.id, issueId));
expect(issue).toMatchObject({ status: "in_progress", executionRunId: null, checkoutRunId: null });
} finally { await heartbeat.drainActiveRunExecutions(); }
});
it("records manual cancellation stop metadata", async () => {
const { runId } = await seedRunFixture({
agentStatus: "running",

View File

@ -371,6 +371,7 @@ import {
import { withRecoveryContext } from "./recovery/status-only-context.js";
import {
ACTIVE_RUN_OUTPUT_SUSPICION_THRESHOLD_MS as RECOVERY_ACTIVE_RUN_OUTPUT_SUSPICION_THRESHOLD_MS,
isOperatorCancelledRun,
recoveryService,
} from "./recovery/service.js";
import { collectDispositionRepairSourceState } from "./recovery/disposition-repair.js";
@ -23689,6 +23690,11 @@ export function heartbeatService(
};
}
// Match the periodic recovery policy: an operator's stop must not create
// a fresh continuation that immediately resumes the cancelled sandbox.
// Explicit deferred wakes above remain eligible for normal promotion.
if (isOperatorCancelledRun(run)) return { kind: "released" as const };
const findExistingExecutionPath = (agentId?: string | null) =>
tx
.select({ id: heartbeatRuns.id })

View File

@ -608,7 +608,7 @@ function isStrandedIssueRecoveryIssue(issue: Pick<typeof issues.$inferSelect, "o
* stopped the agent, and re-waking it or escalating "stranding" would
* fight the human. Any newer run or wake supersedes the exemption.
*/
function isOperatorCancelledRun(latestRun: LatestIssueRun): boolean {
export function isOperatorCancelledRun(latestRun: LatestIssueRun): boolean {
if (!latestRun || latestRun.status !== "cancelled") return false;
if (latestRun.errorCode === "operator_interrupted") return true;
const result = parseObject(latestRun.resultJson);