refactor(cli): remove lifecycle-worker subprocess, poll in-process (#1186)

* refactor(cli): remove lifecycle-worker subprocess model, run polling in-process

Replaces the per-project `ao lifecycle-worker` subprocess with an in-process
polling loop managed inside `lifecycle-service`. All registered projects are
now polled from the single long-lived `ao start` process.

- Drops the `lifecycle-worker` command and its registration.
- Rewrites `lifecycle-service` around a `Map<projectId, ActiveLoop>`,
  with SIGINT/SIGTERM/beforeExit graceful shutdown.
- Removes PID-file coordination (PID file, log file, status, etc.) —
  these only existed to track subprocess state.
- Per-project error isolation is preserved: the core lifecycle manager's
  `pollAll()` already catches per-cycle errors, and stop failures in one
  project can't prevent others from stopping.
- Updates `start`/`spawn` callers and tests to drop the PID/logFile shape.
- Adds `lifecycle-service.test.ts` covering idempotency, unknown projects,
  error isolation across projects, and graceful stop-all.

Closes #1185

* fix(cli): address bugbot review on lifecycle-service in-process refactor

- `ao spawn` / `ao batch-spawn` no longer call `ensureLifecycleWorker`.
  That call used to start `setInterval` polling in the one-shot spawn
  process, which (a) kept the CLI alive forever after the session spawned
  and (b) duplicated polling already running in `ao start`. Replaced with
  a `warnIfAONotRunning()` helper that checks `running.json` and prints a
  hint if the orchestrator isn't up.
- `ao stop` no longer calls `stopLifecycleWorker`. That call always ran
  against a fresh in-memory map (stop is a separate process from start),
  so it always returned false and printed "Lifecycle worker not running"
  misleadingly. SIGTERM to the `ao start` PID already triggers the shared
  shutdown handler in `lifecycle-service`, which stops every loop.
- Drop duplicated shutdown closure inside `registerSignalsOnce` — signal
  handlers now reference `stopAllLifecycleWorkers` directly.
- Update tests accordingly: spawn.test.ts mocks `running-state.getRunning`,
  start.test.ts drops `stopLifecycleWorker` expectations.

* fix(cli): drop SIGINT/SIGTERM listeners from lifecycle-service

Installing listeners for those signals removes Node.js's default
"exit on signal" behavior (per Node docs: "its default behavior will
be removed — Node.js will no longer exit"). Since the registered
listener doesn't call process.exit(), the `ao start` process would
hang on SIGTERM with the setInterval timer keeping the event loop
alive forever — effectively breaking `ao stop`.

Default signal handling terminates the process cleanly; the OS
reclaims the interval timer and dashboard child. `stopAllLifecycleWorkers`
stays exported for callers that want explicit cleanup before exit.

* fix(cli): project-scoped spawn warning + flush lifecycle health on exit

Addresses reviewer feedback on PR #1186:

- `warnIfAONotRunning` now takes a projectId and warns not only when no
  AO is running, but also when the running instance isn't polling the
  target project (e.g. `ao start A` then `ao spawn` in B left users
  silent about the fact that B wasn't being polled).
- `running.json` now records only the project this `ao start` actually
  polls, not every project in config. Previously this list was a lie —
  `ensureLifecycleWorker` is called for the selected project only.
- `ao start` installs SIGINT/SIGTERM handlers that call
  `stopAllLifecycleWorkers()` (flushing per-project "stopped" health
  state) and then `process.exit()`. Installing the handler safely
  requires an explicit exit because SIGINT/SIGTERM listeners remove
  Node's default exit behavior.

* fix(cli): remove dead stopLifecycleWorker, add missing test mock

Addresses bugbot comments on PR #1186:

- Delete `stopLifecycleWorker` from `lifecycle-service.ts`. It was only
  called from `ao stop` in the old subprocess model; in the in-process
  model, SIGTERM to the `ao start` pid + the shutdown handler in
  `start.ts` covers cleanup. No production caller remains.
- Add `stopAllLifecycleWorkers: vi.fn()` to `start.test.ts`'s
  `lifecycle-service.js` mock. Without it, `vi.mock` replaced the module
  and the named import resolved to undefined; the shutdown handler's
  try/catch would silently swallow the resulting TypeError, hiding any
  regression in how shutdown is wired.
- Update `lifecycle-service.test.ts` to drop references to the removed
  export (one test removed, one repurposed as a no-op smoke test for
  `stopAllLifecycleWorkers` against an empty active map).
This commit is contained in:
Harsh Batheja 2026-04-14 17:23:33 +05:30 committed by GitHub
parent 3540e512b6
commit 95fbecc379
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 292 additions and 429 deletions

View File

@ -4,7 +4,7 @@ import { join } from "node:path";
import { tmpdir } from "node:os";
import { type Session, type SessionManager, getProjectBaseDir } from "@aoagents/ao-core";
const { mockExec, mockConfigRef, mockSessionManager, mockEnsureLifecycleWorker } = vi.hoisted(
const { mockExec, mockConfigRef, mockSessionManager, mockGetRunning } = vi.hoisted(
() => ({
mockExec: vi.fn(),
mockConfigRef: { current: null as Record<string, unknown> | null },
@ -18,7 +18,7 @@ const { mockExec, mockConfigRef, mockSessionManager, mockEnsureLifecycleWorker }
send: vi.fn(),
claimPR: vi.fn(),
},
mockEnsureLifecycleWorker: vi.fn(),
mockGetRunning: vi.fn(),
}),
);
@ -56,8 +56,8 @@ vi.mock("../../src/lib/create-session-manager.js", () => ({
getSessionManager: async (): Promise<SessionManager> => mockSessionManager as SessionManager,
}));
vi.mock("../../src/lib/lifecycle-service.js", () => ({
ensureLifecycleWorker: (...args: unknown[]) => mockEnsureLifecycleWorker(...args),
vi.mock("../../src/lib/running-state.js", () => ({
getRunning: () => mockGetRunning(),
}));
vi.mock("../../src/lib/metadata.js", () => ({
@ -121,14 +121,8 @@ beforeEach(() => {
mockSessionManager.spawn.mockReset();
mockSessionManager.claimPR.mockReset();
mockExec.mockReset();
mockEnsureLifecycleWorker.mockReset();
mockEnsureLifecycleWorker.mockResolvedValue({
running: true,
started: true,
pid: 12345,
pidFile: "/tmp/lifecycle-worker.pid",
logFile: "/tmp/lifecycle-worker.log",
});
mockGetRunning.mockReset();
mockGetRunning.mockResolvedValue({ pid: 1234, port: 3000, startedAt: "", projects: ["my-app"] });
});
afterEach(() => {
@ -165,11 +159,6 @@ describe("spawn command", () => {
// Single arg = issue; project is auto-detected (only one project in config)
await program.parseAsync(["node", "test", "spawn", "INT-100"]);
expect(mockEnsureLifecycleWorker).toHaveBeenCalledWith(
expect.objectContaining({ configPath: expect.any(String) }),
"my-app",
);
expect(mockSessionManager.spawn).toHaveBeenCalledWith({
projectId: "my-app",
issueId: "INT-100",
@ -248,10 +237,6 @@ describe("spawn command", () => {
await program.parseAsync(["node", "test", "spawn", "INT-42"]);
expect(mockEnsureLifecycleWorker).toHaveBeenCalledWith(
expect.objectContaining({ configPath: expect.any(String) }),
"backend",
);
expect(mockSessionManager.spawn).toHaveBeenCalledWith({
projectId: "backend",
issueId: "INT-42",

View File

@ -24,7 +24,6 @@ const {
mockWaitForPortAndOpen,
mockSpawn,
mockEnsureLifecycleWorker,
mockStopLifecycleWorker,
} = vi.hoisted(() => ({
mockExec: vi.fn(),
mockExecSilent: vi.fn(),
@ -42,7 +41,6 @@ const {
mockWaitForPortAndOpen: vi.fn().mockResolvedValue(undefined),
mockSpawn: vi.fn(),
mockEnsureLifecycleWorker: vi.fn(),
mockStopLifecycleWorker: vi.fn(),
}));
const { mockDetectOpenClawInstallation } = vi.hoisted(() => ({
@ -101,7 +99,7 @@ vi.mock("../../src/lib/create-session-manager.js", () => ({
vi.mock("../../src/lib/lifecycle-service.js", () => ({
ensureLifecycleWorker: (...args: unknown[]) => mockEnsureLifecycleWorker(...args),
stopLifecycleWorker: (...args: unknown[]) => mockStopLifecycleWorker(...args),
stopAllLifecycleWorkers: vi.fn(),
}));
vi.mock("../../src/lib/web-dir.js", () => ({
@ -238,12 +236,7 @@ beforeEach(() => {
mockEnsureLifecycleWorker.mockResolvedValue({
running: true,
started: true,
pid: 12345,
pidFile: "/tmp/lifecycle-worker.pid",
logFile: "/tmp/lifecycle-worker.log",
});
mockStopLifecycleWorker.mockReset();
mockStopLifecycleWorker.mockResolvedValue(true);
mockDetectOpenClawInstallation.mockReset();
mockDetectOpenClawInstallation.mockResolvedValue({
state: "missing",
@ -1039,10 +1032,6 @@ describe("stop command", () => {
expect(mockSessionManager.kill).toHaveBeenCalledWith("app-orchestrator", {
purgeOpenCode: false,
});
expect(mockStopLifecycleWorker).toHaveBeenCalledWith(
expect.objectContaining({ configPath: expect.any(String) }),
"my-app",
);
const output = vi
.mocked(console.log)
.mock.calls.map((c) => c.join(" "))
@ -1058,10 +1047,6 @@ describe("stop command", () => {
await program.parseAsync(["node", "test", "stop"]);
expect(mockSessionManager.kill).not.toHaveBeenCalled();
expect(mockStopLifecycleWorker).toHaveBeenCalledWith(
expect.objectContaining({ configPath: expect.any(String) }),
"my-app",
);
const output = vi
.mocked(console.log)
.mock.calls.map((c) => c.join(" "))

View File

@ -0,0 +1,147 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { LifecycleManager, OrchestratorConfig } from "@aoagents/ao-core";
const mockGetLifecycleManager = vi.fn();
vi.mock("../../src/lib/create-session-manager.js", () => ({
getLifecycleManager: (...args: unknown[]) => mockGetLifecycleManager(...args),
}));
// Import after mocks
import {
ensureLifecycleWorker,
stopAllLifecycleWorkers,
isLifecycleWorkerRunning,
listLifecycleWorkers,
} from "../../src/lib/lifecycle-service.js";
function makeConfig(projectIds: string[]): OrchestratorConfig {
return {
configPath: "/tmp/agent-orchestrator.yaml",
port: 3000,
readyThresholdMs: 300_000,
defaults: { runtime: "tmux", agent: "claude-code", workspace: "worktree", notifiers: [] },
projects: Object.fromEntries(
projectIds.map((id) => [id, { name: id, repo: "", path: "/tmp", defaultBranch: "main" }]),
),
notifiers: {},
notificationRouting: {},
reactions: {},
} as OrchestratorConfig;
}
function makeFakeLifecycle(overrides?: Partial<LifecycleManager>): LifecycleManager & {
start: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
} {
const start = vi.fn();
const stop = vi.fn();
return { start, stop, ...overrides } as unknown as LifecycleManager & {
start: typeof start;
stop: typeof stop;
};
}
describe("lifecycle-service", () => {
beforeEach(() => {
stopAllLifecycleWorkers();
mockGetLifecycleManager.mockReset();
});
afterEach(() => {
stopAllLifecycleWorkers();
});
it("starts polling in-process for a known project", async () => {
const lifecycle = makeFakeLifecycle();
mockGetLifecycleManager.mockResolvedValue(lifecycle);
const result = await ensureLifecycleWorker(makeConfig(["app"]), "app", 1000);
expect(result).toEqual({ running: true, started: true });
expect(lifecycle.start).toHaveBeenCalledWith(1000);
expect(isLifecycleWorkerRunning("app")).toBe(true);
});
it("is idempotent — second ensure is a no-op", async () => {
const lifecycle = makeFakeLifecycle();
mockGetLifecycleManager.mockResolvedValue(lifecycle);
const first = await ensureLifecycleWorker(makeConfig(["app"]), "app");
const second = await ensureLifecycleWorker(makeConfig(["app"]), "app");
expect(first.started).toBe(true);
expect(second.started).toBe(false);
expect(lifecycle.start).toHaveBeenCalledTimes(1);
});
it("throws on unknown projects", async () => {
await expect(
ensureLifecycleWorker(makeConfig(["app"]), "missing"),
).rejects.toThrow(/Unknown project/);
});
it("isolates errors: one project failing to start does not affect another", async () => {
const healthy = makeFakeLifecycle();
mockGetLifecycleManager.mockImplementation(async (_config, projectId: string) => {
if (projectId === "broken") {
throw new Error("boom — broken project plugin");
}
return healthy;
});
const config = makeConfig(["healthy", "broken"]);
await expect(ensureLifecycleWorker(config, "broken")).rejects.toThrow(/boom/);
expect(isLifecycleWorkerRunning("broken")).toBe(false);
const result = await ensureLifecycleWorker(config, "healthy");
expect(result.started).toBe(true);
expect(healthy.start).toHaveBeenCalledTimes(1);
expect(isLifecycleWorkerRunning("healthy")).toBe(true);
});
it("stopAllLifecycleWorkers is a no-op when nothing is active", () => {
expect(() => stopAllLifecycleWorkers()).not.toThrow();
expect(listLifecycleWorkers()).toEqual([]);
});
it("stopAllLifecycleWorkers stops every registered project", async () => {
const a = makeFakeLifecycle();
const b = makeFakeLifecycle();
mockGetLifecycleManager.mockImplementation(async (_cfg, projectId: string) =>
projectId === "a" ? a : b,
);
const config = makeConfig(["a", "b"]);
await ensureLifecycleWorker(config, "a");
await ensureLifecycleWorker(config, "b");
expect(listLifecycleWorkers().sort()).toEqual(["a", "b"]);
stopAllLifecycleWorkers();
expect(a.stop).toHaveBeenCalledTimes(1);
expect(b.stop).toHaveBeenCalledTimes(1);
expect(listLifecycleWorkers()).toEqual([]);
});
it("a throwing stop on one project does not prevent others from stopping", async () => {
const broken = makeFakeLifecycle();
(broken.stop as ReturnType<typeof vi.fn>).mockImplementation(() => {
throw new Error("stop failed");
});
const healthy = makeFakeLifecycle();
mockGetLifecycleManager.mockImplementation(async (_cfg, projectId: string) =>
projectId === "broken" ? broken : healthy,
);
const config = makeConfig(["broken", "healthy"]);
await ensureLifecycleWorker(config, "broken");
await ensureLifecycleWorker(config, "healthy");
expect(() => stopAllLifecycleWorkers()).not.toThrow();
expect(healthy.stop).toHaveBeenCalledTimes(1);
expect(listLifecycleWorkers()).toEqual([]);
});
});

View File

@ -1,139 +0,0 @@
import type { Command } from "commander";
import chalk from "chalk";
import { createCorrelationId, createProjectObserver, loadConfig } from "@aoagents/ao-core";
import { getLifecycleManager } from "../lib/create-session-manager.js";
import {
clearLifecycleWorkerPid,
getLifecycleWorkerStatus,
writeLifecycleWorkerPid,
} from "../lib/lifecycle-service.js";
function parseInterval(value: string): number {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 30_000;
}
export function registerLifecycleWorker(program: Command): void {
program
.command("lifecycle-worker")
.description("Internal lifecycle polling worker")
.argument("<project>", "Project ID from config")
.option("--interval-ms <ms>", "Polling interval in milliseconds", "30000")
.action(async (projectId: string, opts: { intervalMs?: string }) => {
const config = loadConfig();
const observer = createProjectObserver(config, "lifecycle-worker");
if (!config.projects[projectId]) {
observer.setHealth({
surface: "lifecycle.worker",
status: "error",
projectId,
correlationId: createCorrelationId("lifecycle-worker"),
reason: `Unknown project: ${projectId}`,
details: { projectId },
});
console.error(chalk.red(`Unknown project: ${projectId}`));
process.exit(1);
}
const existing = getLifecycleWorkerStatus(config, projectId);
if (existing.running && existing.pid !== process.pid) {
// Another lifecycle worker is already running for this project — exit
// silently to avoid duplicate polling loops.
observer.setHealth({
surface: "lifecycle.worker",
status: "warn",
projectId,
correlationId: createCorrelationId("lifecycle-worker"),
reason: `Worker already running with pid ${existing.pid}`,
details: { projectId, pid: existing.pid },
});
return;
}
const lifecycle = await getLifecycleManager(config, projectId);
const intervalMs = parseInterval(opts.intervalMs ?? "30000");
let shuttingDown = false;
let heartbeat: ReturnType<typeof setInterval> | null = null;
const shutdown = (code: number): void => {
if (shuttingDown) return;
shuttingDown = true;
if (heartbeat) clearInterval(heartbeat);
lifecycle.stop();
clearLifecycleWorkerPid(config, projectId, process.pid);
observer.setHealth({
surface: "lifecycle.worker",
status: code === 0 ? "warn" : "error",
projectId,
correlationId: createCorrelationId("lifecycle-worker"),
reason: code === 0 ? "Worker stopped" : "Worker exited unexpectedly",
details: { projectId, pid: process.pid, exitCode: code },
});
// Flush stdout/stderr before exiting so crash messages reach the log file
const done = (): void => process.exit(code);
if (process.stdout.writableFinished && process.stderr.writableFinished) {
done();
} else {
let flushed = 0;
const tryExit = (): void => {
flushed++;
if (flushed >= 2) done();
};
process.stdout.write("", tryExit);
process.stderr.write("", tryExit);
// Hard exit if flush hangs
setTimeout(done, 1_000).unref();
}
};
process.on("SIGINT", () => shutdown(0));
process.on("SIGTERM", () => shutdown(0));
process.on("uncaughtException", (err) => {
observer.recordOperation({
metric: "lifecycle_poll",
operation: "lifecycle.worker_crash",
outcome: "failure",
correlationId: createCorrelationId("lifecycle-worker"),
projectId,
reason: err instanceof Error ? err.message : String(err),
level: "error",
});
shutdown(1);
});
process.on("unhandledRejection", (reason) => {
observer.recordOperation({
metric: "lifecycle_poll",
operation: "lifecycle.worker_rejection",
outcome: "failure",
correlationId: createCorrelationId("lifecycle-worker"),
projectId,
reason: reason instanceof Error ? reason.message : String(reason),
level: "error",
});
shutdown(1);
});
writeLifecycleWorkerPid(config, projectId, process.pid);
observer.setHealth({
surface: "lifecycle.worker",
status: "ok",
projectId,
correlationId: createCorrelationId("lifecycle-worker"),
details: { projectId, pid: process.pid, intervalMs },
});
// Periodic heartbeat so we can verify the worker is alive from the log
heartbeat = setInterval(() => {
observer.setHealth({
surface: "lifecycle.worker",
status: "ok",
projectId,
correlationId: createCorrelationId("lifecycle-worker"),
details: { projectId, pid: process.pid, intervalMs, heartbeat: true },
});
}, 5 * 60_000); // every 5 minutes
heartbeat.unref();
lifecycle.start(intervalMs);
});
}

View File

@ -11,9 +11,9 @@ import { DEFAULT_PORT } from "../lib/constants.js";
import { exec } from "../lib/shell.js";
import { banner } from "../lib/format.js";
import { getSessionManager } from "../lib/create-session-manager.js";
import { ensureLifecycleWorker } from "../lib/lifecycle-service.js";
import { preflight } from "../lib/preflight.js";
import { findProjectForDirectory } from "../lib/project-resolution.js";
import { getRunning } from "../lib/running-state.js";
/**
* Auto-detect the project ID from the config.
@ -54,6 +54,32 @@ interface SpawnClaimOptions {
assignOnGithub?: boolean;
}
/**
* Lifecycle polling runs in-process inside the long-lived `ao start` process.
* `ao spawn` is a one-shot CLI it can't start polling in its own process
* (the interval would keep the CLI alive forever and duplicate work). Warn
* when no `ao start` is running, or when the running instance isn't covering
* this project (e.g. `ao start A` then `ao spawn` in B).
*/
async function warnIfAONotRunning(projectId: string): Promise<void> {
const running = await getRunning();
if (!running) {
console.log(
chalk.yellow(
"⚠ AO is not running — lifecycle polling is inactive. Run `ao start` so the new session is tracked.",
),
);
return;
}
if (!running.projects.includes(projectId)) {
console.log(
chalk.yellow(
`⚠ The running AO instance (pid ${running.pid}) is not polling project "${projectId}". Run \`ao start ${projectId}\` so the new session is tracked.`,
),
);
}
}
/**
* Run pre-flight checks for a project once, before any sessions are spawned.
* Validates runtime and tracker prerequisites so failures surface immediately
@ -230,7 +256,7 @@ export function registerSpawn(program: Command): void {
try {
await runSpawnPreflight(config, projectId, claimOptions);
await ensureLifecycleWorker(config, projectId);
await warnIfAONotRunning(projectId);
await spawnSession(config, projectId, issueId, opts.open, opts.agent, claimOptions, opts.prompt);
} catch (err) {
@ -276,7 +302,7 @@ export function registerBatchSpawn(program: Command): void {
// Pre-flight once before the loop so a missing prerequisite fails fast
try {
await runSpawnPreflight(config, projectId);
await ensureLifecycleWorker(config, projectId);
await warnIfAONotRunning(projectId);
} catch (err) {
console.error(chalk.red(`${err instanceof Error ? err.message : String(err)}`));
process.exit(1);

View File

@ -38,7 +38,7 @@ import {
import { parse as yamlParse, stringify as yamlStringify } from "yaml";
import { exec, execSilent, git } from "../lib/shell.js";
import { getSessionManager } from "../lib/create-session-manager.js";
import { ensureLifecycleWorker, stopLifecycleWorker } from "../lib/lifecycle-service.js";
import { ensureLifecycleWorker, stopAllLifecycleWorkers } from "../lib/lifecycle-service.js";
import {
findWebDir,
buildDashboardEnv,
@ -993,8 +993,8 @@ async function runStartup(
lifecycleStatus = await ensureLifecycleWorker(config, projectId);
spinner.succeed(
lifecycleStatus.started
? `Lifecycle worker started${lifecycleStatus.pid ? ` (PID ${lifecycleStatus.pid})` : ""}`
: `Lifecycle worker already running${lifecycleStatus.pid ? ` (PID ${lifecycleStatus.pid})` : ""}`,
? "Lifecycle polling started"
: "Lifecycle polling already running",
);
} catch (err) {
spinner.fail("Lifecycle worker failed to start");
@ -1090,10 +1090,7 @@ async function runStartup(
if (shouldStartLifecycle && lifecycleStatus) {
const lifecycleLabel = lifecycleStatus.started ? "started" : "already running";
const lifecycleTarget = lifecycleStatus.pid
? `${lifecycleLabel} (PID ${lifecycleStatus.pid})`
: lifecycleLabel;
console.log(chalk.cyan("Lifecycle:"), lifecycleTarget);
console.log(chalk.cyan("Lifecycle:"), lifecycleLabel);
}
if (hasExistingOrchestrators) {
@ -1370,13 +1367,35 @@ export function registerStart(program: Command): void {
const actualPort = await runStartup(config, projectId, project, opts);
// ── Register in running.json (Step 11) ──
// Only record the project this invocation actually polls. Other
// configured projects are not covered by this lifecycle loop, and
// `ao spawn` relies on this list to decide whether to warn users.
await register({
pid: process.pid,
configPath: config.configPath,
port: actualPort,
startedAt: new Date().toISOString(),
projects: Object.keys(config.projects),
projects: [projectId],
});
// Install shutdown handlers so `ao stop` (which sends SIGTERM to
// this pid) flushes lifecycle health state before exit. Handlers
// MUST call process.exit() — installing a SIGINT/SIGTERM listener
// removes Node's default exit behavior, so without an explicit
// exit the interval timer would keep the event loop alive.
let shuttingDown = false;
const shutdown = (signal: NodeJS.Signals): void => {
if (shuttingDown) return;
shuttingDown = true;
try {
stopAllLifecycleWorkers();
} catch {
// Best-effort cleanup — never block shutdown on observability.
}
process.exit(signal === "SIGINT" ? 130 : 0);
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
} catch (err) {
if (err instanceof Error) {
console.error(chalk.red("\nError:"), err.message);
@ -1451,12 +1470,11 @@ export function registerStop(program: Command): void {
console.log(chalk.yellow(`Orchestrator session "${sessionId}" is not running`));
}
const lifecycleStopped = await stopLifecycleWorker(config, _projectId);
if (lifecycleStopped) {
console.log(chalk.green("Lifecycle worker stopped"));
} else {
console.log(chalk.yellow("Lifecycle worker not running"));
}
// Lifecycle polling runs in-process inside the `ao start` process
// (registered via `running.json`). Sending SIGTERM to that PID below
// triggers the shared shutdown handler in `lifecycle-service`, which
// stops every per-project loop. No explicit stop call needed here —
// this CLI invocation is a separate process with an empty active map.
// Stop dashboard — kill parent PID from running.json, then also stop
// any dashboard child process via lsof (parent SIGTERM may not propagate)

View File

@ -1,254 +1,97 @@
import { spawn } from "node:child_process";
import {
closeSync,
existsSync,
mkdirSync,
openSync,
readFileSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { getProjectBaseDir, type OrchestratorConfig } from "@aoagents/ao-core";
createCorrelationId,
createProjectObserver,
type LifecycleManager,
type OrchestratorConfig,
} from "@aoagents/ao-core";
import { getLifecycleManager } from "./create-session-manager.js";
const LIFECYCLE_PID_FILE = "lifecycle-worker.pid";
const LIFECYCLE_LOG_FILE = "lifecycle-worker.log";
const DEFAULT_START_TIMEOUT_MS = 5_000;
const STOP_TIMEOUT_MS = 5_000;
const DEFAULT_INTERVAL_MS = 30_000;
interface ActiveLoop {
lifecycle: LifecycleManager;
stop: () => void;
}
const active = new Map<string, ActiveLoop>();
// Note: no SIGINT/SIGTERM listeners are installed here. Adding a listener for
// those signals removes Node.js's default "exit on signal" behavior, which
// would leave `ao start` hanging when `ao stop` sends SIGTERM (the setInterval
// keeps the event loop alive forever). Default signal handling terminates the
// process cleanly; the OS reclaims the interval timer. Callers that need to
// flush state explicitly before exit can call `stopAllLifecycleWorkers()`.
export interface LifecycleWorkerStatus {
running: boolean;
pid: number | null;
pidFile: string;
logFile: string;
}
function getProjectBase(config: OrchestratorConfig, projectId: string): string {
const project = config.projects[projectId];
if (!project) {
throw new Error(`Unknown project: ${projectId}`);
}
return getProjectBaseDir(config.configPath, project.path);
}
export function getLifecyclePidFile(config: OrchestratorConfig, projectId: string): string {
return join(getProjectBase(config, projectId), LIFECYCLE_PID_FILE);
}
export function getLifecycleLogFile(config: OrchestratorConfig, projectId: string): string {
return join(getProjectBase(config, projectId), LIFECYCLE_LOG_FILE);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (err) {
if (
err &&
typeof err === "object" &&
"code" in err &&
(err as NodeJS.ErrnoException).code === "EPERM"
) {
return true;
}
return false;
}
}
function readPid(pidFile: string): number | null {
if (!existsSync(pidFile)) return null;
try {
const raw = readFileSync(pidFile, "utf-8").trim();
const pid = Number.parseInt(raw, 10);
return Number.isFinite(pid) && pid > 0 ? pid : null;
} catch {
return null;
}
}
export function writeLifecycleWorkerPid(
config: OrchestratorConfig,
projectId: string,
pid: number,
): void {
const pidFile = getLifecyclePidFile(config, projectId);
mkdirSync(getProjectBase(config, projectId), { recursive: true });
writeFileSync(pidFile, `${pid}\n`, "utf-8");
}
export function clearLifecycleWorkerPid(
config: OrchestratorConfig,
projectId: string,
pid?: number,
): void {
const pidFile = getLifecyclePidFile(config, projectId);
if (!existsSync(pidFile)) return;
if (pid !== undefined) {
const currentPid = readPid(pidFile);
if (currentPid !== null && currentPid !== pid) {
return;
}
}
try {
unlinkSync(pidFile);
} catch {
// Best effort cleanup
}
}
export function getLifecycleWorkerStatus(
config: OrchestratorConfig,
projectId: string,
): LifecycleWorkerStatus {
const pidFile = getLifecyclePidFile(config, projectId);
const logFile = getLifecycleLogFile(config, projectId);
const pid = readPid(pidFile);
if (pid !== null && isProcessRunning(pid)) {
return { running: true, pid, pidFile, logFile };
}
if (pid !== null) {
clearLifecycleWorkerPid(config, projectId, pid);
}
return { running: false, pid: null, pidFile, logFile };
}
function resolveLifecycleWorkerLaunch(projectId: string): { command: string; args: string[] } {
const entry = process.argv[1];
const workerArgs = ["lifecycle-worker", projectId];
if (entry && /\.(?:c|m)?js$/i.test(entry)) {
return {
command: process.execPath,
args: [entry, ...workerArgs],
};
}
if (entry && /\.ts$/i.test(entry)) {
return {
command: "npx",
args: ["tsx", entry, ...workerArgs],
};
}
return {
command: "ao",
args: workerArgs,
};
}
async function waitForLifecycleWorker(
config: OrchestratorConfig,
projectId: string,
timeoutMs = DEFAULT_START_TIMEOUT_MS,
): Promise<LifecycleWorkerStatus> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const status = getLifecycleWorkerStatus(config, projectId);
if (status.running) {
return status;
}
await sleep(100);
}
return getLifecycleWorkerStatus(config, projectId);
started: boolean;
}
export async function ensureLifecycleWorker(
config: OrchestratorConfig,
projectId: string,
): Promise<LifecycleWorkerStatus & { started: boolean }> {
const current = getLifecycleWorkerStatus(config, projectId);
if (current.running) {
return { ...current, started: false };
intervalMs: number = DEFAULT_INTERVAL_MS,
): Promise<LifecycleWorkerStatus> {
if (!config.projects[projectId]) {
throw new Error(`Unknown project: ${projectId}`);
}
const baseDir = getProjectBase(config, projectId);
const logFile = getLifecycleLogFile(config, projectId);
mkdirSync(baseDir, { recursive: true });
const stdoutFd = openSync(logFile, "a");
const stderrFd = openSync(logFile, "a");
try {
const launch = resolveLifecycleWorkerLaunch(projectId);
const child = spawn(launch.command, launch.args, {
cwd: process.cwd(),
detached: true,
stdio: ["ignore", stdoutFd, stderrFd],
env: {
...process.env,
AO_LIFECYCLE_PROJECT: projectId,
AO_CONFIG_PATH: config.configPath,
},
});
child.unref();
// Write PID from the parent immediately after spawn to close the TOCTOU
// window: without this, a second concurrent `ensureLifecycleWorker` call
// could pass the "not running" check before the child writes its own PID.
if (child.pid) {
writeLifecycleWorkerPid(config, projectId, child.pid);
}
} finally {
closeSync(stdoutFd);
closeSync(stderrFd);
if (active.has(projectId)) {
return { running: true, started: false };
}
const status = await waitForLifecycleWorker(config, projectId);
if (!status.running) {
throw new Error(
`Lifecycle worker failed to start for project ${projectId}. See ${status.logFile}`,
);
}
const observer = createProjectObserver(config, "lifecycle-service");
const lifecycle = await getLifecycleManager(config, projectId);
return { ...status, started: true };
lifecycle.start(intervalMs);
observer.setHealth({
surface: "lifecycle.worker",
status: "ok",
projectId,
correlationId: createCorrelationId("lifecycle-service"),
details: { projectId, intervalMs, inProcess: true },
});
active.set(projectId, {
lifecycle,
stop: () => {
try {
lifecycle.stop();
} finally {
observer.setHealth({
surface: "lifecycle.worker",
status: "warn",
projectId,
correlationId: createCorrelationId("lifecycle-service"),
reason: "Lifecycle polling stopped",
details: { projectId },
});
}
},
});
return { running: true, started: true };
}
export async function stopLifecycleWorker(
config: OrchestratorConfig,
projectId: string,
): Promise<boolean> {
const status = getLifecycleWorkerStatus(config, projectId);
if (!status.running || status.pid === null) {
clearLifecycleWorkerPid(config, projectId);
return false;
}
try {
process.kill(status.pid, "SIGTERM");
} catch {
clearLifecycleWorkerPid(config, projectId, status.pid);
return false;
}
const deadline = Date.now() + STOP_TIMEOUT_MS;
while (Date.now() < deadline) {
if (!isProcessRunning(status.pid)) {
clearLifecycleWorkerPid(config, projectId, status.pid);
return true;
export function stopAllLifecycleWorkers(): void {
for (const projectId of Array.from(active.keys())) {
const entry = active.get(projectId);
if (entry) {
try {
entry.stop();
} catch {
// Best-effort
}
}
await sleep(100);
active.delete(projectId);
}
try {
process.kill(status.pid, "SIGKILL");
} catch {
// Best effort hard stop
}
clearLifecycleWorkerPid(config, projectId, status.pid);
return true;
}
export function isLifecycleWorkerRunning(projectId: string): boolean {
return active.has(projectId);
}
export function listLifecycleWorkers(): string[] {
return Array.from(active.keys());
}

View File

@ -8,7 +8,6 @@ import { registerReviewCheck } from "./commands/review-check.js";
import { registerDashboard } from "./commands/dashboard.js";
import { registerOpen } from "./commands/open.js";
import { registerStart, registerStop } from "./commands/start.js";
import { registerLifecycleWorker } from "./commands/lifecycle-worker.js";
import { registerVerify } from "./commands/verify.js";
import { registerDoctor } from "./commands/doctor.js";
import { registerUpdate } from "./commands/update.js";
@ -36,7 +35,6 @@ export function createProgram(): Command {
registerReviewCheck(program);
registerDashboard(program);
registerOpen(program);
registerLifecycleWorker(program);
registerVerify(program);
registerDoctor(program);
registerUpdate(program);