Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/agent/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ export class AgentRunner {
liveSession,
});

workspace = await this.workspaceManager.createForIssue(issue.identifier);
workspace = await this.workspaceManager.createForIssue(issue.id);
runAttempt.workspacePath = validateWorkspaceCwd({
cwd: workspace.path,
workspacePath: workspace.path,
Expand Down
6 changes: 3 additions & 3 deletions src/orchestrator/runtime-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ export class OrchestratorRuntimeHost implements DashboardServerHost {
);

if (execution.stopRequest?.cleanupWorkspace === true) {
await this.workspaceManager.removeForIssue(execution.issueIdentifier);
await this.workspaceManager.removeForIssue(execution.issueId);
}

this.orchestrator.onWorkerExit({
Expand Down Expand Up @@ -703,7 +703,7 @@ async function cleanupTerminalIssueWorkspaces(input: {
);
await Promise.all(
issues.map(async (issue) => {
await input.workspaceManager.removeForIssue(issue.identifier);
await input.workspaceManager.removeForIssue(issue.id);
}),
);
} catch (error) {
Expand Down Expand Up @@ -906,7 +906,7 @@ function toRunningIssueDetail(
issue_id: running.issue.id,
status: "running",
workspace: {
path: workspaceManager.resolveForIssue(running.identifier).workspacePath,
path: workspaceManager.resolveForIssue(running.issue.id).workspacePath,
},
attempts: {
restart_count: running.retryAttempt ?? 0,
Expand Down
8 changes: 4 additions & 4 deletions src/workspace/path-safety.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ export class WorkspacePathError extends Error {
}
}

export function sanitizeWorkspaceKey(issueIdentifier: string): string {
return toWorkspaceKey(issueIdentifier);
export function sanitizeWorkspaceKey(issueKeySource: string): string {
return toWorkspaceKey(issueKeySource);
}

export function isWorkspaceKeySafe(workspaceKey: string): boolean {
Expand All @@ -37,10 +37,10 @@ export function resolveWorkspaceRoot(workspaceRoot: string): string {

export function resolveWorkspacePath(
workspaceRoot: string,
issueIdentifier: string,
issueKeySource: string,
): WorkspacePathInfo {
const normalizedRoot = resolveWorkspaceRoot(workspaceRoot);
const workspaceKey = sanitizeWorkspaceKey(issueIdentifier);
const workspaceKey = sanitizeWorkspaceKey(issueKeySource);

assertWorkspaceKeySafe(workspaceKey);

Expand Down
16 changes: 8 additions & 8 deletions src/workspace/workspace-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,13 @@ export class WorkspaceManager {
this.#hooks = isHookRunner(options.hooks) ? options.hooks : null;
}

resolveForIssue(issueIdentifier: string): WorkspacePathInfo {
return resolveWorkspacePath(this.root, issueIdentifier);
resolveForIssue(issueId: string): WorkspacePathInfo {
return resolveWorkspacePath(this.root, issueId);
}

async createForIssue(issueIdentifier: string): Promise<Workspace> {
async createForIssue(issueId: string): Promise<Workspace> {
const { workspaceKey, workspacePath, workspaceRoot } =
this.resolveForIssue(issueIdentifier);
this.resolveForIssue(issueId);

try {
await this.#fs.mkdir(workspaceRoot, { recursive: true });
Expand All @@ -67,14 +67,14 @@ export class WorkspaceManager {

throw new WorkspacePathError(
ERROR_CODES.workspaceCreateFailed,
`Failed to prepare workspace for ${issueIdentifier}`,
`Failed to prepare workspace for ${issueId}`,
{ cause: error },
);
}
}

async removeForIssue(issueIdentifier: string): Promise<boolean> {
const { workspacePath } = this.resolveForIssue(issueIdentifier);
async removeForIssue(issueId: string): Promise<boolean> {
const { workspacePath } = this.resolveForIssue(issueId);

try {
const existsAsDirectory = await this.#workspaceExists(workspacePath);
Expand All @@ -90,7 +90,7 @@ export class WorkspaceManager {
} catch (error) {
throw new WorkspacePathError(
ERROR_CODES.workspaceCleanupFailed,
`Failed to remove workspace for ${issueIdentifier}`,
`Failed to remove workspace for ${issueId}`,
{ cause: error },
);
}
Expand Down
34 changes: 30 additions & 4 deletions tests/agent/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,32 @@ describe("AgentRunner", () => {
);
});

it("keeps the workspace path stable when the issue identifier changes", async () => {
const root = await createRoot();
const tracker = createTracker({
refreshStates: [
{ id: "issue-1", identifier: "RENAMED-456", state: "Done" },
],
});
const runner = new AgentRunner({
config: createConfig(root, "unused"),
tracker,
createCodexClient: (input) =>
createStubCodexClient([], input, {
statuses: ["completed"],
}),
});

const result = await runner.run({
issue: ISSUE_FIXTURE,
attempt: null,
});

expect(result.issue.identifier).toBe("RENAMED-456");
expect(result.workspace.path).toBe(join(root, "issue-1"));
expect(result.runAttempt.workspacePath).toBe(join(root, "issue-1"));
});

it("sends the rendered workflow prompt first and continuation guidance afterwards", async () => {
const root = await createRoot();
const prompts: string[] = [];
Expand Down Expand Up @@ -140,7 +166,7 @@ describe("AgentRunner", () => {
code: ERROR_CODES.hookFailed,
message: "before_run hook failed",
hook: "beforeRun",
workspacePath: join(root, "ABC-123"),
workspacePath: join(root, "issue-1"),
exitCode: 1,
});
}),
Expand Down Expand Up @@ -169,13 +195,13 @@ describe("AgentRunner", () => {
expect(createCodexClient).not.toHaveBeenCalled();
expect(hooks.runBestEffort).toHaveBeenCalledWith({
name: "afterRun",
workspacePath: join(root, "ABC-123"),
workspacePath: join(root, "issue-1"),
});
});

it("removes temporary workspace artifacts before each attempt starts", async () => {
const root = await createRoot();
const workspacePath = join(root, "ABC-123");
const workspacePath = join(root, "issue-1");
await mkdir(join(workspacePath, "tmp"), { recursive: true });

const hooks = {
Expand Down Expand Up @@ -263,7 +289,7 @@ describe("AgentRunner", () => {
expect(close).toHaveBeenCalledTimes(1);
expect(hooks.runBestEffort).toHaveBeenCalledWith({
name: "afterRun",
workspacePath: expect.stringContaining("ABC-123"),
workspacePath: expect.stringContaining("issue-1"),
});
});

Expand Down
8 changes: 5 additions & 3 deletions tests/cli/runtime-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,15 @@ describe("runtime integration", () => {
const root = await createTempDir("symphony-task16-runtime-");
const logsRoot = join(root, "logs");
const workspaceRoot = join(root, "workspaces");
const terminalWorkspace = join(workspaceRoot, "DONE-1");
const terminalWorkspace = join(workspaceRoot, "done-1");

await mkdir(terminalWorkspace, { recursive: true });
await writeFile(join(terminalWorkspace, "artifact.txt"), "stale\n", "utf8");

const tracker = createTracker({
terminalIssues: [createIssue({ identifier: "DONE-1", state: "Done" })],
terminalIssues: [
createIssue({ id: "done-1", identifier: "DONE-1", state: "Done" }),
],
candidates: [],
});
const stdout = new PassThrough();
Expand Down Expand Up @@ -403,7 +405,7 @@ Implement {{ issue.identifier }} attempt={{ attempt }}
stdout: new PassThrough(),
});

const workspacePath = join(workspaceRoot, "ISSUE-1");
const workspacePath = join(workspaceRoot, "issue-1");
await vi.waitFor(async () => {
const state = await service.runtimeHost.getRuntimeSnapshot();
expect(state.counts.running + state.counts.retrying).toBeGreaterThan(0);
Expand Down
37 changes: 33 additions & 4 deletions tests/orchestrator/runtime-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,15 +80,15 @@ describe("OrchestratorRuntimeHost", () => {
fakeRunner.resolve("1", {
issue: createIssue({ state: "In Progress" }),
workspace: {
path: "/tmp/workspaces/ISSUE-1",
workspaceKey: "ISSUE-1",
path: "/tmp/workspaces/1",
workspaceKey: "1",
createdNow: true,
},
runAttempt: {
issueId: "1",
issueIdentifier: "ISSUE-1",
attempt: null,
workspacePath: "/tmp/workspaces/ISSUE-1",
workspacePath: "/tmp/workspaces/1",
startedAt: "2026-03-06T00:00:00.000Z",
status: "succeeded",
},
Expand Down Expand Up @@ -200,6 +200,35 @@ describe("OrchestratorRuntimeHost", () => {
expect(tracker.fetchCandidateIssues).toHaveBeenCalledTimes(1);
});

it("resolves running workspace details from issue id after identifier changes", async () => {
const tracker = createTracker();
const fakeRunner = new FakeAgentRunner();
const host = new OrchestratorRuntimeHost({
config: createConfig(),
tracker,
createAgentRunner: ({ onEvent }) => {
fakeRunner.onEvent = onEvent;
return fakeRunner;
},
now: () => new Date("2026-03-06T00:00:05.000Z"),
});

await host.pollOnce();
tracker.setStateSnapshots([
{ id: "1", identifier: "RENAMED-2", state: "In Progress" },
]);
await host.pollOnce();

const details = await host.getIssueDetails("RENAMED-2");

expect(details).toMatchObject({
issue_identifier: "RENAMED-2",
workspace: {
path: "/tmp/workspaces/1",
},
});
});

it("emits issue and session context for agent lifecycle logs", async () => {
const tracker = createTracker();
const fakeRunner = new FakeAgentRunner();
Expand Down Expand Up @@ -297,7 +326,7 @@ class FakeAgentRunner {
issueId,
issueIdentifier: "ISSUE-1",
attempt: null,
workspacePath: "/tmp/workspaces/ISSUE-1",
workspacePath: "/tmp/workspaces/1",
turnCount: event.turnCount ?? 0,
});
}
Expand Down
12 changes: 6 additions & 6 deletions tests/workspace/path-safety.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,22 @@ import {
} from "../../src/workspace/path-safety.js";

describe("workspace path safety", () => {
it("sanitizes issue identifiers into deterministic workspace keys", () => {
expect(sanitizeWorkspaceKey("ABC-123/needs review")).toBe(
"ABC-123_needs_review",
it("sanitizes issue ids into deterministic workspace keys", () => {
expect(sanitizeWorkspaceKey("issue/123:needs review")).toBe(
"issue_123_needs_review",
);
expect(sanitizeWorkspaceKey("你好 world")).toBe("___world");
});

it("builds an absolute workspace path under the configured root", () => {
const info = resolveWorkspacePath(
"./tmp/workspaces",
"ABC-123/needs review",
"issue/123:needs review",
);

expect(info.workspaceKey).toBe("ABC-123_needs_review");
expect(info.workspaceKey).toBe("issue_123_needs_review");
expect(info.workspacePath).toBe(
join(info.workspaceRoot, "ABC-123_needs_review"),
join(info.workspaceRoot, "issue_123_needs_review"),
);
});

Expand Down
36 changes: 18 additions & 18 deletions tests/workspace/workspace-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ afterEach(async () => {
await Promise.allSettled(
roots.splice(0).map(async (root) => {
const manager = new WorkspaceManager({ root });
await manager.removeForIssue("ABC-123");
await manager.removeForIssue("ABC-123/needs review");
await manager.removeForIssue("issue-123");
await manager.removeForIssue("issue/123:needs review");
}),
);
});
Expand All @@ -28,21 +28,21 @@ describe("WorkspaceManager", () => {
const root = await createRoot();
const manager = new WorkspaceManager({ root });

const workspace = await manager.createForIssue("ABC-123/needs review");
const workspace = await manager.createForIssue("issue/123:needs review");

expect(workspace.workspaceKey).toBe("ABC-123_needs_review");
expect(workspace.path).toBe(join(root, "ABC-123_needs_review"));
expect(workspace.workspaceKey).toBe("issue_123_needs_review");
expect(workspace.path).toBe(join(root, "issue_123_needs_review"));
expect(workspace.createdNow).toBe(true);
});

it("reuses an existing workspace directory on later attempts", async () => {
const root = await createRoot();
const manager = new WorkspaceManager({ root });

await manager.createForIssue("ABC-123");
const workspace = await manager.createForIssue("ABC-123");
await manager.createForIssue("issue-123");
const workspace = await manager.createForIssue("issue-123");

expect(workspace.path).toBe(join(root, "ABC-123"));
expect(workspace.path).toBe(join(root, "issue-123"));
expect(workspace.createdNow).toBe(false);
});

Expand All @@ -69,8 +69,8 @@ describe("WorkspaceManager", () => {
});
const manager = new WorkspaceManager({ root, hooks });

const first = await manager.createForIssue("ABC-123");
await manager.createForIssue("ABC-123");
const first = await manager.createForIssue("issue-123");
await manager.createForIssue("issue-123");

expect(hookCalls).toEqual([first.path]);
});
Expand Down Expand Up @@ -98,19 +98,19 @@ describe("WorkspaceManager", () => {
});
const manager = new WorkspaceManager({ root, hooks });

const workspace = await manager.createForIssue("ABC-123");
const removed = await manager.removeForIssue("ABC-123");
const workspace = await manager.createForIssue("issue-123");
const removed = await manager.removeForIssue("issue-123");

expect(removed).toBe(true);
expect(hookCalls).toEqual([workspace.path]);
});

it("fails safely when the workspace path already exists as a file", async () => {
const root = await createRoot();
await writeFile(join(root, "ABC-123"), "not a directory");
await writeFile(join(root, "issue-123"), "not a directory");
const manager = new WorkspaceManager({ root });

await expect(manager.createForIssue("ABC-123")).rejects.toThrowError(
await expect(manager.createForIssue("issue-123")).rejects.toThrowError(
expect.objectContaining<Partial<WorkspacePathError>>({
code: ERROR_CODES.workspacePathInvalid,
}),
Expand All @@ -121,13 +121,13 @@ describe("WorkspaceManager", () => {
const root = await createRoot();
const manager = new WorkspaceManager({ root });

const workspace = await manager.createForIssue("ABC-123");
const removed = await manager.removeForIssue("ABC-123");
const workspace = await manager.createForIssue("issue-123");
const removed = await manager.removeForIssue("issue-123");

expect(removed).toBe(true);
await expect(manager.createForIssue("ABC-123")).resolves.toEqual({
await expect(manager.createForIssue("issue-123")).resolves.toEqual({
path: workspace.path,
workspaceKey: "ABC-123",
workspaceKey: "issue-123",
createdNow: true,
});
});
Expand Down
Loading