Skip to content

Commit 835b9ea

Browse files
FL4TLiN3claude
andcommitted
fix(runtime): add stoppedByCancellation status for proper cancellation handling
When shouldContinueRun returns false, the coordinator now sets the checkpoint status to stoppedByCancellation. This allows the run loop to properly handle the cancellation case and update the job status accordingly. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 71785f5 commit 835b9ea

File tree

8 files changed

+83
-3
lines changed

8 files changed

+83
-3
lines changed

.changeset/calm-birds-teach.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@perstack/core": patch
3+
"@perstack/runtime": patch
4+
---
5+
6+
Add stoppedByCancellation status for proper job cancellation handling
7+
8+
When `shouldContinueRun` returns `false`, the coordinator now sets the checkpoint status to `stoppedByCancellation` instead of leaving it as `init` or `proceeding`. This allows the run loop to properly handle the cancellation case and update the job status accordingly.

apps/runtime/src/run.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,15 @@ describe("@perstack/runtime: run", () => {
7373
expect(result.status).toBe("stoppedByError")
7474
})
7575

76+
it("returns checkpoint on stoppedByCancellation status", async () => {
77+
const stoppedCheckpoint = createCheckpoint({ status: "stoppedByCancellation" })
78+
setupMockExecutor([{ checkpoint: stoppedCheckpoint }])
79+
80+
const result = await run({ setting, checkpoint })
81+
82+
expect(result.status).toBe("stoppedByCancellation")
83+
})
84+
7685
it("throws error on unknown status", async () => {
7786
// Manually create checkpoint with invalid status to bypass schema validation
7887
const unknownCheckpoint = {
@@ -195,6 +204,19 @@ describe("@perstack/runtime: run", () => {
195204
const lastCall = storeJob.mock.calls[storeJob.mock.calls.length - 1][0] as Job
196205
expect(lastCall.status).toBe("stoppedByError")
197206
})
207+
208+
it("updates job status to stoppedByCancellation on cancellation", async () => {
209+
const stoppedCheckpoint = createCheckpoint({ status: "stoppedByCancellation" })
210+
setupMockExecutor([{ checkpoint: stoppedCheckpoint }])
211+
212+
const storeJob = vi.fn()
213+
214+
await run({ setting, checkpoint }, { storeJob })
215+
216+
const lastCall = storeJob.mock.calls[storeJob.mock.calls.length - 1][0] as Job
217+
expect(lastCall.status).toBe("stoppedByCancellation")
218+
expect(lastCall.finishedAt).toBeDefined()
219+
})
198220
})
199221

200222
describe("returnOnDelegationComplete option", () => {

apps/runtime/src/run.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,11 @@ export async function run(runInput: RunParamsInput, options?: RunOptions): Promi
163163
return resultCheckpoint
164164
}
165165

166+
case "stoppedByCancellation": {
167+
storeJob({ ...job, status: "stoppedByCancellation", finishedAt: Date.now() })
168+
return resultCheckpoint
169+
}
170+
166171
default:
167172
throw new Error("Run stopped by unknown reason")
168173
}

apps/runtime/src/state-machine/coordinator.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,7 @@ describe("@perstack/runtime: StateMachineCoordinator", () => {
380380
expect(params.storeCheckpoint).toHaveBeenCalledWith(eventCheckpoint)
381381
})
382382

383-
it("respects shouldContinueRun callback", async () => {
383+
it("respects shouldContinueRun callback and sets stoppedByCancellation status", async () => {
384384
const shouldContinueRun = vi.fn().mockResolvedValue(false)
385385
const params = createMockParams({ shouldContinueRun })
386386
const mockCloseManagers = vi.fn().mockResolvedValue(undefined)
@@ -420,12 +420,13 @@ describe("@perstack/runtime: StateMachineCoordinator", () => {
420420
logics: mockLogics,
421421
})
422422

423-
await coordinator.execute()
423+
const result = await coordinator.execute()
424424

425425
expect(shouldContinueRun).toHaveBeenCalledTimes(1)
426426
expect(mockActor.stop).toHaveBeenCalledTimes(1)
427427
expect(mockCloseManagers).toHaveBeenCalledTimes(1)
428428
expect(mockActor.send).not.toHaveBeenCalled()
429+
expect(result.status).toBe("stoppedByCancellation")
429430
})
430431
})
431432

apps/runtime/src/state-machine/coordinator.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,11 @@ export class StateMachineCoordinator {
127127
if (!shouldContinue) {
128128
this.actor?.stop()
129129
await this.closeManagers(runState.context.skillManagers)
130-
this.resolvePromise?.(runState.context.checkpoint)
130+
const cancelledCheckpoint = {
131+
...runState.context.checkpoint,
132+
status: "stoppedByCancellation" as const,
133+
}
134+
this.resolvePromise?.(cancelledCheckpoint)
131135
return
132136
}
133137
}

apps/runtime/src/state-machine/states/init.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,4 +226,40 @@ describe("@perstack/runtime: StateMachineLogic['Init']", () => {
226226
}),
227227
).rejects.toThrow("Input message is undefined")
228228
})
229+
230+
it("resumes from stoppedByCancellation correctly", async () => {
231+
const setting = createRunSetting({
232+
input: { text: "resume-text" },
233+
})
234+
const checkpoint = createCheckpoint({
235+
status: "stoppedByCancellation",
236+
})
237+
const step = createStep()
238+
await expect(
239+
StateMachineLogics.Init({
240+
setting,
241+
checkpoint,
242+
step,
243+
eventListener: async () => {},
244+
skillManagers: {},
245+
llmExecutor: mockLLMExecutor,
246+
}),
247+
).resolves.toStrictEqual({
248+
type: "startRun",
249+
id: expect.any(String),
250+
expertKey: setting.expertKey,
251+
timestamp: expect.any(Number),
252+
jobId: setting.jobId,
253+
runId: setting.runId,
254+
stepNumber: checkpoint.stepNumber,
255+
initialCheckpoint: checkpoint,
256+
inputMessages: [
257+
{
258+
type: "userMessage",
259+
id: expect.any(String),
260+
contents: [{ type: "textPart", id: expect.any(String), text: "resume-text" }],
261+
},
262+
],
263+
})
264+
})
229265
})

packages/core/src/schemas/checkpoint.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export type CheckpointStatus =
1919
| "stoppedByDelegate"
2020
| "stoppedByExceededMaxSteps"
2121
| "stoppedByError"
22+
| "stoppedByCancellation"
2223

2324
export const checkpointStatusSchema = z.enum([
2425
"init",
@@ -28,6 +29,7 @@ export const checkpointStatusSchema = z.enum([
2829
"stoppedByDelegate",
2930
"stoppedByExceededMaxSteps",
3031
"stoppedByError",
32+
"stoppedByCancellation",
3133
])
3234

3335
/** Information about a delegation target */

packages/core/src/schemas/job.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@ export type JobStatus =
1010
| "stoppedByMaxSteps"
1111
| "stoppedByInteractiveTool"
1212
| "stoppedByError"
13+
| "stoppedByCancellation"
1314

1415
export const jobStatusSchema = z.enum([
1516
"running",
1617
"completed",
1718
"stoppedByMaxSteps",
1819
"stoppedByInteractiveTool",
1920
"stoppedByError",
21+
"stoppedByCancellation",
2022
])
2123

2224
export interface Job {

0 commit comments

Comments
 (0)