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
21 changes: 20 additions & 1 deletion src/node/services/taskService.reservedLaunch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Expand All @@ -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;
Expand Down Expand Up @@ -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", () => {
Expand Down
76 changes: 55 additions & 21 deletions src/node/services/taskService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7338,13 +7338,13 @@
* microtask. startReservedAgentTask awaits the whole chain before it releases the lease.
*/
private async startReservedAgentTaskLeased(plan: TaskLaunchPlan): Promise<void> {
assert(plan.parentWorkspaceId.length > 0, "startReservedAgentTask requires parentWorkspaceId");

Check warning on line 7341 in src/node/services/taskService.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: > -> >=

No unit test failed when startReservedAgentTaskLeased had > -> >=. Add a test that tells them apart, or say why they are equivalent.

Check warning on line 7341 in src/node/services/taskService.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: 0 -> 1

No unit test failed when startReservedAgentTaskLeased had 0 -> 1. Add a test that tells them apart, or say why they are equivalent.
if (plan.start.kind === "sendMessage") {
assert(plan.start.prompt.length > 0, "startReservedAgentTask requires prompt");

Check warning on line 7343 in src/node/services/taskService.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: > -> >=

No unit test failed when startReservedAgentTaskLeased had > -> >=. Add a test that tells them apart, or say why they are equivalent.
}

const entryAtStart = this.readLaunchRow(plan.taskId);
if (entryAtStart?.workspace.taskStatus !== "starting") {

Check warning on line 7347 in src/node/services/taskService.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: ?. -> .

No unit test failed when startReservedAgentTaskLeased had ?. -> .. Add a test that tells them apart, or say why they are equivalent.
return;
}
// Stop-cascade barrier: no launch while its own or its parent's stop latch is held. The
Expand All @@ -7353,27 +7353,7 @@
// 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;
}

Expand All @@ -7390,19 +7370,56 @@
});
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<void> {
log.debug("startReservedAgentTask deferred: a stop is in progress", { taskId: plan.taskId });
try {
await this.editActiveWorkspaceEntry(
plan.taskId,
(workspace) => {
if (workspace.taskStatus !== "starting") return;

Check warning on line 7387 in src/node/services/taskService.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: !== -> ===

No unit test failed when deferLaunchForStopLatch had !== -> ===. Add a test that tells them apart, or say why they are equivalent.
if (rowSupersedes(workspace, plan.attemptId)) return;
workspace.taskStatus = "queued";
},
{ allowMissing: true }

Check warning on line 7391 in src/node/services/taskService.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: true -> false

No unit test failed when deferLaunchForStopLatch had true -> false. Add a test that tells them apart, or say why they are equivalent.
);
} 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)) {

Check warning on line 7400 in src/node/services/taskService.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: delete !

No unit test failed when deferLaunchForStopLatch had delete !. Add a test that tells them apart, or say why they are equivalent.
this.scheduleMaybeStartQueuedTasks();
}
}

/** Gives the launch its attempt id (stamped on a row without one), then adopts it. */
private async stampLaunchAttempt(
plan: TaskLaunchPlan,
entryAtStart: NonNullable<ReturnType<typeof findWorkspaceEntry>>
): Promise<void> {
// 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
// first, marked unproven — an older build's stale-starting revert may have relaunched an
// admitted execution without any marker.
let launchAttemptId = entryAtStart.workspace.taskAttemptId;
let launchReceiptEligible = entryAtStart.workspace.taskAttemptUnproven !== true;

Check warning on line 7416 in src/node/services/taskService.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: true -> false

No unit test failed when stampLaunchAttempt had true -> false. Add a test that tells them apart, or say why they are equivalent.

Check warning on line 7416 in src/node/services/taskService.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: !== -> ===

No unit test failed when stampLaunchAttempt had !== -> ===. Add a test that tells them apart, or say why they are equivalent.
if (!isTaskAttemptId(launchAttemptId)) {
launchReceiptEligible = false;
const stamped = newTaskAttemptId();
try {
await this.editActiveWorkspaceEntry(plan.taskId, (workspace) => {
if (workspace.taskStatus !== "starting" || isTaskAttemptId(workspace.taskAttemptId)) {

Check warning on line 7422 in src/node/services/taskService.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: || -> &&

No unit test failed when stampLaunchAttempt had || -> &&. Add a test that tells them apart, or say why they are equivalent.
return;
}
workspace.taskAttemptId = stamped;
Expand All @@ -7417,13 +7434,30 @@
.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", {
attemptId: launchAttemptId,
receiptEligible: launchReceiptEligible,
});
}
}

/** The desktop recovery admission between two cancel checks, then the materialize step. */
private async admitLaunchDesktopRecovery(
plan: TaskLaunchPlan,
entryAtStart: NonNullable<ReturnType<typeof findWorkspaceEntry>>
): Promise<void> {
if (plan.abortSignal?.aborted) {
await this.cancelReservedLaunch(plan);
return;
Expand Down
Loading