This repository has been archived on 2026-06-01. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
nerve/docs/rfc-001-observation-engine.md
xiaoju b269f76b33 refactor(daemon): rename reflex-scheduler → sense-scheduler
Rename ReflexScheduler to SenseScheduler, update all file names,
imports, comments, test descriptions, and log source values.

Fixes #202
2026-04-27 12:07:22 +00:00

777 lines
29 KiB
Markdown

# RFC-001: Nerve — Observation Engine
**Author:** 主人 & 小橘 🍊(NEKO Team)
**Date:** 2026-04-21
**Status:** Draft
**Repo:** `@uncaged/nerve`
---
## 1. 动机
现有的 agent 架构中,事件处理、状态投影、工作流触发、角色调度等概念交织在一起,边界模糊。我们需要一组正交的、最小化的抽象,使得 agent 的能力可以声明式地组合和扩展。
## 2. 核心洞察
### 2.1 两类事件
系统中流通的事件分为两类:
- **Signal**:Sense compute 返回非 null 值时发出的通知。纯事实,无意图,不期待响应。例:CPU 占用 95%。compute 返回 null 时不产生 Signal。
- **Command Event**:Workflow Thread 内部的流转事件。有明确因果链,必须被响应。例:reviewer 返回 rejected。
两者不在同一层级。Signal 驱动系统的前半部(感知),Command Event 驱动后半部(行动)。
### 2.2 Projection 不是独立概念
传统 Event Sourcing 中 Projection 是一等公民。在本设计中,Projection 只是"一个 compute 从存储中拉数据的 Sense"。它的输出和原始采样一样,都是 Signal。不需要单独的机制。
### 2.3 触发条件与计算逻辑解耦
一个 Sense **怎么算**(compute)和**什么时候算**(reflex)是两个独立的关注点。同一个 Sense 可以被定时触发、被事件触发、被按需查询触发。
### 2.4 Log 是数据资产,不是触发源
系统运行过程中产生的各种记录(reflex 执行记录、workflow 状态变迁、错误日志等)统称为 **Log**。Log 是有价值的数据资产,用于审计、溯源、分析。
**Log 与 Signal 的本质区别:**
- **Signal** — 来自外部世界的感知数据,可以触发 Reflex
- **Log** — 系统内部产出的副作用记录,**不能触发 Reflex**
因果链是单向的:
```
外部世界 → Sense → Signal → Reflex → Action + Log
Reflex 可以读 Log(查询/聚合)
Log 不能触发 Reflex ✗
```
禁止 Log 触发 Reflex 是防止雪崩的关键约束——如果 reflex 执行产生的 log 又能触发 reflex,系统会形成无限循环。Log 是因果链的终点,不是起点。
## 3. 术语表
| 术语 | 隐喻 | 含义 |
|------|------|------|
| **Sense** | 感官 | 定义怎么感知——compute 函数,每种 Sense 有自己的数据类型和独立存储 |
| **Reflex** | 反射 | 定义什么时候感知——声明式触发条件 |
| **Signal** | 信号 | Sense compute 返回非 null 时发出的通知,其他 Reflex 可以监听 |
| **Log** | 日志 | 系统内部产出的记录(执行记录、状态变迁、错误等),数据资产,不触发 Reflex |
| **Workflow** | 行动 | 定义怎么做——内含 Moderator(调度)和 Role(执行)|
| **Moderator** | 协调者 | Workflow 内部概念,在 Role 之间递话筒 |
| **Role** | 执行者 | Workflow 内部概念,执行具体动作,有副作用 |
| **Thread** | 工作流实例 | 一个 Workflow 的一次执行上下文 |
## 4. 三个扩展点
整个系统只有三个用户可扩展的概念:
| 扩展点 | 回答的问题 | 性质 |
|--------|-----------|------|
| **Sense** | 是什么(怎么算) | compute 函数 |
| **Reflex** | 什么时候算 | 声明式 YAML |
| **Workflow** | 怎么做 | Role + Moderator |
三者职责完全正交。Reflex 不知道 compute 的内容,Sense 不知道自己什么时候被触发,Workflow 不知道自己为什么被发起。
### 4.1 Sense
Sense 是系统中唯一的一等公民。一个 Sense 定义一个 compute 函数。
Sense 是多态的——每种 Sense 有自己的 Payload 类型。
所有 Sense 的 compute 返回 `T | null`。返回值时发 signal,返回 `null` 时静默——不写存储、不发 signal、不触发下游 reflex。
```typescript
// 原始采样:读物理世界,每次都有值
// senses/cpu-usage.ts
export async function compute(): Promise<number | null> {
return os.loadavg()[0] // 实际上总是有值
}
// 派生计算(即"Projection"):读其他 Sense 的存储
// senses/active-tasks.ts
export async function compute(): Promise<Task[] | null> {
const prev = myDb.prepare('SELECT * FROM tasks').all()
const newEvents = taskEventsDb.prepare(
'SELECT * FROM events WHERE ts > ?'
).all(lastSync)
const result = applyChanges(prev, newEvents)
return hasChanges(result, prev) ? result : null // 无变化则静默
}
```
**关键设计:每个 Sense 拥有自己独立的存储。** 没有统一的 Event Store。每种 Sense 按自己的数据结构定义自己的表、自己的库文件。Sense 之间需要查询时,以只读方式打开对方的库。
这是合理的,因为 Signal 本质是 append-only 的时序数据,不会跨 Sense join。每个 Sense 最了解自己的查询模式。
#### Schema 管理:Drizzle 作为标准工具链
每个 Sense 用 Drizzle ORM 定义 schema,**`schema.ts` 是 single source of truth**:
```typescript
// senses/cpu-usage/schema.ts
import { sqliteTable, integer, real } from 'drizzle-orm/sqlite-core'
export const samples = sqliteTable('samples', {
ts: integer('ts').primaryKey(),
value: real('value').notNull(),
})
```
Migration 由 `drizzle-kit generate` 自动生成,提交进 git:
```
senses/
cpu-usage/
schema.ts ← 开发者(agent)写这个
index.ts ← compute,查询有类型推导
migrations/ ← drizzle-kit 自动生成
0001_init.sql
```
compute 拿到的 db 实例是 Drizzle 包装过的,查询全部 type-safe:
```typescript
// senses/cpu-usage/index.ts
import { samples } from './schema'
// db 和 peers 由 engine runtime 注入,不需要用户创建
export async function compute(
db: DrizzleDB,
peers: Readonly<Record<string, DrizzleDB>>
): Promise<number | null> {
const load = os.loadavg()[0]
await db.insert(samples).values({ ts: Date.now(), value: load })
return load
}
```
**Cross-sense 类型安全读取:** compute 通过 `peers` 参数拿到其他 Sense 的只读 db 实例。类型来自 import 对方的 schema:
```typescript
// senses/active-tasks/index.ts
import { tasks } from './schema'
import { samples } from '../cpu-usage/schema' // 只导入类型定义
export async function compute(
db: DrizzleDB,
peers: Readonly<Record<string, DrizzleDB>>
): Promise<TaskSummary | null> {
// 自己的 db:读写
const activeTasks = await db.select().from(tasks).where(...)
// peer 的 db:只读,类型安全
const cpuLoad = await peers['cpu-usage'].select().from(samples).orderBy(desc(samples.ts)).limit(1)
return { activeTasks, cpuLoad }
}
```
**为什么用 Drizzle 而不是手写 SQL migration:**
Nerve 的 Sense 开发者是机器上的 Coding Agent(通过 Nerve 自身的 Workflow 自举开发)。Agent 行为的正确性应靠确定性工具保证,不应依赖概率模型的"自律"。Drizzle 让 agent 只写一个东西(`schema.ts`),migration 和类型都是机械派生,消除了 TS 与 SQL 不一致的风险。
**引擎职责:** 运行时只执行 migration SQL(`drizzle migrate`),不依赖 drizzle-kit。生成 migration 是开发时(workflow role action)的事。
**Migration rollback:** Drizzle 不支持 down migration,但 Sense 的 SQLite 是派生数据——坏了删 `.db` 文件,engine 重启时自动重跑 migration 重建。不需要 rollback 机制。
**drizzle.config:** 不需要每个 Sense 各一份。Engine runtime 统一参数化——根据 sense name 确定 `.db` 路径和 `migrations/` 目录,调用 `drizzle-kit generate` 时动态传入。Sense 开发者只写 `schema.ts`
**Schema 新鲜度:** Engine 启动时不校验 `schema.ts``migrations/` 是否同步。这是开发时的职责——创建/修改 Sense 的 Workflow 负责跑 `drizzle-kit generate` 并提交 migration。运行时只管执行已有的 migration SQL。
#### Sense 不知道 Workflow
Sense 只感知世界并产出 Signal,永远不关心"谁在听"或"听了之后要做什么"。Sense 不引用 Workflow、不返回 `ThreadStart`、不知道自己的 Signal 会触发什么动作。
```typescript
// senses/disk-usage.ts — 只关心磁盘状态
export async function compute(): Promise<DiskUsage | null> {
const usage = await getDiskUsage()
return usage // 纯事实,不关心下游
}
```
启动 Workflow 是 Reflex 的职责,见 §4.2。
### 4.2 Reflex
Reflex 是纯声明式的 YAML 配置,定义**引擎对 Signal 的反应**。Reflex 是连接 Sense 与 Sense、Sense 与 Workflow 的唯一纽带。
Reflex 有两种 action:
| action | 含义 |
|--------|------|
| 触发 compute | 让某个 Sense 重新计算(默认行为) |
| 启动 workflow | 创建一个 Thread(Post-MVP) |
```yaml
# nerve.yaml
senses:
cpu-usage:
group: system
throttle: 5s
timeout: 3s
disk-usage:
group: system
throttle: 30s
active-tasks:
group: tasks
throttle: 10s
timeout: 30s
reflexes:
# Sense → Sense(触发 compute,默认 action)
- sense: cpu-usage
interval: 10s
- sense: disk-usage
interval: 5m
- sense: active-tasks
on: ["task.created", "task.completed"]
interval: 10m
# Sense → Workflow(启动 thread)
- workflow: cleanup
on: ["disk-usage"]
```
两种触发条件:
| 条件 | 含义 |
|------|------|
| `interval` | 定时触发 |
| `on: [signals]` | 当指定的 Sense 发出新 Signal 时触发 |
一个 Reflex 可以有多个触发条件(如 `active-tasks` 同时有事件触发和定时兜底)。
OnDemand(按需触发)不需要声明——引擎内置提供,任何 Sense 都可以被外部 API 调用触发。
#### Reflex 是 Event Mesh
所有的 Reflex 声明合在一起,构成了一个声明式的事件路由网格(Event Mesh)。引擎启动时解析所有 Reflex,构建出完整的事件流拓扑:
```
Signal Bus ──→ Reflex Mesh ──→ Sense compute
──→ Workflow thread
```
每个 Signal 进入 bus 后,Reflex Mesh 决定它的去向——触发哪些 Sense 重算、启动哪些 Workflow。这不是 Sense 主动"发消息"给下游,而是引擎根据声明式规则自动路由。
**进程视角:** Sense Worker 只负责执行 compute 并把 Signal 交给 Kernel。Kernel 里的 Reflex Mesh 决定下一步——该触发谁的 compute、该起哪个 Workflow Worker。Worker 之间永远不直连,所有路由决策都在 Kernel 完成。
#### Reflex 语义规则
**Interval 起点**:以库中记录的上次 compute 完成时间为准,不是 daemon 启动时间。daemon 重启时,若已过期则立即执行一次,然后恢复正常节奏。不会对停机期间的缺失进行逐次补偿。
**Event 补偿**:OnEvent 的语义是"有新数据了,该重新算一下",不是"每个 event 都要处理一次"。daemon 重启时最多触发一次 compute,compute 内部通过 `pullSince(lastTimestamp)` 拉取所有积压数据。
**合并与幂等**:同一个 Sense 同一时刻最多一个 compute 在执行。多个触发条件同时满足时合并为一次调用。compute 执行期间收到新 trigger,标记为 pending,当前完成后再执行一次。
```
┌─ OnInterval 到了 ──┐
├─ OnEvent 来了 ─────┤──→ 合并 → 一次 compute
└─ OnEvent 又来了 ──┘
compute 执行中 → 新 trigger 到达 → pending → 当前完成后再执行一次
```
这些规则与 Sense 的语义一致——compute 是"重新感知当前状态",不是"处理某个具体事件"。无论触发几次,做的是同一个动作。
### 4.3 Workflow(Post-MVP)
Workflow 定义一个有状态的工作流执行上下文(Thread)。内部包含:
- **Role**:执行具体动作的角色,有副作用
- **Moderator**:在 Role 之间递话筒的调度逻辑
Role 和 Moderator 是 Workflow 的内部细节,不跨 Workflow 共享,不作为顶层扩展点。
#### Workflow 配置
```yaml
# nerve.yaml
workflows:
cleanup:
concurrency: 1 # 同时最多 1 个 thread
overflow: drop # 已有活跃 thread 时丢弃新请求
execute-task:
concurrency: 10 # 可并行 10 个 thread
overflow: queue # 超出时排队
code-review:
concurrency: 3
overflow: queue
max_queue: 20 # 队列上限,超出丢弃最旧请求
```
- **concurrency**:同时允许的最大活跃 Thread 数
- **overflow**:达到上限时的策略
- `drop`:丢弃,适用于幂等操作(如 cleanup)
- `queue`:排队等待,适用于每次都需要执行的操作(如 deploy)
- **max_queue**(仅 `overflow: queue` 时生效):队列上限,默认 100。超出时丢弃最旧的请求
不需要 throttle——Workflow 的触发频率由上游 Sense 的 throttle 控制,Workflow 层只管并发。
#### Signal 系统与 Thread 的关系
Signal 和 Thread 是两个独立的循环,单向桥接:
```
Signal 循环 ──→ ThreadStart ──→ Thread 循环
(无状态,幂等,可合并) (有状态,顺序,Command Event 驱动)
Thread 产出 Log
(执行日志,供 retrospection)
```
**Signal 只负责 kickoff Thread**。Thread 启动后,由自己的事件循环驱动——Moderator 递话筒、Role 执行、Command Event 流转。Thread 内部不走 Signal 系统。
**Thread 产出的 Log 是执行日志**,记录 Thread 的中间状态和最终结果。这些 Log 可以被 Sense 的 compute 查询用于 retrospection(如统计成功率、平均耗时),但 Log 不能触发 Reflex(见 §2.4)。
这保证了两个循环的性质不被污染:
- Signal 循环:无状态、幂等、可合并、可丢弃
- Thread 循环:有状态、严格顺序、每个 Command Event 必须响应
## 5. 运行时模型
### 5.1 进程架构
Engine 主进程是整个系统的 **kernel 和 event hub**。它持有 Signal Bus、Scheduler、Process Manager,是所有 worker 的唯一通信对象。Worker 之间永远不直连——所有信息流都经过 engine 中转。
Engine 与用户代码完全分离。主进程**永远不加载用户代码**,所有用户代码在独立子进程中运行。
```
systemd / pm2
└─ nerve-engine (永驻主进程,纯引擎代码)
├─ Scheduler ← nerve.yaml
├─ Signal Bus ← worker 产出的 signal
├─ File Watcher ← 监听 ~/.uncaged-nerve/ 变化
└─ Process Manager
├─ nerve-worker group=system (永驻,用户 sense 代码)
├─ nerve-worker group=tasks (永驻,用户 sense 代码)
├─ nerve-worker workflow=cleanup (按需启动,用户 workflow 代码)
└─ nerve-worker workflow=code-review (按需启动,用户 workflow 代码)
```
#### 隔离理由
worker_thread 的隔离是假的——用户代码的 `process.exit()`、native module segfault、OOM 都会杀死主进程。只有进程边界才是真正的隔离墙。
#### Worker 架构:Engine Runtime + 用户代码
Worker 不是用户代码直接跑的进程,而是 **engine 提供的 runtime 加载用户代码**。用户代码是被 import 的模块,不是入口。
```
nerve-engine (kernel)
└─ nerve worker sense --group system
├─ runtime bootstrap(engine 代码)
│ ├─ 建立 IPC
│ ├─ 读 nerve.yaml,找到 group 里有哪些 sense
│ ├─ 对每个 sense:打开 .db → 跑 migration → drizzle 包装
│ ├─ 构建 peers 只读连接
│ └─ 发 { type: 'ready' }
└─ 用户代码(被 import 进来)
├─ cpu-usage/schema.ts ← 纯类型定义
└─ cpu-usage/index.ts ← compute 函数,拿到注入的 db
```
这意味着:
- **用户代码不操心基础设施**——IPC、db 初始化、migration 都是 runtime 的事
- **compute 函数签名简单**——engine 注入 `db`(自己的)和 `peers`(只读),用户只写业务逻辑
- **隔离天然成立**——用户代码跑在 engine 控制的沙箱里,crash 只影响同 group
#### Sense Worker
Sense 按 group 分组,同 group 共享一个 worker 进程。用户决定隔离粒度。
```yaml
senses:
cpu-usage:
group: system
disk-usage:
group: system
memory-usage:
group: system
active-tasks:
group: tasks
```
- 同 group 的 sense 一个出问题会影响同组,但不影响其他组和引擎
- Worker 长驻,跟 engine 一起活
- 崩溃后 engine 自动 respawn
#### Workflow Worker
同一个 workflow 的所有 thread 共享一个 worker 进程。`concurrency` 控制进程内的并发 async task 数。
```yaml
workflows:
cleanup:
concurrency: 1
overflow: drop
code-review:
concurrency: 3
overflow: queue
```
- 进程数 = workflow 种类数,不会膨胀
- 有活跃 thread 时启动,所有 thread 完成后退出(或保持待命一段时间)
- 崩溃后 engine respawn worker,从持久化状态恢复 thread
#### 进程对比
| | Sense Worker | Workflow Worker |
|---|---|---|
| 粒度 | 按 group | 按 workflow 种类 |
| 内部并发 | 多 sense 的 compute | 多 thread(async) |
| 生命周期 | 永驻 | 有活跃 thread 时存活 |
| 崩溃恢复 | respawn,继续调度 | respawn,从持久化状态恢复 |
| 状态 | 无 | 持久化状态机 |
### 5.2 主进程 ↔ Worker 通信
所有 worker 只与 engine 通信,worker 之间无任何直接通道。这是 hub-and-spoke 拓扑——engine 是 hub,worker 是 spoke。
子进程通过 stdio/IPC 与主进程通信,协议极简:
```typescript
// 主进程 → worker
{ type: 'compute', sense: 'cpu-usage' } // 触发一次 compute
{ type: 'shutdown' } // 优雅退出
// worker → 主进程
{ type: 'signal', sense: 'cpu-usage', payload: ... } // compute 完成
{ type: 'error', sense: 'cpu-usage', error: ... } // compute 失败
{ type: 'ready' } // worker 启动完成
```
主进程不关心 payload 的内容,只负责转发 signal 到 bus。
**Signal Bus 是纯内存结构,不持久化。** Engine 崩溃重启后 bus 中的 signal 丢失,但这不影响正确性——reflex 的语义是"重新感知当前状态"而非"处理每个历史事件"。重启后 scheduler 按 interval 和 lastComputeTime 自然恢复节奏,不需要回放丢失的 signal。
### 5.3 Sense 运行时配置
Sense 的运行时属性(`group``throttle``timeout`)在 `nerve.yaml``senses` 字段中声明,完整示例见 §4.2。
- **group**:隔离分组,同 group 共享 worker 进程
- **throttle**:最小触发间隔,防止高频 signal 导致的无意义重算
- **timeout**:compute 超时上限(soft timeout),超时后 abort 当前 compute,记录错误 signal
- **grace_period**:soft timeout 后的宽限期(默认 timeout × 3),超过后 hard kill 整个 group worker 并 respawn。防止跑飞的 compute 堵住同 group
### 5.4 存储架构
系统有两大类持久化数据,全部 append-only:
#### Signal 存储
每个 Sense 独立一个 SQLite 文件(见 §8),由 Sense 自行管理 schema。这部分不变。
#### Log 存储
所有 Log 写入统一的 SQLite 文件 `data/logs.db`,单表:
```sql
CREATE TABLE logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL, -- "sense_scheduler", "sense", "workflow", "system"
type TEXT NOT NULL, -- "run_start", "run_complete", "error", "state_change"
ref_id TEXT, -- 关联的 reflex name / workflow run_id
payload TEXT, -- JSON
ts INTEGER NOT NULL -- unix ms
);
CREATE INDEX idx_logs_source_type ON logs(source, type);
CREATE INDEX idx_logs_ts ON logs(ts);
CREATE INDEX idx_logs_ref_id ON logs(ref_id);
```
- **统一一张表**,通过 `source + type` 区分 log 来源和类型
- Reflex 可以查询 logs 表(只读),但 log 不能触发 reflex(见 §2.4)
#### Workflow 状态:事件溯源 + 物化表
Workflow Thread 的状态以 append-only 事件流为 source of truth:
```sql
-- 也在 logs 表中,source = "workflow"
-- type 取值:queued, started, step_complete, completed, failed, crashed
```
当前状态 = 该 run_id 最后一条 log entry。例:
```
source=workflow, type=queued, ref_id=run-7, ts=1000
source=workflow, type=started, ref_id=run-7, ts=1001
source=workflow, type=completed, ref_id=run-7, ts=1005
```
为避免每次查活跃 workflow 都扫描全表,引擎维护一张 **物化表**,在写 log 的同一事务中 UPSERT:
```sql
CREATE TABLE workflow_runs (
run_id TEXT PRIMARY KEY,
workflow TEXT NOT NULL, -- workflow 名
status TEXT NOT NULL, -- 最新状态:queued, started, completed, failed, crashed
ts INTEGER NOT NULL -- 最新状态的时间戳
);
CREATE INDEX idx_workflow_runs_status ON workflow_runs(status);
```
写入流程(同一事务):
```sql
BEGIN;
INSERT INTO logs (source, type, ref_id, payload, ts) VALUES ('workflow', 'started', 'run-7', '{}', 1001);
INSERT OR REPLACE INTO workflow_runs (run_id, workflow, status, ts) VALUES ('run-7', 'alert', 'started', 1001);
COMMIT;
```
查询当前活跃 workflow 变为 O(活跃数):
```sql
SELECT * FROM workflow_runs WHERE status IN ('queued', 'started')
```
物化表是 logs 的派生数据——数据丢失时可从 logs 重建。logs 表仍是 source of truth。
进程重启时从 log 重建内存状态。运行时用内存 materialized view 进一步加速。
#### 冷归档
Engine 定期(cron 或内置 task)将超过 30 天的 log 和 signal 数据导出为按天 JSONL 文件归档:
```
data/
logs.db # 热数据(近 30 天)
archive/
logs/
2026-03-22.jsonl # 冷数据,按天归档
2026-03-23.jsonl
senses/
cpu-usage/
2026-03-22.jsonl
```
导出后从主库 DELETE + VACUUM。冷数据用 grep/jq 即可查询,不需要 SQL。
**水位标记**:归档进度记录在 meta 表中,确保任一步崩溃都能安全恢复:
```sql
CREATE TABLE meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- 归档水位:已成功归档到哪一天
-- key = "archived_up_to", value = "2026-03-22"
```
归档流程:
```
1. 读 meta.archived_up_to,确定从哪天开始
2. 导出该天数据到 JSONL(幂等:同一天重复导出会覆盖文件)
3. 同一事务:DELETE 该天数据 + UPDATE meta.archived_up_to
4. VACUUM(可选,非事务内)
```
任何一步崩溃,重启后从水位标记处继续,不会丢数据也不会重复删除。
### 5.5 热更新
主进程 watch `~/.uncaged-nerve/` 文件变化,按类型处理:
| 变化 | 处理 |
|------|------|
| sense ts 文件修改 | 等当前 compute 完成 → kill 对应 group worker → respawn |
| workflow ts 文件修改 | drain(等活跃 thread 完成,`drain_timeout` 后 force kill + 标记 crashed)→ respawn |
| nerve.yaml 修改 | 主进程重新解析,diff 变更(见下) |
nerve.yaml diff 处理:
| 变更 | 处理 |
|------|------|
| 新增 sense | spawn worker(或加入已有 group) |
| 删除 sense | 从 worker 中移除 |
| 修改 reflex | 更新 scheduler,不动 worker |
| 修改 throttle/timeout | 更新 scheduler |
| 修改 group | kill 旧 worker,按新分组 respawn |
### 5.6 错误处理
单个 sense 或 workflow 的失败不影响其他组件。
| 情况 | 处理 |
|------|------|
| compute 抛异常 | 写 log(source=system, type=error),下次触发重试 |
| compute 超时 | soft timeout → abort + 写 error log;grace_period 后 hard kill worker + respawn |
| 存储写入失败 | 写 error log,不发 signal(未成功产出) |
| nerve.yaml 语法错误 | daemon 拒绝加载,保持当前配置 |
| sense ts 语法错误 | 该 group worker 加载失败,其他 group 正常 |
| workflow worker 崩溃 | 幂等 thread 自动恢复,非幂等标记 crashed |
### 5.7 自监测
Daemon 用自己的 Sense 机制监测自身健康:
```typescript
// senses/nerve-health.ts
export async function compute(): Promise<NerveHealth | null> {
return {
uptime: process.uptime(),
activeSenses: scheduler.activeSenseCount(),
pendingComputes: scheduler.pendingCount(),
lastErrors: errorLog.recent(10),
memoryUsage: process.memoryUsage(),
}
}
```
但 daemon 自身挂了这个 sense 也不会跑,所以外层 systemd/pm2 是最后防线:
```
systemd (最后防线,只管进程存活)
└─ nerve-engine (自监测 + 丰富健康数据)
└─ nerve-health sense → 可产出 signal 通知外部
```
## 6. 函数式性质
用 Haskell 描述核心类型和函数:
```haskell
-- Sense 是多态的,compute 返回 Maybe
class Sense a where
type Payload a
compute :: IO (Maybe (Payload a)) -- Nothing → 静默,Just → 发 signal
-- Reflex 是纯数据,连接 Signal 与 Action
data Reflex = Reflex
{ condition :: ReflexCondition
, action :: ReflexAction
, enabled :: Bool
}
data ReflexCondition
= OnInterval Interval
| OnSignal [SenseId]
-- OnDemand 是引擎内置能力,不需要声明
data ReflexAction
= TriggerCompute SenseId -- 触发 Sense 重算
| StartWorkflow WorkflowId -- 启动 Workflow Thread
-- Reflex 构成 Event Mesh:Signal → ReflexCondition → ReflexAction
-- Workflow 内部
moderate :: Thread -> CommandEvent -> (RoleId, Prompt) -- 纯函数 ✅
execute :: Role -> Prompt -> IO CommandEvent -- 有副作用 ❌
```
核心的纯函数/副作用边界:
| 函数 | 纯/IO |
|------|-------|
| `compute` (Sense) | IO — 读世界或读存储,返回 Maybe |
| `moderate` (Workflow) | 纯 ✅ |
| `execute` (Role) | IO — 调 API、改文件 |
| Reflex 条件判断 | 纯数据,引擎硬编码 |
## 7. 依赖关系与生命周期
### 依赖图
```
Reflex ──→ Sense ──→ Sense (复合依赖)
└──→ Workflow (Reflex 声明触发)
```
### 软删除与级联
出于历史不可变性,不删除实体,只关停(`enabled: false`)。
| 操作 | 级联 |
|------|------|
| disable Sense | disable 依赖它的 Reflex + 依赖它的复合 Sense |
| disable Workflow | disable 触发它的 Reflex |
| disable Reflex | 无级联 |
清理是可选的离线 GC 过程。
## 8. 用户本地配置
用户的 nerve 实例位于 `~/.uncaged-nerve/`,作为一个 local git repo 管理:
```
~/.uncaged-nerve/
package.json # 依赖管理(含 drizzle-orm)
nerve.yaml # 主配置(含 reflexes)
senses/
cpu-usage/
schema.ts # Drizzle schema(single source of truth)
index.ts # compute 逻辑
migrations/ # drizzle-kit 自动生成
0001_init.sql
disk-usage/
schema.ts
index.ts
migrations/
0001_init.sql
active-tasks/
schema.ts
index.ts
migrations/
0001_init.sql
workflows/ # post-MVP
cleanup.ts
data/ # ⛔ gitignored
logs.db # 统一 log 存储(append-only)
senses/
cpu-usage.db # 每个 sense 独立的 sqlite
disk-usage.db
active-tasks.db
archive/ # 冷归档(>30天)
logs/
2026-03-22.jsonl
senses/
cpu-usage/
2026-03-22.jsonl
blobs/ # CAS blob store,sha256 寻址
ab/
cd1234...
node_modules/ # ⛔ gitignored
.gitignore
.git/
```
### 设计要点
- **local git repo** — 配置和 sense 逻辑可回滚,data 不进 git
- **package.json** — 标准 npm 包,`npm install` 管理依赖(含 `drizzle-orm``drizzle-kit` 为 devDependency)
- **nerve.yaml** — 单一配置入口,含 senses(运行时属性如 throttle)和 reflexes(触发条件)两个字段
- **senses/{name}/** — 每个 sense 一个目录,含 `schema.ts`(Drizzle schema)、`index.ts`(compute)、`migrations/`(自动生成)
- **data/senses/** — 每个 sense 一个 sqlite 文件,引擎启动时自动执行 migration
- **data/blobs/** — CAS(Content-Addressable Storage),sha256 前两位分片目录,sense 存大对象时写 blob 拿 hash,db 里只存 hash 引用
- **data/ 和 node_modules/ gitignored** — 只有逻辑和配置进版本控制
## 9. MVP 范围
| 范围 | 状态 |
|------|------|
| Sense 定义(compute + 独立存储) | ✅ MVP |
| Reflex 配置(nerve.yaml) | ✅ MVP |
| Nerve Daemon(读 yaml,按条件调 compute) | ✅ MVP |
| Workflow(Role + Moderator + Thread) | ❌ Post-MVP |
## 10. 设计原则
1. **Sense 是唯一的一等公民** — 原始采样和派生计算统一为 Sense,Sense 不知道下游
2. **计算与触发解耦** — compute 不知道自己什么时候被调用
3. **Reflex 是 Event Mesh** — 所有事件路由都声明在 Reflex 中,Sense 和 Workflow 之间无直连
4. **Log 是终点不是起点** — Log 是数据资产,Reflex 可读但不被 Log 触发,防止雪崩
5. **存储去中心化** — 每个 Sense 自管存储,Log 统一一张表,全部 append-only
6. **不删除只关停** — 历史不可变,生命周期通过 enabled 控制
7. **Workflow 是可选扩展** — MVP 不需要,后续按需加入
8. **引擎极简** — 只做调度,不做业务逻辑