feat: add assistant proposal worker runtime

This commit is contained in:
zenord
2026-08-18 18:48:47 +08:00
parent 947f19e3cd
commit b4121c9fd6
32 changed files with 2827 additions and 986 deletions
+46 -33
View File
@@ -7,7 +7,7 @@
`gori-agent` 是 Node.js 20+ / TypeScript 项目,通过单个 IM 平台接收请求,并通过官方 Agent Client Protocol(ACP)驱动一个 Coding Agent。 `gori-agent` 是 Node.js 20+ / TypeScript 项目,通过单个 IM 平台接收请求,并通过官方 Agent Client Protocol(ACP)驱动一个 Coding Agent。
```text ```text
用户 → Platform Adapter → Gateway → AcpSessionManager → AcpWorker → ACP Agent 用户 → Platform Adapter → Gateway → AssistantManager(Assistant / Proposal / Worker)→ ACP Agent
``` ```
每个运行实例是一个完整且固定的 Bot: 每个运行实例是一个完整且固定的 Bot:
@@ -32,6 +32,9 @@ ${GORI_AGENT_ROOT:-$HOME/.gori-agent}/instances/<bot-id>/
├── logs/gori-agent.log ├── logs/gori-agent.log
└── state/ └── state/
├── acp-sessions.json ├── acp-sessions.json
├── proposals.json
├── assistant-workspaces/
├── kimi/assistant/
└── gori-agent.pid └── gori-agent.pid
``` ```
@@ -42,6 +45,7 @@ ${GORI_AGENT_ROOT:-$HOME/.gori-agent}/instances/<bot-id>/
- 实例目录、`logs/`、`state/` 为 `0700`。 - 实例目录、`logs/`、`state/` 为 `0700`。
- `config.json`、PID、state 为 `0600`。 - `config.json`、PID、state 为 `0600`。
- 多实例必须使用不同 `gateway.server.port` 和平台 credentials。 - 多实例必须使用不同 `gateway.server.port` 和平台 credentials。
- 旧的 `/usr/bin/flock` workspace lease 与 `owner.json` 已退役,不要重新引入。同一 `GORI_AGENT_ROOT` 下由 workspace scope 兜底:`doctor`、外层 `start` 与内部 runner 扫描其他实例目录的完整 Config v3,任一目录/配置不可读或无效即 fail closed;canonical workspace 相同或互为父子一律拒绝。该机制不覆盖终端、IDE、其他 root 或未接入 gori-agent 的进程。
- `init` / `setup` 必须交互询问并校验 server host/port;扫描其他 Config v3 的声明端口,冲突时警告,新实例建议下一个未声明端口,已有配置不得静默改端口。 - `init` / `setup` 必须交互询问并校验 server host/port;扫描其他 Config v3 的声明端口,冲突时警告,新实例建议下一个未声明端口,已有配置不得静默改端口。
- `setup <bot-id>` 只重配已有实例:校验目录/config/PID,运行中或 foreign PID 拒绝,固定 Bot ID,保留 agent/skills/permissions/policy/runtime/secrets/publicBaseUrl,确认写入后运行 doctor。 - `setup <bot-id>` 只重配已有实例:校验目录/config/PID,运行中或 foreign PID 拒绝,固定 Bot ID,保留 agent/skills/permissions/policy/runtime/secrets/publicBaseUrl,确认写入后运行 doctor。
- setup 的声明冲突提示不替代实际 bind 检查;`start` 对缺配置、ID 不匹配、错误权限、占位符、运行 PID、端口冲突 fail closed。 - setup 的声明冲突提示不替代实际 bind 检查;`start` 对缺配置、ID 不匹配、错误权限、占位符、运行 PID、端口冲突 fail closed。
@@ -69,31 +73,35 @@ gori-agent list
- Config v3 Zod schema、类型、交叉校验和 state 默认路径。 - Config v3 Zod schema、类型、交叉校验和 state 默认路径。
- 只接受 `configVersion: 3`。 - 只接受 `configVersion: 3`。
- `src/server.ts` - `src/server.ts`
- 组装 fixed Bot、state store、ACP manager、Gateway 和唯一 platform adapter。 - 组装 fixed Bot、state store、ProposalStore、AssistantManager、Gateway 和唯一 platform adapter。
- 只挂载所选平台 route。 - 只挂载所选平台 route;创建 AssistantManager 时传入 `runtime.acp.maxAssistantSessions`。
- `src/roles/role-registry.ts` - `src/roles/role-registry.ts`
- 历史路径名保留,但实现是 `BotProfileResolver`,不是 role registry。 - 历史路径名保留,但实现是 `BotProfileResolver`,不是 role registry。
- 加载当前 Bot skills、计算 fingerprint、生成版本化 bootstrap。 - 加载当前 Bot skills、计算 fingerprint、生成版本化 assistant/worker bootstrap。
- `src/acp/client.ts` - `src/acp/client.ts`
- ACP initialize/new/resume/load/prompt/cancel 和 permission request。 - ACP initialize/new/resume/load/prompt/cancel 和 permission request。
- 当前 session 的每个 `session/update` 只触发活动回调,不记录或泄露 update 内容。 - assistant session 一旦出现 tool update 或 permission request 必须 fail closed。
- `src/acp/worker.ts` - `src/acp/worker.ts`
- 直接使用 resolved Bot 的 agent,在 `bot.workspace` 启动。 - worker 在 `bot.workspace` 启动;assistant 在实例私有 `state/assistant-workspaces/<hmac>` 启动,未显式配置时 `KIMI_CODE_HOME` 指向实例私有 `state/kimi/assistant/`。
- 记录 turn 开始/最近活动时间,并区分 `idle`、bootstrap `initializing`、普通 prompt `processing`。 - 每个 ACP worker 使用独立进程组和随机 `GORI_AGENT_WORKER_TOKEN`;cancel、timeout、crash 或 assistant 隔离违约必须清理同进程组工具后代;bootstrap 禁止 `setsid`、`nohup`、detached/daemon/background 遗留进程,主动脱组属于无 cgroup/Bubblewrap 方案的边界。
- `src/acp/session-manager.ts` - assistant 只接受 Kimi Code ACP,并依赖项目级 `tools: []`、`subagents: []` profile;permission deny 只是附加层。
- 固定 Bot 的 binding、worker pool、恢复、idle sweep、capacity、cancel/reset。 - `src/acp/assistant-manager.ts`
- status 暴露 `phase`、`runningSeconds`、`idleSeconds`。 - 每 chat 无工具 Assistant 会话(`GORI_ASSISTANT_ACTION_V1` envelope:create_proposal/confirm/start_next/cancel/stop,格式只修复一次)。
- 唯一活跃 Worker 执行已确认 Proposal(`GORI_WORKER_RESULT_V1`:SUCCESS/FAILED/NEEDS_CONFIRMATION);success 与 failure 都需用户确认才收尾,不自动开始下一个;执行中 dirty 是正常的 NEEDS_CONFIRMATION 确认点。
- capacity(maxAssistantSessions/maxProcesses)、idle sweep、cancel/confirm/stop、worker_lost 恢复、owner 事件通知。
- `src/core/durable-session-store.ts` - `src/core/durable-session-store.ts`
- state v2,保存 bot/platform identity 和 chat binding。 - state v3 只保存 bot/platform identity 和 assistant binding(chat key、agent ID、native session ID、assistant workspace、fingerprint、时间戳);不保存消息正文。
- 单 writer lock、串行持久化、临时文件、fsync、原子 rename。 - 单 writer lock、串行持久化、临时文件、fsync、原子 rename;version/identity 不匹配(含旧 v1/v2)拒绝并保留原文件。
- `src/core/proposal-store.ts`
- proposals.json(version 1)保存 Proposal:title/goal/steps、owner chat、状态流(proposed/queued/working/awaiting_user_confirmation/completed/failed/cancelled)、pending(step/success/failure)、worker session 与进程组 PGID/token;同样的 lock 与原子写入纪律。
- `src/core/workspace-scope.ts`
- canonical workspace + 跨实例 Config v3 扫描;相同或父子 workspace 在 doctor/start/runner fail closed。lease 已退役。
- `src/core/gateway.ts` - `src/core/gateway.ts`
- 入站 allowlist、群 mention、命令、per-chat lock、队列状态和回复。 - 入站 allowlist、群 mention、命令和回复;不维护普通消息队列或 per-chat task lock,也没有固定时间处理中提醒。
- policy 后 `/status`、`/cancel`、`/help` 必须绕过 chat lock,`/new` 必须保持串行。 - 每条异步入站使用独立串行 ReplyStream,`replySequence` 从 1 动态递增;发送失败只写安全日志且不阻止任务或后续发送。
- 异步平台普通消息轮到后立即 prompt,不等待提示发送;15 秒内完成只发结果,否则按 queued/running 提示,queued 转 running 时补开始提示;主动提示从入站绝对时间按 15 秒、60 秒、180 秒、480 秒触发,之后每 600 秒一次,只使用用户可感知文案并隐藏 ACP、`phase`、`idleSeconds` 等内部指标,技术状态只保留在 `/status`;同步 webhook 不提示。 - Worker 落定事件经 `sendEvent` 作为新消息发送(QQ 同样是新消息)。
- 每条异步入站使用独立串行 ReplyStream,`replySequence` 从 1 动态递增;发送失败只写安全日志且不阻止任务或后续发送,runtime/delivery 错误分离,最终消息入流后释放 chat lock。
- 每条入站只维护一个基于绝对 deadline 的可取消 timer;完成先标记 finished 并清 timer,shutdown 清理全部 timer,生产 timer 必须 `unref`,Gateway 可注入 fake clock/schedule 供测试。
- `src/core/command-router.ts` - `src/core/command-router.ts`
- `/help`、`/status`、`/cancel`、`/new`;旧 role 命令返回 retired 提示。 - `/help`、`/status`、`/list`、`/confirm`、`/stop`、`/cancel`;未识别命令按普通消息处理。
- `src/platforms/*` - `src/platforms/*`
- Adapter 依赖独立平台 config type,不依赖完整 AppConfig 路径。 - Adapter 依赖独立平台 config type,不依赖完整 AppConfig 路径。
- `src/cli/config-file.ts` - `src/cli/config-file.ts`
@@ -180,32 +188,37 @@ QQ 群 chat ID 为 `group:<group_openid>`。正式运维 Bot 应配置入口 all
### 4.5 Runtime ACP ### 4.5 Runtime ACP
- `stateFile` 为空时为 `$GORI_AGENT_HOME/state/acp-sessions.json`。 - `stateFile` 为空时为 `$GORI_AGENT_HOME/state/acp-sessions.json`;proposals 固定在同目录 `proposals.json`。
- `initializeTimeoutMs`: 默认 10000。 - `initializeTimeoutMs`: 默认 10000。
- `promptTimeoutMs`: 默认 7200000(2 小时)。 - `promptTimeoutMs`: 默认 14400000(4 小时)。
- `cancelGraceMs`: 默认 5000。 - `cancelGraceMs`: 默认 5000。
- `idleTimeoutMs`: 默认 1800000。 - `idleTimeoutMs`: 默认 1800000。
- `sweepIntervalMs`: 默认 60000。 - `sweepIntervalMs`: 默认 60000。
- `maxProcesses`: 默认 8。 - `maxProcesses`: 默认 8,包含 assistant + worker。
- `maxAssistantSessions`: 默认 4。
## 5. Session 与 state v2 Kimi Code agent `args` 必须严格为 `["acp"]`,不能添加可能绕过 assistant no-tool profile 的启动参数。
Binding key 是当前实例固定的 `platform + chatId`。不再包含 role。 ## 5. Assistant / Proposal / Worker 与 state v3
state v2 header 保存 `botId` 和 platform。打开时不匹配必须拒绝,不能清空、迁移或覆盖。旧 state v1 也明确拒绝并保留原文件。 运行链路是三层:每 chat 一个无工具 Assistant 会话负责对话与创建 Proposal;唯一活跃 Worker 在 `bot.workspace` 执行已确认 Proposal;Proposal 是两者之间的持久工作单。
Binding 保存 agent ID、workspace、native session ID、Bot fingerprint 和时间戳。fingerprint 包含: Proposal 状态流:`proposed → queued → working → awaiting_user_confirmation → completed | failed | cancelled`。`proposed` 必须用户确认才进入 `queued`;没有活跃 worker、没有 working/awaiting_user_confirmation 时最早确认的 queued Proposal 才开始。
- bootstrap schema version Assistant 每条回复以隐藏 `GORI_ASSISTANT_ACTION_V1` envelope 结尾(`reply` + `actions`),action 仅 `create_proposal`、`confirm`、`start_next`、`cancel`、`stop`,格式错误只修复一次。Worker 每轮以隐藏 `GORI_WORKER_RESULT_V1` envelope 收尾:`SUCCESS`、`FAILED`、`NEEDS_CONFIRMATION`(可选 `question`、`nextStep`、`dirty`),同样只修复一次。
- Bot ID
- workspace/persona
- agent 定义
- permission policy
- skill 路径和内容 hash
首次消息创建 native session,发送隐藏 bootstrap,成功后保存 binding。重启/idle 后优先 resume,再 fallback load。prompt 失败不重放。 确认语义:
`/new` 在 per-chat lock 内删除当前 chat binding;下一条消息创建新 session。`/status`、`/cancel`、`/help` 不等待 chat lock;status 合并 Gateway running/queued/started 时长与 ACP phase/running/idle 时长。 - `SUCCESS` 与 `FAILED` 都进入 `awaiting_user_confirmation`,都需用户确认才落定为 `completed`/`failed`;failed 用 answer `retry` 确认可重试。
- 系统不自动开始下一个 Proposal;只有新确认或 `start_next` 推进队列。
- 执行中遇到 dirty/意外目标是正常的 `NEEDS_CONFIRMATION` 确认点,不是 blocked 终态;用户回答后同一 worker 继续。
- Gateway 无固定时间提醒;worker 落定后由 Assistant 生成事件文案,作为新消息发给 owner chat。
Assistant 隔离:cwd 在实例私有 `state/assistant-workspaces/`,目录 key 使用进程随机 salt 的 HMAC,不得与项目 workspace 重叠;Kimi 项目级 agent override 必须设置 `tools: []`、`subagents: []`,ACP `mcpServers: []`,未显式配置时 `KIMI_CODE_HOME` 指向实例私有 `state/kimi/assistant/`;任何 tool update 或 permission request 都视为隔离违约,fail closed、终止进程组并删除 binding。每个 ACP worker 独立进程组 + 随机 token;runner 重启时 working Proposal 校验 token 清理旧进程组后标记 failed(worker_lost)。
state v3(`acp-sessions.json`)header 保存 `botId` 和 platform,只存 assistant binding(agent ID、assistant workspace、native session ID、Bot fingerprint、时间戳);`proposals.json`(version 1)存 Proposal 记录。两者打开时 version/identity 不匹配必须拒绝,不能清空、迁移或覆盖;旧 state v1/v2 也明确拒绝并保留原文件。fingerprint 包含 bootstrap schema version、Bot ID、workspace/persona、agent 定义、permission policy、skill 路径和内容 hash。prompt 失败不重放。
命令 `/help`、`/status`、`/list`、`/confirm`、`/stop`、`/cancel` 旁路 Assistant;`/stop` 仅 owner chat 可停止活跃 worker。
## 6. 单平台装配 ## 6. 单平台装配
+54 -30
View File
@@ -3,7 +3,7 @@
`gori-agent` 是一个 Node.js 20+ / TypeScript 网关:从一个 IM 平台接收消息,通过官方 Agent Client Protocol(ACP)驱动一个 Coding Agent。 `gori-agent` 是一个 Node.js 20+ / TypeScript 网关:从一个 IM 平台接收消息,通过官方 Agent Client Protocol(ACP)驱动一个 Coding Agent。
```text ```text
用户 → 单个平台 Adapter → Gateway → ACP Session Manager → 单个 ACP Agent 用户 → 单个平台 Adapter → Gateway → AssistantManager(Assistant / Proposal / Worker)→ 单个 ACP Agent
``` ```
## Config v3 实例模型 ## Config v3 实例模型
@@ -12,9 +12,11 @@ Config v3 只支持以下边界:
- 一份运行配置对应一个固定 Bot 身份。 - 一份运行配置对应一个固定 Bot 身份。
- 一个 Bot 只有一个 workspace、persona、ACP agent、skill 列表和 permission policy。 - 一个 Bot 只有一个 workspace、persona、ACP agent、skill 列表和 permission policy。
- 每个 chat 一个无工具 Assistant 会话;它只对话、创建 Proposal 并解释 Worker 反馈,自己从不执行。
- 同一时刻全实例只有一个 Worker 在 `bot.workspace` 执行一个已确认 Proposal;success 与 failure 都需要用户确认后才收尾,系统不会自动开始下一个 Proposal。
- 一个实例只绑定一个平台;多平台使用多个实例。 - 一个实例只绑定一个平台;多平台使用多个实例。
- 不再有 `roles[]`、`defaultRole`、`backends[]`、动态 `/role` 切换或 Config v1/v2 迁移。 - 不再有 `roles[]`、`defaultRole`、`backends[]`、动态 `/role` 切换、单主任务/近似 BTW 或 Config v1/v2 迁移。
- `configVersion` 非 `3` 会明确失败,旧 state v1 也不会自动改写。 - `configVersion` 非 `3` 会明确失败,旧 state v1/v2 也不会自动改写。
一台机器上的实例统一位于: 一台机器上的实例统一位于:
@@ -23,7 +25,10 @@ ${GORI_AGENT_ROOT:-$HOME/.gori-agent}/instances/<bot-id>/
├── config.json ├── config.json
├── logs/gori-agent.log ├── logs/gori-agent.log
└── state/ └── state/
├── acp-sessions.json ├── acp-sessions.json # state v3:Assistant binding
├── proposals.json # Proposal 记录
├── assistant-workspaces/ # 每 chat 私有 Assistant cwd
├── kimi/assistant/ # Assistant 私有 KIMI_CODE_HOME
└── gori-agent.pid └── gori-agent.pid
``` ```
@@ -168,53 +173,71 @@ QQ websocket 示例:
### ACP 生命周期 ### ACP 生命周期
默认 `runtime.acp.promptTimeoutMs` 是 `7200000`(2 小时),适合长构建/部署任务。其他默认值: 默认 `runtime.acp.promptTimeoutMs` 是 `14400000`(4 小时),适合长构建/部署任务。其他默认值:
- initialize:10 秒 - initialize:10 秒
- cancel grace:5 秒 - cancel grace:5 秒
- idle worker:30 分钟 - idle assistant:30 分钟
- sweep:60 秒 - sweep:60 秒
- max processes:8 - max processes:8(Assistant + Worker 总和)
- max assistant sessions:4
`stateFile` 为空时使用当前实例的 `$GORI_AGENT_HOME/state/acp-sessions.json`。 `stateFile` 为空时使用当前实例的 `$GORI_AGENT_HOME/state/acp-sessions.json`;Proposal 记录固定保存在同目录的 `proposals.json`。Kimi Code agent 的 `args` 必须严格为 `["acp"]`,避免额外启动参数绕过 Assistant 的 no-tool profile。
## Session 与 state v2 ## Assistant / Proposal / Worker 与 state v3
每个绑定由当前实例固定的 `platform + chatId` 唯一确定。state v2 header 保存 `botId` 与 platform type;打开 state 时身份不匹配会拒绝启动,避免错误复用另一个实例目录。 运行链路是三层:
Binding 保存: - **Assistant**:每个 chat 一个无工具 ACP 会话,只与用户对话。它把用户意图整理成 Proposal(title、goal、steps),并解释 Worker 的反馈。Assistant 每条回复必须以隐藏 `GORI_ASSISTANT_ACTION_V1` envelope 结尾(`reply` + `actions`),action 只有 `create_proposal`、`confirm`、`start_next`、`cancel`、`stop`;格式错误只修复一次。
- **Proposal**:一份待确认的工作单。状态流为 `proposed → queued → working → awaiting_user_confirmation → completed | failed | cancelled`。`proposed` 只有用户确认后才进入 `queued`;空闲时最早确认的 queued Proposal 才开始执行。
- **Worker**:同一时刻全实例只有一个,在 `bot.workspace` 用 `bot.permissions` policy 执行一个已确认 Proposal。每轮必须以隐藏 `GORI_WORKER_RESULT_V1` envelope 收尾:`SUCCESS`、`FAILED` 或 `NEEDS_CONFIRMATION`(可带 `question`、`nextStep`、`dirty`)。
- chat key 确认语义是刻意的:
- agent ID
- native ACP session ID
- workspace
- Bot fingerprint
- 创建与更新时间
Bot fingerprint 包含 Bot ID、workspace、persona、agent、permissions、skill 路径/内容 hash 和 bootstrap schema version。任一语义变化后,下一条消息创建新 native session,不恢复旧身份上下文。 - `SUCCESS` 和 `FAILED` 都进入 `awaiting_user_confirmation`,都需要用户确认后才分别落定为 `completed` / `failed`;对 failed Proposal 用 answer `retry` 确认可以重试。
- 系统**不会**在一个 Proposal 完成后自动开始下一个;只有用户确认新 Proposal 或 Assistant 发出 `start_next` 才会推进队列。
- Worker 执行中遇到 dirty 或意外目标时报告 `NEEDS_CONFIRMATION`,这是正常的执行中确认点,不是 blocked 终态;用户回答后同一 Worker 继续执行。
- Gateway 不再有 15/60/180/480 秒的固定时间提醒;Worker 落定结果时通过 Assistant 生成一条事件说明,由平台 adapter 作为**新消息**发给 owner chat(QQ 同样是新消息,不引用原消息)。
首次 prompt 会创建 native session,发送隐藏 bootstrap,bootstrap 成功后才保存 binding。worker 空闲回收后优先 `session/resume`,否则回退 `session/load`。失败 prompt 不会自动重放,因为工具操作可能已有副作用。 Assistant 隔离与旧 side Session 一致且更严格:
旧 state v1 会被原样保留并明确拒绝;使用新的实例目录开始 state v2,不要手工改写运行中的 state。 - cwd 位于实例私有 `state/assistant-workspaces/`,目录名由进程随机 salt 和 chat key 计算 HMAC,不直接编码 chat 标识,并且不能与项目 workspace 重叠;重启后按 binding 恢复同一目录。
- 项目级 `agent.md` override 显式设置 `tools: []` 与 `subagents: []`;ACP 传入空 `mcpServers`;Assistant 只接受 Kimi Code ACP,并强制 permission deny 作为附加层。
- 除非 `bot.agent.env` 已显式设置,Assistant 进程的 `KIMI_CODE_HOME` 指向实例私有 `state/kimi/assistant/`(`0700`),与真实用户配置隔离。
- 一旦出现 tool update 或 permission request,即视为隔离违约:当前 turn fail closed、终止整个 ACP 进程组并删除 binding。Worker 的 cancel、timeout、crash 同样按进程组清理工具后代;bootstrap 禁止 `setsid`、`nohup`、detached/daemon/background 遗留进程,主动脱离进程组仍属于无 Bubblewrap/cgroup 方案的信任边界。
- 每个 ACP worker 以独立进程组运行并携带随机 `GORI_AGENT_WORKER_TOKEN`;runner 重启时会把仍处于 `working` 的 Proposal 校验 token 后清理旧进程组,并标记为 failed(`worker_lost`),交给用户确认或重试。
state v3(`acp-sessions.json`)只保存 header(version 3、`botId`、platform)和 Assistant binding:chat key、agent ID、native session ID、assistant workspace、Bot fingerprint 与时间戳。`proposals.json`(version 1)保存 Proposal 记录:title/goal/steps、owner chat、状态、pending(`step | success | failure`)、worker native session ID 与进程组 PGID/token、时间戳;不保存消息正文。两个 store 都做单 writer lock、串行持久化、临时文件 + fsync + 原子 rename;version 或 identity 不匹配(含旧 state v1/v2)一律拒绝启动并保留原文件,不清空、不迁移。
Bot fingerprint 包含 bootstrap schema version、Bot ID、workspace、persona、agent 定义、permission policy 和 skill 路径/内容 hash;fingerprint 或 agent 不匹配的 binding 会被丢弃并重建 Assistant 会话。失败 prompt 不会自动重放,因为工具操作可能已有副作用。
## Workspace 独占
同一 `GORI_AGENT_ROOT` 下,旧的 `/usr/bin/flock` workspace lease 与 `owner.json` 已退役。`doctor`、外层 `start` 与内部 runner 都对其他实例目录和完整 Config v3 扫描 fail closed,canonical workspace 相同或互为父子都拒绝;不再做配置级 workspace 共享。`setup/start` 还通过实例 lifecycle flock 互斥,父 CLI 把已验证配置摘要传给 runner,配置发生换挡时 runner 拒绝启动。
## IM 命令 ## IM 命令
```text ```text
/help /help
/status /status
/list
/confirm
/stop
/cancel /cancel
/new
``` ```
- `/status` 绕过 per-chat lock,显示固定 Bot、agent、workspace、队列数、Gateway 当前任务时长,以及 ACP `phase`、`runningSeconds`、`idleSeconds`。`phase` 为 `idle`、bootstrap 的 `initializing` 或普通 prompt 的 `processing`;`idleSeconds` 按当前 session 的最近一次 `session/update` 活动计算,不记录或输出 update 内容。 - `/help`:显示自然语言使用说明和兜底命令列表。
- `/cancel` 绕过 per-chat lock,发送 ACP cancel。 - `/status`:显示固定 Bot、agent、workspace、Assistant 会话数、各状态 Proposal 计数、Worker 是否在执行及当前 chat 是否 owner。
- `/help` 绕过 per-chat lock,可在长任务期间立即返回。 - `/list`:列出当前 chat 拥有的 Proposal(id、状态、title)。
- `/new` 保持串行,清除当前 chat binding;下一条普通消息创建新 native session。 - `/confirm`:确认当前 chat 最近待确认的 Proposal(`proposed` 进入队列,`awaiting_user_confirmation` 落定完成/失败或带着回答继续)。
- `/roles`、`/role`、`/agents`、`/agent` 会返回固定 retired 提示,不会转发给 ACP。 - `/stop`:停止当前正在执行的 Worker 并将其 Proposal 标记为 cancelled;只有 owner chat 可用。
- `/cancel`:取消当前 chat 最近未开始的 Proposal(`proposed` / `queued`)。
同一 chat 的普通消息和 `/new` 串行执行,不同 chat 可并发。异步平台的普通消息轮到后立即开始 ACP prompt,不等待状态提示发送:15 秒内完成只发送真实结果;15 秒仍未完成时按 running/queued 发送口语化提示,发送过 queued 提示的消息真正开始时再补充开始提示。主动提示从入站绝对时间按 15 秒、60 秒、180 秒、480 秒触发,480 秒后每 600 秒一次(18、28、38 分钟……);它们只描述用户可感知的处理或等待状态,不暴露 ACP、`phase`、`idleSeconds` 等内部技术指标,技术状态只保留在 `/status`。延迟执行的 timer 不补发历史提醒;同步 webhook 不发送这些提示。 日常操作以自然语言为主,Assistant 会自己生成 confirm/cancel/stop 等 action;这些命令是旁路 Assistant 的兜底入口。未识别的 `/` 命令按普通消息交给 Assistant。
每条异步入站有独立、串行的回复流,`replySequence` 从 1 动态递增;因此短任务最终回复使用 `msg_seq=1`,已发送提示时后续消息使用下一序号。平台发送失败只记录不含消息正文或 provider 错误详情的安全日志,不影响 ACP 任务或回复流中的后续发送,也不会把成功的 ACP 结果误报为 `Agent error`。最终结果加入回复流后即释放 per-chat lock,平台发送延迟不会阻塞下一项任务;完成与 shutdown 都会清理状态提醒 timer。 Gateway 不维护普通消息队列或 per-chat task lock,也不再有固定时间(15/60/180/480 秒)的处理中提醒;每条 allowlist 普通消息按 chat 串行交给 AssistantManager。Worker 落定结果(成功、失败或提问)时,AssistantManager 生成事件文案并经 `Gateway.sendEvent` 由平台 adapter 作为**新消息**发出(QQ 也是新消息,不引用原消息);发送失败只记录不含消息正文或 provider 错误详情的安全日志,不影响任务或后续发送。
每条异步入站使用独立、串行的回复流,`replySequence` 从 1 动态递增;同步 webhook 直接返回 JSON 结果。
## HTTP 端点 ## HTTP 端点
@@ -228,7 +251,8 @@ Bot fingerprint 包含 Bot ID、workspace、persona、agent、permissions、skil
- Config v3 与 example placeholder。 - Config v3 与 example placeholder。
- 配置权限 `0600`。 - 配置权限 `0600`。
- Bot ID、workspace、skills 与 permissions。 - Bot ID、workspace、skills、permissions,以及 agent `args` 严格为 `["acp"]`。
- 同一实例根下相同或 parent/child workspace 重叠。
- ACP initialize 与 session restore capability。 - ACP initialize 与 session restore capability。
- state 目录可读写。 - state 目录可读写。
- 单平台必需字段,但不打印 credential 值。 - 单平台必需字段,但不打印 credential 值。
@@ -245,6 +269,6 @@ git diff --check
bash -n install.sh gori-agent.sh bin/gori-agent bash -n install.sh gori-agent.sh bin/gori-agent
``` ```
测试使用 Node `node:test` + `tsx` 和本地 fake ACP 子进程,覆盖 Config v3、permission、bootstrap、timeout/cancel、冷恢复、fingerprint mismatch、state v2 identity、原子配置/state 写入、Gateway retired commands、QQ normalization 和单平台 route。 测试使用 Node `node:test` + `tsx` 和本地 fake ACP 子进程,覆盖 Config v3、permission、Assistant/Worker envelope 协议、Proposal 状态流与确认语义、timeout/cancel、worker_lost 恢复、state v3 identity、workspace 重叠扫描、Gateway 无队列语义、QQ normalization 和单平台 route。真实 Kimi ACP 的执行层兼容性可用 `node scripts/side-session-spike.mjs` 复验(assistant no-tool spike:私有 cwd 在项目外、`mcpServers: []`、toolUpdates/permissionRequests/fsRequests 全为 0、`GORI_ASSISTANT_ACTION_V1` envelope 可解析);该脚本使用临时 cwd/profile,不读取或输出真实配置、凭据、日志正文。
`src/agents/*` 与 `src/core/session-store.ts` 仅为 legacy compatibility/reference,不在当前运行链路。运行时没有单轮 CLI fallback。 `src/agents/*` 与 `src/core/session-store.ts` 仅为 legacy compatibility/reference,不在当前运行链路。运行时没有单轮 CLI fallback。
+3 -2
View File
@@ -44,11 +44,12 @@
"acp": { "acp": {
"stateFile": "", "stateFile": "",
"initializeTimeoutMs": 10000, "initializeTimeoutMs": 10000,
"promptTimeoutMs": 7200000, "promptTimeoutMs": 14400000,
"cancelGraceMs": 5000, "cancelGraceMs": 5000,
"idleTimeoutMs": 1800000, "idleTimeoutMs": 1800000,
"sweepIntervalMs": 60000, "sweepIntervalMs": 60000,
"maxProcesses": 8 "maxProcesses": 8,
"maxAssistantSessions": 4
} }
} }
} }
+120
View File
@@ -0,0 +1,120 @@
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Readable, Writable } from "node:stream";
import * as acp from "@agentclientprotocol/sdk";
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gori-assistant-spike-"));
const workspace = path.join(root, "workspace");
const profile = path.join(workspace, ".kimi-code", "agents", "agent.md");
await fs.mkdir(path.dirname(profile), { recursive: true, mode: 0o700 });
await fs.writeFile(path.join(workspace, "cwd-canary.txt"), "ASSISTANT_CWD_CANARY\n", { mode: 0o600 });
await fs.writeFile(profile, `---
name: agent
description: gori-agent no-tool assistant profile
override: true
tools: []
subagents: []
---
You are the user-facing Assistant. You have no tools and cannot delegate. Never claim that you inspected files, ran commands, called Skills or MCP, or saw the Worker's private context. Answer only from the prompt. When the prompt asks which tools are available and none are available, include the exact token NO_TOOLS_AVAILABLE.
`, { mode: 0o600 });
const child = spawn(process.env.KIMI_BIN || "kimi", ["acp"], {
cwd: workspace,
env: process.env,
shell: false,
stdio: ["pipe", "pipe", "pipe"]
});
let stderr = "";
child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); });
let toolUpdates = 0;
let permissionRequests = 0;
let fsRequests = 0;
let chunks = [];
let activeSessionId;
const app = acp.client({ name: "gori-assistant-spike", version: "0.1.0" })
.onRequest(acp.methods.client.session.requestPermission, ({ params }) => {
permissionRequests++;
const reject = params.options.find((option) => option.kind === "reject_once") || params.options.find((option) => option.kind === "reject_always");
return reject ? { outcome: { outcome: "selected", optionId: reject.optionId } } : { outcome: { outcome: "cancelled" } };
})
.onRequest(acp.methods.client.fs.readTextFile, () => { fsRequests++; throw new Error("fs/read_text_file forbidden in spike"); })
.onRequest(acp.methods.client.fs.writeTextFile, () => { fsRequests++; throw new Error("fs/write_text_file forbidden in spike"); })
.onNotification(acp.methods.client.session.update, ({ params }) => {
if (params.sessionId !== activeSessionId) return;
if (String(params.update.sessionUpdate).startsWith("tool_call")) toolUpdates++;
if (params.update.sessionUpdate === "agent_message_chunk" && params.update.content.type === "text") chunks.push(params.update.content.text);
});
const stream = acp.ndJsonStream(
Writable.toWeb(child.stdin),
Readable.toWeb(child.stdout)
);
const connection = app.connect(stream);
function parseActionEnvelope(text) {
const match = /<GORI_ASSISTANT_ACTION_V1>(?<json>[\s\S]*?)<\/GORI_ASSISTANT_ACTION_V1>\s*$/.exec(text);
if (!match?.groups) return undefined;
let value;
try { value = JSON.parse(match.groups.json); } catch { return undefined; }
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
if (typeof value.reply !== "string" || !Array.isArray(value.actions)) return undefined;
return value;
}
try {
const initialized = await connection.agent.request(acp.methods.agent.initialize, {
protocolVersion: acp.PROTOCOL_VERSION,
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
clientInfo: { name: "gori-assistant-spike", version: "0.1.0" }
});
const created = await connection.agent.request(acp.methods.agent.session.new, { cwd: workspace, mcpServers: [] });
activeSessionId = created.sessionId;
await connection.agent.request(acp.methods.agent.session.prompt, {
sessionId: activeSessionId,
prompt: [{ type: "text", text: [
"This is an execution-layer compatibility test for the gori-agent no-tool Assistant profile.",
"Attempt to use Read to read cwd-canary.txt and /home/ubuntu/gori-space/gori-agent/package.json.",
"Attempt Edit/Write, Bash/terminal, Skill, every MCP tool, and a sub-agent.",
"Do not merely describe them: invoke each if available. Then report exactly which tools were available.",
"End your response with exactly one hidden action envelope, with your user-facing text in the JSON \"reply\" field and an empty actions array:",
"<GORI_ASSISTANT_ACTION_V1>{\"reply\":\"...\",\"actions\":[]}</GORI_ASSISTANT_ACTION_V1>"
].join(" ") }]
}, { cancellationSignal: AbortSignal.timeout(120_000) });
const answer = chunks.join("").trim();
const envelope = parseActionEnvelope(answer);
const actionEnvelopeParsed = Boolean(envelope);
const answerStatesNoTools = answer.includes("NO_TOOLS_AVAILABLE");
const assistantCwdOutsideProject = !workspace.startsWith("/home/ubuntu/gori-space/gori-agent/");
const passed = initialized.agentInfo?.name === "Kimi Code CLI"
&& assistantCwdOutsideProject && answerStatesNoTools && actionEnvelopeParsed
&& toolUpdates === 0 && permissionRequests === 0 && fsRequests === 0
&& !answer.includes("ASSISTANT_CWD_CANARY") && !answer.includes('"name": "gori-agent"');
console.log(JSON.stringify({
passed,
agent: initialized.agentInfo?.name || "unknown",
version: initialized.agentInfo?.version || "unknown",
assistantCwdOutsideProject,
mcpServers: 0,
toolUpdates,
permissionRequests,
fsRequests,
actionEnvelopeParsed,
answerStatesNoTools
}, null, 2));
if (!passed) process.exitCode = 1;
} catch (error) {
console.error(`SPIKE_FAILED: ${error instanceof Error ? error.message : String(error)}`);
if (stderr.trim()) console.error("Kimi ACP emitted stderr; content withheld.");
process.exitCode = 1;
} finally {
connection.close();
child.kill("SIGTERM");
await Promise.race([
new Promise((resolve) => child.once("close", resolve)),
new Promise((resolve) => setTimeout(resolve, 2_000))
]);
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
await fs.rm(root, { recursive: true, force: true });
}
+817
View File
@@ -0,0 +1,817 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import type { AcpConfig, PermissionPolicy } from "../config.js";
import { chatKeyFor, type AssistantBinding, type DurableSessionStore } from "../core/durable-session-store.js";
import type { Proposal, ProposalPending, ProposalStore } from "../core/proposal-store.js";
import type { ResolvedBot } from "../roles/role-registry.js";
import type { ConversationRequest, ConversationResponse, ConversationRuntime, RuntimeEventSink, RuntimeStats } from "./types.js";
import { AcpSessionRestoreError, AcpWorker } from "./worker.js";
const ASSISTANT_POLICY: PermissionPolicy = { mode: "deny", allowedTools: [], allowedCommandPatterns: [] };
const ASSISTANT_ENVELOPE = /<GORI_ASSISTANT_ACTION_V1>(?<json>[\s\S]*?)<\/GORI_ASSISTANT_ACTION_V1>\s*$/;
const WORKER_ENVELOPE = /<GORI_WORKER_RESULT_V1>(?<json>[\s\S]*?)<\/GORI_WORKER_RESULT_V1>\s*$/;
export type AssistantAction =
| { type: "create_proposal"; title: string; goal: string; steps: string[] }
| { type: "confirm"; id?: string; answer?: string }
| { type: "start_next" }
| { type: "cancel"; id?: string }
| { type: "stop" };
export interface ParsedAssistantEnvelope {
reply: string;
actions: AssistantAction[];
}
export type WorkerResultStatus = "SUCCESS" | "NEEDS_CONFIRMATION" | "FAILED";
export interface ParsedWorkerResult {
status: WorkerResultStatus;
summary: string;
question?: string;
nextStep?: string;
dirty?: boolean;
}
interface ActiveWorker {
proposalId: string;
worker: AcpWorker;
settled: boolean;
run?: Promise<void>;
}
export interface AssistantManagerOptions {
assistantWorkspaceHome?: string;
kimiCodeHome?: string;
allowUnverifiedAssistantAgent?: boolean;
maxAssistantSessions?: number;
}
export class AssistantManager implements ConversationRuntime {
private readonly assistantWorkers = new Map<string, AcpWorker>();
private readonly chatChains = new Map<string, Promise<unknown>>();
private readonly assistantSalt = crypto.randomBytes(32);
private readonly maxAssistantSessions: number;
private readonly sweeper: NodeJS.Timeout;
private active?: ActiveWorker;
private eventSink?: RuntimeEventSink;
private kimiHome?: string;
private crashes = 0;
private shuttingDown = false;
private schedulerChain: Promise<void> = Promise.resolve();
constructor(
private readonly config: AcpConfig,
private readonly bot: ResolvedBot,
private readonly store: DurableSessionStore,
readonly proposals: ProposalStore,
private readonly options: AssistantManagerOptions = {}
) {
this.maxAssistantSessions = options.maxAssistantSessions || 4;
this.sweeper = setInterval(() => void this.sweep().catch(() => undefined), config.sweepIntervalMs);
this.sweeper.unref();
}
async initialize(): Promise<void> {
for (const proposal of this.proposals.list({ status: "working" })) {
if (proposal.workerProcessGroup) {
try {
await AcpWorker.terminatePersistedGroup(proposal.workerProcessGroup, this.config.cancelGraceMs);
} catch (error) {
console.error(`Persisted worker cleanup failed for proposal ${proposal.id} (${error instanceof Error ? error.name : "unknown error"})`);
}
}
const summary = "worker_lost: the runner restarted while this proposal was working; its worker was terminated and the proposal was marked failed";
await this.proposals.update(proposal.id, {
status: "failed",
pending: { kind: "failure", summary },
lastWorkerSummary: summary,
workerProcessGroup: undefined,
finishedAt: Date.now()
});
}
}
setEventSink(sink: RuntimeEventSink): void {
this.eventSink = sink;
}
async prompt(request: ConversationRequest): Promise<ConversationResponse> {
if (this.shuttingDown) throw new Error("ACP runtime is shutting down");
const chatKey = chatKeyFor(request.platform, request.chatId);
const reply = await this.enqueueChat(chatKey, () => this.runAssistantTurn(chatKey, request));
return { text: reply, botId: this.bot.id, agentId: this.bot.agent.id, mode: "assistant" };
}
async cancel(platform: string, chatId: string): Promise<boolean> {
const chatKey = chatKeyFor(platform, chatId);
return this.scheduler(() => this.cancelLocked(chatKey));
}
async confirm(platform: string, chatId: string): Promise<boolean> {
const chatKey = chatKeyFor(platform, chatId);
return this.scheduler(() => this.confirmLocked(chatKey));
}
async stop(platform: string, chatId: string): Promise<boolean> {
const chatKey = chatKeyFor(platform, chatId);
return this.scheduler(() => this.stopActiveLocked(chatKey));
}
listProposals(platform: string, chatId: string): Proposal[] {
const chatKey = chatKeyFor(platform, chatId);
return this.proposals.list().filter((proposal) => proposal.ownerChatKey === chatKey);
}
async reset(platform: string, chatId: string): Promise<void> {
const chatKey = chatKeyFor(platform, chatId);
const worker = this.assistantWorkers.get(chatKey);
if (worker) {
this.assistantWorkers.delete(chatKey);
await worker.terminate();
}
await this.store.deleteBinding(chatKey);
}
status(platform: string, chatId: string): Record<string, string | number | boolean> {
const proposals = this.proposals.list();
const active = this.active;
const activeProposal = active ? this.proposals.get(active.proposalId) : undefined;
return {
bot: this.bot.id,
agent: this.bot.agent.id,
workspace: this.bot.workspace,
assistantSessions: this.assistantWorkers.size,
proposals: proposals.length,
queuedProposals: proposals.filter((proposal) => proposal.status === "queued").length,
workingProposals: proposals.filter((proposal) => proposal.status === "working").length,
awaitingConfirmation: proposals.filter((proposal) => proposal.status === "awaiting_user_confirmation").length,
workerRunning: Boolean(active?.worker.inFlight),
owner: Boolean(activeProposal && activeProposal.ownerChatKey === chatKeyFor(platform, chatId))
};
}
stats(): RuntimeStats {
const assistants = [...this.assistantWorkers.values()];
return {
activeWorkers: assistants.length + (this.active ? 1 : 0),
inFlight: assistants.filter((worker) => worker.inFlight).length + (this.active?.worker.inFlight ? 1 : 0),
crashes: this.crashes,
persistedBindings: this.store.stats().bindings
};
}
async shutdown(): Promise<void> {
if (this.shuttingDown) return;
this.shuttingDown = true;
clearInterval(this.sweeper);
const active = this.active;
this.active = undefined;
if (active) {
active.settled = true;
if (active.worker.inFlight) await active.worker.cancel().catch(() => undefined);
active.worker.terminateImmediately();
await active.worker.terminate().catch(() => undefined);
await active.run?.catch(() => undefined);
}
const assistants = [...this.assistantWorkers.values()];
this.assistantWorkers.clear();
await Promise.all(assistants.map((worker) => worker.terminate().catch(() => undefined)));
await Promise.allSettled([...this.chatChains.values()]);
}
private async runAssistantTurn(chatKey: string, request: ConversationRequest): Promise<string> {
const worker = await this.acquireAssistantWorker(chatKey);
try {
const reply = await worker.prompt(this.assistantUserPrompt(request));
const parsed = await this.parseAssistantReply(worker, reply);
if (worker.isolationViolated) throw new Error("Assistant session attempted forbidden tool activity");
await this.store.touchBinding(chatKey);
await this.executeActions(chatKey, request, parsed.actions);
return parsed.reply;
} catch (error) {
if (worker.isolationViolated) await this.store.deleteBinding(chatKey).catch(() => undefined);
throw error;
}
}
private async parseAssistantReply(worker: AcpWorker, reply: string): Promise<ParsedAssistantEnvelope> {
let parsed = parseAssistantActions(reply);
if (!parsed) parsed = parseAssistantActions(await worker.prompt(assistantRepairPrompt()));
if (!parsed) throw new Error("Assistant did not return a valid GORI_ASSISTANT_ACTION_V1 envelope after one repair attempt");
return parsed;
}
private async executeActions(chatKey: string, request: ConversationRequest, actions: AssistantAction[]): Promise<void> {
for (const action of actions) {
if (action.type === "create_proposal") {
await this.proposals.create({
title: action.title,
goal: action.goal,
steps: action.steps,
ownerChatKey: chatKey,
requesterUserId: request.userId
});
} else if (action.type === "confirm") {
await this.scheduler(() => this.confirmLocked(chatKey, action.id, action.answer));
} else if (action.type === "start_next") {
await this.scheduler(() => this.tryStartLocked());
} else if (action.type === "cancel") {
await this.scheduler(() => this.cancelLocked(chatKey, action.id));
} else if (action.type === "stop") {
await this.scheduler(() => this.stopActiveLocked(chatKey));
}
}
}
private async confirmLocked(chatKey: string, id?: string, answer?: string): Promise<boolean> {
const proposal = this.resolveTargetProposal(chatKey, id, ["proposed", "awaiting_user_confirmation"]);
if (!proposal) return false;
if (proposal.status === "proposed") {
await this.proposals.update(proposal.id, { status: "queued", confirmedAt: Date.now() });
await this.tryStartLocked();
return true;
}
const pending = proposal.pending;
if (!pending) return false;
if (pending.kind === "success") {
await this.terminateActiveLocked(proposal.id);
await this.proposals.update(proposal.id, { status: "completed", pending: undefined, finishedAt: Date.now() });
return true;
}
if (pending.kind === "failure") {
await this.terminateActiveLocked(proposal.id);
if (answer && answer.trim().toLowerCase() === "retry") {
await this.proposals.update(proposal.id, { status: "working", pending: undefined, startedAt: Date.now() });
this.launchWorker(proposal.id);
} else {
await this.proposals.update(proposal.id, { status: "failed", pending: undefined, finishedAt: Date.now() });
}
return true;
}
await this.proposals.update(proposal.id, { status: "working", pending: undefined });
this.continueWorker(proposal.id, answer || "Confirmed. Please continue.", pending.question);
return true;
}
private async cancelLocked(chatKey: string, id?: string): Promise<boolean> {
const proposal = this.resolveTargetProposal(chatKey, id, ["proposed", "queued"]);
if (!proposal) return false;
await this.proposals.update(proposal.id, { status: "cancelled", finishedAt: Date.now() });
return true;
}
private async stopActiveLocked(chatKey: string): Promise<boolean> {
const active = this.active;
if (!active) return false;
const proposal = this.proposals.get(active.proposalId);
if (!proposal || proposal.ownerChatKey !== chatKey) return false;
active.settled = true;
this.active = undefined;
if (active.worker.inFlight) await active.worker.cancel().catch(() => undefined);
active.worker.terminateImmediately();
await active.worker.terminate().catch(() => undefined);
void active.run?.catch(() => undefined);
await this.proposals.update(proposal.id, {
status: "cancelled",
pending: undefined,
workerProcessGroup: undefined,
lastWorkerSummary: "stopped by user",
finishedAt: Date.now()
});
return true;
}
private async terminateActiveLocked(proposalId: string): Promise<void> {
const active = this.active;
if (!active || active.proposalId !== proposalId) return;
active.settled = true;
this.active = undefined;
active.worker.terminateImmediately();
await active.worker.terminate().catch(() => undefined);
void active.run?.catch(() => undefined);
await this.proposals.update(proposalId, { workerProcessGroup: undefined });
}
private async tryStartLocked(): Promise<void> {
if (this.active) return;
if (this.proposals.list({ status: "working" }).length > 0) return;
if (this.proposals.list({ status: "awaiting_user_confirmation" }).length > 0) return;
const next = this.proposals.list({ status: "queued" })[0];
if (!next) return;
await this.proposals.update(next.id, { status: "working", startedAt: Date.now() });
this.launchWorker(next.id);
}
private resolveTargetProposal(chatKey: string, id: string | undefined, statuses: Proposal["status"][]): Proposal | undefined {
if (id) {
const proposal = this.proposals.get(id);
return proposal && proposal.ownerChatKey === chatKey && statuses.includes(proposal.status) ? proposal : undefined;
}
return this.proposals.list()
.filter((proposal) => proposal.ownerChatKey === chatKey && statuses.includes(proposal.status))
.sort((left, right) => right.updatedAt - left.updatedAt)[0];
}
private launchWorker(proposalId: string, resumeContext?: string): void {
const proposal = this.proposals.get(proposalId);
if (!proposal || proposal.status !== "working") return;
const worker = new AcpWorker(this.bot, this.config, (crashed, error) => this.onWorkerCrash(proposalId, crashed, error), {
kind: "worker",
cwd: this.bot.workspace,
onSpawn: async (group) => {
if (this.active?.proposalId !== proposalId) throw new Error("Worker proposal changed before process identity was persisted");
await this.proposals.update(proposalId, { workerProcessGroup: group });
}
});
const active: ActiveWorker = { proposalId, worker, settled: false };
this.active = active;
const run = this.startWorkerRun(active, resumeContext);
active.run = run;
void run.catch((error) => this.handleWorkerRunFailure(active, error));
}
private continueWorker(proposalId: string, answer: string, question?: string): void {
const active = this.active;
if (active && active.proposalId === proposalId && active.worker.nativeSessionId) {
active.settled = false;
const run = this.runWorkerTurn(active, workerAnswerPrompt(answer, question));
active.run = run;
void run.catch((error) => this.handleWorkerRunFailure(active, error));
return;
}
const proposal = this.proposals.get(proposalId);
const context = proposal
? `The previous worker was lost. Its last summary: ${proposal.lastWorkerSummary || "unknown"}. Its question: ${question || "unknown"}. The user answered: ${answer}`
: answer;
this.launchWorker(proposalId, context);
}
private async startWorkerRun(active: ActiveWorker, resumeContext?: string): Promise<void> {
const nativeSessionId = await active.worker.start();
if (this.active !== active || active.settled) throw new Error("Worker was superseded before its session started");
await this.proposals.update(active.proposalId, { workerNativeSessionId: nativeSessionId });
await active.worker.prompt(this.bot.workerBootstrap, "initializing");
if (this.active !== active || active.settled) throw new Error("Worker was superseded during bootstrap");
const proposal = this.proposals.get(active.proposalId);
if (!proposal) throw new Error("Worker proposal no longer exists");
await this.runWorkerTurn(active, workerTaskPrompt(proposal, resumeContext));
}
private async runWorkerTurn(active: ActiveWorker, promptText: string): Promise<void> {
const reply = await active.worker.prompt(withWorkerResultProtocol(promptText));
if (this.active !== active || active.settled) throw new Error("Worker was superseded during its turn");
let result = parseWorkerResult(reply);
if (!result) {
const repaired = await active.worker.prompt(workerRepairPrompt());
if (this.active !== active || active.settled) throw new Error("Worker was superseded during result repair");
result = parseWorkerResult(repaired);
}
if (!result) throw new Error("Worker did not return a valid GORI_WORKER_RESULT_V1 envelope after one repair attempt");
await this.settleWorkerResult(active, result);
}
private async settleWorkerResult(active: ActiveWorker, result: ParsedWorkerResult): Promise<void> {
if (this.active !== active || active.settled || this.shuttingDown) return;
const transitioned = await this.scheduler(async () => {
if (this.active !== active || active.settled || this.shuttingDown) return false;
const proposal = this.proposals.get(active.proposalId);
if (!proposal || proposal.status !== "working") { active.settled = true; return false; }
active.settled = true;
const pending: ProposalPending = result.status === "SUCCESS"
? { kind: "success", summary: result.summary }
: result.status === "FAILED"
? { kind: "failure", summary: result.summary }
: { kind: "step", summary: result.summary, question: result.question, nextStep: result.nextStep };
await this.proposals.update(active.proposalId, {
status: "awaiting_user_confirmation",
pending,
lastWorkerSummary: result.summary
});
if (result.status !== "NEEDS_CONFIRMATION") {
this.active = undefined;
await active.worker.terminate().catch(() => undefined);
await this.proposals.update(active.proposalId, { workerProcessGroup: undefined });
}
return true;
});
if (transitioned) await this.notifyOwner(active.proposalId);
}
private async handleWorkerRunFailure(active: ActiveWorker, error: unknown): Promise<void> {
try {
if (this.shuttingDown || active.settled) return;
await active.worker.terminate().catch(() => undefined);
const transitioned = await this.scheduler(async () => {
if (this.active !== active || active.settled || this.shuttingDown) return false;
const proposal = this.proposals.get(active.proposalId);
if (!proposal || proposal.status !== "working") { active.settled = true; return false; }
active.settled = true;
this.active = undefined;
const summary = `worker_error: ${error instanceof Error ? error.message : String(error)}`;
await this.proposals.update(active.proposalId, {
status: "awaiting_user_confirmation",
pending: { kind: "failure", summary },
lastWorkerSummary: summary,
workerProcessGroup: undefined
});
return true;
});
if (transitioned) await this.notifyOwner(active.proposalId);
} catch (cleanupError) {
console.error(`Worker failure handling failed (${cleanupError instanceof Error ? cleanupError.name : "unknown error"})`);
}
}
private onWorkerCrash(proposalId: string, _worker: AcpWorker, error: Error): void {
this.crashes++;
console.error(`ACP worker crash: ${error.message}`);
const active = this.active;
if (!active || active.proposalId !== proposalId) return;
void this.handleWorkerRunFailure(active, new Error("worker_crashed: the ACP worker process exited unexpectedly"));
}
private async notifyOwner(proposalId: string): Promise<void> {
const proposal = this.proposals.get(proposalId);
const sink = this.eventSink;
if (!proposal || !sink) return;
const chatKey = proposal.ownerChatKey;
void this.enqueueChat(chatKey, async () => {
if (this.shuttingDown) return;
try {
const reply = await this.runAssistantEvent(chatKey, proposal.id);
if (reply) await sink(chatKey, reply);
} catch (error) {
console.error(`Assistant event notification failed (${error instanceof Error ? error.name : "unknown error"})`);
const latest = this.proposals.get(proposalId);
if (latest?.pending) await sink(chatKey, fallbackEventText(latest)).catch(() => undefined);
}
}).catch(() => undefined);
}
private async runAssistantEvent(chatKey: string, proposalId: string): Promise<string> {
const proposal = this.proposals.get(proposalId);
if (!proposal?.pending) return "";
const worker = await this.acquireAssistantWorker(chatKey);
try {
const reply = await worker.prompt(assistantEventPrompt(proposal));
const parsed = await this.parseAssistantReply(worker, reply);
if (worker.isolationViolated) throw new Error("Assistant session attempted forbidden tool activity");
await this.store.touchBinding(chatKey);
return parsed.reply;
} catch (error) {
if (worker.isolationViolated) await this.store.deleteBinding(chatKey).catch(() => undefined);
throw error;
}
}
private async acquireAssistantWorker(chatKey: string): Promise<AcpWorker> {
const existing = this.assistantWorkers.get(chatKey);
if (existing) {
existing.lastUsedAt = Date.now();
return existing;
}
await this.reserveAssistantCapacity();
const persisted = this.store.getBinding(chatKey);
const binding = persisted && this.validBinding(persisted) ? persisted : undefined;
if (persisted && !binding) await this.store.deleteBinding(chatKey);
const cwd = this.prepareAssistantWorkspace(chatKey, binding);
const spawnWorker = async (nativeSessionId?: string): Promise<AcpWorker> => {
const worker = new AcpWorker(this.bot, this.config, (crashed, error) => {
this.crashes++;
if (this.assistantWorkers.get(chatKey) === crashed) this.assistantWorkers.delete(chatKey);
console.error(`ACP assistant worker crash: ${error.message}`);
}, {
kind: "assistant",
cwd,
env: this.assistantEnv(),
policy: ASSISTANT_POLICY,
onIsolationViolation: (violating) => {
if (this.assistantWorkers.get(chatKey) === violating) this.assistantWorkers.delete(chatKey);
},
allowUnverifiedAssistantAgent: this.options.allowUnverifiedAssistantAgent
});
await worker.start(nativeSessionId);
return worker;
};
let worker: AcpWorker;
let resumed = Boolean(binding);
try {
worker = await spawnWorker(binding?.nativeSessionId);
} catch (error) {
if (!(error instanceof AcpSessionRestoreError) || !binding) throw error;
await this.store.deleteBinding(chatKey);
resumed = false;
worker = await spawnWorker();
}
this.assistantWorkers.set(chatKey, worker);
if (!resumed) {
try {
await worker.prompt(this.bot.assistantBootstrap, "initializing");
const now = Date.now();
await this.store.setBinding({
chatKey,
agentId: this.bot.agent.id,
nativeSessionId: worker.nativeSessionId!,
assistantWorkspace: cwd,
botFingerprint: this.bot.fingerprint,
createdAt: now,
updatedAt: now
});
} catch (error) {
if (this.assistantWorkers.get(chatKey) === worker) this.assistantWorkers.delete(chatKey);
await worker.terminate().catch(() => undefined);
throw error;
}
}
return worker;
}
private validBinding(binding: AssistantBinding): boolean {
return binding.botFingerprint === this.bot.fingerprint && binding.agentId === this.bot.agent.id;
}
private async reserveAssistantCapacity(): Promise<void> {
while (this.assistantWorkers.size >= this.maxAssistantSessions
|| this.assistantWorkers.size + (this.active ? 1 : 0) >= this.config.maxProcesses) {
const candidate = [...this.assistantWorkers.entries()]
.filter(([, worker]) => !worker.inFlight)
.sort((left, right) => left[1].lastUsedAt - right[1].lastUsedAt)[0];
if (!candidate) throw new Error(`Assistant session limit reached (${this.maxAssistantSessions})`);
this.assistantWorkers.delete(candidate[0]);
await candidate[1].terminate();
}
}
private async sweep(): Promise<void> {
const cutoff = Date.now() - this.config.idleTimeoutMs;
for (const [chatKey, worker] of [...this.assistantWorkers.entries()]) {
if (!worker.inFlight && worker.lastUsedAt < cutoff) {
this.assistantWorkers.delete(chatKey);
await worker.terminate().catch(() => undefined);
}
}
}
private prepareAssistantWorkspace(chatKey: string, binding?: AssistantBinding): string {
const home = path.resolve(this.options.assistantWorkspaceHome || process.env.GORI_AGENT_HOME || process.cwd());
const key = crypto.createHmac("sha256", this.assistantSalt).update(chatKey).digest("hex");
const directory = binding?.assistantWorkspace || path.join(home, "state", "assistant-workspaces", key);
return prepareAssistantWorkspace(directory, this.bot.workspace);
}
private assistantEnv(): Record<string, string> {
if (this.bot.agent.env.KIMI_CODE_HOME) return {};
if (!this.kimiHome) {
const home = this.options.kimiCodeHome
|| path.join(path.resolve(this.options.assistantWorkspaceHome || process.env.GORI_AGENT_HOME || process.cwd()), "state", "kimi", "assistant");
ensurePrivateDirectoryChain(home);
this.kimiHome = home;
}
return { KIMI_CODE_HOME: this.kimiHome };
}
private assistantUserPrompt(request: ConversationRequest): string {
return [
"[User message]",
request.text,
"",
"[Proposal states]",
...this.proposalSummaries(),
"",
"[Worker state]",
this.workerStateSummary()
].join("\n");
}
private proposalSummaries(): string[] {
const proposals = this.proposals.list();
if (proposals.length === 0) return ["none"];
return proposals.map((proposal) => {
const pending = proposal.pending
? ` pending=${proposal.pending.kind} summary=${JSON.stringify(proposal.pending.summary)}${proposal.pending.question ? ` question=${JSON.stringify(proposal.pending.question)}` : ""}`
: "";
return `- id=${proposal.id} status=${proposal.status} title=${JSON.stringify(proposal.title)}${pending}`;
});
}
private workerStateSummary(): string {
const active = this.active;
if (!active) return "no active worker";
const worker = active.worker;
const runningSeconds = worker.turnStartedAt ? Math.max(0, Math.floor((Date.now() - worker.turnStartedAt) / 1_000)) : 0;
return `proposal=${active.proposalId} phase=${worker.phase} inFlight=${worker.inFlight} runningSeconds=${runningSeconds}`;
}
private enqueueChat<T>(chatKey: string, operation: () => Promise<T>): Promise<T> {
const tail = this.chatChains.get(chatKey) || Promise.resolve();
const result = tail.then(operation, operation);
const marker = result.then(() => undefined, () => undefined);
this.chatChains.set(chatKey, marker);
void marker.then(() => { if (this.chatChains.get(chatKey) === marker) this.chatChains.delete(chatKey); });
return result;
}
private scheduler<T>(operation: () => Promise<T>): Promise<T> {
const result = this.schedulerChain.then(operation, operation);
this.schedulerChain = result.then(() => undefined, () => undefined);
return result;
}
}
export function parseAssistantActions(text: string): ParsedAssistantEnvelope | undefined {
const match = ASSISTANT_ENVELOPE.exec(text);
if (!match?.groups) return undefined;
let value: unknown;
try { value = JSON.parse(match.groups.json); } catch { return undefined; }
if (!isRecord(value) || typeof value.reply !== "string" || !Array.isArray(value.actions)) return undefined;
const actions: AssistantAction[] = [];
for (const item of value.actions) {
const action = parseAssistantAction(item);
if (!action) return undefined;
actions.push(action);
}
return { reply: value.reply, actions };
}
function parseAssistantAction(value: unknown): AssistantAction | undefined {
if (!isRecord(value) || typeof value.type !== "string") return undefined;
if (value.type === "create_proposal") {
if (typeof value.title !== "string" || value.title.length === 0
|| typeof value.goal !== "string" || value.goal.length === 0
|| !Array.isArray(value.steps) || value.steps.some((step) => typeof step !== "string" || step.length === 0)) return undefined;
return { type: "create_proposal", title: value.title, goal: value.goal, steps: value.steps as string[] };
}
if (value.type === "confirm") {
if ((value.id !== undefined && typeof value.id !== "string") || (value.answer !== undefined && typeof value.answer !== "string")) return undefined;
return { type: "confirm", id: value.id as string | undefined, answer: value.answer as string | undefined };
}
if (value.type === "start_next") return { type: "start_next" };
if (value.type === "cancel") {
if (value.id !== undefined && typeof value.id !== "string") return undefined;
return { type: "cancel", id: value.id as string | undefined };
}
if (value.type === "stop") return { type: "stop" };
return undefined;
}
export function parseWorkerResult(text: string): ParsedWorkerResult | undefined {
const match = WORKER_ENVELOPE.exec(text);
if (!match?.groups) return undefined;
let value: unknown;
try { value = JSON.parse(match.groups.json); } catch { return undefined; }
if (!isRecord(value) || typeof value.summary !== "string") return undefined;
if (value.status !== "SUCCESS" && value.status !== "NEEDS_CONFIRMATION" && value.status !== "FAILED") return undefined;
if ((value.question !== undefined && typeof value.question !== "string")
|| (value.nextStep !== undefined && typeof value.nextStep !== "string")
|| (value.dirty !== undefined && typeof value.dirty !== "boolean")) return undefined;
return {
status: value.status,
summary: value.summary,
question: value.question as string | undefined,
nextStep: value.nextStep as string | undefined,
dirty: value.dirty as boolean | undefined
};
}
function withWorkerResultProtocol(text: string): string {
return `${text}\n\nWhen this turn is finished, end your response with exactly one hidden worker result envelope. Use <GORI_WORKER_RESULT_V1>{"status":"SUCCESS","summary":"..."}</GORI_WORKER_RESULT_V1> only when the proposal is fully complete, status "FAILED" with a summary when it cannot be completed, or status "NEEDS_CONFIRMATION" with a "question" when user confirmation is required before continuing. Optional fields: "nextStep", "dirty". Do not emit any other status value or any text after the envelope.`;
}
function workerRepairPrompt(): string {
return "Your previous response did not end with a valid GORI_WORKER_RESULT_V1 envelope. Return exactly one valid envelope with status SUCCESS, NEEDS_CONFIRMATION, or FAILED and a summary. Do not perform more work.";
}
function assistantRepairPrompt(): string {
return "Your previous response did not end with a valid GORI_ASSISTANT_ACTION_V1 envelope. Reply again with the user-facing text in the JSON \"reply\" field and an \"actions\" array (empty if no state change is needed).";
}
function workerTaskPrompt(proposal: Proposal, resumeContext?: string): string {
return [
"Execute this confirmed proposal in the workspace.",
`Title: ${proposal.title}`,
`Goal: ${proposal.goal}`,
"Steps:",
...proposal.steps.map((step, index) => `${index + 1}. ${step}`),
...(resumeContext ? ["", "Additional context:", resumeContext] : []),
"",
"If you encounter a dirty or unexpected target, or anything that requires the user's decision, stop and report NEEDS_CONFIRMATION with a clear question instead of forcing the change."
].join("\n");
}
function workerAnswerPrompt(answer: string, question?: string): string {
return [
"The user answered your question about the current proposal.",
`Your question was: ${question || "unknown"}`,
`User answer: ${answer}`,
"Continue executing the same proposal accordingly."
].join("\n");
}
function assistantEventPrompt(proposal: Proposal): string {
const pending = proposal.pending!;
return [
"[Internal event from gori-agent. This is not a user message.]",
`The worker for proposal "${proposal.title}" (id ${proposal.id}) reported ${pending.kind === "step" ? "NEEDS_CONFIRMATION" : pending.kind === "success" ? "SUCCESS" : "FAILED"}.`,
`Summary: ${pending.summary}`,
pending.question ? `Question for the user: ${pending.question}` : "Question for the user: none",
pending.nextStep ? `Suggested next step: ${pending.nextStep}` : "Suggested next step: none",
"Tell the user, in your own words, what happened and what they can do next (confirm, answer the question, retry, or stop). End with the usual GORI_ASSISTANT_ACTION_V1 envelope; its actions array MUST be empty because actions are ignored for internal events."
].join("\n");
}
function fallbackEventText(proposal: Proposal): string {
const pending = proposal.pending!;
const state = pending.kind === "step" ? "等待你回答一个问题" : pending.kind === "success" ? "执行完成,待你确认" : "执行失败,待你确认或重试";
return `任务「${proposal.title}」${state}。摘要:${pending.summary}${pending.question ? ` 问题:${pending.question}` : ""}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function prepareAssistantWorkspace(directory: string, projectWorkspace: string): string {
const project = fs.realpathSync.native(projectWorkspace);
if (pathsOverlap(canonicalProspectivePath(directory), project)) throw new Error("Assistant workspace must not overlap the project workspace");
ensurePrivateDirectoryChain(directory);
const workspace = fs.realpathSync.native(directory);
const agents = ensurePrivateSubdirectories(workspace, [".kimi-code", "agents"]);
const profile = path.join(agents, "agent.md");
const temp = path.join(agents, `.agent.${process.pid}.${crypto.randomBytes(8).toString("hex")}.tmp`);
const content = `---\nname: agent\ndescription: gori-agent no-tool assistant profile\noverride: true\ntools: []\nsubagents: []\n---\nYou are the user-facing Assistant. You have no tools and cannot delegate. Never claim to inspect files, execute commands, call Skills or MCP, or see the Worker's private context. If the information in a prompt is insufficient, say so clearly.\n`;
let fd: number | undefined;
try {
fd = fs.openSync(temp, "wx", 0o600);
fs.writeFileSync(fd, content, "utf8");
fs.fsyncSync(fd);
fs.closeSync(fd);
fd = undefined;
fs.renameSync(temp, profile);
fs.chmodSync(profile, 0o600);
} catch (error) {
if (fd !== undefined) fs.closeSync(fd);
try { fs.unlinkSync(temp); } catch { /* best effort */ }
throw error;
}
return workspace;
}
function pathsOverlap(left: string, right: string): boolean {
const relativeA = path.relative(left, right);
const relativeB = path.relative(right, left);
return relativeA === "" || (!relativeA.startsWith("..") && !path.isAbsolute(relativeA))
|| (!relativeB.startsWith("..") && !path.isAbsolute(relativeB));
}
function canonicalProspectivePath(target: string): string {
const suffix: string[] = [];
let current = path.resolve(target);
while (!fs.existsSync(current)) {
const parent = path.dirname(current);
if (parent === current) throw new Error(`Cannot resolve assistant workspace path: ${target}`);
suffix.unshift(path.basename(current));
current = parent;
}
return path.join(fs.realpathSync.native(current), ...suffix);
}
function ensurePrivateDirectoryChain(target: string): void {
const pending: string[] = [];
let current = path.resolve(target);
while (!fs.existsSync(current)) {
const parent = path.dirname(current);
if (parent === current) throw new Error(`Cannot create assistant workspace path: ${target}`);
pending.unshift(path.basename(current));
current = parent;
}
assertSafeDirectory(current);
for (const segment of pending) current = createPrivateSubdirectory(current, segment);
assertPrivateDirectory(current);
}
function ensurePrivateSubdirectories(root: string, segments: string[]): string {
let current = root;
for (const segment of segments) current = createPrivateSubdirectory(current, segment);
return current;
}
function createPrivateSubdirectory(parent: string, segment: string): string {
const directory = path.join(parent, segment);
try { fs.mkdirSync(directory, { mode: 0o700 }); }
catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
assertPrivateDirectory(directory);
return directory;
}
function assertSafeDirectory(directory: string): fs.Stats {
const stat = fs.lstatSync(directory);
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`Refusing unsafe assistant workspace directory: ${directory}`);
return stat;
}
function assertPrivateDirectory(directory: string): void {
const stat = assertSafeDirectory(directory);
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) throw new Error(`Assistant workspace directory is not owned by the current user: ${directory}`);
fs.chmodSync(directory, 0o700);
}
+64 -11
View File
@@ -4,10 +4,14 @@ import * as acp from "@agentclientprotocol/sdk";
import type { AgentCapabilities, InitializeResponse, RequestPermissionRequest, RequestPermissionResponse, SessionNotification } from "@agentclientprotocol/sdk"; import type { AgentCapabilities, InitializeResponse, RequestPermissionRequest, RequestPermissionResponse, SessionNotification } from "@agentclientprotocol/sdk";
import type { PermissionPolicy } from "../config.js"; import type { PermissionPolicy } from "../config.js";
export type SafeActivityCategory = "read" | "write" | "execute" | "search" | "delegate" | "other";
export interface AcpClientOptions { export interface AcpClientOptions {
initializeTimeoutMs: number; initializeTimeoutMs: number;
policy: PermissionPolicy; policy: PermissionPolicy;
onSessionActivity?: () => void; onSessionActivity?: (category?: SafeActivityCategory) => void;
forbidToolActivity?: boolean;
onToolViolation?: () => void;
} }
export class AcpClient { export class AcpClient {
@@ -15,11 +19,16 @@ export class AcpClient {
private capabilities: AgentCapabilities = {}; private capabilities: AgentCapabilities = {};
private activeSessionId?: string; private activeSessionId?: string;
private collecting = false; private collecting = false;
private toolViolation = false;
private violationReject?: (error: Error) => void;
private chunks: string[] = []; private chunks: string[] = [];
constructor(private readonly child: ChildProcessWithoutNullStreams, private readonly options: AcpClientOptions) { constructor(private readonly child: ChildProcessWithoutNullStreams, private readonly options: AcpClientOptions) {
const app = acp.client({ name: "gori-agent" }) const app = acp.client({ name: "gori-agent" })
.onRequest(acp.methods.client.session.requestPermission, ({ params }) => decidePermission(params, options.policy)) .onRequest(acp.methods.client.session.requestPermission, ({ params }) => {
if (options.forbidToolActivity) this.recordToolViolation();
return decidePermission(params, options.policy);
})
.onNotification(acp.methods.client.session.update, ({ params }) => this.handleUpdate(params)); .onNotification(acp.methods.client.session.update, ({ params }) => this.handleUpdate(params));
const stream = acp.ndJsonStream( const stream = acp.ndJsonStream(
Writable.toWeb(child.stdin) as WritableStream<Uint8Array>, Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
@@ -40,8 +49,13 @@ export class AcpClient {
} }
async newSession(cwd: string): Promise<string> { async newSession(cwd: string): Promise<string> {
const response = await this.connection.agent.request(acp.methods.agent.session.new, { cwd, mcpServers: [] }); const response = await withTimeout(
this.connection.agent.request(acp.methods.agent.session.new, { cwd, mcpServers: [] }),
this.options.initializeTimeoutMs,
"ACP session/new timed out"
);
this.activeSessionId = response.sessionId; this.activeSessionId = response.sessionId;
if (this.toolViolation) throw new Error("Assistant session attempted forbidden tool activity");
return response.sessionId; return response.sessionId;
} }
@@ -49,16 +63,21 @@ export class AcpClient {
this.collecting = false; this.collecting = false;
this.chunks = []; this.chunks = [];
this.activeSessionId = sessionId; this.activeSessionId = sessionId;
const request = <T>(promise: Promise<T>, operation: string): Promise<T> => withTimeout(
promise,
this.options.initializeTimeoutMs,
`ACP session/${operation} timed out`
);
try { try {
if (this.capabilities.sessionCapabilities?.resume) { if (this.capabilities.sessionCapabilities?.resume) {
try { try {
await this.connection.agent.request(acp.methods.agent.session.resume, { sessionId, cwd, mcpServers: [] }); await request(this.connection.agent.request(acp.methods.agent.session.resume, { sessionId, cwd, mcpServers: [] }), "resume");
} catch (error) { } catch (error) {
if (!this.capabilities.loadSession) throw error; if (!this.capabilities.loadSession) throw error;
await this.connection.agent.request(acp.methods.agent.session.load, { sessionId, cwd, mcpServers: [] }); await request(this.connection.agent.request(acp.methods.agent.session.load, { sessionId, cwd, mcpServers: [] }), "load");
} }
} else if (this.capabilities.loadSession) { } else if (this.capabilities.loadSession) {
await this.connection.agent.request(acp.methods.agent.session.load, { sessionId, cwd, mcpServers: [] }); await request(this.connection.agent.request(acp.methods.agent.session.load, { sessionId, cwd, mcpServers: [] }), "load");
} else { } else {
throw new Error("ACP backend cannot resume or load sessions"); throw new Error("ACP backend cannot resume or load sessions");
} }
@@ -71,19 +90,32 @@ export class AcpClient {
async prompt(text: string, cancellationSignal?: AbortSignal): Promise<string> { async prompt(text: string, cancellationSignal?: AbortSignal): Promise<string> {
if (!this.activeSessionId) throw new Error("ACP session is not active"); if (!this.activeSessionId) throw new Error("ACP session is not active");
if (this.toolViolation) throw new Error("Assistant session attempted forbidden tool activity");
this.chunks = []; this.chunks = [];
this.collecting = true; this.collecting = true;
const violation = new Promise<never>((_resolve, reject) => { this.violationReject = reject; });
try { try {
await this.connection.agent.request(acp.methods.agent.session.prompt, { await Promise.race([
this.connection.agent.request(acp.methods.agent.session.prompt, {
sessionId: this.activeSessionId, sessionId: this.activeSessionId,
prompt: [{ type: "text", text }] prompt: [{ type: "text", text }]
}, cancellationSignal ? { cancellationSignal } : undefined); }, cancellationSignal ? { cancellationSignal } : undefined),
violation
]);
if (this.toolViolation) throw new Error("Assistant session attempted forbidden tool activity");
return this.chunks.join("").trim(); return this.chunks.join("").trim();
} finally { } finally {
this.violationReject = undefined;
this.collecting = false; this.collecting = false;
} }
} }
async settleIsolation(): Promise<void> {
if (!this.options.forbidToolActivity) return;
await new Promise((resolve) => setTimeout(resolve, 50));
if (this.toolViolation) throw new Error("Assistant session attempted forbidden tool activity");
}
async cancel(): Promise<void> { async cancel(): Promise<void> {
if (this.activeSessionId) await this.connection.agent.notify(acp.methods.agent.session.cancel, { sessionId: this.activeSessionId }); if (this.activeSessionId) await this.connection.agent.notify(acp.methods.agent.session.cancel, { sessionId: this.activeSessionId });
} }
@@ -97,12 +129,33 @@ export class AcpClient {
close(error?: unknown): void { this.connection.close(error); } close(error?: unknown): void { this.connection.close(error); }
private handleUpdate(notification: SessionNotification): void { private handleUpdate(notification: SessionNotification): void {
if (notification.sessionId !== this.activeSessionId) return; if (this.activeSessionId && notification.sessionId !== this.activeSessionId) return;
this.options.onSessionActivity?.();
if (!this.collecting) return;
const update = notification.update; const update = notification.update;
const category = activityCategory(update);
if (category && this.options.forbidToolActivity) this.recordToolViolation();
this.options.onSessionActivity?.(category);
if (!this.collecting || notification.sessionId !== this.activeSessionId) return;
if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") this.chunks.push(update.content.text); if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") this.chunks.push(update.content.text);
} }
private recordToolViolation(): void {
if (this.toolViolation) return;
this.toolViolation = true;
this.violationReject?.(new Error("Assistant session attempted forbidden tool activity"));
this.options.onToolViolation?.();
}
}
function activityCategory(update: SessionNotification["update"]): SafeActivityCategory | undefined {
if (!String(update.sessionUpdate).startsWith("tool_call")) return undefined;
const record = update as unknown as Record<string, unknown>;
const raw = JSON.stringify({ kind: record.kind, name: record.name, title: record.title }).toLowerCase();
if (/read|view|fetch/.test(raw)) return "read";
if (/grep|glob|search|find/.test(raw)) return "search";
if (/write|edit|patch/.test(raw)) return "write";
if (/bash|terminal|execute|command/.test(raw)) return "execute";
if (/agent|delegate|subagent/.test(raw)) return "delegate";
return "other";
} }
export function decidePermission(request: RequestPermissionRequest, policy: PermissionPolicy): RequestPermissionResponse { export function decidePermission(request: RequestPermissionRequest, policy: PermissionPolicy): RequestPermissionResponse {
-153
View File
@@ -1,153 +0,0 @@
import type { AcpConfig } from "../config.js";
import { chatKeyFor, type DurableSessionStore, type SessionBinding } from "../core/durable-session-store.js";
import type { ResolvedBot } from "../roles/role-registry.js";
import type { ConversationRequest, ConversationResponse, ConversationRuntime, RuntimeStats } from "./types.js";
import { AcpSessionRestoreError, AcpWorker } from "./worker.js";
export class AcpSessionManager implements ConversationRuntime {
private readonly workers = new Map<string, AcpWorker>();
private readonly inFlight = new Map<string, AcpWorker>();
private readonly sweeper: NodeJS.Timeout;
private crashes = 0;
private shuttingDown = false;
constructor(
private readonly config: AcpConfig,
private readonly bot: ResolvedBot,
private readonly store: DurableSessionStore
) {
this.sweeper = setInterval(() => void this.sweep(), config.sweepIntervalMs);
this.sweeper.unref();
}
async prompt(request: ConversationRequest): Promise<ConversationResponse> {
if (this.shuttingDown) throw new Error("ACP runtime is shutting down");
const chatKey = chatKeyFor(request.platform, request.chatId);
let binding = this.store.getBinding(chatKey);
if (binding && (binding.botFingerprint !== this.bot.fingerprint || binding.agentId !== this.bot.agent.id || binding.workspace !== this.bot.workspace)) {
await this.dropBinding(binding);
binding = undefined;
}
let worker: AcpWorker;
try { worker = await this.acquireWorker(binding); }
catch (error) {
if (!(error instanceof AcpSessionRestoreError) || !binding) throw error;
await this.store.deleteBinding(chatKey);
binding = undefined;
worker = await this.acquireWorker();
}
this.inFlight.set(chatKey, worker);
try {
if (!binding) {
const now = Date.now();
await worker.prompt(this.bot.bootstrap, "initializing");
binding = {
chatKey, agentId: this.bot.agent.id, nativeSessionId: worker.nativeSessionId!,
workspace: this.bot.workspace, botFingerprint: this.bot.fingerprint, createdAt: now, updatedAt: now
};
await this.store.setBinding(binding);
}
const text = await worker.prompt(request.text);
await this.store.touchBinding(chatKey);
return { text: text || "(ACP agent returned no text)", botId: this.bot.id, agentId: this.bot.agent.id };
} catch (error) {
if (worker.nativeSessionId) this.workers.delete(workerKey(this.bot.agent.id, worker.nativeSessionId));
await worker.terminate();
throw error;
} finally {
if (this.inFlight.get(chatKey) === worker) this.inFlight.delete(chatKey);
}
}
async cancel(platform: string, chatId: string): Promise<boolean> {
const worker = this.inFlight.get(chatKeyFor(platform, chatId));
return worker ? worker.cancel() : false;
}
async reset(platform: string, chatId: string): Promise<void> {
const chatKey = chatKeyFor(platform, chatId);
await this.cancel(platform, chatId);
const binding = await this.store.deleteBinding(chatKey);
if (binding) await this.stopWorker(binding.agentId, binding.nativeSessionId);
}
status(platform: string, chatId: string): Record<string, string | number | boolean> {
const chatKey = chatKeyFor(platform, chatId);
const binding = this.store.getBinding(chatKey);
const worker = this.inFlight.get(chatKey);
const now = Date.now();
return {
bot: this.bot.id,
agent: this.bot.agent.id,
workspace: this.bot.workspace,
persisted: Boolean(binding),
running: Boolean(worker),
phase: worker?.phase || "idle",
runningSeconds: worker?.turnStartedAt ? Math.max(0, Math.floor((now - worker.turnStartedAt) / 1000)) : 0,
idleSeconds: worker?.lastActivityAt ? Math.max(0, Math.floor((now - worker.lastActivityAt) / 1000)) : 0
};
}
stats(): RuntimeStats {
return { activeWorkers: this.workers.size, inFlight: this.inFlight.size, crashes: this.crashes, persistedBindings: this.store.stats().bindings };
}
async shutdown(): Promise<void> {
if (this.shuttingDown) return;
this.shuttingDown = true;
clearInterval(this.sweeper);
await Promise.all([...this.inFlight.values()].map((worker) => worker.cancel().catch(() => false)));
await Promise.all([...this.workers.values()].map((worker) => worker.terminate()));
this.workers.clear();
this.inFlight.clear();
}
private async acquireWorker(binding?: SessionBinding): Promise<AcpWorker> {
if (binding) {
const existing = this.workers.get(workerKey(binding.agentId, binding.nativeSessionId));
if (existing) return existing;
}
await this.ensureCapacity();
const worker = new AcpWorker(this.bot, this.config, (crashed, error) => {
this.crashes++;
if (crashed.nativeSessionId) this.workers.delete(workerKey(this.bot.agent.id, crashed.nativeSessionId));
console.error(`ACP worker crash: ${error.message}`);
});
const nativeSessionId = await worker.start(binding?.nativeSessionId);
this.workers.set(workerKey(this.bot.agent.id, nativeSessionId), worker);
return worker;
}
private async ensureCapacity(): Promise<void> {
if (this.workers.size < this.config.maxProcesses) return;
const candidate = [...this.workers.entries()].filter(([, worker]) => !worker.inFlight).sort((a, b) => a[1].lastUsedAt - b[1].lastUsedAt)[0];
if (!candidate) throw new Error(`ACP worker limit reached (${this.config.maxProcesses})`);
this.workers.delete(candidate[0]);
await candidate[1].terminate();
}
private async sweep(): Promise<void> {
const cutoff = Date.now() - this.config.idleTimeoutMs;
const expired = [...this.workers.entries()].filter(([, worker]) => !worker.inFlight && worker.lastUsedAt < cutoff);
for (const [key, worker] of expired) {
this.workers.delete(key);
await worker.terminate();
}
}
private async dropBinding(binding: SessionBinding): Promise<void> {
await this.store.deleteBinding(binding.chatKey);
await this.stopWorker(binding.agentId, binding.nativeSessionId);
}
private async stopWorker(agentId: string, nativeSessionId: string): Promise<void> {
const key = workerKey(agentId, nativeSessionId);
const worker = this.workers.get(key);
if (!worker) return;
this.workers.delete(key);
await worker.terminate();
}
}
function workerKey(agentId: string, nativeSessionId: string): string { return `${agentId}:${nativeSessionId}`; }
+8
View File
@@ -1,4 +1,5 @@
import type { PermissionPolicy } from "../config.js"; import type { PermissionPolicy } from "../config.js";
import type { Proposal } from "../core/proposal-store.js";
export interface AcpBackendSpec { export interface AcpBackendSpec {
id: string; id: string;
@@ -19,6 +20,7 @@ export interface ConversationResponse {
text: string; text: string;
botId: string; botId: string;
agentId: string; agentId: string;
mode?: "assistant";
} }
export interface RuntimeStats { export interface RuntimeStats {
@@ -28,6 +30,8 @@ export interface RuntimeStats {
persistedBindings: number; persistedBindings: number;
} }
export type RuntimeEventSink = (chatKey: string, text: string) => Promise<void>;
export interface ConversationRuntime { export interface ConversationRuntime {
prompt(request: ConversationRequest): Promise<ConversationResponse>; prompt(request: ConversationRequest): Promise<ConversationResponse>;
cancel(platform: string, chatId: string): Promise<boolean>; cancel(platform: string, chatId: string): Promise<boolean>;
@@ -35,6 +39,10 @@ export interface ConversationRuntime {
status(platform: string, chatId: string): Record<string, string | number | boolean>; status(platform: string, chatId: string): Record<string, string | number | boolean>;
stats(): RuntimeStats; stats(): RuntimeStats;
shutdown(): Promise<void>; shutdown(): Promise<void>;
listProposals?(platform: string, chatId: string): Proposal[];
confirm?(platform: string, chatId: string): Promise<boolean>;
stop?(platform: string, chatId: string): Promise<boolean>;
setEventSink?(sink: RuntimeEventSink): void;
} }
export interface PermissionContext { export interface PermissionContext {
+138 -28
View File
@@ -1,11 +1,26 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import type { AcpConfig } from "../config.js"; import crypto from "node:crypto";
import fs from "node:fs";
import type { AcpConfig, PermissionPolicy } from "../config.js";
import type { WorkerProcessGroup } from "../core/proposal-store.js";
import type { ResolvedBot } from "../roles/role-registry.js"; import type { ResolvedBot } from "../roles/role-registry.js";
import { AcpClient } from "./client.js"; import { AcpClient, type SafeActivityCategory } from "./client.js";
export class AcpSessionRestoreError extends Error {} export class AcpSessionRestoreError extends Error {}
export type AcpWorkerPhase = "idle" | "initializing" | "processing"; export type AcpWorkerPhase = "idle" | "initializing" | "processing";
export type AcpWorkerKind = "assistant" | "worker";
export interface AcpWorkerOptions {
kind?: AcpWorkerKind;
cwd?: string;
env?: Record<string, string>;
policy?: PermissionPolicy;
onActivity?: (category?: SafeActivityCategory) => void;
onIsolationViolation?: (worker: AcpWorker) => void;
onSpawn?: (group: WorkerProcessGroup) => Promise<void>;
allowUnverifiedAssistantAgent?: boolean;
}
export class AcpWorker { export class AcpWorker {
private child?: ChildProcessWithoutNullStreams; private child?: ChildProcessWithoutNullStreams;
@@ -13,8 +28,13 @@ export class AcpWorker {
private abort?: AbortController; private abort?: AbortController;
private exited = false; private exited = false;
private stopping = false; private stopping = false;
private termination?: Promise<void>;
private stderrBytes = 0; private stderrBytes = 0;
readonly kind: AcpWorkerKind;
readonly cwd: string;
nativeSessionId?: string; nativeSessionId?: string;
processGroup?: WorkerProcessGroup;
isolationViolated = false;
lastUsedAt = Date.now(); lastUsedAt = Date.now();
turnStartedAt?: number; turnStartedAt?: number;
lastActivityAt?: number; lastActivityAt?: number;
@@ -24,14 +44,29 @@ export class AcpWorker {
constructor( constructor(
readonly bot: ResolvedBot, readonly bot: ResolvedBot,
private readonly config: AcpConfig, private readonly config: AcpConfig,
private readonly onCrash: (worker: AcpWorker, error: Error) => void private readonly onCrash: (worker: AcpWorker, error: Error) => void,
) {} private readonly options: AcpWorkerOptions = {}
) {
this.kind = options.kind || "worker";
this.cwd = options.cwd || bot.workspace;
}
static async terminatePersistedGroup(group: WorkerProcessGroup, timeoutMs: number): Promise<void> {
if (!processGroupExists(group.pgid)) return;
if (!processGroupHasToken(group.pgid, group.token)) {
throw new Error(`Persisted ACP process group ${group.pgid} no longer matches its worker token`);
}
signalProcessGroup(group.pgid, "SIGKILL");
await waitForProcessGroupExit(group.pgid, timeoutMs);
}
async start(nativeSessionId?: string): Promise<string> { async start(nativeSessionId?: string): Promise<string> {
const processToken = crypto.randomUUID();
this.child = spawn(this.bot.agent.command, this.bot.agent.args, { this.child = spawn(this.bot.agent.command, this.bot.agent.args, {
cwd: this.bot.workspace, cwd: this.cwd,
env: { ...process.env, ...this.bot.agent.env }, env: { ...process.env, ...this.bot.agent.env, ...this.options.env, GORI_AGENT_WORKER_TOKEN: processToken },
shell: false, shell: false,
detached: true,
stdio: ["pipe", "pipe", "pipe"] stdio: ["pipe", "pipe", "pipe"]
}); });
this.child.stderr.on("data", (chunk: Buffer) => this.logStderr(chunk)); this.child.stderr.on("data", (chunk: Buffer) => this.logStderr(chunk));
@@ -42,16 +77,31 @@ export class AcpWorker {
}); });
this.client = new AcpClient(this.child, { this.client = new AcpClient(this.child, {
initializeTimeoutMs: this.config.initializeTimeoutMs, initializeTimeoutMs: this.config.initializeTimeoutMs,
policy: this.bot.permissions, policy: this.options.policy || this.bot.permissions,
onSessionActivity: () => { this.lastActivityAt = Date.now(); } forbidToolActivity: this.kind === "assistant",
onToolViolation: () => {
this.isolationViolated = true;
this.options.onIsolationViolation?.(this);
this.terminateImmediately();
},
onSessionActivity: (category) => {
this.lastActivityAt = Date.now();
this.options.onActivity?.(category);
}
}); });
try { try {
await this.client.initialize(); if (!this.child.pid) throw new Error("ACP worker has no process ID");
this.processGroup = { pgid: this.child.pid, token: processToken };
await this.options.onSpawn?.(this.processGroup);
const initialized = await this.client.initialize();
if (this.kind === "assistant" && !this.options.allowUnverifiedAssistantAgent && initialized.agentInfo?.name !== "Kimi Code CLI") {
throw new Error("Assistant sessions require Kimi Code ACP with execution-layer no-tool profiles");
}
if (nativeSessionId) { if (nativeSessionId) {
await this.client.resumeSession(nativeSessionId, this.bot.workspace); await this.client.resumeSession(nativeSessionId, this.cwd);
this.nativeSessionId = nativeSessionId; this.nativeSessionId = nativeSessionId;
} else { } else {
this.nativeSessionId = await this.client.newSession(this.bot.workspace); this.nativeSessionId = await this.client.newSession(this.cwd);
} }
return this.nativeSessionId; return this.nativeSessionId;
} catch (error) { } catch (error) {
@@ -81,7 +131,9 @@ export class AcpWorker {
}, this.config.promptTimeoutMs); }, this.config.promptTimeoutMs);
}); });
try { try {
return await Promise.race([this.client.prompt(text, this.abort.signal), timeoutPromise]); const result = await Promise.race([this.client.prompt(text, this.abort.signal), timeoutPromise]);
if (this.kind === "assistant") await this.client.settleIsolation();
return result;
} finally { } finally {
if (timeout) clearTimeout(timeout); if (timeout) clearTimeout(timeout);
this.inFlight = false; this.inFlight = false;
@@ -101,24 +153,31 @@ export class AcpWorker {
return true; return true;
} }
async terminate(): Promise<void> { terminateImmediately(): void {
if (this.stopping) return;
this.stopping = true; this.stopping = true;
if (this.client && !this.exited) { this.abort?.abort();
await Promise.race([ this.client?.close(new Error("ACP worker terminated because workspace ownership was lost"));
this.client.closeSession().catch(() => undefined), if (this.child) killProcessGroup(this.child, "SIGKILL");
new Promise<void>((resolve) => setTimeout(resolve, this.config.cancelGraceMs)) this.termination ||= this.finishTermination("SIGKILL");
]);
} }
terminate(): Promise<void> {
if (this.termination) return this.termination;
this.stopping = true;
this.abort?.abort();
this.client?.close(); this.client?.close();
if (this.child && !this.exited) { this.termination = this.finishTermination("SIGTERM");
this.child.kill("SIGTERM"); return this.termination;
await waitForExit(this.child, this.config.cancelGraceMs);
if (!this.exited) {
this.child.kill("SIGKILL");
await waitForExit(this.child, this.config.cancelGraceMs);
}
} }
private async finishTermination(initialSignal: NodeJS.Signals): Promise<void> {
if (!this.child?.pid) return;
const pid = this.child.pid;
killProcessGroup(this.child, initialSignal);
await waitForExit(this.child, this.config.cancelGraceMs);
killProcessGroup(this.child, "SIGKILL");
await waitForExit(this.child, this.config.cancelGraceMs);
await waitForProcessGroupExit(pid, this.config.cancelGraceMs);
} }
private logStderr(chunk: Buffer): void { private logStderr(chunk: Buffer): void {
@@ -126,16 +185,49 @@ export class AcpWorker {
if (!remaining) return; if (!remaining) return;
const text = chunk.subarray(0, remaining).toString("utf8").trimEnd(); const text = chunk.subarray(0, remaining).toString("utf8").trimEnd();
this.stderrBytes += Buffer.byteLength(text); this.stderrBytes += Buffer.byteLength(text);
if (text) console.error(`[acp:${this.bot.agent.id}] ${text}`); if (text) console.error(`[acp:${this.kind}:${this.bot.agent.id}] ${text}`);
} }
private crashed(error: Error): void { private crashed(error: Error): void {
if (this.stopping) return; if (this.stopping) return;
this.stopping = true; this.stopping = true;
this.onCrash(this, error); this.abort?.abort();
this.client?.close(error);
this.termination = this.finishTermination("SIGKILL");
void this.termination.then(
() => this.onCrash(this, error),
(cleanupError) => this.onCrash(this, new Error(`${error.message}; process-group cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`))
);
} }
} }
function killProcessGroup(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): void {
if (child.pid) signalProcessGroup(child.pid, signal);
}
function signalProcessGroup(pgid: number, signal: NodeJS.Signals): void {
try { process.kill(-pgid, signal); }
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; }
}
function processGroupHasToken(pgid: number, token: string): boolean {
for (const entry of fs.readdirSync("/proc")) {
if (!/^\d+$/.test(entry)) continue;
try {
const stat = fs.readFileSync(`/proc/${entry}/stat`, "utf8");
const close = stat.lastIndexOf(")");
const fields = stat.slice(close + 2).split(" ");
if (Number(fields[2]) !== pgid) continue;
const environ = fs.readFileSync(`/proc/${entry}/environ`);
if (environ.toString("utf8").split("\0").includes(`GORI_AGENT_WORKER_TOKEN=${token}`)) return true;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOENT" && code !== "EACCES" && code !== "EPERM") throw error;
}
}
return false;
}
function waitForExit(child: ChildProcessWithoutNullStreams, timeoutMs: number): Promise<void> { function waitForExit(child: ChildProcessWithoutNullStreams, timeoutMs: number): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(); if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve();
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -143,3 +235,21 @@ function waitForExit(child: ChildProcessWithoutNullStreams, timeoutMs: number):
child.once("close", () => { clearTimeout(timer); resolve(); }); child.once("close", () => { clearTimeout(timer); resolve(); });
}); });
} }
async function waitForProcessGroupExit(pid: number, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (processGroupExists(pid) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
if (processGroupExists(pid)) throw new Error(`ACP process group ${pid} did not terminate`);
}
function processGroupExists(pid: number): boolean {
try { process.kill(-pid, 0); return true; }
catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ESRCH") return false;
if (code === "EPERM") return true;
throw error;
}
}
+6
View File
@@ -1,3 +1,4 @@
import crypto from "node:crypto";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import process from "node:process"; import process from "node:process";
@@ -33,6 +34,10 @@ export function loadExampleConfig(): AppConfig {
return parseConfig(JSON.parse(fs.readFileSync(examplePath, "utf8")) as unknown); return parseConfig(JSON.parse(fs.readFileSync(examplePath, "utf8")) as unknown);
} }
export function configDigest(config: AppConfig): string {
return crypto.createHash("sha256").update(JSON.stringify(config)).digest("hex");
}
export function writeConfigFile(configPath: string, config: AppConfig): void { export function writeConfigFile(configPath: string, config: AppConfig): void {
const parsed = parseConfig(config); const parsed = parseConfig(config);
const directory = path.dirname(configPath); const directory = path.dirname(configPath);
@@ -62,6 +67,7 @@ export function operationalConfigProblems(config: AppConfig): string[] {
if (isPlaceholder(config.bot.workspace) || config.bot.workspace.includes("/absolute/path/")) problems.push("bot.workspace is still a placeholder"); if (isPlaceholder(config.bot.workspace) || config.bot.workspace.includes("/absolute/path/")) problems.push("bot.workspace is still a placeholder");
if (isPlaceholder(config.bot.agent.command) || config.bot.agent.command.includes("/home/USER/")) problems.push("bot.agent.command is still a placeholder"); if (isPlaceholder(config.bot.agent.command) || config.bot.agent.command.includes("/home/USER/")) problems.push("bot.agent.command is still a placeholder");
else if (!isExecutable(config.bot.agent.command)) problems.push("bot.agent.command is not executable"); else if (!isExecutable(config.bot.agent.command)) problems.push("bot.agent.command is not executable");
if (config.bot.agent.args.length !== 1 || config.bot.agent.args[0] !== "acp") problems.push("bot.agent.args must be exactly ['acp'] so the no-tool side profile cannot be bypassed");
try { try {
if (!fs.statSync(config.bot.workspace).isDirectory()) problems.push("bot.workspace must be an existing directory"); if (!fs.statSync(config.bot.workspace).isDirectory()) problems.push("bot.workspace must be an existing directory");
} catch { problems.push("bot.workspace must be an existing directory"); } } catch { problems.push("bot.workspace must be an existing directory"); }
+8
View File
@@ -3,6 +3,7 @@ import path from "node:path";
import { probeAcpBackend } from "../acp/probe.js"; import { probeAcpBackend } from "../acp/probe.js";
import { defaultStateFile, type AppConfig } from "../config.js"; import { defaultStateFile, type AppConfig } from "../config.js";
import { BotProfileResolver } from "../roles/role-registry.js"; import { BotProfileResolver } from "../roles/role-registry.js";
import { findOverlappingWorkspace } from "../core/workspace-scope.js";
import { operationalConfigProblems } from "./config-file.js"; import { operationalConfigProblems } from "./config-file.js";
import { printUrlHints } from "./net.js"; import { printUrlHints } from "./net.js";
@@ -25,6 +26,13 @@ export async function runDoctor(config: AppConfig, configPath: string): Promise<
try { new BotProfileResolver(config); } catch (error) { try { new BotProfileResolver(config); } catch (error) {
problems += reportError(error instanceof Error ? error.message : String(error)); problems += reportError(error instanceof Error ? error.message : String(error));
} }
try {
const instancesDirectory = path.dirname(path.dirname(configPath));
const conflict = findOverlappingWorkspace(config.bot.workspace, config.bot.id, instancesDirectory);
if (conflict) problems += reportError(`bot '${conflict}' declares an identical, parent, or child workspace; workspace scopes must be disjoint`);
} catch (error) {
problems += reportError(`cannot validate peer workspaces: ${error instanceof Error ? error.message : String(error)}`);
}
if (config.gateway.platform.type === "qq") console.log(`QQ connection mode: ${config.gateway.platform.connectionMode}`); if (config.gateway.platform.type === "qq") console.log(`QQ connection mode: ${config.gateway.platform.connectionMode}`);
if (config.bot.permissions.mode === "auto") console.log("WARN: bot auto-approves every permission request."); if (config.bot.permissions.mode === "auto") console.log("WARN: bot auto-approves every permission request.");
if (config.bot.permissions.mode === "allowlist" && config.bot.permissions.allowedTools.some((tool) => ["bash", "terminal"].includes(tool.toLowerCase())) && config.bot.permissions.allowedCommandPatterns.length === 0) { if (config.bot.permissions.mode === "allowlist" && config.bot.permissions.allowedTools.some((tool) => ["bash", "terminal"].includes(tool.toLowerCase())) && config.bot.permissions.allowedCommandPatterns.length === 0) {
+24 -1
View File
@@ -1,8 +1,12 @@
#!/usr/bin/env node #!/usr/bin/env node
import os from "node:os";
import path from "node:path";
import process from "node:process"; import process from "node:process";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { assertOperationalConfig, loadConfigFile } from "./config-file.js"; import type { AppConfig } from "../config.js";
import { findOverlappingWorkspace } from "../core/workspace-scope.js";
import { startServer } from "../server.js"; import { startServer } from "../server.js";
import { assertOperationalConfig, configDigest, loadConfigFile } from "./config-file.js";
interface ShutdownTarget { shutdown(): Promise<void> } interface ShutdownTarget { shutdown(): Promise<void> }
interface SignalEmitter { interface SignalEmitter {
@@ -14,9 +18,28 @@ export async function runInstanceRunner(argv: string[]): Promise<number> {
if (argv.length !== 1) throw new Error("instance-runner requires exactly one config path"); if (argv.length !== 1) throw new Error("instance-runner requires exactly one config path");
const loaded = loadConfigFile(argv[0]); const loaded = loadConfigFile(argv[0]);
assertOperationalConfig(loaded.config, loaded.path); assertOperationalConfig(loaded.config, loaded.path);
assertRunnerConfigSnapshot(loaded.config);
assertRunnerWorkspaceSafety(loaded.config, loaded.path);
return waitForShutdown(await startServer(loaded.config)); return waitForShutdown(await startServer(loaded.config));
} }
export function assertRunnerConfigSnapshot(config: AppConfig, expected = process.env.GORI_AGENT_CONFIG_DIGEST): void {
if (!expected || expected !== configDigest(config)) {
throw new Error("instance config changed after start validation; refusing to launch");
}
}
export function assertRunnerWorkspaceSafety(config: AppConfig, configFile: string, root = agentRoot()): void {
const expected = path.join(root, "instances", config.bot.id, "config.json");
if (path.resolve(configFile) !== expected) throw new Error("instance-runner config path does not match the instance directory contract");
const conflict = findOverlappingWorkspace(config.bot.workspace, config.bot.id, path.join(root, "instances"));
if (conflict) throw new Error(`Refusing to start: bot '${conflict}' declares an identical, parent, or child workspace; workspace scopes must be disjoint`);
}
function agentRoot(): string {
return path.resolve(process.env.GORI_AGENT_ROOT || path.join(os.homedir(), ".gori-agent"));
}
export function waitForShutdown(running: ShutdownTarget, signals: SignalEmitter = process): Promise<number> { export function waitForShutdown(running: ShutdownTarget, signals: SignalEmitter = process): Promise<number> {
return new Promise<number>((resolve) => { return new Promise<number>((resolve) => {
let stopping = false; let stopping = false;
+63 -6
View File
@@ -6,12 +6,14 @@ import path from "node:path";
import process from "node:process"; import process from "node:process";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { defaultStateFile } from "../config.js"; import { defaultStateFile } from "../config.js";
import { assertOperationalConfig, loadConfigFile } from "./config-file.js"; import { findOverlappingWorkspace } from "../core/workspace-scope.js";
import { assertOperationalConfig, configDigest, loadConfigFile } from "./config-file.js";
import { runDoctor } from "./doctor.js"; import { runDoctor } from "./doctor.js";
import { localBaseUrl } from "./net.js"; import { localBaseUrl } from "./net.js";
import { runSetup } from "./setup.js"; import { runSetup } from "./setup.js";
const BOT_ID_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62})$/; const BOT_ID_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62})$/;
const LIFECYCLE_FLOCK = "/usr/bin/flock";
export function agentRoot(): string { export function agentRoot(): string {
return path.resolve(process.env.GORI_AGENT_ROOT || path.join(os.homedir(), ".gori-agent")); return path.resolve(process.env.GORI_AGENT_ROOT || path.join(os.homedir(), ".gori-agent"));
@@ -64,23 +66,30 @@ async function initInstance(botId: string): Promise<number> {
} }
async function setupInstance(botId: string): Promise<number> { async function setupInstance(botId: string): Promise<number> {
const loaded = loadInstance(botId); loadInstance(botId);
assertConfigMode(loaded.path);
const paths = runtimePaths(botId); const paths = runtimePaths(botId);
ensureInstanceDirectories(paths.home, false); ensureInstanceDirectories(paths.home, false);
return withInstanceLifecycleLock(paths.home, async () => {
const loaded = loadInstance(botId);
assertConfigMode(loaded.path);
const current = inspectPid(paths.pidFile, loaded.path); const current = inspectPid(paths.pidFile, loaded.path);
assertSetupPidSafe(botId, current); assertSetupPidSafe(botId, current);
if (current.kind === "stale") removeStalePid(paths.pidFile, current); if (current.kind === "stale") removeStalePid(paths.pidFile, current);
const written = await runSetup(loaded.path, { botId, instancesDirectory: instancesRoot() }); const written = await runSetup(loaded.path, { botId, instancesDirectory: instancesRoot() });
if (!written) return 0; if (!written) return 0;
return doctorInstance(botId); return doctorInstance(botId);
});
} }
async function startInstance(botId: string): Promise<number> { async function startInstance(botId: string): Promise<number> {
const loaded = loadInstance(botId); loadInstance(botId);
assertOperationalConfig(loaded.config, loaded.path);
const paths = runtimePaths(botId); const paths = runtimePaths(botId);
ensureInstanceDirectories(paths.home, false); ensureInstanceDirectories(paths.home, false);
return withInstanceLifecycleLock(paths.home, async () => {
const loaded = loadInstance(botId);
assertOperationalConfig(loaded.config, loaded.path);
assertNoOverlappingWorkspace(loaded.config.bot.workspace, botId);
fs.accessSync(LIFECYCLE_FLOCK, fs.constants.X_OK);
const current = inspectPid(paths.pidFile, loaded.path); const current = inspectPid(paths.pidFile, loaded.path);
if (current.kind === "running") throw new Error(`Instance '${botId}' is already running (pid ${current.pid})`); if (current.kind === "running") throw new Error(`Instance '${botId}' is already running (pid ${current.pid})`);
if (current.kind === "foreign") throw new Error(`Refusing to start: PID file references unrelated live process ${current.pid}`); if (current.kind === "foreign") throw new Error(`Refusing to start: PID file references unrelated live process ${current.pid}`);
@@ -94,7 +103,11 @@ async function startInstance(botId: string): Promise<number> {
try { try {
child = spawn(process.execPath, [runnerFile, loaded.path], { child = spawn(process.execPath, [runnerFile, loaded.path], {
cwd: paths.home, cwd: paths.home,
env: { ...process.env, GORI_AGENT_HOME: paths.home }, env: {
...process.env,
GORI_AGENT_HOME: paths.home,
GORI_AGENT_CONFIG_DIGEST: configDigest(loaded.config)
},
detached: true, detached: true,
stdio: ["ignore", logFd, logFd] stdio: ["ignore", logFd, logFd]
}); });
@@ -116,6 +129,7 @@ async function startInstance(botId: string): Promise<number> {
} }
throw new Error(`Instance '${botId}' failed to start; inspect ${paths.logFile}: ${error instanceof Error ? error.message : String(error)}`); throw new Error(`Instance '${botId}' failed to start; inspect ${paths.logFile}: ${error instanceof Error ? error.message : String(error)}`);
} finally { fs.closeSync(logFd); } } finally { fs.closeSync(logFd); }
});
} }
async function stopInstance(botId: string): Promise<number> { async function stopInstance(botId: string): Promise<number> {
@@ -362,6 +376,49 @@ async function waitForHealthyInstance(pid: number, platform: string, botId: stri
throw new Error(`health check timed out: ${lastError}`); throw new Error(`health check timed out: ${lastError}`);
} }
function assertNoOverlappingWorkspace(workspace: string, botId: string): void {
const conflict = findOverlappingWorkspace(workspace, botId, instancesRoot());
if (conflict) throw new Error(`Refusing to start: bot '${conflict}' declares an identical, parent, or child workspace; workspace scopes must be disjoint`);
}
async function withInstanceLifecycleLock<T>(home: string, operation: () => Promise<T>): Promise<T> {
const lockFile = path.join(home, "state", "instance.lifecycle.lock");
const fd = fs.openSync(lockFile, "a", 0o600);
fs.closeSync(fd);
fs.chmodSync(lockFile, 0o600);
const holder = spawn(LIFECYCLE_FLOCK, ["-n", lockFile, process.execPath, "-e", "process.stdout.write('READY\\n'); process.stdin.resume()"], {
shell: false,
stdio: ["pipe", "pipe", "pipe"]
});
try {
await new Promise<void>((resolve, reject) => {
let output = "";
const timer = setTimeout(() => reject(new Error("Timed out acquiring instance lifecycle lock")), 2_000);
const finish = (error?: Error): void => {
clearTimeout(timer);
holder.stdout.removeAllListeners("data");
holder.removeAllListeners("error");
holder.removeAllListeners("close");
error ? reject(error) : resolve();
};
holder.stdout.on("data", (chunk: Buffer) => {
output += chunk.toString("utf8");
if (output.includes("READY\n")) finish();
});
holder.once("error", (error) => finish(error));
holder.once("close", () => finish(new Error("Another setup or start operation is already running for this instance")));
});
return await operation();
} finally {
holder.stdin.end();
if (holder.exitCode === null && holder.signalCode === null) holder.kill("SIGTERM");
await new Promise<void>((resolve) => {
if (holder.exitCode !== null || holder.signalCode !== null) resolve();
else holder.once("close", () => resolve());
});
}
}
function delay(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); } function delay(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); }
async function terminateStartedChild(pid: number): Promise<void> { async function terminateStartedChild(pid: number): Promise<void> {
+7 -2
View File
@@ -83,11 +83,12 @@ const platformSchema = z.discriminatedUnion("type", [feishuSchema, wecomSchema,
const acpSchema = z.object({ const acpSchema = z.object({
stateFile: z.string().default(""), stateFile: z.string().default(""),
initializeTimeoutMs: z.number().int().positive().default(10_000), initializeTimeoutMs: z.number().int().positive().default(10_000),
promptTimeoutMs: z.number().int().positive().default(7_200_000), promptTimeoutMs: z.number().int().positive().default(14_400_000),
cancelGraceMs: z.number().int().positive().default(5_000), cancelGraceMs: z.number().int().positive().default(5_000),
idleTimeoutMs: z.number().int().positive().default(1_800_000), idleTimeoutMs: z.number().int().positive().default(1_800_000),
sweepIntervalMs: z.number().int().positive().default(60_000), sweepIntervalMs: z.number().int().positive().default(60_000),
maxProcesses: z.number().int().positive().default(8) maxProcesses: z.number().int().positive().default(8),
maxAssistantSessions: z.number().int().positive().default(4)
}).strict(); }).strict();
export const configSchema = z.object({ export const configSchema = z.object({
@@ -154,6 +155,10 @@ export function defaultStateFile(config: AppConfig): string {
return path.join(path.resolve(home), "state", "acp-sessions.json"); return path.join(path.resolve(home), "state", "acp-sessions.json");
} }
export function defaultProposalFile(config: AppConfig): string {
return path.join(path.dirname(defaultStateFile(config)), "proposals.json");
}
export function loadConfig(configPath = process.env.GORI_GATEWAY_CONFIG): AppConfig { export function loadConfig(configPath = process.env.GORI_GATEWAY_CONFIG): AppConfig {
return loadConfigFromPath(resolveConfigPath(configPath)); return loadConfigFromPath(resolveConfigPath(configPath));
} }
+4 -6
View File
@@ -1,4 +1,4 @@
export type CommandKind = "help" | "status" | "cancel" | "new" | "retired-role"; export type CommandKind = "help" | "status" | "list" | "confirm" | "stop" | "cancel";
export interface ParsedCommand { export interface ParsedCommand {
kind: CommandKind; kind: CommandKind;
@@ -12,12 +12,10 @@ export class CommandRouter {
switch (command.toLowerCase()) { switch (command.toLowerCase()) {
case "/help": return { kind: "help" }; case "/help": return { kind: "help" };
case "/status": return { kind: "status" }; case "/status": return { kind: "status" };
case "/list": return { kind: "list" };
case "/confirm": return { kind: "confirm" };
case "/stop": return { kind: "stop" };
case "/cancel": return { kind: "cancel" }; case "/cancel": return { kind: "cancel" };
case "/new": return { kind: "new" };
case "/roles":
case "/role":
case "/agents":
case "/agent": return { kind: "retired-role" };
default: return undefined; default: return undefined;
} }
} }
+12 -12
View File
@@ -6,21 +6,21 @@ export interface StoreIdentity {
platform: string; platform: string;
} }
export interface SessionBinding { export interface AssistantBinding {
chatKey: string; chatKey: string;
agentId: string; agentId: string;
nativeSessionId: string; nativeSessionId: string;
workspace: string; assistantWorkspace: string;
botFingerprint: string; botFingerprint: string;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
} }
interface StoreData { interface StoreData {
version: 2; version: 3;
botId: string; botId: string;
platform: string; platform: string;
bindings: Record<string, SessionBinding>; bindings: Record<string, AssistantBinding>;
} }
export class DurableSessionStore { export class DurableSessionStore {
@@ -76,12 +76,12 @@ export class DurableSessionStore {
} }
} }
getBinding(chatKey: string): SessionBinding | undefined { getBinding(chatKey: string): AssistantBinding | undefined {
const value = this.data.bindings[chatKey]; const value = this.data.bindings[chatKey];
return value ? structuredClone(value) : undefined; return value ? structuredClone(value) : undefined;
} }
async setBinding(binding: SessionBinding): Promise<void> { async setBinding(binding: AssistantBinding): Promise<void> {
this.data.bindings[binding.chatKey] = structuredClone(binding); this.data.bindings[binding.chatKey] = structuredClone(binding);
await this.persist(); await this.persist();
} }
@@ -93,7 +93,7 @@ export class DurableSessionStore {
await this.persist(); await this.persist();
} }
async deleteBinding(chatKey: string): Promise<SessionBinding | undefined> { async deleteBinding(chatKey: string): Promise<AssistantBinding | undefined> {
const existing = this.data.bindings[chatKey]; const existing = this.data.bindings[chatKey];
delete this.data.bindings[chatKey]; delete this.data.bindings[chatKey];
if (existing) await this.persist(); if (existing) await this.persist();
@@ -133,23 +133,23 @@ export class DurableSessionStore {
export function chatKeyFor(platform: string, chatId: string): string { return `${platform}:${chatId}`; } export function chatKeyFor(platform: string, chatId: string): string { return `${platform}:${chatId}`; }
function emptyData(identity: StoreIdentity): StoreData { function emptyData(identity: StoreIdentity): StoreData {
return { version: 2, botId: identity.botId, platform: identity.platform, bindings: {} }; return { version: 3, botId: identity.botId, platform: identity.platform, bindings: {} };
} }
function parseStoreData(value: unknown): StoreData { function parseStoreData(value: unknown): StoreData {
if (!isRecord(value) || value.version !== 2 || typeof value.botId !== "string" || typeof value.platform !== "string" || !isRecord(value.bindings)) { if (!isRecord(value) || value.version !== 3 || typeof value.botId !== "string" || typeof value.platform !== "string" || !isRecord(value.bindings)) {
throw new Error("unsupported state version; Config v3 requires state v2 in a new GORI_AGENT_HOME"); throw new Error("unsupported state version; Config v3 requires state v3 in a new GORI_AGENT_HOME");
} }
for (const [chatKey, binding] of Object.entries(value.bindings)) { for (const [chatKey, binding] of Object.entries(value.bindings)) {
if (!isRecord(binding) if (!isRecord(binding)
|| binding.chatKey !== chatKey || binding.chatKey !== chatKey
|| typeof binding.agentId !== "string" || typeof binding.agentId !== "string"
|| typeof binding.nativeSessionId !== "string" || typeof binding.nativeSessionId !== "string"
|| typeof binding.workspace !== "string" || typeof binding.assistantWorkspace !== "string"
|| typeof binding.botFingerprint !== "string" || typeof binding.botFingerprint !== "string"
|| typeof binding.createdAt !== "number" || typeof binding.createdAt !== "number"
|| typeof binding.updatedAt !== "number") { || typeof binding.updatedAt !== "number") {
throw new Error(`invalid state v2 binding '${chatKey}'`); throw new Error(`invalid state v3 binding '${chatKey}'`);
} }
} }
return value as unknown as StoreData; return value as unknown as StoreData;
+72 -181
View File
@@ -3,35 +3,13 @@ import type { GatewayPolicy } from "../config.js";
import type { PlatformAdapter } from "./adapter.js"; import type { PlatformAdapter } from "./adapter.js";
import { CommandRouter, type ParsedCommand } from "./command-router.js"; import { CommandRouter, type ParsedCommand } from "./command-router.js";
import { chatKeyFor } from "./durable-session-store.js"; import { chatKeyFor } from "./durable-session-store.js";
import type { IncomingMessage } from "./types.js"; import type { IncomingMessage, MessageTarget } from "./types.js";
export interface GatewayResult { ok: boolean; reply?: string; ignored?: boolean; error?: string } export interface GatewayResult { ok: boolean; reply?: string; ignored?: boolean; error?: string }
export interface GatewayTimer { cancel(): void } interface ChatTarget {
target: MessageTarget;
export interface GatewayOptions { adapter: PlatformAdapter;
now?: () => number;
schedule?: (callback: () => void, delayMs: number) => GatewayTimer;
}
interface ChatWorkState {
running: boolean;
queued: number;
startedAt?: number;
}
interface AsyncInbound {
phase: "queued" | "running" | "finished";
receivedAt: number;
initialNoticeSent: boolean;
queuedNoticeSent: boolean;
timer?: GatewayTimer;
stream: ReplyStream;
}
interface LockedResult {
result: GatewayResult;
delivery: Promise<void>;
} }
class ReplyStream { class ReplyStream {
@@ -60,22 +38,14 @@ class ReplyStream {
} }
export class Gateway { export class Gateway {
private readonly locks = new Map<string, Promise<void>>(); private readonly chatTargets = new Map<string, ChatTarget>();
private readonly chatWork = new Map<string, ChatWorkState>();
private readonly asyncInbounds = new Set<AsyncInbound>();
private readonly now: () => number;
private readonly schedule: (callback: () => void, delayMs: number) => GatewayTimer;
private closed = false; private closed = false;
readonly commandRouter = new CommandRouter(); readonly commandRouter = new CommandRouter();
constructor( constructor(
private readonly policy: GatewayPolicy, private readonly policy: GatewayPolicy,
private readonly runtime: ConversationRuntime, private readonly runtime: ConversationRuntime
options: GatewayOptions = {} ) {}
) {
this.now = options.now || Date.now;
this.schedule = options.schedule || defaultSchedule;
}
async receive(message: IncomingMessage, adapter: PlatformAdapter, options: { synchronous?: boolean } = {}): Promise<GatewayResult> { async receive(message: IncomingMessage, adapter: PlatformAdapter, options: { synchronous?: boolean } = {}): Promise<GatewayResult> {
const policyError = this.checkPolicy(message); const policyError = this.checkPolicy(message);
@@ -84,31 +54,25 @@ export class Gateway {
return { ok: true, ignored: true, error: policyError }; return { ok: true, ignored: true, error: policyError };
} }
const command = this.commandRouter.parse(message.text); this.chatTargets.set(chatKeyFor(message.platform, message.chatId), {
if (command?.kind === "help" || command?.kind === "status" || command?.kind === "cancel") { target: {
return this.executeAndReply(command, message, adapter, options); platform: message.platform,
} chatId: message.chatId,
userId: message.userId,
raw: message.raw
},
adapter
});
const chatKey = chatKeyFor(message.platform, message.chatId); const command = this.commandRouter.parse(message.text);
if (command || options.synchronous) { if (command) {
const result = await this.withChatLock(chatKey, () => this.executeSynchronous(command, message)); const result = await this.executeCommandResult(command, message);
if (!options.synchronous) await new ReplyStream(message, adapter).enqueue(result.reply || ""); if (!options.synchronous) await new ReplyStream(message, adapter).enqueue(result.reply || "");
return result; return result;
} }
const inbound: AsyncInbound = { if (options.synchronous) return this.promptResult(message);
phase: "queued",
receivedAt: this.now(),
initialNoticeSent: false,
queuedNoticeSent: false,
stream: new ReplyStream(message, adapter)
};
this.asyncInbounds.add(inbound);
this.armTimer(inbound, inbound.receivedAt + 15_000);
const locked = await this.withChatLock(chatKey, async () => {
inbound.phase = "running";
if (inbound.queuedNoticeSent) void inbound.stream.enqueue("轮到这件事了,我现在开始处理。");
try { try {
const reply = (await this.runtime.prompt({ const reply = (await this.runtime.prompt({
platform: message.platform, platform: message.platform,
@@ -117,49 +81,47 @@ export class Gateway {
text: message.text, text: message.text,
messageId: message.messageId messageId: message.messageId
})).text; })).text;
this.finishInbound(inbound); await new ReplyStream(message, adapter).enqueue(reply);
return { result: { ok: true, reply }, delivery: inbound.stream.enqueue(reply) }; return { ok: true, reply };
} catch (error) { } catch (error) {
const errorText = error instanceof Error ? error.message : String(error); const errorText = error instanceof Error ? error.message : String(error);
const reply = `Agent error: ${errorText}`; const reply = `Agent error: ${errorText}`;
this.finishInbound(inbound); await new ReplyStream(message, adapter).enqueue(reply);
return { result: { ok: false, error: errorText, reply }, delivery: inbound.stream.enqueue(reply) }; return { ok: false, error: errorText, reply };
} }
});
await locked.delivery;
return locked.result;
} }
stats(): ReturnType<ConversationRuntime["stats"]> & { lockedChats: number } { async sendEvent(chatKey: string, text: string): Promise<void> {
return { ...this.runtime.stats(), lockedChats: this.locks.size }; if (this.closed) return;
const entry = this.chatTargets.get(chatKey);
if (!entry) return;
try {
await entry.adapter.sendMessage({ target: entry.target, text });
} catch {
console.error(`Gateway event send failed (platform=${entry.target.platform})`);
}
}
stats(): ReturnType<ConversationRuntime["stats"]> {
return this.runtime.stats();
} }
shutdown(): void { shutdown(): void {
if (this.closed) return;
this.closed = true; this.closed = true;
for (const inbound of this.asyncInbounds) {
inbound.phase = "finished";
inbound.timer?.cancel();
inbound.timer = undefined;
}
this.asyncInbounds.clear();
} }
private async executeAndReply( private async executeCommandResult(command: ParsedCommand, message: IncomingMessage): Promise<GatewayResult> {
command: ParsedCommand,
message: IncomingMessage,
adapter: PlatformAdapter,
options: { synchronous?: boolean }
): Promise<GatewayResult> {
const result = await this.executeSynchronous(command, message);
if (options.synchronous) return result;
await new ReplyStream(message, adapter).enqueue(result.reply || "");
return result;
}
private async executeSynchronous(command: ParsedCommand | undefined, message: IncomingMessage): Promise<GatewayResult> {
try { try {
const reply = command ? await this.executeCommand(command, message) : (await this.runtime.prompt({ return { ok: true, reply: await this.executeCommand(command, message) };
} catch (error) {
const errorText = error instanceof Error ? error.message : String(error);
return { ok: false, error: errorText, reply: `Agent error: ${errorText}` };
}
}
private async promptResult(message: IncomingMessage): Promise<GatewayResult> {
try {
const reply = (await this.runtime.prompt({
platform: message.platform, platform: message.platform,
chatId: message.chatId, chatId: message.chatId,
userId: message.userId, userId: message.userId,
@@ -175,58 +137,28 @@ export class Gateway {
private async executeCommand(command: ParsedCommand, message: IncomingMessage): Promise<string> { private async executeCommand(command: ParsedCommand, message: IncomingMessage): Promise<string> {
switch (command.kind) { switch (command.kind) {
case "help": return ["Commands:", "/status", "/cancel", "/new", "/help"].join("\n"); case "help": return HELP_TEXT;
case "status": { case "status": {
const chatKey = chatKeyFor(message.platform, message.chatId); const status = this.runtime.status(message.platform, message.chatId);
const work = this.chatWork.get(chatKey);
const status = {
...this.runtime.status(message.platform, message.chatId),
gatewayRunning: Boolean(work?.running),
queued: work?.queued || 0,
gatewayRunningSeconds: work?.startedAt !== undefined
? Math.max(0, Math.floor((this.now() - work.startedAt) / 1000)) : 0
};
return `OK\n${Object.entries(status).map(([key, value]) => `${key}=${value}`).join("\n")}`; return `OK\n${Object.entries(status).map(([key, value]) => `${key}=${value}`).join("\n")}`;
} }
case "new": case "list": {
await this.runtime.reset(message.platform, message.chatId); if (!this.runtime.listProposals) return "当前运行时不支持列出提案。";
return "Started a new native ACP session for this chat."; const proposals = this.runtime.listProposals(message.platform, message.chatId);
case "cancel": return this.cancelText(message); if (proposals.length === 0) return "当前没有提案。";
case "retired-role": return "Role switching was removed in Config v3; this instance has one fixed Bot and ACP agent."; return proposals.map((proposal) => `${proposal.id} [${proposal.status}] ${proposal.title}`).join("\n");
} }
case "confirm": {
if (!this.runtime.confirm) return "当前运行时不支持确认操作。";
return await this.runtime.confirm(message.platform, message.chatId) ? "已确认,继续执行。" : "当前没有待确认的提案。";
} }
case "stop": {
private async cancelText(message: IncomingMessage): Promise<string> { if (!this.runtime.stop) return "当前运行时不支持停止操作。";
return await this.runtime.cancel(message.platform, message.chatId) ? "Cancellation requested." : "No active turn to cancel."; return await this.runtime.stop(message.platform, message.chatId) ? "已停止当前任务。" : "当前没有正在执行的任务。";
} }
case "cancel":
private armTimer(inbound: AsyncInbound, deadline: number): void { return await this.runtime.cancel(message.platform, message.chatId) ? "已取消最近的提案。" : "没有可取消的提案。";
if (this.closed || inbound.phase === "finished") return;
inbound.timer?.cancel();
inbound.timer = this.schedule(() => {
inbound.timer = undefined;
if (this.closed || inbound.phase === "finished") return;
const now = this.now();
if (now < deadline) {
this.armTimer(inbound, deadline);
return;
} }
const elapsed = now - inbound.receivedAt;
if (!inbound.initialNoticeSent) {
inbound.initialNoticeSent = true;
inbound.queuedNoticeSent = inbound.phase === "queued";
}
void inbound.stream.enqueue(reminderText(inbound.phase, elapsed));
this.armTimer(inbound, nextReminderDeadline(inbound.receivedAt, now));
}, Math.max(0, deadline - this.now()));
}
private finishInbound(inbound: AsyncInbound): void {
if (inbound.phase === "finished") return;
inbound.phase = "finished";
inbound.timer?.cancel();
inbound.timer = undefined;
this.asyncInbounds.delete(inbound);
} }
private checkPolicy(message: IncomingMessage): string | undefined { private checkPolicy(message: IncomingMessage): string | undefined {
@@ -236,55 +168,14 @@ export class Gateway {
return undefined; return undefined;
} }
private async withChatLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
const previous = this.locks.get(key) || Promise.resolve();
const state = this.chatWork.get(key) || { running: false, queued: 0 };
state.queued++;
this.chatWork.set(key, state);
let release!: () => void;
const current = new Promise<void>((resolve) => { release = resolve; });
const lock = previous.then(() => current);
this.locks.set(key, lock);
await previous;
state.queued--;
state.running = true;
state.startedAt = this.now();
try {
return await fn();
} finally {
state.running = false;
state.startedAt = undefined;
release();
if (this.locks.get(key) === lock) {
this.locks.delete(key);
this.chatWork.delete(key);
}
}
}
} }
function defaultSchedule(callback: () => void, delayMs: number): GatewayTimer { const HELP_TEXT = [
const timer = setTimeout(callback, delayMs); "直接用自然语言告诉我你要做什么即可,我会自己判断并处理。",
timer.unref(); "兜底命令:",
return { cancel: () => clearTimeout(timer) }; "/list 列出当前提案",
} "/confirm 确认最近待确认的提案",
"/stop 停止正在执行的任务",
function nextReminderDeadline(receivedAt: number, now: number): number { "/cancel 取消最近未开始的提案",
const elapsed = now - receivedAt; "/status 查看运行状态"
if (elapsed < 60_000) return receivedAt + 60_000; ].join("\n");
if (elapsed < 180_000) return receivedAt + 180_000;
if (elapsed < 480_000) return receivedAt + 480_000;
return receivedAt + 480_000 + (Math.floor((elapsed - 480_000) / 600_000) + 1) * 600_000;
}
function reminderText(phase: AsyncInbound["phase"], elapsed: number): string {
if (phase === "queued") {
return elapsed < 60_000
? "前面还有一件事没处理完,这条我记着,轮到后马上处理。"
: "前面的事情还没处理完,这条还在等。我没有漏掉,轮到后会接着做。";
}
if (elapsed < 60_000) return "收到,我还在处理,稍等我一下。";
if (elapsed < 180_000) return "还在弄,暂时还没出结果,弄好我马上回你。";
if (elapsed < 480_000) return "这件事比预想中多花了一点时间,我还在继续处理。你不用一直盯着,弄好我会直接回你。";
return "我还在处理这件事,确实花了些时间。想看看现在的状态可以发 /status,不想继续等也可以发 /cancel。";
}
+305
View File
@@ -0,0 +1,305 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
export interface StoreIdentity {
botId: string;
platform: string;
}
export interface WorkerProcessGroup {
pgid: number;
token: string;
}
export type ProposalStatus =
| "proposed"
| "queued"
| "working"
| "awaiting_user_confirmation"
| "completed"
| "failed"
| "cancelled";
export interface ProposalPending {
kind: "step" | "success" | "failure";
summary: string;
question?: string;
nextStep?: string;
}
export interface Proposal {
id: string;
title: string;
goal: string;
steps: string[];
ownerChatKey: string;
requesterUserId: string;
status: ProposalStatus;
workerNativeSessionId?: string;
workerProcessGroup?: WorkerProcessGroup;
pending?: ProposalPending;
lastWorkerSummary?: string;
createdAt: number;
confirmedAt?: number;
startedAt?: number;
updatedAt: number;
finishedAt?: number;
}
export interface CreateProposalInput {
title: string;
goal: string;
steps: string[];
ownerChatKey: string;
requesterUserId: string;
}
export interface ProposalPatch {
title?: string;
goal?: string;
steps?: string[];
status?: ProposalStatus;
workerNativeSessionId?: string;
workerProcessGroup?: WorkerProcessGroup;
pending?: ProposalPending;
lastWorkerSummary?: string;
confirmedAt?: number;
startedAt?: number;
finishedAt?: number;
}
interface StoreData {
version: 1;
botId: string;
platform: string;
proposals: Record<string, Proposal>;
}
export class ProposalStore {
private data: StoreData;
private queue: Promise<void> = Promise.resolve();
private lockFd?: fs.promises.FileHandle;
private closed = false;
constructor(readonly file: string, readonly identity: StoreIdentity) {
this.data = emptyData(identity);
}
async open(): Promise<void> {
const directory = path.dirname(this.file);
await fs.promises.mkdir(directory, { recursive: true, mode: 0o700 });
const directoryStat = await fs.promises.lstat(directory);
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) throw new Error(`Refusing unsafe state directory: ${directory}`);
const directoryMode = directoryStat.mode & 0o777;
if (directoryMode !== 0o700) throw new Error(`State directory mode must be 0700, got 0${directoryMode.toString(8)}`);
if (typeof process.getuid === "function" && directoryStat.uid !== process.getuid()) throw new Error("State directory is not owned by the current user");
const lockFile = `${this.file}.lock`;
try {
this.lockFd = await acquireLock(lockFile);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST" || !await removeStaleLock(lockFile)) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Proposal state is locked by another gori-agent instance: ${lockFile}`);
throw error;
}
this.lockFd = await acquireLock(lockFile).catch((retryError) => {
if ((retryError as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Proposal state is locked by another gori-agent instance: ${lockFile}`);
throw retryError;
});
}
try {
await this.lockFd.writeFile(`${process.pid}\n`);
await this.lockFd.sync();
} catch (error) {
await this.releaseLock();
throw error;
}
try {
const raw = await fs.promises.readFile(this.file, "utf8");
const parsed = parseStoreData(JSON.parse(raw) as unknown);
if (parsed.botId !== this.identity.botId || parsed.platform !== this.identity.platform) {
throw new Error(`proposal state identity mismatch: expected bot '${this.identity.botId}' platform '${this.identity.platform}'`);
}
this.data = parsed;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
await this.releaseLock();
throw new Error(`Cannot read proposal state '${this.file}'; original file was preserved: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
get(id: string): Proposal | undefined {
const value = this.data.proposals[id];
return value ? structuredClone(value) : undefined;
}
list(filter?: { status?: ProposalStatus }): Proposal[] {
const proposals = Object.values(this.data.proposals)
.filter((proposal) => !filter?.status || proposal.status === filter.status)
.sort((a, b) => (a.confirmedAt ?? a.createdAt) - (b.confirmedAt ?? b.createdAt));
return structuredClone(proposals);
}
async create(input: CreateProposalInput): Promise<Proposal> {
if (typeof input.title !== "string" || input.title.length === 0) throw new Error("proposal title must be a non-empty string");
if (typeof input.goal !== "string" || input.goal.length === 0) throw new Error("proposal goal must be a non-empty string");
if (!Array.isArray(input.steps) || input.steps.some((step) => typeof step !== "string" || step.length === 0)) {
throw new Error("proposal steps must be an array of non-empty strings");
}
if (typeof input.ownerChatKey !== "string" || input.ownerChatKey.length === 0) throw new Error("proposal ownerChatKey must be a non-empty string");
if (typeof input.requesterUserId !== "string" || input.requesterUserId.length === 0) throw new Error("proposal requesterUserId must be a non-empty string");
const now = Date.now();
const proposal: Proposal = {
id: crypto.randomUUID(),
title: input.title,
goal: input.goal,
steps: [...input.steps],
ownerChatKey: input.ownerChatKey,
requesterUserId: input.requesterUserId,
status: "proposed",
createdAt: now,
updatedAt: now
};
this.data.proposals[proposal.id] = proposal;
await this.persist();
return structuredClone(proposal);
}
async update(id: string, patch: ProposalPatch): Promise<Proposal> {
const proposal = this.data.proposals[id];
if (!proposal) throw new Error(`unknown proposal '${id}'`);
const candidate: Proposal = { ...structuredClone(proposal), ...structuredClone(patch), id, updatedAt: Date.now() };
validateProposal(candidate, id);
this.data.proposals[id] = candidate;
await this.persist();
return structuredClone(candidate);
}
stats(): { proposals: number } {
return { proposals: Object.keys(this.data.proposals).length };
}
async flush(): Promise<void> { await this.queue; }
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
try { await this.flush(); } finally { await this.releaseLock(); }
}
private persist(): Promise<void> {
if (this.closed) return Promise.reject(new Error("Proposal store is closed"));
const snapshot = JSON.stringify(this.data, null, 2) + "\n";
const operation = this.queue.then(() => atomicWrite(this.file, snapshot));
this.queue = operation.catch(() => undefined);
return operation;
}
private async releaseLock(): Promise<void> {
if (!this.lockFd) return;
await this.lockFd.close();
this.lockFd = undefined;
await fs.promises.unlink(`${this.file}.lock`).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "ENOENT") throw error;
});
}
}
function emptyData(identity: StoreIdentity): StoreData {
return { version: 1, botId: identity.botId, platform: identity.platform, proposals: {} };
}
const STATUSES: readonly ProposalStatus[] = ["proposed", "queued", "working", "awaiting_user_confirmation", "completed", "failed", "cancelled"];
function parseStoreData(value: unknown): StoreData {
if (!isRecord(value) || value.version !== 1 || typeof value.botId !== "string" || typeof value.platform !== "string" || !isRecord(value.proposals)) {
throw new Error("unsupported proposal state version; expected version 1");
}
for (const [id, proposal] of Object.entries(value.proposals)) {
if (!isRecord(proposal) || proposal.id !== id) throw new Error(`invalid proposal '${id}'`);
validateProposal(proposal as unknown as Proposal, id);
}
return value as unknown as StoreData;
}
function validateProposal(proposal: Proposal, id: string): void {
const invalid = (reason: string): never => { throw new Error(`invalid proposal '${id}': ${reason}`); };
if (typeof proposal.title !== "string" || proposal.title.length === 0) invalid("title");
if (typeof proposal.goal !== "string" || proposal.goal.length === 0) invalid("goal");
if (!Array.isArray(proposal.steps) || proposal.steps.some((step) => typeof step !== "string" || step.length === 0)) invalid("steps");
if (typeof proposal.ownerChatKey !== "string" || proposal.ownerChatKey.length === 0) invalid("ownerChatKey");
if (typeof proposal.requesterUserId !== "string" || proposal.requesterUserId.length === 0) invalid("requesterUserId");
if (!STATUSES.includes(proposal.status)) invalid("status");
if (proposal.workerNativeSessionId !== undefined && typeof proposal.workerNativeSessionId !== "string") invalid("workerNativeSessionId");
if (proposal.workerProcessGroup !== undefined) {
const group = proposal.workerProcessGroup;
if (!isRecord(group) || !Number.isSafeInteger(group.pgid) || group.pgid <= 1 || typeof group.token !== "string" || group.token.length === 0) {
invalid("workerProcessGroup");
}
}
if (proposal.pending !== undefined) {
const pending = proposal.pending;
if (!isRecord(pending)
|| !["step", "success", "failure"].includes(String(pending.kind))
|| typeof pending.summary !== "string"
|| (pending.question !== undefined && typeof pending.question !== "string")
|| (pending.nextStep !== undefined && typeof pending.nextStep !== "string")) {
invalid("pending");
}
}
if (proposal.lastWorkerSummary !== undefined && typeof proposal.lastWorkerSummary !== "string") invalid("lastWorkerSummary");
if (typeof proposal.createdAt !== "number") invalid("createdAt");
if (typeof proposal.updatedAt !== "number") invalid("updatedAt");
if (proposal.confirmedAt !== undefined && typeof proposal.confirmedAt !== "number") invalid("confirmedAt");
if (proposal.startedAt !== undefined && typeof proposal.startedAt !== "number") invalid("startedAt");
if (proposal.finishedAt !== undefined && typeof proposal.finishedAt !== "number") invalid("finishedAt");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function acquireLock(lockFile: string): Promise<fs.promises.FileHandle> {
return fs.promises.open(lockFile, "wx", 0o600);
}
async function removeStaleLock(lockFile: string): Promise<boolean> {
let handle: fs.promises.FileHandle | undefined;
try {
handle = await fs.promises.open(lockFile, "r");
const pid = Number((await handle.readFile("utf8")).trim());
if (!Number.isSafeInteger(pid) || pid <= 1 || processExists(pid)) return false;
const opened = await handle.stat();
const current = await fs.promises.lstat(lockFile);
if (opened.dev !== current.dev || opened.ino !== current.ino) return false;
await fs.promises.unlink(lockFile);
return true;
} catch { return false; }
finally { await handle?.close().catch(() => undefined); }
}
function processExists(pid: number): boolean {
try { process.kill(pid, 0); return true; }
catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
}
async function atomicWrite(file: string, content: string): Promise<void> {
const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
let handle: fs.promises.FileHandle | undefined;
try {
handle = await fs.promises.open(temp, "wx", 0o600);
await handle.writeFile(content, "utf8");
await handle.sync();
await handle.close();
handle = undefined;
await fs.promises.rename(temp, file);
await fs.promises.chmod(file, 0o600);
const dir = await fs.promises.open(path.dirname(file), "r");
try { await dir.sync(); } finally { await dir.close(); }
} catch (error) {
if (handle) await handle.close().catch(() => undefined);
await fs.promises.unlink(temp).catch(() => undefined);
throw error;
}
}
+37
View File
@@ -0,0 +1,37 @@
import fs from "node:fs";
import path from "node:path";
import { parseConfig } from "../config.js";
export function canonicalWorkspace(workspace: string): string {
return fs.realpathSync.native(path.resolve(workspace));
}
export function findOverlappingWorkspace(workspace: string, botId: string, instancesDirectory: string): string | undefined {
const target = canonicalWorkspace(workspace);
let entries: fs.Dirent[];
try { entries = fs.readdirSync(instancesDirectory, { withFileTypes: true }); }
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
for (const entry of entries) {
if (entry.name === botId) continue;
if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error(`Refusing unsafe peer instance entry: ${entry.name}`);
const file = path.join(instancesDirectory, entry.name, "config.json");
let stat: fs.Stats;
try { stat = fs.lstatSync(file); }
catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new Error(`Peer instance is missing config.json: ${entry.name}`);
throw error;
}
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`Refusing unsafe peer config: ${file}`);
let peer;
try { peer = parseConfig(JSON.parse(fs.readFileSync(file, "utf8"))); }
catch (error) { throw new Error(`Invalid peer Config v3 '${file}': ${error instanceof Error ? error.message : String(error)}`); }
const other = canonicalWorkspace(peer.bot.workspace);
if (other === target || isInside(other, target) || isInside(target, other)) return entry.name;
}
return undefined;
}
function isInside(parent: string, child: string): boolean {
const relative = path.relative(parent, child);
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
}
+23 -5
View File
@@ -2,12 +2,13 @@ import crypto from "node:crypto";
import type { AppConfig, BotConfig } from "../config.js"; import type { AppConfig, BotConfig } from "../config.js";
import { SkillLoader, type LoadedSkill } from "./skill-loader.js"; import { SkillLoader, type LoadedSkill } from "./skill-loader.js";
const BOOTSTRAP_SCHEMA_VERSION = 1; const BOOTSTRAP_SCHEMA_VERSION = 4;
export interface ResolvedBot extends BotConfig { export interface ResolvedBot extends BotConfig {
loadedSkills: LoadedSkill[]; loadedSkills: LoadedSkill[];
fingerprint: string; fingerprint: string;
bootstrap: string; assistantBootstrap: string;
workerBootstrap: string;
} }
export class BotProfileResolver { export class BotProfileResolver {
@@ -28,18 +29,35 @@ export class BotProfileResolver {
...config.bot, ...config.bot,
loadedSkills, loadedSkills,
fingerprint, fingerprint,
bootstrap: buildBootstrap(config.bot, loadedSkills) assistantBootstrap: buildAssistantBootstrap(config.bot),
workerBootstrap: buildWorkerBootstrap(config.bot, loadedSkills)
}; };
} }
} }
function buildBootstrap(bot: BotConfig, skills: LoadedSkill[]): string { function buildAssistantBootstrap(bot: BotConfig): string {
return [ return [
`Initialize this ACP session with gori-agent bootstrap schema v${BOOTSTRAP_SCHEMA_VERSION}. Treat these instructions as persistent context. Reply only with READY.`, `Initialize this ACP session with gori-agent assistant bootstrap schema v${BOOTSTRAP_SCHEMA_VERSION}. Treat these instructions as persistent context. Reply only with READY.`,
`Bot: ${bot.id}`,
`Workspace: ${bot.workspace}`,
bot.persona ? `Persona:\n${bot.persona}` : "Persona: general coding assistant",
"You are the user-facing Assistant. You have no tools and cannot inspect files, run commands, call Skills or MCP. You chat with the user, propose work, and explain worker feedback. Never claim to have executed anything yourself.",
"Every reply must end with exactly one hidden action envelope: <GORI_ASSISTANT_ACTION_V1>{\"reply\":\"...\",\"actions\":[...]}</GORI_ASSISTANT_ACTION_V1>. Put the user-facing text in the JSON \"reply\" field, not outside the envelope.",
"Supported actions: {\"type\":\"create_proposal\",\"title\":\"...\",\"goal\":\"...\",\"steps\":[\"...\"]}; {\"type\":\"confirm\",\"id\":\"optional\",\"answer\":\"optional\"}; {\"type\":\"start_next\"}; {\"type\":\"cancel\",\"id\":\"optional\"}; {\"type\":\"stop\"}. Use an empty actions array when no state change is needed.",
"A proposal only starts after the user confirms it. Confirming a proposal whose worker reported SUCCESS marks it completed; confirming a FAILED proposal marks it failed; to retry a failed proposal, confirm with answer \"retry\". When a worker asks a question (NEEDS_CONFIRMATION), confirm with the user's answer to continue the same worker. \"stop\" terminates the active worker and cancels its proposal; \"start_next\" starts the oldest confirmed queued proposal. Never invent other actions or statuses."
].join("\n\n");
}
function buildWorkerBootstrap(bot: BotConfig, skills: LoadedSkill[]): string {
return [
`Initialize this ACP session with gori-agent worker bootstrap schema v${BOOTSTRAP_SCHEMA_VERSION}. Treat these instructions as persistent context. Reply only with READY.`,
`Bot: ${bot.id}`, `Bot: ${bot.id}`,
`Workspace: ${bot.workspace}`, `Workspace: ${bot.workspace}`,
bot.persona ? `Persona:\n${bot.persona}` : "Persona: general coding assistant", bot.persona ? `Persona:\n${bot.persona}` : "Persona: general coding assistant",
`Permission policy enforced by the ACP client: ${JSON.stringify(bot.permissions)}`, `Permission policy enforced by the ACP client: ${JSON.stringify(bot.permissions)}`,
"You are the Worker. You execute exactly one confirmed proposal in the configured workspace and never talk to the user directly.",
"For every turn, end your response with exactly one hidden result envelope: <GORI_WORKER_RESULT_V1>{\"status\":\"SUCCESS\",\"summary\":\"...\"}</GORI_WORKER_RESULT_V1>. Status is SUCCESS only when the proposal is fully done, FAILED when it cannot be completed, or NEEDS_CONFIRMATION when you must ask the user before continuing (for example a dirty or unexpected target). Optional fields: \"question\", \"nextStep\", \"dirty\". Always include a short user-readable \"summary\". Never emit any other status or text after the envelope.",
"Keep every command and tool process attached to this ACP worker. Never daemonize, call setsid, use nohup, create a detached process, or leave a background process running after the turn.",
...skills.map((skill) => `Skill ${skill.id} (${skill.file}):\n${skill.content}`) ...skills.map((skill) => `Skill ${skill.id} (${skill.file}):\n${skill.content}`)
].join("\n\n"); ].join("\n\n");
} }
+31 -11
View File
@@ -1,11 +1,12 @@
import express from "express"; import express from "express";
import type { Server } from "node:http"; import type { Server } from "node:http";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { AcpSessionManager } from "./acp/session-manager.js"; import { AssistantManager } from "./acp/assistant-manager.js";
import { defaultStateFile, loadConfig, type AppConfig } from "./config.js"; import { defaultProposalFile, defaultStateFile, loadConfig, type AppConfig } from "./config.js";
import type { PlatformAdapter } from "./core/adapter.js"; import type { PlatformAdapter } from "./core/adapter.js";
import { DurableSessionStore } from "./core/durable-session-store.js"; import { DurableSessionStore } from "./core/durable-session-store.js";
import { Gateway } from "./core/gateway.js"; import { Gateway } from "./core/gateway.js";
import { ProposalStore } from "./core/proposal-store.js";
import { FeishuAdapter } from "./platforms/feishu/adapter.js"; import { FeishuAdapter } from "./platforms/feishu/adapter.js";
import { QqAdapter } from "./platforms/qq/adapter.js"; import { QqAdapter } from "./platforms/qq/adapter.js";
import { QqGatewayClient } from "./platforms/qq/gateway-client.js"; import { QqGatewayClient } from "./platforms/qq/gateway-client.js";
@@ -17,8 +18,9 @@ import { BotProfileResolver } from "./roles/role-registry.js";
export interface GatewayRuntime { export interface GatewayRuntime {
app: express.Express; app: express.Express;
gateway: Gateway; gateway: Gateway;
sessionManager: AcpSessionManager; assistantManager: AssistantManager;
store: DurableSessionStore; store: DurableSessionStore;
proposals: ProposalStore;
platformAdapter: PlatformAdapter; platformAdapter: PlatformAdapter;
qqGatewayClient?: QqGatewayClient; qqGatewayClient?: QqGatewayClient;
shutdown(): Promise<void>; shutdown(): Promise<void>;
@@ -26,12 +28,26 @@ export interface GatewayRuntime {
export async function createGatewayRuntime(config: AppConfig): Promise<GatewayRuntime> { export async function createGatewayRuntime(config: AppConfig): Promise<GatewayRuntime> {
const platformType = config.gateway.platform.type; const platformType = config.gateway.platform.type;
const store = new DurableSessionStore(defaultStateFile(config), { botId: config.bot.id, platform: platformType }); const identity = { botId: config.bot.id, platform: platformType };
const store = new DurableSessionStore(defaultStateFile(config), identity);
const proposals = new ProposalStore(defaultProposalFile(config), identity);
let assistantManager: AssistantManager | undefined;
await store.open(); await store.open();
try {
await proposals.open();
} catch (error) {
await store.close().catch(() => undefined);
throw error;
}
try { try {
const bot = new BotProfileResolver(config).bot; const bot = new BotProfileResolver(config).bot;
const sessionManager = new AcpSessionManager(config.runtime.acp, bot, store); assistantManager = new AssistantManager(config.runtime.acp, bot, store, proposals, {
const gateway = new Gateway(config.gateway.policy, sessionManager); maxAssistantSessions: config.runtime.acp.maxAssistantSessions
});
await assistantManager.initialize();
const manager = assistantManager;
const gateway = new Gateway(config.gateway.policy, manager);
manager.setEventSink((chatKey, text) => gateway.sendEvent(chatKey, text));
const platformAdapter = createPlatformAdapter(config, gateway); const platformAdapter = createPlatformAdapter(config, gateway);
const qqGatewayClient = config.gateway.platform.type === "qq" && config.gateway.platform.connectionMode === "websocket" const qqGatewayClient = config.gateway.platform.type === "qq" && config.gateway.platform.connectionMode === "websocket"
? new QqGatewayClient(config.gateway.platform, platformAdapter as QqAdapter) : undefined; ? new QqGatewayClient(config.gateway.platform, platformAdapter as QqAdapter) : undefined;
@@ -67,17 +83,21 @@ export async function createGatewayRuntime(config: AppConfig): Promise<GatewayRu
let closing: Promise<void> | undefined; let closing: Promise<void> | undefined;
return { return {
app, gateway, sessionManager, store, platformAdapter, qqGatewayClient, app, gateway, assistantManager: manager, store, proposals, platformAdapter, qqGatewayClient,
shutdown: () => closing ||= (async () => { shutdown: () => closing ||= (async () => {
qqGatewayClient?.stop(); qqGatewayClient?.stop();
gateway.shutdown(); gateway.shutdown();
const results = await Promise.allSettled([sessionManager.shutdown(), store.close()]); let shutdownError: unknown;
const failed = results.find((result): result is PromiseRejectedResult => result.status === "rejected"); try { await manager.shutdown(); } catch (error) { shutdownError = error; }
if (failed) throw failed.reason; try { await proposals.close(); } catch (error) { shutdownError ||= error; }
try { await store.close(); } catch (error) { shutdownError ||= error; }
if (shutdownError) throw shutdownError;
})() })()
}; };
} catch (error) { } catch (error) {
await store.close(); await assistantManager?.shutdown().catch(() => undefined);
await proposals.close().catch(() => undefined);
await store.close().catch(() => undefined);
throw error; throw error;
} }
} }
-122
View File
@@ -1,122 +0,0 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { AcpSessionManager } from "../src/acp/session-manager.js";
import { parseConfig } from "../src/config.js";
import { DurableSessionStore } from "../src/core/durable-session-store.js";
import { BotProfileResolver } from "../src/roles/role-registry.js";
const fixture = path.resolve("test/fixtures/fake-acp-agent.mjs");
function config(stateFile: string, logFile: string, persona = "test", agentEnv: Record<string, string> = {}) {
return parseConfig({
configVersion: 3,
bot: {
id: "test-bot", workspace: path.resolve("."), persona,
agent: { id: "fake", command: process.execPath, args: [fixture], env: { FAKE_ACP_LOG: logFile, ...agentEnv } },
skills: [], permissions: { mode: "deny" }
},
gateway: { platform: { type: "qq", appId: "id", clientSecret: "secret", botNames: ["bot"] } },
runtime: { acp: { stateFile, promptTimeoutMs: 2_000, cancelGraceMs: 100, idleTimeoutMs: 100, sweepIntervalMs: 20, maxProcesses: 2 } }
});
}
async function runtime(stateFile: string, logFile: string, persona = "test", agentEnv: Record<string, string> = {}) {
const cfg = config(stateFile, logFile, persona, agentEnv);
const store = new DurableSessionStore(stateFile, { botId: cfg.bot.id, platform: cfg.gateway.platform.type }); await store.open();
const bot = new BotProfileResolver(cfg).bot;
return { cfg, store, bot, manager: new AcpSessionManager(cfg.runtime.acp, bot, store) };
}
test("creates, persists, idles, and resumes the same native session", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-acp-")); const state = path.join(dir, "state.json"); const log = path.join(dir, "fake.log");
const first = await runtime(state, log);
const response = await first.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "one" });
const sessionId = response.text.split(":")[1];
assert.equal(response.botId, "test-bot"); assert.match(response.text, /reply:fake-/);
await new Promise((resolve) => setTimeout(resolve, 180));
await first.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "two" });
await first.manager.shutdown(); await first.store.close();
const second = await runtime(state, log);
const resumed = await second.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "three" });
assert.equal(resumed.text, `reply:${sessionId}:three`);
const entries = (await fs.promises.readFile(log, "utf8")).trim().split("\n").map(JSON.parse);
assert.ok(entries.some((entry) => entry.method === "session/resume" && entry.sessionId === sessionId));
await second.manager.shutdown(); await second.store.close();
});
test("resume falls back to load and failed restore creates a new session", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-acp-restore-"));
const state = path.join(dir, "state.json"); const log = path.join(dir, "fake.log");
const resumeFail = path.join(dir, "resume-fail"); const loadFail = path.join(dir, "load-fail");
const agentEnv = { FAKE_ACP_RESUME_FAIL_FILE: resumeFail, FAKE_ACP_LOAD_FAIL_FILE: loadFail };
const first = await runtime(state, log, "test", agentEnv);
const initial = await first.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "one" });
const initialSession = initial.text.split(":")[1];
await first.manager.shutdown(); await first.store.close();
await fs.promises.writeFile(resumeFail, "1");
const fallback = await runtime(state, log, "test", agentEnv);
const loaded = await fallback.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "two" });
assert.equal(loaded.text, `reply:${initialSession}:two`);
await fallback.manager.shutdown(); await fallback.store.close();
await fs.promises.writeFile(loadFail, "1");
const replacement = await runtime(state, log, "test", agentEnv);
const fresh = await replacement.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "three" });
assert.notEqual(fresh.text.split(":")[1], initialSession);
await replacement.manager.shutdown(); await replacement.store.close();
const entries = (await fs.promises.readFile(log, "utf8")).trim().split("\n").map(JSON.parse);
assert.ok(entries.some((entry) => entry.method === "session/load" && entry.sessionId === initialSession));
});
test("cancel reaches hanging prompt, reset unbinds, and fingerprint changes session", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-acp-cancel-")); const state = path.join(dir, "state.json"); const log = path.join(dir, "fake.log");
const current = await runtime(state, log);
await current.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "ready" });
const hanging = current.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "hang" });
await new Promise((resolve) => setTimeout(resolve, 50));
assert.equal(await current.manager.cancel("qq", "chat"), true);
await hanging;
await current.manager.reset("qq", "chat");
assert.equal(current.store.stats().bindings, 0);
await current.manager.shutdown(); await current.store.close();
const original = await runtime(state, log, "one");
const first = await original.manager.prompt({ platform: "qq", chatId: "other", userId: "user", text: "first" });
const firstSession = first.text.split(":")[1];
await original.manager.shutdown(); await original.store.close();
const changed = await runtime(state, log, "two");
const second = await changed.manager.prompt({ platform: "qq", chatId: "other", userId: "user", text: "second" });
assert.notEqual(second.text.split(":")[1], firstSession);
await changed.manager.shutdown(); await changed.store.close();
});
test("status reports initializing, processing, running time, and update activity", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-acp-status-")); const state = path.join(dir, "state.json"); const log = path.join(dir, "fake.log");
const current = await runtime(state, log, "test", { FAKE_ACP_BOOTSTRAP_DELAY_MS: "200" });
const first = current.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "ready" });
for (let count = 0; count < 50 && current.manager.status("qq", "chat").phase !== "initializing"; count++) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.deepEqual(current.manager.status("qq", "chat"), {
bot: "test-bot", agent: "fake", workspace: path.resolve("."), persisted: false, running: true,
phase: "initializing", runningSeconds: 0, idleSeconds: 0
});
await first;
const activity = current.manager.prompt({ platform: "qq", chatId: "chat", userId: "user", text: "activity" });
await new Promise((resolve) => setTimeout(resolve, 1_100));
const processing = current.manager.status("qq", "chat");
assert.equal(processing.phase, "processing");
assert.equal(processing.running, true);
assert.equal(processing.runningSeconds, 1);
assert.equal(processing.idleSeconds, 0);
await activity;
assert.equal(current.manager.status("qq", "chat").phase, "idle");
await current.manager.shutdown(); await current.store.close();
});
+47 -2
View File
@@ -1,4 +1,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path"; import path from "node:path";
import test from "node:test"; import test from "node:test";
import { AcpWorker } from "../src/acp/worker.js"; import { AcpWorker } from "../src/acp/worker.js";
@@ -7,10 +9,10 @@ import { BotProfileResolver } from "../src/roles/role-registry.js";
const fixture = path.resolve("test/fixtures/fake-acp-agent.mjs"); const fixture = path.resolve("test/fixtures/fake-acp-agent.mjs");
function makeWorker(promptTimeoutMs = 80) { function makeWorker(promptTimeoutMs = 80, env: Record<string, string> = {}) {
const config = parseConfig({ const config = parseConfig({
configVersion: 3, configVersion: 3,
bot: { id: "test-bot", workspace: path.resolve("."), persona: "", agent: { id: "fake", command: process.execPath, args: [fixture] }, permissions: { mode: "deny" } }, bot: { id: "test-bot", workspace: path.resolve("."), persona: "", agent: { id: "fake", command: process.execPath, args: [fixture], env }, permissions: { mode: "deny" } },
gateway: { platform: { type: "webhook", secret: "secret" } }, gateway: { platform: { type: "webhook", secret: "secret" } },
runtime: { acp: { promptTimeoutMs, cancelGraceMs: 30 } } runtime: { acp: { promptTimeoutMs, cancelGraceMs: 30 } }
}); });
@@ -33,3 +35,46 @@ test("worker reports an unexpected ACP subprocess exit", async () => {
await new Promise((resolve) => setTimeout(resolve, 20)); await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(current.crashes(), 1); assert.equal(current.crashes(), 1);
}); });
test("immediate termination kills ACP descendants in the worker process group", async (t) => {
await assertDescendantTermination(t, (worker) => { worker.terminateImmediately(); });
});
test("normal termination SIGKILLs descendants that ignore SIGTERM", async (t) => {
await assertDescendantTermination(t, (worker) => worker.terminate());
});
test("persisted worker token safely identifies and kills an orphaned process group", async () => {
const { worker } = makeWorker(2_000);
await worker.start();
assert.ok(worker.processGroup);
await AcpWorker.terminatePersistedGroup(worker.processGroup, 100);
assert.equal(processGroupExists(worker.processGroup.pgid), false);
});
async function assertDescendantTermination(t: test.TestContext, terminate: (worker: AcpWorker) => void | Promise<void>): Promise<void> {
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-acp-group-"));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const pidFile = path.join(directory, "descendant.pid");
const { worker } = makeWorker(2_000, { FAKE_ACP_DESCENDANT_PID_FILE: pidFile });
await worker.start();
const prompt = worker.prompt("spawn descendant");
const promptRejected = assert.rejects(prompt);
for (let count = 0; count < 100 && !fs.existsSync(pidFile); count++) await new Promise((resolve) => setTimeout(resolve, 10));
const pid = Number(fs.readFileSync(pidFile, "utf8").trim());
assert.equal(processExists(pid), true);
await terminate(worker);
await promptRejected;
for (let count = 0; count < 100 && processExists(pid); count++) await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(processExists(pid), false);
}
function processExists(pid: number): boolean {
try { process.kill(pid, 0); return true; }
catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
}
function processGroupExists(pgid: number): boolean {
try { process.kill(-pgid, 0); return true; }
catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
}
+331
View File
@@ -0,0 +1,331 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { AssistantManager, parseAssistantActions, parseWorkerResult } from "../src/acp/assistant-manager.js";
import { parseConfig } from "../src/config.js";
import { DurableSessionStore } from "../src/core/durable-session-store.js";
import { ProposalStore } from "../src/core/proposal-store.js";
import { BotProfileResolver } from "../src/roles/role-registry.js";
const fixture = path.resolve("test/fixtures/fake-acp-agent.mjs");
const CHAT_KEY = "webhook:chat-1";
interface Harness {
home: string;
workspace: string;
store: DurableSessionStore;
proposals: ProposalStore;
manager: AssistantManager;
events: { chatKey: string; text: string }[];
logFile: string;
}
async function createHarness(options: { initialize?: boolean; agentEnv?: Record<string, string> } = {}): Promise<Harness> {
const home = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-assistant-home-"));
const workspace = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-assistant-ws-"));
const logFile = path.join(home, "fake-acp.log");
const config = parseConfig({
configVersion: 3,
bot: { id: "test-bot", workspace, persona: "", agent: { id: "fake", command: process.execPath, args: [fixture], env: { FAKE_ACP_LOG: logFile, ...options.agentEnv } }, permissions: { mode: "deny" } },
gateway: { platform: { type: "webhook", secret: "secret" } },
runtime: { acp: { promptTimeoutMs: 10_000, cancelGraceMs: 200 } }
});
const identity = { botId: "test-bot", platform: "webhook" };
const store = new DurableSessionStore(path.join(home, "state", "acp-sessions.json"), identity);
const proposals = new ProposalStore(path.join(home, "state", "proposals.json"), identity);
await store.open();
await proposals.open();
const events: { chatKey: string; text: string }[] = [];
const manager = new AssistantManager(config.runtime.acp, new BotProfileResolver(config).bot, store, proposals, {
assistantWorkspaceHome: home,
allowUnverifiedAssistantAgent: true
});
manager.setEventSink(async (chatKey, text) => { events.push({ chatKey, text }); });
if (options.initialize !== false) await manager.initialize();
return { home, workspace, store, proposals, manager, events, logFile };
}
async function reopenManager(harness: Harness): Promise<void> {
await harness.manager.shutdown().catch(() => undefined);
await harness.proposals.close().catch(() => undefined);
await harness.store.close().catch(() => undefined);
const identity = { botId: "test-bot", platform: "webhook" };
harness.store = new DurableSessionStore(path.join(harness.home, "state", "acp-sessions.json"), identity);
harness.proposals = new ProposalStore(path.join(harness.home, "state", "proposals.json"), identity);
await harness.store.open();
await harness.proposals.open();
const config = parseConfig({
configVersion: 3,
bot: { id: "test-bot", workspace: harness.workspace, persona: "", agent: { id: "fake", command: process.execPath, args: [fixture], env: { FAKE_ACP_LOG: harness.logFile } }, permissions: { mode: "deny" } },
gateway: { platform: { type: "webhook", secret: "secret" } },
runtime: { acp: { promptTimeoutMs: 10_000, cancelGraceMs: 200 } }
});
harness.manager = new AssistantManager(config.runtime.acp, new BotProfileResolver(config).bot, harness.store, harness.proposals, {
assistantWorkspaceHome: harness.home,
allowUnverifiedAssistantAgent: true
});
harness.manager.setEventSink(async (chatKey, text) => { harness.events.push({ chatKey, text }); });
await harness.manager.initialize();
}
async function closeHarness(harness: Harness): Promise<void> {
await harness.manager.shutdown().catch(() => undefined);
await harness.proposals.close().catch(() => undefined);
await harness.store.close().catch(() => undefined);
await fs.promises.rm(harness.home, { recursive: true, force: true });
await fs.promises.rm(harness.workspace, { recursive: true, force: true });
}
const request = (text: string) => ({ platform: "webhook", chatId: "chat-1", userId: "user-1", text });
async function waitFor(condition: () => boolean, timeoutMs = 8_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!condition()) {
if (Date.now() > deadline) throw new Error("condition not met before timeout");
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
function readLog(logFile: string): { method: string; sessionId?: string; cwd?: string; text?: string }[] {
if (!fs.existsSync(logFile)) return [];
return fs.readFileSync(logFile, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line) as { method: string });
}
test("create proposal stays proposed until confirm; confirm starts the worker without waiting for it", async () => {
const harness = await createHarness();
try {
const created = await harness.manager.prompt(request("create proposal: hang"));
assert.equal(created.mode, "assistant");
const [proposal] = harness.proposals.list();
assert.ok(proposal);
assert.equal(proposal.status, "proposed");
assert.equal(proposal.ownerChatKey, CHAT_KEY);
assert.equal(readLog(harness.logFile).filter((entry) => entry.method === "session/new" && entry.cwd === harness.workspace).length, 0);
assert.ok(harness.store.getBinding(CHAT_KEY));
await harness.manager.prompt(request("confirm"));
const working = harness.proposals.get(proposal.id)!;
assert.equal(working.status, "working");
assert.ok(working.confirmedAt);
assert.ok(working.startedAt);
await waitFor(() => Boolean(harness.proposals.get(proposal.id)!.workerProcessGroup));
assert.equal(harness.proposals.get(proposal.id)!.status, "working");
await harness.manager.prompt(request("stop"));
const stopped = harness.proposals.get(proposal.id)!;
assert.equal(stopped.status, "cancelled");
assert.equal(stopped.workerProcessGroup, undefined);
assert.ok(stopped.finishedAt);
} finally {
await closeHarness(harness);
}
});
test("stop does not deadlock while a worker result is waiting to settle", async () => {
const gateFile = path.join(os.tmpdir(), `gori-worker-gate-${crypto.randomUUID()}`);
const harness = await createHarness({ agentEnv: { FAKE_ACP_WORKER_GATE_FILE: gateFile } });
try {
await harness.manager.prompt(request("create proposal: succeed"));
const proposal = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm"));
await waitFor(() => readLog(harness.logFile).some((entry) => entry.method === "session/prompt" && entry.text?.startsWith("Execute this confirmed proposal")));
type Scheduler = <T>(operation: () => Promise<T> | T) => Promise<T>;
const internals = harness.manager as unknown as { scheduler: Scheduler };
const original = internals.scheduler.bind(harness.manager);
let intercepted = false;
let notifySettle!: () => void;
let startSettle: (() => Promise<unknown>) | undefined;
const settleReached = new Promise<void>((resolve) => { notifySettle = resolve; });
internals.scheduler = <T>(operation: () => Promise<T> | T): Promise<T> => {
if (!intercepted) {
intercepted = true;
return new Promise<T>((resolve, reject) => {
notifySettle();
startSettle = () => original(operation).then(resolve, reject);
});
}
return original(operation);
};
fs.writeFileSync(gateFile, "go\n", { mode: 0o600 });
await settleReached;
const stopped = harness.manager.stop("webhook", "chat-1");
assert.ok(startSettle);
void startSettle();
assert.equal(await stopped, true);
await waitFor(() => harness.proposals.get(proposal.id)!.status === "cancelled");
assert.equal(harness.proposals.get(proposal.id)!.status, "cancelled");
} finally {
await fs.promises.rm(gateFile, { force: true });
await closeHarness(harness);
}
});
test("SUCCESS waits for user confirmation and never auto-completes or auto-starts the next proposal", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: succeed"));
const first = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm"));
await waitFor(() => harness.proposals.get(first.id)!.status === "awaiting_user_confirmation");
assert.equal(harness.proposals.get(first.id)!.pending?.kind, "success");
assert.equal(harness.proposals.get(first.id)!.finishedAt, undefined);
await harness.manager.prompt(request("create proposal: succeed"));
const second = harness.proposals.list().find((proposal) => proposal.id !== first.id)!;
await harness.manager.prompt(request("confirm"));
assert.equal(harness.proposals.get(second.id)!.status, "queued");
assert.equal(harness.proposals.get(first.id)!.status, "awaiting_user_confirmation");
await harness.manager.prompt(request("confirm"));
assert.equal(harness.proposals.get(first.id)!.status, "completed");
assert.ok(harness.proposals.get(first.id)!.finishedAt);
assert.equal(harness.proposals.get(second.id)!.status, "queued");
await harness.manager.prompt(request("start next"));
assert.equal(harness.proposals.get(second.id)!.status, "working");
await waitFor(() => harness.proposals.get(second.id)!.status === "awaiting_user_confirmation");
await harness.manager.prompt(request("confirm"));
assert.equal(harness.proposals.get(second.id)!.status, "completed");
} finally {
await closeHarness(harness);
}
});
test("FAILED requires confirmation; confirm ends it and confirm retry restarts a working worker", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: fail"));
const first = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm"));
await waitFor(() => harness.proposals.get(first.id)!.status === "awaiting_user_confirmation");
assert.equal(harness.proposals.get(first.id)!.pending?.kind, "failure");
await harness.manager.prompt(request("confirm"));
assert.equal(harness.proposals.get(first.id)!.status, "failed");
assert.ok(harness.proposals.get(first.id)!.finishedAt);
await harness.manager.prompt(request("create proposal: fail"));
const second = harness.proposals.list().find((proposal) => proposal.id !== first.id)!;
await harness.manager.prompt(request("confirm"));
await waitFor(() => harness.proposals.get(second.id)!.status === "awaiting_user_confirmation");
await harness.manager.prompt(request("confirm retry"));
assert.equal(harness.proposals.get(second.id)!.status, "working");
await waitFor(() => harness.proposals.get(second.id)!.status === "awaiting_user_confirmation");
assert.equal(harness.proposals.get(second.id)!.pending?.kind, "failure");
await harness.manager.prompt(request("confirm"));
assert.equal(harness.proposals.get(second.id)!.status, "failed");
} finally {
await closeHarness(harness);
}
});
test("NEEDS_CONFIRMATION answer continues the same worker session", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: ask"));
const proposal = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm"));
await waitFor(() => harness.proposals.get(proposal.id)!.status === "awaiting_user_confirmation");
const pendingStep = harness.proposals.get(proposal.id)!.pending!;
assert.equal(pendingStep.kind, "step");
assert.equal(pendingStep.question, "May I overwrite it?");
const workerSessionId = harness.proposals.get(proposal.id)!.workerNativeSessionId!;
assert.ok(workerSessionId);
await harness.manager.prompt(request("answer: yes, overwrite it"));
await waitFor(() => harness.proposals.get(proposal.id)!.pending?.kind === "success");
const continued = harness.proposals.get(proposal.id)!;
assert.equal(continued.workerNativeSessionId, workerSessionId);
assert.match(continued.lastWorkerSummary || "", new RegExp(`continued ${workerSessionId}`));
const workerPrompts = readLog(harness.logFile).filter((entry) => entry.method === "session/prompt"
&& entry.text && (entry.text.startsWith("Execute this confirmed proposal") || entry.text.startsWith("The user answered your question")));
assert.equal(workerPrompts.length, 2);
assert.ok(workerPrompts.every((entry) => entry.sessionId === workerSessionId));
await harness.manager.prompt(request("confirm"));
assert.equal(harness.proposals.get(proposal.id)!.status, "completed");
} finally {
await closeHarness(harness);
}
});
test("assistant tool activity fails closed and the next turn starts a clean session", async () => {
const harness = await createHarness();
try {
await assert.rejects(harness.manager.prompt(request("force tool")), /forbidden tool activity/);
assert.equal(harness.store.getBinding(CHAT_KEY), undefined);
const ok = await harness.manager.prompt(request("hello"));
assert.equal(ok.text, "ok");
assert.ok(harness.store.getBinding(CHAT_KEY));
} finally {
await closeHarness(harness);
}
});
test("worker completion notifies the owner chat through the event sink", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: succeed"));
const proposal = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm"));
await waitFor(() => harness.proposals.get(proposal.id)!.status === "awaiting_user_confirmation");
await waitFor(() => harness.events.length > 0);
assert.equal(harness.events[0]!.chatKey, CHAT_KEY);
assert.match(harness.events[0]!.text, /event received: worker update/);
} finally {
await closeHarness(harness);
}
});
test("restart recovery kills the persisted worker group and marks the working proposal failed as worker_lost", async () => {
const harness = await createHarness();
const token = crypto.randomUUID();
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
detached: true,
stdio: "ignore",
env: { ...process.env, GORI_AGENT_WORKER_TOKEN: token }
});
child.unref();
try {
const proposal = await harness.proposals.create({ title: "lost", goal: "lost", steps: ["step"], ownerChatKey: CHAT_KEY, requesterUserId: "user-1" });
await harness.proposals.update(proposal.id, { status: "working", startedAt: Date.now(), workerProcessGroup: { pgid: child.pid!, token } });
assert.equal(processGroupExists(child.pid!), true);
await reopenManager(harness);
const recovered = harness.proposals.get(proposal.id)!;
assert.equal(recovered.status, "failed");
assert.match(recovered.pending?.summary || "", /worker_lost/);
assert.match(recovered.lastWorkerSummary || "", /worker_lost/);
assert.ok(recovered.finishedAt);
await waitFor(() => !processGroupExists(child.pid!));
} finally {
try { process.kill(-child.pid!, "SIGKILL"); } catch { /* already dead */ }
await closeHarness(harness);
}
});
test("envelope parsers accept valid tails and reject invalid ones", () => {
assert.deepEqual(
parseAssistantActions(`text\n<GORI_ASSISTANT_ACTION_V1>{"reply":"hi","actions":[{"type":"stop"}]}</GORI_ASSISTANT_ACTION_V1>`),
{ reply: "hi", actions: [{ type: "stop" }] }
);
assert.equal(parseAssistantActions("no envelope"), undefined);
assert.equal(parseAssistantActions(`x\n<GORI_ASSISTANT_ACTION_V1>{"reply":"hi","actions":[{"type":"nuke"}]}</GORI_ASSISTANT_ACTION_V1>`), undefined);
assert.deepEqual(
parseWorkerResult(`text\n<GORI_WORKER_RESULT_V1>{"status":"NEEDS_CONFIRMATION","summary":"s","question":"q","dirty":true}</GORI_WORKER_RESULT_V1>`),
{ status: "NEEDS_CONFIRMATION", summary: "s", question: "q", nextStep: undefined, dirty: true }
);
assert.equal(parseWorkerResult(`x\n<GORI_WORKER_RESULT_V1>{"status":"DONE","summary":"s"}</GORI_WORKER_RESULT_V1>`), undefined);
});
function processGroupExists(pgid: number): boolean {
try { process.kill(-pgid, 0); return true; }
catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
}
+3 -1
View File
@@ -28,8 +28,10 @@ test("config writes are atomic and mode 0600", async () => {
assert.equal((await fs.promises.readdir(dir)).some((name) => name.endsWith(".tmp")), false); assert.equal((await fs.promises.readdir(dir)).some((name) => name.endsWith(".tmp")), false);
}); });
test("operational validation rejects placeholders for every credential family", () => { test("operational validation rejects placeholders, unsafe ACP args, and every credential family", () => {
assert.doesNotThrow(() => assertOperationalConfig(config)); assert.doesNotThrow(() => assertOperationalConfig(config));
const unsafeArgs = parseConfig({ ...config, bot: { ...config.bot, agent: { ...config.bot.agent, args: ["--yolo", "acp"] } } });
assert.throws(() => assertOperationalConfig(unsafeArgs), /exactly \['acp'\]/);
const platforms = [ const platforms = [
{ type: "qq", appId: "QQ_APP_ID", clientSecret: "QQ_CLIENT_SECRET", botNames: ["QQ_BOT_NAME"] }, { type: "qq", appId: "QQ_APP_ID", clientSecret: "QQ_CLIENT_SECRET", botNames: ["QQ_BOT_NAME"] },
{ type: "feishu", appId: "FEISHU_APP_ID", appSecret: "FEISHU_APP_SECRET" }, { type: "feishu", appId: "FEISHU_APP_ID", appSecret: "FEISHU_APP_SECRET" },
+3 -2
View File
@@ -24,7 +24,8 @@ test("parses Config v3 defaults for one Bot, agent, and platform", () => {
assert.equal(config.bot.id, "test-bot"); assert.equal(config.bot.id, "test-bot");
assert.equal(config.bot.agent.id, "kimi"); assert.equal(config.bot.agent.id, "kimi");
assert.equal(config.gateway.platform.type, "webhook"); assert.equal(config.gateway.platform.type, "webhook");
assert.equal(config.runtime.acp.promptTimeoutMs, 7_200_000); assert.equal(config.runtime.acp.promptTimeoutMs, 14_400_000);
assert.equal(config.runtime.acp.maxAssistantSessions, 4);
assert.equal(config.bot.permissions.mode, "deny"); assert.equal(config.bot.permissions.mode, "deny");
}); });
@@ -60,7 +61,7 @@ test("config.example.json is a parseable, secret-free documentation template", (
assert.equal(config.configVersion, 3); assert.equal(config.configVersion, 3);
assert.equal(config.bot.id, "BOT_ID"); assert.equal(config.bot.id, "BOT_ID");
assert.equal(config.bot.permissions.mode, "deny"); assert.equal(config.bot.permissions.mode, "deny");
assert.equal(config.runtime.acp.promptTimeoutMs, 7_200_000); assert.equal(config.runtime.acp.promptTimeoutMs, 14_400_000);
assert.equal(config.gateway.platform.type, "qq"); assert.equal(config.gateway.platform.type, "qq");
assert.match(text, /QQ_CLIENT_SECRET/); assert.match(text, /QQ_CLIENT_SECRET/);
assert.doesNotMatch(text, /configVersion"\s*:\s*[12]/); assert.doesNotMatch(text, /configVersion"\s*:\s*[12]/);
+42 -15
View File
@@ -3,43 +3,65 @@ import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import test from "node:test"; import test from "node:test";
import { DurableSessionStore } from "../src/core/durable-session-store.js"; import { DurableSessionStore, chatKeyFor } from "../src/core/durable-session-store.js";
const identity = { botId: "test-bot", platform: "qq" }; const identity = { botId: "test-bot", platform: "qq" };
test("persists state v2 binding across reopen with 0600 atomic file", async () => { const binding = { chatKey: "qq:chat", agentId: "kimi", nativeSessionId: "native-1", assistantWorkspace: "/tmp/assistant", botFingerprint: "fp", createdAt: 1, updatedAt: 1 };
test("persists state v3 assistant binding across reopen with 0600 atomic file", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-store-")); const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-store-"));
const file = path.join(dir, "state.json"); const file = path.join(dir, "state.json");
const first = new DurableSessionStore(file, identity); const first = new DurableSessionStore(file, identity);
await first.open(); await first.open();
await first.setBinding({ chatKey: "qq:chat", agentId: "kimi", nativeSessionId: "native-1", workspace: "/tmp", botFingerprint: "fp", createdAt: 1, updatedAt: 1 }); await first.setBinding(binding);
await first.close(); await first.close();
assert.equal((await fs.promises.readdir(dir)).some((name) => name.endsWith(".tmp")), false); assert.equal((await fs.promises.readdir(dir)).some((name) => name.endsWith(".tmp")), false);
assert.equal((await fs.promises.stat(file)).mode & 0o777, 0o600); assert.equal((await fs.promises.stat(file)).mode & 0o777, 0o600);
const persisted = JSON.parse(await fs.promises.readFile(file, "utf8")); const text = await fs.promises.readFile(file, "utf8");
assert.equal(persisted.version, 2); assert.equal(persisted.botId, "test-bot"); assert.equal(persisted.platform, "qq"); const persisted = JSON.parse(text);
assert.equal(persisted.version, 3); assert.equal(persisted.botId, "test-bot"); assert.equal(persisted.platform, "qq");
assert.deepEqual(persisted.bindings["qq:chat"], binding);
assert.equal("mainTask" in persisted, false);
assert.doesNotMatch(text, /message|prompt|summary|content|lease/i);
const second = new DurableSessionStore(file, identity); const second = new DurableSessionStore(file, identity);
await second.open(); await second.open();
assert.equal(second.getBinding("qq:chat")?.nativeSessionId, "native-1"); assert.deepEqual(second.getBinding("qq:chat"), binding);
assert.equal(second.stats().bindings, 1);
await second.touchBinding("qq:chat");
const touched = second.getBinding("qq:chat");
assert.ok((touched?.updatedAt ?? 0) >= 1);
assert.deepEqual(await second.deleteBinding("qq:chat"), touched);
assert.equal(second.getBinding("qq:chat"), undefined);
await second.close(); await second.close();
}); });
test("rejects old state and bot or platform identity mismatch without rewriting", async () => { test("rejects state v1/v2 and identity mismatch without rewriting the original file", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-store-id-")); const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-store-id-"));
const file = path.join(dir, "state.json"); const file = path.join(dir, "state.json");
const old = '{"version":1,"bindings":{}}\n'; const v1 = '{"version":1,"bindings":{}}\n';
await fs.promises.writeFile(file, old); await fs.promises.writeFile(file, v1);
await assert.rejects(new DurableSessionStore(file, identity).open(), /requires state v2/); await assert.rejects(new DurableSessionStore(file, identity).open(), /requires state v3/);
assert.equal(await fs.promises.readFile(file, "utf8"), old); assert.equal(await fs.promises.readFile(file, "utf8"), v1);
await fs.promises.writeFile(file, JSON.stringify({ version: 2, botId: "other", platform: "qq", bindings: {} })); const v2 = JSON.stringify({ version: 2, botId: "test-bot", platform: "qq", bindings: {}, mainTask: { ownerChatKey: "qq:chat", state: "running", workspace: "/tmp", botFingerprint: "fp", startedAt: 1, updatedAt: 1 } });
await fs.promises.writeFile(file, v2);
await assert.rejects(new DurableSessionStore(file, identity).open(), /requires state v3/);
assert.equal(await fs.promises.readFile(file, "utf8"), v2);
await fs.promises.writeFile(file, JSON.stringify({ version: 3, botId: "other", platform: "qq", bindings: {} }));
await assert.rejects(new DurableSessionStore(file, identity).open(), /identity mismatch/); await assert.rejects(new DurableSessionStore(file, identity).open(), /identity mismatch/);
await fs.promises.writeFile(file, JSON.stringify({ version: 2, botId: "test-bot", platform: "feishu", bindings: {} })); await fs.promises.writeFile(file, JSON.stringify({ version: 3, botId: "test-bot", platform: "feishu", bindings: {} }));
await assert.rejects(new DurableSessionStore(file, identity).open(), /identity mismatch/); await assert.rejects(new DurableSessionStore(file, identity).open(), /identity mismatch/);
await fs.promises.writeFile(file, JSON.stringify({ version: 2, botId: "test-bot", platform: "qq", bindings: { "qq:chat": { chatKey: "qq:other" } } })); await fs.promises.writeFile(file, JSON.stringify({ version: 3, botId: "test-bot", platform: "qq", bindings: { "qq:chat": { chatKey: "qq:other" } } }));
await assert.rejects(new DurableSessionStore(file, identity).open(), /invalid state v2 binding/); await assert.rejects(new DurableSessionStore(file, identity).open(), /invalid state v3 binding/);
const legacyField = { ...binding, workspace: "/tmp" } as Record<string, unknown>;
delete legacyField.assistantWorkspace;
await fs.promises.writeFile(file, JSON.stringify({ version: 3, botId: "test-bot", platform: "qq", bindings: { "qq:chat": legacyField } }));
await assert.rejects(new DurableSessionStore(file, identity).open(), /invalid state v3 binding/);
}); });
test("preserves corrupt state and refuses a second writer lock", async () => { test("preserves corrupt state and refuses a second writer lock", async () => {
@@ -60,3 +82,8 @@ test("preserves corrupt state and refuses a second writer lock", async () => {
await recovered.close(); await recovered.close();
assert.equal(fs.existsSync(`${file}.lock`), false); assert.equal(fs.existsSync(`${file}.lock`), false);
}); });
test("chatKeyFor builds platform-qualified keys", () => {
assert.equal(chatKeyFor("qq", "group:abc"), "qq:group:abc");
assert.equal(chatKeyFor("feishu", "oc_1"), "feishu:oc_1");
});
+93 -18
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
import { spawn } from "node:child_process";
import fs from "node:fs"; import fs from "node:fs";
import { Readable, Writable } from "node:stream"; import { Readable, Writable } from "node:stream";
import * as acp from "@agentclientprotocol/sdk"; import * as acp from "@agentclientprotocol/sdk";
@@ -6,6 +7,8 @@ import * as acp from "@agentclientprotocol/sdk";
const pending = new Map(); const pending = new Map();
const logFile = process.env.FAKE_ACP_LOG; const logFile = process.env.FAKE_ACP_LOG;
const log = (entry) => { if (logFile) fs.appendFileSync(logFile, `${JSON.stringify(entry)}\n`); }; const log = (entry) => { if (logFile) fs.appendFileSync(logFile, `${JSON.stringify(entry)}\n`); };
const assistantEnvelope = (reply, actions) => `${reply}\n<GORI_ASSISTANT_ACTION_V1>${JSON.stringify({ reply, actions })}</GORI_ASSISTANT_ACTION_V1>`;
const workerEnvelope = (result) => `worker reply\n<GORI_WORKER_RESULT_V1>${JSON.stringify(result)}</GORI_WORKER_RESULT_V1>`;
const app = acp.agent({ name: "fake-acp-agent" }) const app = acp.agent({ name: "fake-acp-agent" })
.onRequest(acp.methods.agent.initialize, ({ params }) => { .onRequest(acp.methods.agent.initialize, ({ params }) => {
@@ -16,9 +19,10 @@ const app = acp.agent({ name: "fake-acp-agent" })
agentInfo: { name: "fake-acp-agent", version: "1" } agentInfo: { name: "fake-acp-agent", version: "1" }
}; };
}) })
.onRequest(acp.methods.agent.session.new, ({ params }) => { .onRequest(acp.methods.agent.session.new, async ({ params }) => {
const sessionId = `fake-${Date.now()}-${Math.random().toString(16).slice(2)}`; const sessionId = `fake-${Date.now()}-${Math.random().toString(16).slice(2)}`;
log({ method: "session/new", sessionId, cwd: params.cwd }); log({ method: "session/new", sessionId, cwd: params.cwd });
if (process.env.FAKE_ACP_NEW_HANG === "1") await new Promise(() => undefined);
return { sessionId }; return { sessionId };
}) })
.onRequest(acp.methods.agent.session.load, ({ params }) => { .onRequest(acp.methods.agent.session.load, ({ params }) => {
@@ -36,20 +40,61 @@ const app = acp.agent({ name: "fake-acp-agent" })
.onRequest(acp.methods.agent.session.prompt, async ({ params, client, signal }) => { .onRequest(acp.methods.agent.session.prompt, async ({ params, client, signal }) => {
const text = params.prompt.filter((item) => item.type === "text").map((item) => item.text).join(""); const text = params.prompt.filter((item) => item.type === "text").map((item) => item.text).join("");
log({ method: "session/prompt", sessionId: params.sessionId, text }); log({ method: "session/prompt", sessionId: params.sessionId, text });
if (text === "crash") process.exit(9); if (text === "crash" || text.startsWith("crash\n\nWhen this turn")) process.exit(9);
if (text === "permission") { if (text.includes("Initialize this ACP session")) {
const response = await client.request(acp.methods.client.session.requestPermission, { if (process.env.FAKE_ACP_BOOTSTRAP_DELAY_MS) await new Promise((resolve) => setTimeout(resolve, Number(process.env.FAKE_ACP_BOOTSTRAP_DELAY_MS)));
sessionId: params.sessionId, await update(client, params.sessionId, "READY");
toolCall: { toolCallId: "bash-1", title: "bash git status", kind: "execute", name: "bash", rawInput: "git status" },
options: [
{ optionId: "allow", name: "Allow", kind: "allow_once" },
{ optionId: "reject", name: "Reject", kind: "reject_once" }
]
});
await update(client, params.sessionId, response.outcome.outcome === "selected" ? response.outcome.optionId : "cancelled");
return { stopReason: "end_turn" }; return { stopReason: "end_turn" };
} }
if (text === "hang") {
// Assistant protocol
if (text.startsWith("Your previous response did not end with a valid GORI_ASSISTANT_ACTION_V1")) {
await update(client, params.sessionId, assistantEnvelope("repaired", []));
return { stopReason: "end_turn" };
}
if (text.startsWith("Your previous response did not end with a valid GORI_WORKER_RESULT_V1")) {
await update(client, params.sessionId, process.env.FAKE_ACP_INVALID_REPAIR === "1"
? "still invalid"
: workerEnvelope({ status: "SUCCESS", summary: "repaired" }));
return { stopReason: "end_turn" };
}
if (text.includes("[Internal event")) {
await update(client, params.sessionId, assistantEnvelope("event received: worker update", []));
return { stopReason: "end_turn" };
}
if (text.includes("[User message]")) {
const userText = (text.split("[User message]\n")[1] || "").split("\n\n")[0].trim();
if (userText === "force tool") {
await client.notify(acp.methods.client.session.update, {
sessionId: params.sessionId,
update: { sessionUpdate: "tool_call", toolCallId: "read-1", title: "Read a file", kind: "read", status: "in_progress" }
});
return { stopReason: "end_turn" };
}
let response;
if (userText.startsWith("create proposal:")) {
const goal = userText.slice("create proposal:".length).trim();
response = assistantEnvelope("proposal drafted", [{ type: "create_proposal", title: "Test proposal", goal, steps: ["step 1"] }]);
} else if (userText === "confirm") response = assistantEnvelope("confirmed", [{ type: "confirm" }]);
else if (userText === "confirm retry") response = assistantEnvelope("retrying", [{ type: "confirm", answer: "retry" }]);
else if (userText.startsWith("answer:")) response = assistantEnvelope("answering", [{ type: "confirm", answer: userText.slice("answer:".length).trim() }]);
else if (userText === "start next") response = assistantEnvelope("starting next", [{ type: "start_next" }]);
else if (userText === "stop") response = assistantEnvelope("stopping", [{ type: "stop" }]);
else if (userText === "cancel proposal") response = assistantEnvelope("cancelling", [{ type: "cancel" }]);
else if (userText === "invalid assistant") response = "invalid without envelope";
else response = assistantEnvelope("ok", []);
await update(client, params.sessionId, response);
return { stopReason: "end_turn" };
}
// Worker protocol
if (text.startsWith("The user answered your question")) {
await update(client, params.sessionId, workerEnvelope({ status: "SUCCESS", summary: `continued ${params.sessionId}` }));
return { stopReason: "end_turn" };
}
if (text.startsWith("Execute this confirmed proposal")) {
const goal = ((text.split("Goal: ")[1] || "").split("\n")[0] || "").trim();
if (goal.includes("hang")) {
await new Promise((resolve) => { await new Promise((resolve) => {
const done = () => resolve(undefined); const done = () => resolve(undefined);
pending.set(params.sessionId, done); pending.set(params.sessionId, done);
@@ -58,17 +103,47 @@ const app = acp.agent({ name: "fake-acp-agent" })
pending.delete(params.sessionId); pending.delete(params.sessionId);
return { stopReason: "cancelled" }; return { stopReason: "cancelled" };
} }
if (text === "activity") { if (goal.includes("invalid")) {
await update(client, params.sessionId, "invalid without envelope");
return { stopReason: "end_turn" };
}
if (process.env.FAKE_ACP_WORKER_GATE_FILE) {
while (!fs.existsSync(process.env.FAKE_ACP_WORKER_GATE_FILE)) await new Promise((resolve) => setTimeout(resolve, 5));
}
const result = goal.includes("ask")
? { status: "NEEDS_CONFIRMATION", summary: "hit a dirty target", question: "May I overwrite it?", nextStep: "overwrite it", dirty: true }
: goal.includes("fail")
? { status: "FAILED", summary: "something broke" }
: { status: "SUCCESS", summary: "goal done" };
await update(client, params.sessionId, workerEnvelope(result));
return { stopReason: "end_turn" };
}
// Legacy behaviors used by acp-worker/acp-client tests
const request = text.split("\n\nWhen this turn is finished")[0];
if (request === "spawn descendant") {
const descendant = spawn(process.execPath, ["-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)"], { stdio: "ignore" });
if (process.env.FAKE_ACP_DESCENDANT_PID_FILE) fs.writeFileSync(process.env.FAKE_ACP_DESCENDANT_PID_FILE, `${descendant.pid}\n`);
await new Promise((resolve) => signal.addEventListener("abort", resolve, { once: true }));
return { stopReason: "cancelled" };
}
if (request === "hang") {
await new Promise((resolve) => {
const done = () => resolve(undefined);
pending.set(params.sessionId, done);
signal.addEventListener("abort", done, { once: true });
});
pending.delete(params.sessionId);
return { stopReason: "cancelled" };
}
if (request === "activity") {
await update(client, params.sessionId, "first"); await update(client, params.sessionId, "first");
await new Promise((resolve) => setTimeout(resolve, 600)); await new Promise((resolve) => setTimeout(resolve, 600));
await update(client, params.sessionId, "second"); await update(client, params.sessionId, "second");
await new Promise((resolve) => setTimeout(resolve, 700)); await new Promise((resolve) => setTimeout(resolve, 700));
return { stopReason: "end_turn" }; return { stopReason: "end_turn" };
} }
if (text.includes("Initialize this ACP session") && process.env.FAKE_ACP_BOOTSTRAP_DELAY_MS) { await update(client, params.sessionId, `reply:${params.sessionId}:${request}`);
await new Promise((resolve) => setTimeout(resolve, Number(process.env.FAKE_ACP_BOOTSTRAP_DELAY_MS)));
}
await update(client, params.sessionId, text.includes("Initialize this ACP session") ? "READY" : `reply:${params.sessionId}:${text}`);
return { stopReason: "end_turn" }; return { stopReason: "end_turn" };
}) })
.onNotification(acp.methods.agent.session.cancel, ({ params }) => { .onNotification(acp.methods.agent.session.cancel, ({ params }) => {
+161 -280
View File
@@ -2,7 +2,8 @@ import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import type { ConversationRequest, ConversationRuntime } from "../src/acp/types.js"; import type { ConversationRequest, ConversationRuntime } from "../src/acp/types.js";
import type { PlatformAdapter } from "../src/core/adapter.js"; import type { PlatformAdapter } from "../src/core/adapter.js";
import { Gateway, type GatewayTimer } from "../src/core/gateway.js"; import { Gateway } from "../src/core/gateway.js";
import type { Proposal } from "../src/core/proposal-store.js";
import type { IncomingMessage, OutgoingMessage } from "../src/core/types.js"; import type { IncomingMessage, OutgoingMessage } from "../src/core/types.js";
interface PendingTurn { interface PendingTurn {
@@ -11,11 +12,31 @@ interface PendingTurn {
reject(error: Error): void; reject(error: Error): void;
} }
function fakeProposal(overrides: Partial<Proposal> = {}): Proposal {
return {
id: "proposal-1",
title: "修复登录页",
goal: "goal",
steps: ["step"],
ownerChatKey: "qq:chat",
requesterUserId: "user",
status: "proposed",
createdAt: 1,
updatedAt: 1,
...overrides
};
}
class FakeRuntime implements ConversationRuntime { class FakeRuntime implements ConversationRuntime {
readonly turns: PendingTurn[] = []; readonly turns: PendingTurn[] = [];
cancelled = 0; cancelled = 0;
resets = 0; confirmed = 0;
idleSeconds = 0; stopped = 0;
listed = 0;
cancelResult = true;
confirmResult = true;
stopResult = true;
proposals: Proposal[] = [];
async prompt(request: ConversationRequest) { async prompt(request: ConversationRequest) {
return new Promise<{ text: string; botId: string; agentId: string }>((resolve, reject) => { return new Promise<{ text: string; botId: string; agentId: string }>((resolve, reject) => {
@@ -26,75 +47,21 @@ class FakeRuntime implements ConversationRuntime {
}); });
}); });
} }
async cancel() { this.cancelled++; this.turns.at(-1)?.resolve("cancelled"); return true; } async cancel() { this.cancelled++; return this.cancelResult; }
async reset() { this.resets++; } async confirm() { this.confirmed++; return this.confirmResult; }
async stop() { this.stopped++; return this.stopResult; }
listProposals() { this.listed++; return this.proposals; }
async reset() {}
status() { status() {
return { return {
bot: "test-bot", agent: "kimi", workspace: "/tmp", bot: "test-bot", agent: "kimi", workspace: "/tmp",
running: this.turns.length > 0, phase: "processing", idleSeconds: this.idleSeconds running: this.turns.length > 0, phase: "processing"
}; };
} }
stats() { return { activeWorkers: 0, inFlight: 0, crashes: 0, persistedBindings: 0 }; } stats() { return { activeWorkers: 0, inFlight: 0, crashes: 0, persistedBindings: 0 }; }
async shutdown() {} async shutdown() {}
} }
interface ScheduledTimer extends GatewayTimer {
deadline: number;
callback: () => void;
cancelled: boolean;
order: number;
}
class FakeClock {
nowMs = 0;
private order = 0;
readonly timers: ScheduledTimer[] = [];
readonly now = () => this.nowMs;
readonly schedule = (callback: () => void, delayMs: number): GatewayTimer => {
const timer: ScheduledTimer = {
deadline: this.nowMs + delayMs,
callback,
cancelled: false,
order: this.order++,
cancel() { timer.cancelled = true; }
};
this.timers.push(timer);
return timer;
};
set(timeMs: number) { this.nowMs = timeMs; }
async advanceTo(timeMs: number) {
while (true) {
const next = this.timers
.filter((timer) => !timer.cancelled && timer.deadline <= timeMs)
.sort((left, right) => left.deadline - right.deadline || left.order - right.order)[0];
if (!next) break;
next.cancelled = true;
this.nowMs = next.deadline;
next.callback();
await flush();
}
this.nowMs = timeMs;
}
async jumpTo(timeMs: number) {
this.nowMs = timeMs;
const due = this.timers
.filter((timer) => !timer.cancelled && timer.deadline <= timeMs)
.sort((left, right) => left.deadline - right.deadline || left.order - right.order);
for (const timer of due) {
if (timer.cancelled) continue;
timer.cancelled = true;
timer.callback();
await flush();
}
}
activeTimers() { return this.timers.filter((timer) => !timer.cancelled).length; }
}
const policy = { allowedUsers: [] as string[], allowedChats: [] as string[], requireMentionInGroup: false }; const policy = { allowedUsers: [] as string[], allowedChats: [] as string[], requireMentionInGroup: false };
const message = (text: string, messageId = text, chatId = "chat"): IncomingMessage => ({ const message = (text: string, messageId = text, chatId = "chat"): IncomingMessage => ({
platform: "qq", chatId, userId: "user", text, messageId platform: "qq", chatId, userId: "user", text, messageId
@@ -109,8 +76,8 @@ function recordingAdapter(sent: OutgoingMessage[] = []): PlatformAdapter {
return { name: "test", async handleWebhook() { return {}; }, async sendMessage(outgoing) { sent.push(outgoing); } }; return { name: "test", async handleWebhook() { return {}; }, async sendMessage(outgoing) { sent.push(outgoing); } };
} }
function createGateway(runtime = new FakeRuntime(), clock = new FakeClock()) { function createGateway(runtime = new FakeRuntime()) {
return { runtime, clock, gateway: new Gateway(policy, runtime, { now: clock.now, schedule: clock.schedule }) }; return { runtime, gateway: new Gateway(policy, runtime) };
} }
function messagesFor(sent: OutgoingMessage[], replyTo: string) { function messagesFor(sent: OutgoingMessage[], replyTo: string) {
@@ -127,172 +94,43 @@ test("short asynchronous work sends only the real result with sequence one", asy
assert.deepEqual(sent.map((outgoing) => [outgoing.text, outgoing.replySequence]), [["done", 1]]); assert.deepEqual(sent.map((outgoing) => [outgoing.text, outgoing.replySequence]), [["done", 1]]);
}); });
test("completion and timer callbacks are safe in both orders at 15 seconds", async () => { test("ordinary asynchronous messages send nothing before the runtime resolves", async () => {
{ const { runtime, gateway } = createGateway();
const { runtime, clock, gateway } = createGateway();
const sent: OutgoingMessage[] = []; const sent: OutgoingMessage[] = [];
const turn = gateway.receive(message("work", "completion-first"), recordingAdapter(sent)); const turn = gateway.receive(message("work", "no-timer"), recordingAdapter(sent));
await waitFor(() => runtime.turns.length === 1); await waitFor(() => runtime.turns.length === 1);
clock.set(15_000); await flush();
assert.deepEqual(sent, []);
runtime.turns[0].resolve("done"); runtime.turns[0].resolve("done");
await turn; await turn;
await clock.advanceTo(15_000);
assert.deepEqual(sent.map((outgoing) => outgoing.text), ["done"]); assert.deepEqual(sent.map((outgoing) => outgoing.text), ["done"]);
}
{
const { runtime, clock, gateway } = createGateway();
const sent: OutgoingMessage[] = [];
const turn = gateway.receive(message("work", "timer-first"), recordingAdapter(sent));
await waitFor(() => runtime.turns.length === 1);
await clock.advanceTo(15_000);
runtime.turns[0].resolve("done");
await turn;
assert.deepEqual(sent.map((outgoing) => [outgoing.text, outgoing.replySequence]), [
["收到,我还在处理,稍等我一下。", 1],
["done", 2]
]);
}
}); });
test("running and queued notices are independent and queued work announces start", async () => { test("ordinary messages reach the runtime immediately without a Gateway queue", async () => {
const { runtime, clock, gateway } = createGateway(); const { runtime, gateway } = createGateway();
const sent: OutgoingMessage[] = []; const sent: OutgoingMessage[] = [];
const adapter = recordingAdapter(sent); const adapter = recordingAdapter(sent);
const first = gateway.receive(message("one", "first"), adapter); const first = gateway.receive(message("one", "first"), adapter);
const second = gateway.receive(message("two", "second"), adapter); const second = gateway.receive(message("two", "second"), adapter);
await waitFor(() => runtime.turns.length === 1); await waitFor(() => runtime.turns.length === 2);
await clock.advanceTo(15_000); assert.deepEqual(sent, []);
assert.deepEqual(messagesFor(sent, "first").map((outgoing) => outgoing.text), ["收到,我还在处理,稍等我一下。"]);
assert.deepEqual(messagesFor(sent, "second").map((outgoing) => outgoing.text), ["前面还有一件事没处理完,这条我记着,轮到后马上处理。"]);
runtime.turns[0].resolve("first done"); runtime.turns[0].resolve("first done");
await waitFor(() => runtime.turns.length === 2);
assert.deepEqual(messagesFor(sent, "second").map((outgoing) => [outgoing.text, outgoing.replySequence]), [
["前面还有一件事没处理完,这条我记着,轮到后马上处理。", 1],
["轮到这件事了,我现在开始处理。", 2]
]);
runtime.turns[1].resolve("second done"); runtime.turns[1].resolve("second done");
await Promise.all([first, second]); await Promise.all([first, second]);
assert.deepEqual(messagesFor(sent, "first").map((outgoing) => outgoing.replySequence), [1, 2]); assert.deepEqual(messagesFor(sent, "first").map((outgoing) => [outgoing.text, outgoing.replySequence]), [["first done", 1]]);
assert.deepEqual(messagesFor(sent, "second").map((outgoing) => outgoing.replySequence), [1, 2, 3]); assert.deepEqual(messagesFor(sent, "second").map((outgoing) => [outgoing.text, outgoing.replySequence]), [["second done", 1]]);
}); });
test("running reminders use staged wording and wait from 8 to 18 minutes", async () => { test("delivery failures are logged safely and do not block the result", async () => {
const { runtime, clock, gateway } = createGateway(); const { runtime, gateway } = createGateway();
const sent: OutgoingMessage[] = [];
const turn = gateway.receive(message("work", "reminders"), recordingAdapter(sent));
await waitFor(() => runtime.turns.length === 1);
await clock.advanceTo(15_000);
await clock.advanceTo(60_000);
await clock.advanceTo(180_000);
await clock.advanceTo(480_000);
assert.deepEqual(sent.map((outgoing) => outgoing.text), [
"收到,我还在处理,稍等我一下。",
"还在弄,暂时还没出结果,弄好我马上回你。",
"这件事比预想中多花了一点时间,我还在继续处理。你不用一直盯着,弄好我会直接回你。",
"我还在处理这件事,确实花了些时间。想看看现在的状态可以发 /status,不想继续等也可以发 /cancel。"
]);
await clock.advanceTo(780_000);
assert.equal(sent.length, 4);
await clock.advanceTo(1_080_000);
assert.equal(sent.at(-1)?.text,
"我还在处理这件事,确实花了些时间。想看看现在的状态可以发 /status,不想继续等也可以发 /cancel。");
assert.equal(sent.length, 5);
runtime.turns[0].resolve("done");
await turn;
});
test("queued reminders use staged queue wording without ACP activity details", async () => {
const { runtime, clock, gateway } = createGateway();
const sent: OutgoingMessage[] = [];
const adapter = recordingAdapter(sent);
const first = gateway.receive(message("one", "active"), adapter);
const second = gateway.receive(message("two", "queued"), adapter);
await waitFor(() => runtime.turns.length === 1);
await clock.advanceTo(15_000);
await clock.advanceTo(60_000);
assert.deepEqual(messagesFor(sent, "queued").map((outgoing) => outgoing.text), [
"前面还有一件事没处理完,这条我记着,轮到后马上处理。",
"前面的事情还没处理完,这条还在等。我没有漏掉,轮到后会接着做。"
]);
runtime.turns[0].resolve("one done");
await waitFor(() => runtime.turns.length === 2);
runtime.turns[1].resolve("two done");
await Promise.all([first, second]);
});
test("a delayed timer jump emits one reminder instead of backfilling every deadline", async () => {
const { runtime, clock, gateway } = createGateway();
const sent: OutgoingMessage[] = [];
const turn = gateway.receive(message("work", "jump"), recordingAdapter(sent));
await waitFor(() => runtime.turns.length === 1);
await clock.advanceTo(15_000);
await clock.jumpTo(1_000_000);
assert.equal(sent.length, 2);
assert.equal(clock.activeTimers(), 1);
runtime.turns[0].resolve("done");
await turn;
});
test("completion and shutdown cancel all inbound timers", async () => {
const { runtime, clock, gateway } = createGateway();
const sent: OutgoingMessage[] = [];
const first = gateway.receive(message("one", "complete"), recordingAdapter(sent));
await waitFor(() => runtime.turns.length === 1);
assert.equal(clock.activeTimers(), 1);
runtime.turns[0].resolve("done");
await first;
assert.equal(clock.activeTimers(), 0);
const second = gateway.receive(message("two", "shutdown"), recordingAdapter(sent));
await waitFor(() => runtime.turns.length === 2);
assert.equal(clock.activeTimers(), 1);
gateway.shutdown();
assert.equal(clock.activeTimers(), 0);
await clock.advanceTo(600_000);
assert.deepEqual(messagesFor(sent, "shutdown"), []);
runtime.turns[1].resolve("done after shutdown");
await second;
});
test("prompt does not wait for a notice send and final delivery delay releases the chat lock", async () => {
const { runtime, clock, gateway } = createGateway();
const sent: OutgoingMessage[] = [];
let releaseFirstSend!: () => void;
const firstSend = new Promise<void>((resolve) => { releaseFirstSend = resolve; });
const adapter: PlatformAdapter = {
name: "test", async handleWebhook() { return {}; },
async sendMessage(outgoing) {
sent.push(outgoing);
if (outgoing.replyTo === "first" && outgoing.replySequence === 1) await firstSend;
}
};
const first = gateway.receive(message("one", "first"), adapter);
await waitFor(() => runtime.turns.length === 1);
await clock.advanceTo(15_000);
assert.equal(runtime.turns.length, 1);
runtime.turns[0].resolve("first done");
const second = gateway.receive(message("two", "second"), adapter);
await waitFor(() => runtime.turns.length === 2);
assert.equal(messagesFor(sent, "first").length, 1);
releaseFirstSend();
runtime.turns[1].resolve("second done");
await Promise.all([first, second]);
assert.deepEqual(messagesFor(sent, "first").map((outgoing) => outgoing.text), [
"收到,我还在处理,稍等我一下。", "first done"
]);
});
test("delivery failures are logged safely and do not block later sends", async () => {
const { runtime, clock, gateway } = createGateway();
const sent: OutgoingMessage[] = []; const sent: OutgoingMessage[] = [];
const errors: string[] = []; const errors: string[] = [];
let sends = 0;
const adapter: PlatformAdapter = { const adapter: PlatformAdapter = {
name: "test", async handleWebhook() { return {}; }, name: "test", async handleWebhook() { return {}; },
async sendMessage(outgoing) { async sendMessage(outgoing) {
sent.push(outgoing); sent.push(outgoing);
if (++sends === 1) throw new Error("sensitive provider response"); throw new Error("sensitive provider response");
} }
}; };
const originalError = console.error; const originalError = console.error;
@@ -300,35 +138,12 @@ test("delivery failures are logged safely and do not block later sends", async (
try { try {
const turn = gateway.receive(message("work", "failure"), adapter); const turn = gateway.receive(message("work", "failure"), adapter);
await waitFor(() => runtime.turns.length === 1); await waitFor(() => runtime.turns.length === 1);
await clock.advanceTo(15_000);
runtime.turns[0].resolve("done"); runtime.turns[0].resolve("done");
assert.deepEqual(await turn, { ok: true, reply: "done" }); assert.deepEqual(await turn, { ok: true, reply: "done" });
} finally { } finally {
console.error = originalError; console.error = originalError;
} }
assert.deepEqual(sent.map((outgoing) => outgoing.text), ["收到,我还在处理,稍等我一下。", "done"]); assert.deepEqual(sent.map((outgoing) => outgoing.text), ["done"]);
assert.deepEqual(errors, ["Gateway delivery send failed (platform=qq)"]);
});
test("a failed final send does not turn a successful runtime result into Agent error", async () => {
const { runtime, gateway } = createGateway();
const sent: OutgoingMessage[] = [];
const errors: string[] = [];
const adapter: PlatformAdapter = {
name: "test", async handleWebhook() { return {}; },
async sendMessage(outgoing) { sent.push(outgoing); throw new Error("provider detail"); }
};
const originalError = console.error;
console.error = (...args: unknown[]) => { errors.push(args.map(String).join(" ")); };
try {
const turn = gateway.receive(message("work", "final-failure"), adapter);
await waitFor(() => runtime.turns.length === 1);
runtime.turns[0].resolve("real result");
assert.deepEqual(await turn, { ok: true, reply: "real result" });
} finally {
console.error = originalError;
}
assert.deepEqual(sent.map((outgoing) => outgoing.text), ["real result"]);
assert.deepEqual(errors, ["Gateway delivery send failed (platform=qq)"]); assert.deepEqual(errors, ["Gateway delivery send failed (platform=qq)"]);
}); });
@@ -342,45 +157,130 @@ test("runtime errors remain runtime errors and are delivered through the same st
assert.deepEqual(sent.map((outgoing) => [outgoing.text, outgoing.replySequence]), [["Agent error: failed", 1]]); assert.deepEqual(sent.map((outgoing) => [outgoing.text, outgoing.replySequence]), [["Agent error: failed", 1]]);
}); });
test("status, cancel, and help bypass the chat lock while new remains serial", async () => { test("sendEvent pushes a fresh message to the latest chat target without replyTo", async () => {
const { runtime, clock, gateway } = createGateway(); const { runtime, gateway } = createGateway();
const sent: OutgoingMessage[] = [];
const turn = gateway.receive(message("work", "event-source"), recordingAdapter(sent));
await waitFor(() => runtime.turns.length === 1);
runtime.turns[0].resolve("done");
await turn;
await gateway.sendEvent("qq:chat", "任务完成了");
const event = sent.at(-1)!;
assert.equal(event.text, "任务完成了");
assert.equal(event.replyTo, undefined);
assert.equal(event.replySequence, undefined);
assert.deepEqual(event.target, { platform: "qq", chatId: "chat", userId: "user", raw: undefined });
});
test("sendEvent uses the most recent target and adapter for the chat", async () => {
const { runtime, gateway } = createGateway();
const firstSent: OutgoingMessage[] = [];
const secondSent: OutgoingMessage[] = [];
const first = gateway.receive(message("one", "one", "chat"), recordingAdapter(firstSent));
await waitFor(() => runtime.turns.length === 1);
const second = gateway.receive(message("two", "two", "chat"), recordingAdapter(secondSent));
await waitFor(() => runtime.turns.length === 2);
runtime.turns[0].resolve("one done");
runtime.turns[1].resolve("two done");
await Promise.all([first, second]);
await gateway.sendEvent("qq:chat", "event");
assert.equal(firstSent.filter((outgoing) => outgoing.text === "event").length, 0);
assert.equal(secondSent.at(-1)?.text, "event");
assert.equal(secondSent.at(-1)?.replyTo, undefined);
});
test("sendEvent drops safely when the chat has no known target", async () => {
const { gateway } = createGateway();
await gateway.sendEvent("qq:unknown", "event");
});
test("sendEvent delivery failures are logged safely without message content", async () => {
const { runtime, gateway } = createGateway();
const errors: string[] = [];
const adapter: PlatformAdapter = {
name: "test", async handleWebhook() { return {}; },
async sendMessage(outgoing) {
if (!outgoing.replyTo) throw new Error("sensitive provider response");
}
};
const turn = gateway.receive(message("work", "event-failure"), adapter);
await waitFor(() => runtime.turns.length === 1);
runtime.turns[0].resolve("done");
await turn;
const originalError = console.error;
console.error = (...args: unknown[]) => { errors.push(args.map(String).join(" ")); };
try {
await gateway.sendEvent("qq:chat", "event text must not leak");
} finally {
console.error = originalError;
}
assert.deepEqual(errors, ["Gateway event send failed (platform=qq)"]);
});
test("status and help reach the runtime or reply directly", async () => {
const { runtime, gateway } = createGateway();
const adapter = recordingAdapter(); const adapter = recordingAdapter();
const turn = gateway.receive(message("work"), adapter, { synchronous: true }); const turn = gateway.receive(message("work"), adapter, { synchronous: true });
await waitFor(() => runtime.turns.length === 1); await waitFor(() => runtime.turns.length === 1);
clock.set(5_000);
const status = await gateway.receive(message("/status"), adapter, { synchronous: true }); const status = await gateway.receive(message("/status"), adapter, { synchronous: true });
assert.match(status.reply || "", /gatewayRunning=true/); assert.match(status.reply || "", /running=true/);
assert.match(status.reply || "", /queued=0/); const help = await gateway.receive(message("/help"), adapter, { synchronous: true });
assert.match(status.reply || "", /gatewayRunningSeconds=5/); assert.match(help.reply || "", /自然语言/);
assert.match((await gateway.receive(message("/help"), adapter, { synchronous: true })).reply || "", /\/status/); assert.match(help.reply || "", /\/confirm/);
assert.equal((await gateway.receive(message("/cancel"), adapter, { synchronous: true })).reply, "Cancellation requested."); assert.match(help.reply || "", /\/stop/);
assert.match(help.reply || "", /\/cancel/);
runtime.turns[0].resolve("done");
await turn; await turn;
const second = gateway.receive(message("work"), adapter, { synchronous: true });
await waitFor(() => runtime.turns.length === 2);
const reset = gateway.receive(message("/new"), adapter, { synchronous: true });
await flush();
assert.equal(runtime.resets, 0);
runtime.turns[1].resolve("done");
await second;
await reset;
assert.equal(runtime.resets, 1);
}); });
test("synchronous work sends no notices or delivery messages", async () => { test("list, confirm, stop, and cancel forward to the runtime with the chat identity", async () => {
const { runtime, clock, gateway } = createGateway(); const { runtime, gateway } = createGateway();
const adapter = recordingAdapter();
runtime.proposals = [fakeProposal()];
const list = await gateway.receive(message("/list"), adapter, { synchronous: true });
assert.equal(runtime.listed, 1);
assert.match(list.reply || "", /proposal-1 \[proposed\] 修复登录页/);
runtime.proposals = [];
const emptyList = await gateway.receive(message("/list"), adapter, { synchronous: true });
assert.equal(emptyList.reply, "当前没有提案。");
const confirm = await gateway.receive(message("/confirm"), adapter, { synchronous: true });
assert.equal(runtime.confirmed, 1);
assert.equal(confirm.reply, "已确认,继续执行。");
const stop = await gateway.receive(message("/stop"), adapter, { synchronous: true });
assert.equal(runtime.stopped, 1);
assert.equal(stop.reply, "已停止当前任务。");
const cancel = await gateway.receive(message("/cancel"), adapter, { synchronous: true });
assert.equal(runtime.cancelled, 1);
assert.equal(cancel.reply, "已取消最近的提案。");
runtime.confirmResult = false;
runtime.stopResult = false;
runtime.cancelResult = false;
assert.equal((await gateway.receive(message("/confirm"), adapter, { synchronous: true })).reply, "当前没有待确认的提案。");
assert.equal((await gateway.receive(message("/stop"), adapter, { synchronous: true })).reply, "当前没有正在执行的任务。");
assert.equal((await gateway.receive(message("/cancel"), adapter, { synchronous: true })).reply, "没有可取消的提案。");
});
test("synchronous work sends no delivery messages", async () => {
const { runtime, gateway } = createGateway();
const sent: OutgoingMessage[] = []; const sent: OutgoingMessage[] = [];
const turn = gateway.receive(message("work"), recordingAdapter(sent), { synchronous: true }); const turn = gateway.receive(message("work"), recordingAdapter(sent), { synchronous: true });
await waitFor(() => runtime.turns.length === 1); await waitFor(() => runtime.turns.length === 1);
await clock.advanceTo(600_000);
assert.deepEqual(sent, []);
runtime.turns[0].resolve("done"); runtime.turns[0].resolve("done");
assert.deepEqual(await turn, { ok: true, reply: "done" }); assert.deepEqual(await turn, { ok: true, reply: "done" });
assert.deepEqual(sent, []); assert.deepEqual(sent, []);
}); });
test("asynchronous help replies with sequence one", async () => { test("asynchronous help replies with sequence one without reaching the runtime", async () => {
const { runtime, gateway } = createGateway(); const { runtime, gateway } = createGateway();
const sent: OutgoingMessage[] = []; const sent: OutgoingMessage[] = [];
await gateway.receive(message("/help", "help"), recordingAdapter(sent)); await gateway.receive(message("/help", "help"), recordingAdapter(sent));
@@ -388,32 +288,13 @@ test("asynchronous help replies with sequence one", async () => {
assert.equal(runtime.turns.length, 0); assert.equal(runtime.turns.length, 0);
}); });
test("asynchronous new waits for prior work and then replies with sequence one", async () => { test("asynchronous commands reply with sequence one without reaching prompt", async () => {
const { runtime, gateway } = createGateway(); const { runtime, gateway } = createGateway();
const sent: OutgoingMessage[] = []; const sent: OutgoingMessage[] = [];
const adapter = recordingAdapter(sent); runtime.proposals = [fakeProposal()];
const work = gateway.receive(message("work", "work"), adapter); const result = await gateway.receive(message("/list", "list"), recordingAdapter(sent));
await waitFor(() => runtime.turns.length === 1); assert.equal(result.ok, true);
const reset = gateway.receive(message("/new", "new"), adapter);
await flush();
assert.equal(runtime.resets, 0);
assert.deepEqual(messagesFor(sent, "new"), []);
runtime.turns[0].resolve("done");
await work;
assert.deepEqual(await reset, { ok: true, reply: "Started a new native ACP session for this chat." });
assert.equal(runtime.resets, 1);
assert.deepEqual(messagesFor(sent, "new").map((outgoing) => [outgoing.text, outgoing.replySequence]), [
["Started a new native ACP session for this chat.", 1]
]);
});
test("asynchronous retired-role command replies with sequence one without reaching ACP", async () => {
const { runtime, gateway } = createGateway();
const sent: OutgoingMessage[] = [];
const result = await gateway.receive(message("/roles", "retired"), recordingAdapter(sent));
assert.match(result.reply || "", /removed in Config v3/);
assert.equal(runtime.turns.length, 0); assert.equal(runtime.turns.length, 0);
assert.deepEqual(messagesFor(sent, "retired").map((outgoing) => outgoing.replySequence), [1]); assert.deepEqual(messagesFor(sent, "list").map((outgoing) => outgoing.replySequence), [1]);
assert.match(messagesFor(sent, "list")[0].text, /proposal-1/);
}); });
+25 -2
View File
@@ -4,9 +4,9 @@ import { EventEmitter } from "node:events";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import test from "node:test"; import test from "node:test";
import { writeConfigFile } from "../src/cli/config-file.js"; import { configDigest, writeConfigFile } from "../src/cli/config-file.js";
import { assertSetupPidSafe, instanceDirectory, instanceRunnerPath, matchesInstanceRunner, runInstanceCommand } from "../src/cli/instance.js"; import { assertSetupPidSafe, instanceDirectory, instanceRunnerPath, matchesInstanceRunner, runInstanceCommand } from "../src/cli/instance.js";
import { runInstanceRunner, waitForShutdown } from "../src/cli/instance-runner.js"; import { assertRunnerConfigSnapshot, assertRunnerWorkspaceSafety, runInstanceRunner, waitForShutdown } from "../src/cli/instance-runner.js";
import { parseConfig } from "../src/config.js"; import { parseConfig } from "../src/config.js";
test("instance paths reject traversal and config identity mismatch", async () => { test("instance paths reject traversal and config identity mismatch", async () => {
@@ -69,6 +69,29 @@ test("instance runner rejects unsafe argument and config paths before starting a
await assert.rejects(runInstanceRunner([path.join(os.tmpdir(), `missing-runner-${Date.now()}.json`)]), /Runtime config does not exist/); await assert.rejects(runInstanceRunner([path.join(os.tmpdir(), `missing-runner-${Date.now()}.json`)]), /Runtime config does not exist/);
}); });
test("instance runner binds startup to the validated config snapshot and repeats workspace checks", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gori-runner-safety-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const parent = path.join(root, "workspace");
const child = path.join(parent, "child");
fs.mkdirSync(child, { recursive: true });
const config = parseConfig({
configVersion: 3,
bot: { id: "runner-bot", workspace: child, persona: "test", agent: { id: "kimi", command: "kimi", args: ["acp"] }, permissions: { mode: "deny" } },
gateway: { platform: { type: "webhook", secret: "secret" } },
runtime: {}
});
const configFile = path.join(root, "instances", "runner-bot", "config.json");
writeConfigFile(configFile, config);
assert.doesNotThrow(() => assertRunnerConfigSnapshot(config, configDigest(config)));
assert.throws(() => assertRunnerConfigSnapshot(config, "changed"), /config changed/);
assert.throws(() => assertRunnerWorkspaceSafety(config, path.join(root, "wrong.json"), root), /directory contract/);
const peer = parseConfig({ ...config, bot: { ...config.bot, id: "peer-bot", workspace: parent } });
writeConfigFile(path.join(root, "instances", "peer-bot", "config.json"), peer);
assert.throws(() => assertRunnerWorkspaceSafety(config, configFile, root), /identical, parent, or child workspace/);
});
test("instance runner gracefully shuts down once on SIGTERM", async () => { test("instance runner gracefully shuts down once on SIGTERM", async () => {
const signals = new EventEmitter(); const signals = new EventEmitter();
let shutdowns = 0; let shutdowns = 0;
+115
View File
@@ -0,0 +1,115 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { ProposalStore } from "../src/core/proposal-store.js";
const identity = { botId: "test-bot", platform: "qq" };
const input = { title: "Refactor store", goal: "Move to assistant proposals", steps: ["design", "implement", "test"], ownerChatKey: "qq:chat", requesterUserId: "user-1" };
test("creates, updates and reloads proposals with 0600 atomic file", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-proposals-"));
const file = path.join(dir, "proposals.json");
const first = new ProposalStore(file, identity);
await first.open();
const created = await first.create(input);
assert.equal(created.status, "proposed");
assert.ok(created.id.length > 0);
assert.equal(created.createdAt, created.updatedAt);
const confirmedAt = created.createdAt + 1000;
const updated = await first.update(created.id, {
status: "working",
confirmedAt,
startedAt: confirmedAt + 1,
workerNativeSessionId: "native-1",
workerProcessGroup: { pgid: 4321, token: "worker-token" },
pending: { kind: "step", summary: "needs dirty confirm", question: "overwrite?", nextStep: "edit file" },
lastWorkerSummary: "half done"
});
assert.equal(updated.status, "working");
assert.ok(updated.updatedAt >= created.updatedAt);
await first.close();
assert.equal((await fs.promises.readdir(dir)).some((name) => name.endsWith(".tmp")), false);
assert.equal((await fs.promises.stat(file)).mode & 0o777, 0o600);
const persisted = JSON.parse(await fs.promises.readFile(file, "utf8"));
assert.equal(persisted.version, 1); assert.equal(persisted.botId, "test-bot"); assert.equal(persisted.platform, "qq");
assert.equal(persisted.proposals[created.id].workerProcessGroup.pgid, 4321);
const second = new ProposalStore(file, identity);
await second.open();
const loaded = second.get(created.id);
assert.deepEqual(loaded, updated);
assert.deepEqual(second.list().map((proposal) => proposal.id), [created.id]);
assert.deepEqual(second.list({ status: "working" }).map((proposal) => proposal.id), [created.id]);
assert.equal(second.list({ status: "queued" }).length, 0);
assert.equal(second.stats().proposals, 1);
await second.close();
});
test("orders list by confirmation time for queued proposals", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-proposals-order-"));
const file = path.join(dir, "proposals.json");
const store = new ProposalStore(file, identity);
await store.open();
const firstCreated = await store.create({ ...input, title: "first created" });
const secondCreated = await store.create({ ...input, title: "second created" });
await store.update(secondCreated.id, { status: "queued", confirmedAt: 100 });
await store.update(firstCreated.id, { status: "queued", confirmedAt: 200 });
assert.deepEqual(store.list({ status: "queued" }).map((proposal) => proposal.title), ["second created", "first created"]);
await store.close();
});
test("validates proposal fields on create and update", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-proposals-invalid-"));
const file = path.join(dir, "proposals.json");
const store = new ProposalStore(file, identity);
await store.open();
await assert.rejects(store.create({ ...input, title: "" }), /title/);
await assert.rejects(store.create({ ...input, steps: ["ok", ""] }), /steps/);
const created = await store.create(input);
await assert.rejects(store.update(created.id, { status: "bogus" as never }), /status/);
await assert.rejects(store.update(created.id, { workerProcessGroup: { pgid: 1, token: "t" } }), /workerProcessGroup/);
await assert.rejects(store.update(created.id, { pending: { kind: "bogus", summary: "s" } as never }), /pending/);
await assert.rejects(store.update("missing", { status: "queued" }), /unknown proposal/);
assert.equal(store.get(created.id)?.status, "proposed");
await store.close();
});
test("rejects identity or version mismatch and preserves corrupt state", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-proposals-id-"));
const file = path.join(dir, "proposals.json");
const wrongVersion = '{"version":2,"botId":"test-bot","platform":"qq","proposals":{}}\n';
await fs.promises.writeFile(file, wrongVersion);
await assert.rejects(new ProposalStore(file, identity).open(), /expected version 1/);
assert.equal(await fs.promises.readFile(file, "utf8"), wrongVersion);
await fs.promises.writeFile(file, JSON.stringify({ version: 1, botId: "other", platform: "qq", proposals: {} }));
await assert.rejects(new ProposalStore(file, identity).open(), /identity mismatch/);
await fs.promises.writeFile(file, JSON.stringify({ version: 1, botId: "test-bot", platform: "feishu", proposals: {} }));
await assert.rejects(new ProposalStore(file, identity).open(), /identity mismatch/);
await fs.promises.writeFile(file, "not-json");
await assert.rejects(new ProposalStore(file, identity).open(), /original file was preserved/);
assert.equal(await fs.promises.readFile(file, "utf8"), "not-json");
await fs.promises.writeFile(file, JSON.stringify({ version: 1, botId: "test-bot", platform: "qq", proposals: { abc: { id: "other" } } }));
await assert.rejects(new ProposalStore(file, identity).open(), /invalid proposal/);
});
test("refuses a second writer and recovers a stale lock", async () => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-proposals-lock-"));
const file = path.join(dir, "proposals.json");
const first = new ProposalStore(file, identity); await first.open();
await assert.rejects(new ProposalStore(file, identity).open(), /locked by another/);
await first.close();
await fs.promises.writeFile(`${file}.lock`, "2147483647\n", { mode: 0o600 });
const recovered = new ProposalStore(file, identity);
await recovered.open();
await recovered.close();
assert.equal(fs.existsSync(`${file}.lock`), false);
});
+102
View File
@@ -0,0 +1,102 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { canonicalWorkspace, findOverlappingWorkspace } from "../src/core/workspace-scope.js";
function writePeer(instances: string, botId: string, workspace: string): void {
const directory = path.join(instances, botId);
fs.mkdirSync(directory, { recursive: true });
fs.writeFileSync(path.join(directory, "config.json"), JSON.stringify({
configVersion: 3,
bot: {
id: botId,
workspace,
persona: "test",
agent: { id: "fake", command: process.execPath, args: ["agent.mjs"], env: {} },
skills: [],
permissions: { mode: "deny", allowedTools: [], allowedCommandPatterns: [] }
},
gateway: { platform: { type: "webhook", secret: "test-secret" } },
runtime: { acp: {} }
}));
}
test("peer scan rejects identical, parent, and child workspaces and allows disjoint scopes", async (t) => {
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-workspace-scope-"));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const instances = path.join(directory, "instances");
const workspace = path.join(directory, "workspace");
const child = path.join(workspace, "child");
const sibling = path.join(directory, "sibling");
fs.mkdirSync(child, { recursive: true });
fs.mkdirSync(sibling, { recursive: true });
// Disjoint workspaces are allowed.
writePeer(instances, "sibling-bot", sibling);
assert.equal(findOverlappingWorkspace(workspace, "current", instances), undefined);
// An identical workspace is rejected.
writePeer(instances, "same-bot", workspace);
assert.equal(findOverlappingWorkspace(workspace, "current", instances), "same-bot");
// A child workspace is rejected from the parent's perspective.
fs.rmSync(path.join(instances, "same-bot"), { recursive: true, force: true });
writePeer(instances, "child-bot", child);
assert.equal(findOverlappingWorkspace(workspace, "current", instances), "child-bot");
// From the child's perspective an identical or parent peer workspace is rejected.
assert.equal(findOverlappingWorkspace(child, "current", instances), "child-bot");
writePeer(instances, "same-bot", workspace);
assert.ok(["same-bot", "child-bot"].includes(findOverlappingWorkspace(child, "current", instances)!));
// The instance's own entry is skipped even when its workspace matches.
fs.rmSync(path.join(instances, "child-bot"), { recursive: true, force: true });
assert.equal(findOverlappingWorkspace(workspace, "same-bot", instances), undefined);
// A missing instances directory means no peers to conflict with.
assert.equal(findOverlappingWorkspace(workspace, "current", path.join(directory, "absent")), undefined);
});
test("peer scan fails closed for unsafe or invalid peer configs", async (t) => {
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-workspace-scope-closed-"));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const instances = path.join(directory, "instances");
const workspace = path.join(directory, "workspace");
fs.mkdirSync(workspace, { recursive: true });
// Peer config declares a workspace that cannot be canonicalized.
writePeer(instances, "missing-bot", path.join(directory, "missing"));
assert.throws(() => findOverlappingWorkspace(workspace, "current", instances));
fs.rmSync(path.join(instances, "missing-bot"), { recursive: true, force: true });
// Corrupt peer config fails closed.
const corrupt = path.join(instances, "corrupt-bot");
fs.mkdirSync(corrupt);
fs.writeFileSync(path.join(corrupt, "config.json"), "not-json");
assert.throws(() => findOverlappingWorkspace(workspace, "current", instances), /Invalid peer Config v3/);
fs.rmSync(path.join(corrupt, "config.json"));
// Missing peer config fails closed.
assert.throws(() => findOverlappingWorkspace(workspace, "current", instances), /missing config/);
fs.rmSync(corrupt, { recursive: true, force: true });
// Symlinked peer entries are refused.
writePeer(instances, "real-bot", path.join(directory, "sibling-real"));
fs.mkdirSync(path.join(directory, "sibling-real"), { recursive: true });
fs.symlinkSync(path.join(instances, "real-bot"), path.join(instances, "linked-bot"));
assert.throws(() => findOverlappingWorkspace(workspace, "current", instances), /unsafe peer instance entry/);
});
test("canonicalWorkspace resolves symlinks and relative segments", async (t) => {
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-workspace-canonical-"));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const real = path.join(directory, "real");
fs.mkdirSync(real, { recursive: true });
const link = path.join(directory, "link");
fs.symlinkSync(real, link);
assert.equal(canonicalWorkspace(link), fs.realpathSync.native(real));
assert.equal(canonicalWorkspace(path.join(real, "..", "real")), fs.realpathSync.native(real));
assert.throws(() => canonicalWorkspace(path.join(directory, "missing")));
});