diff --git a/src/services/coder.ts b/src/services/coder.ts index 9086818..5539b74 100644 --- a/src/services/coder.ts +++ b/src/services/coder.ts @@ -50,16 +50,36 @@ export class CoderClient { }, ); - // Handle 409 conflict — stale workspace from a previous orchestrator instance + // Handle 409 conflict — workspace with this name already exists. + // This happens when the orchestrator restarts and loses in-memory state. + // Only delete if the workspace is old (>30 min) or stopped/failed. + // If it's young and running, it may be mid-task — skip to avoid data loss. if (res.status === 409) { - console.log(`[coder] workspace "${name}" already exists — deleting stale workspace and retrying`); const existing = await this.findWorkspaceByName(name); - if (existing) { - await this.deleteWorkspace(existing.id); - // Wait for deletion to propagate - await new Promise((resolve) => setTimeout(resolve, 5000)); + if (!existing) { + throw new Error(`Coder 409 but workspace "${name}" not found — possible race condition`); } - return this.createWorkspace(task); + + const ageMs = Date.now() - new Date(existing.createdAt).getTime(); + const ageMin = Math.round(ageMs / 60_000); + const stoppedStatuses = ["stopped", "failed", "canceled", "deleted"]; + const isStopped = stoppedStatuses.includes(existing.latestBuildStatus); + const isStale = ageMin > 30; + + if (isStopped || isStale) { + console.log( + `[coder] workspace "${name}" already exists (age: ${ageMin}m, status: ${existing.latestBuildStatus}) — deleting and retrying`, + ); + await this.deleteWorkspace(existing.id); + await new Promise((resolve) => setTimeout(resolve, 5000)); + return this.createWorkspace(task); + } + + // Workspace is young and still running — don't kill it + console.log( + `[coder] workspace "${name}" is active (age: ${ageMin}m, status: ${existing.latestBuildStatus}) — skipping creation`, + ); + return { id: existing.id, name: existing.name }; } if (!res.ok) { @@ -107,7 +127,12 @@ export class CoderClient { } } - async findWorkspaceByName(name: string): Promise<{ id: string } | null> { + async findWorkspaceByName(name: string): Promise<{ + id: string; + name: string; + createdAt: string; + latestBuildStatus: string; + } | null> { const res = await fetch( `${this.baseUrl}/api/v2/workspaces?q=name:${encodeURIComponent(name)}`, { @@ -119,8 +144,21 @@ export class CoderClient { if (!res.ok) return null; - const data = (await res.json()) as { workspaces: Array<{ id: string; name: string }> }; + const data = (await res.json()) as { + workspaces: Array<{ + id: string; + name: string; + created_at: string; + latest_build: { status: string }; + }>; + }; const match = data.workspaces?.find((w) => w.name === name); - return match ? { id: match.id } : null; + if (!match) return null; + return { + id: match.id, + name: match.name, + createdAt: match.created_at, + latestBuildStatus: match.latest_build?.status ?? "unknown", + }; } }