Skip to content

Commit af0ee16

Browse files
committed
fix: preserve concurrent supervisor updates
1 parent d2e61b1 commit af0ee16

2 files changed

Lines changed: 111 additions & 6 deletions

File tree

packages/server/src/__tests__/supervisor-manager.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -798,6 +798,77 @@ describe("SupervisorManager cycle triggers", () => {
798798
expect(deps.supervisorRepo.findById(supervisor.id)?.objective).toBe("Initial objective");
799799
});
800800

801+
it("preserves a pause request while a target reset update is still persisting", async () => {
802+
const supervisor = await manager.create({
803+
sessionId: "sess-objective-reset-pause-race",
804+
workspaceId: "ws-1",
805+
objective: "Initial objective",
806+
evaluatorProviderId: "codex",
807+
});
808+
809+
let releaseReset: (() => void) | null = null;
810+
deps.targetStore.resetTargetFiles.mockImplementation(
811+
async () =>
812+
await new Promise<void>((resolve) => {
813+
releaseReset = resolve;
814+
})
815+
);
816+
817+
const updatedPromise = manager.update(supervisor.id, {
818+
objective: "New objective",
819+
});
820+
821+
await waitFor(() => {
822+
expect(deps.targetStore.resetTargetFiles).toHaveBeenCalledTimes(1);
823+
expect(releaseReset).not.toBeNull();
824+
});
825+
826+
const paused = await manager.pause(supervisor.id);
827+
releaseReset?.();
828+
829+
const updated = await updatedPromise;
830+
831+
expect(paused.state).toBe("paused");
832+
expect(updated.state).toBe("paused");
833+
expect(updated.objective).toBe("New objective");
834+
expect(manager.get(supervisor.id)?.state).toBe("paused");
835+
});
836+
837+
it("does not resurrect a supervisor deleted during target reset persistence", async () => {
838+
const supervisor = await manager.create({
839+
sessionId: "sess-objective-reset-delete-race",
840+
workspaceId: "ws-1",
841+
objective: "Initial objective",
842+
evaluatorProviderId: "codex",
843+
});
844+
845+
let releaseReset: (() => void) | null = null;
846+
deps.targetStore.resetTargetFiles.mockImplementation(
847+
async () =>
848+
await new Promise<void>((resolve) => {
849+
releaseReset = resolve;
850+
})
851+
);
852+
853+
const updatedPromise = manager.update(supervisor.id, {
854+
objective: "New objective",
855+
});
856+
857+
await waitFor(() => {
858+
expect(deps.targetStore.resetTargetFiles).toHaveBeenCalledTimes(1);
859+
expect(releaseReset).not.toBeNull();
860+
});
861+
862+
await manager.delete(supervisor.id);
863+
releaseReset?.();
864+
865+
await expect(updatedPromise).rejects.toMatchObject({
866+
code: "supervisor_not_found",
867+
});
868+
expect(manager.get(supervisor.id)).toBeUndefined();
869+
expect(deps.supervisorRepo.findById(supervisor.id)).toBeUndefined();
870+
});
871+
801872
it("preserves a pause request while an objective change abort is in flight", async () => {
802873
const supervisor = await manager.create({
803874
sessionId: "sess-objective-pause-race",

packages/server/src/supervisor/manager.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -449,7 +449,7 @@ export class SupervisorManager {
449449
};
450450

451451
const rollbackPatch = this.toSupervisorUpdatePatch(current, Date.now());
452-
let updated = this.attachCycles(this.deps.supervisorRepo.update(id, nextPatch));
452+
this.deps.supervisorRepo.update(id, nextPatch);
453453

454454
if (objectiveChanged) {
455455
try {
@@ -473,7 +473,11 @@ export class SupervisorManager {
473473
}
474474
}
475475

476-
const enriched = await this.attachTargetState(updated, workspace.path);
476+
const enriched = await this.hydratePersistedSupervisor(id, workspace.path);
477+
if (!enriched) {
478+
await this.markTargetCancelledIfActive(workspace.path, current).catch(() => {});
479+
throw this.supervisorNotFoundError(id);
480+
}
477481

478482
this.storeSnapshot(enriched);
479483
this.broadcastState(enriched, "updated");
@@ -1320,6 +1324,32 @@ export class SupervisorManager {
13201324
return await this.attachTargetState(supervisor, workspace.path);
13211325
}
13221326

1327+
private async hydratePersistedSupervisor(
1328+
supervisorId: string,
1329+
workspacePath: string
1330+
): Promise<Supervisor | null> {
1331+
const persisted = this.deps.supervisorRepo.findById(supervisorId);
1332+
if (!persisted) {
1333+
return null;
1334+
}
1335+
1336+
const hydrated = await this.attachTargetState(this.attachCycles(persisted), workspacePath);
1337+
const latest = this.deps.supervisorRepo.findById(supervisorId);
1338+
if (!latest) {
1339+
return null;
1340+
}
1341+
1342+
if (latest.targetId !== hydrated.targetId) {
1343+
return await this.attachTargetState(this.attachCycles(latest), workspacePath);
1344+
}
1345+
1346+
return {
1347+
...this.attachCycles(latest),
1348+
currentTargetMemory: hydrated.currentTargetMemory,
1349+
recentTargetCycles: hydrated.recentTargetCycles,
1350+
};
1351+
}
1352+
13231353
private withCurrentTargetState(supervisor: Supervisor): Supervisor {
13241354
const current = this.supervisors.get(supervisor.id);
13251355
if (!current || current.targetId !== supervisor.targetId) {
@@ -1525,13 +1555,17 @@ export class SupervisorManager {
15251555
this.inFlightCompletions.delete(supervisorId);
15261556
}
15271557

1558+
private supervisorNotFoundError(id: string): { code: "supervisor_not_found"; message: string } {
1559+
return {
1560+
code: "supervisor_not_found",
1561+
message: `Supervisor ${id} not found`,
1562+
};
1563+
}
1564+
15281565
private requireSupervisor(id: string): Supervisor {
15291566
const supervisor = this.supervisors.get(id);
15301567
if (!supervisor) {
1531-
throw {
1532-
code: "supervisor_not_found",
1533-
message: `Supervisor ${id} not found`,
1534-
};
1568+
throw this.supervisorNotFoundError(id);
15351569
}
15361570
return supervisor;
15371571
}

0 commit comments

Comments
 (0)