Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions packages/core/src/__tests__/session-manager/spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
Agent,
Workspace,
Tracker,
Issue,
} from "../../types.js";
import {
setupTestContext,
Expand Down Expand Up @@ -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<Issue> & Pick<Issue, "id" | "state">): 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 });

Expand Down
22 changes: 22 additions & 0 deletions packages/core/src/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ import { promisify } from "node:util";
import {
isIssueNotFoundError,
isRestorable,
isSpawnableIssueState,
isTerminalSession,
NON_RESTORABLE_STATUSES,
IssueNotSpawnableError,
SessionNotFoundError,
SessionNotRestorableError,
WorkspaceMissingError,
Expand Down Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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<Issue["state"], "open" | "in_progress">,
) {
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(
Expand Down
14 changes: 10 additions & 4 deletions packages/web/src/app/api/spawn/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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,
);
}
Expand Down