backend-ts/src/app-lifecycle.ts
Rama main · réplica de lectura del repositorio original.
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { Db } from './db/db.js';
import type { AppConfig } from './config/app-config.js';
import type { AppContext } from './create-app.js';
import type { FastifyInstance } from 'fastify';
import { ScheduledTaskScheduler } from './schedule/scheduled-task.service.js';
import { readActiveBackendPort, readDeployLock } from './harness/core/deploy-lock.js';
import { WebhookDeliveryScheduler, DeliverySchedulerDbStore } from './notification/task/delivery.scheduler.js';
import { userMessagePreviewOf } from './notification/task/user-message-preview.js';
import { createSessionDetailUrlResolver } from './feishu/progress-card.js';
import { OutboundDeliveryScheduler } from './openapi/outbound-delivery.scheduler.js';
import { InboxCleanupScheduler } from './inbox/inbox.cleanup.js';
import { ScheduledTaskRunCleanupScheduler } from './schedule/run-cleanup.js';
import { EcpRenewScheduler } from './auth/ecp-renew.scheduler.js';
import { replayFeishuPendingMessage } from './feishu/pending-replay.js';
import { RuntimeCleanupScheduler } from './harness/runtime/runtime-cleanup-scheduler.js';
import { SubagentExecutionRecoveryService } from './harness/delegate/subagent-execution-recovery.service.js';
import { SubagentRecoveryCoordinator } from './harness/delegate/subagent-recovery-coordinator.js';
import { CrashRecoveryRunner } from './harness/core/crash-recovery-runner.js';
import { FeishuCardProgressListener } from './feishu/card-progress-listener.js';
import { DeployDrainWatcher } from './harness/core/deploy-drain-watcher.js';
import { createShutdownHandler } from './app-shutdown.js';
export interface LifecycleDeps {
app: FastifyInstance;
db: Db;
cfg: AppConfig;
ctx: AppContext;
[key: string]: any;
}
export interface LifecycleResult {
closeApp: () => Promise<void>;
}
export function assembleLifecycle(deps: LifecycleDeps): LifecycleResult {
const d = deps as any;
const { app, db, cfg, ctx } = d;
const scheduler = new ScheduledTaskScheduler(d.scheduledStore, d.scheduledService, () => {
const active = readActiveBackendPort(cfg.app.harness.runtimeDir);
if (active != null) return active === cfg.server.port;
const lock = readDeployLock(cfg.app.harness.runtimeDir);
if (lock != null && lock.oldPort === cfg.server.port) return false;
return true;
});
scheduler.start();
const deliveryScheduler = new WebhookDeliveryScheduler(
new DeliverySchedulerDbStore(db),
() => d.settingService.getNotificationTuningConfig(),
d.notifCipher,
d.senderRegistry,
);
const sessionDetailUrl = createSessionDetailUrlResolver({
getSiteBaseUrl: () => d.settingService.getSiteBaseUrl(),
});
deliveryScheduler.setContextProvider({
latestUserMessage: async (sessionId: number) => {
const message = await d.sessionService.getLastUserMessage(sessionId);
return userMessagePreviewOf(message?.content ?? null) || null;
},
sessionDetailUrl: async (sessionId: number) => (await sessionDetailUrl(sessionId)) ?? null,
});
deliveryScheduler.start();
const outboundDeliveryScheduler = new OutboundDeliveryScheduler({
deliveryRepo: d.outboundDeliveryRepo,
subscriptionRepo: d.outboundSubscriptionRepo,
cipher: d.notifCipher,
// 共享单实例(create-app 构造):订阅测试投递与调度器真实投递同用
sender: d.genericWebhookSender,
auditRecorder: {
recordDeliveryFailed: async (input: any) => {
let userId: number | null = null;
if (input.subscriptionId != null) {
const subscription = await d.outboundSubscriptionRepo.findById(input.subscriptionId).catch(() => null);
userId = subscription?.userId ?? null;
}
await d.auditService.record({
action: 'EXECUTE',
objectType: 'outbound.subscription',
objectId: input.subscriptionId == null ? null : String(input.subscriptionId),
userId,
username: null,
method: 'POST',
path: `/v1/open/subscriptions/${input.subscriptionId}/deliveries`,
ip: null,
status: 502,
success: 0,
errorMessage: `出站投递终态失败 event=${input.event} url=${input.targetUrl} error=${input.error ?? ''}`,
}).catch(() => undefined);
},
},
execute: (fn: any) => d.agentExecutor.submit(fn),
});
outboundDeliveryScheduler.start();
const inboxCleanupScheduler = new InboxCleanupScheduler(d.inboxCleanupStore);
inboxCleanupScheduler.start();
const scheduledRunCleanup = new ScheduledTaskRunCleanupScheduler(d.scheduledStore);
scheduledRunCleanup.start();
d.callLogService.start();
d.callLogCleanup.start();
const ecpRenewScheduler = new EcpRenewScheduler(
d.ecpSessionRepo,
() => d.settingService.getEcpConfig(),
undefined,
(token: string) => d.ecpSessionRepo.encryptToken(token),
);
ecpRenewScheduler.start();
d.weixinMonitor.start();
d.feishuMonitor.start();
d.dingtalk.start();
void d.pendingBindingMessages.listRecoverable().then(async (pending: any[]) => {
for (const message of pending) {
if (ctx.pendingBindingProcessor == null) return;
const claimed = await d.pendingBindingMessages.claim(message.state);
if (claimed == null) continue;
try {
await replayFeishuPendingMessage(ctx.pendingBindingProcessor, claimed);
await d.pendingBindingMessages.complete(claimed.state);
} catch (error) {
await d.pendingBindingMessages.release(claimed.state);
console.error(`恢复飞书待绑定消息失败, state=${claimed.state}`, error);
}
}
}).catch((error: unknown) => console.error('恢复飞书待绑定消息列表失败', error));
d.shellManager.startCleanup();
d.terminalManager.startCleanup();
const runtimeCleanup = new RuntimeCleanupScheduler(
cfg.app.harness.runtimeDir,
cfg.app.harness.cleanup,
(_userId: number, sessionId: number) => d.wsHandler.hasExecutionClaim(sessionId),
cfg.app.harness.skillViewsDir,
);
runtimeCleanup.start();
const subagentExecutionRecovery = new SubagentExecutionRecoveryService(
d.subagentMapper, d.sessionMap, d.sessionSvc, d.compactionSvc, d.definitionRegistry,
(childSession: any, definition: any) => d.backgroundSubagentManager.buildSubContext(childSession, definition),
d.agentLoop, d.visibility, d.localToolSessions,
);
const subagentCoordinator = new SubagentRecoveryCoordinator(
d.subagentMapper, subagentExecutionRecovery, d.subagentResultDelivery,
d.sessionMap, d.sessionSvc, d.compactionSvc, d.agentExecutor,
);
const crash = new CrashRecoveryRunner(
d.sessionMap, d.sessionSvc, d.taskTerminal, d.harness, d.agentLoop, d.wsRegistry,
d.activityService as never, d.activityHeartbeat, d.todoMapper, d.modelRepo as never,
cfg.app.harness.runtimeDir,
d.agentExecutor,
async (sessionId: number, userId: number, phase: string) => {
await d.scheduledService.reconcileRunsForSession(sessionId, phase).catch((error: unknown) => {
console.warn(`定时任务运行记录在崩溃恢复后收敛失败, sessionId=${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
});
if (phase !== 'FAILED') {
void d.wsHandler.autoConsumeQueue(sessionId, userId);
await d.feishuProgressCardRepo.deleteBySessionId(sessionId).catch((error: unknown) => {
console.warn(`清理飞书进度卡片映射失败, sessionId=${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
});
await d.feishuInboundHandler.drainNextIfPending(sessionId).catch((error: unknown) => {
console.error(`飞书崩溃恢复后队列接力消费失败, sessionId=${sessionId}`, error);
});
await d.dingtalk.onCrashFinished(sessionId, phase).catch((error: unknown) => {
console.error(`钉钉崩溃恢复后队列接力消费失败, sessionId=${sessionId}`, error);
});
}
},
subagentCoordinator,
async (sessionId: number) => {
const dingtalkRecovered = await d.dingtalk.recoverProgress(sessionId);
if (dingtalkRecovered != null) return new FeishuCardProgressListener(dingtalkRecovered.progress, dingtalkRecovered.roundOffset, cfg.feishu.bot.cardToolTickMs);
const recovered = await d.createFeishuRecoveryProgress(sessionId);
return recovered == null ? null : new FeishuCardProgressListener(recovered.progress, recovered.roundOffset, cfg.feishu.bot.cardToolTickMs);
},
(sessionId: number) => d.agentLoop.getCancelFlag(sessionId) != null,
cfg.server.port,
d.localToolSessions,
);
// 失败会话批量恢复:服务在 create-app 构造并注入路由,
// 此处把崩溃恢复内核接给它——批量恢复与崩溃恢复共用同一续跑流程与并发防护。
d.failedSessionRecovery?.attachKernel(crash);
void crash.run().catch((e: unknown) => console.error('Crash recovery failed', e)).then(async () => {
await d.dingtalk.hydrate().catch((error: unknown) => console.error('钉钉入站队列启动恢复失败', error));
const sessionIds = await d.feishuTaskQueue.hydrate();
for (const sessionId of sessionIds) {
await d.feishuInboundHandler.drainNextIfPending(sessionId).catch((error: unknown) => {
console.error(`飞书队列启动恢复消费失败, sessionId=${sessionId}`, error);
});
}
}).catch((error: unknown) => console.error('飞书入站队列启动恢复失败', error));
crash.startPeriodicSweep();
const drainWatcher = new DeployDrainWatcher({
runtimeDir: cfg.app.harness.runtimeDir,
selfPort: cfg.server.port,
startedAtSec: Math.floor((Date.now() - process.uptime() * 1000) / 1000),
shutdown: () => closeApp(),
});
const closeApp = createShutdownHandler({
app, db, cfg, ctx,
crash, drainWatcher,
weixinMonitor: d.weixinMonitor, feishuMonitor: d.feishuMonitor, dingtalk: d.dingtalk,
weixinInboundHandler: d.weixinInboundHandler, feishuInboundProcessor: d.feishuInboundProcessor,
scheduler, deliveryScheduler, callLogService: d.callLogService, callLogCleanup: d.callLogCleanup,
watcherScheduler: d.watcherScheduler, signalCheckCleanup: d.signalCheckCleanup, ecpRenewScheduler,
shellManager: d.shellManager, terminalManager: d.terminalManager,
ptyChannelHubs: d.ptyChannelHubs, ptyHubGeneration: d.ptyHubGeneration,
containerRegistry: d.containerRegistry, channelServer: d.channelServer, runtimeCleanup,
activityHeartbeat: d.activityHeartbeat, wsRegistry: d.wsRegistry,
agentLoop: d.agentLoop, wsHandler: d.wsHandler, sessionService: d.sessionService,
});
drainWatcher.start();
return { closeApp };
}