From 835b9eaefe198c0903f013d78108b300fa264342 Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Sun, 1 Feb 2026 22:40:56 +0000 Subject: [PATCH 1/2] 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 --- .changeset/calm-birds-teach.md | 8 +++++ apps/runtime/src/run.test.ts | 22 ++++++++++++ apps/runtime/src/run.ts | 5 +++ .../src/state-machine/coordinator.test.ts | 5 +-- apps/runtime/src/state-machine/coordinator.ts | 6 +++- .../src/state-machine/states/init.test.ts | 36 +++++++++++++++++++ packages/core/src/schemas/checkpoint.ts | 2 ++ packages/core/src/schemas/job.ts | 2 ++ 8 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 .changeset/calm-birds-teach.md 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/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 { From 7e285aaa73fad560758f54f482f8aae29f7ff96a Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Mon, 2 Feb 2026 00:05:45 +0000 Subject: [PATCH 2/2] fix(core): allow flexible runtime version format in schema - Change RuntimeVersion from literal "v1.0" to pattern `v${number}.${number}` - Split runtime-version E2E tests to use separate config for future version tests - This allows testing version validation failures without breaking other tests Co-Authored-By: Claude Opus 4.5 --- e2e/experts/runtime-version-future.toml | 34 ++++++++++++++++++++ e2e/experts/runtime-version.toml | 28 ---------------- e2e/perstack-runtime/runtime-version.test.ts | 7 ++-- packages/core/src/schemas/runtime-version.ts | 7 ++-- 4 files changed, 43 insertions(+), 33 deletions(-) create mode 100644 e2e/experts/runtime-version-future.toml 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/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)