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

259 lines
8.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 引擎进程管理(任务 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();
}
}