跳到主要内容

本地设备架构

本文档介绍本地设备支持的技术架构,包括通信协议、心跳机制和安全设计。


🏗 架构概述​

系统组件​

设备产物声明与下载​

Claude Code 和 Codex 可以通过 Wegent executor 声明最终交付文件:

$WEGENT_EXECUTOR_BINARY artifact declare --path outputs/report.pdf

声明先校验文件真实路径位于当前任务工作区内,并计算文件名、相对路径、规范化工作区根目录、大小、修改时间和 SHA-256。

本地 Runtime 会话没有 Backend Task。Executor 不为它设置 WEGENT_BACKEND_TASK_ID;声明直接返回元数据和 local_path,不请求 Backend,也不需要云端凭据。最终回复使用指向 local_path 的 Markdown 文件链接,复用桌面的文件卡片和本地预览;链接随会话记录保留,文件仍在原工作区。

关联 Backend Task 时,WEGENT_BACKEND_TASK_ID 必须是正整数,用于 Backend API 路径;WEGENT_TASK_ID 保留本地会话身份并用于工作区定位。声明仅发送上述元数据,不上传文件内容。认证只使用该轮执行的 AUTH_TOKEN(Task Token),不能使用独立的 runtime credential。非法 Backend ID 会在发出请求前明确报错,不能将 runtime-* 会话 ID 传给云端 Task 接口。

Executor 工作目录、WEGENT_TASK_WORKSPACE 和声明命令复用同一套工作区路径解析。本地设备配置 LOCAL_WORKSPACE_ROOT 时,不得把逻辑别名 /workspace 当作物理工作区目录。

新版默认 Ghost/default/wegent-wework 要求关联 Backend Task 的交付先成功声明,再引导用户使用对话文件卡片;本地 Runtime 会话使用执行时注入的本地声明规则。初始化 YAML 不覆盖已存在的 Ghost;Claude Code(启用工具时)和 Codex 在执行时注入产物声明规则,因此旧 Ghost 无需数据库改写也能使用这条交付链路。原提示词数据迁移及其空操作 revision 均已删除,不读写业务数据;已记录 a9c4e7b2d610 的环境需在部署前单独处理版本兼容,配置不会被自动恢复。版本链处理见迁移撤回说明。

Backend 从 Task Token 对应的 assistant subtask 捕获 executor 名称和 namespace,并与工作区根目录一起保存为产物来源。下载始终使用这份来源快照,不选择任务最新的 executor;即使原 subtask 后续发生恢复重绑,也不能把旧卡片改指向新设备。原来源不可用时返回不可用,分享和复制任务不会携带这些设备产物。

设备下载复用 workspace_read_file_chunk 的规范路径校验;HTTP executor 下载使用必须指定 root_path 的 /filesystem/artifact。读取前解析真实路径并检查仍位于原工作区内,因此声明后把文件替换为指向工作区外的符号链接也会被拒绝。旧 executor 不支持该端点时明确失败;校验失败后也不得退回 /files。文件内容只在用户下载时经过 Backend 转发,不写入 Backend 产物存储;卡片读取的是原工作区的当前文件,不是永久保存的历史副本。

单个产物下载上限为 50 MiB。HTTP 下载在读取响应体前检查 Content-Length,并在读取过程中累计实际字节数;即使长度缺失或声明偏小,也会在超限时立即停止读取并返回 413。

Wework 打包 App 本地优先通道​

本地 Electron 打包与正式发布包复用相同的品牌元数据和更新源配置。本地打包也必须在 Resources 中生成 app-update.yml,包含按包名生成的 updaterCacheDirName,以便启动时能迁移真实的更新缓存目录;该文件必须在 macOS 签名之前写入。

打包后的 Wework Electron app 默认走本地优先模式。该模式不启动前端 Node dev server,也不在本机额外启动一个 HTTP Backend 服务;React 界面运行在 Electron renderer 内,Electron 主进程提供 app 内部命令层。

本地优先模式包含 Electron 主进程、executor sidecar 和 core DSH 三个本机运行时角色:

Electron 无参数启动 executor sidecar,并通过每次 App 启动独有的 Unix socket 或 Windows named pipe 交换换行分隔 JSON。普通客户端使用 app IPC token;Electron 另用独立 owner token 建立一个贯穿该代 executor 生命周期的长连接。普通客户端(包括 core DSH)断开只结束自己的连接,owner 连接 EOF 则关闭本地端点并退出 executor。因此即使 Electron 被 SIGKILL、崩溃或 Force Quit,无法执行 JavaScript 清理,executor 仍会从 owner socket 断开检测到所有者死亡。

Core DSH 通过 Electron 创建的继承 pipe 调用受限 host command。pipe 的 end 或 close 表示 Electron host 已不存在,DSH 必须调用 launcher 提供的 appExit 服务退出;正常释放客户端时会先移除这些断开监听,避免把有序停机误判为所有者死亡。

App 自己启动的 executor 和 DSH 归 Electron 主进程管理:macOS/Linux 下各自位于独立进程组。正常关闭或重启时先发送 SIGTERM;即使进程组 leader 已退出,也要继续等待整个进程组,超时后对仍存活的成员发送 SIGKILL。开发模式中的 reload supervisor 和它拉起的 executor 也在同一清理范围内。owner socket 和 host pipe 是强退路径的进程内自终止机制,进程组清理是正常停机路径的兜底,两者不能相互替代。

本地端点的写入失败、EOF 或子进程退出表示对应 IPC 连接失效。普通请求超时只结束该请求,不销毁通道,因此系统休眠或调度延迟不会触发端点重连或误切换到其他 executor。executor 意外退出并由 supervisor 拉起后,Electron 必须在新进程进入 ready 前重新完成 owner 握手。

Executor 启动的设备命令必须关闭 stdin,不能继承承载 App JSONL 协议的进程 stdin,否则子命令读取标准输入时会窃取请求字节并破坏后续协议帧。Executor 按字节读取并以换行符划分 App IPC 帧,再单独验证每一帧的 UTF-8;非法帧只记录长度和错误偏移后丢弃,不能终止整个 executor 或中断其他正在运行的任务。检测到 executor runtime instance 变化后,Wework 会重新获取受影响任务的列表和 transcript;已被 transcript 确认接收的排队消息会从队列移除,断线时尚未确认的发送会恢复为可重试的排队状态。

本地 runtime 事件通道和 App IPC 写入队列使用有界缓冲,容量均为 8192。IPC 写入将 Responses 文本、终态、错误和 RPC 响应放入高优先级队列,将工具块、诊断、计划和文件变化事件放入低优先级队列。高优先级队列满时发送方显式等待并记录背压;低优先级队列满时丢弃当前可恢复事件、记录背压并发送 executor.event_lagged,避免直到 broadcast 覆盖旧事件后才发现消息缺失。

工具输出事件最多携带 64 KiB,文件变化事件中的 diff 预览最多携带 128 KiB;完整 patch 仍保存在可读取的 artifact 中。Wework 收到 executor.event_lagged 后会重新拉取当前任务和全部运行中任务的 transcript,并使用稳定的客户端消息 ID 将 transcript 与尚未落盘的乐观用户消息合并。因此任务切换或事件积压后,运行状态、用户输入和 AI 输出会从同一份恢复结果重新收敛。同一工具块的空历史输出不能覆盖已收到的实时结果;此规则同时适用于命令、Node REPL 和其他 MCP 工具,并保留输出截断与加载引用信息。

