From 8cbb10dafe6fc0722d6e32ab2374196eb4521f4f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 30 Oct 2026 09:35:55 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20split=20startRes?= =?UTF-8?q?ervedAgentTaskLeased,=20part=205:=20stop=20latch,=20attempt=20s?= =?UTF-8?q?tamp=20and=20desktop=20admission?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last split part: the head of the reserved launch. The method keeps the asserts, the row read and its not-starting exit, the stop-latch barrier, the superseded exit, and one call to the next step. No behaviour change. - deferLaunchForStopLatch: the body of the barrier (the revert to queued behind its rowSupersedes check, the failure mark, the emit, the second latch check after it and the drain). The latch check stays in the method, so a launch that is not deferred waits no extra microtask. - stampLaunchAttempt: a row without a valid attempt id is stamped and marked unproven (its write failure marks the launch failed), read back and published, then the adopt step and the desktop step. - adoptLaunchAttempt (sync): plan.attemptId and the owned attempt. It runs before the cancel and desktop checks, as before. - admitLaunchDesktopRecovery: the cancel check, admitTaskDesktopRecovery before the materialize step, the second cancel check, then the materialize step. startReservedAgentTaskLeased: CC 23 -> 6 (60 at the base of the stack). New steps: CC 5, 7, 3, 6. Every step of the split is at CC 8 or less. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- src/node/services/taskService.ts | 76 +++++++++++++++++++++++--------- 1 file changed, 55 insertions(+), 21 deletions(-) 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; From ee040d7ad9f0d58784fb95d2eb2a1d9b8210fa54 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 30 Oct 2026 10:58:09 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A4=96=20tests:=20a=20cancelled=20sha?= =?UTF-8?q?red=20launch=20keeps=20the=20parent's=20checkout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the stack-10 advisor: no test pinned that cancelMaterializedLaunch preserves a shared checkout. Scratch edit preservePhysicalWorkspace: false fails only this test. --- .../taskService.reservedLaunch.test.ts | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) 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", () => {