Skip to content

Commit 63ad12a

Browse files
committed
fix: harden supervisor objective resets
1 parent ac6ff4c commit 63ad12a

4 files changed

Lines changed: 342 additions & 33 deletions

File tree

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

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,57 @@ describe("SupervisorManager cycle triggers", () => {
578578
expect(deps.sessionMgr.sendInput).not.toHaveBeenCalled();
579579
});
580580

581+
it("marks the aborted attempt as cancelled when the objective changes", async () => {
582+
const supervisor = await manager.create({
583+
sessionId: "sess-objective-attempt-cancelled",
584+
workspaceId: "ws-1",
585+
objective: "Initial objective",
586+
evaluatorProviderId: "codex",
587+
});
588+
589+
let observedSignal: AbortSignal | undefined;
590+
vi.spyOn(getManagerInternals().evaluator, "evaluate").mockImplementationOnce(
591+
async (_supervisor, _context, options) =>
592+
await new Promise<SupervisorEvaluationResult>((_resolve, reject) => {
593+
observedSignal = options?.signal;
594+
options?.signal?.addEventListener(
595+
"abort",
596+
() => {
597+
reject({
598+
code: "supervisor_eval_aborted",
599+
message: "Supervisor evaluator aborted",
600+
});
601+
},
602+
{ once: true }
603+
);
604+
})
605+
);
606+
607+
const cycle = await manager.triggerEvaluation(supervisor.id);
608+
609+
await waitFor(() => {
610+
expect(observedSignal).toBeDefined();
611+
expect(manager.get(supervisor.id)?.state).toBe("evaluating");
612+
});
613+
614+
const updatedPromise = manager.update(supervisor.id, {
615+
objective: "New objective",
616+
});
617+
618+
await waitFor(() => {
619+
expect(observedSignal?.aborted).toBe(true);
620+
});
621+
622+
await updatedPromise;
623+
624+
expect(deps.cycleAttemptRepo.listForCycle(cycle.id)).toMatchObject([
625+
{
626+
status: "cancelled",
627+
errorReason: undefined,
628+
},
629+
]);
630+
});
631+
581632
it("marks supervisor_uncertain stops as cancelled instead of completed target meta", async () => {
582633
const supervisor = await manager.create({
583634
sessionId: "sess-uncertain-stop",
@@ -711,6 +762,42 @@ describe("SupervisorManager cycle triggers", () => {
711762
);
712763
});
713764

765+
it("rolls back persisted supervisor changes when resetting target files fails", async () => {
766+
const supervisor = await manager.create({
767+
sessionId: "sess-objective-rollback",
768+
workspaceId: "ws-1",
769+
objective: "Initial objective",
770+
evaluatorProviderId: "codex",
771+
});
772+
773+
deps.targetStore.resetTargetFiles.mockImplementation(async () => {
774+
throw new Error("disk full");
775+
});
776+
777+
await expect(
778+
manager.update(supervisor.id, {
779+
objective: "New objective",
780+
})
781+
).rejects.toThrow("disk full");
782+
783+
const updatesForSupervisor = deps.supervisorRepo.update.mock.calls.filter(
784+
([id]: [string, SupervisorUpdatePatch]) => id === supervisor.id
785+
);
786+
787+
expect(updatesForSupervisor).toHaveLength(2);
788+
expect(updatesForSupervisor[0]?.[1]).toEqual(
789+
expect.objectContaining({
790+
objective: "New objective",
791+
})
792+
);
793+
expect(updatesForSupervisor[1]?.[1]).toEqual(
794+
expect.objectContaining({
795+
objective: "Initial objective",
796+
})
797+
);
798+
expect(deps.supervisorRepo.findById(supervisor.id)?.objective).toBe("Initial objective");
799+
});
800+
714801
it("retries evaluator timeout up to the global retry budget", async () => {
715802
vi.useFakeTimers();
716803
deps.settingsRepo.get = vi.fn((key: string) => {

packages/server/src/supervisor/manager.ts

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -448,17 +448,31 @@ export class SupervisorManager {
448448
updatedAt: Date.now(),
449449
};
450450

451+
const rollbackPatch = this.toSupervisorUpdatePatch(current, Date.now());
452+
let updated = this.attachCycles(this.deps.supervisorRepo.update(id, nextPatch));
453+
451454
if (objectiveChanged) {
452-
await this.deps.targetStore.resetTargetFiles(workspace.path, {
453-
targetId: current.targetId,
454-
sessionId: current.sessionId,
455-
workspaceId: current.workspaceId,
456-
objective: nextObjective,
457-
createdAt: nextPatch.updatedAt ?? Date.now(),
458-
});
455+
try {
456+
await this.deps.targetStore.resetTargetFiles(workspace.path, {
457+
targetId: current.targetId,
458+
sessionId: current.sessionId,
459+
workspaceId: current.workspaceId,
460+
objective: nextObjective,
461+
createdAt: nextPatch.updatedAt ?? Date.now(),
462+
});
463+
} catch (error) {
464+
try {
465+
this.deps.supervisorRepo.update(id, rollbackPatch);
466+
} catch (rollbackError) {
467+
this.logger.error(
468+
{ err: rollbackError, supervisorId: id },
469+
"Failed to roll back supervisor after target reset failure"
470+
);
471+
}
472+
throw error;
473+
}
459474
}
460475

461-
const updated = this.attachCycles(this.deps.supervisorRepo.update(id, nextPatch));
462476
const enriched = await this.attachTargetState(updated, workspace.path);
463477

464478
this.storeSnapshot(enriched);
@@ -782,8 +796,7 @@ export class SupervisorManager {
782796
return finalized.cycle;
783797
} catch (error: unknown) {
784798
if (isSupervisorEvalAborted(error)) {
785-
const cancelled =
786-
this.pendingPauses.has(supervisorId) || this.pendingObjectiveUpdates.has(supervisorId);
799+
const cancelled = this.isCancellationRequested(supervisorId);
787800
const abortedCycle = this.deps.cycleRepo.update(activeCycle.id, {
788801
status: cancelled ? "cancelled" : "failed",
789802
errorReason: cancelled ? null : messageOf(error, "Supervisor evaluator aborted"),
@@ -1043,12 +1056,11 @@ export class SupervisorManager {
10431056
};
10441057
} catch (error) {
10451058
if (isSupervisorEvalAborted(error)) {
1059+
const cancelled = this.isCancellationRequested(supervisor.id);
10461060
this.deps.cycleAttemptRepo.update(attempt.id, {
1047-
status: this.pendingPauses.has(supervisor.id) ? "cancelled" : "failed",
1061+
status: cancelled ? "cancelled" : "failed",
10481062
completedAt: Date.now(),
1049-
errorReason: this.pendingPauses.has(supervisor.id)
1050-
? null
1051-
: messageOf(error, "Supervisor evaluator aborted"),
1063+
errorReason: cancelled ? null : messageOf(error, "Supervisor evaluator aborted"),
10521064
});
10531065
throw error;
10541066
}
@@ -1318,6 +1330,30 @@ export class SupervisorManager {
13181330
};
13191331
}
13201332

1333+
private isCancellationRequested(supervisorId: string): boolean {
1334+
return this.pendingPauses.has(supervisorId) || this.pendingObjectiveUpdates.has(supervisorId);
1335+
}
1336+
1337+
private toSupervisorUpdatePatch(
1338+
supervisor: Supervisor,
1339+
updatedAt: number
1340+
): Parameters<SupervisorRepo["update"]>[1] {
1341+
return {
1342+
state: supervisor.state,
1343+
objective: supervisor.objective,
1344+
evaluatorProviderId: supervisor.evaluatorProviderId,
1345+
evaluatorModel: supervisor.evaluatorModel ?? null,
1346+
maxSupervisionCount: supervisor.maxSupervisionCount,
1347+
completedSupervisionCount: supervisor.completedSupervisionCount,
1348+
scheduledAt: supervisor.scheduledAt ?? null,
1349+
stopReason: supervisor.stopReason ?? null,
1350+
lastCycleAt: supervisor.lastCycleAt ?? null,
1351+
lastEvaluatedTurnId: supervisor.lastEvaluatedTurnId ?? null,
1352+
errorReason: supervisor.errorReason ?? null,
1353+
updatedAt,
1354+
};
1355+
}
1356+
13211357
private applyEvaluationToTargetMemory(
13221358
memory: SupervisorTargetMemory,
13231359
evaluation: Awaited<ReturnType<SupervisorEvaluator["evaluate"]>>,
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { mkdtempSync, rmSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5+
6+
const renameState = vi.hoisted(() => ({
7+
callCount: 0,
8+
failOnCall: 0,
9+
}));
10+
11+
vi.mock("node:fs/promises", async () => {
12+
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
13+
return {
14+
...actual,
15+
rename: vi.fn(async (from: string, to: string) => {
16+
renameState.callCount += 1;
17+
if (renameState.failOnCall !== 0 && renameState.callCount === renameState.failOnCall) {
18+
throw new Error("promote failed");
19+
}
20+
return actual.rename(from, to);
21+
}),
22+
};
23+
});
24+
25+
import {
26+
appendTargetCycleRecord,
27+
createTargetFiles,
28+
loadTargetMemory,
29+
readTargetCycleRecords,
30+
readTargetMeta,
31+
resetTargetFiles,
32+
saveTargetMemory,
33+
} from "./target-store.js";
34+
35+
describe("target store atomic reset", () => {
36+
let workspacePath: string;
37+
38+
beforeEach(() => {
39+
workspacePath = mkdtempSync(join(tmpdir(), "supervisor-target-store-atomic-"));
40+
renameState.callCount = 0;
41+
renameState.failOnCall = 0;
42+
});
43+
44+
afterEach(() => {
45+
rmSync(workspacePath, { recursive: true, force: true });
46+
});
47+
48+
it("restores the existing target files if promotion fails during reset", async () => {
49+
await createTargetFiles(workspacePath, {
50+
targetId: "tgt-1",
51+
sessionId: "sess-1",
52+
workspaceId: "ws-1",
53+
objective: "Old objective",
54+
createdAt: 1,
55+
});
56+
57+
await saveTargetMemory(workspacePath, "tgt-1", {
58+
targetId: "tgt-1",
59+
planGenerated: true,
60+
plan: [{ id: "step-1", title: "Old step", status: "in_progress" }],
61+
activeStepId: "step-1",
62+
progressSummary: "In progress",
63+
lastGuidance: "Do old thing",
64+
stalledCount: 1,
65+
updatedAt: 2,
66+
});
67+
68+
await appendTargetCycleRecord(workspacePath, "tgt-1", {
69+
cycleId: "cycle-1",
70+
targetId: "tgt-1",
71+
startedAt: 1,
72+
completedAt: 2,
73+
result: "continue",
74+
reason: "Stale progress",
75+
guidance: "Do the old thing",
76+
injected: true,
77+
attemptCount: 1,
78+
});
79+
80+
renameState.failOnCall = 2;
81+
82+
await expect(
83+
resetTargetFiles(workspacePath, {
84+
targetId: "tgt-1",
85+
sessionId: "sess-1",
86+
workspaceId: "ws-1",
87+
objective: "New objective",
88+
createdAt: 3,
89+
})
90+
).rejects.toThrow("promote failed");
91+
92+
const meta = await readTargetMeta(workspacePath, "tgt-1");
93+
const memory = await loadTargetMemory(workspacePath, "tgt-1");
94+
const cycles = await readTargetCycleRecords(workspacePath, "tgt-1");
95+
96+
expect(meta.objective).toBe("Old objective");
97+
expect(memory).toMatchObject({
98+
targetId: "tgt-1",
99+
planGenerated: true,
100+
activeStepId: "step-1",
101+
progressSummary: "In progress",
102+
lastGuidance: "Do old thing",
103+
stalledCount: 1,
104+
updatedAt: 2,
105+
});
106+
expect(cycles).toMatchObject([
107+
{
108+
cycleId: "cycle-1",
109+
guidance: "Do the old thing",
110+
},
111+
]);
112+
});
113+
});

0 commit comments

Comments
 (0)