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..9edc7d2729 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -20,8 +20,10 @@ import { promisify } from "node:util"; import { isIssueNotFoundError, isRestorable, + isSpawnableIssueState, isTerminalSession, NON_RESTORABLE_STATUSES, + IssueNotSpawnableError, SessionNotFoundError, SessionNotRestorableError, WorkspaceMissingError, @@ -1258,6 +1260,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 && !isSpawnableIssueState(state)) { + 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..324ddf298f 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,19 @@ 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: Exclude, + ) { + super( + `Issue ${issueId} is ${state}. 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..8fd194d121 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,20 @@ 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 } + : {}), + }, }); } return jsonWithCorrelation( { error: err instanceof Error ? err.message : "Failed to spawn session" }, - { status: 500 }, + { status: statusCode }, correlationId, ); }