diff --git a/src/agent/runner.ts b/src/agent/runner.ts index 37fdc8d0..d518fab7 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -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, diff --git a/src/orchestrator/runtime-host.ts b/src/orchestrator/runtime-host.ts index 030995fe..6733ad74 100644 --- a/src/orchestrator/runtime-host.ts +++ b/src/orchestrator/runtime-host.ts @@ -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({ @@ -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) { @@ -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, diff --git a/src/workspace/path-safety.ts b/src/workspace/path-safety.ts index dc9c1b10..69c24d49 100644 --- a/src/workspace/path-safety.ts +++ b/src/workspace/path-safety.ts @@ -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 { @@ -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); diff --git a/src/workspace/workspace-manager.ts b/src/workspace/workspace-manager.ts index 830c20e6..8c303d68 100644 --- a/src/workspace/workspace-manager.ts +++ b/src/workspace/workspace-manager.ts @@ -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 { + async createForIssue(issueId: string): Promise { const { workspaceKey, workspacePath, workspaceRoot } = - this.resolveForIssue(issueIdentifier); + this.resolveForIssue(issueId); try { await this.#fs.mkdir(workspaceRoot, { recursive: true }); @@ -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 { - const { workspacePath } = this.resolveForIssue(issueIdentifier); + async removeForIssue(issueId: string): Promise { + const { workspacePath } = this.resolveForIssue(issueId); try { const existsAsDirectory = await this.#workspaceExists(workspacePath); @@ -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 }, ); } diff --git a/tests/agent/runner.test.ts b/tests/agent/runner.test.ts index 52afa2b8..bda9b03f 100644 --- a/tests/agent/runner.test.ts +++ b/tests/agent/runner.test.ts @@ -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[] = []; @@ -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, }); }), @@ -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 = { @@ -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"), }); }); diff --git a/tests/cli/runtime-integration.test.ts b/tests/cli/runtime-integration.test.ts index f0173bf6..774dbaf5 100644 --- a/tests/cli/runtime-integration.test.ts +++ b/tests/cli/runtime-integration.test.ts @@ -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(); @@ -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); diff --git a/tests/orchestrator/runtime-host.test.ts b/tests/orchestrator/runtime-host.test.ts index 5e281c1b..aa5300ef 100644 --- a/tests/orchestrator/runtime-host.test.ts +++ b/tests/orchestrator/runtime-host.test.ts @@ -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", }, @@ -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(); @@ -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, }); } diff --git a/tests/workspace/path-safety.test.ts b/tests/workspace/path-safety.test.ts index a78fd4e7..4113e4e5 100644 --- a/tests/workspace/path-safety.test.ts +++ b/tests/workspace/path-safety.test.ts @@ -12,9 +12,9 @@ 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"); }); @@ -22,12 +22,12 @@ describe("workspace path safety", () => { 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"), ); }); diff --git a/tests/workspace/workspace-manager.test.ts b/tests/workspace/workspace-manager.test.ts index b8778f17..2dc8a6bb 100644 --- a/tests/workspace/workspace-manager.test.ts +++ b/tests/workspace/workspace-manager.test.ts @@ -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"); }), ); }); @@ -28,10 +28,10 @@ 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); }); @@ -39,10 +39,10 @@ describe("WorkspaceManager", () => { 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); }); @@ -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]); }); @@ -98,8 +98,8 @@ 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]); @@ -107,10 +107,10 @@ describe("WorkspaceManager", () => { 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>({ code: ERROR_CODES.workspacePathInvalid, }), @@ -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, }); });