aps-agent/server/agent/process-manager.ts

259 lines
8.7 KiB
TypeScript
Raw Normal View History

/**
* 引擎进程管理(任务 A3.10,ADR-0006)。
*
* 两个引擎共用一套:会话级资源登记、空闲回收、并发上限、崩溃退避重启、日志(stderr)环形缓冲。
* 平台不关心引擎是子进程还是别的形态——只管「谁在用、用了多久、坏了怎么办」。
*
* 默认值(实施设计 9.5):一进程一会话、空闲 15 分钟回收、并发上限 4、连续重启上限 5。
* 重启上限是「连续」的:一次正常回合会把计数清零(markHealthy)。
*/
import { AppError } from '../app/errors.js';
import type { RuntimeSession } from './runtime.js';
export interface SupervisorOptions {
maxConcurrent: number;
idleTimeoutMs: number;
sweepIntervalMs: number;
/** 单个会话允许的连续重启次数 */
maxRestarts: number;
/** 退避基数:第 n 次重启等待 base * 2^(n-1) */
baseBackoffMs: number;
/** 引擎日志(stderr 尾部)输出口 */
onLog?: (message: string) => void;
/** 便于测试注入时钟 */
now?: () => number;
}
const DEFAULTS: Omit<SupervisorOptions, 'onLog' | 'now'> = {
maxConcurrent: 4,
idleTimeoutMs: 15 * 60_000,
sweepIntervalMs: 60_000,
maxRestarts: 5,
baseBackoffMs: 1_000,
};
const STDERR_TAIL_CHARS = 2000;
interface Entry {
/** 死了的条目保留为「墓碑」:记着重试次数与退避截止时间 */
dead: boolean;
runtimeSession: RuntimeSession | undefined;
fingerprint: string;
lastUsedAt: number;
restarts: number;
/** 到点之前不接受新请求;Infinity 表示已达重启上限 */
retryAfter: number;
stderrTail: string;
stderrOffset: number;
}
export interface SupervisorStats {
active: number;
evictedIdle: number;
evictedFailed: number;
evictedClosed: number;
evictedStale: number;
rejectedBusy: number;
rejectedBackoff: number;
}
export class RuntimeSupervisor {
private readonly entries = new Map<string, Entry>();
private readonly options: SupervisorOptions;
private sweeper: NodeJS.Timeout | undefined;
private readonly counters = { evictedIdle: 0, evictedFailed: 0, evictedClosed: 0, evictedStale: 0, rejectedBusy: 0, rejectedBackoff: 0 };
constructor(options: Partial<SupervisorOptions> = {}) {
this.options = { ...DEFAULTS, ...options };
}
/** 活跃(还活着的)引擎会话数,墓碑不算 */
get size(): number {
return [...this.entries.values()].filter((entry) => !entry.dead).length;
}
stats(): SupervisorStats {
return { active: this.size, ...this.counters };
}
get(key: string): RuntimeSession | undefined {
const entry = this.entries.get(key);
return entry?.dead === true ? undefined : entry?.runtimeSession;
}
/**
* 取会话对应的引擎资源:命中且指纹一致就复用;墓碑到点后重建;并发满或退避中则明确拒绝。
*/
async acquire(key: string, fingerprint: string, create: () => Promise<RuntimeSession>): Promise<RuntimeSession> {
const now = this.now();
let entry = this.entries.get(key);
if (entry && entry.fingerprint !== fingerprint) {
await this.evict(key, 'stale', '工具集或身份变化');
entry = this.entries.get(key);
}
if (entry?.dead === true) {
if (entry.retryAfter === Number.POSITIVE_INFINITY) {
this.counters.rejectedBackoff += 1;
throw new AppError('unavailable', `引擎连续启动失败已达上限(${this.options.maxRestarts} 次),停止重试`, {
status: 503,
retryable: false,
});
}
if (entry.retryAfter > now) {
this.counters.rejectedBackoff += 1;
const waitSeconds = Math.max(0.1, Math.round(((entry.retryAfter - now) / 1000) * 10) / 10);
throw new AppError('rate_limited', `引擎正在退避重启(第 ${entry.restarts} 次),请 ${waitSeconds} 秒后重试`, {
status: 429,
retryable: true,
});
}
const runtimeSession = await create();
this.entries.set(key, {
dead: false,
runtimeSession,
fingerprint,
lastUsedAt: now,
restarts: entry.restarts,
retryAfter: 0,
stderrTail: entry.stderrTail,
stderrOffset: 0,
});
return runtimeSession;
}
if (entry) {
entry.lastUsedAt = now;
this.drainStderr(key, entry);
return entry.runtimeSession as RuntimeSession;
}
if (this.size >= this.options.maxConcurrent) {
this.counters.rejectedBusy += 1;
throw new AppError('rate_limited', `同时活跃的引擎会话已达上限(${this.options.maxConcurrent}),请稍后重试`, {
status: 429,
retryable: true,
});
}
const runtimeSession = await create();
this.entries.set(key, {
dead: false,
runtimeSession,
fingerprint,
lastUsedAt: now,
restarts: 0,
retryAfter: 0,
stderrTail: '',
stderrOffset: 0,
});
return runtimeSession;
}
touch(key: string): void {
const entry = this.entries.get(key);
if (entry) entry.lastUsedAt = this.now();
}
/** 一次正常回合之后调用:连续重启计数清零(上限管的是「连续」失败) */
markHealthy(key: string): void {
const entry = this.entries.get(key);
if (entry && !entry.dead) entry.restarts = 0;
}
/**
* 回收:idle(空闲)、failed(崩溃或回合失败)、closed(平台关停)、
* stale(工具集或身份变化,旧会话作废——这不是故障,立即重建且不进退避)
*/
async evict(key: string, reason: 'idle' | 'failed' | 'closed' | 'stale', detail?: string): Promise<void> {
const entry = this.entries.get(key);
if (!entry) return;
if (reason !== 'failed') {
this.entries.delete(key);
if (reason === 'idle') this.counters.evictedIdle += 1;
else if (reason === 'stale') {
this.counters.evictedStale += 1;
this.logTail(key, entry, detail ?? '配置变化');
} else {
this.counters.evictedClosed += 1;
this.logTail(key, entry, detail ?? '平台关停');
}
await this.closeEntry(entry);
return;
}
this.counters.evictedFailed += 1;
const restarts = entry.restarts + 1;
await this.closeEntry(entry);
if (restarts > this.options.maxRestarts) {
this.options.onLog?.(`[process] 会话 ${key} 连续启动失败已达上限(${this.options.maxRestarts}),停止重试:${detail ?? ''}`);
this.entries.set(key, { ...entry, dead: true, runtimeSession: undefined, restarts, retryAfter: Number.POSITIVE_INFINITY });
return;
}
const backoff = this.options.baseBackoffMs * 2 ** (restarts - 1);
this.options.onLog?.(`[process] 会话 ${key} 异常回收(${detail ?? '失败'}),第 ${restarts} 次重启将在 ${backoff}ms 后允许`);
this.entries.set(key, { ...entry, dead: true, runtimeSession: undefined, restarts, retryAfter: this.now() + backoff });
}
/** 平台关停:全部回收,避免子进程残留 */
async disposeAll(): Promise<void> {
for (const key of [...this.entries.keys()]) await this.evict(key, 'closed');
this.stopSweeper();
}
startSweeper(): void {
if (this.sweeper) return;
this.sweeper = setInterval(() => {
void this.sweepIdle();
}, this.options.sweepIntervalMs);
this.sweeper.unref();
}
stopSweeper(): void {
if (this.sweeper) clearInterval(this.sweeper);
this.sweeper = undefined;
}
async sweepIdle(): Promise<void> {
const now = this.now();
for (const [key, entry] of [...this.entries]) {
if (entry.dead) continue; // 墓碑由 acquire 处理
if (now - entry.lastUsedAt >= this.options.idleTimeoutMs) {
await this.evict(key, 'idle');
}
}
}
/** 引擎 stderr 环形缓冲:只留最近一段,回收时打日志 */
private drainStderr(key: string, entry: Entry): void {
if (!entry.runtimeSession) return;
const snapshot = entry.runtimeSession.stderrTail?.() ?? '';
if (snapshot.length > entry.stderrOffset) {
const delta = snapshot.slice(entry.stderrOffset);
entry.stderrOffset = snapshot.length;
entry.stderrTail = `${entry.stderrTail}${delta}`.slice(-STDERR_TAIL_CHARS);
}
}
private logTail(key: string, entry: Entry, detail: string): void {
if (entry.stderrTail.trim() === '') return;
this.options.onLog?.(`[process] 会话 ${key} ${detail},引擎 stderr 尾部:${entry.stderrTail.slice(-400)}`);
}
private async closeEntry(entry: Entry): Promise<void> {
if (!entry.runtimeSession) return;
try {
this.drainStderr('', entry);
await entry.runtimeSession.dispose();
} catch {
/* 回收失败不阻塞:进程可能已经死了 */
}
}
private now(): number {
return this.options.now?.() ?? Date.now();
}
}