Wework 的 DSH 通道通过独立的本地 endpoint 连接订阅 executor 事件。事件序号、断线重放和历史丢失检测全部由 executor 负责:executor 为事件分配单调递增的 sequence,并维护同时受 4096 条事件和 8 MiB 限制的内存日志;客户端重连时携带最后已消费的序号,executor 原子地返回其后的日志快照并继续发送实时事件,避免快照与订阅之间的竞态。请求序号已经落后于日志窗口或超出当前最新序号时,executor 发送带 event_history_lost 原因的 executor.event_lagged,由 Wework 通过 transcript 重新收敛。

Electron 层不保存事件日志、不生成序号,也不实现重放或合并策略。它只把 executor 的专用事件 socket 桥接为 renderer 使用的 SSE;如果 res.write() 表示浏览器端产生 HTTP 背压,Electron 会关闭上游 socket 和当前 SSE,让 renderer 使用最后已消费的 executor 序号重新连接。这样锁屏、系统休眠或 renderer reload 不会让未消费事件在 Electron 中无界增长,也不会把流正确性绑定到 Electron 的调度状态。

Backend 是可选能力,而不是本地 app 的必需依赖。需要登录、模型/能力同步、云端项目或网页版控制本机时,executor 可以使用 Backend Socket.IO 通道注册为本地设备;同一个 executor sidecar 会复用同一个 command handler 和 runtime work handler,一边通过本地 app IPC 端点服务 Wework App 和 core DSH,一边通过 Socket.IO 服务 Backend。这个设计不引入本机 HTTP gateway,也不要求 Wework App 自己启动 Backend。

Codex 原始模型 Catalog​

Wework 的模型路由、Catalog 编辑和保存校验共用同一份原始 Catalog 基线。配置了可读取的自定义上游时使用上游 /models;没有上游或上游目录不可用时,通过当前 Codex 二进制的 debug models --bundled 导出完整内置 Catalog,并在 executor 进程内缓存成功结果。上游目录失败会记录原因,不阻塞仅支持推理接口的服务商。Wework 的额外模型只按 slug 补入缺失条目,不覆盖 Codex 原始字段。

用户修改以字段差异保存在设备本地的 capabilities/model-catalog-overrides.json,恢复默认会删除对应差异。路由在原始基线上应用差异后再生成视觉委托条目。导出失败会明确报错,不会用不完整的 Wework 模型清单替代原始 Catalog。

跨组件请求日志关联​

Wework 在请求进入跨进程或跨服务边界时生成 request ID,并在同一次请求的后续日志中复用它。该字段只用于诊断单次请求,不替代任务 ID、设备 ID、线程 ID 或 OpenTelemetry trace ID,也不能用于推断业务状态。

  • Renderer 发往 Backend 的 HTTP 请求使用 wework-http-<uuid>,通过 X-Request-ID 传递。Backend 复用该值写入请求上下文,并在响应中返回 X-Request-ID;CORS 配置显式暴露该响应头。
  • Renderer 通过 core DSH 调用本地 executor 时使用 wework-local-<uuid>。同一个 request_id 同时写入 DSH 的请求开始、完成或失败日志,以及 executor 的 runtime:rpc 接收与响应日志。
  • Wework 通过 Backend Socket.IO 调用远程 executor 时使用 cloud-runtime-<uuid>。Backend 在处理 runtime:request 时把该值绑定到请求上下文,并原样放入下游 runtime:rpc 信封;远程 executor 的对应日志继续使用同一个值。

调用方没有 request ID 时,协议层不发送空的 request_id 字段;各组件日志使用自身的无上下文占位符。请求 ID 必须是有界、可打印的诊断标识,日志不得同时记录请求正文、认证信息、模型密钥或本地凭据。

排查一条请求时,先从用户可见失败附近的 Wework frontend、DSH 或 Backend 日志取得 request_id,再在同一诊断目录或集中式日志系统中按精确值搜索。开始和结束日志还会记录方法名、结果与耗时,因此缺失的边界可以直接定位请求停留在哪个组件,而不需要按相近时间或任务名称猜测。

MCP 应用探测与请求失败诊断​

普通 MCP 工具调用不代表存在可视化应用。会话先通过 runtime.mcp_apps.request 的 discover 方法验证源调用和工具目录中的应用资源标识;无应用时返回 null,不创建应用卡片,也不保留应用连接。确认存在应用后,原生宿主显式 retain 连接,initialize 才读取资源。探测失败仅记录诊断;已确认应用的资源加载失败仍显示错误和重试入口。

Electron 日志目录中的 executor-transport.log 保存本地 RPC 的请求 ID、方法、耗时和失败阶段:fetch_failed、body_parse_failed 或 rpc_failed。MCP 探测失败另记录调用 ID、任务 ID 和工具状态。Chromium 本地 RPC 网络错误记录路径、窗口标识和 net::ERR_* 错误码,不记录请求正文、Cookie 或认证信息。先按请求 ID 对照 DSH 和 executor 日志,再用窗口标识及时间对照 Chromium 错误;仅凭 Failed to fetch 不能认定为服务端业务错误或某一种网络故障。

每个应用实例创建独立的 sessionId,由原生宿主显式持有和释放连接。关闭组件、窗口销毁、渲染进程退出或主页面刷新时,宿主先执行清理回调,删除隔离文档,再等待未完成的持有请求结束并释放该实例的连接;旧初始化迟到不能释放替代实例。云端实例拥有独立且绑定账号的 RPC 客户端,释放实例时同时关闭客户端,切换账号不能复用旧实例的认证连接。Backend 按已认证 Socket 记录远端持有关系,连接断开时释放;若断开时持有请求尚未完成,完成后立即释放。退出清理受宿主标准五秒宽限约束,断开连接继续触发 Backend 清理。应用持有和文档生命周期均不使用额外的定时心跳或过期租约。Executor 重启后,下一次应用操作按进程身份恢复持有关系。

桌面 CI 的 mcp-apps 检查点覆盖真实应用加载、资源失败重试、普通历史调用、初始化迟到后的实例替换、页面刷新后的持有关系与文档清理,以及通过 Chromium 取消真实探测请求后的无卡片状态和网络恢复。故障注入只在隔离 E2E 环境启用,不替换 Runtime 响应。

Executor 启动环境与 Codex Home 初始化​

自动凭据同步只对实际凭据尝试应用冷却。登录或退出使旧迁移意图失效时,plugin_auth_migration_expired 不得阻止下一轮处理新登录意图;其他失败仍保留冷却,避免反复触发原生授权。同步日志仅记录插件、连接标识和结果码,不记录凭据或迁移意图令牌。

