ferrycat 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -22
- package/README_zh.md +96 -23
- package/dist/cli.js +73 -146
- package/dist/cli.js.map +1 -1
- package/dist/daemon.d.ts +54 -0
- package/dist/daemon.js +372 -0
- package/dist/daemon.js.map +1 -0
- package/dist/engine.d.ts +29 -1
- package/dist/engine.js +188 -27
- package/dist/engine.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/schemas.d.ts +228 -0
- package/dist/schemas.js +41 -0
- package/dist/schemas.js.map +1 -1
- package/dist/supervisor.d.ts +98 -0
- package/dist/supervisor.js +562 -0
- package/dist/supervisor.js.map +1 -0
- package/dist/types.d.ts +41 -0
- package/package.json +3 -1
- package/server.json +3 -3
package/README.md
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
<a href="README_zh.md">中文</a>
|
|
6
6
|
</p>
|
|
7
7
|
|
|
8
|
+
[](https://www.npmjs.com/package/ferrycat)
|
|
9
|
+
[](https://registry.modelcontextprotocol.io/v0/servers?search=ferrycat)
|
|
8
10
|
[](https://nodejs.org/)
|
|
9
11
|
[-success?style=flat)]()
|
|
10
12
|
[](https://modelcontextprotocol.io/)
|
|
@@ -15,7 +17,7 @@
|
|
|
15
17
|
> Purpose-built to safely bridge isolated desktop IDE agent hosts (such as Google Antigravity, WorkBuddy, Claude Code, and Cursor) across separate OS processes without context drift or blind reviews on stale files.
|
|
16
18
|
|
|
17
19
|
<p align="center">
|
|
18
|
-
<img src="https://guobug.github.io/assets/images/flowchart-ferrycat-cross-host-demand-and-workflow.svg" alt="FerryCat Architecture and Workflow" width="960" />
|
|
20
|
+
<img src="https://guobug.github.io/assets/images/flowchart-ferrycat-cross-host-demand-and-workflow-en.svg" alt="FerryCat Architecture and Workflow" width="960" />
|
|
19
21
|
</p>
|
|
20
22
|
|
|
21
23
|
> 📖 **Deep Dive**: [Why I Started Building FerryCat: When Two AI Coding Hosts Live on the Same Desktop](https://guobug.github.io/posts/2026/10/22/why-we-built-ferrycat-cross-host-agent-review-bus/)
|
|
@@ -118,6 +120,7 @@ Holder-side lifecycle controls on a `CLAIMED` contract (all require `agent_id ==
|
|
|
118
120
|
### 6. Threat Model & Security Assumptions (Local Workstation IPC)
|
|
119
121
|
- **Assumed Trust Environment**: FerryCat is intentionally designed as a **Trusted Local Workstation Bus** bridging isolated IDE hosts operating under the **same OS user account**. Callers and workers share the local file system (`workspace_root`) and SQLite database (`~/.ferrycat/mesh.db`).
|
|
120
122
|
- **Defense Scope**: Path containment (`assertSafePointerPath`) and sensitive file pattern filters prevent unintentional workspace escapes, symlink traversals, or accidental leakage of credentials/secret files during handoffs.
|
|
123
|
+
- **Daemon Execution Surface**: In daemon mode (`ferrycat-cli daemon`), handler scripts are invoked directly via `execFile`. Write permissions to `ferrycat.config.json` or handler script paths are equivalent to host execution privileges (ADR-0005 §2.2). Keep configurations local and version-controlled.
|
|
121
124
|
- **Out of Scope (Non-Goals)**: FerryCat does **not** provide adversarial sandboxing, multi-tenant isolation, or network authorization against malicious local processes or adversarial agents spoofing `AGENT_NAME`.
|
|
122
125
|
|
|
123
126
|
---
|
|
@@ -126,32 +129,61 @@ Holder-side lifecycle controls on a `CLAIMED` contract (all require `agent_id ==
|
|
|
126
129
|
|
|
127
130
|
1. **Disjoint File Systems**: FerryCat offloads context by file reference; both hosts must share access to the same local `workspace_root`.
|
|
128
131
|
2. **Trivial Single-Turn Queries**: Each cross-host handoff involves 3 explicit human window switches (~15–25s overhead).
|
|
129
|
-
3. **
|
|
132
|
+
3. **Multi-Step Dynamic DAG Pipelines**: FerryCat's background daemon (`ferrycat-cli daemon`, ADR-0005) manages single-step contract dispatch and supervised worker lifecycles; for complex multi-step branching DAG orchestrations, use dedicated DAG workflow engines such as [PatchCat](https://github.com/GuoBug/ai-prompt-orchestrator).
|
|
130
133
|
|
|
131
134
|
---
|
|
132
135
|
|
|
133
|
-
##
|
|
136
|
+
## Controlled Daemon & Declarative Handlers (ADR-0005)
|
|
137
|
+
|
|
138
|
+
FerryCat provides a controlled daemon mode governed by SQLite microsecond CAS leases on `daemon_locks` and declarative scripts defined in `ferrycat.config.json`:
|
|
134
139
|
|
|
135
|
-
### 1. Build & Verify
|
|
136
140
|
```bash
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
141
|
+
# Run a single dispatch cycle with declared handlers
|
|
142
|
+
ferrycat-cli daemon --config ./ferrycat.config.json --once
|
|
143
|
+
|
|
144
|
+
# Start continuous daemon loop
|
|
145
|
+
ferrycat-cli daemon --config ./ferrycat.config.json
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Example `ferrycat.config.json`:
|
|
149
|
+
```json
|
|
150
|
+
{
|
|
151
|
+
"version": 1,
|
|
152
|
+
"daemon": {
|
|
153
|
+
"poll_interval_ms": 1000,
|
|
154
|
+
"lease_ttl_sec": 30,
|
|
155
|
+
"heartbeat_interval_ms": 10000,
|
|
156
|
+
"notify": true
|
|
157
|
+
},
|
|
158
|
+
"agents": {
|
|
159
|
+
"workbuddy": {
|
|
160
|
+
"handlers": {
|
|
161
|
+
"code_review": {
|
|
162
|
+
"exec_file": "./scripts/review-handler.js",
|
|
163
|
+
"pass_env": ["CUSTOM_ANALYZER_PATH"]
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
142
169
|
```
|
|
143
170
|
|
|
144
|
-
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## Quickstart
|
|
174
|
+
|
|
175
|
+
### 1. Recommended: Zero-Install MCP Mounting (via npx)
|
|
176
|
+
|
|
177
|
+
No local clone or manual compilation required. Mount directly into your IDE's MCP configuration using `npx -y ferrycat`:
|
|
178
|
+
|
|
179
|
+
#### Antigravity Configuration
|
|
145
180
|
Edit `%USERPROFILE%\.gemini\antigravity\mcp_config.json` (or `~/.gemini/antigravity/mcp_config.json` on macOS/Linux):
|
|
146
181
|
```json
|
|
147
182
|
{
|
|
148
183
|
"mcpServers": {
|
|
149
184
|
"ferrycat": {
|
|
150
|
-
"command": "
|
|
151
|
-
"args": [
|
|
152
|
-
"--experimental-sqlite",
|
|
153
|
-
"F:/git/FerryCat/dist/index.js"
|
|
154
|
-
],
|
|
185
|
+
"command": "npx",
|
|
186
|
+
"args": ["-y", "ferrycat"],
|
|
155
187
|
"env": {
|
|
156
188
|
"AGENT_NAME": "antigravity"
|
|
157
189
|
}
|
|
@@ -160,17 +192,14 @@ Edit `%USERPROFILE%\.gemini\antigravity\mcp_config.json` (or `~/.gemini/antigrav
|
|
|
160
192
|
}
|
|
161
193
|
```
|
|
162
194
|
|
|
163
|
-
|
|
195
|
+
#### WorkBuddy / Claude Code / Cursor / Codex Configuration
|
|
164
196
|
Configure the target host's MCP settings with its respective `AGENT_NAME`:
|
|
165
197
|
```json
|
|
166
198
|
{
|
|
167
199
|
"mcpServers": {
|
|
168
200
|
"ferrycat": {
|
|
169
|
-
"command": "
|
|
170
|
-
"args": [
|
|
171
|
-
"--experimental-sqlite",
|
|
172
|
-
"F:/git/FerryCat/dist/index.js"
|
|
173
|
-
],
|
|
201
|
+
"command": "npx",
|
|
202
|
+
"args": ["-y", "ferrycat"],
|
|
174
203
|
"env": {
|
|
175
204
|
"AGENT_NAME": "workbuddy"
|
|
176
205
|
}
|
|
@@ -178,7 +207,53 @@ Configure the target host's MCP settings with its respective `AGENT_NAME`:
|
|
|
178
207
|
}
|
|
179
208
|
}
|
|
180
209
|
```
|
|
181
|
-
|
|
210
|
+
|
|
211
|
+
### 2. Global Installation & Source Build
|
|
212
|
+
|
|
213
|
+
- **Global CLI Installation**:
|
|
214
|
+
```bash
|
|
215
|
+
npm install -g ferrycat
|
|
216
|
+
```
|
|
217
|
+
Then simply set `"command": "ferrycat"` in your MCP config.
|
|
218
|
+
- **Local Source Build (For Contributors)**:
|
|
219
|
+
```bash
|
|
220
|
+
git clone https://github.com/GuoBug/FerryCat.git
|
|
221
|
+
cd FerryCat
|
|
222
|
+
npm install
|
|
223
|
+
npm run build
|
|
224
|
+
npm test
|
|
225
|
+
```
|
|
226
|
+
Mount using your local path:
|
|
227
|
+
`"command": "node"`, `"args": ["--experimental-sqlite", "/path/to/FerryCat/dist/index.js"]`.
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## First-Class CLI Experience (`ferrycat-cli`)
|
|
232
|
+
|
|
233
|
+
Beyond serving as an in-IDE MCP Server, FerryCat provides a first-class CLI utility (`ferrycat-cli` or `npx ferrycat-cli`). Sharing the same `~/.ferrycat/mesh.db` SQLite WAL database and Zod validation contracts, the CLI allows developers to inspect mesh health, verify working tree safety, and hook into automated CI/Git scripts without leaving their terminal:
|
|
234
|
+
|
|
235
|
+
### 1. Terminal Visual Inspection (ASCII Status Table)
|
|
236
|
+
Inspect all cross-host handoffs and error reasons formatted as an ASCII table:
|
|
237
|
+
```bash
|
|
238
|
+
# View all contracts in an ASCII table
|
|
239
|
+
npx ferrycat-cli inspect
|
|
240
|
+
|
|
241
|
+
# Filter by target agent (e.g., only tasks for antigravity)
|
|
242
|
+
npx ferrycat-cli inspect --target antigravity
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### 2. Pre-Flight Verification (Auditing Before Mutation)
|
|
246
|
+
Audit on-disk pointer file integrity against the contract's baseline SHA-256 before committing review work (ADR-0004), eliminating false-positive deliveries:
|
|
247
|
+
```bash
|
|
248
|
+
npx ferrycat-cli verify <CONTRACT_ID>
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### 3. Local CI & Headless Worker Execution
|
|
252
|
+
Poll the queue, claim tasks, and execute local review/test scripts with automatic failure closure:
|
|
253
|
+
```bash
|
|
254
|
+
# Safely run a script upon task arrival (no shell quotation hazards)
|
|
255
|
+
npx ferrycat-cli worker --agent codex --mode=exec --exec-file ./scripts/review.sh
|
|
256
|
+
```
|
|
182
257
|
|
|
183
258
|
---
|
|
184
259
|
|
package/README_zh.md
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
<b>中文</b>
|
|
6
6
|
</p>
|
|
7
7
|
|
|
8
|
+
[](https://www.npmjs.com/package/ferrycat)
|
|
9
|
+
[](https://registry.modelcontextprotocol.io/v0/servers?search=ferrycat)
|
|
8
10
|
[](https://nodejs.org/)
|
|
9
11
|
[-success?style=flat)]()
|
|
10
12
|
[](https://modelcontextprotocol.io/)
|
|
@@ -118,6 +120,7 @@ MCP 官方的 `Tasks` 规范(2025-11 / 2026-07)解决的是**单条 `Client
|
|
|
118
120
|
### 6. 威胁模型与安全边界声明 (同机同用户受信 IPC)
|
|
119
121
|
- **环境信任假设**:FerryCat 定位为**面向同机同操作系统用户的跨宿主 AI 开发者协作总线**。所有 IDE 宿主与 Worker 均运行在同一本地用户权限下,共享本地工作区(`workspace_root`)与本地 SQLite 文件(`~/.ferrycat/mesh.db`)。
|
|
120
122
|
- **防护范畴**:双端路径防逃逸(`assertSafePointerPath`)与敏感文件模式黑名单旨在防范跨宿主误触敏感配置(如 `.env`、SSH 密钥)、软链接越界与无意识的目录逃逸。
|
|
123
|
+
- **守护进程执行面**:在守护进程模式(`ferrycat-cli daemon`)下,处理脚本通过 `execFile` 直接执行。`ferrycat.config.json` 或处理脚本所在目录的写权限等价于宿主代码执行权限(详见 ADR-0005 §2.2),请确保配置文件置于本地受信任路径。
|
|
121
124
|
- **非防护目标**:FerryCat **不作为**面向不受信外部网络、多租户强隔离或恶意本地进程伪造 `AGENT_NAME` 的零信任安全网关。
|
|
122
125
|
|
|
123
126
|
---
|
|
@@ -126,34 +129,61 @@ MCP 官方的 `Tasks` 规范(2025-11 / 2026-07)解决的是**单条 `Client
|
|
|
126
129
|
|
|
127
130
|
1. **两端不共享同一本地文件系统**:FerryCat 依赖按引用传递文件指针(Context Offloading by Reference),要求双方能访问同一份 `workspace_root`。
|
|
128
131
|
2. **单轮简单问答即可解决的任务**:每次跨宿主交接需要 3 次人工窗口切换(约 15~25 秒操作开销),简单问题直接在单个 IDE 内完成即可。
|
|
129
|
-
3.
|
|
132
|
+
3. **跨步骤复杂有向图编排**:FerryCat 的守护进程(`ferrycat-cli daemon`)负责同机多 Agent 之间的单步契约分发与进程生命周期托管;若需跨多步骤的复杂动态 DAG 有向无环图分支编排,请使用专门的工作流引擎(如 [PatchCat](https://github.com/GuoBug/ai-prompt-orchestrator))。
|
|
130
133
|
|
|
131
134
|
---
|
|
132
135
|
|
|
133
|
-
##
|
|
136
|
+
## 守护进程与声明式处理 (Controlled Daemon, ADR-0005)
|
|
137
|
+
|
|
138
|
+
FerryCat 支持受控后台守护进程,通过 SQLite 微事务 CAS 租约锁实现多进程安全抢占与故障转移,并强制通过 `ferrycat.config.json` 静态绑定脚本处理器:
|
|
134
139
|
|
|
135
|
-
### 1. 安装与编译测试
|
|
136
140
|
```bash
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
141
|
+
# 单次轮询分发并执行已声明的处理脚本
|
|
142
|
+
ferrycat-cli daemon --config ./ferrycat.config.json --once
|
|
143
|
+
|
|
144
|
+
# 启动后台守护主循环
|
|
145
|
+
ferrycat-cli daemon --config ./ferrycat.config.json
|
|
142
146
|
```
|
|
143
147
|
|
|
144
|
-
|
|
148
|
+
配置文件形态示例(`ferrycat.config.json`):
|
|
149
|
+
```json
|
|
150
|
+
{
|
|
151
|
+
"version": 1,
|
|
152
|
+
"daemon": {
|
|
153
|
+
"poll_interval_ms": 1000,
|
|
154
|
+
"lease_ttl_sec": 30,
|
|
155
|
+
"heartbeat_interval_ms": 10000,
|
|
156
|
+
"notify": true
|
|
157
|
+
},
|
|
158
|
+
"agents": {
|
|
159
|
+
"workbuddy": {
|
|
160
|
+
"handlers": {
|
|
161
|
+
"code_review": {
|
|
162
|
+
"exec_file": "./scripts/review-handler.js",
|
|
163
|
+
"pass_env": ["CUSTOM_ANALYZER_PATH"]
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## 快速上手 (Quickstart)
|
|
145
174
|
|
|
146
|
-
###
|
|
175
|
+
### 1. 推荐:零配置免安装挂载 (npx)
|
|
176
|
+
|
|
177
|
+
无需手动克隆仓库或本地编译,直接在各 IDE 宿主的 MCP 配置文件中声明(基于全局或 `npx -y ferrycat`):
|
|
178
|
+
|
|
179
|
+
#### Antigravity 宿主配置
|
|
147
180
|
编辑 `%USERPROFILE%\.gemini\antigravity\mcp_config.json`(macOS/Linux 为 `~/.gemini/antigravity/mcp_config.json`):
|
|
148
181
|
```json
|
|
149
182
|
{
|
|
150
183
|
"mcpServers": {
|
|
151
184
|
"ferrycat": {
|
|
152
|
-
"command": "
|
|
153
|
-
"args": [
|
|
154
|
-
"--experimental-sqlite",
|
|
155
|
-
"F:/git/FerryCat/dist/index.js"
|
|
156
|
-
],
|
|
185
|
+
"command": "npx",
|
|
186
|
+
"args": ["-y", "ferrycat"],
|
|
157
187
|
"env": {
|
|
158
188
|
"AGENT_NAME": "antigravity"
|
|
159
189
|
}
|
|
@@ -162,17 +192,14 @@ npm test
|
|
|
162
192
|
}
|
|
163
193
|
```
|
|
164
194
|
|
|
165
|
-
|
|
166
|
-
在目标宿主的 MCP
|
|
195
|
+
#### WorkBuddy / Claude Code / Cursor / Codex 宿主配置
|
|
196
|
+
在目标宿主的 MCP 配置文件中指定对应 `AGENT_NAME` 即可:
|
|
167
197
|
```json
|
|
168
198
|
{
|
|
169
199
|
"mcpServers": {
|
|
170
200
|
"ferrycat": {
|
|
171
|
-
"command": "
|
|
172
|
-
"args": [
|
|
173
|
-
"--experimental-sqlite",
|
|
174
|
-
"F:/git/FerryCat/dist/index.js"
|
|
175
|
-
],
|
|
201
|
+
"command": "npx",
|
|
202
|
+
"args": ["-y", "ferrycat"],
|
|
176
203
|
"env": {
|
|
177
204
|
"AGENT_NAME": "workbuddy"
|
|
178
205
|
}
|
|
@@ -180,7 +207,53 @@ npm test
|
|
|
180
207
|
}
|
|
181
208
|
}
|
|
182
209
|
```
|
|
183
|
-
|
|
210
|
+
|
|
211
|
+
### 2. 全局安装或本地源码模式
|
|
212
|
+
|
|
213
|
+
- **全局 CLI 安装**:
|
|
214
|
+
```bash
|
|
215
|
+
npm install -g ferrycat
|
|
216
|
+
```
|
|
217
|
+
在 MCP 配置文件中直接指定 `"command": "ferrycat"` 即可。
|
|
218
|
+
- **本地源码编译(适合贡献者二次开发)**:
|
|
219
|
+
```bash
|
|
220
|
+
git clone https://github.com/GuoBug/FerryCat.git
|
|
221
|
+
cd FerryCat
|
|
222
|
+
npm install
|
|
223
|
+
npm run build
|
|
224
|
+
npm test
|
|
225
|
+
```
|
|
226
|
+
本地调试时可将配置中的 `command` 与 `args` 设为:
|
|
227
|
+
`"command": "node"`, `"args": ["--experimental-sqlite", "/path/to/FerryCat/dist/index.js"]`。
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## 一级入口:CLI 极速总线与自动化调度 (`ferrycat-cli`)
|
|
232
|
+
|
|
233
|
+
除了作为 MCP Server 嵌入在 IDE 内部供大模型自主调用外,FerryCat 原生提供一级命令行工具 **`ferrycat-cli`**(可通过全局安装或直接运行 `npx ferrycat-cli`)。它与 MCP 共享同一份 `~/.ferrycat/mesh.db` 数据库与 Zod 校验契约,无需切换 IDE 窗口即可在终端直观排查链路、接入 Git Hook 或自动化 CI:
|
|
234
|
+
|
|
235
|
+
### 1. 终端可视化监控(ASCII 状态总线)
|
|
236
|
+
实时打印工单流转状态、执行租约与报错信息,无需打开数据库查看:
|
|
237
|
+
```bash
|
|
238
|
+
# 查看全局工单 ASCII 终端表格
|
|
239
|
+
npx ferrycat-cli inspect
|
|
240
|
+
|
|
241
|
+
# 按接收方过滤(例如仅查看 target 为 antigravity 的工单)
|
|
242
|
+
npx ferrycat-cli inspect --target antigravity
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### 2. 交付前只读预检(Pre-Flight Verify)
|
|
246
|
+
在正式交付前,无需变更契约状态即可安全核验磁盘文件指针一致性,杜绝因未落盘修改导致的假阳性交付:
|
|
247
|
+
```bash
|
|
248
|
+
npx ferrycat-cli verify <CONTRACT_ID>
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### 3. 本地 CI 与自动化 Worker 执行
|
|
252
|
+
监听契约队列并直接拉起本地脚本执行(支持安全脚本执行与非零退出自动熔断为 `FAILED`):
|
|
253
|
+
```bash
|
|
254
|
+
# 启动常驻 Worker 监听并自动执行指定脚本(无 Shell 注入风险)
|
|
255
|
+
npx ferrycat-cli worker --agent codex --mode=exec --exec-file ./scripts/review.sh
|
|
256
|
+
```
|
|
184
257
|
|
|
185
258
|
---
|
|
186
259
|
|
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { exec, execFile } from 'node:child_process';
|
|
3
2
|
import fs from 'node:fs';
|
|
4
3
|
import path from 'node:path';
|
|
5
4
|
import { fileURLToPath } from 'node:url';
|
|
6
5
|
import { ZodError } from 'zod';
|
|
7
6
|
import { expandTilde, FerryCatEngine } from './engine.js';
|
|
7
|
+
import { FerryCatDaemon } from './daemon.js';
|
|
8
|
+
import { executeTaskAndFulfill, terminateActiveChildrenSync, } from './supervisor.js';
|
|
8
9
|
import { DispatchContractSchema, ExtendTtlSchema, FailContractSchema, FulfillContractSchema, InspectMeshStateSchema, PollNextTaskSchema, ReleaseContractSchema, VerifyContractSchema, WorkerCommandSchema, } from './schemas.js';
|
|
9
10
|
function loadPackageVersion() {
|
|
10
11
|
try {
|
|
@@ -33,6 +34,7 @@ Commands:
|
|
|
33
34
|
fail Explicitly mark a CLAIMED contract you hold as terminal FAILED with reason
|
|
34
35
|
inspect Query contracts (renders concise table by default; pass --json for raw JSON)
|
|
35
36
|
worker Run background watcher, single-shot claim, or automated script execution
|
|
37
|
+
daemon Run multi-tenant production daemon with CAS micro-lease and declarative handlers (ADR-0005)
|
|
36
38
|
|
|
37
39
|
Common Options:
|
|
38
40
|
--agent <id> Agent identity (overrides AGENT_NAME env var)
|
|
@@ -174,6 +176,22 @@ Examples:
|
|
|
174
176
|
ferrycat-cli worker --agent workbuddy --mode=watch
|
|
175
177
|
ferrycat-cli worker --agent workbuddy --mode=claim
|
|
176
178
|
ferrycat-cli worker --agent workbuddy --mode=exec --once --exec-file ./worker-handler.js`,
|
|
179
|
+
daemon: `Usage: ferrycat-cli daemon [--config <path>] [--agent <id>] [--once] [--lease-ttl <sec>] [--interval <ms>]
|
|
180
|
+
|
|
181
|
+
Runs the FerryCat production daemon (ADR-0005). Coordinates background workers using short-transaction
|
|
182
|
+
CAS leases on the daemon_locks table and executes declarative scripts defined in ferrycat.config.json.
|
|
183
|
+
|
|
184
|
+
Options:
|
|
185
|
+
--config <path> Path to ferrycat.config.json (defaults to ./ferrycat.config.json or ~/.ferrycat/config.json)
|
|
186
|
+
--agent <id> Optional target agent filter (overrides config defaults)
|
|
187
|
+
--once Run a single dispatch cycle and exit
|
|
188
|
+
--lease-ttl <sec> Daemon CAS lease TTL in seconds (default: 30)
|
|
189
|
+
--interval <ms> Poll interval in milliseconds (default: 1000)
|
|
190
|
+
--no-notify Disable console notices
|
|
191
|
+
|
|
192
|
+
Examples:
|
|
193
|
+
ferrycat-cli daemon --config ./ferrycat.config.json
|
|
194
|
+
ferrycat-cli daemon --agent workbuddy --once`,
|
|
177
195
|
};
|
|
178
196
|
function stripBom(content) {
|
|
179
197
|
return content.replace(/^\uFEFF/, '');
|
|
@@ -384,6 +402,28 @@ function parseArgs(argv) {
|
|
|
384
402
|
}
|
|
385
403
|
flagOverrides.exec_file = scriptPath;
|
|
386
404
|
}
|
|
405
|
+
else if (token === '--pass-env') {
|
|
406
|
+
const varName = requireFlagValue('--pass-env', rest[++i]).trim();
|
|
407
|
+
if (!varName) {
|
|
408
|
+
throw new Error('Invalid --pass-env value: variable name cannot be empty.');
|
|
409
|
+
}
|
|
410
|
+
const existing = flagOverrides.pass_env ?? [];
|
|
411
|
+
existing.push(varName);
|
|
412
|
+
flagOverrides.pass_env = existing;
|
|
413
|
+
}
|
|
414
|
+
else if (token === '--config' || token === '-c') {
|
|
415
|
+
flagOverrides.config = requireFlagValue(token, rest[++i]);
|
|
416
|
+
}
|
|
417
|
+
else if (token === '--lease-ttl') {
|
|
418
|
+
const ttl = Number(requireFlagValue('--lease-ttl', rest[++i]));
|
|
419
|
+
if (!Number.isInteger(ttl) || ttl < 5) {
|
|
420
|
+
throw new Error('Invalid --lease-ttl: must be an integer >= 5 seconds.');
|
|
421
|
+
}
|
|
422
|
+
flagOverrides.lease_ttl_sec = ttl;
|
|
423
|
+
}
|
|
424
|
+
else if (token === '--no-notify') {
|
|
425
|
+
flagOverrides.notify = false;
|
|
426
|
+
}
|
|
387
427
|
else if (token === '--format') {
|
|
388
428
|
const fmt = requireFlagValue('--format', rest[++i]).trim().toLowerCase();
|
|
389
429
|
if (fmt !== 'table' && fmt !== 'json') {
|
|
@@ -461,149 +501,6 @@ function interruptibleSleep(ms, signal) {
|
|
|
461
501
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
462
502
|
});
|
|
463
503
|
}
|
|
464
|
-
function spawnExecTarget(spec, options, callback) {
|
|
465
|
-
if (spec.execFile) {
|
|
466
|
-
const resolvedScript = path.resolve(expandTilde(spec.execFile));
|
|
467
|
-
const ext = path.extname(resolvedScript).toLowerCase();
|
|
468
|
-
if (ext === '.js' || ext === '.mjs' || ext === '.cjs') {
|
|
469
|
-
return execFile(process.execPath, [resolvedScript], options, callback);
|
|
470
|
-
}
|
|
471
|
-
if (ext === '.ts') {
|
|
472
|
-
return execFile(process.execPath, ['--import', 'tsx', resolvedScript], options, callback);
|
|
473
|
-
}
|
|
474
|
-
if (ext === '.ps1') {
|
|
475
|
-
return execFile('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', resolvedScript], options, callback);
|
|
476
|
-
}
|
|
477
|
-
if (ext === '.sh') {
|
|
478
|
-
return execFile('sh', [resolvedScript], options, callback);
|
|
479
|
-
}
|
|
480
|
-
return execFile(resolvedScript, [], options, callback);
|
|
481
|
-
}
|
|
482
|
-
return exec(spec.execCmd, options, callback);
|
|
483
|
-
}
|
|
484
|
-
async function executeTaskAndFulfill(engine, contract, spec) {
|
|
485
|
-
return new Promise((resolve) => {
|
|
486
|
-
const childEnv = {
|
|
487
|
-
...process.env,
|
|
488
|
-
FERRYCAT_CONTRACT_ID: contract.contract_id,
|
|
489
|
-
FERRYCAT_TASK_TYPE: contract.task_type,
|
|
490
|
-
FERRYCAT_WORKSPACE_ROOT: contract.workspace_root,
|
|
491
|
-
FERRYCAT_TARGET_AGENT: contract.target_agent,
|
|
492
|
-
FERRYCAT_CLAIM_TOKEN: contract.claim_token ?? '',
|
|
493
|
-
};
|
|
494
|
-
const timeoutMs = Math.max(10000, contract.exec_ttl_sec * 1000);
|
|
495
|
-
const maxBuffer = 10 * 1024 * 1024;
|
|
496
|
-
const label = spec.execFile ?? spec.execCmd ?? 'exec';
|
|
497
|
-
const child = spawnExecTarget(spec, { env: childEnv, timeout: timeoutMs, maxBuffer }, (err, stdout, stderr) => {
|
|
498
|
-
if (err) {
|
|
499
|
-
const execErr = err;
|
|
500
|
-
const execError = `External exec command failed with code ${execErr.code ?? 1}${execErr.killed ? ' (killed after exec timeout)' : ''}: ${stderr || execErr.message}`;
|
|
501
|
-
let contractStatus;
|
|
502
|
-
try {
|
|
503
|
-
contractStatus = engine.failContract({
|
|
504
|
-
contract_id: contract.contract_id,
|
|
505
|
-
agent_id: contract.target_agent,
|
|
506
|
-
claim_token: contract.claim_token,
|
|
507
|
-
reason: execError.slice(0, 2000),
|
|
508
|
-
}).status;
|
|
509
|
-
}
|
|
510
|
-
catch (failErr) {
|
|
511
|
-
contractStatus = `UNCHANGED (${failErr instanceof Error ? failErr.message : String(failErr)})`;
|
|
512
|
-
}
|
|
513
|
-
return resolve({
|
|
514
|
-
ok: false,
|
|
515
|
-
status: 'EXEC_FAILED',
|
|
516
|
-
error: execError,
|
|
517
|
-
contract_status: contractStatus,
|
|
518
|
-
});
|
|
519
|
-
}
|
|
520
|
-
let structuredOutput;
|
|
521
|
-
let resultSummary = stripBom(stdout).trim();
|
|
522
|
-
try {
|
|
523
|
-
const parsed = JSON.parse(resultSummary);
|
|
524
|
-
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
|
525
|
-
if (parsed.structured_output && typeof parsed.structured_output === 'object') {
|
|
526
|
-
structuredOutput = parsed.structured_output;
|
|
527
|
-
}
|
|
528
|
-
else {
|
|
529
|
-
structuredOutput = parsed;
|
|
530
|
-
}
|
|
531
|
-
if (parsed.result_summary && typeof parsed.result_summary === 'string') {
|
|
532
|
-
resultSummary = parsed.result_summary;
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
catch {
|
|
537
|
-
// Plain text stdout, fallback to result_summary
|
|
538
|
-
}
|
|
539
|
-
const boundedSummary = (resultSummary || `Completed via automated worker exec: ${label}`).slice(0, 20000);
|
|
540
|
-
try {
|
|
541
|
-
const validatedFulfillInput = FulfillContractSchema.parse({
|
|
542
|
-
contract_id: contract.contract_id,
|
|
543
|
-
agent_id: contract.target_agent,
|
|
544
|
-
claim_token: contract.claim_token,
|
|
545
|
-
result_summary: boundedSummary,
|
|
546
|
-
structured_output: structuredOutput,
|
|
547
|
-
});
|
|
548
|
-
let fulfillRes = engine.fulfillContract(validatedFulfillInput);
|
|
549
|
-
// If RETRY_NEEDED occurs in automated exec (schema retry vs TOCTOU strike 1):
|
|
550
|
-
if (fulfillRes.status === 'RETRY_NEEDED') {
|
|
551
|
-
// RETRY_NEEDED has two distinct causes with incompatible retry semantics:
|
|
552
|
-
// (a) schema validation failure -> retry_count increments, so retrying the
|
|
553
|
-
// same payload until the breaker trips is the intended behavior.
|
|
554
|
-
// (b) TOCTOU dirty pointers -> retry_count does NOT move. The on-disk
|
|
555
|
-
// baseline cannot heal between two consecutive calls, so retrying only
|
|
556
|
-
// burns the single ADR-0004 strike and forces a terminal POINTER_STALE
|
|
557
|
-
// with nobody in the loop to restore the files. Never auto-retry it.
|
|
558
|
-
const isToctouRejection = (fulfillRes.toctou_strike ?? 0) > 0 && (fulfillRes.retry_count ?? 0) === 0;
|
|
559
|
-
if (isToctouRejection) {
|
|
560
|
-
resolve({
|
|
561
|
-
ok: false,
|
|
562
|
-
status: 'TOCTOU_RESCUE_REQUIRED',
|
|
563
|
-
fulfill_result: fulfillRes,
|
|
564
|
-
error: 'Automated exec mutated pointer files. The contract stays CLAIMED with a renewed execution window. Restore the files (git stash / git checkout) and re-run, or deliver edits via structured_output.fixes instead of writing to the workspace.',
|
|
565
|
-
});
|
|
566
|
-
return;
|
|
567
|
-
}
|
|
568
|
-
const maxAttempts = (fulfillRes.max_retries ?? 2) + 1;
|
|
569
|
-
for (let i = fulfillRes.retry_count ?? 1; i < maxAttempts; i++) {
|
|
570
|
-
fulfillRes = engine.fulfillContract(validatedFulfillInput);
|
|
571
|
-
if (fulfillRes.status !== 'RETRY_NEEDED')
|
|
572
|
-
break;
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
resolve({ ok: fulfillRes.ok, status: fulfillRes.status, fulfill_result: fulfillRes });
|
|
576
|
-
}
|
|
577
|
-
catch (fulfillErr) {
|
|
578
|
-
const fulfillError = fulfillErr instanceof Error ? fulfillErr.message : String(fulfillErr);
|
|
579
|
-
let contractStatus;
|
|
580
|
-
try {
|
|
581
|
-
contractStatus = engine.failContract({
|
|
582
|
-
contract_id: contract.contract_id,
|
|
583
|
-
agent_id: contract.target_agent,
|
|
584
|
-
reason: `FULFILL_ERROR: ${fulfillError}`.slice(0, 2000),
|
|
585
|
-
}).status;
|
|
586
|
-
}
|
|
587
|
-
catch {
|
|
588
|
-
contractStatus = undefined;
|
|
589
|
-
}
|
|
590
|
-
resolve({
|
|
591
|
-
ok: false,
|
|
592
|
-
status: 'FULFILL_ERROR',
|
|
593
|
-
error: fulfillError,
|
|
594
|
-
contract_status: contractStatus,
|
|
595
|
-
});
|
|
596
|
-
}
|
|
597
|
-
});
|
|
598
|
-
if (child.stdin) {
|
|
599
|
-
child.stdin.on('error', () => {
|
|
600
|
-
// Child process exited without consuming stdin, ignore EPIPE
|
|
601
|
-
});
|
|
602
|
-
child.stdin.write(JSON.stringify(contract));
|
|
603
|
-
child.stdin.end();
|
|
604
|
-
}
|
|
605
|
-
});
|
|
606
|
-
}
|
|
607
504
|
async function runWorkerLoop(engine, agentId, intervalMs, spec) {
|
|
608
505
|
const resolvedAgent = resolveWorkerIdentity(agentId);
|
|
609
506
|
const abortController = new AbortController();
|
|
@@ -625,9 +522,10 @@ async function runWorkerLoop(engine, agentId, intervalMs, spec) {
|
|
|
625
522
|
const stopHandler = () => {
|
|
626
523
|
sigintCount++;
|
|
627
524
|
if (sigintCount >= 2) {
|
|
525
|
+
terminateActiveChildrenSync();
|
|
628
526
|
console.error(JSON.stringify({
|
|
629
527
|
status: 'WORKER_FORCE_EXIT',
|
|
630
|
-
message: 'Second interrupt received, exiting immediately.',
|
|
528
|
+
message: 'Second interrupt received, terminated active child process trees and exiting immediately.',
|
|
631
529
|
}));
|
|
632
530
|
process.exit(130);
|
|
633
531
|
}
|
|
@@ -957,6 +855,7 @@ async function main() {
|
|
|
957
855
|
const execSpec = {
|
|
958
856
|
execCmd: validated.exec,
|
|
959
857
|
execFile: validated.exec_file,
|
|
858
|
+
passEnv: validated.pass_env,
|
|
960
859
|
};
|
|
961
860
|
engine = new FerryCatEngine();
|
|
962
861
|
if (effectiveOnce) {
|
|
@@ -996,8 +895,36 @@ async function main() {
|
|
|
996
895
|
}
|
|
997
896
|
break;
|
|
998
897
|
}
|
|
898
|
+
case 'daemon': {
|
|
899
|
+
const configPath = data.config;
|
|
900
|
+
const targetAgent = data.target_agent ?? data.agent_id;
|
|
901
|
+
const once = Boolean(data.once);
|
|
902
|
+
const leaseTtl = data.lease_ttl_sec ? Number(data.lease_ttl_sec) : undefined;
|
|
903
|
+
const intervalMs = data.interval_ms ? Number(data.interval_ms) : undefined;
|
|
904
|
+
const notify = data.notify !== false;
|
|
905
|
+
const daemon = new FerryCatDaemon({
|
|
906
|
+
configPath,
|
|
907
|
+
targetAgent,
|
|
908
|
+
leaseTtlSec: leaseTtl,
|
|
909
|
+
pollIntervalMs: intervalMs,
|
|
910
|
+
notify,
|
|
911
|
+
});
|
|
912
|
+
if (once) {
|
|
913
|
+
const res = await daemon.pollAndDispatchOnce();
|
|
914
|
+
daemon.releaseLease();
|
|
915
|
+
daemon.engine.close();
|
|
916
|
+
output = { ok: true, daemon_once: res };
|
|
917
|
+
console.log(JSON.stringify(output, null, 2));
|
|
918
|
+
}
|
|
919
|
+
else {
|
|
920
|
+
await daemon.start();
|
|
921
|
+
// Keep active process alive until signal
|
|
922
|
+
await new Promise(() => { });
|
|
923
|
+
}
|
|
924
|
+
break;
|
|
925
|
+
}
|
|
999
926
|
default:
|
|
1000
|
-
throw new Error(`Unknown subcommand "${command}". Supported commands: dispatch, poll, verify, fulfill, release, extend, fail, inspect, worker. Run "ferrycat-cli --help" for usage.`);
|
|
927
|
+
throw new Error(`Unknown subcommand "${command}". Supported commands: dispatch, poll, verify, fulfill, release, extend, fail, inspect, worker, daemon. Run "ferrycat-cli --help" for usage.`);
|
|
1001
928
|
}
|
|
1002
929
|
}
|
|
1003
930
|
catch (err) {
|