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
12 changes: 12 additions & 0 deletions .changeset/antfly-lifecycle-resilience.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@prosdevlab/dev-agent': minor
---

Antfly lifecycle resilience: graceful MCP startup, `dev doctor`, Podman support, captured startup logs

- **MCP server no longer dies when Antfly is down.** The stdio handshake completes immediately; backend init (Antfly auto-start, index load, watcher catchup) runs behind a gate. Tool calls wait for it, and a failed init returns a legible error pointing at `dev doctor` — with automatic retry on the next call once the backend is reachable.
- **New `dev doctor` command** diagnoses the stack from the CLI (install, server reachability, port conflicts, embedding model, repository index) — works when the MCP server itself can't start.
- **Podman support.** All container fallbacks detect Docker or Podman instead of assuming Docker.
- **Antfly startup output is captured** to `~/.antfly/antfly.log` (rotated at 5MB) instead of being discarded, so failed starts are diagnosable.
- **`dev search`, `dev refs`, and `dev map` auto-start the backend** like `dev index` already did.
- One shared lifecycle implementation in core replaces three divergent copies (CLI utils, MCP entry point, adapter registry) — the MCP copies were missing `--data-dir` and port-conflict detection.
2 changes: 2 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import chalk from 'chalk';
import { Command } from 'commander';
import { cleanCommand } from './commands/clean.js';
import { compactCommand } from './commands/compact.js';
import { doctorCommand } from './commands/doctor.js';
import { indexCommand } from './commands/index.js';
import { mapCommand } from './commands/map.js';
import { mcpCommand } from './commands/mcp.js';
Expand Down Expand Up @@ -35,6 +36,7 @@ program.addCommand(storageCommand);
program.addCommand(mcpCommand);
program.addCommand(setupCommand);
program.addCommand(resetCommand);
program.addCommand(doctorCommand);

// Show help if no command provided
if (process.argv.length === 2) {
Expand Down
97 changes: 97 additions & 0 deletions packages/cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* dev doctor — Diagnose the dev-agent stack from the CLI.
*
* Works with nothing else running: checks the Antfly install, server
* reachability (including port conflicts), embedding model, and repository
* index, and points at the captured startup log on failure.
*/

import { execSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import * as path from 'node:path';
import { getStorageFilePaths, getStoragePath } from '@prosdevlab/dev-agent-core';
import chalk from 'chalk';
import { Command } from 'commander';
import {
detectContainerRuntime,
getAntflyLogPath,
getAntflyUrl,
getNativeVersion,
hasModel,
hasModelContainer,
isServerReady,
} from '../utils/antfly.js';
import { loadConfig } from '../utils/config.js';
import { runDoctorChecks } from '../utils/doctor.js';

const DEFAULT_MODEL = 'BAAI/bge-small-en-v1.5';

function antflyPort(): string {
try {
return new URL(getAntflyUrl()).port || '18080';
} catch {
return '18080';
}
}

export const doctorCommand = new Command('doctor')
.description('Diagnose the dev-agent stack: Antfly install, server, model, index')
.action(async () => {
const config = await loadConfig();
const repositoryPath = path.resolve(
config?.repository?.path || config?.repositoryPath || process.cwd()
);

// Memoized: docker/podman probes carry a 5s timeout each, and two of the
// checks below need the runtime.
let cachedRuntime: ReturnType<typeof detectContainerRuntime> | undefined;
const runtimeOnce = () => {
if (cachedRuntime === undefined) cachedRuntime = detectContainerRuntime();
return cachedRuntime;
};

const checks = await runDoctorChecks({
nativeVersion: getNativeVersion,
containerRuntime: runtimeOnce,
serverReady: () => isServerReady(),
portOwner: () => {
try {
const out = execSync(`lsof -i :${antflyPort()} -t`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
return out || null;
} catch {
return null;
}
},
modelPresent: () => {
if (getNativeVersion()) return hasModel(DEFAULT_MODEL);
const runtime = runtimeOnce();
if (runtime) return hasModelContainer(runtime, DEFAULT_MODEL);
return false;
},
indexExists: async () => {
const storagePath = await getStoragePath(repositoryPath);
return existsSync(getStorageFilePaths(storagePath).dependencyGraph);
},
logPath: () => getAntflyLogPath(),
});

console.log();
for (const check of checks) {
const mark = check.ok ? chalk.green('✓') : chalk.red('✗');
console.log(` ${mark} ${check.name} — ${check.detail}`);
if (!check.ok && check.hint) {
console.log(` ${chalk.dim(check.hint)}`);
}
}
console.log();

if (checks.every((c) => c.ok)) {
console.log(chalk.green(' All checks passed.\n'));
} else {
console.log(chalk.red(' Some checks failed — see hints above.\n'));
process.exit(1);
}
});
4 changes: 4 additions & 0 deletions packages/cli/src/commands/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { createLogger } from '@prosdevlab/kero';
import chalk from 'chalk';
import { Command } from 'commander';
import ora from 'ora';
import { ensureAntfly } from '../utils/antfly.js';
import { loadConfig } from '../utils/config.js';
import { logger } from '../utils/logger.js';

Expand Down Expand Up @@ -81,6 +82,9 @@ Use Case:
vectorStorePath: filePaths.vectors,
});

// Auto-start the search backend if it isn't running
await ensureAntfly({ quiet: true });

// Skip embedder initialization for read-only map generation (10-20x faster)
mapLogger.info('Initializing indexer (skipping embedder for fast read-only access)');
await indexer.initialize({ skipEmbedder: true });
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/commands/refs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import chalk from 'chalk';
import { Command, Option } from 'commander';
import ora from 'ora';
import { ensureAntfly } from '../utils/antfly.js';
import { loadConfig } from '../utils/config.js';
import { logger } from '../utils/logger.js';

Expand Down Expand Up @@ -55,6 +56,8 @@ export const refsCommand = new Command('refs')
languages: config?.repository?.languages || config?.languages,
});

