diff --git a/README.md b/README.md index 6de6399..232691a 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ node ~/.zcode/gatekeeper/gatekeeper.mjs approve --last # 或直接批准最近 ## 守护进程(daemon) -客户端每次调用先探测 `127.0.0.1:47811`(无副作用 ping),daemon 不在就自动拉起。收益:到审查端点的 TLS 连接常驻、决策缓存与任务状态驻内存、同请求并发去重、配置热重载(客户端每次请求带配置文件 mtime 签名,变了即重载)。 +客户端每次调用先探测 `127.0.0.1:47811`(无副作用 ping),daemon 不在就自动拉起;**无调用 30 分钟后自动退出释放内存**(`daemonIdleMs` 可调,0 = 常驻),下次调用自动重新拉起。收益:到审查端点的 TLS 连接常驻、决策缓存与任务状态驻内存、同请求并发去重、配置热重载(客户端每次请求带配置文件 mtime 签名,变了即重载)。 ```bash node gatekeeper/gatekeeper.mjs shutdown-daemon # 停止(下次调用自动重新拉起) diff --git a/gatekeeper/config.example.json b/gatekeeper/config.example.json index df43bed..9a307fc 100644 --- a/gatekeeper/config.example.json +++ b/gatekeeper/config.example.json @@ -16,6 +16,7 @@ "recentActions": 10, "daemon": true, "daemonPort": 47811, + "daemonIdleMs": 1800000, "extraBody": {}, "toolAllowlist": [], "autoMode": { diff --git a/gatekeeper/core.mjs b/gatekeeper/core.mjs index e0952e2..e61a8e4 100644 --- a/gatekeeper/core.mjs +++ b/gatekeeper/core.mjs @@ -69,6 +69,7 @@ const DEFAULTS = { recentActions: 10, // 提供给审查器的最近工具调用条数(组合风险判定) daemon: true, // 守护进程模式(客户端自动拉起,连不上则内联降级) daemonPort: 47811, + daemonIdleMs: 1800000, // daemon 空闲自动退出(ms):无调用 30 分钟后退出释放内存,下次调用自动拉起;0 = 常驻 extraBody: {}, // 深合并进 LLM 请求体的自定义字段(如 chat_template_kwargs.enable_thinking) toolAllowlist: [], autoMode: { allow: [], soft_deny: [], environment: [] }, // 用户自定义规则(非空则整段替换默认) @@ -91,6 +92,9 @@ export function loadAll() { if (process.env.GATEKEEPER_ON_UNCERTAIN) { config.onUncertain = String(process.env.GATEKEEPER_ON_UNCERTAIN).toLowerCase(); } + if (process.env.GATEKEEPER_DAEMON_IDLE_MS != null) { + config.daemonIdleMs = parseInt(process.env.GATEKEEPER_DAEMON_IDLE_MS, 10) || 0; + } // 超时预算钳制:LLM 最坏耗时(超时×(重试+1))+ 5s 余量不得超过 35s, // 否则内联降级会撞上 hook 超时(PreToolUse 50s / PermissionRequest 45s)被掐成无决策。 const worst = (config.llmTimeoutMs | 0) * ((config.retries | 0) + 1); diff --git a/gatekeeper/gatekeeper-daemon.mjs b/gatekeeper/gatekeeper-daemon.mjs index 25ff18e..c784ed7 100644 --- a/gatekeeper/gatekeeper-daemon.mjs +++ b/gatekeeper/gatekeeper-daemon.mjs @@ -67,12 +67,22 @@ async function handleHook(req) { // allowHalfOpen: true —— 客户端会半关闭(write+end),hook 判定是异步的(LLM 数秒), // 默认模式下 Node 会在收到 FIN 时自动关闭本端,响应就写进了一条已死的连接。 +// 空闲自动退出:ZCode 关闭/无调用一段时间后自动退出释放内存(~55MB),下次 hook 调用会自动重新拉起 +let idleTimer = null; +function armIdle() { + const ms = all.config.daemonIdleMs | 0; + if (idleTimer) clearTimeout(idleTimer); + if (ms <= 0) return; + idleTimer = setTimeout(() => { log('idle ' + Math.round(ms / 1000) + 's — exit to free memory'); process.exit(0); }, ms); +} + const server = net.createServer({ allowHalfOpen: true }, (socket) => { let buf = ''; socket.setEncoding('utf8'); socket.setTimeout(42_000, () => socket.destroy()); // 兜底:不允许超过 hook 超时 const dispatch = async (line) => { + armIdle(); // 任何请求(含 ping)都重置空闲计时 // 协议异常/未知 op 一律返回 {ok:false}(无 exitCode 字段)——客户端据此降级内联做完整审查, // 绝不能返回 {exitCode:0}(会被当作"明确放行"决策,形成 fail-open)。 let res = { ok: false, error: 'unknown op' }; @@ -118,7 +128,8 @@ server.on('error', async (e) => { }); server.listen(PORT, '127.0.0.1', () => { - log('daemon listening on 127.0.0.1:' + PORT + ' (model=' + all.config.model + ', guard=' + all.config.fullAccessGuard + ')'); + log('daemon listening on 127.0.0.1:' + PORT + ' (model=' + all.config.model + ', guard=' + all.config.fullAccessGuard + ', idleExit=' + Math.round((all.config.daemonIdleMs|0)/1000) + 's)'); + armIdle(); // 启动即计时:无任何调用则按时退出 // 预热:开一条到审查端点的 TLS 连接,让第一次真实审查免握手 setTimeout(async () => { try {