diff --git a/.changeset/calm-birds-teach.md b/.changeset/calm-birds-teach.md new file mode 100644 index 00000000..4b46b914 --- /dev/null +++ b/.changeset/calm-birds-teach.md @@ -0,0 +1,8 @@ +--- +"@perstack/core": patch +"@perstack/runtime": patch +--- + +Add stoppedByCancellation status for proper job cancellation handling + +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. diff --git a/apps/runtime/src/run.test.ts b/apps/runtime/src/run.test.ts index a2ba4f8a..be592ae3 100644 --- a/apps/runtime/src/run.test.ts +++ b/apps/runtime/src/run.test.ts @@ -73,6 +73,15 @@ describe("@perstack/runtime: run", () => { expect(result.status).toBe("stoppedByError") }) + it("returns checkpoint on stoppedByCancellation status", async () => { + const stoppedCheckpoint = createCheckpoint({ status: "stoppedByCancellation" }) + setupMockExecutor([{ checkpoint: stoppedCheckpoint }]) + + const result = await run({ setting, checkpoint }) + + expect(result.status).toBe("stoppedByCancellation") + }) + it("throws error on unknown status", async () => { // Manually create checkpoint with invalid status to bypass schema validation const unknownCheckpoint = { @@ -195,6 +204,19 @@ describe("@perstack/runtime: run", () => { const lastCall = storeJob.mock.calls[storeJob.mock.calls.length - 1][0] as Job expect(lastCall.status).toBe("stoppedByError") }) + + it("updates job status to stoppedByCancellation on cancellation", async () => { + const stoppedCheckpoint = createCheckpoint({ status: "stoppedByCancellation" }) + setupMockExecutor([{ checkpoint: stoppedCheckpoint }]) + + const storeJob = vi.fn() + + await run({ setting, checkpoint }, { storeJob }) + + const lastCall = storeJob.mock.calls[storeJob.mock.calls.length - 1][0] as Job + expect(lastCall.status).toBe("stoppedByCancellation") + expect(lastCall.finishedAt).toBeDefined() + }) }) describe("returnOnDelegationComplete option", () => { diff --git a/apps/runtime/src/run.ts b/apps/runtime/src/run.ts index fca33877..59c4366d 100755 --- a/apps/runtime/src/run.ts +++ b/apps/runtime/src/run.ts @@ -163,6 +163,11 @@ export async function run(runInput: RunParamsInput, options?: RunOptions): Promi return resultCheckpoint } + case "stoppedByCancellation": { + storeJob({ ...job, status: "stoppedByCancellation", finishedAt: Date.now() }) + return resultCheckpoint + } + default: throw new Error("Run stopped by unknown reason") } diff --git a/apps/runtime/src/state-machine/coordinator.test.ts b/apps/runtime/src/state-machine/coordinator.test.ts index 522ad778..83f1cf40 100644 --- a/apps/runtime/src/state-machine/coordinator.test.ts +++ b/apps/runtime/src/state-machine/coordinator.test.ts @@ -380,7 +380,7 @@ describe("@perstack/runtime: StateMachineCoordinator", () => { expect(params.storeCheckpoint).toHaveBeenCalledWith(eventCheckpoint) }) - it("respects shouldContinueRun callback", async () => { + it("respects shouldContinueRun callback and sets stoppedByCancellation status", async () => { const shouldContinueRun = vi.fn().mockResolvedValue(false) const params = createMockParams({ shouldContinueRun }) const mockCloseManagers = vi.fn().mockResolvedValue(undefined) @@ -420,12 +420,13 @@ describe("@perstack/runtime: StateMachineCoordinator", () => { logics: mockLogics, }) - await coordinator.execute() + const result = await coordinator.execute() expect(shouldContinueRun).toHaveBeenCalledTimes(1) expect(mockActor.stop).toHaveBeenCalledTimes(1) expect(mockCloseManagers).toHaveBeenCalledTimes(1) expect(mockActor.send).not.toHaveBeenCalled() + expect(result.status).toBe("stoppedByCancellation") }) }) diff --git a/apps/runtime/src/state-machine/coordinator.ts b/apps/runtime/src/state-machine/coordinator.ts index b08503ad..7eb03248 100644 --- a/apps/runtime/src/state-machine/coordinator.ts +++ b/apps/runtime/src/state-machine/coordinator.ts @@ -127,7 +127,11 @@ export class StateMachineCoordinator { if (!shouldContinue) { this.actor?.stop() await this.closeManagers(runState.context.skillManagers) - this.resolvePromise?.(runState.context.checkpoint) + const cancelledCheckpoint = { + ...runState.context.checkpoint, + status: "stoppedByCancellation" as const, + } + this.resolvePromise?.(cancelledCheckpoint) return } } diff --git a/apps/runtime/src/state-machine/states/init.test.ts b/apps/runtime/src/state-machine/states/init.test.ts index f1121820..4a81e1c8 100644 --- a/apps/runtime/src/state-machine/states/init.test.ts +++ b/apps/runtime/src/state-machine/states/init.test.ts @@ -226,4 +226,40 @@ describe("@perstack/runtime: StateMachineLogic['Init']", () => { }), ).rejects.toThrow("Input message is undefined") }) + + it("resumes from stoppedByCancellation correctly", async () => { + const setting = createRunSetting({ + input: { text: "resume-text" }, + }) + const checkpoint = createCheckpoint({ + status: "stoppedByCancellation", + }) + const step = createStep() + await expect( + StateMachineLogics.Init({ + setting, + checkpoint, + step, + eventListener: async () => {}, + skillManagers: {}, + llmExecutor: mockLLMExecutor, + }), + ).resolves.toStrictEqual({ + type: "startRun", + id: expect.any(String), + expertKey: setting.expertKey, + timestamp: expect.any(Number), + jobId: setting.jobId, + runId: setting.runId, + stepNumber: checkpoint.stepNumber, + initialCheckpoint: checkpoint, + inputMessages: [ + { + type: "userMessage", + id: expect.any(String), + contents: [{ type: "textPart", id: expect.any(String), text: "resume-text" }], + }, + ], + }) + }) }) diff --git a/e2e/experts/runtime-version-future.toml b/e2e/experts/runtime-version-future.toml new file mode 100644 index 00000000..d9afec29 --- /dev/null +++ b/e2e/experts/runtime-version-future.toml @@ -0,0 +1,34 @@ +model = "claude-sonnet-4-5" + +[provider] +providerName = "anthropic" + +envPath = [".env", ".env.local"] + +# Expert requiring future version (validation failure) +[experts."e2e-runtime-future"] +version = "1.0.0" +minRuntimeVersion = "v99.0" +instruction = "Call attemptCompletion with result 'OK'" + +[experts."e2e-runtime-future".skills."@perstack/base"] +type = "mcpStdioSkill" +command = "npx" +packageName = "@perstack/base" +pick = ["attemptCompletion"] + +# Chain with nested future version: root(v1.0) -> nested(v99.0) +[experts."e2e-runtime-chain-future"] +version = "1.0.0" +minRuntimeVersion = "v1.0" +instruction = """ +1. Delegate to "e2e-runtime-future" with "test" +2. When done, call attemptCompletion with result "OK" +""" +delegates = ["e2e-runtime-future"] + +[experts."e2e-runtime-chain-future".skills."@perstack/base"] +type = "mcpStdioSkill" +command = "npx" +packageName = "@perstack/base" +pick = ["attemptCompletion"] diff --git a/e2e/experts/runtime-version.toml b/e2e/experts/runtime-version.toml index dd733bf3..ab82ec5a 100644 --- a/e2e/experts/runtime-version.toml +++ b/e2e/experts/runtime-version.toml @@ -28,18 +28,6 @@ command = "npx" packageName = "@perstack/base" pick = ["attemptCompletion"] -# Expert requiring future version (validation failure) -[experts."e2e-runtime-future"] -version = "1.0.0" -minRuntimeVersion = "v99.0" -instruction = "Call attemptCompletion with result 'OK'" - -[experts."e2e-runtime-future".skills."@perstack/base"] -type = "mcpStdioSkill" -command = "npx" -packageName = "@perstack/base" -pick = ["attemptCompletion"] - # 3-level chain: root(v1.0) -> level1(v1.0) -> level2(v1.0) [experts."e2e-runtime-chain-ok"] version = "1.0.0" @@ -81,19 +69,3 @@ type = "mcpStdioSkill" command = "npx" packageName = "@perstack/base" pick = ["attemptCompletion"] - -# Chain with nested future version: root(v1.0) -> nested(v99.0) -[experts."e2e-runtime-chain-future"] -version = "1.0.0" -minRuntimeVersion = "v1.0" -instruction = """ -1. Delegate to "e2e-runtime-future" with "test" -2. When done, call attemptCompletion with result "OK" -""" -delegates = ["e2e-runtime-future"] - -[experts."e2e-runtime-chain-future".skills."@perstack/base"] -type = "mcpStdioSkill" -command = "npx" -packageName = "@perstack/base" -pick = ["attemptCompletion"] diff --git a/e2e/perstack-runtime/runtime-version.test.ts b/e2e/perstack-runtime/runtime-version.test.ts index c2d0b313..7a01f100 100644 --- a/e2e/perstack-runtime/runtime-version.test.ts +++ b/e2e/perstack-runtime/runtime-version.test.ts @@ -8,7 +8,7 @@ * - 3-level delegation chain with all v1.0 * - Nested delegate with future version requirement * - * TOML: e2e/experts/runtime-version.toml + * TOML: e2e/experts/runtime-version.toml, e2e/experts/runtime-version-future.toml */ import { describe, expect, it } from "vitest" import { assertEventSequenceContains } from "../lib/assertions.js" @@ -16,6 +16,7 @@ import { filterEventsByType } from "../lib/event-parser.js" import { runRuntimeCli, withEventParsing } from "../lib/runner.js" const RUNTIME_VERSION_CONFIG = "./e2e/experts/runtime-version.toml" +const RUNTIME_VERSION_FUTURE_CONFIG = "./e2e/experts/runtime-version-future.toml" const LLM_TIMEOUT = 120000 const LLM_EXTENDED_TIMEOUT = 300000 @@ -56,7 +57,7 @@ describe.concurrent("Runtime Version Validation", () => { "should fail when expert requires future version", async () => { const cmdResult = await runRuntimeCli( - ["run", "--config", RUNTIME_VERSION_CONFIG, "e2e-runtime-future", "test"], + ["run", "--config", RUNTIME_VERSION_FUTURE_CONFIG, "e2e-runtime-future", "test"], { timeout: LLM_TIMEOUT }, ) expect(cmdResult.exitCode).toBe(1) @@ -84,7 +85,7 @@ describe.concurrent("Runtime Version Validation", () => { "should fail when nested delegate requires future version", async () => { const cmdResult = await runRuntimeCli( - ["run", "--config", RUNTIME_VERSION_CONFIG, "e2e-runtime-chain-future", "test"], + ["run", "--config", RUNTIME_VERSION_FUTURE_CONFIG, "e2e-runtime-chain-future", "test"], { timeout: LLM_TIMEOUT }, ) expect(cmdResult.exitCode).toBe(1) diff --git a/packages/core/src/schemas/checkpoint.ts b/packages/core/src/schemas/checkpoint.ts index 9b7cbe65..eb7c0e09 100644 --- a/packages/core/src/schemas/checkpoint.ts +++ b/packages/core/src/schemas/checkpoint.ts @@ -19,6 +19,7 @@ export type CheckpointStatus = | "stoppedByDelegate" | "stoppedByExceededMaxSteps" | "stoppedByError" + | "stoppedByCancellation" export const checkpointStatusSchema = z.enum([ "init", @@ -28,6 +29,7 @@ export const checkpointStatusSchema = z.enum([ "stoppedByDelegate", "stoppedByExceededMaxSteps", "stoppedByError", + "stoppedByCancellation", ]) /** Information about a delegation target */ diff --git a/packages/core/src/schemas/job.ts b/packages/core/src/schemas/job.ts index 8508f7f2..54b9d183 100644 --- a/packages/core/src/schemas/job.ts +++ b/packages/core/src/schemas/job.ts @@ -10,6 +10,7 @@ export type JobStatus = | "stoppedByMaxSteps" | "stoppedByInteractiveTool" | "stoppedByError" + | "stoppedByCancellation" export const jobStatusSchema = z.enum([ "running", @@ -17,6 +18,7 @@ export const jobStatusSchema = z.enum([ "stoppedByMaxSteps", "stoppedByInteractiveTool", "stoppedByError", + "stoppedByCancellation", ]) export interface Job { diff --git a/packages/core/src/schemas/runtime-version.ts b/packages/core/src/schemas/runtime-version.ts index c20e4327..d89a957a 100644 --- a/packages/core/src/schemas/runtime-version.ts +++ b/packages/core/src/schemas/runtime-version.ts @@ -1,5 +1,8 @@ import { z } from "zod" -export type RuntimeVersion = "v1.0" +export type RuntimeVersion = `v${number}.${number}` -export const runtimeVersionSchema = z.enum(["v1.0"]) +export const runtimeVersionSchema = z + .string() + .regex(/^v\d+\.\d+$/, 'Runtime version must be in format "vX.Y" (e.g., "v1.0")') + .transform((v) => v as RuntimeVersion)