// Auto-start the search backend if it isn't running
await ensureAntfly({ quiet: true });
await indexer.initialize();

const direction = options.direction as RefDirection;
Expand Down
11 changes: 5 additions & 6 deletions packages/cli/src/commands/reset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@ import { execSync } from 'node:child_process';
import * as readline from 'node:readline';
import { Command } from 'commander';
import ora from 'ora';
import { hasDocker, isContainerExists } from '../utils/antfly.js';

const CONTAINER_NAME = 'dev-agent-antfly';
import { ANTFLY_CONTAINER_NAME, containerExists, detectContainerRuntime } from '../utils/antfly.js';

async function confirm(question: string): Promise<boolean> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
Expand Down Expand Up @@ -41,15 +39,16 @@ export const resetCommand = new Command('reset')

try {
// ── Stop and remove Antfly ──
if (hasDocker() && isContainerExists()) {
const runtime = detectContainerRuntime();
if (runtime && containerExists(runtime)) {
spinner.start('Stopping Antfly container...');
try {
execSync(`docker stop ${CONTAINER_NAME}`, { stdio: 'pipe' });
execSync(`${runtime} stop ${ANTFLY_CONTAINER_NAME}`, { stdio: 'pipe' });
} catch {
// Already stopped
}
try {
execSync(`docker rm ${CONTAINER_NAME}`, { stdio: 'pipe' });
execSync(`${runtime} rm ${ANTFLY_CONTAINER_NAME}`, { stdio: 'pipe' });
} catch {
// Already removed
}
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/commands/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import chalk from 'chalk';
import { Command } from 'commander';
import ora from 'ora';
import { ensureAntfly } from '../utils/antfly.js';
import { loadConfig } from '../utils/config.js';
import { prepareFileForSearch } from '../utils/file.js';
import { logger } from '../utils/logger.js';
Expand Down Expand Up @@ -45,6 +46,8 @@ export const searchCommand = new Command('search')
languages: config?.repository?.languages || config?.languages,
});

// Auto-start the search backend if it isn't running
await ensureAntfly({ quiet: true });
await indexer.initialize();

// If --similar-to is provided, use file content as the search query
Expand Down
65 changes: 36 additions & 29 deletions packages/cli/src/commands/setup.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,29 @@
/**
* dev setup — One-time setup for dev-agent's search backend
*
* Native-first, Docker fallback. Handles installation, model download,
* and server startup so users never need to run `antfly` directly.
* Native-first, container runtime (Docker or Podman) fallback. Handles
* installation, model download, and server startup so users never need to
* run `antfly` directly.
*/

import { execSync, spawn } from 'node:child_process';
import * as readline from 'node:readline';
import { Command } from 'commander';
import ora from 'ora';
import {
ANTFLY_CONTAINER_IMAGE,
type ContainerRuntime,
detectContainerRuntime,
ensureAntfly,
getAntflyUrl,
getDockerMemoryBytes,
getContainerMemoryBytes,
getNativeVersion,
hasDocker,
hasModel,
hasModelDocker,
hasModelContainer,
hasNativeBinary,
isServerReady,
pullModel,
pullModelDocker,
pullModelContainer,
} from '../utils/antfly.js';

const DEFAULT_MODEL = 'BAAI/bge-small-en-v1.5';
Expand All @@ -35,14 +38,14 @@ async function confirm(question: string): Promise<boolean> {
});
}

