diff --git a/src/node/services/taskService.reservedLaunch.test.ts b/src/node/services/taskService.reservedLaunch.test.ts index 492f95fd1d8..5171fc3562e 100644 --- a/src/node/services/taskService.reservedLaunch.test.ts +++ b/src/node/services/taskService.reservedLaunch.test.ts @@ -255,7 +255,7 @@ describe("TaskService reserved launch: exits, cancellations and refusals", () => await settle(); }, /** The lease wrapper on a plan without an attempt id, as for a pre-identity row. */ - launchPreIdentity: async () => { + launchPreIdentity: async (abortSignal?: AbortSignal) => { const parentMeta = (await config.getAllWorkspaceMetadata()).find( (metadata) => metadata.id === PARENT_ID ); @@ -276,6 +276,7 @@ describe("TaskService reserved launch: exits, cancellations and refusals", () => taskModelString: "openai:gpt-5.2", canonicalModel: "openai:gpt-5.2", skipInitHook: true, + ...(abortSignal != null ? { abortSignal } : {}), }); directLaunches.push(launched); await launched; @@ -531,6 +532,24 @@ describe("TaskService reserved launch: exits, cancellations and refusals", () => expect(s.sends()).toBe(0); expect(s.markedFailed()).toEqual([]); }); + + test("keeps that checkout when the launch is cancelled after the materialize step", async () => { + // createMany never shares a checkout on a local parent, so the lease wrapper launches the + // shared row directly with a signal that aborts once the checkout is materialized. + const s = await setup([child({ taskStatus: "starting", taskIsolation: "none" })]); + const cancel = new AbortController(); + s.hooks.materialized = (launch) => { + cancel.abort(); + return Promise.resolve(launch); + }; + + await s.launchPreIdentity(cancel.signal); + + expect(s.cleanups()).toEqual([[CHILD, { preservePhysicalWorkspace: true }]]); + expect(await pathExists(projectPath)).toBe(true); + expect(s.registeredInits()).toBe(0); + expect(s.sends()).toBe(0); + }); }); describe("a row another writer re-admits under its own attempt", () => { diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index ebdf148ed78..322ee6de4c8 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -7353,27 +7353,7 @@ export class TaskService implements AgentTaskIntegration { // rechecks the queue. Only a still-reserved record is reverted — a status written meanwhile // (an explicit Stop's "interrupted") stays as is and is never relaunched. if (this.taskStopLatch.deferLaunchWhileStopInProgress(plan.taskId, plan.parentWorkspaceId)) { - log.debug("startReservedAgentTask deferred: a stop is in progress", { taskId: plan.taskId }); - try { - await this.editActiveWorkspaceEntry( - plan.taskId, - (workspace) => { - if (workspace.taskStatus !== "starting") return; - if (rowSupersedes(workspace, plan.attemptId)) return; - workspace.taskStatus = "queued"; - }, - { allowMissing: true } - ); - } catch (error) { - await this.markTaskLaunchFailed(plan.taskId, getErrorMessage(error), plan); - return; - } - await this.emitWorkspaceMetadata(plan.taskId); - // The release-time drain may have seen "starting" while this rollback was awaiting disk. - // Complete the handoff after publishing "queued", or leave the next release owning it. - if (!this.taskStopLatch.deferLaunchWhileStopInProgress(plan.taskId, plan.parentWorkspaceId)) { - this.scheduleMaybeStartQueuedTasks(); - } + await this.deferLaunchForStopLatch(plan); return; } @@ -7390,6 +7370,43 @@ export class TaskService implements AgentTaskIntegration { }); return; } + await this.stampLaunchAttempt(plan, entryAtStart); + } + + /** + * Defers a launch while a stop latch is held (the barrier in startReservedAgentTaskLeased): a + * still-reserved row goes back to `queued`, and the drain runs here if the latch was released + * during that write. + */ + private async deferLaunchForStopLatch(plan: TaskLaunchPlan): Promise { + log.debug("startReservedAgentTask deferred: a stop is in progress", { taskId: plan.taskId }); + try { + await this.editActiveWorkspaceEntry( + plan.taskId, + (workspace) => { + if (workspace.taskStatus !== "starting") return; + if (rowSupersedes(workspace, plan.attemptId)) return; + workspace.taskStatus = "queued"; + }, + { allowMissing: true } + ); + } catch (error) { + await this.markTaskLaunchFailed(plan.taskId, getErrorMessage(error), plan); + return; + } + await this.emitWorkspaceMetadata(plan.taskId); + // The release-time drain may have seen "starting" while this rollback was awaiting disk. + // Complete the handoff after publishing "queued", or leave the next release owning it. + if (!this.taskStopLatch.deferLaunchWhileStopInProgress(plan.taskId, plan.parentWorkspaceId)) { + this.scheduleMaybeStartQueuedTasks(); + } + } + + /** Gives the launch its attempt id (stamped on a row without one), then adopts it. */ + private async stampLaunchAttempt( + plan: TaskLaunchPlan, + entryAtStart: NonNullable> + ): Promise { // An owned reservation (or the queue drain's launch CAS) keeps its identity through the // launch. A `starting` record nobody in this process owns under its current id (pre-identity // entry reached without the drain CAS) becomes owned here; without an id it is stamped @@ -7417,6 +7434,16 @@ export class TaskService implements AgentTaskIntegration { .taskAttemptId ?? stamped; this.attemptLedger.publishAttemptRotation(plan.taskId, launchAttemptId); } + this.adoptLaunchAttempt(plan, launchAttemptId, launchReceiptEligible); + await this.admitLaunchDesktopRecovery(plan, entryAtStart); + } + + /** Binds the plan to the launch's attempt and owns that attempt in this process. */ + private adoptLaunchAttempt( + plan: TaskLaunchPlan, + launchAttemptId: string, + launchReceiptEligible: boolean + ): void { plan.attemptId = launchAttemptId; if (this.attemptLedger.ownedAttempt(plan.taskId)?.attemptId !== launchAttemptId) { this.beginOwnedTaskAttempt(plan.taskId, "launch", { @@ -7424,6 +7451,13 @@ export class TaskService implements AgentTaskIntegration { receiptEligible: launchReceiptEligible, }); } + } + + /** The desktop recovery admission between two cancel checks, then the materialize step. */ + private async admitLaunchDesktopRecovery( + plan: TaskLaunchPlan, + entryAtStart: NonNullable> + ): Promise { if (plan.abortSignal?.aborted) { await this.cancelReservedLaunch(plan); return;