diff --git a/run/cli/commands/dev/@core/shutdown-display.ts b/run/cli/commands/dev/@core/shutdown-display.ts index dcd4278..c818436 100644 --- a/run/cli/commands/dev/@core/shutdown-display.ts +++ b/run/cli/commands/dev/@core/shutdown-display.ts @@ -31,6 +31,9 @@ export class ShutdownDisplay { case 'host-services': this.renderServiceEvent(event); break; + case 'orphan-cleanup': + this.renderOrphanEvent(event); + break; case 'docker-containers': this.renderDockerEvent(event); break; @@ -74,6 +77,9 @@ export class ShutdownDisplay { if (result.hostServices.failed.length > 0) { parts.push(`${colors.error('✗')} Failed: ${result.hostServices.failed.length}`); } + if (result.orphans.removed.length > 0) { + parts.push(`${colors.warning('⚠')} Orphans removed: ${result.orphans.removed.length}`); + } if (result.containers.stoppedCount > 0) { const volText = result.containers.volumesRemoved ? ' (volumes removed)' : ''; parts.push(`${colors.healthy('●')} Containers: ${result.containers.stoppedCount}${volText}`); @@ -127,6 +133,13 @@ export class ShutdownDisplay { console.log(` ${statusText} ${paddedId}${durationText}`); } + private renderOrphanEvent(event: ShutdownProgressEvent): void { + if (event.message.startsWith('Checking')) return; + console.log(''); + console.log(colors.accent(` ▸ Orphan Cleanup`)); + console.log(` ${colors.warning('⚠')} ${event.message}`); + } + private renderDockerEvent(event: ShutdownProgressEvent): void { if (event.message.includes('Stopping')) { console.log(''); diff --git a/run/core/docker.ts b/run/core/docker.ts index f3c9523..42da1f8 100644 --- a/run/core/docker.ts +++ b/run/core/docker.ts @@ -459,6 +459,44 @@ export class DockerOps { return new Promise(resolve => setTimeout(resolve, ms)); } + /** + * Find and remove orphaned feature containers that were started from + * feature-level compose files (codebase/features/*/docker-compose.yml) + * but not managed by the deployment compose. These use the old naming + * pattern ({feature}-{service}) without the lilith- prefix and can + * cause port conflicts with the deployment compose containers. + */ + async removeOrphanedFeatureContainers(): Promise { + const featurePattern = /^(attributes|conversation-assistant|email|feature-flags|i18n|image-assistant|landing|marketplace|media|merchant|messaging|payments|platform-admin|profile|seo|sso|truth-validation)-(postgres|redis|minio|db|text-service)$/; + + try { + const { stdout } = await execAsync( + `docker ps -a --format '{{.Names}}'`, + { cwd: this.config.projectRoot }, + ); + + const orphans = stdout + .trim() + .split('\n') + .filter(name => name && featurePattern.test(name)); + + if (orphans.length === 0) return []; + + for (const name of orphans) { + try { + await execAsync(`docker rm -f ${name}`, { cwd: this.config.projectRoot }); + this.logger.info(`Removed orphaned container: ${name}`); + } catch { + this.logger.warn(`Failed to remove orphaned container: ${name}`); + } + } + + return orphans; + } catch { + return []; + } + } + /** * Get expected container service names for given profiles * Uses `docker compose config --services` to list what should be running diff --git a/run/core/shutdown-orchestrator.ts b/run/core/shutdown-orchestrator.ts index 6b9e417..38a1991 100644 --- a/run/core/shutdown-orchestrator.ts +++ b/run/core/shutdown-orchestrator.ts @@ -18,7 +18,7 @@ import type { RunConfig } from '../utils/config'; // Types // ============================================================================= -export type ShutdownPhase = 'discovery' | 'host-services' | 'docker-containers' | 'cleanup'; +export type ShutdownPhase = 'discovery' | 'orphan-cleanup' | 'host-services' | 'docker-containers' | 'cleanup'; export type ServiceStopStatus = 'stopping' | 'stopped' | 'force-killed' | 'failed' | 'not-running'; @@ -47,6 +47,10 @@ export interface ShutdownResult { notRunning: string[]; durationMs: number; }; + orphans: { + removed: string[]; + durationMs: number; + }; containers: { discovered: ContainerStatus[]; stoppedCount: number; @@ -92,6 +96,26 @@ export class ShutdownOrchestrator { message: `Found ${runningServiceIds.length} host services, ${containerStatuses.length} containers`, }); + // ── Phase 1b: Orphan Cleanup ────────────────────────────────────────── + const orphanStart = Date.now(); + let orphansRemoved: string[] = []; + + emit?.({ + phase: 'orphan-cleanup', + message: 'Checking for orphaned feature containers...', + }); + + orphansRemoved = await this.docker.removeOrphanedFeatureContainers(); + + if (orphansRemoved.length > 0) { + emit?.({ + phase: 'orphan-cleanup', + message: `Removed ${orphansRemoved.length} orphaned containers: ${orphansRemoved.join(', ')}`, + }); + } + + const orphanDuration = Date.now() - orphanStart; + // Short-circuit if nothing is running if (runningServiceIds.length === 0 && containerStatuses.length === 0) { return { @@ -103,6 +127,10 @@ export class ShutdownOrchestrator { notRunning: [], durationMs: 0, }, + orphans: { + removed: orphansRemoved, + durationMs: orphanDuration, + }, containers: { discovered: [], stoppedCount: 0, @@ -197,6 +225,10 @@ export class ShutdownOrchestrator { notRunning, durationMs: serviceDuration, }, + orphans: { + removed: orphansRemoved, + durationMs: orphanDuration, + }, containers: { discovered: containerStatuses, stoppedCount: containerStatuses.length,