上篇我们拆解了 Git 集成——opencode 通过两层抽象将 git 封装为类型安全的 Service。本篇聚焦于 project/workspace 管理,探讨工作单元如何被组织与追踪。
那么,问题来了:如果你需要设计一个 AI Agent 的工作单元——它必须知道当前工作在哪个目录、项目是否包含 git、以及该目录之前是否已有 session——你会如何进行抽象?
一个直观的想法是:直接用 process.cwd() 作为 session 的唯一标识,当前工作目录决定了全部。但假设你在同一个 monorepo 的不同子目录中分别启动 Agent,例如 packages/opencode/ 和 packages/core/——它们位于同一个 git 仓库,共享同一个 .git 目录。process.cwd() 会返回两个不同的路径,然而逻辑上它们属于“同一个项目”。反过来,如果你使用 git worktree 同时打开同一个仓库的不同分支——比如 /project/main 和 /project/feature——这两个目录各自拥有独立的 .git,代表独立的项目。由此可见,cwd 既不能唯一标识项目,也无法唯一标识工作单元。
因此,opencode 的选择是:用一个三元组(directory, worktree, project)来定义工作单元,并通过 InstanceContext 绑定这三个字段。 整个 project/workspace 管理模块仅有 700 行代码,核心任务聚焦于三件事——从目录自动发现项目、管理 InstanceContext 的生命周期、以及支持 sandboxes(一个项目关联的多个工作目录,如 monorepo 中的不同子目录)的多目录能力。
【问题】“这个文件属于哪个项目”的三个变体
directory ≠ project:反直觉的第三个字段
InstanceContext 定义在 packages/opencode/src/project/instance-context.ts:5-9,仅包含 3 个字段:directory、worktree、project。但为什么需要三个?两个字段(directory + project)难道不够吗?
如果 opencode 只使用 directory(当前工作目录)作为唯一标识,那么在同一个 git 仓库的不同子目录中启动 session,就会被视为“不同项目”——虽然它们的 git 仓库是同一个,但 sandboxes 应该合并而非新建。反之,如果只使用 worktree(git 仓库根目录),那么当通过 git worktree 切换分支时,两个 worktree 路径不同,但尚未完成的 session 数据应当隔离。
因此,三个字段对应三个不同的生命周期:directory 是 session 粒度的(一次 opencode 命令的上下文),worktree 是项目粒度的(git 仓库的物理位置),project 是跨 session 粒度的(数据库中持久化的项目状态)。三种粒度,对应三种消费场景。
实际生效的边界判定在 containsPath(instance-context.ts:18-23)中:
export function containsPath(filepath: string, ctx: InstanceContext): boolean {if (FSUtil.contains(ctx.directory, filepath)) return trueif (ctx.worktree === "/") return falsereturn FSUtil.contains(ctx.worktree, filepath)}
首先检查 directory——如果文件位于 session 的启动目录内,则直接通过。接着检查 worktree——对于非 git 项目,worktree 被设置为 "/",这会匹配所有绝对路径,因此 / 场景下跳过 worktree 检查。这个 / 值的处理并非边界 hack,而是设计选择:全局模式下的 worktree 就是 /,此时文件访问仅受 directory 约束。如果你在 /tmp 下运行 Agent,且 /tmp 不是 git 仓库,那么 /tmp 就是你全部的可见空间——既不应询问 /etc 是否属于你(不在 directory 内,也不在 worktree 内),也不应误以为 / 内的所有内容都归你管理(因为 worktree 被跳过)。
为什么非 git 目录就不是项目
opencode 的项目发现机制隐藏着一个设计原则:没有 git 的项目不是“项目”,仅仅是一个目录。 非 git 项目的 worktree 被硬编码为 /,sandboxes 为空数组,project.id 被设为 global。这并非歧视非 git 项目——而是因为 opencode 的大部分功能(如 diff/review/checkout/commit/apply patch)严重依赖 git。没有 git,Agent 的写操作缺乏回退能力,review 缺少基准线,branch 切换也无法感知。如果一个目录确实没有 git,opencode 仍然允许你工作——但仅限于“只读 consult(咨询式对话)”模式,不能执行写操作。
这个设计选择背后的工程理由在于:git 是 opencode 的底层存储抽象,而非可有可无的工具。 第三章之前拆解的 Git.Service 的 350 行代码、Vcs 的 431 行代码、以及 Snapshot 的自建 git 仓库——它们都假设 git 存在。在非 git 项目上,这些模块不会抛出错误(已通过 Effect.catch 处理),但实际不会执行任何有用操作。因此,fromDirectory 在发现没有 git 时,会将 vcs 设置为 undefined,后续所有依赖 git 的功能会自动降级。
【设计】三层抽象:directory → project → instance
InstanceContext:三个字段划定边界
整个工作单元模型的入口是 InstanceContext。当你使用 InstanceStore.load("/my/project") 时,会得到:
{directory: "/my/project/src", // session 启动目录worktree: "/my/project",// git 仓库根目录project: {id: "my-project", // 项目唯一标识worktree: "/my/project",vcs: "git",sandboxes: ["/my/project/src", "/my/project/libs"],time: { created: 1748512345678, updated: 1748512345678 }}}
三个字段的关系是:directory ⊆ worktree(session 目录一定在 git 仓库内),worktree 等于 project.worktree 的值(一个 project 只有一个物理 worktree),sandboxes 可用于记录额外的 session 启动目录。因此,containsPath 需要先检查 directory,再检查 worktree——sandboxes 中记录的目录可能不在 worktree 下(例如 Cargo workspace 的独立 crate 目录)。
这里的关键设计决策是:InstanceContext 是一个贫接口,而非富 Service。 它不执行任何操作,只是将三个字段绑定在一起。实际的操作(如项目发现、生命周期管理、session 关联)都委托给其他 Service。InstanceContext 只是“结果的快照”,而非“过程的控制器”。这意味着你可以安全地跨模块传递 InstanceContext,无需担心它持有锁或引用。
fromDirectory:5 步自动发现管道
Project.fromDirectory(project.ts:242-339)是项目发现的入口,通过 5 步完成从路径到完整 project 的映射:
① projectV2.resolve(AbsolutePath.make(directory)) — 核心库调用。从给定路径反向查找最近的 git 仓库根目录(查找 .git 目录链),返回 { id, directory, vcs }。这一步决定了“我在哪个项目里”。
② migrateProjectId(previous, projectID) — 项目 ID 迁移。如果 resolve 发现旧 ID 与新 ID 不同(例如项目被重命名或 .git 配置发生了变化),则迁移数据库中的 session、workspace 记录至新的 ID。这是 git worktree 切换后的关键保障——切换分支后,.git 的 worktree 配置发生了变化,resolve 可能返回不同 ID,迁移确保 session 不会丢失。
③ upsert(ProjectTable) — 数据库行写入。INSERT ... ON CONFLICT DO UPDATE,将解析后的 project 信息持久化到 SQLite。核心行只有 60 行,但覆盖了 worktree、vcs、name、icon、sandboxes、commands、time_initialized 共 7 个字段的 upsert 操作。
④ saveProjectDirectory — 辅助索引。在 ProjectDirectoryTable 中记录 directory → projectID 映射。这个表只做一件事——后续 InstanceStore.load 无需再处理 project 发现逻辑,直接通过该表查询当前目录属于哪个 project。
⑤ emitUpdated — 事件通知。发出 project.updated 事件,GlobalBus 将新状态广播给所有订阅者(包括 TUI、SDK event stream、工作区面板)。
在这 5 步中,第 ① 步是最频繁的执行路径——每次 InstanceStore.load 都会调用一次 fromDirectory。如果项目已在数据库中,第 ② 步会跳过(迁移仅在 ID 不同时执行),第 ③ 步是 upsert(有则更新,无则插入),第 ④ 步是插入。整体成本大约为一次 resolve + 一次 upsert + 一次 insert + 一次事件广播,约 3ms(本地 SQLite)。
Project Schema:9 个字段定义项目状态
export const Info = Schema.Struct({id: ProjectV2.ID,worktree: Schema.String,vcs: optionalOmitUndefined(ProjectVcs),name: optionalOmitUndefined(Schema.String),icon: optionalOmitUndefined(ProjectIcon),commands: optionalOmitUndefined(ProjectCommands),time: ProjectTime,sandboxes: Schema.Array(Schema.String),}).annotate({ identifier: "Project" })
9 个字段(project.ts:45-54)分为三组:
标识组(id, name, icon)——标注你是谁。id 是自动生成的(由 git 仓库的路径哈希决定),name 和 icon 可由用户设置或自动发现(discover() 方法会在 worktree 中扫描 favicon.*)。
边界组(worktree, vcs, sandboxes)——定义你在哪。worktree 是物理根目录,vcs 是版本控制类型(目前仅支持 "git"),sandboxes 是额外的工作目录列表。sandboxes 的存在使得一个 project 可以关联多个路径——例如同时打开 libs/core/ 和 apps/web/ 两个目录,它们共享同一个 git 仓库(同一个 id),open system 会将两个目录都加入 sandboxes,后续的 fromDirectory 不会重复创建 project。
生命周期组(time.created, time.updated, time.initialized)——记录何时开始。initialized 是一个特殊的时间戳——它在用户第一次执行 /init 命令时被设置。这个字段的存在表明了一个设计意图:项目创建与项目初始化是两个独立的事件。 前者在 fromDirectory 中自动发生(第一次打开目录即注册),后者需要用户显式触发(通过执行 /init 设置项目配置)。用户可能只是临时打开一个目录查阅资料,尚未决定是否在该项目中进行实质性工作——opencode 不会因为未初始化而报错或阻塞。
特别地:time.initialized 的设置并非通过 Project Service 自身完成,而是通过订阅 Command.Event.Executed 事件(project.ts:416-425)。这种做法符合 opencode 的一贯风格:核心 Service 不耦合具体的命令逻辑。
【源码】InstanceStore:工作单元生命周期
Deferred 异步加载:合并同一目录的请求
InstanceStore(instance-store.ts:108-124)是整个 project/workspace 管理的运行时核心。它的 load 方法并非直接调用 boot,而是采用了一种精妙的模式:
const load = (input: LoadInput): Effect.Effect<InstanceContext> => {const directory = FSUtil.resolve(input.directory)return Effect.uninterruptibleMask((restore) =>Effect.gen(function* () {const existing = cache.get(directory)if (existing) return yield* restore(Deferred.await(existing.deferred))const entry: Entry = { deferred: Deferred.makeUnsafe<InstanceContext>() }cache.set(directory, entry)yield* Effect.forkIn(scope, { startImmediately: true })(Effect.gen(function* () {yield* completeLoad(directory, input, entry)}),)return yield* restore(Deferred.await(entry.deferred))}),)}
三步走:① 缓存命中 → 等待已有的 Deferred(Effect 中代表异步结果的容器,可被 await 一次或多次)完成。② 缓存未命中 → 创建 Deferred,通过 forkIn 在后台运行 boot。③ 调用方挂起等待结果。
这里的关键在于 forkIn(scope, { startImmediately: true })——boot 过程不会阻塞当前 Effect 流程。load 立即返回 Deferred.await,让调用方感觉“我拿到了一个 ctx”。如果 boot 失败,Deferred.done(exit) 会释放所有等待的调用方,它们同时获得失败结果,不会出现多个调用方各自重试的情况。
为什么不使用简单的 Promise.all 模式?因为在同一个 session 中多次调用 load(例如 config-service 和 project 同时需要 InstanceContext),如果使用 Promise.all 会触发两次 boot。Deferred 模式结合 Map 绑定,确保同一个目录只有一个 boot 在执行。opencode 在多个场景中采用这种模式处理“同源并发”——如 capture-screenshots.js 的 HTTP server 初始化、Config.Service 的远程配置加载、Watcher 的文件系统监听初始化——都是先检查缓存,没有才创建,确保只有一个执行者。
boot:fromDirectory → bootstrap → InstanceRef
boot 函数是 load 的实现细节(instance-store.ts:45-63):
const boot = (input: LoadInput & { directory: string }) =>Effect.gen(function* () {const ctx: InstanceContext = input.project && input.worktree? { directory: input.directory, worktree: input.worktree, project: input.project }: yield* project.fromDirectory(input.directory).pipe(Effect.map((result) => ({directory: input.directory,worktree: result.sandbox,project: result.project,})),)yield* bootstrap.run.pipe(Effect.provideService(InstanceRef, ctx))return ctx})
包含两段逻辑:如果调用方直接传入了 project + worktree(例如 reload 场景),则跳过 fromDirectory,直接构造 InstanceContext。否则走 fromDirectory 的自动发现管道。
获取 ctx 后,立即执行 bootstrap.run。InstanceBootstrap 的定义在 bootstrap-service.ts——它只是一个 Effect.Effect 的接口。具体实现由 bootstrap.ts 提供,内容包括:Watcher(文件系统监听器)初始化、EventV2 会话建立、Permission 上下文绑定。所有这些初始化操作都通过 Effect.provideService(InstanceRef, ctx) 注入 ctx——bootstrap 实现方通过 InstanceRef 获取当前 instance 的 InstanceContext,无需知道调用方是谁。
这里的设计模式值得注意:InstanceRef 是一个 Context Service,而非全局变量。 provideService 将 ctx 注入到 Effect 的作用域中,bootstrap 实现方通过 yield* InstanceRef 获取。它不依赖全局变量,不污染函数签名。Effect 的 Layer 系统保证了这种注入是类型安全的——如果你在 bootstrap 中尝试获取一个未注入的 Service,TypeScript 编译期就会报错。
dispose:三路清理 + EventBus 事件
整个 instance 的生命周期遵循 load → boot → use → dispose 的流程:
Instance 的销毁配置了三路清理:
路径 A:dispose(ctx) — 从外部传入 ctx,在缓存中查找对应 entry,验证匹配后执行清理。这是 TUI 关闭面板、SDK 客户端断开、用户手动执行 /exit 时的清理路径。
路径 B:disposeDirectory(directory) — 仅通过目录名进行清理。在不知道 ctx 的情况下(如清理脚本、后台 GC 任务),也能精确清理特定目录。
路径 C:disposeAll() — 销毁所有 instance。在进程退出时执行,通过 cachedWithTTL(Duration.zero) 确保只执行一次。Scope.addFinalizer 在 layer 初始化时注册了这个清理函数——layer 的 scope 就是一个 instance 的“全局生命周期”。
三层 dispose 共享同一个 disposeContext 函数(instance-store.ts:94-98):
const disposeContext = Effect.fn("InstanceStore.disposeContext")(function* (ctx: InstanceContext) {yield* Effect.promise(() => runDisposers(ctx.directory))yield* emitDisposed({ directory: ctx.directory, project: ctx.project.id })})
runDisposers 是注册在 instance-registry.ts 中的同步函数集合(通过 disposeInstance 注册)。每个模块在初始化时通过 disposeInstance 注册自己的清理函数——例如 Watcher 关闭文件监听、Session 保存未完成的 session 数据、Permission 释放权限缓存。这种“注册式清理”的好处在于:InstanceStore 无需知道有哪些模块需要清理,只需遍历 registry 逐个调用即可。
最后,emitDisposed 发出 server.instance.disposed 事件。该事件通过 GlobalBus 广播给所有连接——TUI 面板关闭标签页、SDK client 收到 instance 断开通知、HTTP API 的 workspace routing 清理路由表。
【权衡】为什么不用全局单例?
三个方案,一个选择
最朴素的方案是全局单例——系统中只有一个 InstanceContext,谁需要谁 import。opencode 的作者实际上也考虑过这个方案,代码库中甚至留有遗迹——GlobalBus 本身就是一个全局单例,但 InstanceStore 没有选择这条路。
| 维度 | 全局单例 | 目录映射(opencode 的选择) | 懒加载 Proxy |
|---|---|---|---|
| API 简洁度 | Instance.current() | InstanceStore.load(dir) | instance(directory).project |
| 多目录并发 | ❌ 不支持 | ✅ 每个目录独立 ctx | ✅ 原型支持 |
| 清理粒度 | ❌ 全局一起清理 | ✅ 按目录精确清理 | ⚠️ GC 难度较高 |
| 类型安全 | 良好(有状态) | 良好(每次 load 返回新值) | 较差(Proxy 返回的类型不确定) |
| 实现复杂度 | 约 50 行 | 约 200 行 | 约 400 行 |
全局单例被否决的原因并非多目录并发(Agent 一次只运行一个 session 的场景居多),而是清理粒度。sandboxes 的存在意味着一个 project 可能关联多个目录。如果采用全局单例,A 目录的 instance 清理会干扰 B 目录的 boot 结果。opencode 的 disposeDirectory(dir) 可以实现精确清理,因为缓存的关键字是 directory。
那么极端的场景是:同一个目录被两个不同的 session 同时使用(例如两个 WebSocket 客户端同时连接到同一个 instance)。这会导致缓存被覆盖吗?观察 load 的实现——cache.set(directory, entry) 会覆盖,但第二个请求不会销毁第一个的 ctx(通过 restore(Deferred.await(entry.deferred)) 返回同一个 ctx)。两个 session 获取的 InstanceContext 引用相同,任何一方调用 dispose 也不会影响另一方(dispose 会验证 Entry 的匹配后才执行清理)。但如果第一个 session 调用了 disposeDirectory,第二个 session 的调用将拿到 disposed 后的 ctx。opencode 对此场景的态度是:同一个目录不应同时被两个独立 session 管理——如果有,后面的 session 应首先启动 admin 操作(决定是接管还是等待)。InstanceStore 的 reload 方法正是为接管设计的——它先 dispose 旧的,再 boot 新的。
那么,为什么非要使用 map
这触及了 Effect 的作用域边界问题。load 返回的 ctx 是 Effect 值,而非 Service——它不会被 Layer 自动管理。如果将 ctx 放在 Scope 中(像 Watcher 的文件监听那样),那么每个调用 load 的调用方都需要自行管理 scope 生命周期,一旦 scope 过期,ctx 就会丢失。InstanceStore 选择使用 Map + Deferred 自行管理生命周期——不依赖调用方的 scope 管理。代价是增加了 dispose 的手动清理步骤,但换来的是 ctx 的生命周期不再绑定在某个 Effect 流程上——即使用户断开 WebSocket(Effect scope 被清理),ctx 仍保留在缓存中,下次同一目录重连时无需重建。
【锚点】Context-as-a-Service 模式
三个字段定义了一个工作单元的一生
从这篇文章中,你可以带走三个心智模型:
第一——项目的“身份”由 git 决定,而非由用户决定。opencode 的 fromDirectory 通过 projectV2.resolve 反向查找 git 仓库,然后 upsert 到 SQLite。没有 git 的目录仍然可以工作,但其 project.id 为 global,vcs 为 undefined,sandboxes 为空数组——所有依赖 git 的功能会自动降级。这个设计的力量在于:你无需告诉 opencode“我在做什么项目”——它自己会查。
第二——InstanceContext 的“贫接口”是刻意为之。它只有三个字段,不执行任何操作。InstanceStore 提供 load/reload/dispose 生命周期,Project.Service 提供 fromDirectory/list/update 查询管道——但 InstanceContext 不引用其中任何一个。这意味着它可以在任何 Effect 作用域中安全传递,不会因为携带 Service 引用而导致循环依赖。
第三——Deferred + Map 是 Effect 世界中的“懒加载单例”模式。它不会在启动时创建所有 instance(因为不知道用户会打开哪个目录),也不会在每次调用时重新创建(成本太高)。第一次被请求时创建并缓存,后续请求复用——通过 forkIn 将初始化过程下沉到后台,调用方仅看到 Deferred.await。这个模式在 opencode 中至少出现了 4 次(InstanceStore、Config 远程加载、Watcher 初始化、capture-screenshots HTTP server),是 Effect 生态中的惯用做法。
下次设计工作单元时
当你的系统需要管理多个“上下文”——每个上下文拥有自己的目录、配置、状态——可以按照 opencode 的模式分三步走:定义 InstanceContext(贫接口,仅绑定数据)、定义 InstanceStore(提供生命周期和并发控制)、定义消费方接入点(通过 provideService 注入 InstanceRef)。三步完成,工作单元的生命周期管理即可成型。
但这个模式有一个反直觉的适用边界:并非每个需要目录的地方都需要 InstanceContext。 判断标准是——这个上下文是否需要跨模块共享、是否需要在 dispose 时触发清理。Opencode 的 Watcher 仅在自己的模块中使用了一个 Map,并未走 InstanceStore,因为 Watcher 不需要跨模块共享——它在 bootstrap.run 中初始化,在 disposeInstance 中注册清理,生命周期完全自包含。上下文的共享范围决定了是否需要中心化的生命周期管理。 如果只有一个模块消费,使用模块自身的 Map 就已足够。如果被多个服务消费(Config、Permission、Project、Vcs 都读取 InstanceContext),才需要 InstanceStore。
这个判断标准在你的架构设计中也同样适用。微服务中的“请求上下文”(request-scoped context)仅在网关和 handler 之间传递时,一个 Map 就足够了——不需要中心化的生命周期管理器。但如果你有多个中间件、多个数据源、多个下游服务都需要同一份上下文,那么值得建立一个中心化的 ContextStore。否则,你会在 N 个模块中重复实现“创建上下文”和“销毁上下文”的逻辑——而上下文销毁时的资源释放恰恰是最容易被遗忘的部分。
结语
InstanceStore 与 InstanceContext 的组合是 opencode 工作单元管理的核心。700 行代码完成了三件事:从目录自动发现项目、通过 InstanceContext 绑定三粒度数据、利用 Deferred 控制并发生命周期的 load/dispose。从实例启动到文件访问边界判定,整个 Agent 运行时都依赖这三个字段。
下一篇我们将拆解错误处理体系——从 error.ts 到用户提示的完整链路,看 opencode 的 Effect 错误模型如何贯穿整个系统的异常处理。
