feat(core/shutdown): Enhance shutdown feedback & Docker container cleanup during graceful exits

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Quinn Ftw 2026-02-01 18:36:49 -08:00
parent fa539a1ab0
commit b8ab200016
3 changed files with 84 additions and 1 deletions

View file

@ -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('');

View file

@ -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<string[]> {
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

View file

@ -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,