Documentation
¶
Overview ¶
adapter.go —— Claude Code 语义到 executor.Adapter 契约的翻译层。
职责:
- 五动作编排:Start(物料 → socket → prochost 进程 → 等 init → 投首回合 prompt)、 Send(fifo 续接)、RespondPermission(裁决回发 socket)、Stop(kill + 收摊)、 Events(事件通道)
- stream 消息 → AdapterEvent 映射:init → progress(SessionID);text_delta → render.log + 节流 progress;result → trailer 分类(ask→question / finish→result / none→兜底 git 实况裁决);handoff_exit 哨兵 → 失败 result
- 权限:perm.sock 的 ask 回调 → permission 事件(PermissionID = 裸 tool_use_id)
边界:
- 不写 store、不做审批判断(executor 包级边界):会话 id 经事件交 manager 落库
- 不做状态机迁移:6 状态迁移全在 manager
- 不重试、不决策:解析宽容(未知消息 Debug 跳过、绝不 panic)
事件映射以 2026-08-08 真实采样与 2026-08-09 探针为准(claude 2.1.220/2.1.226):
- 文本载体:stream_event.event.content_block_delta.delta 的 text_delta 是模型 正文增量(thinking_delta 是思考过程,隔离不进 render.log 与回合文本,与 opencode 的 reasoning 隔离一致);assistant.message.content 的 text 块是整块 文本(render.log 已由 delta 写过则不重复追加,只做回合文本累积)
- 回合收尾:result.subtype=success 时 result.result 即最后一条 assistant 正文, 正是 turn.ParseTrailer 的输入;subtype!=success 时按失败处理
- 死亡:脚本在进程退出后往 out.jsonl 追加 handoff_exit 哨兵(带退出码), 随事件流天然送达本层,是本 adapter 唯一可靠的死亡信号
- 权限:完全走 socket 旁路(perm.go),out.jsonl 里那次 mcp__handoff__ask 的 tool_use 只当普通工具调用渲染,不产生 permission 事件(避免同一请求出两次)
perm.go —— 权限裁决 unix socket 服务端。
职责:
- 在任务目录的 perm.sock 上受理裁决请求(由 claude 拉起的 permission-mcp 进程发来)
- 把请求以回调交给 adapter(转成 AdapterEvent 进 manager 审批链)
- 收到裁决后回发给对应连接,放行或拒绝该次工具调用
边界:
- 不做任何审批判断:批不批由 manager 依协调者应答决定(executor 包级边界)
- 不认识 claude 的消息格式:只处理本文件定义的两条线协议
为什么用 unix socket 而不是 agentd 的 HTTP 口:被监管的 executor 不该拿到 agentd token;socket 文件落在任务目录内,由**目录本身的访问控制**构成边界, 且无需分配端口。
「访问控制」分平台:unix 上是任务目录的 0700 权限位;Windows 上没有 POSIX 权限位(socket 文件的 mode 恒显示为 Srw-rw-rw-),边界由任务目录的 NTFS ACL 继承提供。**这不是同一句话的两种说法**——换平台时要重新确认边界真的成立, 不能因为 unix 上验过就假定 Windows 上也成立。
AF_UNIX 在 Windows 10 1803+ / Server 2019+ 上原生可用,Go 的 net 包支持它 (src/net/unixsock_posix.go 的构建约束含 windows),B128 已在 Server 2025 上 跨进程实测 Listen/Dial/双向收发全通,且 Close() 会自动删除 socket 文件。
probe.go —— 只读存活探测。
职责:
- Probe:读恢复凭据,走 Proc.Alive 的既有判据,如实返回存活结论
边界:
- **绝不写**:不回收执行者进程、不占 runs 位、不碰 store、不发事件。 Resume 这三件事都做(判死后 Kill 是冷恢复不撞名的前置),正是它不能被 status 复用的原因
- 不重试、不做抖动吸收:一次探测一个结论。抖动误判的代价由调用方承担, 而 status 只读——误判的代价是输出里一行错话,不是一个被错判的任务
proc.go —— Claude Code 进程生命周期管理。
职责:
- 组 claude 的 argv(headless 双向流式),经 prochost 以 detached 方式拉起
- 经命名管道 in.fifo 投递指令;stdout 落 out.jsonl,stderr 落 claude.log
- 死亡判定(out.jsonl 末尾的 handoff_exit 哨兵 + 存活锁)与凭据持久化(proc.json)
边界:
- 不解析事件:out.jsonl 的解析在 stream.go
- 不做权限裁决:socket 服务端在 perm.go
- 不关心进程怎么脱离 agentd:那是 prochost 的事
为什么进程经 prochost 而不是 agentd 直接 fork:agentd 重启或崩溃时子进程 若未脱离会话会被一并回收,正在执行的任务无辜中断;prochost 的 shim 以新会话 拉起并持有存活锁,生命周期与 agentd 解耦——agentd 重启后靠 Alive() 探测重连。
reap.go —— 无内存运行态时的确定性兜底回收。
职责:
- Reap:按 proc.json 拿 prochost.Handle 并 Kill
边界:
- 不碰任务状态(adapter 不写 store);回收不掉只返回错误,留不留事件是 manager 的事
resume.go —— 执行恢复:热重连与冷恢复。
职责:
- Resume:按 ResumeReq 走恢复阶梯,返回实际走到的级别
边界:
- 不判断「该不该恢复」的业务前提(如是否有未决权限工单)——那需要工单知识, 属 manager(见 manager.go 的 volatilePermitter)
- 不改任务状态:adapter 不写 store(见 executor 包级边界)
spend.go —— claudecode 的**累计消耗**账目解析。
职责:把 result 行解析成一条 proto.SpendEntry(本轮新增的 token 与花费)。 边界:纯函数,不碰 runState、不发事件、不写日志——接线在 adapter.go。
与同目录 usage.go 的关系:usage.go 解「当前 context 占用」(assistant 消息, 最后一次调用的输入侧),本文件解「一共烧了多少」(result 行,逐轮累加)。 两个口径的公式不同且都容易写错,刻意分文件,不要合并。
stream.go —— out.jsonl 的增量解析(claude stream-json → 内部消息)。
职责:
- 从指定 offset 起持续读 out.jsonl 的新行,宽容解码为 streamMsg 交回调
- 维护已消费 offset,供 proc.json 持久化与 agentd 重启后续读
- 从 stream_event 中提取模型正文增量(只认 text_delta)
边界:
- 不映射 AdapterEvent、不碰 render.log:那是 adapter.go 的职责
- 不管进程死活:哨兵行原样交出,判定在 proc.go / adapter.go
为什么轮询文件而不是接管管道:进程由 shim 承载、stdout 落盘, agentd 重启后没有任何管道可继承,文件 + offset 是唯一能跨重启接续的形态。
taskenv.go —— Claude Code 任务环境物料生成。
职责:
- 生成任务级 settings.json(权限静态分级:ask 收窄危险面、allow 逐条白名单)
- 生成 mcp.json(把 handoff 内置裁决工具挂到本任务的 perm.sock 上)
- 渲染首回合 prompt(回合纪律模板复用 turn 共享包,两个 executor 同源)
边界:
- 不启动进程、不建 socket:进程在 proc.go,socket 服务端在 perm.go
- 不做权限判断:本文件只生成静态策略,运行期裁决全部经 perm.sock 交 manager
- settings.json 只放 permissions、**不含任何凭证**——凭证由 claude 自己经 `--setting-sources user` 从真实 `~/.claude/settings.json` 读取(2026-08-09 真机 e2e 实测:不带凭证 env 即跑通,见 spec §5.4);env 注入(B19)是给 代理/自定义 base_url 这类额外环境用的,不是鉴权必要条件。因此 settings.json 可以放心进日志/工单/diff
usage.go —— claudecode 的 token 用量与实际模型名解析。
职责:把 assistant 消息里的 model 与 usage 解析出来。 边界:纯函数,不碰 runState、不发事件、不写日志——接线在 adapter.go。
Index ¶
- func WriteTaskEnv(taskDir, taskID, planContent, sockPath, handoffBin, disciplineBlock string) (settingsPath, mcpPath, promptText string, err error)
- type Adapter
- func (a *Adapter) DenyReasonInBand() bool
- func (a *Adapter) Events(taskID string) <-chan executor.AdapterEvent
- func (a *Adapter) Probe(req executor.ProbeReq) (executor.ProbeOutcome, error)
- func (a *Adapter) ProcHandle(taskID, taskDir string) (prochost.Handle, error)
- func (a *Adapter) Reap(taskID, taskDir string) error
- func (a *Adapter) RespondPermission(ctx context.Context, taskID, permID, decision, reason string) (err error)
- func (a *Adapter) Resume(req executor.ResumeReq) (out executor.ResumeOutcome, err error)
- func (a *Adapter) Send(ctx context.Context, taskID, text string) (err error)
- func (a *Adapter) Start(ctx context.Context, req executor.StartReq) (err error)
- func (a *Adapter) Stop(taskID string) (err error)
- type Proc
- type StartProcReq
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func WriteTaskEnv ¶
func WriteTaskEnv(taskDir, taskID, planContent, sockPath, handoffBin, disciplineBlock string) (settingsPath, mcpPath, promptText string, err error)
WriteTaskEnv 在 taskDir 生成 Claude Code 的任务级物料并渲染首回合 prompt。
参数:
- taskDir: 任务工作目录(须已存在,由 manager 创建,0700)
- taskID: 任务 ID,写入 prompt 标题行
- planContent: 实现计划全文,原样嵌入 prompt
- sockPath: 本任务的权限裁决 socket 路径(perm.go 监听它)
- handoffBin: handoff 二进制绝对路径,作为裁决 MCP server 的启动命令
- disciplineBlock: 唯一来自 StartReq 的纪律块正文;taskenv 不自行解析
返回:
- settingsPath / mcpPath: 生成的两个配置文件路径
- promptText: 渲染后的首回合 prompt 原文(由 adapter 投递进 fifo)
- err: 渲染或写文件失败
注意:
- 重复调用幂等覆盖,Start 失败重试可安全重来
- 两个配置文件都是 0600:mcp.json 泄露 socket 路径即泄露裁决入口
Types ¶
type Adapter ¶
type Adapter struct {
// contains filtered or unexported fields
}
Adapter 是 claude 的 executor.Adapter 实现(语义翻译层)。
并发安全:runs 表由 mu 保护;每个任务的运行态(回合累积、事件通道)只被 该任务自己的 streamLoop goroutine 访问,不做跨任务共享。
func (*Adapter) DenyReasonInBand ¶ added in v0.3.0
DenyReasonInBand 表明本 adapter 把拒绝理由与裁决同帧送达模型。
返回恒为 true:理由进 permDecision.Message,经裁决 socket 回到 cmd/permission_mcp.go,再作为 tool_result 正文当场交给模型。
manager 据此跳过 B50 的带外挂起注入——两条路都走会让模型被同一条理由说两遍。
func (*Adapter) Events ¶
func (a *Adapter) Events(taskID string) <-chan executor.AdapterEvent
Events 返回任务的事件流通道(Start 后可用;Stop 或执行终结后关闭)。
注意(P1-11 同款):任务不在运行(未启动/已 Stop/运行态已随终结注销)时返回 **已关闭的通道**而非 nil——契约是「通道关闭 = 执行终结」,nil 会让 for-range 永久阻塞。
func (*Adapter) Probe ¶
Probe 只读探测 claude 执行器是否仍存活(manager 的 prober 可选接口)。
判据与 Resume 共用同一份 Proc.Alive:存活锁被持有 **且** out.jsonl 无 handoff_exit 哨兵,缺一即视为死亡。判据一旦分叉,status 说的和实际恢复行为 就是两回事。
参数:
- req: 探测请求(TaskDir 是 proc.json 所在,即 DataDir/tasks/<id>)
返回:
- Alive=true:执行器仍在,Note 为空
- Alive=false + Note:已判死,Note 是给协调者看的一句话理由
- err != nil:探不出结论(恢复凭据缺失/损坏),调用方按 unknown 处理, **不得当成 dead**
func (*Adapter) ProcHandle ¶
ProcHandle 交出该任务的进程句柄(来自任务目录的 proc.json)。
参数:
- taskID: 任务 ID,仅用于日志定位
- taskDir: 任务目录(凭据所在)
返回:
- 进程句柄;proc.json 不存在或不可解析时返回错误
注意:本方法**只读**,不探活、不发信号——存活判定与回收分别是 prochost.Alive 与 prochost.Sweep 的职责。agentd 以可选接口消费它 (不实现该方法的 adapter 一律按「无凭据」降级,与 reaper/prober 同款路数)。
func (*Adapter) Reap ¶
Reap 在没有内存运行态时按 proc.json 兜底回收 executor 侧资源。
回收顺序:读 proc.json 拿 Handle → prochost.Kill(内部先试锁,锁空闲直接成功)。
为什么不再有「确定性命名兜底」:旧实现在 proc.json 缺失时退到 tmux 会话名 handoff-<id8>,因为会话名可由 taskID 推导。锁+pid 无法从 taskID 推导, proc.json 缺失就是真的无据可查——如实报错交协调者,不猜。
返回:Handle 对应的进程本就不在时返回 nil——目标是「确保它没了」, 不是「确保我杀了它」。
func (*Adapter) RespondPermission ¶
func (a *Adapter) RespondPermission(ctx context.Context, taskID, permID, decision, reason string) (err error)
RespondPermission 把协调者的权限裁决回发到 perm.sock。
参数:
- permID: 与 permission 事件中的 PermissionID 一致(manager 还原后的裸 tool_use_id)
- decision: "once"(批准本次)或 "reject"(拒绝)
- reason: 协调者给出的拒绝理由;decision 为 reject 且理由非空时,经 permDecision.Message 与裁决同帧送达模型(见下方 DenyReasonInBand)
注意:
- decision 取值**除 once 外一律 deny**:不认识的裁决绝不当成放行 (与 manager 侧 gateDecision「allow 之外一律 reject」同一条纪律)
- 找不到挂起请求(进程已死 / 请求已被重试替换)时按 ErrTaskNotRunning 处置
func (*Adapter) Resume ¶
Resume 恢复 agentd 重启前已在执行的任务(manager 的 restorer 可选接口)。
存活判据(本 adapter 与 opencode 的关键差异):prochost 的存活锁**不够**—— claude 自己退出后 shim 会写哨兵再退出,锁与哨兵之间有一个极短窗口锁还在但 进程已死(opencode 靠 HTTP 探活兜住这一点,claude 没有这个面)。统一走 Proc.Alive():存活锁被持有 **且** out.jsonl 无 handoff_exit 哨兵,缺一即死。
参数:
- req: 恢复请求(TaskDir 是 proc.json 所在,即 DataDir/tasks/<id>; RepoPath 用于重启后重新捕获 git 兜底分类的起点 commit 基线; SessionID 是落库的 claude 会话 id)
返回:
- Alive=false 时调用方(manager)把任务转 failed 交协调者裁决(保守优于静默)
- err: 重建失败(proc.json 缺失/损坏、SessionID 为空),视为不可恢复
注意:
- 恢复出的运行态与 Start 对称:Stop(done 归档)对其同样有效
- 挂起权限不需要在此重建应答:MCP 子进程(claude 拉起的)会自行重连 perm.sock 重登记同一 tool_use_id,manager 侧 ticket 按 id 幂等去重
func (*Adapter) Send ¶
Send 往同一会话续发指令(fifo 续接:上下文完整保留)。
参数:
- text: 协调者的回答/修改指令,原样透传,不得加工
注意:
- 进程不在(fifo 无读端,O_NONBLOCK 打开失败)时包装 executor.ErrTaskNotRunning
func (*Adapter) Start ¶
Start 按「perm server → 任务物料 → prochost 进程 → 等 init → 投首回合 prompt → 会话就绪」流程启动任务执行并立即返回。
参数:
- ctx: 控制启动阶段的超时/取消(prochost 拉起、等 init 受其约束); 不代表执行生命周期(执行延续到 Stop)
- req: 任务快照、计划原文与任务工作目录
返回:
- 任一启动阶段失败返回错误,调用方(manager)应把任务标记 failed
注意:
- 已建资源(perm server / 执行者进程)在失败时逐级回滚,避免半启动残留
- req.Env 必须原样透传进 StartProc(B19):漏传编译照过、用户 env 静默失效
type Proc ¶
Proc 描述一个运行中的 claude 进程句柄。
字段说明:
- Handle: prochost 句柄(shim pid + 存活锁路径),存活与回收都靠它
- TaskDir: 任务目录(fifo/out.jsonl/claude.log/proc.json 都在其中)
- SessionID: claude --session-id(agentd 生成,写进 proc.json 供恢复)
func StartProc ¶
StartProc 备物料、经 prochost 拉起 shim 承载 claude,返回进程句柄。
参数:
- ctx: 保留以与 adapter 契约一致(当前未参与拉起,启动是子秒级操作); 执行生命周期延续到 Kill
- req: 进程完整入参(见 StartProcReq)
- log: 本模块日志入口(进程启动点,日志需要显式传入而非走默认)
返回:
- 已拉起的进程句柄;任一阶段失败返回错误(此时无残留进程)
注意:
- 就绪判定(等 system/init)由 adapter 在 stream 层完成,本函数只负责拉起
- in.fifo 必须先于进程存在:shim 第一件事是 O_RDWR 打开它, fifo 缺失会让整条 claude 命令失败
- startProcHost 返回后必须等 in.fifo 出现读者(WaitInputReader)才能返回: Start 只代表 shim 已 fork,写端不等到读端会 ENXIO(见 fifoReaderTimeout 的 why)
- startProcHost 成功之后的一切失败路径必须自行 Kill 回收:调用方 rollback 依赖 r.proc,而 StartProc 失败时返回 nil(见 WaitInputReader 失败处)
func (*Proc) Alive ¶
Alive 检查 claude 是否仍然存活:存活锁被持有 且 out.jsonl 无死亡哨兵。
为什么两条都要:锁只证明 shim 活着;claude 自己退出后 shim 会写哨兵再退出, 两者之间有一个极短窗口锁还在但 claude 已死,哨兵兜住它。 (旧实现这里的第一条是 tmux has-session,而第二窗口的 tail -f 会一直吊着会话, 导致 claude 早死了会话还在——换成锁之后这个假存活来源被连根拔掉。)
func (*Proc) WriteInput ¶
WriteInput 往输入通道投递一条 stream-json user message。
参数:
- text: 指令原文,原样透传不加工(executor 契约要求)
注意:
- 投递失败多半意味着执行者已不在(读端消失),调用方据此包装 executor.ErrTaskNotRunning。平台差异(unix 的 ENXIO / Windows 的 ERROR_FILE_NOT_FOUND)由 prochost 吸收,本层不感知
type StartProcReq ¶
type StartProcReq struct {
RepoPath string
TaskID string
TaskDir string
SessionID string
Model string
SettingsPath string
MCPPath string
Env []string
MarkRoot string
Resume bool
}
StartProcReq 是一次 StartProc 的完整入参。
字段说明:
- Env 来自 executor.StartReq.Env(manager 按任务执行者从 env 文件解析), 已解析已展开。**不读它会静默失效**:漏传编译照过、用户配的代理/密钥在 shim 里根本不出现——见 Global Constraints 与 proc_test 的钉子测试
- Resume=true 时启动命令用 --resume(载入既有会话)而非 --session-id (建一个这个 id 的新会话)。两者语义相反,写错的表现是「日志说恢复成功、 模型却什么都不记得」