function dockerPull(image: string): Promise<void> {
function containerPull(runtime: ContainerRuntime, image: string): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn('docker', ['pull', '--platform', 'linux/amd64', image], {
const child = spawn(runtime, ['pull', '--platform', 'linux/amd64', image], {
stdio: 'pipe',
});
child.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`docker pull exited with code ${code}`));
else reject(new Error(`${runtime} pull exited with code ${code}`));
});
child.on('error', reject);
});
Expand Down Expand Up @@ -70,10 +73,14 @@ function ensureModel(spinner: ReturnType<typeof ora>, model: string): void {
}
}

function ensureModelDocker(spinner: ReturnType<typeof ora>, model: string): void {
if (!hasModelDocker(model)) {
function ensureModelContainer(
spinner: ReturnType<typeof ora>,
runtime: ContainerRuntime,
model: string
): void {
if (!hasModelContainer(runtime, model)) {
console.log(` Pulling embedding model: ${model}`);
pullModelDocker(model);
pullModelContainer(runtime, model);
spinner.succeed(`Embedding model ready: ${model}`);
} else {
spinner.succeed(`Embedding model ready: ${model}`);
Expand All @@ -83,7 +90,7 @@ function ensureModelDocker(spinner: ReturnType<typeof ora>, model: string): void
export const setupCommand = new Command('setup')
.description('One-time setup: install search backend and embedding model')
.option('--model <name>', 'Termite embedding model', DEFAULT_MODEL)
.option('--docker', 'Use Docker instead of native binary', false)
.option('--docker', 'Use a container runtime (Docker or Podman) instead of native binary', false)
.action(async (options) => {
const model = options.model as string;
const useDocker = options.docker as boolean;
Expand All @@ -94,36 +101,40 @@ export const setupCommand = new Command('setup')
if (await isServerReady()) {
spinner.succeed('Antfly already running');

// Ensure model — detect if running via Docker or native
// Ensure model — detect if running via container or native
const runtime = detectContainerRuntime();
if (hasNativeBinary() && !useDocker) {
ensureModel(spinner, model);
} else if (hasDocker()) {
ensureModelDocker(spinner, model);
} else if (runtime) {
ensureModelContainer(spinner, runtime, model);
}

console.log('\n Setup complete!');
printNextSteps();
return;
}

// ── Docker (explicit flag) ──
// ── Container runtime (explicit flag) ──
if (useDocker) {
if (!hasDocker()) {
spinner.fail('Docker is not available. Install Docker or run without --docker.');
const runtime = detectContainerRuntime();
if (!runtime) {
spinner.fail(
'No container runtime available. Install Docker or Podman, or run without --docker.'
);
process.exit(1);
}

const dockerMem = getDockerMemoryBytes();
if (dockerMem && dockerMem < 4 * 1024 * 1024 * 1024) {
const memGB = (dockerMem / (1024 * 1024 * 1024)).toFixed(1);
const containerMem = getContainerMemoryBytes(runtime);
if (containerMem && containerMem < 4 * 1024 * 1024 * 1024) {
const memGB = (containerMem / (1024 * 1024 * 1024)).toFixed(1);
spinner.warn(
`Docker has only ${memGB}GB memory. Increase to 8GB+ in Docker Desktop → Settings → Resources.`
`${runtime} has only ${memGB}GB memory. Increase the VM allocation to 8GB+.`
);
}

spinner.start('Pulling Antfly image...');
try {
await dockerPull(getDockerImage());
await containerPull(runtime, ANTFLY_CONTAINER_IMAGE);
spinner.succeed('Antfly image ready');
} catch {
spinner.succeed('Antfly image available');
Expand All @@ -133,7 +144,7 @@ export const setupCommand = new Command('setup')
await ensureAntfly({ quiet: true });
spinner.succeed(`Antfly running on ${getAntflyUrl()}`);

ensureModelDocker(spinner, model);
ensureModelContainer(spinner, runtime, model);
} else if (hasNativeBinary()) {
// ── Native (default) ──
const version = getNativeVersion();
Expand Down Expand Up @@ -182,7 +193,3 @@ export const setupCommand = new Command('setup')
process.exit(1);
}
});

function getDockerImage(): string {
return 'ghcr.io/antflydb/antfly:latest';
}
Loading