From 5fcf524f15308fc90960a35d9990e57e85de973f Mon Sep 17 00:00:00 2001 From: nikhil achale Date: Tue, 26 May 2026 14:50:13 +0530 Subject: [PATCH 1/2] feat: implement issue spawn rejection for closed or cancelled states Added the IssueNotSpawnableError class to handle cases where spawning a session is attempted for issues that are closed or cancelled. Updated session manager logic to reject spawning in these scenarios and log appropriate activity events. Enhanced API error handling to return a 409 status code for these specific errors. Added tests to verify the new behavior. --- .../__tests__/session-manager/spawn.test.ts | 73 +++++++++++++++++++ packages/core/src/session-manager.ts | 21 ++++++ packages/core/src/types.ts | 21 ++++++ packages/web/src/app/api/spawn/route.ts | 24 +++++- 4 files changed, 135 insertions(+), 4 deletions(-) diff --git a/packages/core/src/__tests__/session-manager/spawn.test.ts b/packages/core/src/__tests__/session-manager/spawn.test.ts index d06dbbe3a3..a6f1ea853a 100644 --- a/packages/core/src/__tests__/session-manager/spawn.test.ts +++ b/packages/core/src/__tests__/session-manager/spawn.test.ts @@ -21,6 +21,7 @@ import type { Agent, Workspace, Tracker, + Issue, } from "../../types.js"; import { setupTestContext, @@ -1089,6 +1090,78 @@ describe("spawn", () => { expect(mockRuntime.create).not.toHaveBeenCalled(); }); + function registryWithTracker(mockTracker: Tracker): PluginRegistry { + return { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return mockAgent; + if (slot === "workspace") return mockWorkspace; + if (slot === "tracker") return mockTracker; + return null; + }), + }; + } + + function baseMockTracker(issue: Partial & Pick): Tracker { + return { + name: "mock-tracker", + getIssue: vi.fn().mockResolvedValue({ + title: "Test issue", + description: "", + url: "https://example.com/issues/1", + labels: [], + ...issue, + }), + isCompleted: vi.fn().mockResolvedValue(issue.state === "closed" || issue.state === "cancelled"), + issueUrl: vi.fn().mockReturnValue("https://example.com/issues/1"), + branchName: vi.fn().mockReturnValue(`feat/${issue.id}`), + generatePrompt: vi.fn().mockResolvedValue("Work on issue"), + }; + } + + it("rejects spawn when tracker issue is closed", async () => { + const mockTracker = baseMockTracker({ id: "42", state: "closed" }); + const sm = createSessionManager({ + config, + registry: registryWithTracker(mockTracker), + }); + + await expect(sm.spawn({ projectId: "my-app", issueId: "42" })).rejects.toThrow( + /Issue 42 is closed/, + ); + expect(mockWorkspace.create).not.toHaveBeenCalled(); + expect(mockRuntime.create).not.toHaveBeenCalled(); + }); + + it("rejects spawn when tracker issue is cancelled", async () => { + const mockTracker = baseMockTracker({ id: "99", state: "cancelled" }); + const sm = createSessionManager({ + config, + registry: registryWithTracker(mockTracker), + }); + + await expect(sm.spawn({ projectId: "my-app", issueId: "99" })).rejects.toThrow( + /Issue 99 is cancelled/, + ); + expect(mockWorkspace.create).not.toHaveBeenCalled(); + expect(mockRuntime.create).not.toHaveBeenCalled(); + }); + + it("allows spawn when tracker issue is in_progress", async () => { + const mockTracker = baseMockTracker({ id: "INT-50", state: "in_progress" }); + const sm = createSessionManager({ + config, + registry: registryWithTracker(mockTracker), + }); + + const session = await sm.spawn({ projectId: "my-app", issueId: "INT-50" }); + + expect(session.issueId).toBe("INT-50"); + expect(mockWorkspace.create).toHaveBeenCalled(); + expect(mockRuntime.create).toHaveBeenCalled(); + }); + it("spawns without issue tracking when no issueId provided", async () => { const sm = createSessionManager({ config, registry: mockRegistry }); diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 86807c47b6..f5de85da21 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -22,6 +22,7 @@ import { isRestorable, isTerminalSession, NON_RESTORABLE_STATUSES, + IssueNotSpawnableError, SessionNotFoundError, SessionNotRestorableError, WorkspaceMissingError, @@ -1258,6 +1259,26 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM throw new Error(`Failed to fetch issue ${spawnConfig.issueId}: ${err}`, { cause: err }); } } + + if (resolvedIssue) { + const { state } = resolvedIssue; + if (state === "closed" || state === "cancelled") { + recordActivityEvent({ + projectId: spawnConfig.projectId, + source: "session-manager", + kind: "session.spawn_rejected", + level: "warn", + summary: `spawn rejected: issue ${spawnConfig.issueId} is ${state}`, + data: { + issueId: spawnConfig.issueId, + issueState: state, + tracker: plugins.tracker.name, + reason: "issue_not_spawnable", + }, + }); + throw new IssueNotSpawnableError(spawnConfig.issueId, state); + } + } } // Get the sessions directory for this project diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 87cbd6b0c5..ca6bdfdc18 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -760,6 +760,13 @@ export interface Issue { branchName?: string; } +/** Issue states that allow spawning a new worker session. */ +export function isSpawnableIssueState( + state: Issue["state"], +): state is "open" | "in_progress" { + return state === "open" || state === "in_progress"; +} + export interface IssueFilters { state?: "open" | "closed" | "all"; labels?: string[]; @@ -1992,6 +1999,20 @@ export function isIssueNotFoundError(err: unknown): boolean { ); } +/** Thrown when spawn is requested for a tracker issue that is closed or cancelled. */ +export class IssueNotSpawnableError extends Error { + constructor( + public readonly issueId: string, + public readonly state: "closed" | "cancelled", + ) { + const label = state === "cancelled" ? "cancelled" : "closed"; + super( + `Issue ${issueId} is ${label}. Cannot spawn a session for a closed/cancelled issue.`, + ); + this.name = "IssueNotSpawnableError"; + } +} + /** Thrown when a session cannot be restored (e.g. merged, still working). */ export class SessionNotRestorableError extends Error { constructor( diff --git a/packages/web/src/app/api/spawn/route.ts b/packages/web/src/app/api/spawn/route.ts index 8fcc37fe56..cac482741c 100644 --- a/packages/web/src/app/api/spawn/route.ts +++ b/packages/web/src/app/api/spawn/route.ts @@ -1,5 +1,5 @@ import { type NextRequest } from "next/server"; -import { recordActivityEvent } from "@aoagents/ao-core"; +import { IssueNotSpawnableError, recordActivityEvent } from "@aoagents/ao-core"; import { validateIdentifier, validateString, validateConfiguredProject } from "@/lib/validation"; import { getServices } from "@/lib/services"; import { sessionToDashboard } from "@/lib/serialize"; @@ -103,6 +103,7 @@ export async function POST(request: NextRequest) { ); } catch (err) { const { config } = await getServices().catch(() => ({ config: undefined })); + const statusCode = err instanceof IssueNotSpawnableError ? 409 : 500; if (config) { recordApiObservation({ config, @@ -111,15 +112,30 @@ export async function POST(request: NextRequest) { correlationId, startedAt, outcome: "failure", - statusCode: 500, + statusCode, projectId: typeof body.projectId === "string" ? body.projectId : undefined, reason: err instanceof Error ? err.message : "Failed to spawn session", - data: { issueId: body.issueId }, + data: { + issueId: body.issueId, + ...(err instanceof IssueNotSpawnableError + ? { reason: "issue_not_spawnable", issueState: err.state } + : {}), + }, }); + if (err instanceof IssueNotSpawnableError) { + recordActivityEvent({ + projectId: typeof body.projectId === "string" ? body.projectId : "unknown", + source: "api", + kind: "api.session_spawn_rejected", + level: "warn", + summary: `session spawn rejected: ${err.message}`, + data: { reason: "issue_not_spawnable", issueId: err.issueId, issueState: err.state }, + }); + } } return jsonWithCorrelation( { error: err instanceof Error ? err.message : "Failed to spawn session" }, - { status: 500 }, + { status: statusCode }, correlationId, ); } From c7da25eb3f5230906af8aca6b60be2ec8f70bbd0 Mon Sep 17 00:00:00 2001 From: nikhil achale Date: Tue, 26 May 2026 18:18:15 +0530 Subject: [PATCH 2/2] refactor: enhance issue state handling in session manager Updated the session manager to utilize the new isSpawnableIssueState function for improved state validation. Modified the IssueNotSpawnableError class to accept a broader range of issue states, ensuring accurate error messaging. Removed redundant error handling in the API spawn route for cleaner code. --- packages/core/src/session-manager.ts | 3 ++- packages/core/src/types.ts | 5 ++--- packages/web/src/app/api/spawn/route.ts | 10 ---------- 3 files changed, 4 insertions(+), 14 deletions(-) diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index f5de85da21..9edc7d2729 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -20,6 +20,7 @@ import { promisify } from "node:util"; import { isIssueNotFoundError, isRestorable, + isSpawnableIssueState, isTerminalSession, NON_RESTORABLE_STATUSES, IssueNotSpawnableError, @@ -1262,7 +1263,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM if (resolvedIssue) { const { state } = resolvedIssue; - if (state === "closed" || state === "cancelled") { + if (state && !isSpawnableIssueState(state)) { recordActivityEvent({ projectId: spawnConfig.projectId, source: "session-manager", diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ca6bdfdc18..324ddf298f 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2003,11 +2003,10 @@ export function isIssueNotFoundError(err: unknown): boolean { export class IssueNotSpawnableError extends Error { constructor( public readonly issueId: string, - public readonly state: "closed" | "cancelled", + public readonly state: Exclude, ) { - const label = state === "cancelled" ? "cancelled" : "closed"; super( - `Issue ${issueId} is ${label}. Cannot spawn a session for a closed/cancelled issue.`, + `Issue ${issueId} is ${state}. Cannot spawn a session for a closed/cancelled issue.`, ); this.name = "IssueNotSpawnableError"; } diff --git a/packages/web/src/app/api/spawn/route.ts b/packages/web/src/app/api/spawn/route.ts index cac482741c..8fd194d121 100644 --- a/packages/web/src/app/api/spawn/route.ts +++ b/packages/web/src/app/api/spawn/route.ts @@ -122,16 +122,6 @@ export async function POST(request: NextRequest) { : {}), }, }); - if (err instanceof IssueNotSpawnableError) { - recordActivityEvent({ - projectId: typeof body.projectId === "string" ? body.projectId : "unknown", - source: "api", - kind: "api.session_spawn_rejected", - level: "warn", - summary: `session spawn rejected: ${err.message}`, - data: { reason: "issue_not_spawnable", issueId: err.issueId, issueState: err.state }, - }); - } } return jsonWithCorrelation( { error: err instanceof Error ? err.message : "Failed to spawn session" },