Documentation
¶
Overview ¶
admission.go —— 开工前的进程余量准入闸。
职责:
- checkProcHeadroom:余量已耗尽时拒绝开工,并给出带数字的理由
边界:
- 不安装围栏、不回收进程:只做一次只读判读
- **不承担拦截职责**。真正拦住事故的是围栏(进程起不来),本闸换来的是 一句人能看懂的话,以及「A 任务吃满时 B 任务得到解释而不是莫名撞墙」。 2026-08-12 两个任务开工时余量都是好的,这个闸拦不住它们——别高估它
approver.go:权限请求的前置裁决——廉价模型 CLI 裁决。
职责:
- 在权限请求升级人工协调者之前,先做一级廉价分流:调用配置的廉价模型 执行者(opencode/claude 的 one-shot 模式)对权限请求做一次裁决, approve 则自动放行
- fail-closed:裁决命令失败 / 输出解析失败 / 超时 / decision 取值非法 一律按 escalate(升级人工协调者),绝不静默放行
边界:
- 无 deny 权——裁决出口只有 approve(自动放行)与 escalate(升级人工), 拒绝权限不是审批者的职权,只有协调者(人)能拒绝
- 不写 store、不碰 adapter、不做状态迁移——纯裁决计算;落库与回传 (工单/应答/事件)由 manager 完成
- 裁决输出的 nonce 防伪:权限原文来自被监管的 executor,不可信
2026-08-09(B23/B27)黑名单与截断判定已迁往 internal/permgate,本文件 不再持有规则表;Approver 退化为只做「调模型 + 解析裁决」。
本文件实现浏览器鉴权的中间件侧:cookie 会话的查找、有效性判定、滑动续期节流, 以及把身份传递给下游 handler 的 context 载体。
职责:
- 定义会话/ticket 的寿命常量与 cookie 名
- sessionFromRequest:按 cookie 查会话并判定有效性,同时给出失败原因
- refreshSession:按节流规则写回滑动续期与最后活跃时刻
- identity:一次请求的身份(主令牌 or 某个会话),供 auth 路由与 WS 复验使用
边界:
- 不签发任何凭据(那是 authroutes.go 的事)
- 不做 Host 校验(那是 hostguard.go 的事,且它先于本层执行)
- 不碰 TLS:Secure 属性的判据只能是 r.TLS,见 authroutes.go 的说明
本文件实现浏览器鉴权的五条路由:签发 ticket、兑换 cookie、列出会话、吊销会话、登出。
职责:
- POST /api/auth/tickets 主令牌签发一次性 ticket,返回可打开的 /console URL
- GET /console?ticket=<t> 原子消费 ticket → Set-Cookie → 302 到 /
- GET /api/auth/sessions 列出会话(含已吊销,供人判断)
- DELETE /api/auth/sessions/{id} 吊销指定会话
- POST /api/auth/logout 吊销当前 cookie 会话并清除 cookie
边界:
- **本文件不负责 / 上有什么**:302 的目标固定是 /,至于 / 返回什么由 server.go 挂在 / 上的 SPA handler(webui)决定。W5a 之后 / 伺服的是真实控制台; 不带 embedweb 标签构建时是一份说明用途的 stub 页。两种情况本文件都不需要知道, 也不得为了迁就其中一种去改 302 的目标
- 不判断 Host(hostguard.go 已在更外层做完),不做会话续期(auth.go 的事)
- 不读 X-Forwarded-Proto:cookie 的 Secure 与 URL 的 scheme 只按 r.TLS 判定, 因为上游可能是一台不可信中转,让它决定安全属性方向是反的
本文件实现任务分支的 git bundle 生成,供 GET /api/tasks/{id}/bundle 使用。
职责:
- 在任务的**主仓库**里按 <have>..<branch> 生成薄包(have 为空则全量),落 OS 临时文件
- 把「空区间」与「have 不存在」这两种预期形态与真故障区分成可判别的哨兵
边界:
- 不碰 HTTP:状态码映射在 server.go 的 handleTaskBundle
- 不删临时文件:调用方拿到路径后自己 defer os.Remove
- 只读仓库,不建分支、不改任何 ref
看板节点动作的 agentd 侧装配:把一张卡的工作流节点跑起来。
职责:
- 解析目标机(配置),装配 ledgerstep.StepRunner
- 守「同一张卡同时只允许一个环节在飞」
- 起 goroutine 异步执行,HTTP 侧立刻返回
边界:
- 不做编排——那在 internal/ledgerstep,CLI 与本文件共用同一份
- 不做恢复:在飞集合是进程内状态,agentd 重启即清空。此时卡上留下的是 一次没有终态事件的环节,与 CLI 跑到一半被 Ctrl-C 的形态一致,人从 timeline 看得出来。本轮不做恢复是刻意的取舍,不是遗漏
- 不做实现类派发:那要挂 plan 文件,浏览器里没有
本文件实现代码图的两个只读端点:整图数据与按 file:line 的源码窗口。
职责:
- handleProjectCodegraph: 一次性返回基线 + 全部视图 diff + 保鲜报告, 合并渲染在前端做(数据契约见 spec 2026-08-19-codegraph-design §3)
- handleProjectCodegraphSource: 详情面板「源码」区按 file:line 实时读
边界:
- 只读,不触发扫描、不写任何文件
- source 的路径校验是参数校验不是安全边界(同 workspacefiles.go 的论证): 挡打错的路径,不防有心人——控制台会话本就等价主令牌
本文件实现薄壳状态的中转:PUT/GET /api/desktop/state。
职责:把薄壳上报的自身状态(版本 + 本次开机同步的结论)在内存里持有一小段 时间,供控制台读取。
边界(承重):
- 只在内存,不落盘。壳没在跑就等于没有壳。落盘会让「上次开过桌面端」的 痕迹在纯浏览器会话里伪装成「现在有个壳」,渲染出一个点了没反应的按钮。
- 不解释内容。SyncPlan 的取值语义属于薄壳,这里不做校验、不做映射。
- 不含反向通道:薄壳只上报、不接指令(spec §5)。
discipline.go —— 控制台的纪律配置 HTTP 面(B157)。
职责:
- GET /api/discipline 列出内置两版、该机纪律块文件、每个 executor 的档位
- GET /api/discipline/file 读单个纪律块文件正文
- PUT /api/discipline/file 写单个纪律块文件(带前置哈希)
- PUT /api/discipline/mapping 整段替换该机的 discipline 配置段
边界:
- 文件判断力全在 internal/discipline(名字校验、大小上限、冲突判定), 本层只做 HTTP 编解码与错误映射,**中文错误原文原样透传**
- 跨机由 forwardIfRequested 处理(?machine=),本文件只管本机
- 不理解纪律内容;不碰任务与派发
env.go —— 控制台的 env 文件配置 HTTP 面(B158)。
职责:
- GET /api/env 列出该机 env 文件与每个 executor 的档位
- GET /api/env/file/keys 解析出的变量清单(**只有 key 名与值长度**)
- GET /api/env/file 读单个 env 文件正文(含值,仅编辑时调用)
- PUT /api/env/file 写单个 env 文件(前置哈希 + **写前解析校验**)
- PUT /api/env/mapping 整段替换该机的 env 配置段
边界:
- 文件判断力全在 internal/envfile(名字校验、大小上限、冲突判定),本层 只做 HTTP 编解码与错误映射,**中文错误原文原样透传**
- 跨机由 forwardIfRequested 处理(?machine=),本文件只管本机
- **任何路径都不得把 env 的值写进日志或响应**(正文读写端点除外——它就是 为编辑而存在的,见 spec §7 的诚实边界)
- 两档语义:键不存在 = 不注入;值为文件名 = 读该文件。**绝不写空串**
eventframes.go —— 把控制面事件派生成 frames.jsonl 里的 event 引用帧。
职责:
- 提供一个 store.SetEventHook 用的回调:事件落库后往该任务的 frames.jsonl 追加一条 event 帧(只存 seq 与类型名)
边界:
- 只写文件,**绝不回调 store**(会自我递归 / 争锁,见 SetEventHook 的约定)
- 不复制 payload:payload 的真相在 events 表,复制一份就有两份会漂移的真相
- 尽力而为:任务目录不在、写失败,都只 Warn,绝不影响已经成功的事件落库
为什么 event 帧要存在:帧流要能表达「模型说了这句 → 请求了权限 → 继续」的 **顺序**。事件与帧由同一进程写、走同一个 append 序,因此单流顺序即真实顺序; 若让前端拿事件流和帧流按时间戳归并,两条不同写入路径的时间戳会真的乱序。
为什么用 turn.WriterFor 而不是 turn.NewFrameWriter:adapter 在 Start 时已持有 该任务的 FrameWriter(r.frames)。若事件钩子每次自己 new 一个 writer,两个 实例各持一份内存 seq 写同一个 frames.jsonl,帧号会互相覆盖(落盘 1 2 3 3)。 WriterFor 按任务目录去重返回**同一个**实例,保证「一个任务目录一个 seq 分配者」。
executor_default.go —— 控制台配置机器级缺省执行者的 HTTP 面(B160)。
职责:
- GET /api/executor/default 该机的缺省执行者、它的默认模型、可选名单
- PUT /api/executor/default 保存这两项(整体替换)
边界:
- 只碰 config 的 executor 段。approver / proc_fence / listen 等机器级配置 一律不给写,理由逐条见 spec §1.2
- 跨机由 forwardIfRequested 处理(?machine=),本文件只管本机
- **Model 不校验**:agentd 不认识任何执行器的模型名单,没有可判据
- 落盘走 swapConf。**不要**为 Executor 补深拷:ExecutorConfig 是两个 string 的值类型,结构体浅拷即完整拷贝
Package agentd 的强制回收入口。
职责:把「停 executor → 判成败 → 成功才落 failed」这三步收成一个方法,供 watchdog 的硬上限档调用(B119 §2.3)。 边界:不删 worktree(那是 handoff stop 的人工决定,watchdog 自动触发不继承 它);不自己清扫(清扫挂在 transit 的终态分支上,见 manager.go 的 transit); 不做告警去重(边沿状态由调用方 watchdog 持有,见 scanTaskProcs)。
本文件实现 agentd → agentd 的请求转发基座。
职责:
- 判定一个请求是否要转发(显式 ?machine= / 按任务 id 路由都走这里的搬运)
- 原样搬运:方法、路径、请求体、查询参数(去掉路由用的 machine)
- 原样回送:状态码、Content-Type、X-Handoff-* 响应头、响应体一律不改写, 响应体按上游块边界及时回送
- 防环:转发请求带 X-Handoff-Forwarded: 1,带此头的请求一律本机处理
边界:
- 不解释业务语义:登记契约由目标机器解释,本机只做搬运,不加校验也不加解释
- 不做凭据转换:token 用 cfg.Targets 里现成的(与 CLI --target 同源同凭据, 信任模型零新增)。**token 绝不进日志**
- 不自己选路:传输一律取自 s.pool(targetclient)——relay 机器没有 addr, 拿 t.Addr 直连构造会退化成 "http:///..."(no Host),选路判据只在池里有一份
- 不做重试:转发失败即如实回 502 带原文。重试会让「已登记成功但响应丢了」 变成重复登记
本文件实现 WebSocket 的跨机反代:`forwardTo` 的 WS 孪生。
职责:
- 按 ?machine= 找到 target,拨它的同路径 WS 端点(带 Bearer 与防环头)
- 两条连接双向对拷,**保持帧类型**(binary 仍是 binary)
- 关闭码与原因双向传播
边界:
- **不解析帧内容**。它不知道 PTY、不知道 JSON,只搬帧
- 一跳封顶:出站带 X-Handoff-Forwarded,对端 handlePtyWS 因此不再反代
- 不重试。与 forwardTo 一致:一次失败就 502 带原文,让调用方决定
为什么不让浏览器直连远端 agentd:cookie 是 host-only 的,远端那台没有本机 这份会话,等于要另做一套跨机 ticket 分发与跨域处理。「本机 agentd 是唯一 入口」本来就是既有模型(/api/workspaces/* 的 ?machine= 转发即此)。
frames_stream.go —— 结构化回合帧(frames.jsonl)的流式读取接口。
职责:
- 按 offset / tail 截取 frames.jsonl 并写出;follow=1 时持续追送增量
- **只在完整行边界切**:ndjson 的消费方按行解析,半行会让它解析失败
- 通过响应头告知客户端当前文件大小,供断线续传对齐
边界:
- 不解析帧内容:本文件只认换行符,不认 JSON 里有什么
- 不做轮转/清理:frames.jsonl 随任务目录走
- 不是事件流:控制面事件走 /ws/events,本接口服务「回合过程的完整复现」
与 render_stream.go 的关系:形态刻意照抄(同样的参数语义、轮询间隔、心跳、 文件不存在返回 200 空内容),唯一的实质差异是行边界对齐。两者共用 renderStartOffset / copyFrom / renderPollInterval / renderHeartbeat, 避免两份会漂移的偏移语义。
本文件实现目录列举的「被 .gitignore 排除」标注。
职责:给一层已列举出的条目盖 Ignored 标记,判据是 git 自己的 check-ignore。
边界:
- **不自己解析 .gitignore**。忽略规则有全局配置、仓库 .gitignore、 .git/info/exclude、目录级 .gitignore、否定规则(!)与优先级,自己写一份 必然与 git 分叉——分叉的方向还很坏:把源码标成垃圾,或把垃圾标成源码
- **查不出来就全部按未忽略**(fail open):git 不在、目录不是仓库、超时, 都只打日志。少标一个标记只是少一点信息,标错则是主动误导
- 不改变条目顺序、不过滤条目:藏不藏由前端决定,服务端只如实报告
本文件负责一件事:把「cwd 落在哪」归并成「项目在这台机器上的那一个位置」。
职责:
- MainWorktreeRoot:无论调用点在主仓、主仓子目录、linked worktree 还是 worktree 子目录,一律返回**主工作树的根目录**
边界:
- 不读登记表、不碰数据库:它只回答「这个目录属于哪个仓库根」
- 不判断该仓库有没有 origin:那是登记层 projectOriginURL 的事
- 不做 symlink 求真(不 EvalSymlinks):返回值来自 git 自己的输出与调用方 给的目录,保持与 git 一致的视角
为什么必须归并(B62):项目位置表以 project_id 为主键,一个项目在一台机器上 只能有一行。本仓库当前有十几个 linked worktree,它们与主仓 origin 相同、 project_id 相同——不归并就会撞主键,允许多行则项目树彻底没法看。
本文件实现 Host 白名单中间件:在鉴权**之前**拒绝 Host 头不在白名单内的请求。
职责:
- 取 r.Host 的 host 部分(忽略端口)与白名单比对,不匹配即 403
- 白名单 = 回环三件套 + cfg.Listen 的 host(通配地址除外)+ cfg.Web.AllowedHosts
- **监听通配地址时,本机网卡的非回环 IP**(B104)
- 拒绝时打 Warn 并记 Host 与来源地址——这是 DNS rebinding 攻击的唯一信号
边界:
- 不做任何身份判断(那是 auth 中间件的事)。本层先于鉴权执行正是为了不让 攻击者从「凭据对不对」的状态码差异里读出信息
- **挡不住本机恶意进程**:进程可以伪造任意 Host 头,那一层由凭据兜住。 两层各司其职,不要指望其中任何一层单独成立
Package agentd 是 handoff agentd 服务的进程内实时路由层。
职责:
- 按 taskID 维度做事件实时扇出(Subscribe/Publish/Watchers),供 HTTP/WS 层推送
- 提供 ticket 应答的一次性等待/通知路由(WaitAnswer/NotifyAnswer)
边界:
- 不做持久化:事件落库在 store,可靠性由 events 表 seq + cursor 承担, 本层只做实时扇出,不保证送达(慢订阅者直接丢弃),历史回放由 server 层用 store.EventsFromAsc 拼接
- 不参与业务决策(状态迁移、审批),仅提供进程内路由原语
killmode.go —— systemd KillMode 的启动期自检。
职责:
- 判断 agentd 是否运行在 systemd unit 下;若是,读取其 KillMode
- KillMode 非 process 时打 WARN,提示执行者会随 agentd 重启一并被杀
边界:
- 只提示不阻断:用户可能有意用 control-group(例如希望重启即清场)
- 不修改任何配置:改 unit 是部署侧的事,agentd 无权也不应代劳
- 非 Linux / 非 systemd 环境一律静默:macOS 与 docker 下报这个警告是纯噪声
为什么这件事必须提示:拆掉 tmux 后,「执行者活过 agentd 重启」依赖 shim 脱离 agentd 的进程树。setsid 做到了会话与进程组的脱离,但**改不了 cgroup 归属** ——cgroup 由 fork 继承。systemd 默认 KillMode=control-group 会在 restart 时 向整个 cgroup 发信号,shim 与执行者一并被杀,目标①直接落空。 这不是本次改动引入的退化:tmux 时代同样如此(tmux server 若由 agentd 首次 拉起也在同一 cgroup 里),只是从没被显式说明过。
账本 HTTP API:web 看板的唯一账本通道。薄层——业务全在 internal/ledger,此处只做解码/调用/编码与错误翻译。写动作: move/note/answer/accept 同步返回;step(工作流节点)异步 202, 编排在 internal/ledgerstep。实现类派发仍只由 CLI 承载——它要挂 plan 文件, 浏览器里没有那个文件。
lock.go —— agentd 的 DataDir 单实例锁。
职责:
- AcquireDataDirLock:对 <DataDir>/agentd.lock 取非阻塞独占文件锁, 保证一个数据目录同时只被一个 agentd 接管
- 撞锁时给出可行动的错误(指向 handoff status),而不是一句「失败」
边界:
- 不做仓库级互斥:agentd 不是 repo-scoped,proto.Task.RepoPath 是每任务 字段,启动时没有「仓库」这个键可锁
- 不管陈旧锁:flock 由内核在进程终止时释放(正常退出/panic/SIGKILL/掉电 皆然),因此不写 PID、不做进程探活、不提供 --force 逃生口
- 不跨机器:flock 是本机语义,两台机器各跑各的 agentd 是 handoff 的正常形态
- 加锁原语与错误语义在 internal/prochost(B34 的 flock_unix.go / flock_other.go 上移而来,全项目只保留这一份实现),本文件只做日志与文案
本文件实现开发机的增删:校验、写时复制落盘。
职责:
- 校验新增请求(名字、地址、令牌)并判重名
- 把改动落进配置文件(经 Server.swapConf)
边界:
- **不做 HTTP 编解码**:状态码与响应体由 machines.go 的 handler 决定
- **不做网络探测**:可达性探测是 I/O,属 handler 层,本文件保持纯逻辑
- **不建表**:机器的真相仍是 config.yaml 的 targets 段(同 machines.go)
本文件实现机器投影与探活:GET /api/machines。
职责:
- 把 cfg.Targets + 本机自身投影成一张机器列表
- 并发探活(每台打一次 GET /api/status),共 machineProbeBudget 预算
边界:
- **不建表**:~/.handoff/config.yaml 的 targets 段已经是机器的真相 (addr/user/token),再建表就是两份真相——改了配置忘了改表,就会有 一台早已删除的机器永远躺在列表里
- 只读:执行者开关、审批器配置、重启 agent 等写操作明确不在范围内
- 不 ssh:探活走 HTTP,与 CLI 的 handoff status 同源同凭据
本文件实现 POST /api/machines/{name}/upgrade:把一台远端执行机升到最新。
边界(承重):
- **不重写编排。** 七种结论、两道闸、pull/push 择路、等新版本上线全在 internal/upgrade 里,与 handoff upgrade 共用同一份。这里只做三件事: 探一台机器、把结论翻成 HTTP 状态码、把动作丢进后台
- **不处理本机。** 本机 agentd 的版本由薄壳的同步路决定(spec §6.5); 在这里再开一个入口就是第二条换版路径,两条会打架
- **不造进度流。** 中途阶段只进日志。但**终态必须可查**:GET /api/machines 里那台的 upgrade 段带着最近一次的结论。原先只说「判据是 version 变了」, 那只覆盖成功;失败时版本不会变,控制台就永远停在「升级中」(真机实测)
本文件实现 handoff 的状态机中枢与 adapter 事件中介(系统的心脏)。
职责:
- 任务生命周期的唯一状态写入者:dispatch(pending→running)、permission/question 中介(→waiting_answer→running)、result(→waiting_review)、continue/done(→running/completed)
- 把 executor 的 AdapterEvent 中介为「ticket + 事件 + 状态迁移」三件套: permission/question 落 ticket 并挂到 hub.WaitAnswer 等协调者应答, progress 只入库,result 落 completed/failed 事件进 waiting_review
- 协调者应答经 reply 回程(server)→ NotifyAnswer 唤醒等待 goroutine → 回传 executor
- stop:协调者主动中止任务(停 executor、作废工单(随终态迁移收口完成)、落 failed)
中介时序与工单幂等的不变量(P1-2/P1-6/P1-7):
- 权限工单 id = taskID+":"+permID(按任务命名空间隔离,跨任务 permID 碰撞不吞工单); question 工单 id 为 uuid。reply 路由整链(事件 payload / hub 等待键 / 应答回程) 都用工单 id,只有回传 adapter 时还原裸 permID
- 中介顺序为「落库 → 置 waiting_answer → 启动 waiter goroutine → Publish」: 状态先就位(reply 回程 resumeIfIdle 的回迁判定依赖它);waiter 的 hub 注册 是异步的,reply 先于注册到达时退化为「无等待者 → 自愈中继」路径兜底 (详见 handlePermission 的顺序契约)
- CreateTicket 返回 created=false(重放)时跳过全部后续动作,幂等是完整的
边界:
- 不做审批判断:「allow 之外一律 reject」「回答原样透传」是仅有的两条翻译规则, 批不批、答什么由协调者(人/上层)决定
- 不直接接触 executor 进程/会话细节,一切经由 executor.Adapter 契约
- 不负责看门狗(Task 12)等横向能力;git 工作区操作委托 workspace 包(Task 9)
- dispatch 前经 workspace.PrepareWorkspace 准备任务工作区(分支×worktree, 脏工作区/非法参数拒绝),其余 git 操作(diff/fetch/run)由 server 路由直接调用 workspace 包
「失败也进 waiting_review」的 why:
失败的 result 同样进 waiting_review 而非自动重试或直接 failed——让协调者看到 失败现场(failed 事件携带原因)决定重试话术;自动重试会在同一坑里反复烧 token 且无人知情,failed 终态又让任务失去续接入口。人工裁决是唯一解。
状态迁移并发安全:
- 全部迁移经 store.UpdateTaskState 的 CAS(WHERE state=旧值)落库,双写者 只有一个赢家;reply 回程的 resumeIfIdle 与应答 goroutine 的回迁 race 由 CAS + transitBestEffort(容忍 ErrBadTransit)吸收
manualworktree.go —— 手工工作树:不属于任何任务的 git worktree。
职责:
- 校验建树请求(模式/分支名/基线/占用/落点)并给出可判别的拒绝理由
- 在 <DataDir>/worktrees/manual/<分支名安全化> 下建树
- 回读一次项目树口径的 proto.Workspace 交给调用方
边界:
- **不认识任务**:不落库、不发事件、不参与回收。它建出来的树没有任何自动 清理路径(本期不做删除入口,见 spec §8),这是自觉的取舍不是遗漏
- 不复用 PrepareWorkspace:那条路径要求 task_id 且按 id8 命名目录,语义是 「为一次派发准备工作区」;本文件的语义是「人手开一棵树」,共用只会让两边 的参数互相污染。二者共用的只有 gitRun 与参数注入防线
- 失败清理只用 os.Remove(只删空目录),**绝不使用递归删除**:落点里一旦有 内容,那就是用户的东西,宁可留残骸也不能替他删
本文件实现远端任务的事件镜像:让浏览器永远只连本机一条 WS,也能看到 远端任务的实时事件。
职责:
- discovery loop:每 mirrorDiscoveryTick 对每台 target 拉一次任务列表, 给活跃任务开上游订阅,给终态任务收掉订阅
- 上游订阅:从本机水位续拉,事件写进 mirror_events 并 Publish 进本机 Hub
- 快照刷新:收到事件即防抖刷新该 target 的任务快照(事件即门铃)
边界:
- 不改派发链路:dispatch --target 仍是 CLI 直拨远端,本机 agentd 不知情; 镜像因此挂在**发现**上而不是派发上
- 不推导状态:任务状态一律来自远端的 GET /api/tasks,本机不拿事件重算 状态机(那是重新实现一遍状态机,两份必然漂移)
- 不改 CLI wait:--target 直拨照旧。镜像跑稳后再谈让 wait 走本机
- 副本不是真相:整表删掉可从 from_seq=0 重建
opennonblock_unix.go —— 打开审阅文件时的 O_NONBLOCK 标志(unix 实现)。
职责:给 ReadFile 提供平台相关的「非阻塞打开」标志。
边界:只声明常量,不含任何逻辑。
本文件是 agentd 侧「项目 × 本机位置」的操作层。
职责:
- RegisterProject:登记本机已有的一份代码,或先 clone 再登记
- ListProjects:列出位置,并**现场探测**每条的实际状态(漂移可见化)
- UnregisterProject:注销位置(只删记录,不动磁盘)
边界:
- 不做解析:派发时「这个请求指哪个项目」由 projectresolve.go 的纯函数决定
- 不做持久化细节:SQL 在 internal/store/projects.go
- 不算 project_id:那是 internal/projectid 的纯函数
- 不删磁盘上的仓库:注销只影响登记,磁盘由人自己处置
- clone 在**本机**执行(agentd 就跑在这台机器上),不走 ssh—— 用的是这台机器自己的 git 凭据
本文件实现项目树的跨机汇总:GET /api/projects/tree?scope=all。
职责:
- 对每台 target 现场取它的**单机**树,按 project_id 与本机的树合并
- 给每台机器的 location 盖上 machine 名
- 无论成败,每台机器都在响应的 machines 里留一行
边界:
- 现场扇出(不读缓存):项目登记是低频操作,实时性换简单。任务列表的 scope=all 走的是镜像快照(见 tasksfanout),两者刻意不同
- 一跳封顶:扇出请求带 X-Handoff-Forwarded,对端不再扇出
- 单台失败不影响整体:整体恒 200
本文件实现任务的项目归属 join:task.repo_path → project_locations.path → 该行的 project_id。
职责:
- 把位置表压成一张 path → project_id 的等值索引
- 给任务盖上 ProjectID 注解(线字段,不入库)
边界:
- 不加库列:tasks 表**不加** project_id 列。历史任务或已注销项目的任务 应当诚实显示「未归属」,而不是一列陈旧数据说谎;加列的代价(回填 + 注销后列失真)只换来微不足道的查询加速
- 不做模糊匹配:B62 的 MainWorktreeRoot 归并让 repo_path 与 project_locations.path 同源同形态,一次 filepath.Clean 后等值比即可。 早前设想的「先比 location 再逐个比 workspace」两段式已整个去掉
本文件是项目解析的**纯逻辑**层:把「派发请求指的是哪个项目」翻译成 「executor 应该在本机的哪个目录工作」。
职责:
- resolveProject:按 project_id / project_name 在位置表里查出那一行
- locationLines:把位置表压成人可读的清单,供拒绝报文使用
边界:
- 不碰数据库:位置行由调用方查好后以切片传入
- 不碰 git、不碰文件系统:路径是否真的可用由 EnsureRepoUsable 另行判定
- 不碰 HTTP:错误只用哨兵表达,状态码映射在 server.go
- **不接受任何路径入参**:调用方描述「代码在这台机器的哪个目录」正是 B62 要根除的漏洞(spec §1.2)。路径由本机查表得出,别人不许指定
为什么单独成文件且刻意保持纯净:这段规则是 dispatch 的必经之路,一旦错了 就会把任务派到错误的项目上。纯函数才能表驱动穷举。
本文件实现项目树:把扁平的位置表折成 project → location → workspace 三层, 并对每个 location 现场探测工作树。
职责:
- buildLocalTree:本机那一棵(GET /api/projects/tree)
- handleProjectTree:端点入口,含 ?scope=all 的分流(汇总实现见 projectfanout.go)
边界:
- 不改 B62 的 GET /api/projects:那条端点返回的就是位置表本身,语义本分。 项目树是另一种表示(嵌套、带探测、可跨机),塞进同一个端点会让一个端点 有两种响应形状,因此另开子路径
- spec §5.3 提到的 “projects?scope=all” 落在**本端点**上:§3 明文 「W3a 不动 /api/projects 那三条」,扁平端点保持单机
- 不写库:树是读出来的,一个字节都不落盘
本文件实现 PTY 终端会话的 HTTP 接口层。
职责:
- REST 三个端点:列会话(含 ?scope=all 扇出)、建会话、显式关会话
- 把 base_path / base_kind 归一化成 ptyhost.OpenOptions
- 组装会话环境:基础环境 + env_forward 解析结果
边界:
- **不持有任何会话状态**,全部转交 s.pty(internal/ptyhost)
- 不认识 PTY 的平台细节;平台不支持时只负责把 ErrNotSupported 翻成 501
- WS 数据通道在 pty_ws.go,反代在 forward_ws.go
本文件实现 /ws/pty:浏览器与 PTY 会话之间的双向字节通道。
职责:
- 建连:按 session/since 订阅 ptyhost,首帧回 attached(含 since 与 truncated)
- 下行:binary 帧搬 PTY 原始字节,text 帧搬 exit / error 控制信息
- 上行:binary 帧是用户按键,text 帧是 resize
边界:
- 不持有会话状态,全部转交 s.pty
- **断开只 detach,不杀会话**(spec §3.2):关页面、切设备、网络抖动 都走这条路,杀会话只有 DELETE 一条
- machine != "" 的远程会话不落在本文件,走 forward_ws.go 的反代
ptyreclaim.go —— agentd 启动时认领既有 PTY 会话,显式退出时收口。
职责:
- reclaimPtySessions:扫描会话目录,活的登记进 Host,死的清掉,坏的留给人
- shutdownPtySessions:service stop 时显式向全部会话发送 kill
边界:
- 启动时不连接任何 socket,只扫目录与试锁;PTY 字节由浏览器真正订阅时才转发
- 不解释 broken:目录留着、进程不动,只记 Error 让人处理
- 不参与 agentd 崩溃或升级重启;没有显式 stop 意图时不会杀会话
为什么显式 stop 一起停而崩溃/升级不停:显式 stop 的入口会调用 ShutdownPtySessions;信号关停与进程内 Trigger(包括升级换版)只取消后台扫描, 让 ptyhost 跨 agentd 生命周期继续持有会话。两类意图不能共用一个会杀会话的 cleanup。
pullstate.go —— 自拉换版的内存状态:并发锁 + 阶段流转 + 快照。
职责:
- 保证同一时刻只有一个自拉在跑(begin 抢锁)
- 记录阶段与失败原文,供 /api/status 回报
边界:
- **只在内存,不落盘**:成功路径的终点是进程重启,状态随之消失,而那时 版本号已经变了、调用方靠它就能确认;一个落盘的 done 会在下次启动时 变成误导性的陈旧数据。失败时进程不重启,内存态正好可查
- 不做下载、不碰文件:它只记「现在到哪一步了」,动作在 update.go
- 不做超时:自拉的总时限由 Installer 的 HTTP 超时兜底
- 本文件不打日志:它是状态容器,日志由动作侧(update.go)打
reclaim.go —— 终态任务 managed worktree 的判定与回收。
职责:
- 从 git worktree list --porcelain 拿地面真相,判定工作树四态 (净 / 脏 / 元数据残留 / 不在册),仓库不可达时如实报判不出
- 对单个终态任务执行回收(git worktree remove,脏树需显式 force)
边界:
- **纯资源动作**:不改任务状态、不追加状态迁移事件、不发唤醒
- 不删任务分支(协调者的工作成果),不删任务目录(失败任务的排查素材)
- 不读 worktree_managed 判断「现在还在不在」——该字段删成功从不回写, 只用于判断「这个任务当初是不是 managed 模式」
- 本文件的解析类函数(parseWorktreeList / parsePorcelainStatus / canonPath / findEntry)是纯函数,刻意不打日志;可观测性由调用方(classifyWorktree / Reclaim / ReclaimList)在关键节点承担
reconcile.go —— 任务运行态与 executor 实际存活性的对账。
职责:
- reconcileExecutorGone:「executor 已不在」这一事实的唯一收尾实现, 三个到达口(启动探活 / 事件通道关闭 / 协调者动作撞上失配)共用
边界:
- 不探活:本文件只负责「已经知道 executor 没了之后怎么办」, 「怎么知道的」属各到达口自己(spec §2.2 明确不加周期性探活)
- 不碰 adapter:收尾只动 store 与 hub
render_stream.go —— 任务实况(render.log)的流式读取接口。
职责:
- 按 offset / tail 截取 render.log 并写出;follow=1 时持续追送增量
- 通过响应头告知客户端当前文件大小,供断线续传对齐
边界:
- 不解析内容:render.log 是模型回合文本的原样增量,本文件只做字节搬运
- 不做轮转/清理:render.log 随任务目录在归档时一起走
- 不是事件流:结构化事件走 /ws/events,本接口只服务「人要看的实况」
为什么用轮询而不是 fsnotify:单文件、1s 粒度、任务数量级在个位数, 轮询 stat 的成本可以忽略;换 fsnotify 要多一个依赖和一套跨平台差异, 而它换来的延迟改善对「人在看文本」这个场景毫无意义。
本文件实现「在访达中显示」(Reveal in Finder,B108):平台能力位与 POST /api/workspaces/reveal 端点。
职责:
- 报告本平台是否支持这个动作(经 /api/status 与 /api/machines 上报)
- 校验「调用方在本机、路径在工作树内」后执行 `open -R`
边界:
- **不接 ?machine= 转发**。转发正是这个端点要拒绝的那件事——在别人的 机器上弹一个没人看的 Finder 窗口。理由见 spec §3.2
- 不做任何写操作;这是一个只读的、给人看的动作
runshell.go —— handoff run 的 shell 解析。
职责:为 RunCmd 选出执行 `-c <cmdline>` 的 shell 可执行文件路径。
边界:
- 只做解析,不执行、不拼参数(那是 RunCmd 的事)
- 不做 shell 方言转换:本文件的全部意义就是让协调者写的 unix 风格命令 在两个平台上是同一句话
本文件实现 agentd 对外唯一的网络入口:HTTP API(任务列表/详情/reply/审阅命令)与 WS 事件流。
职责:
- 对全部 /api 与 /ws 路由做 Bearer token 鉴权
- 提供任务查询(attach 数据源:任务 + 待办工单 + 最近事件)
- 实现 reply 唤醒闭环的回程:AnswerTicket → NotifyAnswer(无等待者时经 manager RelayAnswer 自愈中继)→ 无其余待办工单时状态回迁 running
- 提供三条审阅命令路由(diff/fetch/run):调 workspace 包取任务仓库的 审阅素材(git diff、文件内容、远程跑测试/lint),run 不走审批门
- /ws/events 先订阅 hub 实时流,再补发 store 中 seq>n 的历史事件(重放期间实时 事件经排空器收集、按 seq 归并去重),窗口期事件不丢不重
边界:
- 不创建 ticket:ticket 由 manager(Task 8)把 adapter 事件中介成 ticket 后落库,本层只回答
- 不启动 executor、不执行任务;状态迁移仅限 reply 触发的 waiting_answer → running 回迁
- 审阅路由只读任务仓库(diff/fetch),run 的命令执行也限时回收,绝不经此写仓库
- 实时流不保证每条事件都送达:事件不丢不重由 store 的 seq + 客户端自存 cursor(无需 ack)承担, 掉线期间产生的事件由客户端携带更大 from_seq 重连补拉
shutdown.go —— agentd 的优雅关停协调。
职责:
- 汇合两种停机意图:进程信号(SIGINT/SIGTERM)与进程内触发(Shutdown.Trigger)
- 收到意图后停止接受新连接、给在途请求一段收尾时间、跑调用方给的清理闭包
- 用返回值表达退出码约定:优雅关停返回 nil(→ exit 0),监听失败原样返回(→ exit 1)
边界:
- 不知道**为什么**要停:信号也好、自更新换版也好,本文件一视同仁
- 不关数据库、不释放锁:那些是调用方在 cleanup 闭包里做的事,顺序由调用方定
- 不负责重启:把进程拉回来是 systemd / launchd 的职责
为什么 exit 0 这件事必须写在这里而不是留给调用方:自更新换版的整条链 (下载 → 替换 → 退出 → 管理器拉起新版)唯一的交接点就是退出码。systemd 的 Restart=on-failure 在 exit 0 时**不会**重启——那样服务会在换版后无声消失。 本期把 deploy 模板改成 Restart=always 正是为此。
status.go —— GET /api/status 的服务端聚合。
职责:
- Manager.Status:把版本、配置、任务计数、非终结任务的存活结论聚成一个响应
- 带时限地逐个探活(prober 可选接口),三态如实返回
边界:
- **只读**:不改任务状态、不发事件、不回收任何 executor 资源。发现失配只 报告,修复归 continue/stop 那条既有路径(见 spec §1.4「不兼做恢复」)
- 不做周期性探活:本文件只在有人调 status 时才跑,与 Spec A §2.2 「不新增周期性探活」不冲突——那条拒绝的是后台定时扫
本文件实现 GET /api/tasks/{id}/plan:交出派发当刻给 executor 的指令原文。
为什么需要它:派发内容此前只以两种形态存在——磁盘上任务目录里的那份文件, 和库里截断过的 plan_summary。控制台因此答不出「这个任务当初被要求做什么」, 而这恰恰是审阅一个回合时最先要对照的东西。
边界:
- 只读**归档在任务目录里的那一份**(Task.PlanPath),不读仓库里的原始 plan 文件:后者可能已被改写或删除,而审阅要对照的是「当时发下去的那份」
- 不做渲染、不切段:原文进原文出,markdown 怎么显示是前端的事
- 跨机由 byTask 中间件透明转发(路由注册在 byTask 之下),本函数只管本机
本文件实现「按任务 id 的透明路由」:任务 id 是 UUID、全网唯一,因此 /api/tasks/{id}/... 不需要调用方指定机器。
判定顺序(§5.1):
- 本机 tasks 表有该 id → 本机处理(现状不变)
- 否则查镜像索引 mirror_tasks 得所属机器 → 原样转发
- 两处都没有 → 交给本机处理,由它给出与今天一致的 404
边界:
- 不改任何被包住的 handler 的行为
- 带 X-Handoff-Forwarded 的请求一律本机处理(防环优先于路由)
- 不缓存判定结果:一次本机主键查询的成本远低于「任务刚归档但缓存说它在 远端」这类失真
本文件实现任务列表的跨机汇总:GET /api/tasks?scope=all。
职责:
- 把本机任务与 mirror_tasks 里的远端快照合并成 §5.3 的信封
- 给每台机器留一行 MachineStatus(快照的 fetched_at 即数据新旧)
边界:
- **不现场扇出**:看板 2.5s 轮询打的是本机,快慢与远端可达性解耦。 远端部分取自镜像快照(§6.3),刷新由镜像的「事件即门铃 + 30s 慢对账」 负责——这是本设计里「看板不被远端抖动波及」的兑现处
- 不改无参数时的响应形状:裸数组 []TaskView,与 W2 契约逐字节一致
本文件实现「工单作废 + 留痕」这一个动作(B63)。
职责:
- 把一个任务的全部未回答工单作废,并按需产出一条 tickets_voided 审计事件
- 作为 transit 终态分支与 reconcileExecutorGone 的唯一共用实现
边界:
- 不判断「该不该作废」——时机由调用方决定(终态 / executor 已死)
- 不 Publish:tickets_voided 是纯审计事件,实时流上不出现(见 proto 常量注释)
- 不因作废或写事件失败而中断调用方:状态迁移已经发生,为一条审计写失败回滚 终态得不偿失
update.go —— POST /api/update:接收推来的二进制,换版并触发重启。
职责:
- 复检两道闸(活跃任务 / 非托管),拒绝时给出可判别的 reason
- 校验 sha256、解包、自检、原子换版并保留 .prev
- 换版成功后触发优雅关停,由进程管理器拉起新二进制
边界:
- push 模式(body 非空)不出网:资产由 CLI 下载并推来,这里只收字节。 pull 模式(mode=pull,B87 新增)相反:agentd 自己出网按 tag 下载
- 不做回滚编排:换版失败时 release.Activate 自己把 .prev 换回去, 人工回滚是 handoff upgrade --rollback,不在这条路径上
- 不做鉴权加码:持有 bearer token 的人本来就能 handoff run 执行任意命令, 推二进制不构成提权。token 就是信任边界(spec D4)
本文件实现「查最新版」与「下载桌面端安装包」:
GET /api/update/latest —— 缓存的最新 tag(?refresh=1 绕过) POST /api/update/desktop/download —— 下载本平台薄壳安装包、校验、打开 GET /api/update/desktop/download —— 进度
边界(承重):
- 不做换版。下完把安装包交给用户,最后一步(拖进应用程序 / 解压覆盖)由人 完成。自我替换需要一条控制台→薄壳的指令通道,比它服务的动作还贵(spec §5)。
- 不复用 POST /api/workspaces/reveal。那个端点的 revealTarget 会硬拒绝跑出 工作树的路径——那是它的设计目的,不是缺陷。这里揭示的是下载目录里的文件, 两者的安全边界不同,必须各写各的。
- 检查缓存与 CLI、薄壳共用同一个文件(selfupdate.CLICheckPath): api.github.com 有 60 次/小时/IP 的匿名限流,多消费者各查各的正是触发它的方式。
watchdog.go —— 任务级卡住看门狗、失配对账扫描与 agentd 启动恢复。
职责:
- RunWatchdog:周期扫描 running/waiting_answer 任务,最新事件早于 stallTimeout 判定卡住,追加 stalled 事件并广播,唤醒协调者裁决(spec §8「任务级超时看门狗」); 每轮另做失配对账扫描(scanStateMismatch),把「最新事件已 failed、状态却非终态」 的破损中间态补迁 failed(B97 Task 3 保险丝)
- RecoverOnStartup:agentd 启动时对 running/waiting_answer/waiting_review 任务 逐个探测执行器存活(spec §8「agentd 崩溃后重启恢复」);running/waiting_answer 不存活 → turn_failed 事件 + 迁移 waiting_review 交协调者裁决;waiting_review 不存活 → 保持现状(本就是待审核终态,不追加事件不迁状态);存活(含 waiting_review) → 重建 SSE 订阅继续消费
边界:
- 不做状态机之外的业务决策:stalled 只唤醒不改状态(executor 可能仍在干活, 只是没有事件产出),恢复的 failed 迁移固定落 waiting_review,不自动重试
- 不直接接触 adapter:重建订阅的具体动作经探活闭包注入(见 RecoverOnStartup 的 seam 说明),本文件只负责「探测结果 → 事件/状态」的翻译
- tick 间隔是 runWatchdog 的参数(测试注入 10ms),RunWatchdog 固定每分钟一次
本文件持有 Web 控制台静态资源的伺服逻辑。
职责:
- 把 internal/webui 提供的 fs.FS 伺服出去:命中真实文件就发文件, 未命中就回落 index.html(客户端路由的深链接需要这个)。
- 按「文件名是否带 hash」决定缓存策略。
边界:
- **不处理 /api、/ws、/console**。/api 与 /ws 由路由层用前缀分派收走: server.go 注册 `mux.Handle("/api/", api)` 与 `mux.Handle("/ws/", api)`, api 是无兜底的子 mux,未命中由 ServeMux 给出原生裁决——路径不认识 → 404, 路径认识但方法不对 → 405。/console 注册在 root,本 handler 只兜未知路径。
- 为什么不能只靠「模式精确度」:agentd 的 /api 路由全是精确路径 (如 GET /api/status)或带 {id} 的参数路径,**没有** /api/ 前缀模式。 ServeMux 的 "/" 通配只会输给**已注册**的更长前缀,所以没有前缀分派时 GET /api/no-such 唯一匹配得上的就是 "/",会被这里回落成 HTML—— 前端把 HTML 喂给 JSON.parse,报错信息与真实原因完全无关,排查成本极高。 TestUnknownAPIPathStaysJSON(未命中保持非 HTML 4xx)与 TestApiMethodMismatchStays405(方法错配保持 405)是钉住这条的两道门。
- 不做鉴权。本 handler 挂在 s.auth 之内,到这里的请求已经通过鉴权。
workbench_api.go —— 控制台工作台状态的 HTTP 面(2026-08-20 状态同步 spec §4.2)。
职责:
- GET /api/workbench/state 一次读出全部基准行与两个单例
- PUT /api/workbench/state/base 写/删一行基准状态
- PUT /api/workbench/state/selected 写当前选中目录
- PUT /api/workbench/state/dock 写/清空悬浮窗现场
边界:
- **不解释 payload**:它是前端序列化好的 JSON 字符串,本层只做长度校验。 后端不认识什么叫「分屏」,布局形状改了这里一行都不用动(spec §3)
- **不接 forwardIfRequested**:工作台状态是协调者本机的东西,不按 ?machine= 转发。 布局里可以有远程机器的目录,但那只是 payload 里的一个字段
- 不做鉴权:走 /api 既有的那一套
本文件是 agentd 侧 git 工作区操作与文件/命令读取的唯一出口。
职责:
- 派发前的工作区准备:PrepareWorkspace 按分支×worktree 两个正交维度准备任务 工作区(脏工作区一律拒绝;new-worktree 免脏检查)——PrepareBranch 是其 原地+自动分支的过渡薄包装
- 审阅素材:Diff(基准分支到 HEAD 的差异 + 提交列表)、 ReadFile(读仓库内文件)、ListDir(列举工作树内一层目录)、 RunCmd(远程跑测试/lint 等审阅命令)
- 派发前的基线决议:ResolveBaseline 一次算出「校验结论 + 新分支起点 + 任务仓库领先多少提交」,保证校验的东西和用的东西是同一个
边界:
- 全部操作是「分支准备 + 只读审阅」:绝不代 executor 写代码/提交, executor 的改动必须经它自己的 commit 落进任务分支
- 不解析审阅命令的语义:run 跑什么、diff 怎么审由协调者决定
- git 全部经 exec.Command("git","-C",repo,...) 执行,不拼接 shell
- 每条命令都有超时/输出护栏:工作区准备/清理一组 git 调用上限 WorkspaceGitTimeout=2min(pre-checkout hook / credential 交互提示会挂死 git, 这些调用同步跑在 dispatch handler 里,必须有兜底上限);审阅命令 run 10min; 输出有界回收(最多保留 1MB,超出部分只排空不驻留内存),防挂死与内存失控
本文件提供 RunCmd 的进程组原语(unix 实现):让命令成为独立进程组组长, 超时/取消时把组内孙进程一并回收,不留孤儿。
本文件实现「按工作树寻址」的文件接口:目录列举、读文件、写文件、条目 操作(建/复制/改名/删)与文件夹内查找。
职责:
- resolveWorkspace:白名单闸门——只有 GET /api/projects/tree 探测得出的 工作树路径才被接受
- handleWorkspaceDir / handleWorkspaceFile / handleWorkspaceFileWrite / handleWorkspaceEntryCreate / handleWorkspaceEntryCopy / handleWorkspaceEntryRename / handleWorkspaceEntryDelete / handleWorkspaceSearch:八个端点的 HTTP 层(写文件只解请求体 + 映射错误, 判断力全在 WriteFile;条目操作与搜索同样只做解参 + 映射错误,判断力全在 CreateEntry/CopyEntry/RenameEntry/DeleteEntry/SearchInDir)
边界:
- 写文件只在单个已存在文件上做原子替换,不建目录、不删任何东西;条目 操作能建、能删、能改名、能复制(单层名,不出当前目录),查找在目录内 扫文本行——全部只落在本机**已探测到**的工作树内部,越过边界一律 4xx
- 不接受任意路径。**这是参数校验,不是安全边界**:控制台会话在能力上 等价于主令牌(auth 中间件让两者落在同一个 mux 上,其中包含 POST /api/tasks/{id}/run 的 sh -c),白名单挡不住任何有心人。它存在的 理由是防止前端传一个打错的路径把 agentd 变成任意目录浏览器。 完整论证见 docs/superpowers/specs/2026-08-12-w4-pty-terminal-design.md §1。
- 不改 /api/tasks/{id}/file:那是 CLI `handoff fetch` 在用的既有契约。 另开一条是因为工作树可以没有任务(人手开的、任务已 done 的), 文件浏览不能依赖任务存在
本文件实现工作区(git 工作树)的**现场探测**:给一个 location 的路径, 吐出它下面的全部工作树。
职责:
- 调一次 git worktree list --porcelain,解析成 []proto.Workspace
- 判定每条是不是主工作树、是不是 agentd 自建的任务工作树
- 探测失败时降级为「空列表 + 人话说明」,不向上抛错
边界:
- 不落库:worktree 会在 agentd 背后被 git worktree add/remove 改动, 落表必然产生说谎的行;本机文件系统调用是毫秒级的,缓存只会引入失真窗口
- 不判断工作树上挂着哪个任务(那是 join 的事,见 projectjoin.go)
- 不做鉴权、不碰 HTTP
Index ¶
- Constants
- Variables
- func Branches(repo string) ([]string, error)
- func BundleRange(ctx context.Context, repo, have, branch string) (string, error)
- func CopyEntry(repo, rel string) (proto.DirEntry, error)
- func CreateEntry(repo, parentRel, name, kind string) (proto.DirEntry, error)
- func CreateManualWorktree(ctx context.Context, repo, worktreesDir string, req proto.CreateWorktreeReq) (proto.Workspace, error)
- func DeleteEntry(repo, rel string) error
- func Diff(repo, baseBranch string) (string, error)
- func DiffRange(repo, baseBranch, headRev string) (string, error)
- func EnsureRepoUsable(ctx context.Context, repo string) error
- func ListDir(repo, rel string) ([]proto.DirEntry, error)
- func MainWorktreeRoot(ctx context.Context, dir string) (string, error)
- func ManualWorktreeRoot(worktreesDir string) string
- func PrepareBranch(ctx context.Context, repo, taskID string) (branch string, err error)
- func ReadFile(repo, rel string) (proto.FileRead, error)
- func RecoverOnStartup(st *store.Store, hub *Hub, probe func(taskID string) bool, ...) error
- func RemoveManagedWorktree(ctx context.Context, repo, workdir string) error
- func RenameEntry(repo, rel, newName string) (proto.DirEntry, error)
- func ResolveBaseBranch(ctx context.Context, repo, remote, branch string) (string, error)
- func RunCmd(ctx context.Context, repo, cmdline string) (stdout string, exitCode int, err error)
- func RunWatchdog(ctx context.Context, st *store.Store, hub *Hub, stallTimeout time.Duration, ...)
- func SearchInDir(ctx context.Context, repo, rel, query string, limit int) (proto.SearchResult, error)
- func SetGitProxy(proxy string)
- func SetTaskProcCounter(fn func(taskID string) (int, bool))
- func WarnIfKillModeUnsafe(log *slog.Logger)
- func WriteFile(repo, rel, content, baseSHA256 string) (proto.FileRead, error)
- type Approver
- type ApproverDecision
- type Baseline
- type DataDirLock
- type DirtyWorktreeError
- type DispatchReq
- type Hub
- func (h *Hub) CloseTask(taskID string) int
- func (h *Hub) NotifyAnswer(ticketID, answer string) bool
- func (h *Hub) Publish(ev proto.Event)
- func (h *Hub) Subscribe(taskID string) (<-chan proto.Event, func())
- func (h *Hub) WaitAnswer(ctx context.Context, ticketID string) (string, error)
- func (h *Hub) Watchers(taskID string) int
- type Manager
- func (m *Manager) Continue(ctx context.Context, taskID, instructions string) (err error)
- func (m *Manager) Dispatch(ctx context.Context, req DispatchReq) (task *proto.Task, err error)
- func (m *Manager) Done(ctx context.Context, taskID, note string) (err error)
- func (m *Manager) ExecutorNames() []string
- func (m *Manager) FootprintAll() (*proto.FootprintResp, error)
- func (m *Manager) ForceReclaim(taskID, reason string) error
- func (m *Manager) ListProjects(ctx context.Context) ([]proto.ProjectLocation, error)
- func (m *Manager) MismatchTransit() func(taskID string, to proto.TaskState, reason string) error
- func (m *Manager) NoteDeliveryFailed(taskID, ticketID string, cause error)
- func (m *Manager) Reclaim(ctx context.Context, taskID string, force bool) (resp *proto.ReclaimResp, err error)
- func (m *Manager) ReclaimList() (*proto.ReclaimListResp, error)
- func (m *Manager) RecoverStuck(taskID string, force bool) (*RecoverReport, error)
- func (m *Manager) RegisterProject(ctx context.Context, req RegisterProjectReq) (proto.ProjectLocation, error)
- func (m *Manager) RelayAnswer(taskID, ticketID, answer string) error
- func (m *Manager) ResumeTask(taskID string) bool
- func (m *Manager) Status() (*proto.StatusResp, error)
- func (m *Manager) Stop(ctx context.Context, taskID string) (worktreeRemoved bool, err error)
- func (m *Manager) SweepTaskProcs(taskID string)
- func (m *Manager) TaskProcCount(taskID string) (int, bool)
- func (m *Manager) UnregisterProject(ctx context.Context, name string) error
- type Mirror
- type RecoverReport
- type RegisterProjectReq
- type Server
- func (s *Server) CloseTargets() error
- func (s *Server) DisciplineMapping() map[string]string
- func (s *Server) EnvMapping() map[string]string
- func (s *Server) GracefulShutdownCleanup(wdCancel context.CancelFunc) func()
- func (s *Server) Handler() http.Handler
- func (s *Server) Hub() *Hub
- func (s *Server) Pool() *targetclient.Pool
- func (s *Server) ReclaimPtySessions() error
- func (s *Server) SetConfigPath(p string)
- func (s *Server) SetLedger(st *ledger.Store)
- func (s *Server) SetManager(m *Manager)
- func (s *Server) SetRestart(fn func(reason string) bool)
- func (s *Server) SetUpdateDeps(d UpdateDeps)
- func (s *Server) ShutdownPtySessions(ctx context.Context)
- type Shutdown
- type UpdateDeps
- type Workspace
- type WorkspaceReq
Constants ¶
const MismatchScanMinAge = mismatchScanMinAge
MismatchScanMinAge 是失配对账扫描的事件最小年龄(供 cmd 接线)。
const ShutdownGrace = 15 * time.Second
ShutdownGrace 是停止接受新连接后,留给在途请求收尾的时间上限。
15s 的来由:agentd 最长的同步 handler 是 run 路由(RunCmdTimeout=10min), 但那类长跑请求本来就会被 http.Server.Shutdown 等到底或随进程退出而断, 拿 10min 当 grace 只会让每次重启都卡十分钟。15s 覆盖的是普通 API 调用 (dispatch/reply/show 都是亚秒级)的收尾,够用且不拖慢换版。
Variables ¶
var ( // ErrReclaimNotTerminal 表示任务还没到终态,不予回收——删运行中任务的 // 工作树等于抽它脚下。 ErrReclaimNotTerminal = errors.New("任务非终态,不回收工作树") // ErrReclaimRepoUnreachable 表示仓库不可达或不是 git 仓库,判不出。 // 单任务回收必须据此拒绝,绝不能降级成「无残留」静默成功。 ErrReclaimRepoUnreachable = errors.New("仓库不可达,工作树状态判不出") // ErrReclaimNotManaged 表示该任务用的是协调者自带的工作树,agentd 无权删。 ErrReclaimNotManaged = errors.New("工作区不是 agentd 管理的 worktree") )
var ( ErrDirtyWorktree = errors.New("工作区不干净(有未提交改动),拒绝派发") ErrPathEscape = errors.New("路径逃逸被拒绝") ErrPathIsDir = errors.New("路径是目录,不是文件") ErrPathNotDir = errors.New("路径不是目录") ErrNotRegularFile = errors.New("路径不是普通文件(管道/设备等特殊文件不可读)") ErrRepoUnusable = errors.New("任务仓库不可用(路径不存在或不是 git 仓库)") ErrBadBaseBranch = errors.New("非法的基准分支:不允许以 - 开头") ErrBadWorkspaceReq = errors.New("工作区参数非法") ErrWorkdirBusy = errors.New("目标工作目录已被活跃任务占用") // 以下五个是在线编辑(B81)的拒绝面。文案就是 HTTP 层原样吐给用户的中文, // 所以每条都要能独立读懂——「操作失败」帮不上任何人 ErrGitDirWrite = errors.New("不允许写入 .git 目录") ErrSymlinkTarget = errors.New("目标是符号链接,不支持在线编辑") ErrBinaryFile = errors.New("二进制文件不支持在线编辑") ErrFileTooLarge = errors.New("文件超过 1 MB,不支持在线编辑") ErrBaseMismatch = errors.New("文件已被改动") // ErrBaseCommitMissing 表示审核者本地的基线提交在任务仓库中不存在, // 且 fetch 后仍补不回来——远程仓库落后于本地,派发出去的活会建在错误的基准上。 ErrBaseCommitMissing = errors.New("基线提交在任务仓库中不存在") // ErrBranchIdentityMismatch 表示 git 报告成功,但工作区实际所在的分支 // 不是我们请求的那个(B76:worktree add -b 被 DWIM 顶替)。 ErrBranchIdentityMismatch = errors.New("工作区分支与请求不符") // 以下三个是文件树条目操作(B107:建/改名/删)的拒绝面。文案就是 HTTP 层 // 原样吐给用户的中文,每条都要能独立读懂。 ErrEntryExists = errors.New("目标已存在") ErrEntryNotFound = errors.New("目标不存在") ErrBadEntryName = errors.New("名字不合法") )
错误定义:
- ErrDirtyWorktree:工作区有未提交/未跟踪的改动,拒绝派发
- ErrPathEscape:请求的文件路径逃逸出任务仓库(含符号链接逃逸)
- ErrPathIsDir:请求的文件路径指向目录(fetch 只服务普通文件)
- ErrPathNotDir:请求列举的路径不是目录(ListDir 只服务目录)
- ErrNotRegularFile:请求的文件路径指向管道/设备等特殊文件(不可读)
- ErrRepoUnusable:git 探活本身失败(仓库路径不存在/不是 git 仓库/权限等), 与 ErrDirtyWorktree 的「仓库可用但状态不干净」区分——前者需要协调者先解决 仓库本身的问题,后者一条 git 命令即可清理(server 层映射见 writeDispatchError)
- ErrBadBaseBranch:diff 的基准分支参数非法(以 "-" 开头,会被 git 解释为 选项而非 rev——git 参数注入面)
- ErrBadWorkspaceReq:PrepareWorkspace 的参数非法(互斥冲突/分支不存在/ 路径不是本仓库 worktree/rev 以 "-" 开头等),dispatch 期拒发
var ( RunCmdTimeout = 10 * time.Minute // WorkspaceGitTimeout 是工作区准备/清理这一整组 git 调用的时长上限。 // // 为什么必须有:worktree add / checkout 在网络文件系统、pre-checkout hook 或 // credential 交互式提示下会永久挂住;这些调用同步跑在 dispatch 的 HTTP handler // 里,一次挂死等于一个连接与一条 handler goroutine 永不释放。 // 包级 var 而非 const:测试可注入更短值。 WorkspaceGitTimeout = 2 * time.Minute )
执行护栏:
- RunCmdTimeout:单条审阅命令的执行上限。包级 var 而非 const,便于测试注入更短值。 导出供 cmd 包派生 agentd HTTP WriteTimeout(见 cmd/agentd.go):响应写超时必须 ≥ 该上限,否则长审阅命令会在 handler 执行途中被掐断连接、RunCmd 被提前取消
- maxRunOutput:合并输出的截断上限,防止失控命令刷爆内存与响应体
- WorkspaceGitTimeout:工作区准备/清理这一整组 git 调用的时长上限(见其注释)
var ErrBadWorktreeReq = errors.New("建树请求不合法")
ErrBadWorktreeReq 标记「请求本身不合法」,HTTP 层据此回 400 而不是 500。
var ErrHaveMissing = errors.New("have 提交在任务仓库中不存在")
ErrHaveMissing 表示调用方声明的 have 提交在任务仓库中不存在。
为什么响亮失败而不是悄悄退回全量:客户端只会回传任务记录里的 BaseCommit, 它在任务仓库里找不到意味着真的出了异常。退回全量会让「协调者拿到的包比预期 大得多」这件事无声发生。
var ErrMachineExists = errors.New("同名开发机已存在")
ErrMachineExists 表示同名开发机已存在,调用方应答 409。
var ErrMachineNotFound = errors.New("开发机不存在")
ErrMachineNotFound 表示要删的开发机不存在,调用方应答 404。
var ErrNoProcHeadroom = errors.New("进程余量不足")
ErrNoProcHeadroom 表示进程余量已耗尽,本次开工被拒。
路由层靠 errors.Is 认它并返回 400——这是环境问题不是请求格式问题, 但 4xx 能让协调者立刻知道「不用重试,先腾地方」。
var ErrProjectAlreadyExists = errors.New("项目位置冲突或克隆落点已存在")
ErrProjectAlreadyExists 表示位置冲突或克隆落点已被占用,映射 409。
与 ErrRepoUnusable(400)的区别:那是「请求本身有问题,改了再来」, 这是「当前状态与请求冲突」——和 ErrDirtyWorktree / ErrWorkdirBusy 同层级。
var ErrProjectNotRegistered = errors.New("项目未登记")
ErrProjectNotRegistered 表示派发请求指向的项目在本机没有位置。
映射为 400(调用方先解决请求本身的问题),见 server.go 的 writeDispatchError。 本机 CLI 收到它会触发自动登记后重发(spec §6.2)——因此报文既要给人看, 也要能被 CLI 用 errors 判别,两者都靠这个哨兵。
注意:本机 CLI 按报文里的「项目未登记」四字判别并触发自动补登记 (cmd/dispatch.go 的 projectNotRegisteredMarker)。**改这里的文案要同步改那边。**
var ErrProjectOriginMismatch = errors.New("路径上的仓库与请求的项目不是同一个")
ErrProjectOriginMismatch 表示调用方声称的项目与该路径上实际仓库的 origin 不符,映射 400。
为什么必须单列一个哨兵:这是自动化最容易造出的脏登记——路径敲错但恰好指到 另一个真实仓库。若并进 ErrRepoUnusable,报文就变成含糊的「仓库不可用」, 而人需要的是「你说的是 A,那儿实际是 B」。
var ErrWorkdirGone = errors.New("工作目录不存在")
ErrWorkdirGone 表示 run 要用的工作目录已不存在。
why 单独一个哨兵:它是调用方的条件(多半是 managed worktree 已被 done/stop 回收), 不是服务端故障,路由层据此返回 400 而不是 500。
var FetchTimeout = 2 * time.Minute
FetchTimeout 是基线缺失时补拉远端的时长上限。 独立于 WorkspaceGitTimeout:fetch 走网络,与本地 git 操作不是一个量级。
Functions ¶
func Branches ¶ added in v0.3.0
Branches 列出仓库的本地分支名(refname:short,字母序)。
供审阅栏「改动」的基准分支下拉用:协调者从列表里选,不手填。 只列本地分支——diff 的 base 语义是本地 rev,远端跟踪分支由默认推导覆盖。
返回:分支名切片(可能为空,如空仓库);git 失败返回错误(stderr 在 err 里)。
func BundleRange ¶ added in v0.3.0
BundleRange 在 repo 里生成 <have>..<branch> 的 git bundle,返回临时文件路径。
参数:
- ctx: 调用方上下文,透传给 git 子进程
- repo: 任务的**主仓库**路径(task.RepoPath,不是 Workdir())——worktree 是 主仓的从属工作树,分支对象在主仓库里
- have: 协调者已有的基线提交;空串表示生成全量包
- branch: 任务分支名
返回:
- path: 生成的临时文件路径。**调用方负责 os.Remove**,本函数不回收
- err: ErrHaveMissing(have 不存在)/ ErrBadBaseBranch(参数以 - 开头或 分支名为空)/ 其余为真故障。**不会因为「没有新提交」而失败**——见下
注意:
- 临时文件落 OS 临时目录,绝不落进 repo——那会让 dispatch 的干净工作区校验误报
- 不设体积上限:一个会拒绝合法全量包的上限,是把能用的路径改成坏的
- **区间为空时会自动放宽**(§5.2):git 拒绝造空包,而调用方需要的不只是 对象、还有包里那个 ref——客户端的本地分支引用是 fetch 的副产品。放宽到 <branch>~1..<branch> 让包一定含 ref,客户端因此一行都不用改
func CopyEntry ¶ added in v0.3.0
CopyEntry 复制工作树内一个条目(B107 文件树右键菜单),目录连同其内容一并 递归复制。副本名按「foo copy.go、foo copy 2.go …… 到 foo copy 99.go」的规则 取第一个未被占用的;目录整体当 base 不拆扩展名(`a.b` → `a.b copy`)。 命名细节与 99 上限的理由见 copyEntryName doc。
路径遏制与 CreateEntry/RenameEntry/DeleteEntry 同一红线:全部写操作经 os.OpenRoot 的内核级 jail(理由见 1543 行注释),符号链接等特殊文件不复制、 只跳过并 Warn,绝不在递归途中落回绝对路径。
参数:
- repo: 工作树绝对路径(调用方必须已过白名单闸门,本函数不做白名单判定)
- rel: 待复制条目的相对路径(空串与 "." 都表示工作树根,按 ErrBadEntryName 拒绝——复制整棵工作树没有意义,落点必然撞已存在的副本名)
返回:
- proto.DirEntry: 复制后条目的信息(Name/IsDir/Size 取自磁盘实况)
- ErrBadEntryName: rel 是工作树根(空串 / ".")
- ErrPathEscape: rel 逃逸出工作树(含符号链接逃逸)
- ErrGitDirWrite: 目标是 .git 下的条目
- ErrEntryNotFound: rel 不存在
- ErrEntryExists: 副本名到 copy 99 已全部被占用
func CreateEntry ¶ added in v0.3.0
CreateEntry 在工作树内新建一个空文件或空目录(B107 文件树右键菜单)。
参数:
- repo: 工作树绝对路径(调用方必须已过白名单闸门,本函数不做白名单判定)
- parentRel: 目标所在父目录的相对路径;空串与 "." 都表示工作树根
- name: 新条目名(单层名,不得含 / 或 \)
- kind: "file" 或 "dir"
返回:
- proto.DirEntry: 新建条目的信息(Name/IsDir/Size 取自磁盘实况,非入参拼装)
- ErrBadEntryName: name 非法(空 / . / .. / 含分隔符),或 parentRel 不是目录
- ErrPathEscape: parentRel 逃逸出工作树(含符号链接逃逸)
- ErrGitDirWrite: 目标落在工作树根的 .git 下
- ErrEntryNotFound: parentRel 不存在
- ErrEntryExists: 目标已存在
func CreateManualWorktree ¶ added in v0.3.0
func CreateManualWorktree(ctx context.Context, repo, worktreesDir string, req proto.CreateWorktreeReq) (proto.Workspace, error)
CreateManualWorktree 在 worktreesDir/manual 下开一棵不属于任何任务的工作树。
参数:
- repo: 项目主仓路径(登记表里的 path)
- worktreesDir: <DataDir>/worktrees
- req: 建树请求,见 proto.CreateWorktreeReq
返回:
- 新工作树的 proto.Workspace,口径与项目树上那一条完全一致(含 head/created_at)
- 错误:请求类问题一律包 ErrBadWorktreeReq(调用方回 400);git 执行失败原样返回
注意:
- 整个过程限时 WorkspaceGitTimeout(2 分钟)——pre-checkout hook 与凭证交互 提示都能把 git 挂死,不设上限会拖死一个 HTTP 连接
- 成功后会多跑一次 git worktree list 做回读,为的是让返回值与树上同源; 回读挑不到时退回手工组装并 Warn,**不因为回读失败就把成功报成失败**
func DeleteEntry ¶ added in v0.3.0
DeleteEntry 删除工作树内一个条目(B107 文件树右键菜单),目录连同其内容一并删。
不做回收站:git 能救已跟踪文件(checkout / restore 一条命令),却救不了未跟踪 文件——新建即删除的落盘即永久,这正是「误删会弄丢东西」的全部理由,与凭据 强弱无关(控制台会话的权限本来就是主令牌等价,见 isGitPath 的 why);这道闸 该挡的是「一次误操作就把数据/仓库弄坏」。
参数:
- repo: 工作树绝对路径(调用方必须已过白名单闸门,本函数不做白名单判定)
- rel: 待删除条目的相对路径
返回:
- ErrBadEntryName: rel 是工作树根(空串 / ".")
- ErrPathEscape: rel 逃逸出工作树(含符号链接逃逸)
- ErrGitDirWrite: 目标是 .git 下的条目
- ErrEntryNotFound: rel 不存在
func Diff ¶
Diff 取任务分支相对基准分支的完整审阅素材:git diff <base>...HEAD 的差异 + git log --oneline <base>..HEAD 的提交列表,按序拼接。
参数:
- repo: 任务仓库路径
- baseBranch: 基准分支(通常为派发前的分支,如 main/master)
返回:
- 拼接后的文本(diff 为空时只有提交列表,两边都空时为空串)
- err: git 失败返回错误(如 baseBranch 不存在,stderr 已并入日志); baseBranch 以 "-" 开头返回 ErrBadBaseBranch
func DiffRange ¶ added in v0.3.4
DiffRange 与 Diff 相同,但右端 rev 由调用方指定。
为什么需要指定右端:任务归档后 managed worktree 会被回收,此时只能回到主仓库 去看。**主仓库的 HEAD 不是任务分支**(多半是 main),拿 HEAD 当右端会把 「主线相对基线的全部历史」当成这个任务的改动——实测一个已归档任务因此吐出 1002 个文件,而它真实只改了几个。右端必须显式给任务分支。
headRev 与 baseBranch 同一套校验:空串与 "-" 前缀一律拒绝(git 参数注入)。
func EnsureRepoUsable ¶
EnsureRepoUsable 校验 repo 确实是一个可用的 git 仓库。
参数:
- ctx: 控制本次 git 调用的生命周期
- repo: 任务仓库路径
返回:
- nil:是可用的 git 仓库
- ErrRepoUnusable:路径不存在 / 不是 git 仓库 / git 不在 PATH / 权限不足, 错误文本带 git stderr 原文(server 层据此给 400,见 writeDispatchError)
注意:
- 由 Dispatch 在 ResolveBaseline 之前调用。放在那里而不是建树前,是因为 ResolveBaseline 对非 git 仓库会误报成 ErrBaseCommitMissing(「落后于本地, 请先 push」),那是个比沉默更糟的答案
- 判据用 rev-parse --git-dir 而不是 grep worktree add 的错误串:前者是显式 判据,后者依赖 git 的文案不变
- ensureCleanWorktree 里原有的 ErrRepoUnusable 包装保留——它仍是 git status 因其他原因失败时的兜底
func ListDir ¶ added in v0.3.0
ListDir 列举工作树内某个目录的**直接子项**,不递归。
与 ReadFile 共用同一套路径防护(安全红线,两道):
- filepath.Clean 归一化后,绝对路径或残留 .. 前缀一律拒绝(ErrPathEscape)
- 实际打开经 os.OpenRoot(内核级 jail),符号链接逃逸由内核在单次系统调用 内拒绝,不留 TOCTOU 窗口
为什么不递归:一次递归列举一个大仓库要遍历几十万个 inode,而前端一次只画 一层。按需展开把成本摊到用户真正点开的那几层上。
排序:目录在前、各自按名称字典序。为什么由服务端排而不是前端排:前端会有 搜索过滤与虚拟滚动,排序稳定性交给一处比在多处各排一次可靠。
参数:
- repo: 工作树绝对路径(调用方必须已过白名单闸门,本函数不做白名单判定)
- rel: 相对工作树根的目录路径;"" 与 "." 都表示根
返回:
- 子项列表(**永不为 nil**)
- err: 逃逸返回 ErrPathEscape;目标不是目录返回 ErrPathNotDir; 目标不存在返回 *fs.PathError(含 %w 链,errors.Is(err, fs.ErrNotExist) 为真)
func MainWorktreeRoot ¶
MainWorktreeRoot 返回 dir 所属 git 仓库的主工作树根目录。
参数:
- ctx: 控制 git 调用生命周期
- dir: 任意目录(主仓根/主仓子目录/linked worktree/worktree 子目录)
返回:
- 主工作树根目录的绝对路径(已 Clean)
- 错误:dir 不是 git 仓库(或 git 不可用)时返回包装 ErrRepoUnusable 的错误, 报文带 git 的 stderr 原文
注意:
- git rev-parse --git-common-dir 在**主仓内**返回相对路径(根目录 ".git", 子目录 "../.git"),在 **linked worktree 内**返回指向主仓的绝对路径。 两种形态都要处理:相对时以 dir 为基准拼接,再取父目录
- 返回值一律绝对化:位置表的 path 列是绝对路径,UNIQUE 约束要靠它才有意义
func ManualWorktreeRoot ¶ added in v0.3.0
ManualWorktreeRoot 返回手工树的落点根目录。
参数:worktreesDir 即 <DataDir>/worktrees。 返回:<DataDir>/worktrees/manual。界面用它如实回显「会建在哪」。
func PrepareBranch ¶
PrepareBranch 是 PrepareWorkspace 的过渡薄包装:保持一期「原地 + 自动分支」语义 与全部错误哨兵(ErrDirtyWorktree/ErrRepoUnusable),Dispatch 改走 PrepareWorkspace 后本函数仅剩测试与本包内部引用(Task 7 会清理调用点)。
参数:
- ctx: 控制整组 git 调用的生命周期,内部再叠加 WorkspaceGitTimeout 作为兜底上限
func ReadFile ¶
ReadFile 读取任务仓库内相对路径文件的内容(审核者取上下文用)。
路径逃逸防御(安全红线,两道):
- filepath.Clean 归一化后,任何绝对路径或残留 .. 前缀的路径一律拒绝(ErrPathEscape)
- 实际打开经 os.OpenRoot(内核级 jail):路径中任何符号链接指向仓库外时, 打开直接失败。为什么不用 EvalSymlinks 前缀校验:那是「先校验、后打开」两步, 校验与打开之间有 TOCTOU 竞态窗口——恶意 executor 经 run 命令完全能在这个 窗口里把链接换成指向仓库外的版本;os.OpenRoot 的解析在单次打开内由内核完成, 无窗口。
os.OpenRoot 的边界(stdlib 契约,Go 1.24+):符号链接目标必须是相对路径 ("Symbolic links must not be absolute"),故绝对目标链接一律拒绝(ErrPathEscape), 即使目标在仓库内也拒绝——保守语义,见 TestReadFileSymlinkEscape; 仓库根自身是符号链接时 OpenRoot 跟随之,读链接指向的真实仓库,正常可用。
大小上限:只读 maxRunOutput+1 字节(+1 仅用于判定是否超限),超限截断并 Warn—— 与 RunCmd 的输出截断语义一致:返回开头、不整读内存,64MiB 大文件不会把 agentd 读挂。 截断而非拒绝:fetch 的用途是看文件开头(审阅上下文),1MiB 对源文件足够; 大文件多为生成物/数据文件,拒绝会让审核者误以为路径有误。 截断提示不再由本函数拼接,改由端点层按各自契约决定(handleTaskFile 拼、 handleWorkspaceFile 不拼)——本函数返回的是保真的读,在线编辑那条线才敢把 内容原样存回磁盘。
非普通文件(目录/管道/设备等)一律拒绝:目录给 ErrPathIsDir(400 语义), 其余特殊文件 read 语义不可控(可能无限输出或永久阻塞),给 ErrNotRegularFile。
参数:
- repo: 任务仓库路径
- rel: 相对仓库根的路径(如 cmd/foo.go)
返回:
- proto.FileRead{Content, Size, Truncated, Binary, SHA256},其中 SHA256 仅当 !Binary && !Truncated 时有值
- err: 路径逃逸(含符号链接逃逸)返回 ErrPathEscape;目录返回 ErrPathIsDir; 其他特殊文件返回 ErrNotRegularFile;文件不存在返回 *fs.PathError(含 %w 链)
func RecoverOnStartup ¶
func RecoverOnStartup(st *store.Store, hub *Hub, probe func(taskID string) bool, sweep func(taskID string), log *slog.Logger) error
RecoverOnStartup 在 agentd 启动时恢复未终结任务(spec §8 的 agentd 重启恢复): 对全部 running/waiting_answer/waiting_review 任务调用 probe 探测执行器存活——
- running/waiting_answer 不存活:追加 turn_failed 事件(原因固定为「agentd 重启后 执行器已不在」)并迁移 waiting_review,交协调者裁决(失败现场留在事件里, 协调者凭 tasks/attach 可见);该任务的挂起工单一并作废(P1-16,见 VoidPendingTickets 的语义),事件照常广播,启动期无人订阅则由客户端凭 seq cursor 补拉
- waiting_review 不存活:保持现状即可——它本来就是待审核终态,等待协调者 裁决(continue 重派 / done 归档)是既有的终态语义,追加 turn_failed 事件或再迁 状态只会产生噪音,**不**复用 running/waiting_answer 的 turn_failed 迁移路径
- 存活(含 waiting_review):重建 SSE 订阅继续消费——重建动作由 probe 闭包 内部完成(见 seam 说明),本函数只记录结论日志;waiting_review 存活时 同样重建,续接依赖的会话上下文(opencode serve 进程与 SSE 会话)才不至于 随 agentd 进程消亡而丢失,但**不改任务状态**(它该留在 waiting_review 等人)
参数:
- st: 持久化存储
- hub: 实时路由(failed 事件广播)
- probe: 探活闭包 func(taskID) bool;返回 true 表示执行器存活且(对支持恢复 的 adapter)事件流已重建。这是本函数保持「不带 adapter 引用」签名的接口 缝隙(seam):存活的「重建订阅 + 重启中介循环」动作必须封装在闭包内部, 接线见 cmd/agentd.go(mgr.ResumeTask)与 Manager.ResumeTask 的 doc
- sweep: 残留进程清扫闭包 func(taskID);**无条件调用**——executor 已不在时 残留进程该不该收与任务停在哪个状态无关(waiting_review 同样照收,见下)。 与 probe 是同款注入缝(避免 watchdog 直接接触 adapter),并排放读起来才 是一回事;接线见 cmd/agentd.go(mgr.SweepTaskProcs)
- log: 本模块日志入口
返回:
- 任务列表读取失败时返回错误(恢复不可靠,让 agentd 启动失败暴露问题); 单个任务的恢复失败(事件追加/状态迁移失败)只记录日志,不中断整体恢复
注意:
- 必须在 HTTP 服务开始前调用(cmd/agentd.go 的 bootstrap 顺序保证)
- waiting_answer 任务迁移 waiting_review 需经 running 两跳(waiting_answer→ waiting_review 直接迁移不在 6 状态迁移表中,见 recoverTransit)
- 探活本身无副作用:waiting_review 任务无论 probe 结果如何都不改状态, 状态由协调者动作(continue/done)驱动
func RemoveManagedWorktree ¶
RemoveManagedWorktree 删除 agentd 管理的 worktree(git -C repo worktree remove workdir)。
参数:
- ctx: 控制整组 git 调用的生命周期,内部再叠加 WorkspaceGitTimeout 作为兜底上限
- repo: 主仓库路径
- workdir: 待删除的 worktree 路径(必须为 Managed=true 的工作区)
返回:error 非 nil 表示重试耗尽仍未删掉;调用方按现状只 Warn 不阻断。
注意:
- 只删工作树不删分支(spec:任务分支保留供审阅/回滚)
- workdir 带未提交改动时 git 拒绝删除(错误带 stderr 原文返回);是否降级 由调用方(Done 归档)决定——本函数不做清理性降级
- 失败会重试若干次,见 removeWorktreeAttempts 的注释
func RenameEntry ¶ added in v0.3.0
RenameEntry 给工作树内一个条目改名(B107 文件树右键菜单)。
参数:
- repo: 工作树绝对路径(调用方必须已过白名单闸门,本函数不做白名单判定)
- rel: 待改名条目的相对路径
- newName: 新名字(单层名,不得含 / 或 \——本期不做跨目录移动)
返回:
- proto.DirEntry: 改名后条目的信息(Name/IsDir/Size 取自磁盘实况)
- ErrBadEntryName: newName 非法(空 / . / .. / 含分隔符),或 rel 是工作树根
- ErrPathEscape: rel 逃逸出工作树(含符号链接逃逸)
- ErrGitDirWrite: 目标是 .git 下的条目
- ErrEntryNotFound: rel 不存在
- ErrEntryExists: 新名字已存在
func ResolveBaseBranch ¶ added in v0.3.6
ResolveBaseBranch 把基线**分支名**解析成完整 sha,起点取指定远端上的那一份。
参数:repo 任务仓库路径;remote 持有该分支的远端名;branch 基线分支名。 返回:remote/<branch> 的完整 sha;分支在远端上不存在或 fetch 失败时报错。
与 ResolveBaseline(提交路径)唯一的、也是必须的不同:**无条件 fetch**。 提交路径是「本地没有才拉」,因为 commit sha 在本地要么有要么没有,是可靠 信号;而分支名在本地永远解析得到——那正是陈旧的那一份。拿「解析得到」当 「不用拉」的信号,会让「执行机镜像陈旧导致起点错」这个 bug 永远不触发 修复路径。无本地同名 heads 且发现多棵远端同名 ref 时,仍在 fetch 前拒发。
func RunCmd ¶
RunCmd 在任务仓库内执行一条审阅命令(sh -c),合并 stdout+stderr 有界回收 1MB。
这是协调者主动发起的只读审阅动作(跑测试/lint),不走审批门—— 命令语义(跑什么、看什么)由协调者决定,agentd 只负责执行与回收。
参数:
- ctx: 上层上下文(HTTP 请求取消会终止命令)
- repo: 命令工作目录(任务仓库)
- cmdline: shell 命令原文(sh -c 执行)
返回:
- stdout: 合并后的输出(超过 1MB 截断)
- exitCode: 命令退出码;超时被杀时返回 124(与 time 命令的约定一致)
- err: 超时返回 context 相关错误;启动失败返回 exec 错误。 命令非零退出**不**返回错误——exitCode 已表达结果,路由层据此返回 200
func RunWatchdog ¶
func RunWatchdog(ctx context.Context, st *store.Store, hub *Hub, stallTimeout time.Duration, budget, hardLimit int, reclaim func(taskID, reason string) error, startedAt time.Time, minAge time.Duration, transit stateMismatchTransit, log *slog.Logger)
RunWatchdog 启动任务卡住看门狗并持续运行,直到 ctx 取消。
参数:
- ctx: 控制看门狗生命周期;取消时立即退出(调用方负责在进程退出前 cancel, 避免 goroutine 泄漏)
- st: 持久化存储(任务列表与最新事件的数据源)
- hub: 实时路由(stalled 事件广播)
- stallTimeout: 判定「卡住」的空闲时长(最新事件距今超过它即触发)
- budget: 任务级进程数告警线,<=0 表示该档关闭(见 scanTaskProcs)
- hardLimit: 任务级进程数硬上限,<=0 表示该档关闭(见 scanTaskProcs)
- reclaim: 强制回收入口(接线传 mgr.ForceReclaim);返回非 nil 表示 executor 没停掉,此时任务不落终态
- startedAt: 本次 agentd 启动时刻(失配对账扫描的护栏之一,见 mismatchVerdict)
- minAge: 失配对账扫描的事件最小年龄(生产取 MismatchScanMinAge)
- transit: 失配对账扫描的「迁移」回调(接线传 mgr.MismatchTransit)
- log: 本模块日志入口
注意:
- 扫描间隔固定为 watchdogTick(每分钟);测试需要注入短间隔时直接调用 同包的 runWatchdog 并传入 tick 参数
- 每轮扫描对 running/waiting_answer 任务判定;同一任务在 stalled 之后若无 活动(新事件或 task.UpdatedAt 前进)不会重复触发,有活动(如协调者 reply) 且 executor 仍无事件产出时下一轮会二次触发(「只发一次」按活动裁决, 设计见 scanStalled 的函数头 P1-15a)
- 每轮除卡住判定外,还判读一次进程余量高水位(见 scanPressure),越线沿 给每个活跃任务发一条 resource_pressure 事件唤醒协调者收敛;再按任务点名 进程数(见 scanTaskProcs),两档处置——告警线只唤醒,硬上限走强制回收
- 每轮另做失配对账扫描(见 scanStateMismatch):把「最新事件 failed 但状态 非终态」的破损中间态补迁 failed,经 transit 回调走终态收口
func SearchInDir ¶ added in v0.3.0
func SearchInDir(ctx context.Context, repo, rel, query string, limit int) (proto.SearchResult, error)
SearchInDir 在工作树某个目录内全文搜索关键词,返回命中的行 (B107 文件树右键菜单「在文件夹内查找」)。
参数:
- ctx: 控制搜索生命周期;内部叠加 searchTimeout 作为兜底上限
- repo: 工作树绝对路径(调用方必须已过白名单闸门,本函数不做白名单判定)
- rel: 搜索范围(相对工作树根的目录);"" 与 "." 都表示整棵工作树
- query: 关键词,空串直接报错
- limit: 命中数上限;<=0 取 searchDefaultLimit,>searchMaxLimit 收敛到它
返回:
- proto.SearchResult:Hits 的 Rel 含 scope 前缀、Line 从 1 起、Text 是匹配 行原文(超过 searchHitTextMax 截断);Truncated=true 表示撞到命中数上限 或超时,此时 Hits 是已扫到的部分结论——必须把「只看到这些」与「总共就 这些」分开,用户才知道要收窄范围
- ErrPathEscape: rel 逃逸出工作树(含符号链接逃逸)
路径遏制与 ReadFile/ListDir 同一红线:遍历经 os.OpenRoot 的内核级 jail (root.FS() 做 fs.WalkDir),不落回 filepath.Join(repo, rel) 后的包级 os 调用(理由见本文件 1543 行注释)。
三条护栏(每条的为什么见对应常量/变量注释):
- 命中数:达 limit 立即停止并标 Truncated
- 超时:searchTimeout 到点带已有结果返回,不把超时当错误
- 跳过生成物:searchSkipDirs 命中的目录任意层级即跳整棵子树
func SetGitProxy ¶ added in v0.3.0
func SetGitProxy(proxy string)
SetGitProxy 设置出网 git 使用的代理,由 agentd bootstrap 调用一次。
参数:
- proxy: 代理地址;空串 = 不用代理
注意:
- 只影响 clone / fetch 两处出网操作,本地 git 操作(rev-parse/status/ worktree/diff…)一律不带代理——它们根本不出网,带上只会平白多一个配置
- 非并发安全:只在启动期调用一次。测试里改它必须串行
func SetTaskProcCounter ¶ added in v0.3.0
SetTaskProcCounter 注入「数某任务名下进程数」的实现,由 agentd 启动时调用一次。
参数:fn 为按任务 ID 返回 (进程数, 是否可信) 的实现,生产恒传 Manager.TaskProcCount。
注意:测试直接赋包级 taskProcCountFn 即可,不需要走本函数。
func WarnIfKillModeUnsafe ¶
WarnIfKillModeUnsafe 在 agentd 启动期检查 systemd KillMode 并按需告警。
注意:
- 只打日志,绝不阻断启动(见文件头边界)
- 非 systemd 环境完全静默——macOS 开发机是主要使用场景,不能有噪声
func WriteFile ¶ added in v0.3.0
WriteFile 用新内容原子替换工作树内一个已存在的文本文件,带基线哈希前置条件。
冲突保护的整个机制就是这个前置条件:调用方把它**读到那一版**的 sha256 带回来, 本函数比对磁盘现状,不一致就拒绝并把现状带回去。为什么不用 mtime:executor 在 工作树里频繁跑 git 操作,checkout/rebase 会动 mtime 但不动内容,用 mtime 会把 大量无害情况报成冲突。
**已知窗口,如实记录**:第 6 步读哈希与第 9 步 rename 之间不是原子的。executor 恰好在这个窗口里写同一个文件,本函数检测不到,结果是它的改动被覆盖。加锁解决 不了——锁只挡得住 agentd 自己的并发写,executor 直接动文件系统,根本不经过这里。 窗口从「整个编辑时长」缩到「一次读 + 一次 rename」,是这条路能拿到的全部。
参数:
- repo: 工作树绝对路径(调用方必须已过白名单闸门,本函数不做白名单判定)
- rel: 相对工作树根的路径
- content: 新内容
- baseSHA256: 调用方读到那一版的 sha256 十六进制串;空串一律判为不匹配
返回:
- err == nil:res 是**新内容**的结论(SHA256 可直接当下一次的基线,Size 是新大小)
- errors.Is(err, ErrBaseMismatch):res 是**磁盘现状**(含正文与现状哈希), 给调用方省一次往返
- 其余错误:res 是零值
- 错误取值:ErrPathEscape / ErrGitDirWrite / ErrSymlinkTarget / ErrPathIsDir / ErrNotRegularFile / ErrBinaryFile / ErrFileTooLarge / ErrBaseMismatch / fs.ErrNotExist(含 %w 链)
Types ¶
type Approver ¶
type Approver struct {
// contains filtered or unexported fields
}
Approver 是审批链的廉价模型裁决器。
func NewApprover ¶
func NewApprover(cfg config.ApproverConfig, env *envfile.Resolver, log *slog.Logger) (*Approver, error)
NewApprover 构造审批者。
参数:
- cfg: 审批者配置;cfg.Executor 为空表示不启用审批链,返回 (nil, nil)
- env: 本 agent 的 env 文件解析器(B19);nil=不注入
- log: 包日志入口
返回:
- 未启用时返回 (nil, nil);启用时返回可用的审批者
func (*Approver) Decide ¶
func (a *Approver) Decide(ctx context.Context, permission, taskSummary string) ApproverDecision
Decide 对一次权限请求做裁决:组装 prompt → 调用 one-shot 执行者 → 解析 JSON。
参数:
- ctx: 上层上下文;本方法在其上叠加 a.timeout 作为裁决截止
- permission: 权限请求原文(如 "Bash: rm -rf node_modules")
- taskSummary: 任务摘要(写入 prompt 给模型上下文)
返回:
- ApproverDecision,见该类型注释(fail-closed:一切无法干净裁决的输入 都返回 Approve=false + Err 非 nil)
type ApproverDecision ¶
ApproverDecision 是审批者一次裁决的结果。
- Approve: true=自动放行(executor 收 once);false=升级人工协调者
- Reason: 裁决理由(approve/escalate 时可能非空)
- ElapsedMS: 本次裁决耗时(含 CLI 调用)
- Err: 非 nil 表示裁决本身失败(命令失败/解析失败/超时/取值非法)—— 区别于干净的 escalate(Err=nil)。上层据此做连续失败计数:只有 Err 非 nil 才累计,干净的 escalate 不算失败(那是审批者正常行使职权)
type Baseline ¶
type Baseline struct {
// Start 是新分支起点(40 位 sha)。任务仓库一个提交都没有时为空,
// 退回 git 默认行为(空仓库上 checkout -b 本来就不能带起点)。
Start string
// Ahead 是任务仓库 HEAD 上有、而 Start 上没有的提交数——这些提交不会进新分支。
Ahead int
// Fetched 表示是否为定位 Start 补拉过远端。只用于日志:排障时要能分清
// 「基线本来就在」与「补拉才拿到」,前者说明两边同步,后者说明执行机落后过。
Fetched bool
}
Baseline 是一次基线决议的结果:校验结论与新分支起点出自同一次计算。
为什么必须是同一个结构而不是分两次算:B35 的根因就是「校验这个 sha 存不存在」 与「新分支从哪起」由两段代码各自决定,中间没有任何连接——校验通过了,分支却 从任务仓库 HEAD 开出去,两者可以静默地差出几十个提交而不留任何痕迹。
func ResolveBaseline ¶
ResolveBaseline 决议任务的基线:校验协调者本地基线在任务仓库中可用,并给出 新分支应当使用的起点与「任务仓库比它多出多少提交」。
参数:
- ctx: 上层上下文;fetch 阶段内部叠加 FetchTimeout
- repo: 任务仓库路径
- sha: 协调者本地 HEAD 的 40 位十六进制提交号;空=未提供(--no-sync-check 或调用方 cwd 不是 git 仓库),此时起点退回任务仓库当前 HEAD
返回:
- Baseline: Start=新分支起点;Ahead=任务仓库 HEAD 领先 Start 的提交数
- ErrBadWorkspaceReq: sha 格式非法(会拼进 git 参数,不校验等于开注入面)
- ErrBaseCommitMissing: fetch 后仍缺失,错误文本含 sha、fetch stderr 与动作提示
注意:
- 空 sha 也返回一个具体的 Start:让「这个任务建在哪个提交上」在任何路径下 都答得出来,包括 --no-sync-check 那条——今天那条路上基线是纯粹的空白
- 「命中才不 fetch」是刻意设计:常态下远程并不落后,cat-file 是纯本地对象库 查询(微秒级),只有真落后时才付网络代价
- fetch 失败(无凭证/网络不通)不单独成一类错误,一并归入 ErrBaseCommitMissing: 对调用方而言结论都是「这次派不出去,先解决远程仓库」,stderr 原文已带出根因
type DataDirLock ¶
type DataDirLock struct {
// contains filtered or unexported fields
}
DataDirLock 持有一个 DataDir 的独占权,直到 Release 或进程退出。
func AcquireDataDirLock ¶
func AcquireDataDirLock(dataDir string, log *slog.Logger) (*DataDirLock, error)
AcquireDataDirLock 对 <dataDir>/agentd.lock 取非阻塞独占锁。
参数:
- dataDir: 数据目录,调用方须保证它已存在(agentd 侧由 os.MkdirAll 保证)
- log: 日志入口,nil 时退回 slog.Default()
返回:
- 持有锁的句柄,调用方须一直持有到进程结束
- error: 已被另一个 agentd 占用时,错误文本是一段完整的可行动指引 (含 dataDir 与 `handoff status`),调用方直接原样返回即可,不要再包一层
注意:
- **必须在 store.Open 之前调用**。别指望端口冲突挡住第二个 agentd—— ListenAndServe 是 agentd 启动流程的最后一条语句,在它之前 RecoverOnStartup 已经对在役 agentd 的活执行器重建了订阅并写入状态迁移;SQLite 开了 WAL 也不拦多进程打开。破坏发生在撞端口之前
func (*DataDirLock) Release ¶
func (l *DataDirLock) Release() error
Release 释放锁。
生产侧可有可无——进程退出内核即释放;保留它是为了让测试能验证「释放后可重新 获取」,以及 defer 的习惯写法。重复调用是安全的(第二次直接返回 nil)。
type DirtyWorktreeError ¶
DirtyWorktreeError 表示工作树有未提交改动或未跟踪文件,未带 force 时拒绝回收。
为什么是带清单的类型而不是裸哨兵:协调者要决定「这些改动能不能丢」, 就必须看见改了什么。只给一句「树是脏的」等于把决定权交出去却不给依据。 注意名字避开 workspace.go 里的 ErrDirtyWorktree 哨兵——那是 dispatch 拒发的 错误,语义是「拒绝派发」,与这里的「回收被拒、带清单」是两回事。
func (*DirtyWorktreeError) Error ¶
func (e *DirtyWorktreeError) Error() string
type DispatchReq ¶
type DispatchReq struct {
// ProjectID 是项目身份(sha256(归一化 origin) 前 16 位),由调用方离线算出。
// 与 ProjectName 二选一,都空时 400;同时给出时以 ProjectID 为准。
ProjectID string
// ProjectName 是项目的人可读引用,仅服务 --project <名字> 与 Web 控制台
//(它没有 cwd,从项目树里选)。
ProjectName string
PlanB64 string // plan 内容,base64 编码(路由/CLI 层编码,此处解码)
PlanName string // plan 文件名(归档展示用,写入 task 的 PlanPath 目录下)
Target string // 目标主机名(归档展示用,记入 task.Target)
// Prompt 是无 plan 文件时的直接指令(prompt-only 派发);与 PlanB64 至少其一
// 非空。plan 非空时作为「附加指令」拼接在计划之后。
Prompt string
// Name 是任务展示名(空时从 plan 名/prompt 派生,见 deriveName)。
Name string
// Executor 是任务选择的执行者名;空=缺省(cfg.Executor.Default)。
Executor string
// Discipline 是本次派发点名的纪律块**角色名**(如 review);空=按 executor 兜底。
//
// 为什么是名字而不是路径或正文:路径要跨机器解析(协调者的仓内相对路径在
// agentd 上没有意义),正文要跨网络搬运且没法被机器级覆盖。名字让 agentd
// 成为纪律块的唯一拥有者,调用方只说「我要哪个角色」。
Discipline string
// Model 是任务级模型覆盖;空=配置 executor.model,再空=executor 自身默认。
Model string
// Branch / NewBranch 分支二选一(与 PrepareWorkspace 的 WorkspaceReq 一致):
// Branch=切到已存在分支;NewBranch=新建分支(空且 Branch 空=自动 handoff/<id8>)。
Branch string
NewBranch string
// Base 是新分支起点(仅与 NewBranch/自动分支连用;空=HEAD)。
Base string
// Worktree / NewWorktree worktree 二选一:Worktree=用户自带 worktree;
// NewWorktree=在 DataDir/worktrees 下新建 managed worktree(done 时删除)。
Worktree string
NewWorktree bool
// BaseCommit 是协调者本地 HEAD 的提交号(40 位十六进制),用于校验任务仓库
// 不落后于本地;空=不校验(本地派发或调用方 cwd 不是 git 仓库)。
BaseCommit string
}
DispatchReq 是 Dispatch 的入参:任务仓库、base64 计划与二期派发参数。
type Hub ¶
type Hub struct {
// contains filtered or unexported fields
}
Hub 是进程内实时路由层,管理事件订阅与 ticket 应答等待。
并发安全:subs/answers 两个 map 的全部访问都在 mu 保护下; Publish/NotifyAnswer 的通道发送也在锁内进行,与 cancel 的「移除+关闭」互斥, 从而避免出现向已关闭通道发送的 panic。
func NewHub ¶
func NewHub() *Hub
NewHub 创建空的 Hub。
注意:
- 日志取创建时的 slog.Default();如需统一格式/级别,调用方应先 slog.SetDefault(logx.Setup(...)) 再 NewHub
func (*Hub) CloseTask ¶
CloseTask 关闭该任务的全部事件订阅并从表中摘除。
参数:
- taskID: 已终结(归档)的任务 ID
返回:
- 被关闭的订阅数;无人订阅返回 0
为什么需要它:done 归档只改任务状态、不追加任何事件,事件流上完全无声。 跟随中的客户端(wait --follow)因此拿不到「没有下文了」的信号,会一直挂到 空闲超时——而那个超时的语义是「agentd 可能失联」,把一次正常归档报成了故障。 关闭订阅让 WS 处理器以正常关闭码收尾,客户端据此正常退出。
注意:
- 与 unsubscribe 共用同一把 mu,且 unsubscribe 以「通道是否还在表中」为准, 连接随后 defer cancel 时找不到自己的通道即静默返回,不存在二次 close
- 关闭后 Publish 该任务的事件是空操作(表里已无订阅者),不会向已关闭通道发送
func (*Hub) NotifyAnswer ¶
NotifyAnswer 将应答投递给等待该 ticket 的所有 WaitAnswer 调用者。
参数:
- ticketID: 工单 ID
- answer: 应答内容
返回:
- true: 至少有一个等待者收到了应答
- false: 无人等待(典型为 agentd 重启后等待 goroutine 已随进程消亡)。 应答已由 store 持久化,调用方应走 Manager.RelayAnswer 自愈中继直接回传 executor,避免「回答已落库但 executor 永远阻塞」
注意:
- 投递后该 ticket 的等待表被清空,后续 WaitAnswer 不会拿到旧应答
func (*Hub) Publish ¶
Publish 将事件广播给该 task 的所有订阅者,永不阻塞。
参数:
- ev: 待广播的事件;Seq/TaskID 需已由调用方(store 落库后)赋值
注意:
- 无订阅者时直接丢弃(持久化在 store,不靠 hub)
- 对缓冲已满的慢订阅者走 select-default 丢弃并打 Warn(taskID、seq), 这是「事件为什么没到」的第一排查点
func (*Hub) Subscribe ¶
Subscribe 订阅指定 task 的实时事件流。
参数:
- taskID: 要订阅的任务 ID
返回:
- ch: 事件通道(带缓冲);仅包含订阅后新产生的事件,历史回放由 server 层用 store.EventsFromAsc 拼接
- cancel: 取消订阅函数,可重复调用;取消后本通道不再接收新事件并被关闭, 消费方可直接 range 到结束,Publish 也不会再向其发送
注意:
- 订阅者消费不及时(缓冲写满)时事件会被 Publish 丢弃,本层不保证每条事件都投递
func (*Hub) WaitAnswer ¶
WaitAnswer 等待指定 ticket 的应答,直到收到应答或 ctx 取消。
参数:
- ctx: 控制等待生命周期,取消时立即返回
- ticketID: 要等待应答的工单 ID
返回:
- 应答内容
- ctx 取消时返回 ctx.Err()(context.Canceled / context.DeadlineExceeded)
注意:
- 应答是一次性的:NotifyAnswer 只投递给正在等待的调用者,先 Notify 后 Wait 拿不到旧应答
- 多个调用者可同时等待同一 ticket,均会收到应答
- 等待者退出时会从表里移除,不会泄漏
func (*Hub) Watchers ¶
Watchers 返回当前订阅该任务事件流的连接数。
参数:
- taskID: 任务 ID
返回:
- 订阅者数量;无人订阅或任务不存在均返回 0(两者对本层等价)
为什么这个数字可以直接当「有几个协调者在听」用:全仓 Subscribe 只有一个调用点 (/ws/events 的处理器),没有任何内部订阅者混在里面。若将来新增了内部订阅者, 这条结论就不再成立,必须同步修改本注释与 status 的判据。
注意:
- 走 Hub 现有的 mu,与 Subscribe/unsubscribe/Publish 互斥;返回的是调用瞬间 的快照,调用方不得假设它在返回后仍然成立
- 本方法刻意不打日志:它是高频纯读,订阅数变化已由 Subscribe/unsubscribe 的 Debug 日志覆盖,这里再打一遍只会把真正的线索淹掉
type Manager ¶
type Manager struct {
// contains filtered or unexported fields
}
Manager 是任务状态机中枢与 adapter 事件中介。
并发安全:无共享可变字段(st/hub/ads/cfg/conf/log 构造后只读), 每个任务的中介 goroutine 与应答 goroutine 通过 store CAS + hub 路由协作。 approver 相关的 in-flight/失败计数/停用表由 apMu 保护。
func NewManager ¶
func NewManager(st *store.Store, hub *Hub, ads map[string]executor.Adapter, cfg *config.Config, discMapping func() map[string]string, envMapping func() map[string]string, approver *Approver, gate *permgate.Gate, log *slog.Logger) *Manager
NewManager 创建任务管理器。
参数:
- st: 持久化存储(任务/事件/工单的唯一落库点)
- hub: 进程内实时路由(事件广播 + ticket 应答等待)
- ads: executor 注册表(name → Adapter,如 {"opencode": ..., "fake": ...}); 任务按 executor 名路由,缺省名取 cfg.Executor.Default
- cfg: 配置**快照**。运行期可变的字段(Executor / Targets)不要直接读它, 走 m.conf()——Server.SetManager 会把活取值函数注入进来(B160 §4.2)
- discMapping: 取当前纪律块映射的函数(生产上传 (*Server).DisciplineMapping); nil 时全部 executor 走内置默认
- envMapping: 取当前 env 文件映射的函数(生产上传 (*Server).EnvMapping); nil 时所有 agent 都不注入环境变量
- approver: 审批链裁决器;nil=不启用
- gate: 权限判据网关;**不得为 nil**,它与 approver 是否启用无关
- log: 本模块日志入口
注意:
- 调用方须保证 log 为统一配置后的 logger;st/hub 必须已就绪
func (*Manager) Continue ¶
Continue 向任务续发修改指令:要求任务处于 waiting_review,先回迁 running 再 经 Adapter.Send 原样透传指令(同一会话续接,上下文完整保留)。
返回:
- 任务不存在返回 store.ErrNotFound;状态不允许续接返回 store.ErrBadTransit
- Send 失败时任务回迁 waiting_review(协调者可重试指令),并返回该错误
注意:
- Send 撞 ErrTaskNotRunning 时走恢复阶梯(resumeForContinue):executor 进程已死但会话数据在盘上,冷恢复续上原会话再重试 Send 一次;阶梯全走完 仍不可恢复则回迁 waiting_review,错误带 Outcome.Note 说明原因
func (*Manager) Dispatch ¶
Dispatch 派发一个新任务:准备任务分支 → 建任务 → 建 taskDir 写 plan → Adapter.Start → running → 启动中介 goroutine 消费事件流。
参数:
- req: 仓库路径与 base64 计划(字段说明见 DispatchReq)
返回:
- 已入库的任务(state 为 running);Adapter.Start 失败时返回错误, 此时任务已落库并迁移为 failed(供协调者经 tasks 命令查看失败现场)
注意:
- 任务分支(handoff/<id8>)的准备发生在建任务之前:分支准备是纯前置校验 (工作区干净/可开分支),失败时不落任何任务记录,协调者修好仓库重新 dispatch 即可——不会为每次被拒的派发留下 failed 噪音
- 分支名经 store.SetTaskField 白名单字段 "branch" 写入任务(不随 CreateTask 带列写入,保持「创建期只写创建时已知的字段」的约定)
func (*Manager) Done ¶
Done 归档任务:要求任务处于 waiting_review,迁移 completed 后调用 Adapter.Stop 回收 executor 侧资源,并清理 agentd 管理的 worktree。
返回:
- 任务不存在返回 store.ErrNotFound;状态不允许归档返回 store.ErrBadTransit
参数:
- note: 归档说明(handoff done --note);空串表示未留说明,此时仍照常归档 并发布 archived 事件,只不落说明
注意:
- Stop 失败仅打 Error 日志不影响归档:任务已完成,executor 残留交给人工兜底
- 顺序是「先落说明、再迁移状态」:写失败时任务仍在 waiting_review,协调者可 原样重试;反过来先迁移就会留下「已归档但说明丢了」且不可重试的状态——done 对已 completed 的任务返回 409,协调者补不回来
func (*Manager) ExecutorNames ¶ added in v0.3.0
ExecutorNames 返回本机已注册的 executor 名,按名字升序。
用途:纪律配置端点要把「注册的 adapter」与「配置里已出现的键」取并集, 后者不能省——一个配了纪律块但当前没注册的名字(改名、临时摘掉)若不列出, 界面就看不见它,而它还躺在配置里。
func (*Manager) FootprintAll ¶
func (m *Manager) FootprintAll() (*proto.FootprintResp, error)
FootprintAll 体检全部任务(含已归档)的进程足迹。
返回:
- 每个任务一行(含判定结论)与本机 uid 占用;查询任务列表失败才返回错误
注意:
- **只读,绝不发信号**:本方法只数不杀。数出来之后要不要动手是人的决定
- 与 status 分开的理由:本方法遍历全部历史任务目录,天然是慢命令; status 有「不能变成慢命令」的硬纪律,两者不能合并
- 已归档任务同样体检:Done 只删 worktree、不删任务目录,凭据都还在
func (*Manager) ForceReclaim ¶ added in v0.3.0
ForceReclaim 强制回收一个失控任务:先停 executor,停掉了才把任务落 failed。
参数:
- taskID: 目标任务
- reason: 落 failed 时写进事件的理由,必须含可判断的真实数字(如实际进程数 与硬上限),审核者事后要凭它判断回收得对不对
返回:
- nil: executor 已停、任务已落 failed(清扫与工单作废由 transit 的终态收口 自动完成)
- 非 nil: 没停掉或状态迁移失败,**任务状态保持不变**——调用方应让它留在活跃 集里下一轮继续点名重试
注意:
- **顺序不可换**:想清掉一个正在 fork 的任务,必须先让 fork 的源头停下来; 杀子进程而留着父进程是打地鼠。这也是 B119 的根因——改前直接对活着的 executor 调 Sweep,段①被跳过后什么都没收,却照样宣布「已强制回收」
- 不删 managed worktree:1200 进程这种现场最需要留证,删 worktree 是不可逆 且外部可见的动作,留给协调者事后 handoff reclaim
func (*Manager) ListProjects ¶
ListProjects 列出本机全部项目位置,并现场探测每条的实际状态。
参数:
- ctx: 控制探测用的 git 调用生命周期
返回:
- 位置列表(Status 已填充);查库失败时返回错误
注意:
- 探测是登记与文件系统漂移的可见化手段。探测失败不影响列出—— 状态本身就是要报给人看的结果,不是错误
func (*Manager) MismatchTransit ¶ added in v0.3.0
MismatchTransit 返回失配对账扫描(watchdog.scanStateMismatch)的迁移回调包装。
为什么需要导出:watchdog.go 与 manager.go 同包,扫描本可以直接调 m.transit; 但看门狗的接线点在 cmd/agentd.go(agentd 包外),transit 未导出无法从包外引用, 于是经这个导出方法把「把任务迁到 failed 并做终态收口」的能力交给 cmd 接线。 终态收口(挂起工单作废 + 审计留痕,B63)仍挂在 transit 内部,扫描只负责判定。
func (*Manager) NoteDeliveryFailed ¶
NoteDeliveryFailed 产出 delivery_failed 事件:应答已落库但没送到 executor。
参数:
- taskID: 任务 ID
- ticketID: 未送达的工单 ID
- cause: 送达失败的原因(原样进 payload 供协调者诊断)
why(必须是事件而不只是日志):此时 executor 仍原地阻塞,而工单已被应答消耗、 不再出现在 attach 的挂起项里——只写日志的话协调者这边完全无感,任务一路挂到 看门狗超时。产出事件才能唤醒协调者(wait 不过滤该类型),提示执行 handoff resume。 供 manager 内部的应答等待链路与 server 的 reply 回程共用。
func (*Manager) Reclaim ¶
func (m *Manager) Reclaim(ctx context.Context, taskID string, force bool) (resp *proto.ReclaimResp, err error)
Reclaim 回收一个终态任务残留的 managed worktree。
参数:
- ctx: 上层上下文(HTTP 请求)
- taskID: 目标任务
- force: 为真时对脏工作树也强删(丢弃未提交改动),并在响应里报出丢弃清单
返回:
- 回收结果(removed / pruned / already_absent)
- store.ErrNotFound: 任务不存在
- ErrReclaimNotTerminal / ErrReclaimNotManaged / ErrReclaimRepoUnreachable
- *DirtyWorktreeError: 脏树且未带 force
注意:
- **纯资源动作**:不改任务状态、不追加事件、不删分支、不删任务目录
- 幂等:树已不在则报 already_absent 并成功返回。一条重试第二次会报错的 入口不是重试入口
- 动手前重读任务快照:failed→running 是合法迁移,列表之后任务可能已被 重新派发,终态判定不能停在列表那一刻
func (*Manager) ReclaimList ¶
func (m *Manager) ReclaimList() (*proto.ReclaimListResp, error)
ReclaimList 体检全部终态任务的 managed worktree 残留。
返回:
- 只含「仍有残留或判不出」的行,外加体检总数;查询任务列表失败才返回错误
注意:
- **单个仓库不可达不拖垮整张表**:该行标 unknown 继续走完。列表的核心 价值正是在环境已经不健康的时候还能用——这与单任务回收「判不出就拒绝」 的处置刻意相反,因为两者的失败代价不同
- 按仓库分组只拉一次工作树册:同一仓库下的多个任务共用一次 git 调用
- 与 FootprintAll 分工:那个数进程,这个数工作树,互不覆盖
func (*Manager) RecoverStuck ¶
func (m *Manager) RecoverStuck(taskID string, force bool) (*RecoverReport, error)
RecoverStuck 是协调者的显式恢复操作(CLI: handoff resume <task>), 用来解开两类卡死:
- 「应答已落库但没送到 executor」:重投未送达的应答
- 「agentd 与 executor 断连期间回合已完结、终态事件丢失」(B38): 会话对账补发丢失的终态
为什么需要它:reply 的回程里,应答一旦落库就消耗掉了工单的 answer IS NULL 守卫;若此时中继失败(executor 半死、调用超时),协调者会拿到 502,而工单 已从 pending 里消失、任务停在 waiting_answer——reply 得 404、continue/done 得 409,CLI 上再无一条可走的路。此前唯一的出口是运维重启 agentd 让 RecoverOnStartup 探活,而那条路只在 executor **已死**时有效:executor 还 活着并仍阻塞在权限上时,重启探活成功、订阅重建、已答工单从不重放,是彻底的 死锁。B38 又补上第二类:断连窗口内完成的回合,终态事件在 /event 上永久丢失, 任务冻死在 running。本方法把出口交到协调者自己手里。
参数:
- taskID: 任务 ID
- force: 为真时即使对账判不出(executor 不支持对账 / 回合确实还在忙 / 查询失败),仍把任务强制收口到 waiting_review,使 continue/done 可用; 收口会留下写明「人工强制、未经 executor 确认」的事件
返回:
- 恢复结果快照(即使返回错误也可能非 nil,用于区分「executor 已死」与「这次没成功」)
- 任务不存在、已终结,或重投过程中 executor 仍不可用时返回错误
行为:
- 无未送达应答 → 转入会话对账(adapter 支持且状态合适时);force 时再收口
- 有未送达应答 → 逐条重投;成功即标记送达,全部成功后任务回 running
- 重投遇到 executor.ErrTaskNotRunning(executor 确实不在)→ 追加 failed 事件、作废挂起工单、任务转 waiting_review 交协调者,不再重试
- 重投遇到其他错误(executor 还在,只是这次调用失败)→ 保持 waiting_answer 与未送达标记,返回错误;协调者稍后可再执行一次
注意:
- 幂等:已标记送达的应答不会被重投,重复执行是安全的;对账也幂等(水位已 过的回合不会二次补发)
- 与 ResumeTask 的区别:ResumeTask 是 agentd 重启时的执行器存活探测与订阅 重建(进程级),本方法是单任务的应答重投与会话对账(工单级),两者互不替代
func (*Manager) RegisterProject ¶
func (m *Manager) RegisterProject(ctx context.Context, req RegisterProjectReq) (proto.ProjectLocation, error)
RegisterProject 登记一个项目位置(各形态与分派见 RegisterProjectReq 的决策表)。
参数:
- ctx: 控制整组 git 调用的生命周期
- req: 登记请求
返回:
- 落库后的位置条目
- 错误:ErrRepoUnusable(400,路径不是仓库/无 origin/clone 失败)、 ErrProjectOriginMismatch(400,路径上是另一个项目)、 ErrProjectAlreadyExists(409,项目/名字/路径已被占用,或落点已存在)、 errBadDispatchRequest(400,参数缺失或名字非法)
注意:
- **登记在 clone 成功之后才落库**:反过来会在 clone 失败时留下一条指向 不存在路径的死记录
- Path 非空时必须是绝对路径,否则 400(见 RegisterProjectReq)
- clone 的落点若已存在则直接拒绝,绝不往里 clone、绝不覆盖
func (*Manager) RelayAnswer ¶
RelayAnswer 在 reply 回程找不到等待者时,把已落库的协调者应答直接回传给 executor,自愈「agentd 重启后等待 goroutine 消亡 → 回答丢失 → executor 永远阻塞」。
场景:agentd 重启时 waitPermission/waitQuestion goroutine 随进程消亡,且 /event 不重放历史的话,协调者的 reply 在 hub 里找不到等待者;若应答就此丢弃,任务状态 被 resumeIfIdle 回迁 running,executor 却永远等不到权限裁决——工单已答、二次 reply 404、done 409,不可恢复。本方法读取工单把应答按既有翻译规则直接送达 executor。
参数:
- taskID: 工单所属任务 ID(reply 路由的路径参数)
- ticketID: 已回答的工单 ID(AnswerTicket 已落库,此处只负责回传)
- answer: 协调者应答原文(与落库值一致)
返回:
- 工单不存在/不属于该任务/类型不识别返回错误;adapter 回传失败返回错误
规则(与 waitPermission/waitQuestion 的翻译规则完全一致):
- kind=gate:answer trim 后为 "allow" → RespondPermission("once"),其余一律 "reject"
- kind=ask:answer 原文原样经 Send 透传
func (*Manager) ResumeTask ¶
ResumeTask 恢复 agentd 重启前已在执行的任务:探测执行器存活;存活则经 adapter 重建 SSE 订阅并重启本任务的中介循环(spec §8「存活则重连 SSE 继续」)。
返回:
- true:执行器存活且事件流已重建,任务继续执行
- false:执行器已不在(或 adapter 无恢复能力),供 RecoverOnStartup 把任务 迁移 failed/waiting_review 交协调者裁决
注意:
- 本方法作为 RecoverOnStartup 的探活闭包传入:存活的「重建订阅 + 重启中介 循环」动作封装在闭包内部(见 watchdog.go RecoverOnStartup 的 seam 说明)
- 重启前已挂起的权限/提问等待不需要在此重建:reply 回程的 resumeIfIdle 自带「回答后无未答工单即回迁 running」的兜底(server.go),协调者回答 挂起工单后任务自然恢复执行
- 失败的恢复(adapter 报错)返回 false 而非错误:探活闭包契约只有 bool, 具体原因已由 Error 日志留痕,恢复路径按不存活处理是保守且安全的选择 (宁可交协调者裁决,不可静默吞掉仍在执行的任务事件)
func (*Manager) Status ¶
func (m *Manager) Status() (*proto.StatusResp, error)
Status 聚合本 agentd 的可用性与身份信息。
返回:
- StatusResp:版本、监听地址、DataDir、执行者清单、六状态计数、活跃任务 及其存活结论。StartedAt 由调用方(server 层)填,manager 不持有它
- err:只有查询任务列表失败才返回错误;探活失败不是错误,落到单个任务的 Live=unknown 上
func (*Manager) Stop ¶
Stop 主动中止一个任务:停 executor、落 failed 并唤醒协调者;挂起工单的作废 交由终态迁移的收口完成(B63),不在此处单独做。
参数:
- ctx: 上层上下文(HTTP 请求)
- taskID: 待中止的任务
返回:
- worktreeRemoved: 本次是否实际删除了 managed worktree。true=agentd 建的 worktree 已删除;false=用户自带 worktree / 原地模式(没删),或 managed worktree 清理失败(工作树仍在)。CLI 据此打印与行为一致的提示,不猜
- store.ErrNotFound: 任务不存在
- store.ErrBadTransit: 任务已是终态(completed/failed),无可中止
- 其余:落库失败
注意:
- 复用 failed 终态而不新增 aborted:状态机零改动,且 failed→running 已允许, 中止后仍可重新派发。「人为中止」与「真失败」的区分靠 failed 事件的 fail_reason 文本,不靠状态
- 不删任务分支:那是协调者的工作成果,stop 只让它停下(审阅/回滚仍可切回分支)
- 删除 agentd 管理的 worktree(Managed=true):被 stop 的任务落 failed,没有 done 的 waiting_review 清理路径,不删就永久残留;清理失败只降级为警告事件, 不阻断 stop(此时 worktreeRemoved=false,提示如实反映工作树仍在)
- adapter.Stop 失败只 Warn 不中断:目的是让任务离开活跃态,executor 残留 由执行者进程兜底,不能因为「停不掉进程」就让任务永远卡在 running
func (*Manager) SweepTaskProcs ¶
SweepTaskProcs 清扫一个任务的残留进程,best-effort。
参数:taskID 为目标任务
注意:
- 无返回值是刻意的:它的调用方(watchdog、RecoverOnStartup、reconcileExecutorGone) 全都处在收尾路径上,清扫成败不该反过来影响那件事
- 需要知道清扫结果的调用方(Done/Stop 的有界重试)走 sweepTaskProcsOnce
- 导出是因为 RecoverOnStartup 的接线点在 cmd/agentd.go(与 ResumeTask 同理), 不是给外部当通用 API 用
func (*Manager) TaskProcCount ¶ added in v0.3.0
TaskProcCount 数一个任务名下当前有几个进程。
参数:taskID 为完整任务 id
返回:(n, ok)。**ok 为 false 时 n 无意义**,调用方必须什么都不做—— 取不到句柄、adapter 不支持、Footprint 判定不可信(Verdict 非 OK)都归此类。 把「量不出来」当成「超了」会误杀,当成「没超」会让告警的置位状态错乱。
导出是因为 watchdog 的接线点在 cmd/agentd.go(与 SweepTaskProcs 同理), 不是给外部当通用 API 用。
func (*Manager) UnregisterProject ¶
UnregisterProject 注销一条项目位置。
参数:
- ctx: 上下文(当前实现不发起 git 调用,保留以对齐其余操作签名)
- name: 项目引用名
返回:
- 错误:位置不存在时 store.ErrNotFound(404);路径被活跃任务占用时 ErrWorkdirBusy(409)
注意:
- **只删登记,永不删磁盘上的仓库**。磁盘上那份是不是还要留,由人自己决定
type Mirror ¶ added in v0.3.0
type Mirror struct {
// contains filtered or unexported fields
}
Mirror 是事件镜像的管理器:发现远端活跃任务、持有上游订阅、维护快照。
并发安全:subs/ring 两个 map 的全部访问都在 mu 保护下;wg 跟踪全部 订阅与门铃 goroutine,Stop 等它们收干净才返回。
func NewMirror ¶ added in v0.3.0
NewMirror 创建镜像管理器。
参数:
- pool: target 客户端池(同时提供客户端与活的 target 清单)
- st: 本机存储(mirror_events / mirror_tasks 的落点)
- hub: 本机实时路由(镜像事件经它 Publish,让 /ws/events 订阅者立刻收到)
- log: 本镜像的日志入口
type RecoverReport ¶
type RecoverReport struct {
Task string `json:"task"`
// Redelivered 是本次成功重投给 executor 的应答条数
Redelivered int `json:"redelivered"`
// ExecutorGone 为真表示 executor 已不在,任务已被交给协调者裁决
ExecutorGone bool `json:"executor_gone"`
// Reconciled 为真表示本次真的执行了会话对账(adapter 支持且任务状态合适);
// 为假时 TurnEnded/Emitted 无意义
Reconciled bool `json:"reconciled"`
// TurnEnded 表示对账查到的回合是否已完结
TurnEnded bool `json:"turn_ended"`
// Emitted 是对账补发的终态事件数(0 或 1)
Emitted int `json:"emitted"`
// Forced 为真表示本次走了 --force 强制收口(状态由人工推动,未经 executor 确认)
Forced bool `json:"forced"`
// State 是操作完成后的任务状态
State proto.TaskState `json:"state"`
// Note 是给协调者看的一句话结论
Note string `json:"note"`
}
RecoverReport 是显式恢复操作的结果快照,原样作为 HTTP 响应体回给 CLI。
type RegisterProjectReq ¶
RegisterProjectReq 是登记一个项目位置的请求。
形态由 Path 是否给出 / 路径是否存在 / OriginURL 是否非空共同决定(三态决策表):
- Path 空 + OriginURL 空 → 400:既无身份也无落点
- Path 空 + OriginURL 有 → 由本机 clone 到 cfg.RepoRoot/<Name>(或认领已有落点)
- Path 非空且目录存在 → 登记已有仓(OriginURL 可省,省则现读 origin)
- Path 非空且目录不存在 + OriginURL 空 → 400:无 URL 无法创建
- Path 非空且目录不存在 + OriginURL 有 → clone 到该 Path 再登记
- 其余非法组合 → 400
Path 的形态约束:必须是**绝对路径**。相对路径与 ~ 一律 400——clone 落点与 落库路径的解析基准不同,猜错的代价是一条指向不存在路径的死记录。
为什么没有 Clone 布尔位:形态已被 Path + 文件系统状态 + OriginURL 是否为空 完全决定,多一个布尔位只会多出一组无意义的非法组合。
Name 可省,此时由 OriginURL(请求给出或现读的实际 origin)末段派生; 它只是人可读引用,不参与身份判定。
type Server ¶
type Server struct {
// contains filtered or unexported fields
}
Server 是 agentd 的 HTTP/WS 服务端,持有配置、存储与进程内实时路由 hub。
并发安全:**除 cfg 与升级槽位外**所有字段只读(构造后不变),hub 自身线程安全。 cfg 是可变的——控制台增删开发机会整体换掉它(写时复制),因此一律经 s.conf() 读取;**禁止再引入直接持有 *config.Config 的字段**,那会让 同样的错误从编译错误退化成静默竞态。升级槽位由 upgradeMu 保护。
func NewServer ¶
NewServer 创建 agentd 服务端。
参数:
- cfg: 配置,鉴权使用 cfg.Token
- st: 持久化存储
- log: 本服务日志入口
注意:
- hub 在内部创建,构造时捕获 slog.Default();如需统一日志格式,调用方应先在 slog.SetDefault(logx.Setup(...)) 之后再调用 NewServer
func (*Server) CloseTargets ¶ added in v0.3.5
CloseTargets 关掉池内全部客户端与 relay 隧道。
注意:只在进程退出路径调用。池关了就不再复活(relay.Dialer.Close 是终态)。
func (*Server) DisciplineMapping ¶ added in v0.3.0
DisciplineMapping 返回当前配置里的 executor 名 → 纪律块文件名映射。
交给 Manager 的 discipline.Resolver 每次派发时调用,因此控制台改完映射 **下一个任务就生效**,不必重启 agentd。返回的是当前快照持有的 map, 调用方只读不改(写入方永不原地修改配置,只整体换新)。
func (*Server) EnvMapping ¶ added in v0.3.0
EnvMapping 返回当前配置里的 agent 名 → env 文件名映射。
供 envfile.Resolver 每次派发时取活值:控制台改完映射不必重启 agentd。 返回的是配置快照里的 map 本体,**调用方不得修改**(写入一律走 swapConf)。
func (*Server) GracefulShutdownCleanup ¶ added in v0.3.5
func (s *Server) GracefulShutdownCleanup(wdCancel context.CancelFunc) func()
GracefulShutdownCleanup 返回 agentd 通用优雅关停使用的 cleanup 闭包。
参数:wdCancel 是后台看门狗与镜像循环的取消函数。 返回:只取消这些 agentd 内部后台循环的 cleanup;不会关闭任何 PTY 会话。 注意:信号关停与进程内 Trigger 走同一条 Shutdown 路径,包含升级换版;显式停止 PTY 必须由独立的显式 stop 入口调用 ShutdownPtySessions,不能挂在这里。
func (*Server) Handler ¶
Handler 返回带 Host 白名单 + 鉴权两层中间件的完整路由,便于 httptest 直接挂载。
路由(Go 1.22+ 方法路由):
- GET /api/status agentd 可用性与身份
- GET /api/footprint 全任务进程足迹体检
- GET /api/reclaim 终态任务 managed worktree 残留体检
- GET /api/tasks 任务列表
- POST /api/tasks 派发新任务(dispatch)
- GET /api/tasks/{id} 任务详情(attach 数据源)
- POST /api/tasks/{id}/reply 回答工单
- POST /api/tasks/{id}/continue 续发修改指令
- POST /api/tasks/{id}/done 归档任务
- POST /api/tasks/{id}/reclaim 回收单个终态任务的 managed worktree
- GET /api/tasks/{id}/diff 任务分支相对基准分支的审阅素材(diff + 提交列表)
- GET /api/tasks/{id}/render 任务实况(render.log)流式读取(attach 数据源)
- GET /api/tasks/{id}/frames 结构化回合帧(frames.jsonl)流式读取(W4b/TUI 数据源)
- GET /api/tasks/{id}/file 读任务仓库内文件(审阅上下文)
- GET /api/tasks/{id}/bundle 任务分支的 git bundle(回程 pull,不经 ssh)
- POST /api/tasks/{id}/run 在任务仓库执行审阅命令(跑测试/lint)
- POST /api/projects 登记项目(必要时先克隆)
- GET /api/projects 列出项目位置(含现场实际状态)
- GET /api/discipline 内置纪律、文件列表与 executor 档位
- GET /api/env env 文件列表与 executor 档位
- GET /api/env/file/keys env 文件的变量清单(不含值)
- GET /api/env/file 读 env 文件正文(仅编辑时)
- PUT /api/env/file 写 env 文件(写前解析校验)
- PUT /api/env/mapping 整段替换 executor→env 文件映射
- GET /api/executor/default 查询机器级缺省执行者、它的默认模型与可选名单
- PUT /api/executor/default 整体替换机器级缺省执行者与它的默认模型
- GET /api/workspaces/dir 列举工作树内一层目录(白名单:仅已探测到的工作树)
- GET /api/workspaces/file 读工作树内单个文件(同上白名单)
- PUT /api/workspaces/file 写工作树内单个文件(同上白名单,带哈希前置条件)
- POST /api/workspaces/entry 工作树内新建条目(同上白名单,请求体含 name/kind)
- POST /api/workspaces/entry/copy 复制工作树内条目(副本计数命名,目录递归)
- PATCH /api/workspaces/entry 改名工作树内条目(请求体含 new_name)
- DELETE /api/workspaces/entry 删除工作树内条目(目录连同内容一并删)
- GET /api/workspaces/search 工作树内按关键词搜索命中行(含 limit/超时/跳过生成物护栏)
- POST /api/workspaces/reveal 在本机访达中显示工作树内条目(不支持 ?machine= 转发)
- DELETE /api/projects/{name} 注销项目位置(只删登记,不动磁盘)
- PATCH /api/projects/{name} 改项目位置的引用名与/或路径(本机或 ?machine= 指定机器)
- GET /ws/events 事件流(补发 + 实时)
- GET /ws/pty PTY 会话双向字节通道(binary=数据,text=控制)
- POST /api/auth/tickets 主令牌签发一次性 ticket,返回 /console 兑换 URL
- GET /api/auth/sessions 列出会话(含已吊销)
- DELETE /api/auth/sessions/{id} 吊销指定会话
- POST /api/auth/logout 吊销当前 cookie 会话并清除 cookie
- GET /console 兑换 ticket → Set-Cookie → 302 到 /(无主令牌/cookie 凭据)
- GET/HEAD /(含深链接) 控制台 SPA 兜底:命中文件发文件,否则回落 index.html (/api、/ws 的未命中经前缀分派保持原生 404/405,不被 SPA 吞掉,见下方注册处)
func (*Server) Pool ¶ added in v0.3.5
func (s *Server) Pool() *targetclient.Pool
Pool 返回 target 客户端复用池。
用途:cmd/agentd.go 起预热循环、给 Mirror 注入同一个池——**必须是同一个**, 两个池等于两套隧道,relay 侧会看到重复的节点连接。
func (*Server) ReclaimPtySessions ¶ added in v0.3.5
ReclaimPtySessions 在 agentd 启动完成、开始监听前认领既有会话。
返回:扫描根目录失败时报错;调用方应记录错误并继续启动主服务。 注意:该导出薄壳只供 cmd 启动接线,实际三态逻辑在 reclaimPtySessions。
func (*Server) SetConfigPath ¶ added in v0.3.0
SetConfigPath 注入配置文件路径,供写配置时落盘。
参数:
- p: 配置文件绝对路径;空串表示不允许写配置(swapConf 会报错)
注意:与 SetManager 同款的构造后注入,必须在 Handler 开始服务前调用。
func (*Server) SetLedger ¶ added in v0.3.6
SetLedger 注入账本库(agentd 启动时;nil = 未配置,除 health 外 API 降级 503)。
func (*Server) SetManager ¶
SetManager 把任务管理器挂到 Server 上。
注意:
- manager 依赖本服务内部的 hub 与外部 adapter,必须在 NewServer 之后构造并注入
- 注入前三条路由返回 503(manager 未就绪),agentd bootstrap 顺序保证注入先于监听
- 挂接时会把 Server 的活配置取值函数交给 Manager。Manager 构造时收到的是一份 配置**快照**指针,而 swapConf 换的是新指针,读快照永远拿不到控制台改过的值 (B160 §4.2)。这一步是「保存后下一个任务即生效」成立的前提。
func (*Server) SetRestart ¶
SetRestart 注入优雅关停的触发函数(Shutdown.Trigger)。
必须在监听之前注入:换版接口返回 200 之后就靠它退出进程交接给新二进制, 没注入时换版会成功但永远不重启,而现场只剩一个「版本没变」的空结论。
func (*Server) SetUpdateDeps ¶
func (s *Server) SetUpdateDeps(d UpdateDeps)
SetUpdateDeps 替换换版接口的外部依赖。**仅供测试**:这些依赖会真的 执行文件、rename 二进制、停进程。
func (*Server) ShutdownPtySessions ¶ added in v0.3.5
ShutdownPtySessions 在 agentd 显式停止路径中停止全部 PTY 会话。
参数:ctx 是调用方的关停上下文。 返回:无;内部固定使用 2 秒总预算。 注意:该入口由显式停止路径调用,不挂在信号关停或进程内 Trigger(包括升级换版)上; 崩溃、OOM 与升级重启都必须让 ptyhost 留存。
type Shutdown ¶
type Shutdown struct {
// contains filtered or unexported fields
}
Shutdown 协调 agentd 的停机。
用法:NewShutdown 之后调 Serve,它会一直阻塞到停机或监听失败。 进程内的其它组件(B54.3 的更新循环)调 Trigger 来请求停机。
func (*Shutdown) Serve ¶
Serve 绑定 addrs 上的全部监听并阻塞,直到停机或任一监听失败。
参数:
- srv: 已配置好 Handler 的 HTTP 服务;addrs 为空时回退用 srv.Addr(单监听)
- cleanup: 停机时跑一次的清理闭包(关数据库、释放锁等)。**由调用方决定顺序**
- addrs: 监听地址列表(B85 双监听:主地址 + 可选的 loopback 辅址)
返回:
- nil 表示优雅关停完成(进程应 exit 0,管理器据此重新拉起)
- 非 nil 表示监听/启动失败(进程应 exit 1)。**任一地址绑不上都是启动失败** (B85 决策:辅助监听与主监听同等对待,「第三档 = 两个监听都在」恒成立)
type UpdateDeps ¶
type UpdateDeps struct {
// Getenv 取环境变量,闸二的判据来源
Getenv func(string) string
// Executable 返回当前二进制的真实路径(须已 EvalSymlinks)
Executable func() (string, error)
// Install 校验+解包+自检,返回可供 Activate 的临时文件路径
Install func(tgz []byte, wantSum, wantTag, destDir string) (string, error)
// Activate 原子换版,返回旧二进制的留存路径
Activate func(newPath, target string) (string, error)
// FetchByTag 按 tag 下载本机平台的资产并用 wantSum 校验。自拉模式专用。
//
// 为什么是 tag 而不是 release.Release:agentd **不查 GitHub API**——
// 资产地址是确定性的(release.AssetURL),而 api.github.com 有
// 60 次/小时/IP 的匿名限流,多台执行机很可能共用一个代理出口 IP
FetchByTag func(ctx context.Context, tag, goos, goarch, wantSum string) ([]byte, error)
// Platform 返回本机 goos/goarch。抽成缝只为可测:写死 runtime.GOOS 时
// "按本机平台取资产名"这条行为在单一平台的 CI 上验不出来
Platform func() (string, string)
}
UpdateDeps 是换版接口的外部依赖集合。
抽成结构体而不是散落的包级变量:这些依赖全都是「会真的动这台机器」的动作 (执行文件、rename 二进制、停进程),测试必须能整体替换掉,漏替一个就会 在 CI 上真的把测试二进制 rename 掉。
type Workspace ¶
type Workspace struct {
Branch string
WorkDir string // executor cwd 与审阅命令目录;原地模式 = Repo
Managed bool // WorkDir 是 agentd 创建的 worktree(done 时代删)
// NewBranchTip 是本次 dispatch 新建分支时的尖端 sha;空串表示分支不是本次
// 新建的(--branch <已存在分支> 模式)。补偿删分支前用它复核「自创建以来
// 没动过」。
//
// 为什么用 sha 而不是 BranchCreated bool:一个 bool 加一个 sha 能构造出
// 「声称建了分支却说不出它当时指向哪」这种非法状态,用单字段就构造不出来。
NewBranchTip string
// PrevRef 是非 managed 模式下 checkout 之前的 HEAD:正常在分支上时为分支名,
// detached 时为 commit sha,两者都能直接喂给 git checkout 复原。managed 模式
// 恒为空(新工作树没有「之前」)。空串表示采集失败,补偿据此放弃复原而非乱切。
PrevRef string
// RepoDirtyCount / RepoDirtyFiles 是 managed 模式下派发当时**主仓库**的脏快照
// (语义见 proto.Task 同名字段);非 managed 模式恒为零值——那两条路径的脏
// 工作区已被 ensureCleanWorktree 拒发,不存在「有改动却看不见」的情形。
RepoDirtyCount int
RepoDirtyFiles string
}
Workspace 是准备完成的工作区结果。
func PrepareWorkspace ¶
func PrepareWorkspace(ctx context.Context, req WorkspaceReq) (Workspace, error)
PrepareWorkspace 按 WorkspaceReq 准备任务工作区,返回结果。
参数:
- ctx: 控制整组 git 调用的生命周期,内部再叠加 WorkspaceGitTimeout 作为兜底上限
3 分支模式 × 3 工作树模式的 9 种组合行为表(分支 B/新分支 N/自动 A × 新树 N/用户树 U/原地 I):
新树(NewWorktree) 用户树(Worktree) 原地(默认) B worktree add <p> <b> 校验归属+脏,checkout b 脏检查,checkout b N worktree add -b b <p> t 校验归属+脏,checkout -b 脏检查,checkout -b b [t] A worktree add -b h <p> [t] 校验归属+脏,checkout -b 脏检查,checkout -b h [t]
其中 b=指定分支、h=handoff/<id8>、t=Base(N 行与 A 行均有效,空=HEAD;B 行 切已存在分支,不接受 Base,见第 1 层校验)、p=WorktreesDir/<id8> 或用户路径。 校验规则:Branch 模式分支必须已存在;用户树模式必须归属本仓库(git-common-dir 比对 + show-toplevel 必须等于入参);所有以 "-" 开头的分支名/路径一律拒绝(git 参数注入面)。
为什么 NewWorktree 免脏检查:新 worktree 是从仓库新建的独立工作树,天然干净, 与主仓库的脏状态无关——这是 worktree 并行派发的价值:主仓有人手动改动也不阻塞 新任务开跑;只有原地/用户树模式(复用既有工作树)才需要脏检查防污染。
为什么用户树归属校验用 git-common-dir 比对:普通目录与 worktree 的差别就在 「共享同一仓库 git 目录」;git-common-dir 对 main 仓库返回其 .git、对 worktree 返回同一值,路径经 EvalSymlinks 归一后相等即归属成立——比「读 .git 文件内容」 更稳(.git 可能缺失、可能被链到别处)。校验失败按 ErrBadWorkspaceReq 拒发。
type WorkspaceReq ¶
type WorkspaceReq struct {
Repo string // 主仓库路径
TaskID string
Branch string // 已存在分支(与 NewBranch 互斥)
NewBranch string // 新建分支名(空且 Branch 空 = 自动 handoff/<id8>)
Base string // 新分支起点(空=HEAD);与 Branch 互斥,NewBranch/自动分支均可带
Worktree string // 已存在 worktree 路径(与 NewWorktree 互斥)
NewWorktree bool
WorktreesDir string // agentd 管理的 worktree 根目录(DataDir/worktrees)
}
WorkspaceReq 描述 dispatch 的工作区诉求(分支 × worktree 两个正交维度)。
分支维度三态(互斥):Branch=已存在分支 / NewBranch=新建分支 / 都空=自动 handoff/<id8>;Base 是新分支起点(空=HEAD),与 NewBranch 和自动分支都能 连用,只与 Branch 互斥——切已存在分支时没有「起点」这回事。 worktree 维度三态(互斥):Worktree=用户自带 worktree 路径 / NewWorktree=由 agentd 在 WorktreesDir 下新建 managed worktree / 都空=原地(主仓库)。
Source Files
¶
- admission.go
- approver.go
- auth.go
- authroutes.go
- bundle.go
- cardstep.go
- codegraph.go
- desktopstate.go
- discipline.go
- env.go
- eventframes.go
- executor_default.go
- forcereclaim.go
- forward.go
- forward_ws.go
- frames_stream.go
- gitignore.go
- gitroot.go
- hostguard.go
- hub.go
- killmode.go
- ledgerapi.go
- lock.go
- machineadmin.go
- machines.go
- machineupgrade.go
- manager.go
- manualworktree.go
- mirror.go
- opennonblock_unix.go
- projectadmin.go
- projectfanout.go
- projectjoin.go
- projectresolve.go
- projecttree.go
- pty_api.go
- pty_ws.go
- ptyreclaim.go
- pullstate.go
- reclaim.go
- reconcile.go
- render_stream.go
- reveal.go
- runshell.go
- server.go
- shutdown.go
- status.go
- taskplan.go
- taskroute.go
- tasksfanout.go
- ticketvoid.go
- update.go
- updatedownload.go
- watchdog.go
- webhandler.go
- workbench_api.go
- workspace.go
- workspace_procgroup_unix.go
- workspacefiles.go
- workspaceprobe.go