Skip to content
Open
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
14 changes: 14 additions & 0 deletions forge-engine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@
> [!IMPORTANT]
> Forge Engine 目前是 **experimental / manual setup**。源码、MCP server 和 CLI 都在仓库里,但 **`forge-hub install` 默认不会部署或注册它**。想用的话,按下面步骤单独配置。

## 运行日志与崩溃可见性

`log()` / `logError()` 除了写 stderr,**同时追加到 `<DATA_DIR>/engine.log`**,超过 2MB 转存 `engine.log.1`(只留一份)。

**为什么需要文件日志**:stderr 是给 MCP 宿主看的,但宿主通常只在**连接建立那一刻**捕获 stderr —— engine 启动之后打印的任何东西都不再被记录。一旦进程异常退出,什么线索都不剩。

实测案例(2026-09-03):engine 在 00:00–03:00 之间消失,宿主日志无断开记录、系统无崩溃报告、无内存压力事件,调度静默停摆 **10 小时 31 分**、漏跑 12 个任务。事后无从判断死因。

**崩溃捕获**:`uncaughtException` 与 `unhandledRejection` 会把完整堆栈写进 `engine.log`,随后 `stopScheduler()` 释放 PID 锁并退出(exit 1)。

**为什么退出而不是继续跑**:调度器状态可能已损坏,而一个状态损坏的调度器发出的推送比不推送更坏。

**为什么不自动重起**:多实例 + 自动重起 + 抢 PID 锁是本项目已经踩过的坑(`cleanOrphans` + 一次性 `acquirePidLock` 的组合)。恢复由**外部**检测触发人工重连,engine 自己不做进程管理。

## 架构

```
Expand Down
45 changes: 44 additions & 1 deletion forge-engine/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* Forge Engine 路径常量与日志
*/

import fs from "node:fs";
import path from "node:path";

// ── Channel Identity ────────────────────────────────────────────────────────
Expand All @@ -25,13 +26,55 @@ export const HANDLERS_DIR = path.resolve(CODE_DIR, "handlers");
export const SCHEDULE_FILE = path.join(DATA_DIR, "engine-schedule.json");
export const ACTION_LOG_FILE = path.join(DATA_DIR, "engine-trigger-log.md");
export const PID_FILE = path.join(DATA_DIR, "engine.pid");
export const RUNTIME_LOG_FILE = path.join(DATA_DIR, "engine.log");

// ── Logging (stderr — stdout is MCP stdio) ──────────────────────────────────
// ── Logging ─────────────────────────────────────────────────────────────────
//
// stderr 是给宿主看的(stdout 被 MCP stdio 占用)。但宿主只在**连接建立那一刻**
// 捕获 stderr —— engine 启动之后打印的任何东西都掉进黑洞。
//
// 后果实测(2026-09-03):engine 在 00:00–03:00 之间死亡,无崩溃报告、无内存压力、
// 宿主日志里一条断开记录都没有,调度静默停摆 10 小时 31 分、漏跑 12 个任务。
// 它死之前很可能打印过原因,只是没人听见。
//
// 所以 log/logError 同时落盘。文件是唯一能在进程死后还留下痕迹的地方。

const MAX_LOG_BYTES = 2 * 1024 * 1024; // 2MB 转存一次,只留一份 .1

function appendToFile(line: string): void {
try {
fs.mkdirSync(DATA_DIR, { recursive: true });
// 轮转:日志本身不能变成下一个「不停长大且没人敢删」的文件
try {
if (fs.statSync(RUNTIME_LOG_FILE).size > MAX_LOG_BYTES) {
fs.renameSync(RUNTIME_LOG_FILE, RUNTIME_LOG_FILE + ".1");
}
} catch { /* 文件还不存在 —— 首次写入,正常 */ }
fs.appendFileSync(RUNTIME_LOG_FILE, line);
} catch { /* 日志写不进去不能反过来把进程搞死 */ }
}

function stamp(): string {
return new Date().toISOString().replace("T", " ").slice(0, 19);
}

export function log(msg: string) {
process.stderr.write(`[engine] ${msg}\n`);
appendToFile(`${stamp()} [engine] ${msg}\n`);
}

export function logError(msg: string) {
process.stderr.write(`[engine] ERROR: ${msg}\n`);
appendToFile(`${stamp()} [engine] ERROR: ${msg}\n`);
}

/** 致命错误:带完整堆栈落盘。进程即将退出时用。 */
export function logFatal(kind: string, err: unknown): void {
// err.stack 本身已含 "Name: message" 首行,不要再拼一次
const detail = err instanceof Error
? (err.stack ?? `${err.name}: ${err.message}\n(无堆栈)`)
: String(err);
const block = `${stamp()} [engine] FATAL ${kind} · PID ${process.pid}\n${detail}\n`;
process.stderr.write(block);
appendToFile(block);
}
26 changes: 25 additions & 1 deletion forge-engine/engine-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
SCHEDULE_DIR,
log,
logError,
logFatal,
} from "./config.js";
import { startScheduler, stopScheduler } from "./scheduler.js";
import { resolveTaskTiming } from "./task-timing.js";
Expand Down Expand Up @@ -299,7 +300,30 @@ async function main() {
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));

log("engine started");
// ── Crash Visibility ─────────────────────────────────────────────────────
//
// 2026-09-03:engine 在 00:00–03:00 之间消失,宿主日志里没有任何断开记录、
// 没有崩溃报告、没有内存压力事件,调度静默停摆 10h31m、漏跑 12 个任务。
//
// 定时器回调里的未捕获异常在 Bun 下会直接带走进程,而 log() 当时只写 stderr,
// 宿主又只在连接建立那一刻捕获 stderr —— **它死之前很可能喊过,只是没人听见。**
//
// 取舍:抓到之后**退出,不带病续跑**。scheduler 的状态可能已经坏了,
// 而一个状态损坏的调度器推送出去的东西,比不推送更坏。
// 退出后由外部检测(宿主侧的存活检查)发现并提示人工重连 —— 不做自动重起:
// 多实例 + 自动重起 + 抢 PID 锁,正是本项目已经踩过的坑。
process.on("uncaughtException", (err) => {
logFatal("uncaughtException", err);
stopScheduler(); // 释放 PID 锁,别留一个 stale 锁给下一个实例
process.exit(1);
});
process.on("unhandledRejection", (reason) => {
logFatal("unhandledRejection", reason);
stopScheduler();
process.exit(1);
});

log(`engine started · PID ${process.pid}`);
}

if (import.meta.main) {
Expand Down
Loading