diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4d74b87..b260661 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,14 +1,18 @@ # 当前架构 +> Status: AUTHORITATIVE +> Last updated: 2026-10-08 +> Last verified: 2026-10-08,核对本地源码及回归;真实 provider 和长期运行未复验。 + 本文件描述当前代码,不是历史阶段的冻结设计。公共类型见 `src/interfaces.ts`,MCP schema 见 `src/mcp/schemas.ts`;共享边界见 [SHARED_CORE.md](SHARED_CORE.md)。 `src/mcp/main.ts` 仅为 Codex 入口;`src/host/stdio.ts` 组合宿主 profile、运行配置、TaskStore、DirectWorkspaceProvider、BridgeTaskManager 和 MCP server。启动不创建 ZCode session;坏的 Bridge 配置会明确阻止启动。doctor 对坏配置返回 error。 -Manager 用进程内 promise 队列以及同一 data root 下的 `.tasks/.manager.lock` 序列化调度。锁记录 owner PID,活 owner 不因时间超限被驱逐;死 owner 由串行 reclaim guard 回收。未完成 owner 发布的异常锁明确报错。不同 data root 不共享调度锁,需要调用宿主避免向重叠目录提交冲突任务。PID 重用的跨进程身份强化尚未验证。 +Manager 用进程内 promise 队列以及同一 data root 下的 `.tasks/.manager.lock` 序列化调度。锁记录 owner PID 与 best-effort 启动身份,活 owner 不因时间超限被驱逐;死 owner 由串行 reclaim guard 回收。获取时原子发布完整 owner;释放时原子移出完整目录,再清理私有 retired 目录,避免释放中退出留下缺 owner 的共享锁。不可读取的旧 owner 仍明确报错,不按年龄删除。恢复 single-flight 即使释放锁抛错也会复位。不同 data root 不共享调度锁,需要调用宿主避免向重叠目录提交冲突任务。锁 owner 回收仍依赖 PID 的 ESRCH,尚未按保存的 fingerprint 判断复用。 同一 data root 内,FIFO 队列按 worker 上限和执行路径重叠规则启动 detached worker。Spawner 显式传 attempt。worker 在进入 adapter 前通过 attempt 目录的永久 `execution.claim` 抢占执行权,拒绝重复、旧 attempt 和终态入场。`state.lock` 保护状态更新和结果提交;worker 提交还校验当前 attempt/非终态。Manager 的延迟 PID 写入只作用于仍 running 的同一 attempt。 -未启动 worker 可重拉一次;抢占过的 attempt 不会重复执行。已开始的 worker 不自动重跑。worker 丢失但记录的 ZCode PID 仍存活时保留占用,要求 `zcode_cancel` 验证清理。清理失败的终态任务也保留目录与 slot;续跑被拒绝,再次 cancel 成功后才释放。进程身份依赖 PID;操作系统重用 PID 和自行脱离进程组的后代属于未充分验证的边界。 +未启动 worker 可重拉一次;抢占过的 attempt 不会重复执行。已开始的 worker 不自动重跑。worker 丢失但记录的 ZCode PID 仍存活时保留占用,要求 `zcode_cancel` 验证清理。清理失败的终态任务也保留目录与 slot;续跑被拒绝,再次 cancel 或只读恢复探测确认退出后才释放,原 failed 结果不改写。进程恢复使用 PID 和启动 fingerprint;unknown 不当作已退出。Windows 强制清理先采集当前树及身份,再执行 taskkill,并核验采集到的根与后代均已退出;非零退出也可以由新退出证据解消,存活或 unknown 均不释放。公共错误使用稳定诊断代码,不转发本地化 taskkill 文本。采样后新建、根退出前已脱离树的后代,以及查询与发信号间的 PID 复用窗口仍属未充分验证边界。见 ADR-005。 worker 每 3 秒原子写入一次绑定 attempt 与 PID 的私有 heartbeat,字段含 session、turn、Bridge event 序号和已观测到的 ZCode event 序号。管理器遇到一次负向 PID 探测时,若 heartbeat 不超过 15 秒则暂缓失联判定。ZCode turn 完成后,worker 在清理进程前写入 outcome checkpoint,并在清理成功后更新验证标志;worker 在提交最终 result 之前退出时,管理器可据 checkpoint 恢复报告,清理未验证则保留 `cleanup_failed` 与 workspace 占用。 @@ -22,6 +26,6 @@ worker 每 3 秒原子写入一次绑定 attempt 与 PID 的私有 heartbeat, TaskStore 的 JSON 用临时文件原子 rename;事件文件有独立短锁、字节上限、关键事件保留空间和稀疏索引。追加只读最后一个字节,不重新读取完整历史。worker 把模型输出拆为 2000 字符事件;容量用尽仍可能丢弃非关键事件,普通超长摘要明确标记截断。 -损坏记录会显式诊断、隔离调度,并保留可确定的执行路径。健康且不重叠的任务可继续;无法确定损坏任务的执行范围时暂停新调度,不能猜测它已经释放目录。 +损坏记录会显式诊断、隔离调度,并保留可确定的执行路径。健康且不重叠的任务可继续;无法确定损坏任务的执行范围时暂停新调度,不能猜测它已经释放目录。每次 pump 在调度锁内校验一次全库,快照复用于运行占用、损坏占用和 FIFO 队列;启动前重读 queued 状态。快照不跨操作缓存,终态健康检查仍保留,因此调度成本仍随历史任务数增长。 Desktop 索引同步是 best effort,事务内检查 schema 与 Bridge owner、更新有限状态字段,保留用户标题与额外 metadata。回归使用临时 SQLite。真实 Desktop schema/刷新/并发行为受安装版本影响,本轮未写入真实数据库。 diff --git a/docs/INTERFACES.md b/docs/INTERFACES.md index 8dc75bb..5a6b083 100644 --- a/docs/INTERFACES.md +++ b/docs/INTERFACES.md @@ -1,5 +1,9 @@ # 当前接口与兼容边界 +> Status: AUTHORITATIVE +> Last updated: 2026-10-08 +> Last verified: 2026-10-08,本地源码与回归;原生 session/read 仍 NOT RUN。 + 准确类型与 schema 以 `src/interfaces.ts` 和 `src/mcp/schemas.ts` 为准。核心公共入口见 [SHARED_CORE.md](SHARED_CORE.md)。历史源码注释中的 V0.1/FROZEN 是沿革说明,不代表当前新增功能已经冻结。 默认 MCP 工具:`zcode_task`、`zcode_status`、`zcode_feedback`、`zcode_result`、`zcode_continue`、`zcode_cancel`、`zcode_events`、`zcode_interaction_reply`、`zcode_doctor`、`zcode_model_catalog`、`zcode_default_model`、`zcode_set_default_model`、`zcode_clear_default_model`。实验 progress probe 需显式启用。 @@ -8,6 +12,8 @@ TaskPackage 的五个数组必须存在,可为空。workspace 是绝对项目 任务状态为 queued/running/completed/failed/cancelled/waiting_for_master。后四种结束当前 attempt;completed 表示执行及报告解析完成,宿主仍独立验收。续跑只接受 completed/failed/waiting_for_master,且清理必须已验证;保留同 task ID、执行目录和旧 attempt 证据。 +AgentReport 必须包含布尔型 needs_master_decision;缺失或字符串值仍返回 invalid_agent_report,不静默合成。该错误的显式续作使用仅修复报告的 prompt,携带 candidate 与有界原始响应,不重发原实现任务或测试命令,禁止模型编辑或重跑。Bridge 不自动增加修复 turn;禁止工具操作是 prompt 约束,不是 OS 沙箱。心跳而无业务事件时 observation.activity 为 starting(启动宽限内)或 unknown,不声称业务执行。固定反馈模板区分原 attempt 的 cleanup_failed 与后续 cleanup=verified;未确认清理标为未验证,不能写成 NOT RUN。决策见 ADR-005。 + 任务 objective、requirements、路径、验收、测试命令及续跑 feedback 不截断;完整 prompt 超过 60,000 字符会返回 TASK_INVALID。参考 context 与旧结果摘要仍有明确的截断标记,不能把安全约束只放在参考 context。 zcode_events 使用单调 seq cursor、limit 1–200、wait_ms 0–25,000、raw/summary view。summary 合并可见输出时会注明压缩。公开事件只包含可见文本、工具名称与状态,以及有限的生命周期 metadata;隐藏 reasoning 和未知 usage metadata 不进入公开事件。订阅前记录 `snapshot.runtime.eventSeq`。live 订阅连续 10 秒没有新事件时,每 5 秒按已观测序号向 `session/events` 补拉一次,重放仍经过同样的 session、单调 runtime seq 和 turn ID 过滤;runtime 明确拒绝该方法时降级为纯 live 订阅,连续 3 次失败后同样降级,两种情况都会发出可见的 `session_event_replay_unavailable` 事件。协议变化不能只靠字符串方法名推断支持。`session/read` 未接入,原生当前状态查询仍为 NOT RUN。运行时事件先核对 session、单调 runtime seq 和可用 turn ID;存在历史事件的 session 必须观察新 turn.started 后才接受结束事件。缺少某些身份字段的旧协议仍有兼容路径,真实跨版本行为未全部验证。 diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index 7434fa9..c7ee6b6 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -1,8 +1,8 @@ # Project State > Status: AUTHORITATIVE -> Last updated: 2026-10-07 -> Last verified: 2026-10-07,核对来源见文末。 +> Last updated: 2026-10-08 +> Last verified: 2026-10-08,本地源码与回归;发布与真实运行边界见下文。 当前状态快照,回答"现在什么能用、什么不能用"。路线图和优先级不在这里。 @@ -11,6 +11,7 @@ - 当前发布:`1.2.2`(`package.json`、`plugins/codex-zcode-bridge/plugin.json`)。 - 发布方式:release-please 监听 `master`,合并后自动开版本 PR;合并版本 PR 才产生 tag 与 GitHub Release。 - CI:`.github/workflows/ci.yml`,ubuntu 与 windows 两个作业,跑 typecheck、build、test、validate:plugin,并校验生成的 bundle 已随源码提交。 +- 本地未发布修复:`codex/long-session-reliability-fixes`,包含按身份核验 Windows 进程树、原子释放锁、严格报告提示与续作、provider namespace 兼容、业务观察与清理反馈,以及调度单次快照。版本号未改,未更新安装缓存或重启其他会话服务;不能把本地代码当作已加载版本。决策见 ADR-005,验证结果见 [本次报告](reports/2026-10-08-long-session-reliability.md)。 ## 稳定 @@ -42,6 +43,9 @@ - 真实 GUI 关闭时序、UI 响应与取消时延。 - 真实 Desktop 数据库写入与刷新行为。 - PID 重用,以及自行脱离进程组的后代进程。 +- Windows 树采样后新增的后代,以及采样与发信号之间的身份变化;已运行的真实 Windows 树清理回归不能覆盖这些窗口。 +- 旧不可读取 owner 与崩溃遗留 reclaim guard 的安全恢复;没有按年龄清除旧锁。 +- 长会话真实 provider/报告一次合格率。调度已减少同次重复扫描,仍有随历史库增长的全量校验成本。 ## 核对来源 diff --git a/docs/README.md b/docs/README.md index 1a7b078..e645cc1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,26 +45,27 @@ English readers start at [README.md](../README.md). This index is bilingual; the |---|---|---|---| | [README.md](../README.md) | en | 2026-10-05 | 未记录 | | [README.zh-CN.md](../README.zh-CN.md) | zh | 2026-10-03 | 未记录 | -| [PROJECT_STATE.md](PROJECT_STATE.md) | zh | 2026-10-07 | 2026-10-07 | -| [ARCHITECTURE.md](ARCHITECTURE.md) | zh | 2026-10-03 | 未记录 | -| [INTERFACES.md](INTERFACES.md) | zh | 2026-10-06 | 未记录 | +| [PROJECT_STATE.md](PROJECT_STATE.md) | zh | 2026-10-08 | 2026-10-08,本地源码/回归 | +| [ARCHITECTURE.md](ARCHITECTURE.md) | zh | 2026-10-08 | 2026-10-08,本地源码/回归 | +| [INTERFACES.md](INTERFACES.md) | zh | 2026-10-08 | 2026-10-08,本地源码/回归 | | [SHARED_CORE.md](SHARED_CORE.md) | zh | 2026-10-06 | 未记录 | -| [ZCODE_RUNTIME.md](ZCODE_RUNTIME.md) | zh | 2026-10-03 | 未记录 | +| [ZCODE_RUNTIME.md](ZCODE_RUNTIME.md) | zh | 2026-10-08 | 2026-10-08,本地配置/假运行时 | | [plugins/codex-zcode-bridge/README.md](../plugins/codex-zcode-bridge/README.md) | zh | 2026-10-03 | 未记录 | | [plugins/codex-zcode-bridge/SECURITY.md](../plugins/codex-zcode-bridge/SECURITY.md) | zh + en | 2026-10-03 | 未记录 | -| [plugins/codex-zcode-bridge/skills/zcode-bridge/SKILL.md](../plugins/codex-zcode-bridge/skills/zcode-bridge/SKILL.md) | zh | 2026-10-06 | 未记录 | +| [plugins/codex-zcode-bridge/skills/zcode-bridge/SKILL.md](../plugins/codex-zcode-bridge/skills/zcode-bridge/SKILL.md) | zh | 2026-10-08 | 2026-10-08,本地源码/合同 | -`最后核对` 表示上一次有人把文档内容与代码逐条对照的日期。这一列目前全部为空,说明此前没有这个习惯;新建和修改文档时必须填写,否则该文档只能算"最后更新",不能算"已验证"。 +`最后核对` 表示上一次有人把文档内容与代码对照的日期及范围;标记为未记录的条目仍缺少核对证据。新建和修改文档时必须填写,否则该文档只能算"最后更新",不能算"已验证"。本地回归不代表真实 provider 或长期运行通过。 ### 决策 DECISION | 文档 | 日期 | 说明 | |---|---|---| -| [decisions/README.md](decisions/README.md) | 2026-10-07 | 决策索引 | +| [decisions/README.md](decisions/README.md) | 2026-10-08 | 决策索引 | | [ADR-001](decisions/ADR-001-appserver-as-production-execution-path.md) | 2026-10-07 | Accepted:生产执行路径使用 app-server | | [ADR-002](decisions/ADR-002-manager-owns-task-lifecycle.md) | 2026-10-07 | Accepted:Manager 独占生命周期,worker 通过 attempt claim 入场 | | [ADR-003](decisions/ADR-003-execution-directory-prepared-by-host.md) | 2026-10-07 | Accepted:执行目录由调用宿主准备 | | [ADR-004](decisions/ADR-004-observation-is-not-control.md) | 2026-10-07 | Proposed:本地材料只作观察面,待 Master 决策 | +| [ADR-005](decisions/ADR-005-long-session-reliability.md) | 2026-10-08 | Accepted:长会话清理、锁释放、报告修复与 provider 兼容 | | [decisions/roadmap-decisions-2026-09-27.md](decisions/roadmap-decisions-2026-09-27.md) | 2026-09-27 | 路线图与决策讨论 | | [decisions/reliability-repair-plan-v2-2026-10-03.md](decisions/reliability-repair-plan-v2-2026-10-03.md) | 2026-10-03 | 可靠性修复计划,含未完成项 | @@ -93,6 +94,7 @@ English readers start at [README.md](../README.md). This index is bilingual; the | 文档 | 说明 | |---|---| | [TASK_FEEDBACK_V01_IMPLEMENTATION_REPORT.md](../TASK_FEEDBACK_V01_IMPLEMENTATION_REPORT.md) | Task Feedback v0.1 的交付报告,一次性材料 | +| [reports/2026-10-08-long-session-reliability.md](reports/2026-10-08-long-session-reliability.md) | 长会话故障证据、本地修复、验证与未运行边界 | ### 自动生成 Generated diff --git a/docs/ZCODE_RUNTIME.md b/docs/ZCODE_RUNTIME.md index 5c0a3e2..17b2e37 100644 --- a/docs/ZCODE_RUNTIME.md +++ b/docs/ZCODE_RUNTIME.md @@ -1,9 +1,15 @@ # ZCode 运行配置边界 +> Status: AUTHORITATIVE +> Last updated: 2026-10-08 +> Last verified: 2026-10-08,本地配置及模型选择回归;真实 provider 未复验。 + 当前生产路径是 `node zcode.cjs app-server --stdio`,不是历史 CLI `--prompt --json`。解析入口为 `NodeRuntimeResolver`,配置项与用户设置方法见仓库 README。 Bridge 只读取官方 builtin/personal provider 配置,不复制、不改写内容,也不将环境或凭据写入任务 metadata。personal 配置的 `config.providerConfigRules.providerRules` 必须为非空数组或对象;已知空 stub 被拒绝。结构验证不能证明 provider 可用或账号有权限。 +请求模型时优先保留 catalog 中的精确 provider/model。无前缀旧 provider 只有在 catalog 存在同 model 的 account 前缀项,或官方规则确立了对应 account 映射时才解析;不按模型显示名切换其他 provider,catalog 缺席仍由 session/setModel 验证配置标识与模型。account: 标识不重复加前缀。请求值与 runtime 确认值分别保留,不持久化为工作区默认。见 ADR-005。 + persisted runtime-config 中已知字段只能是字符串或 null;缺文件允许环境/发现回退,存在但损坏、非 object 或超过 64 KiB 会报配置错误。默认 mode 仍为已披露的 yolo;不能把坏配置当成首次未设置而回退到该模式。 执行和模型目录 RPC 客户端都是私有集成实现,当前假运行时测试覆盖调用选择、事件、交互和清理。真实协议随安装版本变化;此前会话的 capability 记录属于历史观察,本轮没有新建真实 session 进行版本认证。 diff --git a/docs/decisions/ADR-005-long-session-reliability.md b/docs/decisions/ADR-005-long-session-reliability.md new file mode 100644 index 0000000..180d8a6 --- /dev/null +++ b/docs/decisions/ADR-005-long-session-reliability.md @@ -0,0 +1,26 @@ +# ADR-005:长会话清理与报告修复 + +> Status: DECISION +> Decision: Accepted(用户于 2026-10-08 授权执行本次修复) +> Date: 2026-10-08 + +## Context + +LumeCAE 长会话的持久化证据包含 Windows 清理失败、报告字段缺失、旧 provider ID 选择失败与恢复锁阻塞。历史失败不应通过改写真实任务记录消除;个别 worker_lost 与旧损坏锁的根因仍不确定。 + +## Decision + +1. Windows 清理在终止前采集当前进程树及启动身份,终止后核验采集到的所有身份。taskkill 非零退出不能独自决定失败;只有已记录身份全部退出才确认清理。仍存活或 unknown 均保持失败与占用,探测表明根身份已退出或重用时不向该 PID 发信号。不按本地化错误文本判定成功。 +2. 锁释放先将完整目录原子移出共享锁名,再尽力清理私有 retired 目录。活 owner、不可读取的旧 owner 不按年龄删除。恢复 promise 的清理必须在锁释放异常时也执行。 +3. AgentReport 继续严格校验,不补造缺失的 needs_master_decision,不自动发送修复 turn。任务提示提供合法 JSON 示例;报告续作只提供既有报告证据和反馈,省去原实现任务,禁止编辑文件或重跑测试。 +4. 模型选择优先保持 runtime catalog 中的精确 provider/model;仅在精确值缺席且对应 account 前缀值真实存在时解析无前缀旧 ID。不能只按 model_id 或显示名称选择其他 provider。 +5. 心跳且缺少业务事件时不声称正在执行业务;固定反馈投影只读取相同 task/attempt/status 的结果,业务时间从 observation 的业务事件年龄取得,不使用 status 更新时间。历史 cleanup_failed 与后续清理验证作为两个事实保留。公开任务状态、MCP schema、隐私边界和依赖保持不变。 +6. 大历史库回归确认调度重复全量校验。每次持有调度锁的 pump 只采集一次健康性与状态快照,复用于 running、损坏记录占用和 FIFO queued 集合;启动前仍重读 queued 状态。快照不跨操作缓存,不省略终态健康检查,不降低损坏任务或 cleanup_unverified 的占用保护。调度复杂度仍随历史任务数增长,不能宣称消除了长期扩展限制。 + +## Rationale + +按身份和实际退出证据消除收尾竞态,比忽略 taskkill 错误更保守;原子撤销锁避免释放中崩溃留下缺 owner 的共享目录。严格报告校验和显式续作保留 Master 决策权。 + +## Consequences + +Windows 清理增加有界的系统进程树查询,使用已有 PowerShell 系统设施,无新包依赖。采样之后新建或自行脱离的后代仍为未充分验证的边界。旧损坏锁需要独立诊断,不自动修复真实任务库。模型输出仍可能违反合同;本修复不保证所有报告一次合格。真实 provider、跨版本协议和长期无人值守回归需另行验证。 diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 79bc1b2..60e8d0f 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -1,7 +1,7 @@ # 决策记录 / Decisions > Status: AUTHORITATIVE(仅指本索引) -> Last updated: 2026-10-07 +> Last updated: 2026-10-08 本目录保存已批准的架构决策和带日期的决策记录。决策回答"为什么这样定",当前实现仍以 `ARCHITECTURE.md` / `INTERFACES.md` 为准。 @@ -11,6 +11,7 @@ |---|---|---| | [roadmap-decisions-2026-09-27.md](roadmap-decisions-2026-09-27.md) | DECISION | 2026-09-27 的路线图与决策讨论。仍然成立的结论需要提炼进 `ARCHITECTURE.md` / `INTERFACES.md`;本文本身不是当前事实来源。 | | [reliability-repair-plan-v2-2026-10-03.md](reliability-repair-plan-v2-2026-10-03.md) | DECISION | Bridge 可靠性修复计划。A/B 主要改动已实现;C/D 与宿主启动核验仍有未完成项。 | +| [ADR-005](ADR-005-long-session-reliability.md) | Accepted | 长会话 Windows 清理验证、原子锁释放、报告续作与 catalog provider 兼容;历史记录保持不变。 | ## 待补的 ADR diff --git a/docs/reports/2026-10-08-long-session-reliability.md b/docs/reports/2026-10-08-long-session-reliability.md new file mode 100644 index 0000000..0b7c3e5 --- /dev/null +++ b/docs/reports/2026-10-08-long-session-reliability.md @@ -0,0 +1,56 @@ +# 长会话可靠性修复记录 + +> Status: RESEARCH(一次性交付与验证记录,不替代当前合同) +> Date: 2026-10-08 +> Branch: codex/long-session-reliability-fixes +> Base HEAD: ed2d402 + +## 问题证据 + +检查会话 `01a0dee0-0001-7773-ba52-706087631028`、本地 LumeCAE 任务记录和整改索引。快照有 167 个逻辑任务、275 个唯一 attempt 结果:completed 138、failed 119、cancelled 16、waiting_for_master 2。失败包含 timeout 56、zcode_nonzero_exit 21、cleanup_failed 15、worker_lost 13、invalid_agent_report 7、provider_config_invalid 6、execution_mode_disabled 1。它们跨插件版本、包含续跑,不是项目失败率,也不是当前版本故障率。 + +6 个格式失败涉及 needs_master_decision;12 个清理失败摘要有编码替换字符。2026-10-06 曾因不可读取的 recovery-lock owner 排队约 1,490 分钟;2026-10-08 JD2 恢复任务与 JD4 再次出现清理和报告格式故障。历史材料仅用于选定回归,不修改真实任务库或重新执行其他会话的项目任务。 + +## 本地改动 + +| 范围 | 修复与保持的边界 | +| --- | --- | +| Windows 清理 | 终止前采集当前根/后代身份,终止后按 startup fingerprint 确认退出。taskkill 非零可由新退出证据解消;根退出不掩盖存活子进程,unknown 不释放占用。公开错误用稳定代码,避免本地化 stderr 乱码。 | +| Windows 身份查询 | 只有正常结束并带完整标记的批量查询可认定缺失 PID 已退出;部分输出、权限错误或截断不变成“已退出”。 | +| 锁释放 | 原子撤销完整共享锁目录,再尽力删除私有 retired 目录。崩溃不在共享锁名留下缺 owner 的目录。释放异常仍复位 recovery single-flight。 | +| 报告 | 保留严格 AgentReport 校验,提供有效 JSON 示例和必填布尔检查。显式报告修复续作只携带 candidate、有界原响应与反馈,不重发原实现目标/测试命令,不自动发起修复 turn。 | +| provider | 优先精确 runtime catalog ID,仅有同 model 的 account catalog 项或官方 account 映射规则时解析无前缀旧 ID;保留请求值与实际选择,不更改工作区默认,最终由 session/setModel 验证。 | +| 反馈 | 心跳而无业务事件不推断业务执行;固定模板区分原 cleanup_failed 和后来 cleanup=verified。记录更新时间不充当业务事件时间;投影拒绝混用旧 task/attempt/status 的结果。 | +| 调度 | 压力回归发现同次 pump 三轮重复全量校验,改为一次锁内快照并复用;启动前重读状态,不跨操作缓存,不降低占用保护。仍随历史库规模增长。 | +| 交付 | ADR-005 先于合同改动,更新架构、接口、运行配置、文档索引与随插件发布的 skill;两个 bundle 重建。 | + +## 验证 + +| 检查 | 本次证据 | +| --- | --- | +| 定向回归第一轮 | 53 passed,0 failed,0 skipped。包括真实 Windows 父/子进程树终止、真实系统身份查询、锁释放中崩溃,以及 taskkill 非零/残留后代/unknown 的受控测试。后续新增反馈、provider 集成和 recovery 释放异常用例纳入全量检查。 | +| 第一轮全量 | 278 tests:276 passed、1 failed、1 skipped;失败为新增 recovery 释放异常用例的 fixture 心跳仍新鲜,未进入注入探测路径。已令 fixture 心跳过期,27 项观察/恢复定向检查全部通过。首次压力用例通过,32/10,000 档 setup 732,770 ms,recovery 25,772 ms;该轮尚未包含调度快照优化。 | +| npm run typecheck | PASS,exit 0 | +| npm test(最终代码) | PASS,exit 0;279 tests:278 passed、0 failed、1 skipped(Windows 不适用的 POSIX mode 权限用例),耗时 647,395 ms。包含真实 Windows 树清理、锁释放崩溃、provider 假运行时集成、单次调度快照和释放异常后的恢复回归。 | +| npm run build | PASS,exit 0;server 与 worker bundle 已同步 | +| npm run validate:plugin | PASS,exit 0 | +| git diff --check | PASS,exit 0;只有既有 LF/CRLF 提示 | + +最终压力回归使用临时库与假 worker/probe,未调用模型。每档状态查询 60 次,均未触发 OS probe,p95 小于测试预算 250 ms: + +| 合成规模(active / historical) | status p50 / p95 / p99(ms) | fixture setup(ms) | recovery(ms) | +| --- | --- | --- | --- | +| 1 / 0 | 6.33 / 19.55 / 23.13 | 137 | 108 | +| 8 / 1,000 | 1.93 / 2.24 / 2.50 | 35,988 | 2,964.2 | +| 32 / 10,000 | 2.92 / 4.58 / 6.36 | 564,977 | 33,741.2 | + +最大档仅 8 个 worker 运行,24 个正常排队。setup 包含历史记录生成及 active 提交,不能当作纯派发延迟;两轮不是控制缓存与系统负载的性能实验,不能据此承诺固定提速比例。最大档 setup 仍约 9.4 分钟,recovery 仍约 33.7 秒,显示全库校验扩展性尚未解决。近容量事件扫描读取 589,824 bytes,在预算与允许块余量内,正确返回 scan_incomplete。 + +## 不确定与未运行 + +- 未在真实 ZCode provider 发起任务验证模型选择或报告一次合格率;模型仍可能违反 JSON 合同。 +- 未运行跨版本 session/read、真实审批、GUI 或长期无人值守验收;不切换默认 Executor。 +- 进程树采样后新建或已脱离树的后代,以及查询与发信号间的 PID 复用窗口,仍未充分验证。 +- 本次树身份快照用于当前强制终止调用,没有作为长期持久化后代清单保存。Windows app-server 自然关闭和后续只读恢复仍依据记录的 worker/runtime 身份,不能据此保证孤儿后代已经退出;完整的跨重启树清理闭环仍未完成。 +- 旧损坏 owner 与遗留 reclaim guard 不能只按年龄删;本次修复防止新的释放缺口,不自动修改旧锁。历史 worker_lost 的确切根因仍不确定。 +- 未改安装缓存、重启服务、同步 dsh、提交、推送或发布;本地修复不等同已加载到其他会话。版本仍 1.2.2,后续发布走 release-please。 diff --git a/plugins/codex-zcode-bridge/dist/bridge.mjs b/plugins/codex-zcode-bridge/dist/bridge.mjs index f656247..5b13c5d 100644 --- a/plugins/codex-zcode-bridge/dist/bridge.mjs +++ b/plugins/codex-zcode-bridge/dist/bridge.mjs @@ -83,8 +83,9 @@ function tryAcquireProcessLockWithRetry(directory, retries) { return () => { const owner = JSON.parse(readFileSync2(path3.join(directory, "owner.json"), "utf8")); if (owner.token !== token) throw new Error("process lock ownership changed"); - unlinkSync(path3.join(directory, "owner.json")); - rmdirSync(directory); + const retired = `${directory}.${token}.retired`; + renameSync(directory, retired); + removeStagedLock(retired); }; } function acquireExistingLock(directory) { @@ -136,8 +137,7 @@ function reclaimDeadOwner(directory) { } const retired = `${directory}.${randomUUID()}.retired`; renameSync(directory, retired); - unlinkSync(path3.join(retired, "owner.json")); - rmdirSync(retired); + removeStagedLock(retired); return true; } catch (error2) { if (error2.code !== "ENOENT") throw error2; @@ -1269,7 +1269,10 @@ function judgeTaskObservation(input) { const eventAge = ageMs(now, input.last_business_event?.at, options.clock_jump_guard_ms).value; const withinStartGrace = startAge !== null && startAge < options.start_grace_ms; if (heartbeatFresh) { - if (eventAge !== null && eventAge > options.stall_hint_ms) { + if (eventAge === null) { + activity = withinStartGrace ? "starting" : "unknown"; + activityReason = "heartbeat_alive_no_business_event"; + } else if (eventAge > options.stall_hint_ms) { activity = "stalled"; activityReason = "heartbeat_alive_business_events_stale"; } else { @@ -23546,7 +23549,297 @@ function createWorkerSpawner(host) { var defaultSpawnWorker = createWorkerSpawner(); // src/adapters/process-spawn.ts +import { spawn as spawn3 } from "node:child_process"; + +// src/runtime/process-probe.ts +import { readFile } from "node:fs/promises"; import { spawn as spawn2 } from "node:child_process"; +var PROCESS_IDENTITY_VERSION = 1; +var DEFAULT_PROBE_TIMEOUT_MS = 5e3; +var DEFAULT_MAX_CONCURRENT = 2; +function identityOfFingerprint(pid, fingerprint, precision, now) { + return { + pid, + fingerprint, + fingerprint_precision: precision, + identity_version: PROCESS_IDENTITY_VERSION, + platform: process.platform, + captured_at: new Date(now()).toISOString() + }; +} +function verdict(state, reason, now) { + return { state, reason_code: reason, observed_at: new Date(now()).toISOString() }; +} +function failedQuery(reason) { + return { ok: false, reason, fingerprints: /* @__PURE__ */ new Map() }; +} +function windowsBatchQuery(pids, timeoutMs) { + return new Promise((resolve) => { + const script = `$ErrorActionPreference = 'Stop'; Get-Process -Id ${pids.join(",")} -ErrorAction SilentlyContinue | ForEach-Object { "{0}|{1}" -f $_.Id, $_.StartTime.Ticks }; 'bridge_probe_complete_v1'`; + const child = spawn2("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + shell: false + }); + let stdout = ""; + let settled = false; + const finish = (query) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(query); + }; + const timer = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + } + finish(failedQuery("timeout")); + }, timeoutMs); + timer.unref(); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + let stderrBytes = 0; + child.stderr.on("data", (chunk) => { + stderrBytes = Math.min(64e3, stderrBytes + chunk.length); + }); + child.stdout.on("data", (chunk) => { + if (stdout.length < 1e6) stdout += chunk; + }); + child.on("error", () => finish(failedQuery("spawn_error"))); + child.on("close", (code) => { + const map = /* @__PURE__ */ new Map(); + const lines = stdout.trim().split(/\r?\n/u); + if (code !== 0 || stderrBytes > 0 || lines.pop() !== "bridge_probe_complete_v1") { + finish(failedQuery("query_error")); + return; + } + for (const line of lines) { + if (!line.trim()) continue; + const match = /^(\d+)\|(\d+)$/u.exec(line.trim()); + if (!match || !pids.includes(Number(match[1])) || map.has(Number(match[1]))) { + finish(failedQuery("query_error")); + return; + } + map.set(Number(match[1]), match[2]); + } + finish({ ok: true, reason: "ok", fingerprints: map }); + }); + }); +} +async function darwinQuery(pids, timeoutMs) { + return new Promise((resolve) => { + const child = spawn2("ps", ["-o", "pid=,lstart=", "-p", pids.join(",")], { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + shell: false + }); + let stdout = ""; + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + try { + child.kill("SIGKILL"); + } catch { + } + resolve(failedQuery("timeout")); + }, timeoutMs); + timer.unref(); + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + if (stdout.length < 1e6) stdout += chunk; + }); + child.on("error", () => { + if (!settled) { + settled = true; + clearTimeout(timer); + resolve(failedQuery("spawn_error")); + } + }); + child.on("close", () => { + if (settled) return; + settled = true; + clearTimeout(timer); + const map = /* @__PURE__ */ new Map(); + for (const line of stdout.split(/\r?\n/u)) { + const match = /^\s*(\d+)\s+(.+)$/u.exec(line); + if (match) map.set(Number(match[1]), match[2].trim()); + } + resolve({ ok: true, reason: "ok", fingerprints: map }); + }); + }); +} +function createPlatformProbe(options = {}) { + const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; + const maxConcurrent = options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT; + const now = options.now ?? Date.now; + let chain = Promise.resolve(); + let inFlight = 0; + let waiters = []; + const schedule = async (operation) => { + if (inFlight >= maxConcurrent) { + await new Promise((resolve) => waiters.push(resolve)); + } + inFlight += 1; + try { + return await operation(); + } finally { + inFlight -= 1; + const next = waiters.shift(); + if (next) next(); + } + }; + const probe = async (requests) => { + if (requests.length === 0) return []; + const distinct = [...new Set(requests.map((request) => request.pid).filter((pid) => Number.isInteger(pid) && pid > 0))]; + const query = await schedule(async () => { + if (process.platform === "win32") return windowsBatchQuery(distinct, timeoutMs); + if (process.platform === "darwin") return darwinQuery(distinct, timeoutMs); + if (process.platform === "linux") { + const map = /* @__PURE__ */ new Map(); + for (const pid of distinct) { + try { + const stat2 = await readFile(`/proc/${pid}/stat`, "utf8"); + const afterComm = stat2.slice(stat2.lastIndexOf(")") + 2); + const fields = afterComm.split(" "); + const starttime = fields[19]; + if (starttime) map.set(pid, starttime.trim()); + } catch (error2) { + if (error2.code === "ENOENT") continue; + return failedQuery("proc_read_error"); + } + } + return { ok: true, reason: "ok", fingerprints: map }; + } + return failedQuery("unsupported_platform"); + }); + return requests.map((request) => { + if (!Number.isInteger(request.pid) || request.pid <= 0) return verdict("unknown", "invalid_pid", now); + if (!query.ok) return verdict("unknown", `query_${query.reason}`, now); + const fingerprint = query.fingerprints.get(request.pid); + if (fingerprint === void 0) { + return verdict("exited", "pid_absent", now); + } + if (!request.identity || request.identity.fingerprint === null) { + return verdict("unknown", "live_pid_no_fingerprint", now); + } + if (request.identity.fingerprint_precision === "coarse") { + return request.identity.fingerprint === fingerprint ? verdict("unknown", "coarse_fingerprint_match", now) : verdict("exited", "pid_reused_coarse", now); + } + if (request.identity.fingerprint !== fingerprint) { + return verdict("exited", "pid_reused", now); + } + return verdict("alive", "pid_and_fingerprint_match", now); + }); + }; + const fingerprintOfPid = async (pid) => { + const query = await schedule(async () => { + if (process.platform === "win32") return windowsBatchQuery([pid], timeoutMs); + if (process.platform === "darwin") return darwinQuery([pid], timeoutMs); + if (process.platform === "linux") { + const map = /* @__PURE__ */ new Map(); + try { + const stat2 = await readFile(`/proc/${pid}/stat`, "utf8"); + const afterComm = stat2.slice(stat2.lastIndexOf(")") + 2); + const starttime = afterComm.split(" ")[19]; + if (starttime) map.set(pid, starttime.trim()); + } catch { + } + return { ok: true, reason: "ok", fingerprints: map }; + } + return failedQuery("unsupported_platform"); + }); + const fingerprint = query.fingerprints.get(pid); + if (!query.ok || !fingerprint) return identityOfFingerprint(pid, null, "unknown", now); + const precision = process.platform === "darwin" ? "coarse" : "exact"; + return identityOfFingerprint(pid, fingerprint, precision, now); + }; + return { + platform: process.platform, + identityOf: fingerprintOfPid, + selfIdentity: () => fingerprintOfPid(process.pid), + probe + }; +} +function livenessVerdict(pid, isAlive, now) { + if (pid === null || !Number.isInteger(pid) || pid <= 0) return verdict("unknown", "no_pid_recorded", now); + return isAlive(pid) ? verdict("alive", "kill0_alive_no_identity", now) : verdict("exited", "kill0_pid_absent", now); +} + +// src/adapters/windows-process-cleanup.ts +import { performance } from "node:perf_hooks"; +async function terminateWindowsProcessTree(pid, killWaitMs, dependencies) { + const requests = await dependencies.capture(pid); + if (!requests.some((request) => request.pid === pid)) throw new Error("process_tree_snapshot_invalid: root missing"); + const inspect = async () => { + const verdicts = await dependencies.probe(requests); + if (verdicts.length !== requests.length || verdicts.some((verdict2) => verdict2.state === "unknown")) { + throw new Error("process_tree_probe_unverified: recorded executor exit is unknown"); + } + return verdicts; + }; + const before = await inspect(); + if (before.every((verdict2) => verdict2.state === "exited")) return; + const rootIndex = requests.findIndex((request) => request.pid === pid); + if (before[rootIndex]?.state !== "alive") { + throw new Error("process_tree_descendants_alive: root exited before termination"); + } + let exitCode = null; + let commandFailed = false; + try { + exitCode = await dependencies.kill(pid); + } catch { + commandFailed = true; + } + const deadline = performance.now() + Math.max(0, killWaitMs); + for (; ; ) { + const after = await inspect(); + if (after.every((verdict2) => verdict2.state === "exited")) return; + if (performance.now() >= deadline) { + const reason = commandFailed ? "command_error" : exitCode === null ? "timeout" : `exit_${exitCode}`; + throw new Error(`process_tree_cleanup_unverified: taskkill ${reason}; recorded executors remain alive`); + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} +function windowsTreeCaptureScript(pid) { + return [ + "$ErrorActionPreference = 'Stop'", + "$all = @(Get-CimInstance Win32_Process -ErrorAction Stop)", + `$selected = [System.Collections.Generic.HashSet[int]]::new(); [void]$selected.Add(${pid})`, + "do { $changed = $false; foreach ($item in $all) { if ($selected.Contains([int]$item.ParentProcessId)) { if ($selected.Add([int]$item.ProcessId)) { $changed = $true } } } } while ($changed)", + "'bridge_tree_snapshot_v1'", + "foreach ($candidate in $selected) { $item = Get-Process -Id $candidate -ErrorAction SilentlyContinue; if ($null -eq $item) { '{0}|absent' -f $candidate } else { '{0}|{1}' -f $candidate, $item.StartTime.Ticks } }" + ].join("; "); +} +function parseWindowsTreeCapture(stdout, pid) { + const lines = stdout.trim().split(/\r?\n/u); + if (lines.shift() !== "bridge_tree_snapshot_v1") throw new Error("process_tree_snapshot_invalid: protocol missing"); + const capturedAt = (/* @__PURE__ */ new Date()).toISOString(); + const requests = []; + const seen = /* @__PURE__ */ new Set(); + for (const line of lines) { + const match = /^(\d+)\|(\d+|absent)$/u.exec(line.trim()); + const candidate = Number(match?.[1]); + if (!match || !Number.isSafeInteger(candidate) || candidate <= 0 || seen.has(candidate)) { + throw new Error("process_tree_snapshot_invalid: malformed identity"); + } + seen.add(candidate); + requests.push({ pid: candidate, identity: match[2] === "absent" ? null : { + pid: candidate, + fingerprint: match[2], + fingerprint_precision: "exact", + identity_version: 1, + platform: "win32", + captured_at: capturedAt + } }); + } + if (!seen.has(pid)) throw new Error("process_tree_snapshot_invalid: root missing"); + return requests; +} + +// src/adapters/process-spawn.ts function appendBounded(current, chunk, maxBytes) { if (!(maxBytes > 0)) return { value: current.value + chunk, truncated: current.truncated }; const remaining = maxBytes - Buffer.byteLength(current.value, "utf8"); @@ -23576,17 +23869,26 @@ var terminateProcessTree = async (pid, options = {}) => { throw new Error(`A positive integer PID is required, got: ${pid}`); } if (process.platform === "win32") { - const result = await runCommand("taskkill", ["/PID", String(pid), "/T", "/F"], { - timeoutMs: 15e3 + const probe = createPlatformProbe(); + await terminateWindowsProcessTree(pid, options.killWaitMs ?? 2e3, { + capture: async (root) => { + let snapshot; + try { + snapshot = await runCommand("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", windowsTreeCaptureScript(root)], { + timeoutMs: 5e3, + maxOutputBytes: 128e3 + }); + } catch { + throw new Error("process_tree_snapshot_unverified: query failed"); + } + if (snapshot.code !== 0 || snapshot.stdoutTruncated || snapshot.stderr.length > 0) { + throw new Error("process_tree_snapshot_unverified: query failed or incomplete"); + } + return parseWindowsTreeCapture(snapshot.stdout, root); + }, + probe: (requests) => probe.probe(requests), + kill: async (root) => (await runCommand("taskkill", ["/PID", String(root), "/T", "/F"], { timeoutMs: 15e3 })).code }); - if (result.code !== 0) { - throw new Error( - result.stderr.trim() || `taskkill exited with code ${String(result.code)}` - ); - } - if (!await waitForPidExit(pid, options.killWaitMs ?? 2e3)) { - throw new Error(`Process tree ${pid} still running after taskkill reported success`); - } return { pid, signal: "SIGKILL", verified: true }; } signalProcessTree(pid, "SIGTERM"); @@ -23601,7 +23903,7 @@ var terminateProcessTree = async (pid, options = {}) => { }; function runCommand(command, args, options) { return new Promise((resolve, reject) => { - const child = spawn2(command, args, { + const child = spawn3(command, args, { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] @@ -23635,7 +23937,8 @@ function runCommand(command, args, options) { code: timedOut ? null : code, signal: timedOut ? "SIGKILL" : signal, stdout: stdout.value, - stderr: stderr.value + stderr: stderr.value, + stdoutTruncated: stdout.truncated }); }); }); @@ -23665,13 +23968,6 @@ function isProcessTreeRunning(pid) { throw error2; } } -async function waitForPidExit(pid, timeoutMs) { - const deadline = Date.now() + timeoutMs; - while (isProcessRunning(pid) && Date.now() < deadline) { - await sleep2(20); - } - return !isProcessRunning(pid); -} async function waitForProcessTreeExit(pid, timeoutMs) { const deadline = Date.now() + timeoutMs; while (isProcessTreeRunning(pid) && Date.now() < deadline) { @@ -23742,6 +24038,24 @@ function buildContinuePrompt(input) { `This run resumes persisted session ${previousSessionId}; earlier conversation context may be available.` ); } + if (previousResult?.error_code === "invalid_agent_report") { + sections.push( + "REPORT REPAIR MODE: The previous execution has ended. Only its final report failed validation. Do not use tools, edit files, rerun tests, or repeat task work. Return the corrected JSON report from the evidence below. Preserve claims as claims, including not_run and failures. Do not invent tests or missing facts. If needs_master_decision cannot be established, explicitly set it to true and record the uncertainty in issues.", + `PROJECT WORKSPACE: ${task.workspace}`, + ...task.worktree_path ? [`HOST-SELECTED EXECUTION WORKTREE: ${task.worktree_path}`] : [], + `PREVIOUS REPORT ERROR +${previousResult.summary}`, + `REPORT CANDIDATE CLAIMS (not independently verified) +${JSON.stringify(previousResult.report_candidate ?? {}, null, 2)}`, + `PREVIOUS RESPONSE +${bounded(previousResult.zcode_output, MAX_SECTION_CHARS)}`, + `MASTER FEEDBACK (report repair only) +${feedback}`, + ...additionalRequirements.length ? [renderList("ADDITIONAL REPORT REQUIREMENTS", [...additionalRequirements])] : [], + OUTPUT_CONTRACT + ); + return joinBoundedPreservingTail(sections, OUTPUT_CONTRACT); + } if (previousResult) { sections.push( `PREVIOUS RESULT (normalized claims from the previous attempt) @@ -23750,11 +24064,6 @@ ${bounded( MAX_SECTION_CHARS )}` ); - if (previousResult.error_code === "invalid_agent_report") { - sections.push( - "REPORT REPAIR MODE: The previous attempt's execution has already ended; only its final report failed validation. Do not edit files, rerun tests, or repeat task work. Reconstruct the final JSON report from the previous response and report_candidate. Do not guess missing facts. If a required boolean or other fact cannot be established, set needs_master_decision=true and describe the uncertainty in issues." - ); - } } sections.push(`MASTER FEEDBACK (address every point) ${feedback}`); @@ -23767,9 +24076,11 @@ ${buildTaskPrompt(task)}`); } var OUTPUT_CONTRACT = [ "OUTPUT CONTRACT (mandatory)", - "Your final response must be exactly one JSON object with no markdown fences and no text before or after it, matching this shape:", - '{"summary": string, "files_changed": string[], "tests": [{"command": string, "status": "passed" | "failed" | "not_run", "details"?: string}], "issues": string[], "needs_master_decision": boolean}', - "List every file you created or modified in files_changed (workspace-relative paths). Give one tests entry per applicable test command; use status not_run when a command was not applicable or could not run. Record problems in issues. Set needs_master_decision=true only when a required decision is outside your authority; never guess." + "Your final response must be exactly one JSON object with no markdown fences and no text before or after it.", + 'Required fields: summary (non-empty string), files_changed (array of strings), tests (array of objects with command string, status "passed" | "failed" | "not_run", optional details string), issues (array of strings), needs_master_decision (JSON boolean).', + 'Valid JSON example (replace example values with observed facts): {"summary":"Describe the actual work","files_changed":[],"tests":[{"command":"An applicable command","status":"not_run","details":"Explain why it was not run"}],"issues":[],"needs_master_decision":true}', + "List every file you created or modified in files_changed (workspace-relative paths). Give one tests entry per applicable test command; use status not_run when a command was not applicable or could not run. Record problems in issues. Set needs_master_decision=true only when a required decision is outside your authority; never guess.", + "Before sending, check that all five top-level fields exist and needs_master_decision is a JSON boolean true or false, never a quoted string and never text such as needs_master_decision=false. If its value is uncertain, set true and explain in issues. Do not omit it." ].join("\n"); var DECISION_RULE = [ "DECISION RULE", @@ -23803,217 +24114,6 @@ function joinBoundedPreservingTail(sections, requiredTail) { throw new Error(`task prompt exceeds ${MAX_PROMPT_CHARS} characters; shorten the task package without dropping constraints`); } -// src/runtime/process-probe.ts -import { readFile } from "node:fs/promises"; -import { spawn as spawn3 } from "node:child_process"; -var PROCESS_IDENTITY_VERSION = 1; -var DEFAULT_PROBE_TIMEOUT_MS = 5e3; -var DEFAULT_MAX_CONCURRENT = 2; -function identityOfFingerprint(pid, fingerprint, precision, now) { - return { - pid, - fingerprint, - fingerprint_precision: precision, - identity_version: PROCESS_IDENTITY_VERSION, - platform: process.platform, - captured_at: new Date(now()).toISOString() - }; -} -function verdict(state, reason, now) { - return { state, reason_code: reason, observed_at: new Date(now()).toISOString() }; -} -function failedQuery(reason) { - return { ok: false, reason, fingerprints: /* @__PURE__ */ new Map() }; -} -function windowsBatchQuery(pids, timeoutMs) { - return new Promise((resolve) => { - const script = `Get-Process -Id ${pids.join(",")} -ErrorAction SilentlyContinue | ForEach-Object { "{0}|{1}" -f $_.Id, $_.StartTime.Ticks }`; - const child = spawn3("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - shell: false - }); - let stdout = ""; - let settled = false; - const finish = (query) => { - if (settled) return; - settled = true; - clearTimeout(timer); - resolve(query); - }; - const timer = setTimeout(() => { - try { - child.kill("SIGKILL"); - } catch { - } - finish(failedQuery("timeout")); - }, timeoutMs); - timer.unref(); - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - let stderrBytes = 0; - child.stderr.on("data", (chunk) => { - stderrBytes = Math.min(64e3, stderrBytes + chunk.length); - }); - child.stdout.on("data", (chunk) => { - if (stdout.length < 1e6) stdout += chunk; - }); - child.on("error", () => finish(failedQuery("spawn_error"))); - child.on("close", () => { - const map = /* @__PURE__ */ new Map(); - for (const line of stdout.split(/\r?\n/u)) { - const match = /^(\d+)\|(\d+)$/u.exec(line.trim()); - if (!match) continue; - map.set(Number(match[1]), match[2]); - } - if (map.size === 0) { - finish(stderrBytes > 0 ? failedQuery("query_error") : { ok: true, reason: "ok", fingerprints: map }); - return; - } - finish({ ok: true, reason: "ok", fingerprints: map }); - }); - }); -} -async function darwinQuery(pids, timeoutMs) { - return new Promise((resolve) => { - const child = spawn3("ps", ["-o", "pid=,lstart=", "-p", pids.join(",")], { - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - shell: false - }); - let stdout = ""; - let settled = false; - const timer = setTimeout(() => { - if (settled) return; - settled = true; - try { - child.kill("SIGKILL"); - } catch { - } - resolve(failedQuery("timeout")); - }, timeoutMs); - timer.unref(); - child.stdout.setEncoding("utf8"); - child.stdout.on("data", (chunk) => { - if (stdout.length < 1e6) stdout += chunk; - }); - child.on("error", () => { - if (!settled) { - settled = true; - clearTimeout(timer); - resolve(failedQuery("spawn_error")); - } - }); - child.on("close", () => { - if (settled) return; - settled = true; - clearTimeout(timer); - const map = /* @__PURE__ */ new Map(); - for (const line of stdout.split(/\r?\n/u)) { - const match = /^\s*(\d+)\s+(.+)$/u.exec(line); - if (match) map.set(Number(match[1]), match[2].trim()); - } - resolve({ ok: true, reason: "ok", fingerprints: map }); - }); - }); -} -function createPlatformProbe(options = {}) { - const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; - const maxConcurrent = options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT; - const now = options.now ?? Date.now; - let chain = Promise.resolve(); - let inFlight = 0; - let waiters = []; - const schedule = async (operation) => { - if (inFlight >= maxConcurrent) { - await new Promise((resolve) => waiters.push(resolve)); - } - inFlight += 1; - try { - return await operation(); - } finally { - inFlight -= 1; - const next = waiters.shift(); - if (next) next(); - } - }; - const probe = async (requests) => { - if (requests.length === 0) return []; - const distinct = [...new Set(requests.map((request) => request.pid).filter((pid) => Number.isInteger(pid) && pid > 0))]; - const query = await schedule(async () => { - if (process.platform === "win32") return windowsBatchQuery(distinct, timeoutMs); - if (process.platform === "darwin") return darwinQuery(distinct, timeoutMs); - if (process.platform === "linux") { - const map = /* @__PURE__ */ new Map(); - for (const pid of distinct) { - try { - const stat2 = await readFile(`/proc/${pid}/stat`, "utf8"); - const afterComm = stat2.slice(stat2.lastIndexOf(")") + 2); - const fields = afterComm.split(" "); - const starttime = fields[19]; - if (starttime) map.set(pid, starttime.trim()); - } catch (error2) { - if (error2.code === "ENOENT") continue; - return failedQuery("proc_read_error"); - } - } - return { ok: true, reason: "ok", fingerprints: map }; - } - return failedQuery("unsupported_platform"); - }); - return requests.map((request) => { - if (!Number.isInteger(request.pid) || request.pid <= 0) return verdict("unknown", "invalid_pid", now); - if (!query.ok) return verdict("unknown", `query_${query.reason}`, now); - const fingerprint = query.fingerprints.get(request.pid); - if (fingerprint === void 0) { - return verdict("exited", "pid_absent", now); - } - if (!request.identity || request.identity.fingerprint === null) { - return verdict("unknown", "live_pid_no_fingerprint", now); - } - if (request.identity.fingerprint_precision === "coarse") { - return request.identity.fingerprint === fingerprint ? verdict("unknown", "coarse_fingerprint_match", now) : verdict("exited", "pid_reused_coarse", now); - } - if (request.identity.fingerprint !== fingerprint) { - return verdict("exited", "pid_reused", now); - } - return verdict("alive", "pid_and_fingerprint_match", now); - }); - }; - const fingerprintOfPid = async (pid) => { - const query = await schedule(async () => { - if (process.platform === "win32") return windowsBatchQuery([pid], timeoutMs); - if (process.platform === "darwin") return darwinQuery([pid], timeoutMs); - if (process.platform === "linux") { - const map = /* @__PURE__ */ new Map(); - try { - const stat2 = await readFile(`/proc/${pid}/stat`, "utf8"); - const afterComm = stat2.slice(stat2.lastIndexOf(")") + 2); - const starttime = afterComm.split(" ")[19]; - if (starttime) map.set(pid, starttime.trim()); - } catch { - } - return { ok: true, reason: "ok", fingerprints: map }; - } - return failedQuery("unsupported_platform"); - }); - const fingerprint = query.fingerprints.get(pid); - if (!query.ok || !fingerprint) return identityOfFingerprint(pid, null, "unknown", now); - const precision = process.platform === "darwin" ? "coarse" : "exact"; - return identityOfFingerprint(pid, fingerprint, precision, now); - }; - return { - platform: process.platform, - identityOf: fingerprintOfPid, - selfIdentity: () => fingerprintOfPid(process.pid), - probe - }; -} -function livenessVerdict(pid, isAlive, now) { - if (pid === null || !Number.isInteger(pid) || pid <= 0) return verdict("unknown", "no_pid_recorded", now); - return isAlive(pid) ? verdict("alive", "kill0_alive_no_identity", now) : verdict("exited", "kill0_pid_absent", now); -} - // src/manager/task-manager.ts init_build(); @@ -24274,8 +24374,11 @@ var BridgeTaskManager = class _BridgeTaskManager { writeFileSync3(lastRunFile, String(Date.now()), { mode: 384 }); } } finally { - releaseRecovery(); - this.#recoveryPromise = null; + try { + releaseRecovery(); + } finally { + this.#recoveryPromise = null; + } } } } @@ -25148,21 +25251,13 @@ var BridgeTaskManager = class _BridgeTaskManager { ); return true; } - #runningTaskIdsLocked() { - return this.#store.listTaskIds().filter((taskId) => { - const status = this.#safeStatus(taskId); - return status?.status === "running" || status?.cleanup_unverified === true; - }); - } - #queuedTaskIdsLocked() { - return this.#store.listTaskIds().map((taskId) => ({ taskId, status: this.#safeStatus(taskId) })).filter((entry) => entry.status?.status === "queued").sort((a, b) => a.status.created_at.localeCompare(b.status.created_at)).map((entry) => entry.taskId); - } #pumpLocked() { - const running = this.#runningTaskIdsLocked(); + const snapshot = this.#store.listTaskIds().map((taskId) => ({ taskId, status: this.#safeStatus(taskId) })); + const running = snapshot.filter(({ status }) => status?.status === "running" || status?.cleanup_unverified === true).map(({ taskId }) => taskId); if (running.length >= this.#maxConcurrentWorkers) return; const occupiedPaths = running.map((taskId) => this.#executionPathKeyLocked(taskId)); - for (const taskId of this.#store.listTaskIds()) { - if (this.#safeStatus(taskId)) continue; + for (const { taskId, status } of snapshot) { + if (status) continue; try { occupiedPaths.push(this.#executionPathKeyLocked(taskId)); } catch { @@ -25170,7 +25265,8 @@ var BridgeTaskManager = class _BridgeTaskManager { } } let slots = this.#maxConcurrentWorkers - running.length; - for (const taskId of this.#queuedTaskIdsLocked()) { + const queued = snapshot.filter(({ status }) => status?.status === "queued").sort((a, b) => a.status.created_at.localeCompare(b.status.created_at)); + for (const { taskId } of queued) { if (slots <= 0) break; const executionPath = this.#executionPathKeyLocked(taskId); if (occupiedPaths.some((occupied) => pathsOverlap(occupied, executionPath))) continue; diff --git a/plugins/codex-zcode-bridge/skills/zcode-bridge/SKILL.md b/plugins/codex-zcode-bridge/skills/zcode-bridge/SKILL.md index 8c4c74e..baf4867 100644 --- a/plugins/codex-zcode-bridge/skills/zcode-bridge/SKILL.md +++ b/plugins/codex-zcode-bridge/skills/zcode-bridge/SKILL.md @@ -26,7 +26,7 @@ description: 通过 Codex ZCode Bridge 把已授权的开发任务交给本机 Z 1. `zcode_task` 返回后立即调用 `zcode_events`(`after_seq: 0, view: "summary"`),不要先做别的工作或只复述 queued receipt。先报告 `workspace_ready` 中的 `project_path`、`execution_path`(如有则说明 Codex 准备的 worktree)和 queued/running 状态。按 `next_seq` 和 `wait_ms` 继续读取,直到出现 `turn_started`、明确启动失败或终态;不要忙轮询。 2. 在 `turn_started` 后、等待模型输出前,先向用户报告:Codex 项目根目录、实际执行目录、ZCode session ID、runtime 实际报告的 provider/model、runtime 实际报告的思考档位和执行模式。思考档位从 `session_ready.details.reasoning_level` 或 `model_selected.details.reasoning_level` 读取;若只有 `requested_reasoning_level`,说明这是请求值而不是 runtime 确认值;两者都没有时明确说 runtime 未报告档位,不要用模型目录的默认档位冒充当前档位。模式来自 Bridge 配置,默认 `yolo`;若为 `yolo`,明确提醒普通工具操作可能不经审批且使用当前 OS 账户权限,并说明可将 `ZCODE_BRIDGE_MODE` 设为 `build`。若使用 worktree,说明它不是 OS 沙箱。模型字段缺失时明确说 runtime 没有报告;不要把用户请求的模型或项目默认值猜成实际已选模型。 3. 如果在 `turn_started` 前失败,立即报告 Bridge 的启动错误;只有在 `session_ready` 已出现时才能声称 ZCode session 已创建。不存在 `session_ready` 时说明没有 ZCode session/model 元数据。 -4. 运行期间用 `zcode_events` 的 `after_seq` 读取增量事件,默认使用 `view: "summary"`,需要逐条文本时改用 `view: "raw"`;`next_seq` 会跨过已合并事件。`wait_ms` 可设为 10000–25000;如需快速刷新状态,可调用 `zcode_status`。需要简洁、可直接呈现的当前 attempt 摘要时调用 `zcode_feedback`;其 `last_observed` activity 不是当前运行中的工具状态,`interaction: not_observed` 也不代表没有交互。向用户简短汇报模型可见输出和工具活动摘要。事件不包含隐藏推理;`interaction_requested` 会包含完成决定所需的有限请求细节。 +4. 运行期间用 `zcode_events` 的 `after_seq` 读取增量事件,默认使用 `view: "summary"`,需要逐条文本时改用 `view: "raw"`;`next_seq` 会跨过已合并事件。`wait_ms` 可设为 10000–25000;如需快速刷新状态,可调用 `zcode_status`。需要简洁、可直接呈现的当前 attempt 摘要时调用 `zcode_feedback`;其 `last_observed` activity 不是当前运行中的工具状态,`interaction: not_observed` 也不代表没有交互。心跳只证明 worker 响应,工具事件只证明已观测活动;没有命令输出或结果时,不把它们描述成测试持续推进、已通过或源码已还原。`stalled` 是提示,不自动取消或重派。向用户简短汇报模型可见输出和工具活动摘要。事件不包含隐藏推理;`interaction_requested` 会包含完成决定所需的有限请求细节。 5. 收到 `interaction_requested` 时,先向用户概述请求的方法、工具、理由、输入、选项或问题。权限请求不得从任务描述、worktree 或 `yolo` 模式推定为获准:只有用户明确授权该项操作才可用 `zcode_interaction_reply` 的 `allow`,否则向用户询问或明确拒绝。用户输入问题只可依据已知信息或用户明确指示回答;计划审批须让用户决定。对于 `interaction/requestUserInput` 的 AskUserQuestion,`answers` 必须以请求中每个 `questions[].question` 的完整原文作为键,以用户选择或明确回答作为值;不得用 `header`、选项标签或选项值作键。先从 `interaction_requested.details.questions`(必要时从 `details.input.questions`)复制准确问题文本,再调用 `zcode_interaction_reply`。若 Bridge 报 `answers must be keyed by the exact ZCode question text`,用原问题文本修正键并重试同一个待处理请求。回复后继续读取事件并确认 ZCode 已继续或结束。 6. 任务终态后调用 `zcode_feedback` 获取标注来源的简洁原生文本,再按需调用 `zcode_result` 查看完整结果。completed 仅表示 Bridge/ZCode 执行和报告规范化完成,不代表代码审查通过;feedback 中的 Agent report 也不是 Bridge 独立验证。 @@ -40,6 +40,7 @@ description: 通过 Codex ZCode Bridge 把已授权的开发任务交给本机 Z 4. Bridge 不会自动应用、合并或删除 worktree。不要在审查及接收改动前删除 Codex 准备的 worktree,也不要自动提交、推送或创建 PR。 5. 如果结果未达标,调用 zcode_continue,反馈具体失败证据;续作会复用同一 ZCode session 和原执行目录。任务运行期间若用户取消,调用 zcode_cancel 并确认终态。 6. 如果结果为 `invalid_agent_report` 且执行工作已完成,先检查 `report_candidate`、原始 `zcode_output` 和实际 diff;续作提示只要求修复 JSON 报告,不要再次编辑或重跑测试。缺少的决定字段必须基于证据补齐,无法确认时设 `needs_master_decision=true`。 +7. 任务为 failed 时用 `zcode_result.error_code` 确认原因,不仅凭 `zcode_feedback` 的失败标签判断代码失败。`cleanup_failed` 是当时清理未能确认;后续 `zcode_status.observation.cleanup=verified` 表示已取得退出证据,原失败仍保留。分别报告两个事实,再独立验收代码。新鲜查询为 unknown 或 unverified 时不自行删除锁、改历史状态或另起同目录任务;旧不可读取锁需要独立诊断,不能按时间删锁。 ## 边界 diff --git a/plugins/codex-zcode-bridge/worker/worker-main.mjs b/plugins/codex-zcode-bridge/worker/worker-main.mjs index f136727..29fb3c7 100644 --- a/plugins/codex-zcode-bridge/worker/worker-main.mjs +++ b/plugins/codex-zcode-bridge/worker/worker-main.mjs @@ -1,5 +1,5 @@ // src/adapters/zcode-app-server-adapter.ts -import { spawn as spawn2 } from "node:child_process"; +import { spawn as spawn3 } from "node:child_process"; import { homedir as homedir3 } from "node:os"; // src/adapters/agent-report.ts @@ -202,6 +202,24 @@ function buildContinuePrompt(input) { `This run resumes persisted session ${previousSessionId}; earlier conversation context may be available.` ); } + if (previousResult?.error_code === "invalid_agent_report") { + sections.push( + "REPORT REPAIR MODE: The previous execution has ended. Only its final report failed validation. Do not use tools, edit files, rerun tests, or repeat task work. Return the corrected JSON report from the evidence below. Preserve claims as claims, including not_run and failures. Do not invent tests or missing facts. If needs_master_decision cannot be established, explicitly set it to true and record the uncertainty in issues.", + `PROJECT WORKSPACE: ${task.workspace}`, + ...task.worktree_path ? [`HOST-SELECTED EXECUTION WORKTREE: ${task.worktree_path}`] : [], + `PREVIOUS REPORT ERROR +${previousResult.summary}`, + `REPORT CANDIDATE CLAIMS (not independently verified) +${JSON.stringify(previousResult.report_candidate ?? {}, null, 2)}`, + `PREVIOUS RESPONSE +${bounded(previousResult.zcode_output, MAX_SECTION_CHARS)}`, + `MASTER FEEDBACK (report repair only) +${feedback}`, + ...additionalRequirements.length ? [renderList("ADDITIONAL REPORT REQUIREMENTS", [...additionalRequirements])] : [], + OUTPUT_CONTRACT + ); + return joinBoundedPreservingTail(sections, OUTPUT_CONTRACT); + } if (previousResult) { sections.push( `PREVIOUS RESULT (normalized claims from the previous attempt) @@ -210,11 +228,6 @@ ${bounded( MAX_SECTION_CHARS )}` ); - if (previousResult.error_code === "invalid_agent_report") { - sections.push( - "REPORT REPAIR MODE: The previous attempt's execution has already ended; only its final report failed validation. Do not edit files, rerun tests, or repeat task work. Reconstruct the final JSON report from the previous response and report_candidate. Do not guess missing facts. If a required boolean or other fact cannot be established, set needs_master_decision=true and describe the uncertainty in issues." - ); - } } sections.push(`MASTER FEEDBACK (address every point) ${feedback}`); @@ -227,9 +240,11 @@ ${buildTaskPrompt(task)}`); } var OUTPUT_CONTRACT = [ "OUTPUT CONTRACT (mandatory)", - "Your final response must be exactly one JSON object with no markdown fences and no text before or after it, matching this shape:", - '{"summary": string, "files_changed": string[], "tests": [{"command": string, "status": "passed" | "failed" | "not_run", "details"?: string}], "issues": string[], "needs_master_decision": boolean}', - "List every file you created or modified in files_changed (workspace-relative paths). Give one tests entry per applicable test command; use status not_run when a command was not applicable or could not run. Record problems in issues. Set needs_master_decision=true only when a required decision is outside your authority; never guess." + "Your final response must be exactly one JSON object with no markdown fences and no text before or after it.", + 'Required fields: summary (non-empty string), files_changed (array of strings), tests (array of objects with command string, status "passed" | "failed" | "not_run", optional details string), issues (array of strings), needs_master_decision (JSON boolean).', + 'Valid JSON example (replace example values with observed facts): {"summary":"Describe the actual work","files_changed":[],"tests":[{"command":"An applicable command","status":"not_run","details":"Explain why it was not run"}],"issues":[],"needs_master_decision":true}', + "List every file you created or modified in files_changed (workspace-relative paths). Give one tests entry per applicable test command; use status not_run when a command was not applicable or could not run. Record problems in issues. Set needs_master_decision=true only when a required decision is outside your authority; never guess.", + "Before sending, check that all five top-level fields exist and needs_master_decision is a JSON boolean true or false, never a quoted string and never text such as needs_master_decision=false. If its value is uncertain, set true and explain in issues. Do not omit it." ].join("\n"); var DECISION_RULE = [ "DECISION RULE", @@ -636,7 +651,293 @@ function errorText(error) { } // src/adapters/process-spawn.ts +import { spawn as spawn2 } from "node:child_process"; + +// src/runtime/process-probe.ts +import { readFile } from "node:fs/promises"; import { spawn } from "node:child_process"; +var PROCESS_IDENTITY_VERSION = 1; +var DEFAULT_PROBE_TIMEOUT_MS = 5e3; +var DEFAULT_MAX_CONCURRENT = 2; +function identityOfFingerprint(pid, fingerprint, precision, now) { + return { + pid, + fingerprint, + fingerprint_precision: precision, + identity_version: PROCESS_IDENTITY_VERSION, + platform: process.platform, + captured_at: new Date(now()).toISOString() + }; +} +function verdict(state, reason, now) { + return { state, reason_code: reason, observed_at: new Date(now()).toISOString() }; +} +function failedQuery(reason) { + return { ok: false, reason, fingerprints: /* @__PURE__ */ new Map() }; +} +function windowsBatchQuery(pids, timeoutMs) { + return new Promise((resolve) => { + const script = `$ErrorActionPreference = 'Stop'; Get-Process -Id ${pids.join(",")} -ErrorAction SilentlyContinue | ForEach-Object { "{0}|{1}" -f $_.Id, $_.StartTime.Ticks }; 'bridge_probe_complete_v1'`; + const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + shell: false + }); + let stdout = ""; + let settled = false; + const finish = (query) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(query); + }; + const timer = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + } + finish(failedQuery("timeout")); + }, timeoutMs); + timer.unref(); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + let stderrBytes = 0; + child.stderr.on("data", (chunk) => { + stderrBytes = Math.min(64e3, stderrBytes + chunk.length); + }); + child.stdout.on("data", (chunk) => { + if (stdout.length < 1e6) stdout += chunk; + }); + child.on("error", () => finish(failedQuery("spawn_error"))); + child.on("close", (code) => { + const map = /* @__PURE__ */ new Map(); + const lines = stdout.trim().split(/\r?\n/u); + if (code !== 0 || stderrBytes > 0 || lines.pop() !== "bridge_probe_complete_v1") { + finish(failedQuery("query_error")); + return; + } + for (const line of lines) { + if (!line.trim()) continue; + const match = /^(\d+)\|(\d+)$/u.exec(line.trim()); + if (!match || !pids.includes(Number(match[1])) || map.has(Number(match[1]))) { + finish(failedQuery("query_error")); + return; + } + map.set(Number(match[1]), match[2]); + } + finish({ ok: true, reason: "ok", fingerprints: map }); + }); + }); +} +async function darwinQuery(pids, timeoutMs) { + return new Promise((resolve) => { + const child = spawn("ps", ["-o", "pid=,lstart=", "-p", pids.join(",")], { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + shell: false + }); + let stdout = ""; + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + try { + child.kill("SIGKILL"); + } catch { + } + resolve(failedQuery("timeout")); + }, timeoutMs); + timer.unref(); + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + if (stdout.length < 1e6) stdout += chunk; + }); + child.on("error", () => { + if (!settled) { + settled = true; + clearTimeout(timer); + resolve(failedQuery("spawn_error")); + } + }); + child.on("close", () => { + if (settled) return; + settled = true; + clearTimeout(timer); + const map = /* @__PURE__ */ new Map(); + for (const line of stdout.split(/\r?\n/u)) { + const match = /^\s*(\d+)\s+(.+)$/u.exec(line); + if (match) map.set(Number(match[1]), match[2].trim()); + } + resolve({ ok: true, reason: "ok", fingerprints: map }); + }); + }); +} +function createPlatformProbe(options = {}) { + const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; + const maxConcurrent = options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT; + const now = options.now ?? Date.now; + let chain = Promise.resolve(); + let inFlight = 0; + let waiters = []; + const schedule = async (operation) => { + if (inFlight >= maxConcurrent) { + await new Promise((resolve) => waiters.push(resolve)); + } + inFlight += 1; + try { + return await operation(); + } finally { + inFlight -= 1; + const next = waiters.shift(); + if (next) next(); + } + }; + const probe = async (requests) => { + if (requests.length === 0) return []; + const distinct = [...new Set(requests.map((request) => request.pid).filter((pid) => Number.isInteger(pid) && pid > 0))]; + const query = await schedule(async () => { + if (process.platform === "win32") return windowsBatchQuery(distinct, timeoutMs); + if (process.platform === "darwin") return darwinQuery(distinct, timeoutMs); + if (process.platform === "linux") { + const map = /* @__PURE__ */ new Map(); + for (const pid of distinct) { + try { + const stat = await readFile(`/proc/${pid}/stat`, "utf8"); + const afterComm = stat.slice(stat.lastIndexOf(")") + 2); + const fields = afterComm.split(" "); + const starttime = fields[19]; + if (starttime) map.set(pid, starttime.trim()); + } catch (error) { + if (error.code === "ENOENT") continue; + return failedQuery("proc_read_error"); + } + } + return { ok: true, reason: "ok", fingerprints: map }; + } + return failedQuery("unsupported_platform"); + }); + return requests.map((request) => { + if (!Number.isInteger(request.pid) || request.pid <= 0) return verdict("unknown", "invalid_pid", now); + if (!query.ok) return verdict("unknown", `query_${query.reason}`, now); + const fingerprint = query.fingerprints.get(request.pid); + if (fingerprint === void 0) { + return verdict("exited", "pid_absent", now); + } + if (!request.identity || request.identity.fingerprint === null) { + return verdict("unknown", "live_pid_no_fingerprint", now); + } + if (request.identity.fingerprint_precision === "coarse") { + return request.identity.fingerprint === fingerprint ? verdict("unknown", "coarse_fingerprint_match", now) : verdict("exited", "pid_reused_coarse", now); + } + if (request.identity.fingerprint !== fingerprint) { + return verdict("exited", "pid_reused", now); + } + return verdict("alive", "pid_and_fingerprint_match", now); + }); + }; + const fingerprintOfPid = async (pid) => { + const query = await schedule(async () => { + if (process.platform === "win32") return windowsBatchQuery([pid], timeoutMs); + if (process.platform === "darwin") return darwinQuery([pid], timeoutMs); + if (process.platform === "linux") { + const map = /* @__PURE__ */ new Map(); + try { + const stat = await readFile(`/proc/${pid}/stat`, "utf8"); + const afterComm = stat.slice(stat.lastIndexOf(")") + 2); + const starttime = afterComm.split(" ")[19]; + if (starttime) map.set(pid, starttime.trim()); + } catch { + } + return { ok: true, reason: "ok", fingerprints: map }; + } + return failedQuery("unsupported_platform"); + }); + const fingerprint = query.fingerprints.get(pid); + if (!query.ok || !fingerprint) return identityOfFingerprint(pid, null, "unknown", now); + const precision = process.platform === "darwin" ? "coarse" : "exact"; + return identityOfFingerprint(pid, fingerprint, precision, now); + }; + return { + platform: process.platform, + identityOf: fingerprintOfPid, + selfIdentity: () => fingerprintOfPid(process.pid), + probe + }; +} + +// src/adapters/windows-process-cleanup.ts +import { performance } from "node:perf_hooks"; +async function terminateWindowsProcessTree(pid, killWaitMs, dependencies) { + const requests = await dependencies.capture(pid); + if (!requests.some((request) => request.pid === pid)) throw new Error("process_tree_snapshot_invalid: root missing"); + const inspect = async () => { + const verdicts = await dependencies.probe(requests); + if (verdicts.length !== requests.length || verdicts.some((verdict2) => verdict2.state === "unknown")) { + throw new Error("process_tree_probe_unverified: recorded executor exit is unknown"); + } + return verdicts; + }; + const before = await inspect(); + if (before.every((verdict2) => verdict2.state === "exited")) return; + const rootIndex = requests.findIndex((request) => request.pid === pid); + if (before[rootIndex]?.state !== "alive") { + throw new Error("process_tree_descendants_alive: root exited before termination"); + } + let exitCode = null; + let commandFailed = false; + try { + exitCode = await dependencies.kill(pid); + } catch { + commandFailed = true; + } + const deadline = performance.now() + Math.max(0, killWaitMs); + for (; ; ) { + const after = await inspect(); + if (after.every((verdict2) => verdict2.state === "exited")) return; + if (performance.now() >= deadline) { + const reason = commandFailed ? "command_error" : exitCode === null ? "timeout" : `exit_${exitCode}`; + throw new Error(`process_tree_cleanup_unverified: taskkill ${reason}; recorded executors remain alive`); + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} +function windowsTreeCaptureScript(pid) { + return [ + "$ErrorActionPreference = 'Stop'", + "$all = @(Get-CimInstance Win32_Process -ErrorAction Stop)", + `$selected = [System.Collections.Generic.HashSet[int]]::new(); [void]$selected.Add(${pid})`, + "do { $changed = $false; foreach ($item in $all) { if ($selected.Contains([int]$item.ParentProcessId)) { if ($selected.Add([int]$item.ProcessId)) { $changed = $true } } } } while ($changed)", + "'bridge_tree_snapshot_v1'", + "foreach ($candidate in $selected) { $item = Get-Process -Id $candidate -ErrorAction SilentlyContinue; if ($null -eq $item) { '{0}|absent' -f $candidate } else { '{0}|{1}' -f $candidate, $item.StartTime.Ticks } }" + ].join("; "); +} +function parseWindowsTreeCapture(stdout, pid) { + const lines = stdout.trim().split(/\r?\n/u); + if (lines.shift() !== "bridge_tree_snapshot_v1") throw new Error("process_tree_snapshot_invalid: protocol missing"); + const capturedAt = (/* @__PURE__ */ new Date()).toISOString(); + const requests = []; + const seen = /* @__PURE__ */ new Set(); + for (const line of lines) { + const match = /^(\d+)\|(\d+|absent)$/u.exec(line.trim()); + const candidate = Number(match?.[1]); + if (!match || !Number.isSafeInteger(candidate) || candidate <= 0 || seen.has(candidate)) { + throw new Error("process_tree_snapshot_invalid: malformed identity"); + } + seen.add(candidate); + requests.push({ pid: candidate, identity: match[2] === "absent" ? null : { + pid: candidate, + fingerprint: match[2], + fingerprint_precision: "exact", + identity_version: 1, + platform: "win32", + captured_at: capturedAt + } }); + } + if (!seen.has(pid)) throw new Error("process_tree_snapshot_invalid: root missing"); + return requests; +} + +// src/adapters/process-spawn.ts function appendBounded(current, chunk, maxBytes) { if (!(maxBytes > 0)) return { value: current.value + chunk, truncated: current.truncated }; const remaining = maxBytes - Buffer.byteLength(current.value, "utf8"); @@ -666,17 +967,26 @@ var terminateProcessTree = async (pid, options = {}) => { throw new Error(`A positive integer PID is required, got: ${pid}`); } if (process.platform === "win32") { - const result = await runCommand("taskkill", ["/PID", String(pid), "/T", "/F"], { - timeoutMs: 15e3 + const probe = createPlatformProbe(); + await terminateWindowsProcessTree(pid, options.killWaitMs ?? 2e3, { + capture: async (root) => { + let snapshot; + try { + snapshot = await runCommand("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", windowsTreeCaptureScript(root)], { + timeoutMs: 5e3, + maxOutputBytes: 128e3 + }); + } catch { + throw new Error("process_tree_snapshot_unverified: query failed"); + } + if (snapshot.code !== 0 || snapshot.stdoutTruncated || snapshot.stderr.length > 0) { + throw new Error("process_tree_snapshot_unverified: query failed or incomplete"); + } + return parseWindowsTreeCapture(snapshot.stdout, root); + }, + probe: (requests) => probe.probe(requests), + kill: async (root) => (await runCommand("taskkill", ["/PID", String(root), "/T", "/F"], { timeoutMs: 15e3 })).code }); - if (result.code !== 0) { - throw new Error( - result.stderr.trim() || `taskkill exited with code ${String(result.code)}` - ); - } - if (!await waitForPidExit(pid, options.killWaitMs ?? 2e3)) { - throw new Error(`Process tree ${pid} still running after taskkill reported success`); - } return { pid, signal: "SIGKILL", verified: true }; } signalProcessTree(pid, "SIGTERM"); @@ -691,7 +1001,7 @@ var terminateProcessTree = async (pid, options = {}) => { }; function runCommand(command, args, options) { return new Promise((resolve, reject) => { - const child = spawn(command, args, { + const child = spawn2(command, args, { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] @@ -725,7 +1035,8 @@ function runCommand(command, args, options) { code: timedOut ? null : code, signal: timedOut ? "SIGKILL" : signal, stdout: stdout.value, - stderr: stderr.value + stderr: stderr.value, + stdoutTruncated: stdout.truncated }); }); }); @@ -755,13 +1066,6 @@ function isProcessTreeRunning(pid) { throw error; } } -async function waitForPidExit(pid, timeoutMs) { - const deadline = Date.now() + timeoutMs; - while (isProcessRunning(pid) && Date.now() < deadline) { - await sleep(20); - } - return !isProcessRunning(pid); -} async function waitForProcessTreeExit(pid, timeoutMs) { const deadline = Date.now() + timeoutMs; while (isProcessTreeRunning(pid) && Date.now() < deadline) { @@ -859,10 +1163,11 @@ function buildAccountProviderPayload(config) { } function accountProviderId(providerId, config) { if (providerId.startsWith("account:")) return providerId; - const table = readJson(config.providerBuiltinConfigFile); - for (const rawRule of readProviderRules(table)) { + const rules = [config.providerBuiltinConfigFile, config.providerPersonalConfigFile].flatMap((file) => readProviderRules(readJson(file))); + for (const rawRule of rules) { if (!isRecord(rawRule)) continue; const rule = rawRule; + if (!providerId.startsWith("builtin:") && rule.providerId === `account:${providerId}`) return rule.providerId; if (!providerId.startsWith("builtin:") && rule.providerId === providerId && rule.config?.access?.type === "zhipu-account") { return `account:${providerId}`; } @@ -872,6 +1177,16 @@ function accountProviderId(providerId, config) { } return providerId; } +function catalogProviderId(providerId, modelId, catalog, config) { + const advertised = (candidate) => catalog.some((model) => model.providerId === candidate && model.modelId === modelId); + if (advertised(providerId)) return providerId; + const configured = accountProviderId(providerId, config); + if (advertised(configured)) return configured; + if (!providerId.startsWith("account:") && !providerId.startsWith("builtin:") && advertised(`account:${providerId}`)) { + return `account:${providerId}`; + } + return configured; +} function runtimeAuthReply(providerId, config) { const unavailable = { headersApplied: false, @@ -1389,9 +1704,9 @@ var ZCodeAppServerAdapter = class { entry.sessionId = sessionId; let selectedReasoningLevel = null; if (preferences.model) { - const requestedProviderId = accountProviderId(preferences.model.provider_id, config); - const requested = `${requestedProviderId}/${preferences.model.model_id}`; const availableModels = readAvailableModels(snapshot); + const requestedProviderId = catalogProviderId(preferences.model.provider_id, preferences.model.model_id, availableModels, config); + const requested = `${requestedProviderId}/${preferences.model.model_id}`; entry.onEvent({ type: "model_catalog", summary: `ZCode runtime advertised ${availableModels.length} selectable model${availableModels.length === 1 ? "" : "s"}`, @@ -1434,7 +1749,7 @@ var ZCodeAppServerAdapter = class { entry.selectedModel = readSelectedModel(modelState) ?? requested; entry.selectedModelSelection = selected; entry.modelSource = preferences.modelSource; - entry.requestedModel = requested; + entry.requestedModel = `${preferences.model.provider_id}/${preferences.model.model_id}`; entry.requestedReasoningLevel = reasoningLevel; selectedReasoningLevel = readEffectiveReasoningLevel(modelState); entry.selectedReasoningLevel = selectedReasoningLevel; @@ -1443,7 +1758,7 @@ var ZCodeAppServerAdapter = class { type: "model_selected", summary: `ZCode selected requested model ${entry.selectedModel}${selectedReasoningLevel ? ` with reasoning level ${selectedReasoningLevel}` : "; runtime did not report its reasoning level"}`, details: { - requested_model: requested, + requested_model: entry.requestedModel, selected_model: entry.selectedModel, provider_id: selected.providerId, model_id: selected.modelId, @@ -1753,7 +2068,7 @@ var ZCodeAppServerAdapter = class { } } #startAppServer(config, cwd, env, entry) { - const child = spawn2(config.nodeExecutable, [config.zcodeEntrypoint, "app-server", "--stdio"], { + const child = spawn3(config.nodeExecutable, [config.zcodeEntrypoint, "app-server", "--stdio"], { cwd, env, shell: false, @@ -2409,8 +2724,9 @@ function tryAcquireProcessLockWithRetry(directory, retries) { return () => { const owner = JSON.parse(readFileSync3(path4.join(directory, "owner.json"), "utf8")); if (owner.token !== token) throw new Error("process lock ownership changed"); - unlinkSync(path4.join(directory, "owner.json")); - rmdirSync(directory); + const retired = `${directory}.${token}.retired`; + renameSync(directory, retired); + removeStagedLock(retired); }; } function acquireExistingLock(directory) { @@ -2462,8 +2778,7 @@ function reclaimDeadOwner(directory) { } const retired = `${directory}.${randomUUID()}.retired`; renameSync(directory, retired); - unlinkSync(path4.join(retired, "owner.json")); - rmdirSync(retired); + removeStagedLock(retired); return true; } catch (error) { if (error.code !== "ENOENT") throw error; @@ -3452,213 +3767,6 @@ function isTerminalStatus(status) { return status === "completed" || status === "failed" || status === "cancelled" || status === "waiting_for_master"; } -// src/runtime/process-probe.ts -import { readFile } from "node:fs/promises"; -import { spawn as spawn3 } from "node:child_process"; -var PROCESS_IDENTITY_VERSION = 1; -var DEFAULT_PROBE_TIMEOUT_MS = 5e3; -var DEFAULT_MAX_CONCURRENT = 2; -function identityOfFingerprint(pid, fingerprint, precision, now) { - return { - pid, - fingerprint, - fingerprint_precision: precision, - identity_version: PROCESS_IDENTITY_VERSION, - platform: process.platform, - captured_at: new Date(now()).toISOString() - }; -} -function verdict(state, reason, now) { - return { state, reason_code: reason, observed_at: new Date(now()).toISOString() }; -} -function failedQuery(reason) { - return { ok: false, reason, fingerprints: /* @__PURE__ */ new Map() }; -} -function windowsBatchQuery(pids, timeoutMs) { - return new Promise((resolve) => { - const script = `Get-Process -Id ${pids.join(",")} -ErrorAction SilentlyContinue | ForEach-Object { "{0}|{1}" -f $_.Id, $_.StartTime.Ticks }`; - const child = spawn3("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - shell: false - }); - let stdout = ""; - let settled = false; - const finish = (query) => { - if (settled) return; - settled = true; - clearTimeout(timer); - resolve(query); - }; - const timer = setTimeout(() => { - try { - child.kill("SIGKILL"); - } catch { - } - finish(failedQuery("timeout")); - }, timeoutMs); - timer.unref(); - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - let stderrBytes = 0; - child.stderr.on("data", (chunk) => { - stderrBytes = Math.min(64e3, stderrBytes + chunk.length); - }); - child.stdout.on("data", (chunk) => { - if (stdout.length < 1e6) stdout += chunk; - }); - child.on("error", () => finish(failedQuery("spawn_error"))); - child.on("close", () => { - const map = /* @__PURE__ */ new Map(); - for (const line of stdout.split(/\r?\n/u)) { - const match = /^(\d+)\|(\d+)$/u.exec(line.trim()); - if (!match) continue; - map.set(Number(match[1]), match[2]); - } - if (map.size === 0) { - finish(stderrBytes > 0 ? failedQuery("query_error") : { ok: true, reason: "ok", fingerprints: map }); - return; - } - finish({ ok: true, reason: "ok", fingerprints: map }); - }); - }); -} -async function darwinQuery(pids, timeoutMs) { - return new Promise((resolve) => { - const child = spawn3("ps", ["-o", "pid=,lstart=", "-p", pids.join(",")], { - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - shell: false - }); - let stdout = ""; - let settled = false; - const timer = setTimeout(() => { - if (settled) return; - settled = true; - try { - child.kill("SIGKILL"); - } catch { - } - resolve(failedQuery("timeout")); - }, timeoutMs); - timer.unref(); - child.stdout.setEncoding("utf8"); - child.stdout.on("data", (chunk) => { - if (stdout.length < 1e6) stdout += chunk; - }); - child.on("error", () => { - if (!settled) { - settled = true; - clearTimeout(timer); - resolve(failedQuery("spawn_error")); - } - }); - child.on("close", () => { - if (settled) return; - settled = true; - clearTimeout(timer); - const map = /* @__PURE__ */ new Map(); - for (const line of stdout.split(/\r?\n/u)) { - const match = /^\s*(\d+)\s+(.+)$/u.exec(line); - if (match) map.set(Number(match[1]), match[2].trim()); - } - resolve({ ok: true, reason: "ok", fingerprints: map }); - }); - }); -} -function createPlatformProbe(options = {}) { - const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; - const maxConcurrent = options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT; - const now = options.now ?? Date.now; - let chain = Promise.resolve(); - let inFlight = 0; - let waiters = []; - const schedule = async (operation) => { - if (inFlight >= maxConcurrent) { - await new Promise((resolve) => waiters.push(resolve)); - } - inFlight += 1; - try { - return await operation(); - } finally { - inFlight -= 1; - const next = waiters.shift(); - if (next) next(); - } - }; - const probe = async (requests) => { - if (requests.length === 0) return []; - const distinct = [...new Set(requests.map((request) => request.pid).filter((pid) => Number.isInteger(pid) && pid > 0))]; - const query = await schedule(async () => { - if (process.platform === "win32") return windowsBatchQuery(distinct, timeoutMs); - if (process.platform === "darwin") return darwinQuery(distinct, timeoutMs); - if (process.platform === "linux") { - const map = /* @__PURE__ */ new Map(); - for (const pid of distinct) { - try { - const stat = await readFile(`/proc/${pid}/stat`, "utf8"); - const afterComm = stat.slice(stat.lastIndexOf(")") + 2); - const fields = afterComm.split(" "); - const starttime = fields[19]; - if (starttime) map.set(pid, starttime.trim()); - } catch (error) { - if (error.code === "ENOENT") continue; - return failedQuery("proc_read_error"); - } - } - return { ok: true, reason: "ok", fingerprints: map }; - } - return failedQuery("unsupported_platform"); - }); - return requests.map((request) => { - if (!Number.isInteger(request.pid) || request.pid <= 0) return verdict("unknown", "invalid_pid", now); - if (!query.ok) return verdict("unknown", `query_${query.reason}`, now); - const fingerprint = query.fingerprints.get(request.pid); - if (fingerprint === void 0) { - return verdict("exited", "pid_absent", now); - } - if (!request.identity || request.identity.fingerprint === null) { - return verdict("unknown", "live_pid_no_fingerprint", now); - } - if (request.identity.fingerprint_precision === "coarse") { - return request.identity.fingerprint === fingerprint ? verdict("unknown", "coarse_fingerprint_match", now) : verdict("exited", "pid_reused_coarse", now); - } - if (request.identity.fingerprint !== fingerprint) { - return verdict("exited", "pid_reused", now); - } - return verdict("alive", "pid_and_fingerprint_match", now); - }); - }; - const fingerprintOfPid = async (pid) => { - const query = await schedule(async () => { - if (process.platform === "win32") return windowsBatchQuery([pid], timeoutMs); - if (process.platform === "darwin") return darwinQuery([pid], timeoutMs); - if (process.platform === "linux") { - const map = /* @__PURE__ */ new Map(); - try { - const stat = await readFile(`/proc/${pid}/stat`, "utf8"); - const afterComm = stat.slice(stat.lastIndexOf(")") + 2); - const starttime = afterComm.split(" ")[19]; - if (starttime) map.set(pid, starttime.trim()); - } catch { - } - return { ok: true, reason: "ok", fingerprints: map }; - } - return failedQuery("unsupported_platform"); - }); - const fingerprint = query.fingerprints.get(pid); - if (!query.ok || !fingerprint) return identityOfFingerprint(pid, null, "unknown", now); - const precision = process.platform === "darwin" ? "coarse" : "exact"; - return identityOfFingerprint(pid, fingerprint, precision, now); - }; - return { - platform: process.platform, - identityOf: fingerprintOfPid, - selfIdentity: () => fingerprintOfPid(process.pid), - probe - }; -} - // src/usage/normalize.ts var KNOWN_NUMERIC_FIELDS = [ ["input_tokens", "inputTokens"], diff --git a/src/adapters/process-spawn.ts b/src/adapters/process-spawn.ts index a3c71f2..cf4d5b4 100644 --- a/src/adapters/process-spawn.ts +++ b/src/adapters/process-spawn.ts @@ -7,6 +7,8 @@ // Version 2.0. Modifications: TypeScript ESM, injection-friendly types for the // Bridge adapter contract, extra termination verification for this project. import { spawn } from "node:child_process"; +import { createPlatformProbe } from "../runtime/process-probe.js"; +import { parseWindowsTreeCapture, terminateWindowsProcessTree, windowsTreeCaptureScript } from "./windows-process-cleanup.js"; export interface OutputStreamLike { setEncoding(encoding: "utf8"): void; @@ -88,8 +90,9 @@ export type TerminateProcessTree = ( /** * Terminates a process tree and only resolves after termination is verified. - * Windows: `taskkill /PID /T /F`, verified by exit code plus a - * direct-child liveness poll. POSIX: SIGTERM to the process group, then + * Windows: `taskkill /PID /T /F`, followed by startup-identity checks + * of the captured root and descendants, regardless of its exit code. + * POSIX: SIGTERM to the process group, then * SIGKILL, verified by group/pid liveness polls. */ export const terminateProcessTree: TerminateProcessTree = async (pid, options = {}) => { @@ -97,17 +100,23 @@ export const terminateProcessTree: TerminateProcessTree = async (pid, options = throw new Error(`A positive integer PID is required, got: ${pid}`); } if (process.platform === "win32") { - const result = await runCommand("taskkill", ["/PID", String(pid), "/T", "/F"], { - timeoutMs: 15_000, + const probe = createPlatformProbe(); + await terminateWindowsProcessTree(pid, options.killWaitMs ?? 2_000, { + capture: async (root) => { + let snapshot: CommandResult; + try { + snapshot = await runCommand("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", windowsTreeCaptureScript(root)], { + timeoutMs: 5_000, maxOutputBytes: 128_000, + }); + } catch { throw new Error("process_tree_snapshot_unverified: query failed"); } + if (snapshot.code !== 0 || snapshot.stdoutTruncated || snapshot.stderr.length > 0) { + throw new Error("process_tree_snapshot_unverified: query failed or incomplete"); + } + return parseWindowsTreeCapture(snapshot.stdout, root); + }, + probe: (requests) => probe.probe(requests), + kill: async (root) => (await runCommand("taskkill", ["/PID", String(root), "/T", "/F"], { timeoutMs: 15_000 })).code, }); - if (result.code !== 0) { - throw new Error( - result.stderr.trim() || `taskkill exited with code ${String(result.code)}`, - ); - } - if (!(await waitForPidExit(pid, options.killWaitMs ?? 2_000))) { - throw new Error(`Process tree ${pid} still running after taskkill reported success`); - } return { pid, signal: "SIGKILL", verified: true }; } signalProcessTree(pid, "SIGTERM"); @@ -126,6 +135,7 @@ interface CommandResult { signal: string | null; stdout: string; stderr: string; + stdoutTruncated: boolean; } /** Minimal shell-free runner (used for taskkill). Not for model invocation. */ @@ -174,6 +184,7 @@ function runCommand( signal: timedOut ? "SIGKILL" : signal, stdout: stdout.value, stderr: stderr.value, + stdoutTruncated: stdout.truncated, }); }); }); @@ -206,14 +217,6 @@ function isProcessTreeRunning(pid: number): boolean { } } -async function waitForPidExit(pid: number, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs; - while (isProcessRunning(pid) && Date.now() < deadline) { - await sleep(20); - } - return !isProcessRunning(pid); -} - async function waitForProcessTreeExit(pid: number, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while (isProcessTreeRunning(pid) && Date.now() < deadline) { diff --git a/src/adapters/windows-process-cleanup.ts b/src/adapters/windows-process-cleanup.ts new file mode 100644 index 0000000..816378e --- /dev/null +++ b/src/adapters/windows-process-cleanup.ts @@ -0,0 +1,80 @@ +import { performance } from "node:perf_hooks"; +import type { ProbeRequest, ProbeVerdict } from "../runtime/process-probe.js"; + +export interface WindowsCleanupDependencies { + capture: (pid: number) => Promise; + probe: (requests: readonly ProbeRequest[]) => Promise; + kill: (pid: number) => Promise; +} + +/** Verify recorded executors, not taskkill's localized output or the root alone. */ +export async function terminateWindowsProcessTree( + pid: number, + killWaitMs: number, + dependencies: WindowsCleanupDependencies, +): Promise { + const requests = await dependencies.capture(pid); + if (!requests.some((request) => request.pid === pid)) throw new Error("process_tree_snapshot_invalid: root missing"); + const inspect = async (): Promise => { + const verdicts = await dependencies.probe(requests); + if (verdicts.length !== requests.length || verdicts.some((verdict) => verdict.state === "unknown")) { + throw new Error("process_tree_probe_unverified: recorded executor exit is unknown"); + } + return verdicts; + }; + const before = await inspect(); + if (before.every((verdict) => verdict.state === "exited")) return; + const rootIndex = requests.findIndex((request) => request.pid === pid); + if (before[rootIndex]?.state !== "alive") { + // Never signal a recycled root PID. Its remaining children need review. + throw new Error("process_tree_descendants_alive: root exited before termination"); + } + let exitCode: number | null = null; + let commandFailed = false; + try { exitCode = await dependencies.kill(pid); } + catch { commandFailed = true; } + const deadline = performance.now() + Math.max(0, killWaitMs); + for (;;) { + const after = await inspect(); + if (after.every((verdict) => verdict.state === "exited")) return; + if (performance.now() >= deadline) { + const reason = commandFailed ? "command_error" : exitCode === null ? "timeout" : `exit_${exitCode}`; + throw new Error(`process_tree_cleanup_unverified: taskkill ${reason}; recorded executors remain alive`); + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} + +/** ASCII-only protocol; locale-dependent stderr never becomes a public error. */ +export function windowsTreeCaptureScript(pid: number): string { + return [ + "$ErrorActionPreference = 'Stop'", + "$all = @(Get-CimInstance Win32_Process -ErrorAction Stop)", + `$selected = [System.Collections.Generic.HashSet[int]]::new(); [void]$selected.Add(${pid})`, + "do { $changed = $false; foreach ($item in $all) { if ($selected.Contains([int]$item.ParentProcessId)) { if ($selected.Add([int]$item.ProcessId)) { $changed = $true } } } } while ($changed)", + "'bridge_tree_snapshot_v1'", + "foreach ($candidate in $selected) { $item = Get-Process -Id $candidate -ErrorAction SilentlyContinue; if ($null -eq $item) { '{0}|absent' -f $candidate } else { '{0}|{1}' -f $candidate, $item.StartTime.Ticks } }", + ].join("; "); +} + +export function parseWindowsTreeCapture(stdout: string, pid: number): ProbeRequest[] { + const lines = stdout.trim().split(/\r?\n/u); + if (lines.shift() !== "bridge_tree_snapshot_v1") throw new Error("process_tree_snapshot_invalid: protocol missing"); + const capturedAt = new Date().toISOString(); + const requests: ProbeRequest[] = []; + const seen = new Set(); + for (const line of lines) { + const match = /^(\d+)\|(\d+|absent)$/u.exec(line.trim()); + const candidate = Number(match?.[1]); + if (!match || !Number.isSafeInteger(candidate) || candidate <= 0 || seen.has(candidate)) { + throw new Error("process_tree_snapshot_invalid: malformed identity"); + } + seen.add(candidate); + requests.push({ pid: candidate, identity: match[2] === "absent" ? null : { + pid: candidate, fingerprint: match[2]!, fingerprint_precision: "exact", identity_version: 1, + platform: "win32", captured_at: capturedAt, + } }); + } + if (!seen.has(pid)) throw new Error("process_tree_snapshot_invalid: root missing"); + return requests; +} diff --git a/src/adapters/zcode-app-server-adapter.ts b/src/adapters/zcode-app-server-adapter.ts index d41d760..f03dca2 100644 --- a/src/adapters/zcode-app-server-adapter.ts +++ b/src/adapters/zcode-app-server-adapter.ts @@ -21,7 +21,7 @@ import { BridgeError } from "../runtime/errors.js"; import { loadPersistedRuntimeEnvironment, NodeRuntimeResolver } from "../runtime/resolver.js"; import { isProcessRunning, terminateProcessTree } from "./process-spawn.js"; import { createMinimalOsEnv } from "../runtime/child-env.js"; -import { accountProviderId, buildAccountProviderPayload, runtimeAuthReply, zcodeDataBaseDir, zcodeTasksIndexPath } from "../runtime/account-provider.js"; +import { catalogProviderId, buildAccountProviderPayload, runtimeAuthReply, zcodeDataBaseDir, zcodeTasksIndexPath } from "../runtime/account-provider.js"; import { resolveSessionPreferences } from "../runtime/session-preferences.js"; import { resolveTaskTimeout } from "../runtime/task-timeout.js"; import { registerDesktopTask, updateDesktopTaskStatus, type DesktopTaskIndexEntry, type DesktopTaskStatus } from "./task-index-sync.js"; @@ -396,9 +396,9 @@ export class ZCodeAppServerAdapter implements CodingAgentAdapter { entry.sessionId = sessionId; let selectedReasoningLevel: string | null = null; if (preferences.model) { - const requestedProviderId = accountProviderId(preferences.model.provider_id, config); - const requested = `${requestedProviderId}/${preferences.model.model_id}`; const availableModels = readAvailableModels(snapshot); + const requestedProviderId = catalogProviderId(preferences.model.provider_id, preferences.model.model_id, availableModels, config); + const requested = `${requestedProviderId}/${preferences.model.model_id}`; entry.onEvent({ type: "model_catalog", summary: `ZCode runtime advertised ${availableModels.length} selectable model${availableModels.length === 1 ? "" : "s"}`, @@ -454,7 +454,7 @@ export class ZCodeAppServerAdapter implements CodingAgentAdapter { entry.selectedModel = readSelectedModel(modelState) ?? requested; entry.selectedModelSelection = selected; entry.modelSource = preferences.modelSource; - entry.requestedModel = requested; + entry.requestedModel = `${preferences.model.provider_id}/${preferences.model.model_id}`; entry.requestedReasoningLevel = reasoningLevel; selectedReasoningLevel = readEffectiveReasoningLevel(modelState); entry.selectedReasoningLevel = selectedReasoningLevel; @@ -463,7 +463,7 @@ export class ZCodeAppServerAdapter implements CodingAgentAdapter { type: "model_selected", summary: `ZCode selected requested model ${entry.selectedModel}${selectedReasoningLevel ? ` with reasoning level ${selectedReasoningLevel}` : "; runtime did not report its reasoning level"}`, details: { - requested_model: requested, + requested_model: entry.requestedModel, selected_model: entry.selectedModel, provider_id: selected.providerId, model_id: selected.modelId, diff --git a/src/feedback/template.ts b/src/feedback/template.ts index cbe61b1..dbcd5c2 100644 --- a/src/feedback/template.ts +++ b/src/feedback/template.ts @@ -163,10 +163,15 @@ export function renderFeedback(input: FeedbackInput): string { }); } if (result.error_code) evidenceRows.push({ item: "执行错误码", result: "历史记录", evidence: result.error_code }); - if (result.report_candidate) evidenceRows.push({ item: "结构化报告", result: "历史记录", evidence: "报告不完整,原始候选已保留(report_candidate)" }); + if (result.report_candidate) evidenceRows.push({ item: "结构化报告", result: "历史记录", evidence: "候选报告已保留(report_candidate);不构成宿主独立验收" }); } if (input.observation?.cleanup === "unverified") { - evidenceRows.push({ item: "进程清理", result: "NOT RUN", evidence: "cleanup 未确认:结果已恢复,但进程退出未获独立确认;目录保留" }); + evidenceRows.push({ item: "进程清理", result: "未验证", evidence: "cleanup 未确认:现有证据无法确认进程树退出;目录保留" }); + } else if (input.observation?.cleanup === "verified") { + evidenceRows.push({ item: "当前进程清理", result: "历史记录", evidence: "Bridge 当前观测 cleanup=verified;不改写原 attempt 结果或代替代码验收" }); + if (input.result?.error_code === "cleanup_failed") { + evidenceRows.push({ item: "原 attempt 清理结果", result: "历史记录", evidence: "cleanup_failed 记录当时未能确认清理;后续验证已确认退出,原 failed 仍保留" }); + } } if (!evidenceRows.length) evidenceRows.push({ item: "(无证据记录)", result: "未验证", evidence: "本任务没有可展示的检查项" }); for (const row of evidenceRows.slice(0, 50)) { @@ -192,25 +197,30 @@ export function renderFeedback(input: FeedbackInput): string { * A running record never inherits a previous attempt's usage/timing facts. */ export function feedbackInputFromRecord(record: TaskStatusRecord, result: TaskResult | null): FeedbackInput { const terminal = record.status !== "queued" && record.status !== "running"; + const currentResult = terminal && result?.task_id === record.task_id && result.attempt === record.attempt && result.status === record.status ? result : null; const elapsed = record.started_at ? Math.max(0, Date.now() - Date.parse(record.started_at)) || null : null; return { objective: `task ${record.task_id}`, task_id: record.task_id, attempt: record.attempt, bridge_status: record.status, - delivered: terminal ? result?.files_changed ?? [] : [], - usage: terminal ? result?.usage ?? null : null, - model: terminal ? result?.model ?? null : null, - timing: terminal ? result?.timing ? { ...result.timing, verify_ms: null } : null : null, + delivered: currentResult?.files_changed ?? [], + usage: currentResult?.usage ?? null, + model: currentResult?.model ?? null, + timing: currentResult?.timing ? { ...currentResult.timing, verify_ms: null } : null, + observation: record.observation ?? null, + result: currentResult, running: terminal ? null : { - phase: record.status, + phase: record.observation?.activity.code ?? record.status, elapsed_ms: elapsed, - last_progress_at: record.updated_at, + last_progress_at: record.observation?.evidence.last_event_age_ms !== null && record.observation?.evidence.last_event_age_ms !== undefined + ? new Date(Date.parse(record.observation.activity.observed_at) - record.observation.evidence.last_event_age_ms).toISOString() + : null, blockers: record.status === "waiting_for_master" ? ["任务等待 Master 反馈"] : [], tokens_note: "截至当前未取得最终 token(任务未结束)", }, blockers: [], - decisions: terminal && result?.needs_master_decision ? ["worker 报告标记需要 Master 决定"] : [], + decisions: currentResult?.needs_master_decision ? ["worker 报告标记需要 Master 决定"] : [], next_steps: [], }; } diff --git a/src/manager/task-manager.ts b/src/manager/task-manager.ts index df474d9..d583cb0 100644 --- a/src/manager/task-manager.ts +++ b/src/manager/task-manager.ts @@ -186,8 +186,8 @@ export class BridgeTaskManager implements ProgressTaskManager { writeFileSync(lastRunFile, String(Date.now()), { mode: 0o600 }); } } finally { - releaseRecovery(); - this.#recoveryPromise = null; + try { releaseRecovery(); } + finally { this.#recoveryPromise = null; } } } } @@ -1187,31 +1187,23 @@ export class BridgeTaskManager implements ProgressTaskManager { return true; } - #runningTaskIdsLocked(): string[] { - return this.#store.listTaskIds().filter((taskId) => { const status = this.#safeStatus(taskId); return status?.status === "running" || status?.cleanup_unverified === true; }); - } - - #queuedTaskIdsLocked(): string[] { - return this.#store - .listTaskIds() - .map((taskId) => ({ taskId, status: this.#safeStatus(taskId) })) - .filter((entry) => entry.status?.status === "queued") - .sort((a, b) => a.status!.created_at.localeCompare(b.status!.created_at)) - .map((entry) => entry.taskId); - } - #pumpLocked(): void { - const running = this.#runningTaskIdsLocked(); + // Reuse one validated snapshot within this scheduling operation only. + // Terminal/corrupt records remain checked; no cross-operation cache can + // hide an external worker transition or release an unverified workspace. + const snapshot = this.#store.listTaskIds().map((taskId) => ({ taskId, status: this.#safeStatus(taskId) })); + const running = snapshot.filter(({ status }) => status?.status === "running" || status?.cleanup_unverified === true).map(({ taskId }) => taskId); if (running.length >= this.#maxConcurrentWorkers) return; const occupiedPaths = running.map((taskId) => this.#executionPathKeyLocked(taskId)); - for (const taskId of this.#store.listTaskIds()) { - if (this.#safeStatus(taskId)) continue; + for (const { taskId, status } of snapshot) { + if (status) continue; try { occupiedPaths.push(this.#executionPathKeyLocked(taskId)); } catch { return; } // Unknown execution scope cannot be safely released. } let slots = this.#maxConcurrentWorkers - running.length; - for (const taskId of this.#queuedTaskIdsLocked()) { + const queued = snapshot.filter(({ status }) => status?.status === "queued").sort((a, b) => a.status!.created_at.localeCompare(b.status!.created_at)); + for (const { taskId } of queued) { if (slots <= 0) break; const executionPath = this.#executionPathKeyLocked(taskId); // Never run two ZCode sessions against the same mutable directory. diff --git a/src/observation/judge.ts b/src/observation/judge.ts index f9286b9..bac140b 100644 --- a/src/observation/judge.ts +++ b/src/observation/judge.ts @@ -92,7 +92,10 @@ export function judgeTaskObservation(input: JudgeInput): TaskObservation { const eventAge = ageMs(now, input.last_business_event?.at, options.clock_jump_guard_ms).value; const withinStartGrace = startAge !== null && startAge < options.start_grace_ms; if (heartbeatFresh) { - if (eventAge !== null && eventAge > options.stall_hint_ms) { + if (eventAge === null) { + activity = withinStartGrace ? "starting" : "unknown"; + activityReason = "heartbeat_alive_no_business_event"; + } else if (eventAge > options.stall_hint_ms) { activity = "stalled"; activityReason = "heartbeat_alive_business_events_stale"; } else { diff --git a/src/prompts/task-prompt.ts b/src/prompts/task-prompt.ts index fd2ee69..04f701d 100644 --- a/src/prompts/task-prompt.ts +++ b/src/prompts/task-prompt.ts @@ -55,6 +55,23 @@ export function buildContinuePrompt(input: ContinuePromptInput): string { `This run resumes persisted session ${previousSessionId}; earlier conversation context may be available.`, ); } + if (previousResult?.error_code === "invalid_agent_report") { + // Do not reintroduce implementation instructions or test commands in a + // report-only continuation. Candidate claims precede the bounded raw + // response so a long response cannot hide the useful structured evidence. + sections.push( + "REPORT REPAIR MODE: The previous execution has ended. Only its final report failed validation. Do not use tools, edit files, rerun tests, or repeat task work. Return the corrected JSON report from the evidence below. Preserve claims as claims, including not_run and failures. Do not invent tests or missing facts. If needs_master_decision cannot be established, explicitly set it to true and record the uncertainty in issues.", + `PROJECT WORKSPACE: ${task.workspace}`, + ...(task.worktree_path ? [`HOST-SELECTED EXECUTION WORKTREE: ${task.worktree_path}`] : []), + `PREVIOUS REPORT ERROR\n${previousResult.summary}`, + `REPORT CANDIDATE CLAIMS (not independently verified)\n${JSON.stringify(previousResult.report_candidate ?? {}, null, 2)}`, + `PREVIOUS RESPONSE\n${bounded(previousResult.zcode_output, MAX_SECTION_CHARS)}`, + `MASTER FEEDBACK (report repair only)\n${feedback}`, + ...(additionalRequirements.length ? [renderList("ADDITIONAL REPORT REQUIREMENTS", [...additionalRequirements])] : []), + OUTPUT_CONTRACT, + ); + return joinBoundedPreservingTail(sections, OUTPUT_CONTRACT); + } if (previousResult) { sections.push( `PREVIOUS RESULT (normalized claims from the previous attempt)\n${bounded( @@ -62,11 +79,6 @@ export function buildContinuePrompt(input: ContinuePromptInput): string { MAX_SECTION_CHARS, )}`, ); - if (previousResult.error_code === "invalid_agent_report") { - sections.push( - "REPORT REPAIR MODE: The previous attempt's execution has already ended; only its final report failed validation. Do not edit files, rerun tests, or repeat task work. Reconstruct the final JSON report from the previous response and report_candidate. Do not guess missing facts. If a required boolean or other fact cannot be established, set needs_master_decision=true and describe the uncertainty in issues.", - ); - } } sections.push(`MASTER FEEDBACK (address every point)\n${feedback}`); if (additionalRequirements.length > 0) { @@ -78,9 +90,11 @@ export function buildContinuePrompt(input: ContinuePromptInput): string { const OUTPUT_CONTRACT = [ "OUTPUT CONTRACT (mandatory)", - "Your final response must be exactly one JSON object with no markdown fences and no text before or after it, matching this shape:", - '{"summary": string, "files_changed": string[], "tests": [{"command": string, "status": "passed" | "failed" | "not_run", "details"?: string}], "issues": string[], "needs_master_decision": boolean}', + "Your final response must be exactly one JSON object with no markdown fences and no text before or after it.", + 'Required fields: summary (non-empty string), files_changed (array of strings), tests (array of objects with command string, status "passed" | "failed" | "not_run", optional details string), issues (array of strings), needs_master_decision (JSON boolean).', + 'Valid JSON example (replace example values with observed facts): {"summary":"Describe the actual work","files_changed":[],"tests":[{"command":"An applicable command","status":"not_run","details":"Explain why it was not run"}],"issues":[],"needs_master_decision":true}', "List every file you created or modified in files_changed (workspace-relative paths). Give one tests entry per applicable test command; use status not_run when a command was not applicable or could not run. Record problems in issues. Set needs_master_decision=true only when a required decision is outside your authority; never guess.", + "Before sending, check that all five top-level fields exist and needs_master_decision is a JSON boolean true or false, never a quoted string and never text such as needs_master_decision=false. If its value is uncertain, set true and explain in issues. Do not omit it.", ].join("\n"); const DECISION_RULE = [ diff --git a/src/runtime/account-provider.ts b/src/runtime/account-provider.ts index cb0c400..3e55331 100644 --- a/src/runtime/account-provider.ts +++ b/src/runtime/account-provider.ts @@ -72,10 +72,12 @@ export function accountProviderId(providerId: string, config: ZCodeRuntimeConfig // The runtime model catalog already uses this namespace for account-backed // providers. Keep it idempotent when callers supply the exact catalog ID. if (providerId.startsWith("account:")) return providerId; - const table = readJson(config.providerBuiltinConfigFile); - for (const rawRule of readProviderRules(table)) { + const rules = [config.providerBuiltinConfigFile, config.providerPersonalConfigFile] + .flatMap((file) => readProviderRules(readJson(file))); + for (const rawRule of rules) { if (!isRecord(rawRule)) continue; const rule = rawRule as ProviderRule; + if (!providerId.startsWith("builtin:") && rule.providerId === `account:${providerId}`) return rule.providerId; if ( !providerId.startsWith("builtin:") && rule.providerId === providerId && @@ -90,6 +92,24 @@ export function accountProviderId(providerId: string, config: ZCodeRuntimeConfig return providerId; } +/** Prefer an exact catalog entry; namespace aliases require runtime evidence + * for the same model. Never substitute a different provider by model name. */ +export function catalogProviderId( + providerId: string, + modelId: string, + catalog: readonly { providerId: string; modelId: string }[], + config: ZCodeRuntimeConfig, +): string { + const advertised = (candidate: string): boolean => catalog.some((model) => model.providerId === candidate && model.modelId === modelId); + if (advertised(providerId)) return providerId; + const configured = accountProviderId(providerId, config); + if (advertised(configured)) return configured; + if (!providerId.startsWith("account:") && !providerId.startsWith("builtin:") && advertised(`account:${providerId}`)) { + return `account:${providerId}`; + } + return configured; +} + /** Supply coding-plan credentials in memory; Start Plan requires a desktop captcha host. */ export function runtimeAuthReply( providerId: string | undefined, diff --git a/src/runtime/process-probe.ts b/src/runtime/process-probe.ts index 35622bd..63902f0 100644 --- a/src/runtime/process-probe.ts +++ b/src/runtime/process-probe.ts @@ -91,7 +91,7 @@ function failedQuery(reason: string): FingerprintQuery { /** Batch Windows probe: one hidden PowerShell call, structured `pid|ticks` lines, bounded timeout. */ function windowsBatchQuery(pids: number[], timeoutMs: number): Promise { return new Promise((resolve) => { - const script = `Get-Process -Id ${pids.join(",")} -ErrorAction SilentlyContinue | ForEach-Object { "{0}|{1}" -f $_.Id, $_.StartTime.Ticks }`; + const script = `$ErrorActionPreference = 'Stop'; Get-Process -Id ${pids.join(",")} -ErrorAction SilentlyContinue | ForEach-Object { "{0}|{1}" -f $_.Id, $_.StartTime.Ticks }; 'bridge_probe_complete_v1'`; const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true, @@ -119,23 +119,24 @@ function windowsBatchQuery(pids: number[], timeoutMs: number): Promise finish(failedQuery("spawn_error"))); - child.on("close", () => { + child.on("close", (code) => { const map = new Map(); - for (const line of stdout.split(/\r?\n/u)) { + const lines = stdout.trim().split(/\r?\n/u); + if (code !== 0 || stderrBytes > 0 || lines.pop() !== "bridge_probe_complete_v1") { + finish(failedQuery("query_error")); + return; + } + for (const line of lines) { + if (!line.trim()) continue; const match = /^(\d+)\|(\d+)$/u.exec(line.trim()); - if (!match) continue; + if (!match || !pids.includes(Number(match[1])) || map.has(Number(match[1]))) { + finish(failedQuery("query_error")); + return; + } map.set(Number(match[1]), match[2]!); } - // Rows for missing PIDs are conclusive (Get-Process skips exited PIDs - // and exits non-zero), so any parsed row means the query ran. With no - // rows at all we cannot tell "all exited" from "broken query" unless - // stdout was well-formed but empty, which Get-Process guarantees only - // when the query executed: treat empty stdout + empty stderr as an - // executed query with zero matches. - if (map.size === 0) { - finish(stderrBytes > 0 ? failedQuery("query_error") : { ok: true, reason: "ok", fingerprints: map }); - return; - } + // Only a complete, successful query can prove a missing PID exited. + // Partial rows followed by a permission/StartTime failure stay unknown. finish({ ok: true, reason: "ok", fingerprints: map }); }); }); diff --git a/src/store/process-lock.ts b/src/store/process-lock.ts index b20a5bf..cb2d760 100644 --- a/src/store/process-lock.ts +++ b/src/store/process-lock.ts @@ -91,8 +91,11 @@ function tryAcquireProcessLockWithRetry(directory: string, retries: number): (() return () => { const owner = JSON.parse(readFileSync(path.join(directory, "owner.json"), "utf8")) as { token?: string }; if (owner.token !== token) throw new Error("process lock ownership changed"); - unlinkSync(path.join(directory, "owner.json")); - rmdirSync(directory); + // Withdraw the complete owner atomically before deleting anything. A + // crash during private cleanup must not leave an unreadable shared lock. + const retired = `${directory}.${token}.retired`; + renameSync(directory, retired); + removeStagedLock(retired); }; } @@ -134,8 +137,7 @@ function reclaimDeadOwner(directory: string): boolean { catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") return false; } const retired = `${directory}.${randomUUID()}.retired`; renameSync(directory, retired); - unlinkSync(path.join(retired, "owner.json")); - rmdirSync(retired); + removeStagedLock(retired); return true; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; return false; } finally { rmdirSync(guard); } diff --git a/test/account-provider.test.ts b/test/account-provider.test.ts index 451cff8..6716d11 100644 --- a/test/account-provider.test.ts +++ b/test/account-provider.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; -import { accountProviderId } from "../src/runtime/account-provider.js"; +import { accountProviderId, catalogProviderId } from "../src/runtime/account-provider.js"; import type { ZCodeRuntimeConfig } from "../src/interfaces.js"; test("account provider IDs are idempotent while legacy builtin IDs still map", () => { @@ -40,7 +40,22 @@ test("account provider IDs are idempotent while legacy builtin IDs still map", ( try { assert.equal(accountProviderId(runtimeProviderId, config), runtimeProviderId); assert.equal(accountProviderId(legacyProviderId, config), runtimeProviderId); + assert.equal(accountProviderId("bigmodel-individual-coding-plan", config), runtimeProviderId); } finally { rmSync(root, { recursive: true, force: true }); } }); + +test("catalog aliases require the same advertised model and preserve exact provider IDs", () => { + const config: ZCodeRuntimeConfig = { + nodeExecutable: process.execPath, zcodeEntrypoint: "unused", dataRoot: "unused", + providerBuiltinConfigFile: "missing-builtin.json", providerPersonalConfigFile: "missing-personal.json", + }; + const account = { providerId: "account:plan", modelId: "flash" }; + assert.equal(catalogProviderId("plan", "flash", [account], config), "account:plan"); + assert.equal(catalogProviderId("plan", "other", [account], config), "plan"); + assert.equal(catalogProviderId("other-plan", "flash", [account], config), "other-plan"); + assert.equal(catalogProviderId("account:plan", "flash", [account], config), "account:plan"); + assert.equal(catalogProviderId("plan", "flash", [account, { providerId: "plan", modelId: "flash" }], config), "plan"); + assert.equal(catalogProviderId("builtin:plan", "flash", [account], config), "builtin:plan"); +}); diff --git a/test/feedback-template.test.ts b/test/feedback-template.test.ts index 19d61c5..fd8734d 100644 --- a/test/feedback-template.test.ts +++ b/test/feedback-template.test.ts @@ -65,6 +65,46 @@ test("A4-02: verified deliverables with unverified cleanup show both facts separ assert.match(rendered, /独立验收 \| 通过/); assert.match(rendered, /cleanup 未确认|清理未确认|cleanup unverified/i); assert.match(rendered, /进程清理/); + assert.match(rendered, /进程清理 \| 未验证/); +}); + +test("current cleanup verification does not erase the attempt's historical cleanup failure", () => { + const record: TaskStatusRecord = { + task_id: "task_1", status: "failed", attempt: 1, created_at: "2026-10-04T00:00:00Z", updated_at: "2026-10-04T00:01:00Z", + started_at: "2026-10-04T00:00:30Z", finished_at: "2026-10-04T00:01:00Z", worker_pid: null, zcode_session_id: "s", exit_code: 0, + observation: { + schema_version: 1, worker: { state: "exited", reason_code: "pid_absent", observed_at: "2026-10-04T00:02:00Z" }, + runtime: { state: "exited", reason_code: "pid_absent", observed_at: "2026-10-04T00:02:00Z" }, + activity: { code: "finalizing", reason_code: "task_terminal", observed_at: "2026-10-04T00:02:00Z" }, + result: "committed", cleanup: "verified", stalled: false, + evidence: { heartbeat_age_ms: null, last_event_age_ms: 60_000, last_event_seq: 9, last_event_type: "task_finished", session_id: "s", turn_id: null, attempt: 1, status_updated_at: null }, + }, + }; + const result: TaskResult = { + task_id: "task_1", status: "failed", attempt: 1, summary: "cleanup_failed", error_code: "cleanup_failed", + files_changed: [], tests: [], issues: [], needs_master_decision: true, zcode_output: "", exit_code: 0, + session_id: "s", started_at: record.started_at, finished_at: record.finished_at, + }; + const input = feedbackInputFromRecord(record, result); + assert.equal(input.result, result); + const rendered = renderFeedback(input); + assert.match(rendered, /当前进程清理[\s\S]*cleanup=verified/); + assert.match(rendered, /原 attempt 清理结果[\s\S]*原 failed 仍保留/); + assert.match(rendered, /独立验收 \| 未验证/); +}); + +test("record updates without business-event evidence cannot impersonate progress", () => { + const record: TaskStatusRecord = { + task_id: "t", status: "running", attempt: 1, created_at: "2026-10-04T00:00:00Z", updated_at: "2026-10-04T00:01:00Z", + started_at: "2026-10-04T00:00:30Z", finished_at: null, worker_pid: 1, zcode_session_id: null, exit_code: null, + }; + assert.equal(feedbackInputFromRecord(record, null).running?.last_progress_at, null); + const terminal = { ...record, status: "failed" as const, attempt: 2 }; + const oldResult: TaskResult = { task_id: "t", status: "failed", attempt: 1, summary: "old", files_changed: ["old.txt"], tests: [], issues: [], needs_master_decision: true, zcode_output: "", exit_code: 0, session_id: null, started_at: null, finished_at: null }; + const input = feedbackInputFromRecord(terminal, oldResult); + assert.deepEqual(input.delivered, []); + assert.equal(input.result, null); + assert.deepEqual(input.decisions, []); }); test("A4-03: commit/push/release states are explicit with evidence; nothing claims published without an operation", () => { diff --git a/test/observation-lifecycle.test.ts b/test/observation-lifecycle.test.ts index b407f96..478ba17 100644 --- a/test/observation-lifecycle.test.ts +++ b/test/observation-lifecycle.test.ts @@ -159,6 +159,43 @@ async function seedRunningTask(fx: ObservedFixture, overrides: Partial { + const now = T0 + 60_000; + const input: import("../src/observation/types.js").JudgeInput = { + status: { status: "running", attempt: 1, started_at: iso(0), finished_at: null, worker_pid: 123 }, + result: null, checkpoint: null, pending_interaction: null, last_business_event: null, now_ms: now, + heartbeat: { attempt: 1, worker_pid: 123, heartbeat_at: iso(59_000), last_event_seq: 0, last_event_type: null, session_id: null, turn_id: null }, + }; + const observation = judgeTaskObservation(input); + assert.equal(observation.worker.state, "alive"); + assert.equal(observation.activity.code, "unknown"); + assert.equal(observation.activity.reason_code, "heartbeat_alive_no_business_event"); + assert.equal(observation.stalled, false); + assert.equal(judgeTaskObservation({ ...input, status: { ...input.status, started_at: iso(59_000) } }).activity.code, "starting"); +}); + +test("a recovery lock release error cannot retain a completed single-flight promise forever", async () => { + const fx = await makeObservedFixture(); + try { + await seedRunningTask(fx); + fx.advance(60_000); // Expire the heartbeat fast path so the injected probe runs. + const original = fx.probe.probe.bind(fx.probe); + const lock = path.join(fx.store.tasksRoot, ".recovery.lock"); + fx.probe.probe = async (requests) => { + const answer = await original(requests); + writeFileSync(path.join(lock, "owner.json"), JSON.stringify({ pid: process.pid, token: "changed" })); + return answer; + }; + await assert.rejects(fx.manager.recoverTasks(), /ownership changed/); + const calls = fx.probe.probeCalls; + // Fixture-only corrupt lock; no real data roots are touched. + rmSync(lock, { recursive: true }); + fx.probe.probe = original; + await fx.manager.recoverTasks(); + assert.ok(fx.probe.probeCalls > calls, "next recovery must execute a new scan"); + } finally { await fx.cleanup(); } +}); + test("A1-01: long quiet tool execution with a fresh heartbeat stays running and is never worker_lost", async () => { const fx = await makeObservedFixture(); try { diff --git a/test/process-lock.test.ts b/test/process-lock.test.ts new file mode 100644 index 0000000..ebf5aeb --- /dev/null +++ b/test/process-lock.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { tryAcquireProcessLock } from "../src/store/process-lock.js"; + +test("crashing during lock retirement cannot strand an ownerless shared lock", { timeout: 15_000 }, () => { + const root = mkdtempSync(path.join(tmpdir(), "bridge-lock-retire-")); + try { + const lock = path.join(root, "recovery.lock"); + const script = path.join(root, "crash-on-unlink.mjs"); + writeFileSync(script, [ + 'import fs from "node:fs";', + 'import { syncBuiltinESMExports } from "node:module";', + `const { tryAcquireProcessLock } = await import(${JSON.stringify(new URL("../src/store/process-lock.js", import.meta.url).href)});`, + `const release = tryAcquireProcessLock(${JSON.stringify(lock)});`, + 'if (!release) process.exit(2);', + // Exit immediately before deleting owner.json. With unlink-before-rename + // this leaves the shared lock stranded; retirement keeps it private. + 'fs.unlinkSync = () => process.exit(0);', + 'syncBuiltinESMExports();', + 'release();', + 'process.exit(3);', + ].join("\n")); + const crashed = spawnSync(process.execPath, [script], { timeout: 10_000, windowsHide: true }); + assert.equal(crashed.status, 0, crashed.stderr.toString()); + assert.equal(existsSync(lock), false, "complete lock must be withdrawn before owner deletion"); + assert.ok(readdirSync(root).some((name) => name.endsWith(".retired")), "interrupted cleanup stays private"); + const release = tryAcquireProcessLock(lock); + assert.ok(release, "a new owner can acquire after the crash"); + release(); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/test/task-manager.test.ts b/test/task-manager.test.ts index 470b2d0..50dabfb 100644 --- a/test/task-manager.test.ts +++ b/test/task-manager.test.ts @@ -90,6 +90,34 @@ test("with a free slot the task starts immediately as running with a persisted p } }); +test("each scheduling operation validates historical records once and observes later changes", async () => { + const fx = await freshFixture(); + try { + const historical = fx.makeTask({ task_id: "historical" }); + fx.store.createTask(historical, CREATED_AT); + fx.store.writeWorkspaceRef(historical.task_id, { requestedPath: fx.workspaceDir, canonicalPath: fx.workspaceDir, mode: "direct" }); + fx.store.writeStatus(historical.task_id, { status: "completed", worker_pid: null }); + const originalRead = fx.store.readTask.bind(fx.store); + let historicalReads = 0; + fx.store.readTask = (taskId) => { + if (taskId === historical.task_id) historicalReads += 1; + return originalRead(taskId); + }; + await fx.manager.createTask(fx.makeTask()); + assert.equal(historicalReads, 1, "one health-validation pass, even with free slots"); + await fx.runWorker("task_1", new FakeAdapter()); + // A new operation must see cleanup occupancy added after the first scan. + fx.store.writeStatus(historical.task_id, { cleanup_unverified: true }); + historicalReads = 0; + const next = await fx.manager.createTask(fx.makeTask({ task_id: "task_2" })); + assert.equal(historicalReads, 1, "no cached terminal status across operations"); + assert.equal(next.status, "queued"); + assert.equal(fx.spawned.length, 1, "unverified historical cleanup still occupies the workspace"); + } finally { + await fx.cleanup(); + } +}); + test("getFeedback aggregates allowlisted current-attempt events across event pages", async () => { const fx = await freshFixture(); try { diff --git a/test/task-prompt.test.ts b/test/task-prompt.test.ts index e918775..806960e 100644 --- a/test/task-prompt.test.ts +++ b/test/task-prompt.test.ts @@ -3,6 +3,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { buildContinuePrompt, buildTaskPrompt } from "../src/prompts/task-prompt.js"; import { makeTask } from "./helpers.js"; +import { parseAgentReport } from "../src/adapters/agent-report.js"; +import type { TaskResult } from "../src/interfaces.js"; test("task prompt contains the package and the output contract", () => { const prompt = buildTaskPrompt(makeTask({ task_id: "task_prompt" })); @@ -19,6 +21,31 @@ test("task prompt contains the package and the output contract", () => { assert.match(prompt, /exactly one JSON object/); }); +test("mandatory output example is valid JSON with all required fields", () => { + const prompt = buildTaskPrompt(makeTask()); + const line = prompt.split("\n").find((text) => text.startsWith("Valid JSON example")); + assert.ok(line); + assert.equal(parseAgentReport(line.slice(line.indexOf("{"))).error, null); +}); + +test("report-only continuation preserves candidate claims without repeating the implementation task", () => { + const result: TaskResult = { + task_id: "repair", status: "failed", error_code: "invalid_agent_report", summary: "report.needs_master_decision must be a boolean", + files_changed: [], tests: [], issues: [], needs_master_decision: true, zcode_output: "x".repeat(50_000), + exit_code: 0, session_id: "session", attempt: 1, started_at: null, finished_at: null, + report_candidate: { summary: "actual candidate", files_changed: ["only.txt"], tests: [{ command: "actual test", status: "failed" }], issues: ["known issue"] }, + }; + const prompt = buildContinuePrompt({ task: makeTask({ objective: "IMPLEMENTATION_SENTINEL", test_commands: ["DO_NOT_RERUN"] }), + feedback: "Correct the report", additionalRequirements: [], previousSessionId: "session", previousResult: result }); + assert.match(prompt, /actual candidate/); + assert.match(prompt, /actual test/); + assert.match(prompt, /known issue/); + assert.match(prompt, /"status": "failed"/); + assert.match(prompt, /Do not use tools/); + assert.doesNotMatch(prompt, /IMPLEMENTATION_SENTINEL|DO_NOT_RERUN|ORIGINAL TASK/); + assert.ok(prompt.length < 8_000); +}); + test("empty arrays render as explicit none", () => { const prompt = buildTaskPrompt( makeTask({ requirements: [], allowed_paths: [], forbidden_paths: [], acceptance_criteria: [], test_commands: [] }), diff --git a/test/windows-process-cleanup.test.ts b/test/windows-process-cleanup.test.ts new file mode 100644 index 0000000..1ba6eba --- /dev/null +++ b/test/windows-process-cleanup.test.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseWindowsTreeCapture, terminateWindowsProcessTree, type WindowsCleanupDependencies } from "../src/adapters/windows-process-cleanup.js"; +import type { ProbeVerdict } from "../src/runtime/process-probe.js"; + +const requests = parseWindowsTreeCapture("bridge_tree_snapshot_v1\n101|123\n102|456", 101); +function verdict(state: ProbeVerdict["state"]): ProbeVerdict { + return { state, reason_code: state === "exited" ? "pid_absent" : "test", observed_at: new Date().toISOString() }; +} +function fixture(after: ProbeVerdict["state"][], code: number | null = 0, before: ProbeVerdict["state"][] = ["alive", "alive"]): WindowsCleanupDependencies & { kills: number[] } { + let probes = 0; + const kills: number[] = []; + return { + kills, capture: async () => requests, + probe: async () => (probes++ === 0 ? before : after).map(verdict), + kill: async (pid) => { kills.push(pid); return code; }, + }; +} + +test("taskkill nonzero is resolved only by fresh exit evidence for root and descendants", async () => { + const dependencies = fixture(["exited", "exited"], 128); + await terminateWindowsProcessTree(101, 0, dependencies); + assert.deepEqual(dependencies.kills, [101]); +}); + +test("successful taskkill and root exit cannot hide a surviving descendant", async () => { + await assert.rejects(terminateWindowsProcessTree(101, 0, fixture(["exited", "alive"])), /recorded executors remain alive/); +}); + +test("a localized command failure does not leak its text or defeat verified natural exit", async () => { + const dependencies = fixture(["exited", "exited"]); + dependencies.kill = async () => { throw new Error("本地化错误与私有路径 �"); }; + await terminateWindowsProcessTree(101, 0, dependencies); + const live = fixture(["exited", "alive"]); + live.kill = dependencies.kill; + await assert.rejects(terminateWindowsProcessTree(101, 0, live), (error: Error) => { + assert.match(error.message, /command_error/); + assert.doesNotMatch(error.message, /本地化|私有|�/); + return true; + }); +}); + +test("unknown post-termination probe does not release the process tree", async () => { + await assert.rejects(terminateWindowsProcessTree(101, 0, fixture(["exited", "unknown"], 128)), /probe_unverified/); +}); + +test("unknown pre-termination identity prevents signaling", async () => { + const dependencies = fixture(["exited", "exited"], 0, ["unknown", "alive"]); + await assert.rejects(terminateWindowsProcessTree(101, 0, dependencies), /probe_unverified/); + assert.deepEqual(dependencies.kills, []); +}); + +test("a root PID whose old identity exited is never signaled even with a surviving child", async () => { + const dependencies = fixture(["exited", "alive"], 0, ["exited", "alive"]); + await assert.rejects(terminateWindowsProcessTree(101, 0, dependencies), /root exited/); + assert.deepEqual(dependencies.kills, []); + const exited = fixture(["exited", "exited"], 0, ["exited", "exited"]); + await terminateWindowsProcessTree(101, 0, exited); + assert.deepEqual(exited.kills, []); +}); + +test("incomplete probe batches cannot prove omitted descendants exited", async () => { + const dependencies = fixture(["exited", "exited"]); + dependencies.probe = async () => [verdict("exited")]; + await assert.rejects(terminateWindowsProcessTree(101, 0, dependencies), /probe_unverified/); + assert.deepEqual(dependencies.kills, []); +}); + +test("tree capture requires a complete ASCII protocol and retains absent root for recheck", () => { + const absent = parseWindowsTreeCapture("bridge_tree_snapshot_v1\n101|absent\n102|456", 101); + assert.equal(absent[0]?.identity, null); + for (const bad of ["101|123", "bridge_tree_snapshot_v1\n102|456", "bridge_tree_snapshot_v1\n101|", "bridge_tree_snapshot_v1\n101|123\n101|456", "bridge_tree_snapshot_v1\n101|123\ntruncated"]) { + assert.throws(() => parseWindowsTreeCapture(bad, 101), /snapshot_invalid/); + } +}); diff --git a/test/zcode-app-server-adapter.test.ts b/test/zcode-app-server-adapter.test.ts index a0f0b3d..3d55de4 100644 --- a/test/zcode-app-server-adapter.test.ts +++ b/test/zcode-app-server-adapter.test.ts @@ -155,6 +155,22 @@ test("lets the app-server resolve a requested model omitted from the initial cat } }); +test("resolves an unprefixed provider from the runtime catalog without losing the caller request", async () => { + const runtime = await makeFakeRuntime({ providerId: "account:plan", modelId: "flash", options: { reasoningLevel: "high" } }, false); + try { + const adapter = new ZCodeAppServerAdapter({ resolver: { resolve: async () => runtime.config }, timeoutMs: 10_000, childEnvBase: { PATH: process.env.PATH }, homeDir: runtime.root }); + const handle = await adapter.startTask({ task: { ...task, model: { provider_id: "plan", model_id: "flash", reasoning_level: "high" } }, workspace: makeWorkspace(runtime.root), attempt: 1 }); + const outcome = await adapter.getResult(handle); + assert.equal(outcome.exitCode, 0); + assert.equal(outcome.modelProfile?.provider_id, "account:plan"); + assert.equal(outcome.modelProfile?.requested_model, "plan/flash"); + const requests = (await readFile(runtime.requestLog, "utf8")).trim().split("\n").map((line) => JSON.parse(line)); + const selection = requests.find((request) => request.method === "session/setModel"); + assert.equal(selection.params.model.providerId, "account:plan"); + assert.equal(selection.params.persistAsWorkspaceLastUsed, false); + } finally { await rm(runtime.root, { recursive: true, force: true }); } +}); + test("omitting model leaves the ZCode session default untouched", async () => { const runtime = await makeFakeRuntime(); try {