Git worktree 会话设置 PNPM_CONFIG_IGNORE_SCRIPTS=true 和 PNPM_CONFIG_ENABLE_GLOBAL_VIRTUAL_STORE=false,复用 pnpm 内容缓存,但将虚拟依赖目录保留在工作树内。全局虚拟仓库的真实路径会让 TypeScript 向用户主目录查找类型,可能混入另一版本的 React。Wework 的 TypeScript 配置还将 react 和 react/* 绑定到自身的 @types/react,让共享 JSON 等工作树包目录外的输入也使用同一套类型。已有工作树切换此设置后,需运行 PNPM_CONFIG_ENABLE_GLOBAL_VIRTUAL_STORE=false pnpm install --frozen-lockfile 重建依赖链接;PowerShell 使用 $env:PNPM_CONFIG_ENABLE_GLOBAL_VIRTUAL_STORE='false'; pnpm install --frozen-lockfile。执行器更新后的新会话会自动使用该设置。

共享插件包按内容哈希保存并保留旧包;Codex 原生缓存目录使用插件清单中的版本号。不能给该缓存版本追加哈希,否则 Codex 原生安装会清理该目录并使工作台保存的路径失效。插件认证 SDK 从 Executor 的 capabilities/manifest-v2.json 解析当前安装身份,不能读取迁移后残留的旧清单。

续聊保留任务 ID,但每轮使用新的子任务 ID,以刷新 MCP 上下文并区分消息。会话读取和续聊使用首次执行所属的原生 Home;不同 Home 的 Codex 查询进程互相隔离,历史刷新只读且不恢复会话,以免占用下一轮执行所需的写锁;续轮保存用户及 Team 身份但不保存凭据。后端 Team 的原生 Home 位于工作台根目录的 agents/<执行用户名>/<namespace>/<Team 名称>。流水线中各 Bot 使用其下的 bots/<Bot ID>,分别保存配置、会话和执行锁;按任务调用技能时,只有持有执行锁且记录了该任务的 Home 才能消除多个阶段之间的歧义。Wework 直接运行的配置(team_id = 0)继续使用应用管理的原生 Home,负责人续轮时保留此标记;其选择的后端技能部署到任务工作区的 .codex/skills,原生插件技能仍由 Codex 管理。共享 Codex 进程因环境变化重启前,必须等待活跃回合和未完成的 RPC 全部结束。

同名 Home 的复用不再要求后端 URL、Team ID、Bot ID 或 Shell 类型与旧标记一致;任务绑定和会话仍保留。任务技能查询要求认证用户 ID 和已记录的任务绑定匹配,但不使用后端 URL 作为身份。因此,共享工作台根目录不会隔离用户和任务 ID 重叠的独立后端;支持这种隔离需要由认证协议提供稳定后端身份。

Unix executor 在创建异步运行时和启动 Agent 子进程之前,通过运行当前用户的非交互登录 shell 读取登录环境,避免执行仅供终端交互使用的提示符、补全和插件初始化。需要传递给 Agent 的环境变量应配置在登录 shell 会读取的启动文件中。shell 优先使用系统用户数据库中的登录 shell,并依次回退到 $SHELL、zsh、bash 和 sh。采集过程有固定超时;失败时 executor 保留父进程环境,并继续补充 Homebrew、/usr/local 等标准开发目录。最终环境由 executor 统一传递给 Codex、Claude Code、插件、技能、Hooks、PTY 和设备命令,因此 Wework 本地 sidecar、独立本地设备以及 Linux 云端或远程设备使用同一套 PATH 解析逻辑。

Windows 没有可采集的登录 shell,executor 改为在启动时合并注册表中的机器与当前用户 PATH。这样即使桌面应用早于 PATH 修改启动,设备命令仍能看到新开 pwsh 可解析的工具。Git diff 与代码托管 CLI 状态等设备命令直接原生调用 git、gh 或 glab,不再依赖 Windows PATH 上不保证存在的 bash 或 python3。

Wework 使用独立 Codex Home 隔离本地运行时配置。首次初始化时,用户可以把原生 Codex Home 中的配置、插件、技能和插件市场复制到该目录。初始化完成后,Wework 默认在 [features] 中写入 apps = true,使迁移后的插件 Apps 能力立即可用;用户之后在设置中明确关闭 Apps 时,后续普通启动不会覆盖该选择。

Wework 的本地可用状态以真实 Codex app-server 完成 initialize 为边界,而不是以 executor stdio 通道建立为边界。 Electron 启动 executor 后,先把当前本地代理配置写入运行时,再通过 runtime.codex.ensure_started 启动并初始化共享 Codex app-server;只有该调用成功后,renderer 才继续进入可交互工作台。Codex 初始化路径不得同步等待插件市场刷新、Git 拉取、更新检查或其他外部网络请求;这些后台请求即使因断网或代理无响应而挂起,也不能延迟 initialize 响应。启动 E2E 必须使用真实 Codex 和阻塞网络代理验证这一约束,同时确认初始化期间不会发送 Agent 模型请求。

Electron 启动主窗口时必须保持主窗口隐藏,并由独立的 startup splash 窗口持续展示启动动画。wework/electron/src/shell/index.html 只承载 Core DSH 启动宿主和失败诊断,不得模拟工作台布局、任务列表、输入框或其他骨架屏。Renderer 通过 renderer.startupReady 报告首个可操作工作台后,Electron 才显示主窗口并关闭 startup splash;启动失败时继续由 startup splash 提供重试和恢复操作。这样启动期间始终只有一条可见反馈路径,不会由未就绪的主窗口覆盖动画或在动画与真实界面之间闪现占位内容。

启动画面沿用用户选择的亮色、暗色或系统外观,原生窗口底色与页面首帧使用同一主题。亮色采用浅灰背景、清晰的状态文字和蓝色标志;底部状态区与背景融合,正常启动与失败恢复控件都必须适配两种主题。

Logo 轮廓由 wework/electron/scripts/generate-startup-brand.py 从原始透明通道离线拟合为连续三次曲线,并检查曲线字形与原图的偏差不超过 3 个源像素;底部短横使用水平直边和圆头,避免平滑拟合将其弯曲。拟合依赖只用于开发阶段,应用启动时直接读取生成的数据。入场采用密度 2.5、速度 1.0 的 60 条流星,从四周向内汇聚,亮头在首次接触字形前消失。光条在物理像素画布上绘制,不得先绘制低分辨率位图再放大。成形和等待阶段复用同一个矢量标志,成形后立即从标志左缘向右扫光,不替换图层或额外淡入;等待阶段不运行 Canvas 绘制循环。启用“减少动态效果”时显示静态标志;窗口隐藏或启动失败时暂停动画。工作台就绪后沿用已有收尾和窗口交接,不为完整播放动画增加启动等待。

运行时任务与目标状态​

运行时任务的 running 字段只表示当前是否存在正在执行的模型回合。回合完成、失败或取消后,executor 必须把该字段收敛为 false,供 Wework 决定是否显示停止按钮、运行中图标,以及新消息能否直接发送。

running 由两类实时信号共同确定:executor 当前进程维护的活跃任务集合,以及 Codex thread 中明确标记为 inProgress 的 turn。后者覆盖 Goal 自动续轮等场景:本地执行包装可能已经返回,但 provider 仍在运行后续 turn。Codex thread 自身的 active 状态、已持久化的任务摘要和 Wework 本地提醒都不能单独推断任务仍在运行。任务列表、transcript、详情面板、系统托盘和阻止休眠逻辑必须消费同一份 executor running 值;executor 或 Wework 重启后,如果 thread/read 或 thread/list 仍返回活跃 turn,界面必须恢复运行态,只有不存在活跃 turn 时才收敛为空闲。

在单个 executor 进程内,发送、引导和取消操作只使用一份本地执行记录作为生命周期权威来源。Codex 的 threadId 和 turnId 是该执行记录的附属上下文,只在 execution ID 仍匹配时有效,不能存放在独立注册表中继续维持任务运行。执行结束时,executor 会原子删除本地执行及其 Codex turn 上下文,再发送终态响应;与完成事件并发但稍后返回的 turn/steer 等 provider 回调必须被忽略,不能把已经结束的任务重新标记为运行中。thread/read 或 thread/list 返回的活跃 turn 仍可用于任务列表投影和重启恢复,但不能创建第二套进程内执行生命周期。

任务摘要同时透传 Codex 的 threadStatus(notLoaded、idle、systemError、active)和 turnStatus(inProgress、completed、interrupted、failed)。continuable 单独表示会话未归档、仍可继续发送消息;它不能用于推断当前回合正在运行。Wework 只使用明确的 running 和真实回合状态显示运行反馈,不把线程或消息的 active 状态转换为 streaming。

线程元数据刷新也不能覆盖已经持久化的任务终态。当 Codex 线程进入 idle 时,executor 保留本地 done、cancelled 或 failed;只有真实活跃回合才能把任务重新设置为 running。因此,一个正常完成且可继续的会话会同时表现为 status=done、running=false、continuable=true、threadStatus=idle 和 turnStatus=completed。

目标(goal)有独立的生命周期。目标为 active 表示其目标仍可在后续回合继续推进,不表示当前存在模型回合。因此,任务空闲时保留 active goal 不会将任务重新标记为运行中;用户发送下一条消息会直接创建新回合,而不是把消息作为对运行中回合的引导。

如果用户在普通回合仍运行时创建目标,Wework 会保留该目标请求,等待当前回合明确结束后以 initialGoal 启动新的目标回合。active goal 会让任务继续显示为运行中,但不能阻止这次已排队的目标接力;普通排队消息仍然只能在任务真正空闲时发送。executor 只在目标已经于回合开始前处于 active 状态时等待 Codex 自动续轮;若目标是在普通回合中途创建,当前执行必须先正常收敛,让 Wework 能够启动排队的目标回合。该边界避免前端等待任务空闲、executor 同时等待并不存在的自动续轮所形成的死锁。

为跨 Wework 或 executor 重启恢复真正运行中的目标,runtime work 会把 active Goal 执行记录写入加密的 turn 队列。恢复依据是这条执行记录,而不是仅凭 goalStatus=active 推断运行:只有重启前仍在执行的 Goal 才会重新绑定。恢复时 executor 必须先订阅事件,再调用 Codex thread/resume,不得额外创建 turn/start、伪造用户消息或注入续聊提示;后续轮次仍由 Codex 的原生 Goal 协议驱动。目标暂停、清除或完成后必须删除对应执行记录,防止下次启动错误恢复。

恢复后的 Goal turn 使用新的原生 turn 身份,不得复用已持久化请求中的 clientUserMessageId、用户消息展示绑定或生成用户消息标记。原用户消息与原 turn 的映射保持不变;该规则同样适用于需要用户操作后的原生 Goal 恢复。

executor 的活动 transcript 缓存同时累计 item/agentMessage/delta 和 item/plan/delta,按原生 item ID 更新条目。历史加载必须能读取尚未收到 item/completed 的部分文本;用户停止执行时,这些文本以中断状态持久化,后续追问或重新打开任务不能将其丢弃。最终 item 快照替换已累计文本,避免重复拼接。

仍在流式累计且尚未标注 phase 的文本条目与实时流一样按过程文本展示,完成后再按终态投影;历史加载不能提前把它改成最终回答。Goal 恢复在原生 turn 建立前失败或取消时,终态事件也必须移除旧用户消息身份,且不能修改用于后端上报的原请求。

Goal 的单个物理 turn 完成后,executor 会等待 Codex 自动创建下一轮。若等待超时,它会通过 thread/goal/get 和 thread/read 对账 provider 的权威状态:已有活跃 turn 时重新绑定并继续监听;目标仍为 active 且线程空闲时只尝试一次原生 thread/resume;仍无法继续时停止静默等待并向 Wework 暴露 needsAttention。Wework 使用 running、recovering 和 needsAttention 三种 Goal 执行状态分别显示正常运行、重启恢复和需要用户恢复,用户点击恢复时复用已保留的 Goal 请求,而不是发送一条普通聊天消息。

首个模型或工具进展的等待超时不能单独证明执行已停止。共享 Codex app-server 只有一个活跃执行时,executor 可以终止该进程并上报超时失败;为保护其他活跃执行而跳过重启时,必须保留原来的启动请求、事件订阅和取消入口,继续等待真实终态,不能上报失败或将 Goal 标记为 needsAttention。等待期间按启动超时间隔重新检查恢复条件;启动请求尚未返回或 Goal 尚无 turn ID 时,取消仍须发送原生启动中断。已经进入 needsAttention 且停止的执行,不得被历史 transcript 中残留的流式回合重新激活;被抑制的回合 ID 必须保留为历史,防止迟到快照覆盖恢复后的新回合。新的运行中 executor 快照或用户恢复操作仍可正常启动后续执行。该规则确保任务状态、Goal 恢复提示和“正在思考”反映同一执行生命周期。

Wework 前端通过一个用户级 RuntimeTaskLifecycleStore 管理所有任务生命周期;Store 为每个任务维护一个状态机并负责事件路由,状态机是执行状态、回合状态、Goal 状态和未读状态的聚合根,reducer 仅作为状态机内部的状态转换实现。React Provider 只把同一个 Store 适配为订阅,不保存或推断运行状态。任务列表、输入框、消息思考态、系统托盘、关闭保护和完成提醒都读取该 Store 的同一份快照。

前端的权威运行状态只保存在内存中,不写入本地文件或浏览器存储。用户发送消息时的乐观 starting 也由同一个状态机维护,并在 executor 明确返回 running=true 或 running=false 后收敛。Active Goal 自动续轮时,只要本地执行仍活跃或 provider 仍返回 inProgress turn,两轮之间和页面重载后都保持任务运行中;回合没有流式内容时可以为 idle,因此不显示“正在思考”也不产生未读。为支持应用重启后的未读边沿判断,Wework 仅持久化已经产生的未读任务键和上一次观察到仍在运行的任务键;后者不是运行状态来源,不能覆盖 executor 的当前快照。

普通持久线程续聊在调用 turn/start 前必须通过 thread/read 确认当前没有活跃 turn。ephemeral 临时线程不支持 thread/read(includeTurns),因此必须在按任务串行化的发送临界区内检查 executor 本地活跃执行;新 turn 必须先登记为本地运行中,后一个并发发送才能离开临界区。若 provider 拒绝重叠发送,Wework 将任务状态立即恢复为运行中、刷新任务列表,并把用户输入保留在队列;provider 收敛为空闲后复用同一个客户端消息 ID 自动发送,避免丢消息或重复消息。完成或中断事件会清除活跃 turn 并使界面恢复为空闲。“打断并发送”只有在旧 turn 已确认中断后才创建新 turn;持久线程还要确认 provider turn 已停止,ephemeral 临时线程则以本地执行中断为准。

ephemeral 临时线程的连续续聊依赖其在共享 Codex app-server 中保持已加载状态。成功回合结束后,executor 不得对这类线程发送 thread/unsubscribe,否则后续直接调用 turn/start 可能停留在已经卸载的线程上。临时线程也不支持分页 transcript RPC,因此 transcript 查询必须读取 executor 的本地运行时缓存,不能调用 thread/turns/list。持久线程仍在每个终态回合后取消订阅,并继续使用 provider transcript 作为历史记录来源。

任务创建期间可能已经分配 provider thread ID,但首个 turn 尚未物化。若 runtime handle 只有不带 turn ID 的本地用户消息 presentation,且没有完成消息、transcript 快照、lastTurnId 或 subtask turn ID,executor 必须把该任务视为未物化会话:transcript 返回本地 presentation,导航返回空列表,不调用 provider 的分页历史接口。项目看板只在对应 workspace tab 处于激活状态时预加载绑定任务的 transcript,并且同一挂载周期内每个任务地址最多尝试一次;失败不会因任务时间戳或状态刷新而自动重试,重新挂载后才允许再次加载。

Codex 引导通过共享 app-server 的活跃回合发送。若回合恰好在发送期间结束或切换,executor 会将该竞态报告为 no_active_turn;Wework 随后把同一内容作为普通后续消息发送,避免丢失用户输入或显示误导性的发送失败。

同一对话可在回合之间切换模型和 provider。Wework 为每次续聊传递所选模型及其 provider 配置,Codex app-server 在 thread/resume 时应用新的 modelProvider。executor 为每个经 Wework router 运行的 task 分配一个稳定的本地模型代理地址,并在每轮开始时原子更新该 task 的上游配置。代理在 thread 创建后绑定根 thread ID,只接受该 thread 及其子 thread 的请求;executor 当前轮传入的上游和模型是实际路由的唯一权威来源。

OpenAI 返回的推理和远程压缩条目可能包含只对原 provider 有效的 encrypted_content。runtime work 在续聊前比较任务摘要中的旧 modelSelection 与本次选择;发生模型或模型类型切换时,会将切换标记传入 executor。目标请求第一次经过 Wework router 时,代理会移除旧 provider 专属的加密推理/压缩条目以及 previous_response_id,并在请求成功后消费该标记。该清理也覆盖切换后先发生 compaction、请求中暂时没有 <model_switch> 标记的情况,避免把旧密文发送给 OpenAI Responses、Chat Completions 或 Anthropic Messages 上游。

收到明确错误后,用户通过“切换模型并重试”发起的是一个新的回合:它复用原 task 和 thread 的可移植上下文,并只向新选择的上游发送一次请求。executor 同时把本轮 modelSelection 写回任务摘要,保证刷新后界面展示的模型与实际请求一致。运行中发送的引导仍属于当前回合,不切换模型;新模型只用于新的普通回合或“打断并发送”创建的回合。

本地模型代理以 Codex Responses 协议作为内部统一表示,并在 OpenAI Responses、OpenAI Chat Completions 和 Anthropic Messages 三种上游协议之间双向转换。切换协议时,历史中的工具调用 ID 和工具结果引用必须在请求边界统一规范化为只包含字母、数字、下划线或短横线的稳定 ID,并在同一历史内保持一一对应;不得把 provider 原始 ID 直接透传给另一个协议。流式响应返回的工具调用 ID 也执行同样的规范化,确保后续工具结果能够关联到原调用,并使 item/started 与 item/completed 收敛到同一个 Wework 工具块。

上游 Responses 接口按位置把 function_call 与其紧随其后的 function_call_output 配对,因此编码时必须把同一轮的文本条目放在该轮调用/结果组之外:assistant 轮的文本与思考排在调用之前,user 轮的文本排在工具结果之后。若报文里出现调用、文本、结果三者交替的排布,即使客户端已经给出全部结果,也会被判成该调用缺少结果而拒绝。

Wework 在发送用户消息前生成稳定的 clientUserMessageId,并在本地先渲染乐观消息。该 ID 通过 runtime create/send 请求原样传入 Codex app-server 的 turn/start.clientUserMessageId。Codex transcript 返回用户消息时,executor 保留同一个 clientUserMessageId;Wework 使用它与本地乐观消息对账。Codex 内部 item ID 仍用于 provider 事件身份,但不能替代客户端 ID,否则 transcript 分页或刷新可能把同一次发送识别成两条消息。

实时发送响应中的回合 ID 可能是 executor 在 provider 回合出现前分配的临时 ID,而随后 transcript 会返回 Codex 的规范回合 ID。Wework 合并实时会话和 transcript 时必须先按规范回合 ID 对账;两者不同时,再按稳定的 clientUserMessageId 合并为同一个回合,并采用 transcript 的规范回合 ID。不能因为本地回合已经有非空 ID 就跳过客户端消息 ID 对账,否则 Goal 自动续轮等竞态会把同一轮用户消息和后续输出重复渲染。

终态事件与 transcript 刷新并发时,同一个 provider assistant item 也可能短暂出现在临时回合和规范回合中。provider item ID 在该场景中是跨回合别名的稳定身份:当一个 assistant item ID 在快照中只属于一个规范回合时,Wework 必须把携带该 item 的本地别名回合并入规范回合,同时保留别名回合中尚未进入快照的工具块等实时内容。不能仅按回合 ID 保留两份表示,也不能按文本内容跨回合去重;后者会误删模型使用不同 item ID 真实输出的重复文本。

Codex Goal 协议返回的 createdAt 和 updatedAt 使用 Unix 秒,而 Wework 的 RuntimeGoal 契约使用 Unix 毫秒。executor 必须在 thread/goal/get、thread/goal/set 和 thread/goal/updated 的 provider 边界统一转换为毫秒,再交给前端按 updatedAt 对账;否则乐观 Goal 的毫秒时间戳会始终大于规范完成态的秒时间戳,导致已完成 Goal 继续显示为活动状态。

Wework 创建 Codex thread 时显式设置 historyMode=paginated。恢复 transcript 时,executor 先用 thread/read(includeTurns=false) 读取线程元数据,再用 thread/turns/list 按时间倒序读取回合,并对每个回合调用 thread/items/list 按正序加载完整 item。分页游标是 Codex 生成的不透明值,前后端不得解析或改写为本地 offset;普通页面只读取一页,搜索、Supervisor 和其他完整历史消费者会沿 nextCursor 读取到末尾。executor 会拒绝重复游标、缺失回合 ID、跨回合 item 等无效响应,避免静默产生缺失或错序的历史。

分页 transcript 响应不伪造全局 rangeStart 或 rangeEnd。完整导航独立读取轮次摘要,按稳定轮次 ID 关联当前已加载的用户消息和助手回复,不能将轮次序号视作页面消息索引;未加载轮次保留请求和回复预览。用户请求更早历史时,将 beforeCursor 原样传回 executor,加载结果再合并到现有会话。真实 Codex 桌面 E2E 必须覆盖超过单页大小的会话,验证最新页与缓存恢复后的导航高亮、历史轮次悬停摘要和不透明游标分页。

工具状态以 app-server 的生命周期事件为准:item/started 创建运行中的工具块,item/completed 必须将对应工具块收敛为 done(显式失败除外)。部分独立工具条目(如图片查看、等待和网页搜索)不携带 status 字段;executor 在实时事件映射和 transcript 恢复时都将这类终态条目规范化为 done,避免 Wework 在工具已经完成后继续显示运行状态或递增计时。

Wework 发起压缩前先解析目标会话保存的模型选择,并用当前凭据、模型请求头和执行设备的代理设置重新生成模型配置,再通过 App-IPC 交给 executor。持久化的执行配置已移除密钥和请求头,只能用于恢复身份,不能直接用于模型请求;没有 Wework 模型覆盖的会话继续使用原生 Codex 配置。

手动上下文压缩以 Codex thread 中新的 contextCompaction 条目持久化为成功边界,而不是以 thread/compact/start 接受请求为成功。executor 在发起压缩前按任务模型配置注册并绑定本地模型路由(与发起一轮对话使用同一套判断和注册逻辑),因此空闲会话、executor 重启后或路由空闲回收后点击压缩同样能触达模型,而不是在 Codex app-server 内以“no local model proxy route is bound to the Codex thread”失败。随后记录最新 turn 并轮询近期 transcript;只有最新 turn 出现压缩条目后,才向 Wework 返回 turnId 和 compactionItemId。轮询按 wall-clock 预算停止(读取 RPC 的耗时也计入该预算),App-IPC 对 runtime.tasks.compact 的超时高于 executor 自身预算;若压缩轮已经结束却没有产生压缩条目(例如 provider 拒绝该轮),立即返回带原因的失败,而不是等满预算后回报超时。压缩期间 Wework 先显示单一的“正在自动压缩上下文”处理块,完成后将同一处理块收敛为“上下文已自动压缩”,失败时收敛为错误态。

压缩事件路由保留 ${taskId}-context-compact 这一合成 subtask 身份,避免真实 Codex turn ID 把前端乐观处理块拆成另一条消息。executor 同时兼容 item/completed 和 context/compaction 两种通知形式:相同压缩项按 item ID 去重,不同压缩项必须分别发出。Wework 会按 subtask 对账乐观块和运行时块,避免同一次压缩显示两条指示器。桌面 E2E 通过受控的 mock 模型端点接收并阻塞 Wework 发出的压缩请求,验证确认、运行中、完成和后续消息四个阶段,并确认后续模型请求实际包含 mock 返回的压缩摘要,而不是只验证界面标记;同一 checkpoint 还会让 mock 端点拒绝一次压缩请求,断言失败在 App-IPC 截止之前自行暴露且不是超时文案。Codex transcript 持久化完成边界由 executor 回归测试覆盖,空闲线程的模型路由绑定由 executor 单测覆盖。

Codex 同一回合可以交错产生推理、助手文本和工具调用。executor 必须按 provider item ID 跟踪每一段助手文本的流式偏移和完成快照:同一 item 的 delta 与 completed 是同一内容的增量和快照,应去重;不同 item 的完成文本即使位于同一回合,也必须作为后续文本继续发送,不能因为前一个 item 已产生 delta 而丢弃。Wework 在把当前助手文本移动到工具或处理块之前会清空该文本流的偏移状态,使工具后的下一段助手文本从 offset 0 开始,并保持 transcript 的事件顺序。

助手文本会按照流式开始事件的 phase 进入 Wework,但 item/started 携带的 phase 只是暂定状态:Codex 可能先把 item 标为 final_answer,再在继续调用工具前以 commentary 完成同一 item。完成事件中的明确 phase 是权威值;若它把当前流式 final 改判为过程文本,executor 会清除该活跃 final,并发送携带同一 provider item ID 和 replacesItemId 的过程块,让 Wework 原位替换暂定 final,而不是同时保留两份内容或提前结束回合。只有完成事件没有明确 phase 时,executor 才回退到流式阶段跟踪的 phase。已完成的明确 final 或 final_answer item 优先;若该回合没有明确 final item,则最后一段已完成的助手文本成为兜底最终结果。

标准云端 executor 的回调链路也必须保留这一 item 身份:正文增量携带 block_id(provider item ID)和按 Unicode 码点计数的块内 block_offset,全局 offset 继续表示正文流的位置。Backend 缓冲只能合并同一块内连续的增量,Redis 按块内偏移原子追加未见过的后缀;同一 ID 的完成块原位更新已存储的文本。这样 phase 从 final_answer 变为 commentary 时,实时消息、运行中刷新和最终历史都只保留一份内容;不同 ID 的相同文本仍分别保留。

Codex 提供的推理摘要会作为 thinking processing block 进入 Wework。流式摘要以单行“正在思考 · 摘要”显示,只用于反馈当前正在生效的思考进度;回合完成、失败或取消后,Wework 会移除该思考块,不在消息历史中保留摘要占位或详情。executor 必须同时映射 reasoning delta 和只携带完整 summary 的 item/completed,否则模型长时间推理时界面会退化成没有进展内容的统一等待状态。未包含在 provider 摘要中的内部推理内容不会展示。

后端设备对话任务 REST 入口​

网页版设备对话页仍然通过 WebSocket 发送消息。对于需要从外部系统或 curl 创建同类任务的场景,Backend 提供 REST 入口:

POST /api/device-chat/tasks

该入口写入中心库 TaskResource 和 Subtask,并复用与设备对话页相同的 create_chat_task、设备解析和 trigger_ai_response_unified 链路。请求不包含 workspacePath 或 localTaskId:普通设备对话任务没有项目工作区概念;如果传入 projectId,Backend 根据项目配置解析目标设备;如果不传 projectId,Backend 按显式 deviceId、已有任务设备、Wework 默认设备或用户默认本地设备解析目标。

新建任务时只需要传 teamId 和 message,可选传 deviceId、projectId、模型和上下文参数。续聊时传 taskId,Backend 会校验当前用户是否有任务访问权限,并沿用已有任务的 client_origin。响应返回中心库任务和消息 id:

{
"taskId": 2267,
"userSubtaskId": 3332,
"assistantSubtaskId": 3333,
"messageId": 5,
"aiTriggered": true,
"deviceId": "device-de8f474294621dd5acfd1287",
"chatUrl": "/devices/chat?taskId=2267"
}

OpenAPI schema 由 FastAPI 根据 DeviceChatTaskRequest 和 DeviceChatTaskResponse 自动生成,不需要维护静态 docs/api 文件。

通信架构​

下图展示了本地设备如何与 Wegent 系统通信:

设备类型​

设备 CRD 使用 spec.deviceType 区分生命周期归属和前端能力入口:

类型生命周期归属连接方式典型入口
local用户本机 executorWebSocket本地安装脚本或手动启动 executor
cloudWegent 云设备服务WebSocket云设备创建、重启、释放流程
remote用户自管 Docker 容器或远端主机WebSocketWework 连接设置中的远程 Docker 命令

remote 设备复用本地 executor 的 WebSocket 注册、心跳、任务执行和 command RPC 通道,但由 RemoteDeviceProvider 独立列出和返回 remoteConfig。Backend 不保存生成命令中的 WEGENT_AUTH_TOKEN;Device CRD 只保存 provider、image、deviceId、deviceName、backendUrl 和 createdAt 等非敏感元数据。

远程 Docker 设备启动后会发送 device:register,payload 中的 device_type=remote 会更新同名 Device CRD。在线状态仍存储在 Redis 的设备在线键中,因此任务调度、slot 统计、terminal/code-server session RPC 与本地设备保持同一套协议。前端不会对 remote 设备展示云设备生命周期操作;停止、重启、删除容器由用户在 Docker 主机上完成。

项目设备授权池与领取​

项目默认允许项目所有者名下的可用设备领取 Run。通过现有 ResourceMember 的 kind + resource 授权关系为项目添加任意设备后,这些授权构成项目设备白名单;不新增专用映射表。

自动处理规则和人工分配都不要求用户选择设备。目标为人时只改变负责人;目标为智能体或协作小组时创建未绑定设备的排队 Run。设备领取时依次校验:

  1. 设备属于 Run 所有者;
  2. 设备满足项目授权池;
  3. 同一 Issue 已经在某台设备执行过时,继续使用该设备;
  4. 设备和智能体仍有可用容量。

领取成功后通过同一个条件更新原子写入规范设备 ID、执行环境、租约和运行请求,避免多个设备同时领取。项目没有显式设备授权时使用默认开放语义;一旦存在授权,则只允许白名单设备。设备是否在线只影响当前能否领取,不影响管理员预先授权离线设备。


📡 WebSocket 协议​

事件类型​

事件方向描述
device:register设备 → 后端设备注册
device:heartbeat设备 → 后端心跳保活
task:execute后端 → 设备下发任务
task:progress设备 → 后端任务进度
task:complete设备 → 后端任务完成

Rust executor 本地事件覆盖​

Rust executor 的本地 Backend 通道需要与旧 Python 本地设备 runner 保持事件级兼容。除任务执行和心跳外,当前本地设备还注册并处理:

  • task:cancel、task:close-session
  • chat:message
  • device:execute_command
  • device:sync_capabilities
  • device:start_terminal_session、device:start_code_server_session
  • terminal:input、terminal:resize、terminal:close
  • runtime:rpc
  • device:upgrade
  • device:run_extension

device:run_extension 的 extension_scope 可设为 task 或 global,省略时默认为 task。任务级扩展从当前任务的 .claude/skills/<extension> 目录运行;全局扩展从 executor 运行用户的 ~/.claude/skills/<extension> 目录运行。脚本路径必须保留在所选 扩展目录内,其他作用域值会被拒绝。

迁移覆盖关系记录在 executor/docs/LOCAL_DEVICE_PYTHON_MIGRATION_TESTS.md。新增本地设备事件时,应先补 executor/tests/local_backend_device_migration_contract.rs,再更新该迁移矩阵。

消息格式​

// device:register
{
"event": "device:register",
"data": {
"device_id": "uuid-xxx",
"name": "Darwin - MacBook-Pro.local",
"max_slots": 5
}
}

// device:heartbeat
{
"event": "device:heartbeat",
"data": {
"device_id": "uuid-xxx",
"running_task_ids": ["task-1", "task-2"]
}
}

// task:execute
{
"event": "task:execute",
"data": {
"subtask_id": "subtask-xxx",
"prompt": "用户消息",
"context": {}
}
}

💓 心跳机制​

时序图​

时间参数​

参数值描述
心跳间隔30 秒设备发送心跳
在线 TTL90 秒Redis 键过期时间
监控间隔60 秒后端检查过期设备
离线阈值3 次心跳缺失设备标记为离线

如果一次 device:heartbeat ACK 超时、被后端拒绝或 Socket.IO 传输出错,executor 会在 10 秒后快速重试下一次心跳,而不是等待完整 30 秒心跳周期。连续两次心跳失败后,executor 会主动断开当前连接并进入重连注册流程。这样可以容忍一次瞬时抖动,同时在短暂网络问题恢复后,通常仍能让设备在 90 秒在线 TTL 过期前重新注册并刷新在线状态。

运行任务追踪​

每次心跳包含当前运行的任务 ID,用于:

  • 实时槽位使用追踪
  • 孤立任务检测
  • 断开连接时自动清理

全局能力状态上报​

本地设备还会通过心跳上报 Claude Code 全局能力状态。完整上报包含:

  • capabilities.revision:本地 Wegent 管理清单版本
  • capabilities.digest:skills、plugins、mcps 的内容摘要
  • capabilities.skills:~/.claude/skills 中可用的 Skill
  • capabilities.plugins:~/.claude/plugins/installed_plugins.json 中已安装的 Plugin
  • capabilities.mcps:Wegent 管理的全局 MCP 配置

Plugin 上报必须包含其内部 Skill 列表。Executor 会扫描每个 Plugin 安装目录下的 SKILL.md,并在 plugins[].skills[] 中返回:

{
"name": "context7",
"marketplace": "claude-plugins-official",
"version": "1057d02c5307",
"source": "wegent",
"installed_plugin_id": 301,
"skills": [
{
"name": "context7",
"description": "Look up version-specific documentation.",
"path": "skills/context7"
}
]
}

后端只在 capabilities.full = true 时保存完整能力状态;后续心跳如果只有相同 digest,只刷新在线状态,不重复写入完整列表。

全局能力同步​

后端可以通过 device:sync_capabilities 向在线本地设备下发全局能力期望状态。当前同步内容包括:

  • skills:通过 backend 解析后的 InstalledSkill / Skill,由 executor 下载到 ~/.claude/skills
  • plugins:通过 backend 解析后的 InstalledPlugin,由 executor 写入 ~/.claude/plugins/installed_plugins.json
  • mcps:通过 backend 解析后的 InstalledMCP,由 executor 写入 Wegent 管理清单

replace 模式只会清理由 Wegent manifest 标记为 managed 且不在期望状态中的能力。用户直接在本机安装的 plugin 不会因为一次 Wegent 同步被删除。

能力包下载被限制在当前配置的 Backend 同源地址内。executor 会将相对下载路径解析到 connection.backend_url,拒绝其它 origin 的包地址,并且只对同源 Backend 请求附加设备 bearer token。Skill 下载 URL 使用编码后的 query 参数构造,包解压会先写入单次同步唯一的 staging 目录,再替换 Wegent 管理的 skill 目录。

项目任务使用本地 executor 执行时,任务级 CLAUDE_CONFIG_DIR 会同时暴露全局 skills 和 plugins 目录,并从本机 ~/.claude/settings.json 继承 enabledPlugins、extraKnownMarketplaces 等非敏感插件配置,使 Claude Code 能加载全局 Skill 以及 Plugin 内部提供的 Skill。模型、Token 等敏感配置仍通过运行时环境变量注入,不会从全局 settings 写入任务目录。

Claude Code、Agno 运行时和 Codex 任务 shell 都会收到一组任务身份环境变量。WEGENT_TASK_ID 标识当前 Task,WEWORK_PARENT_TITLE 提供当前任务标题,AUTH_TOKEN 提供本轮任务访问 Backend API 的 bearer token,WEGENT_RUNTIME_AUTH_TOKEN 提供本地 Skill 访问 Wegent runtime API 的 bearer token,WEGENT_SKILL_IDENTITY_TOKEN 和 WEGENT_SKILL_USER_NAME 用于任务内 Skill 操作的身份校验与展示。Claude Code 和 Agno 通过子进程环境注入;Codex 通过 thread 级 shell_environment_policy.set.* 注入,身份值不会进入共享 app-server 进程环境,避免跨任务泄漏。Wework 连接云端后会通过 POST /api/users/me/wegent-runtime-token 获取 runtime token,并按响应的 expires_in 提前刷新;断开云端时会移除本地 Codex 配置中的 WEGENT_RUNTIME_AUTH_TOKEN。executor 不向这些子运行时注入 WEGENT_SUBTASK_ID。

项目模式下访问 Claude 或 Codex 模型 API 时,executor 会在直接启动的运行时上下文中加入 wecode-project: <project_id> 请求头,并补齐 wecode-action: wegent、wecode-source: wegent-local、wecode-executor: <runtime> 来源标识,其中 Claude Code 使用 claudecode,Codex 使用 codex。Claude Code 本地模式会先合并 executor 启动进程环境和运行时环境里已有的 ANTHROPIC_CUSTOM_HEADERS,再追加 project 标识,并同时写入 ANTHROPIC_CUSTOM_HEADERS 与 DEFAULT_HEADERS/default_headers 环境变量,保证直接 Claude Code 子进程和下游模型网关读取到一致的 header 集合;Codex 在 Wegent 管理 provider 配置时写入 provider 的 http_headers,使用个人 Codex 配置且显式指定 provider 时也会对该 provider 注入同一 project 请求头。

聊天任务设备解析与 Claude Code 启动上下文​

普通聊天 Task 通过本地 executor 执行时,Backend 在创建或继续任务前解析真实派发设备,优先级如下:

  1. 本轮请求显式传入的 device_id。
  2. 当前 Project 的本地执行配置,例如 config.execution.targetType = local 和 config.execution.deviceId。
  3. 已存在 Task spec 中保存的 deviceId。

前端 App IPC 使用的 appDeviceId 只是本机进程侧身份;Backend 会将它映射到 Device CRD 的 executor Socket.IO name 后再派发。如果解析出的本地设备已经离线,但当前用户只有一台在线本地 executor,Backend 会把该任务切到这台在线设备,避免旧设备 id 阻塞本地执行。未知设备 id 不会被静默改写。

Claude Code 子进程启动前,executor 会完成以下准备:

  • 下载本轮附件到任务目录;Project 工作区下的附件放入 .wegent/attachments/<taskId>/<subtaskId>/,非 Project 任务放入 executor 任务目录下的附件子目录。
  • 恢复 ~/.claude/plugins/cache 中仍被 enabledPlugins 启用但安装目录缺失的插件包,并修复插件 hook 权限。
  • 根据 Bot/Task 选择的 Skills,把需要的 task skills 部署到 SKILLS_DIR。普通 Project 任务使用全局 ~/.claude/skills;独立 project_id = 0 且带 task skills 的本地工作使用任务级 .claude/skills,避免污染全局目录。
  • 如果配置了 WEGENT_FILE_EDIT_HOOK_COMMAND,在 Claude settings.json 中写入 Write|Edit|MultiEdit|NotebookEdit 的 PreToolUse 和 PostToolUse hook,使文件变更记录能进入本轮 artifact。Wework macapp 启动本地 sidecar 时会默认生成该命令;可以用 WEGENT_FILE_EDIT_HOOK_COMMAND 覆盖完整命令,或用 WEWORK_FILE_EDIT_LOG_ENDPOINT 调整默认上报地址。

本地 executor 对 Claude stdout 的 NDJSON 输出会即时转换为 Responses API 事件:可见文本产生 response.output_text.delta,reasoning 摘要产生 response.reasoning_summary_text.delta,进程结束后仍发送最终 response.completed 或错误事件。Backend 和前端不能假设 response.created 之后紧跟终态事件。

Backend 处理设备回调时,同一 subtask 的队列写回、文本存储、前端广播和完成结果收集都在同一把消息锁内执行。数据库写回的等待不能让完成事件越过最后一段文本,否则完成结果中的文本块会截断。


🔄 任务执行流程​

本地协作小组编排​

本地项目、智能体和协作小组由 Wework 通过本地 Executor 管理,并持久化在 Executor 的本地 SQLite 数据库中。云端项目及其成员、智能体和协作小组仍由 Backend 管理。界面中的“本地”和“云端”切换只负责选择当前资源域,不能把两个域 的数据合并成同一个列表,也不能让本地资源调用邀请云端成员等仅属于云端的能力。

当本地 Issue 指派给协作小组时,Executor 从所属项目的 collaboration_groups 配置中读取负责人、成员和协作规则。没有负责人的小组不会 启动自动编排;有固定阶段的小组按阶段顺序创建工作流节点。使用“负责人协调”且 未配置固定阶段时,Executor 先创建负责人节点,由负责人根据 Issue 内容规划后续 工作。

负责人运行时只能通过任务作用域内的 wework_space 工具读取当前 Issue、查询 当前协作小组允许的候选人,并调用 submit_workflow_plan 提交一次工作计划。计划 在同一事务内校验:

  • 调用来自当前 Issue 的活跃协作工作流;
  • 每个 client_key 唯一,且任务包含标题和负责人;
  • 负责人必须属于当前协作小组;
  • 智能体必须处于当前项目并保持启用;
  • 同一个工作流只能成功提交一次计划。

校验通过后,Executor 将计划展开为持久化工作流节点,并按依赖顺序创建或推进 子任务。校验失败时不写入部分节点,原 Issue 和工作流保持可诊断的一致状态。本地 工作流不调用云端项目空间的分配或结果上报接口;云端协作继续使用 Backend 的项目 空间工作流。

任务状态流转​


🔐 安全机制​

认证流程​

安全特性​

特性描述
JWT 认证WebSocket 连接需要有效 token
Token 有效期7 天过期,需定期刷新
用户隔离设备只能执行其所有者的任务
硬件绑定设备 ID 基于硬件标识生成

后端触发的 terminal 与 code-server session 会把相对路径解析到配置中的本地 workspace root 下。后端触发的升级必须在重启 executor 前停止运行中的本地任务:未设置 force_stop_tasks 时升级会以 busy 状态拒绝;如果强制停止任一任务失败,升级会立即中止并上报 error 状态,不会继续进入重启流程。

本地执行器连接配置​

本地执行器启动时按“环境变量、~/.wegent-executor/device-config.json、默认值”的顺序解析配置。未设置 WEGENT_EXECUTOR_HOME 时默认使用 ~/.wegent-executor。executor 启动时始终提供 HTTP server。Wework 启动子进程时会设置 WEGENT_APP_IPC_DEVICE_ID,由此明确启用当前进程 stdin/stdout 上的本机 App JSONL IPC;如果同时设置 connection.backend_url 或 WEGENT_BACKEND_URL,同一进程还会连接 Backend,connection.auth_token 或 WEGENT_AUTH_TOKEN 用于设备认证。独立启动且只提供 Backend 连接信息的 Local Executor 不启用 stdio 控制面,继续沿用原有 Socket.IO 远端设备链路。Wework App 只管理并连接自己直接启动的 executor 子进程,不会发现或附着 App 外手动启动的 executor;完整退出 App 时也只终止自己持有的子进程。

macOS 开发模式通过 Node 监听器监控源码:启动时只编译并运行一次实际 executor,源码变化后重新编译并重启。Windows 开发模式继续使用 wegent-executor-dev。监听器必须同时监控启动它的 Wework 父进程:Unix 上一旦父 PID 变化,就停止当前 executor 并退出,不能在 Wework 已退出后被系统接管并继续重启 executor。

EXECUTOR_MODE 覆盖 mode。docker 表示只启动 HTTP server;其他值启动 loopback HTTP server,并根据上述显式身份选择 Wework stdio 控制面或独立 Local Executor 的远端 Backend 控制面,不再创建本机 IPC socket。WEGENT_BACKEND_URL 覆盖 connection.backend_url,WEGENT_SOCKET_URL 覆盖 connection.socket_url,WEGENT_AUTH_TOKEN 覆盖 connection.auth_token。Socket 地址为空时默认复用 Backend 地址;地址分离时,HTTP API 使用 Backend 地址,executor 的 Socket.IO transport 使用独立 Socket 地址。因此常规独立启动脚本不需要传入 WEGENT_APP_IPC_DEVICE_ID,远端功能和连接方式保持不变。

云设备启动身份变量​

云设备通过 user data 启动脚本自动安装并运行 executor。启动脚本会注入以下身份相关环境变量:

变量来源用途
WEGENT_AUTH_TOKEN后端为云设备自动生成的 API Keyexecutor 连接后端并注册设备
WEGENT_USER_JWT_TOKEN创建云设备请求中的当前用户 Bearer JWT云设备内需要以当前用户身份访问后端能力的脚本或集成
WEGENT_USER_NAME当前登录用户名云设备内需要识别当前用户的脚本或集成

WEGENT_AUTH_TOKEN 与 WEGENT_USER_JWT_TOKEN 不能混用:前者代表设备认证身份,后者代表创建云设备时的用户身份。

云设备启动系统配置​

创建云设备时,后端会生成 ubuntu 用户的初始化登录密码,并存储在 Device CRD 的 spec.cloudConfig.ubuntuInitialPassword 字段中。user data 启动脚本会使用该密码执行 chpasswd,完成 ubuntu 用户密码初始化。

同一个 user data 启动脚本还会创建 /etc/systemd/system/fstrim.timer.d/override.conf,将 fstrim.timer 配置为每天运行,并重新加载、重启、启用该 timer。

用户隔离​

每个设备会话绑定到用户:

  • 设备只能接收其注册所有者的任务
  • 防止跨用户任务执行
  • 子任务根据用户命名空间进行验证

数据隐私​

使用本地设备时:

  • 代码留在本地:源代码不会上传到云端
  • 本地执行:所有处理在用户机器上进行
  • 结果流式传输:只有输出文本被传输
  • 无持久存储:云端不存储本地文件

🔧 设备 ID 生成​

Executor 自动生成稳定的设备 ID,基于以下优先级:

  1. 缓存 ID:存储在 ~/.wegent-executor/device_id(如存在)
  2. 硬件 UUID:
    • macOS:系统硬件 UUID
    • Linux:/etc/machine-id
    • Windows:注册表中的 MachineGuid
  3. 后备方案:MAC 地址或随机 UUID

这确保设备在重启后保持一致的身份标识。


📊 并发控制​

槽位管理​

每个设备支持最多 5 个并发任务:

  • 槽位使用通过心跳实时追踪
  • 所有槽位被占用时设备显示"繁忙"
  • 如果选择繁忙设备,任务会排队等待

负载均衡​


🔗 相关文档​


💬 获取帮助​

需要帮助?