Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
21 changes: 21 additions & 0 deletions packages/core/src/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
isRestorable,
isTerminalSession,
NON_RESTORABLE_STATUSES,
IssueNotSpawnableError,
SessionNotFoundError,
SessionNotRestorableError,
WorkspaceMissingError,
Expand Down Expand Up @@ -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") {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
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
21 changes: 21 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,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.`,
);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
this.name = "IssueNotSpawnableError";
}
}

/** Thrown when a session cannot be restored (e.g. merged, still working). */
export class SessionNotRestorableError extends Error {
constructor(
Expand Down
24 changes: 20 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,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 },
});
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
}
return jsonWithCorrelation(
{ error: err instanceof Error ? err.message : "Failed to spawn session" },
{ status: 500 },
{ status: statusCode },
correlationId,
);
}
Expand Down