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 退化为只做「调模型 + 解析裁决」。
本文件负责一件事:把「cwd 落在哪」归并成「项目在这台机器上的那一个位置」。
职责:
- MainWorktreeRoot:无论调用点在主仓、主仓子目录、linked worktree 还是 worktree 子目录,一律返回**主工作树的根目录**
边界:
- 不读登记表、不碰数据库:它只回答「这个目录属于哪个仓库根」
- 不判断该仓库有没有 origin:那是登记层 projectOriginURL 的事
- 不做 symlink 求真(不 EvalSymlinks):返回值来自 git 自己的输出与调用方 给的目录,保持与 git 一致的视角
为什么必须归并(B62):项目位置表以 project_id 为主键,一个项目在一台机器上 只能有一行。本仓库当前有十几个 linked worktree,它们与主仓 origin 相同、 project_id 相同——不归并就会撞主键,允许多行则项目树彻底没法看。
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 里),只是从没被显式说明过。
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 上移而来,全项目只保留这一份实现),本文件只做日志与文案
本文件实现 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)吸收
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 凭据
本文件是项目解析的**纯逻辑**层:把「派发请求指的是哪个项目」翻译成 「executor 应该在本机的哪个目录工作」。
职责:
- resolveProject:按 project_id / project_name 在位置表里查出那一行
- locationLines:把位置表压成人可读的清单,供拒绝报文使用
边界:
- 不碰数据库:位置行由调用方查好后以切片传入
- 不碰 git、不碰文件系统:路径是否真的可用由 EnsureRepoUsable 另行判定
- 不碰 HTTP:错误只用哨兵表达,状态码映射在 server.go
- **不接受任何路径入参**:调用方描述「代码在这台机器的哪个目录」正是 B62 要根除的漏洞(spec §1.2)。路径由本机查表得出,别人不许指定
为什么单独成文件且刻意保持纯净:这段规则是 dispatch 的必经之路,一旦错了 就会把任务派到错误的项目上。纯函数才能表驱动穷举。
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 要多一个依赖和一套跨平台差异, 而它换来的延迟改善对「人在看文本」这个场景毫无意义。
本文件实现 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 「不新增周期性探活」不冲突——那条拒绝的是后台定时扫
本文件实现「工单作废 + 留痕」这一个动作(B63)。
职责:
- 把一个任务的全部未回答工单作废,并按需产出一条 tickets_voided 审计事件
- 作为 transit 终态分支与 reconcileExecutorGone 的唯一共用实现
边界:
- 不判断「该不该作废」——时机由调用方决定(终态 / executor 已死)
- 不 Publish:tickets_voided 是纯审计事件,实时流上不出现(见 proto 常量注释)
- 不因作废或写事件失败而中断调用方:状态迁移已经发生,为一条审计写失败回滚 终态得不偿失
update.go —— POST /api/update:接收推来的二进制,换版并触发重启。
职责:
- 复检两道闸(活跃任务 / 非托管),拒绝时给出可判别的 reason
- 校验 sha256、解包、自检、原子换版并保留 .prev
- 换版成功后触发优雅关停,由进程管理器拉起新二进制
边界:
- **不出网**:资产由 CLI 下载并推来,这里只收字节(B59 spec D1)
- 不做回滚编排:换版失败时 release.Activate 自己把 .prev 换回去, 人工回滚是 handoff upgrade --rollback,不在这条路径上
- 不做鉴权加码:持有 bearer token 的人本来就能 handoff run 执行任意命令, 推二进制不构成提权。token 就是信任边界(spec D4)
watchdog.go —— 任务级卡住看门狗与 agentd 启动恢复。
职责:
- RunWatchdog:周期扫描 running/waiting_answer 任务,最新事件早于 stallTimeout 判定卡住,追加 stalled 事件并广播,唤醒协调者裁决(spec §8「任务级超时看门狗」)
- RecoverOnStartup:agentd 启动时对 running/waiting_answer/waiting_review 任务 逐个探测执行器存活(spec §8「agentd 崩溃后重启恢复」);running/waiting_answer 不存活 → failed 事件 + 迁移 waiting_review 交协调者裁决;waiting_review 不存活 → 保持现状(本就是待审核终态,不追加事件不迁状态);存活(含 waiting_review) → 重建 SSE 订阅继续消费
边界:
- 不做状态机之外的业务决策:stalled 只唤醒不改状态(executor 可能仍在干活, 只是没有事件产出),恢复的 failed 迁移固定落 waiting_review,不自动重试
- 不直接接触 adapter:重建订阅的具体动作经探活闭包注入(见 RecoverOnStartup 的 seam 说明),本文件只负责「探测结果 → 事件/状态」的翻译
- tick 间隔是 runWatchdog 的参数(测试注入 10ms),RunWatchdog 固定每分钟一次
本文件是 agentd 侧 git 工作区操作与文件/命令读取的唯一出口。
职责:
- 派发前的工作区准备:PrepareWorkspace 按分支×worktree 两个正交维度准备任务 工作区(脏工作区一律拒绝;new-worktree 免脏检查)——PrepareBranch 是其 原地+自动分支的过渡薄包装
- 协调者审阅素材:Diff(基准分支到 HEAD 的差异 + 提交列表)、 ReadFile(读仓库内文件)、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 实现):让命令成为独立进程组组长, 超时/取消时把组内孙进程一并回收,不留孤儿。
Index ¶
- Constants
- Variables
- func Diff(repo, baseBranch string) (string, error)
- func EnsureRepoUsable(ctx context.Context, repo string) error
- func MainWorktreeRoot(ctx context.Context, dir string) (string, error)
- func PrepareBranch(ctx context.Context, repo, taskID string) (branch string, err error)
- func ReadFile(repo, rel string) (string, error)
- func RecoverOnStartup(st *store.Store, hub *Hub, probe func(taskID string) bool, ...) error
- func RemoveManagedWorktree(ctx context.Context, repo, workdir 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 WarnIfKillModeUnsafe(log *slog.Logger)
- 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) FootprintAll() (*proto.FootprintResp, error)
- func (m *Manager) ListProjects(ctx context.Context) ([]proto.ProjectLocation, 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) UnregisterProject(ctx context.Context, name string) error
- type RecoverReport
- type RegisterProjectReq
- type Server
- type Shutdown
- type UpdateDeps
- type Workspace
- type WorkspaceReq
Constants ¶
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("路径是目录,不是文件") ErrNotRegularFile = errors.New("路径不是普通文件(管道/设备等特殊文件不可读)") ErrRepoUnusable = errors.New("任务仓库不可用(路径不存在或不是 git 仓库)") ErrBadBaseBranch = errors.New("非法的基准分支:不允许以 - 开头") ErrBadWorkspaceReq = errors.New("工作区参数非法") ErrWorkdirBusy = errors.New("目标工作目录已被活跃任务占用") // ErrBaseCommitMissing 表示协调者本地的基线提交在任务仓库中不存在, // 且 fetch 后仍补不回来——远程仓库落后于本地,派发出去的活会建在错误的基准上。 ErrBaseCommitMissing = errors.New("基线提交在任务仓库中不存在") // ErrBranchIdentityMismatch 表示 git 报告成功,但工作区实际所在的分支 // 不是我们请求的那个(B76:worktree add -b 被 DWIM 顶替)。 ErrBranchIdentityMismatch = errors.New("工作区分支与请求不符") )
错误定义:
- ErrDirtyWorktree:工作区有未提交/未跟踪的改动,拒绝派发
- ErrPathEscape:请求的文件路径逃逸出任务仓库(含符号链接逃逸)
- ErrPathIsDir:请求的文件路径指向目录(fetch 只服务普通文件)
- 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 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 FetchTimeout = 2 * time.Minute
FetchTimeout 是基线缺失时补拉远端的时长上限。 独立于 WorkspaceGitTimeout:fetch 走网络,与本地 git 操作不是一个量级。
Functions ¶
func Diff ¶
Diff 取任务分支相对基准分支的完整审阅素材:git diff <base>...HEAD 的差异 + git log --oneline <base>..HEAD 的提交列表,按序拼接。
参数:
- repo: 任务仓库路径
- baseBranch: 基准分支(通常为派发前的分支,如 main/master)
返回:
- 拼接后的文本(diff 为空时只有提交列表,两边都空时为空串)
- err: git 失败返回错误(如 baseBranch 不存在,stderr 已并入日志); baseBranch 以 "-" 开头返回 ErrBadBaseBranch
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 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 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 对源文件足够; 大文件多为生成物/数据文件,拒绝会让协调者误以为路径有误。
非普通文件(目录/管道/设备等)一律拒绝:目录给 ErrPathIsDir(400 语义), 其余特殊文件 read 语义不可控(可能无限输出或永久阻塞),给 ErrNotRegularFile。
参数:
- repo: 任务仓库路径
- rel: 相对仓库根的路径(如 cmd/foo.go)
返回:
- 文件内容(超过 1MiB 时截断为开头 1MiB)
- 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 不存活:追加 failed 事件(原因固定为「agentd 重启后 执行器已不在」)并迁移 waiting_review,交协调者裁决(失败现场留在事件里, 协调者凭 tasks/attach 可见);该任务的挂起工单一并作废(P1-16,见 VoidPendingTickets 的语义),事件照常广播,启动期无人订阅则由客户端凭 seq cursor 补拉
- waiting_review 不存活:保持现状即可——它本来就是待审核终态,等待协调者 裁决(continue 重派 / done 归档)是既有的终态语义,追加 failed 事件或再迁 状态只会产生噪音,**不**复用 running/waiting_answer 的 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 的工作区)
注意:
- 只删工作树不删分支(spec:任务分支保留供审阅/回滚)
- workdir 带未提交改动时 git 拒绝删除(错误带 stderr 原文返回);是否降级 由调用方(Done 归档)决定——本函数不做清理性降级
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, log *slog.Logger)
RunWatchdog 启动任务卡住看门狗并持续运行,直到 ctx 取消。
参数:
- ctx: 控制看门狗生命周期;取消时立即退出(调用方负责在进程退出前 cancel, 避免 goroutine 泄漏)
- st: 持久化存储(任务列表与最新事件的数据源)
- hub: 实时路由(stalled 事件广播)
- stallTimeout: 判定「卡住」的空闲时长(最新事件距今超过它即触发)
- log: 本模块日志入口
注意:
- 扫描间隔固定为 watchdogTick(每分钟);测试需要注入短间隔时直接调用 同包的 runWatchdog 并传入 tick 参数
- 每轮扫描对 running/waiting_answer 任务判定;同一任务在 stalled 之后若无 活动(新事件或 task.UpdatedAt 前进)不会重复触发,有活动(如协调者 reply) 且 executor 仍无事件产出时下一轮会二次触发(「只发一次」按活动裁决, 设计见 scanStalled 的函数头 P1-15a)
- 每轮除卡住判定外,还判读一次进程余量高水位(见 scanPressure),越线沿 给每个活跃任务发一条 resource_pressure 事件唤醒协调者收敛
func WarnIfKillModeUnsafe ¶
WarnIfKillModeUnsafe 在 agentd 启动期检查 systemd KillMode 并按需告警。
注意:
- 只打日志,绝不阻断启动(见文件头边界)
- 非 systemd 环境完全静默——macOS 开发机是主要使用场景,不能有噪声
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
// 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/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, approver *Approver, gate *permgate.Gate, log *slog.Logger) *Manager
NewManager 创建任务管理器。
参数:
- st: 持久化存储(任务/事件/工单的唯一落库点)
- hub: 进程内实时路由(事件广播 + ticket 应答等待)
- ads: executor 注册表(name → Adapter,如 {"opencode": ..., "fake": ...}); 任务按 executor 名路由,缺省名取 cfg.Executor.Default
- cfg: 配置(DataDir 用于派生任务目录、Executor.Default 为缺省执行者名)
- 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) FootprintAll ¶
func (m *Manager) FootprintAll() (*proto.FootprintResp, error)
FootprintAll 体检全部任务(含已归档)的进程足迹。
返回:
- 每个任务一行(含判定结论)与本机 uid 占用;查询任务列表失败才返回错误
注意:
- **只读,绝不发信号**:本方法只数不杀。数出来之后要不要动手是人的决定
- 与 status 分开的理由:本方法遍历全部历史任务目录,天然是慢命令; status 有「不能变成慢命令」的硬纪律,两者不能合并
- 已归档任务同样体检:Done 只删 worktree、不删任务目录,凭据都还在
func (*Manager) ListProjects ¶
ListProjects 列出本机全部项目位置,并现场探测每条的实际状态。
参数:
- ctx: 控制探测用的 git 调用生命周期
返回:
- 位置列表(Status 已填充);查库失败时返回错误
注意:
- 探测是登记与文件系统漂移的可见化手段。探测失败不影响列出—— 状态本身就是要报给人看的结果,不是错误
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 失败时留下一条指向 不存在路径的死记录
- 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 为目标任务
注意:
- 无返回值:调用方全都处在收尾路径上,清扫成败不该反过来影响那件事
- 只有「确实有残留但我们没敢动」才发事件提示人工;成功与无残留只进日志。 这是尊重 stopExecutor 已经想清楚过的事——「其余失败五花八门,全发事件 等于把协调者淹了,那样这条提示就没人看了」
- 导出是因为 RecoverOnStartup 的接线点在 cmd/agentd.go(与 ResumeTask 同理), 不是给外部当通用 API 用
func (*Manager) UnregisterProject ¶
UnregisterProject 注销一条项目位置。
参数:
- ctx: 上下文(当前实现不发起 git 调用,保留以对齐其余操作签名)
- name: 项目引用名
返回:
- 错误:位置不存在时 store.ErrNotFound(404);路径被活跃任务占用时 ErrWorkdirBusy(409)
注意:
- **只删登记,永不删磁盘上的仓库**。磁盘上那份是不是还要留,由人自己决定
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 是否为空决定:
- Path 非空:这台机器上已经有一份,用它(agentd 现读它的 origin 校验一致)
- Path 为空:由本机 clone 到 cfg.RepoRoot/<Name>
为什么没有 Clone 布尔位:形态已被 Path 完全决定,多一个布尔位只会多出 一组无意义的非法组合。
Name 可省,此时由 OriginURL 末段派生;它只是人可读引用,不参与身份判定。
type Server ¶
type Server struct {
// contains filtered or unexported fields
}
Server 是 agentd 的 HTTP/WS 服务端,持有配置、存储与进程内实时路由 hub。
并发安全:所有字段只读(构造后不变),hub 自身线程安全,无需额外加锁。
func NewServer ¶
NewServer 创建 agentd 服务端。
参数:
- cfg: 配置,鉴权使用 cfg.Token
- st: 持久化存储
- log: 本服务日志入口
注意:
- hub 在内部创建,构造时捕获 slog.Default();如需统一日志格式,调用方应先在 slog.SetDefault(logx.Setup(...)) 之后再调用 NewServer
func (*Server) Handler ¶
Handler 返回带 Bearer 鉴权中间件的完整路由,便于 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}/file 读任务仓库内文件(审阅上下文)
- POST /api/tasks/{id}/run 在任务仓库执行审阅命令(跑测试/lint)
- POST /api/projects 登记项目(必要时先克隆)
- GET /api/projects 列出项目位置(含现场实际状态)
- DELETE /api/projects/{name} 注销项目位置(只删登记,不动磁盘)
- GET /ws/events 事件流(补发 + 实时)
func (*Server) SetManager ¶
SetManager 注入任务管理器,激活 dispatch/continue/done 三条路由。
注意:
- manager 依赖本服务内部的 hub 与外部 adapter,必须在 NewServer 之后构造并注入
- 注入前三条路由返回 503(manager 未就绪),agentd bootstrap 顺序保证注入先于监听
func (*Server) SetRestart ¶
SetRestart 注入优雅关停的触发函数(Shutdown.Trigger)。
必须在监听之前注入:换版接口返回 200 之后就靠它退出进程交接给新二进制, 没注入时换版会成功但永远不重启,而现场只剩一个「版本没变」的空结论。
func (*Server) SetUpdateDeps ¶
func (s *Server) SetUpdateDeps(d UpdateDeps)
SetUpdateDeps 替换换版接口的外部依赖。**仅供测试**:这些依赖会真的 执行文件、rename 二进制、停进程。
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)
}
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 / 都空=原地(主仓库)。