diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index d81a819de..f4fc724f0 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -3,7 +3,8 @@ # ---------------------------------------------------------------------------- # Purpose : Run unit + Playwright e2e tests across Linux & Windows # Trigger : Push to `main`/`dev`, PRs targeting `main`, manual dispatch -# Jobs : unit — `bun turbo test` on linux only (windows dropped — see +# Jobs : unit — `bun turbo test` + config_assistant Go tests on linux +# only (windows dropped — see # unit-tests matrix comment; free windows-latest runners # can't fit the suite in a reasonable CI budget) # e2e — Playwright chromium on linux + windows (matrix) @@ -68,6 +69,13 @@ jobs: with: node-version: "24" + - name: Setup Go + if: runner.os == 'Linux' + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: config_assistant/go.mod + cache-dependency-path: config_assistant/go.sum + - name: Setup Bun uses: ./.github/actions/setup-bun with: @@ -109,6 +117,11 @@ jobs: env: OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} + - name: Run config assistant tests + if: runner.os == 'Linux' + working-directory: config_assistant + run: go test ./... + - name: Check generated client if: runner.os == 'Linux' working-directory: packages/client diff --git a/README.md b/README.md index d934def00..21d7095e2 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,6 @@ Each node declares: | `output_schema` | JSON Schema; the child agent must call `submit_result` with a matching structured payload | | `required` | Failure of a required node fails the workflow | | `report_to_parent` | Wake the parent agent when this node reaches a terminal state | -| `model` | Optional per-node model pin; otherwise resolves node → `node_defaults` → agent → `dag.jsonc` tier → parent session | | `review` | `design` or `diff` review phase with an implementation-fingerprint contract (below) | Workflow-level knobs: `max_concurrency` (default 5), `max_node_replan_attempts` (5), `max_total_nodes` (100), per-node `timeout_ms` (default 10 min, queue wait counts toward the deadline). @@ -90,6 +89,7 @@ Workflow-level knobs: `max_concurrency` (default 5), `max_node_replan_attempts` - **Tool robustness**: JSON repair for broken multi-byte Unicode escapes in LLM output, structured validation errors with field-level hints, expanded tool docs, child-process pipe fixes. - **CJK & IME fixes**: corrections for Chinese/Japanese/Korean input in the terminal UI (IME composition flushing, full-width text handling), plus a Korean IME fix script under [`patches/`](./patches). - **Worktree isolation**: per-workflow `git worktree` isolation, with experimental sandbox-worktree HTTP endpoints. +- **Configuration assistant**: a standalone Go TUI under [`config_assistant`](./config_assistant) for locating, validating, and editing opencode configuration (`cd config_assistant && go run ./cmd/ocfg`). - An earlier "Goal auto-loop" and the `/goal`, `/subgoal`, `/workflow` slash commands are gone; autonomous execution now goes through the `workflow` tool and its wake mechanism. All upstream capabilities (multi-provider, built-in LSP, client/server architecture, TUI/desktop/web clients) are preserved. @@ -117,9 +117,8 @@ bun dev serve # headless API server (port 4096) ## Quality gates -- **CI**: typecheck on every PR; the `main` gate additionally runs the full unit suite (Linux), Playwright e2e (Linux + Windows), an HTTP API contract exerciser, and generated-SDK freshness checks. +- **CI**: typecheck on every PR; the `main` gate additionally runs the Bun/Turbo unit suite and config-assistant Go tests (Linux), Playwright e2e (Linux + Windows), an HTTP API contract exerciser, and generated-SDK freshness checks. - **DAG-specific tests**: core scheduling unit tests, projector/state-machine drift tests, workflow lifecycle integration tests, and HTTP API exercise scenarios for every DAG route. -- **Specs**: engine behavior is pinned by [openspec](./openspec/specs) specifications (execution engine, state-machine enforcement, scheduler recovery, step semantics, structured output, replay idempotency, and more). ## License @@ -135,7 +134,6 @@ Exact file boundaries are listed in [`NOTICE`](./NOTICE). The AGPL covers the DA ## Docs - [`docs/harness-dag.md`](./docs/harness-dag.md) — deep-mode admission & review lifecycle -- [`openspec/specs`](./openspec/specs) — engine behavior specifications - [`.opencode/dag-prompts`](./.opencode/dag-prompts) — built-in node prompt templates - [`AGENTS.md`](./AGENTS.md) — contribution & development guide diff --git a/README.zh.md b/README.zh.md index 45b45e1d0..0d092e37e 100644 --- a/README.zh.md +++ b/README.zh.md @@ -38,7 +38,6 @@ | `output_schema` | JSON Schema;子智能体必须调用 `submit_result` 提交匹配的结构化结果 | | `required` | 必需节点失败会使整个工作流失败 | | `report_to_parent` | 节点到达终态时唤醒父智能体 | -| `model` | 可选的节点级模型指定;否则按 节点 → `node_defaults` → agent → `dag.jsonc` 分层 → 父会话 解析 | | `review` | `design` 或 `diff` 审查阶段,带实现指纹契约(见下) | 工作流级参数:`max_concurrency`(默认 5)、`max_node_replan_attempts`(5)、`max_total_nodes`(100)、节点级 `timeout_ms`(默认 10 分钟,排队等待计入预算)。 @@ -90,6 +89,7 @@ - **工具健壮性**:修复 LLM 输出里损坏的多字节 Unicode 转义(JSON 修复),校验错误带字段级提示,工具文档扩充,子进程管道修复。 - **CJK 与 IME 修复**:终端 UI 里中日韩文输入的修正(IME 组字刷新、全角文本处理),另有 [`patches/`](./patches) 下的韩文 IME 修复脚本。 - **Worktree 隔离**:按工作流的 `git worktree` 隔离,附实验性的 sandbox-worktree HTTP 端点。 +- **配置助手**:[`config_assistant`](./config_assistant) 下提供独立 Go TUI,用于定位、校验和编辑 opencode 配置(`cd config_assistant && go run ./cmd/ocfg`)。 - 早期的「Goal 自动循环」和 `/goal`、`/subgoal`、`/workflow` 斜杠命令已经移除,自主执行统一走 `workflow` 工具和它的唤醒机制。 上游全部能力(多 Provider、内置 LSP、客户端/服务器架构、TUI/桌面/Web 客户端)均完整保留。 @@ -117,9 +117,8 @@ bun dev serve # headless API 服务(端口 4096) ## 质量门禁 -- **CI**:每个 PR 跑 typecheck;`main` 门禁额外运行全量单元测试(Linux)、Playwright e2e(Linux + Windows)、HTTP API 契约测试器、以及生成 SDK 的新鲜度校验。 +- **CI**:每个 PR 跑 typecheck;`main` 门禁额外运行 Bun/Turbo 单元测试与配置助手 Go 测试(Linux)、Playwright e2e(Linux + Windows)、HTTP API 契约测试器、以及生成 SDK 的新鲜度校验。 - **DAG 专项测试**:核心调度单元测试、投影器/状态机漂移测试、工作流生命周期集成测试、每条 DAG 路由的 HTTP API 演练场景。 -- **规范**:引擎行为由 [openspec](./openspec/specs) 规范固定(执行引擎、状态机强制、调度器恢复、单步语义、结构化输出、回放幂等性等)。 ## 许可证 @@ -135,7 +134,6 @@ bun dev serve # headless API 服务(端口 4096) ## 文档 - [`docs/harness-dag.md`](./docs/harness-dag.md) —— deep 模式准入与审查生命周期 -- [`openspec/specs`](./openspec/specs) —— 引擎行为规范 - [`.opencode/dag-prompts`](./.opencode/dag-prompts) —— 内置节点 prompt 模板 - [`AGENTS.md`](./AGENTS.md) —— 贡献与开发指南 diff --git a/openspec/changes/dag-post-crash-continuation/.openspec.yaml b/openspec/changes/dag-post-crash-continuation/.openspec.yaml deleted file mode 100644 index 9e5b8a190..000000000 --- a/openspec/changes/dag-post-crash-continuation/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-07-23 diff --git a/openspec/changes/dag-post-crash-continuation/design.md b/openspec/changes/dag-post-crash-continuation/design.md deleted file mode 100644 index cb76e09a4..000000000 --- a/openspec/changes/dag-post-crash-continuation/design.md +++ /dev/null @@ -1,111 +0,0 @@ -## Context - -DAG 节点执行同时存在两类状态: - -1. SQLite/EventV2 中的持久状态,例如节点 `running`、`child_session_id` 和 `deadline_ms`。 -2. `DagLoop.WorkflowEntry.fibers` 中的进程本地执行所有权,真正驱动 `SessionPrompt.prompt()`、timeout 和最终 `NodeCompleted`/`NodeFailed`。 - -进程崩溃会保留第一类状态并永久销毁第二类状态。当前 `reconcileWorkflow()` 在 child Session 看似 `active`/`unknown` 且 deadline 未过期时保留节点 `running`,但不会恢复 provider turn,也不会安装 completion watcher。由于 child Session 的终态不会自动变成 DAG 终态事件,这类节点可能永远悬挂。 - -项目的 V2 Session 约束明确规定:本地 Session drain 没有 durable execution identity,崩溃后的 provider work 不得在没有独立设计的情况下自动重试。恢复方案因此必须优先保证“不重复执行工具/副作用”和“workflow 最终可达终态”,不能把数据库中的 `active` 当作可恢复执行所有权。 - -## Goals / Non-Goals - -**Goals:** - -- 为 recovered-running 节点建立明确、可测试的执行所有权规则。 -- 不自动重试 provider/tool work 的前提下消除永久 `running`。 -- 继续复用既有 `NodeFailed`、依赖级联、workflow 终态和 wake delivery 链路。 -- 保持已完成 child Session 的结构化输出恢复行为。 -- 用真实服务层级的集成测试覆盖启动恢复,而不只测试纯 `WorkflowRuntime`。 - -**Non-Goals:** - -- 不实现 durable provider-turn checkpoint 或工具调用 exactly-once。 -- 不调用 `SessionExecution.wake()` 自动续跑崩溃前的 provider turn。 -- 不引入集群执行所有权、lease 或远程 worker adoption。 -- 不修改数据库 schema、HTTP API、SDK、TUI 或 replan 产品语义。 -- 不在本 change 中大规模拆分 `DagLoop` 或重构 `planReplan`。 - -## Decisions - -### D1: 进程本地 fiber 是 DAG 节点执行所有权的唯一证明 - -恢复后的 `WorkflowEntry.fibers` 初始为空。数据库中的 `status = running` 和 Session 的最后消息只能说明崩溃前的投影状态,不能证明当前进程仍有执行在推进。 - -因此,启动恢复扫描发现的每个 `running` 节点都必须在 `reconcileWorkflow()` 内完成一次确定性分类,函数返回后不得留下无当前进程 fiber 所有权的 `running` 节点。 - -**备选:**把 child Session 的 `active` 状态当作仍在执行。否决,因为当前 Session drain 是进程本地的,重启后没有执行所有权或终态桥接。 - -### D2: 已结算结果投影;未结算执行确定性失败 - -恢复分类顺序如下: - -1. 无 `childSessionId`:发布 `NodeFailed(exec_failed)`。 -2. child Session 已完成:沿用现有 output schema/captured output 判定,发布 `NodeCompleted` 或 `NodeFailed(verdict_fail)`。 -3. child Session 已失败:发布 `NodeFailed(exec_failed)`。 -4. child Session 为 `active` 或 `unknown`: - - deadline 已过:先 best-effort cancel child Session,再发布 `NodeFailed(timeout)`,原因保持 `deadline exceeded on recovery`。 - - deadline 未过或未设置:先 best-effort cancel child Session,再发布 `NodeFailed(exec_failed)`,原因明确为 `execution ownership lost on recovery`。 - -取消失败不得阻止 DAG 终态化;失败应记录结构化 warning。投影器的终态守卫负责拒绝之后可能到达的过期 `NodeStarted`/`NodeCompleted`。 - -**备选:**等待 deadline。否决,因为无 deadline 节点仍会永久悬挂,且未来 deadline 也没有本地 timeout fiber 负责触发。 - -### D3: 恢复不隐式创建新执行尝试 - -恢复流程不得调用 `spawnNode`、`SessionExecution.wake` 或重新提交旧 prompt 来延续 recovered-running 节点。需要继续业务工作时,parent orchestrator 必须通过既有 workflow `replan`/restart 创建一个显式新尝试。 - -这会把“可能重复执行副作用”的隐式恢复,转换为“旧尝试失败、后续尝试有明确控制事件”的可审计过程。 - -**备选:**自动把节点重置为 pending 并重跑。否决,因为工具调用和外部副作用没有 durable attempt identity 或 exactly-once 保证。 - -### D4: 恢复失败继续走标准 DAG 事件闭环 - -不增加新的节点状态或旁路恢复表。恢复只调用公开的 `Dag.Service.nodeCompleted/nodeFailed`: - -- Projector 更新 read model; -- DagLoop 的既有节点终态 handler 更新 `WorkflowRuntime`; -- required failure 触发现有 workflow cancel; -- optional failure 允许既有调度继续; -- `report_to_parent` 和 workflow terminal 继续使用既有 durable wake eligibility。 - -`reconcileWorkflow()` 的返回统计改为反映实际结果,例如 `reconciled` 和 `ownershipLost`;不再暴露暗示节点仍可推进的 `leftRunning`,或至少强制该值在恢复后为零。 - -### D5: 增加真实 DagLoop 启动恢复集成夹具 - -新增测试应构造包含 Database、EventV2Bridge、DagProjector、DagStore、Dag.Service 和 DagLoop 的服务层,并以可控的 Session/SessionPrompt 服务替代真实 provider。 - -至少覆盖: - -- active child + future deadline → cancel + `NodeFailed(exec_failed)`; -- active child + no deadline → cancel + `NodeFailed(exec_failed)`; -- active child + expired deadline → cancel + `NodeFailed(timeout)`; -- completed child + captured output → `NodeCompleted`; -- 恢复完成后 read model 不存在无本地所有权的 `running` 节点; -- 恢复产生的终态事件只投影一次,且不会触发 replacement spawn; -- wake-eligible node failure 仍可被既有 unreported wake 查询发现。 - -测试不得使用 `globalThis` mock;通过 Effect Layer 和现有 fixture 注入服务。 - -## Risks / Trade-offs - -- **[恢复时可能放弃一个外部仍在运行的请求]** → 当前产品明确是进程本地执行;先调用 child Session cancel,并以终态投影守卫拒绝迟到事件。未来引入远程执行时必须先增加 durable ownership lease,再修改本契约。 -- **[节点在未过 deadline 时更早失败]** → 这是执行所有权丢失后的真实状态,不是超时;使用 `exec_failed` 和明确 reason,让 orchestrator 可选择 replan/restart。 -- **[required 节点恢复失败会取消 workflow]** → 复用既有 required failure 语义,避免新增仅用于恢复的状态机分支。 -- **[取消 child Session 失败后仍可能出现迟到输出]** → 记录 warning,依赖 Projector 终态守卫保持 DAG read model 不被复活。 -- **[集成夹具依赖较多、测试成本上升]** → 只覆盖启动恢复关键路径;纯调度排列组合继续留在快速单元测试中。 - -## Migration Plan - -1. 先补恢复单元测试和服务层集成夹具,使当前行为以失败测试暴露。 -2. 修改 `reconcileWorkflow()` 分类与取消顺序。 -3. 调整 DagLoop rehydration 对恢复统计的处理与日志。 -4. 运行 DAG 全量测试、core/opencode typecheck 和 HTTP exercise(确认无路由缺失)。 -5. 部署无需数据迁移;已有悬挂 workflow 会在下一次实例启动/初始化时被确定性终态化。 - -回滚只需恢复旧 reconciliation 分支;不会涉及 schema 回退,但会重新引入 recovered-running 永久悬挂风险。 - -## Open Questions - -- 本 change 使用现有 `exec_failed` trigger 并把 `execution ownership lost on recovery` 放入 reason。若后续需要按恢复故障单独统计,可另行增加 `recovery_lost` trigger;本次不扩展事件 schema。 diff --git a/openspec/changes/dag-post-crash-continuation/proposal.md b/openspec/changes/dag-post-crash-continuation/proposal.md deleted file mode 100644 index b572c1796..000000000 --- a/openspec/changes/dag-post-crash-continuation/proposal.md +++ /dev/null @@ -1,32 +0,0 @@ -## Why - -DAG 节点执行由进程内 fiber 驱动;进程崩溃后,持久化读模型可能仍把节点记为 `running`,但原 fiber 已永久丢失。当前恢复逻辑会把 child Session 看似 `active` 且未过 deadline 的节点继续留在 `running`,同时不恢复执行、不安装 watcher,也没有任何 DAG 终态事件可触发 wake,导致 workflow 可能永久悬挂。 - -## What Changes - -- 明确进程重启后的执行所有权语义:不存在当前进程执行 fiber 的 recovered-running 节点不得被当作仍在推进。 -- 恢复时先投影已经完成或失败的 child Session;对仍为 `active`/`unknown` 且已失去执行所有权的节点,停止旧 child Session 并确定性发布 `NodeFailed`,不隐式重试 provider/tool 执行。 -- 保留 deadline 优先语义:已过 deadline 的 recovered-running 节点继续以 `timeout` 失败;未过 deadline 或无 deadline 也不得无限保持 `running`。 -- 让节点失败、依赖级联、workflow 终态与 parent wake 继续通过既有 DAG 事件链路发生,显式 replan/restart 仍是恢复业务工作的唯一入口。 -- 新增真实 DagLoop 恢复集成测试,覆盖 EventV2、Projector、DagStore、DagLoop、child Session 状态判断和 wake eligibility,而不是只直接驱动 `WorkflowRuntime`。 -- 删除或修订“active child Session 会自行通过正常 wake 产生 DAG 终态”的失效假设与相关测试。 - -## Capabilities - -### New Capabilities - -- 无。 - -### Modified Capabilities - -- `dag-scheduler-recovery`: 将失去当前进程执行所有权的 recovered-running 节点从“继续等待”改为“不自动重试、确定性终态化”,消除无 deadline 节点的永久悬挂。 -- `dag-execution-engine`: 明确 node execution fiber 是进程本地执行所有权;恢复流程不得假定旧 provider turn 会继续,也不得在没有显式新执行尝试的情况下保留 `running`。 - -## Impact - -- `packages/opencode/src/dag/runtime/recovery.ts`:恢复判定、旧 child Session 取消和终态发布。 -- `packages/opencode/src/dag/runtime/loop.ts`:rehydration 对 reconciliation 结果的处理与恢复可观测性。 -- `packages/opencode/test/dag/`:恢复单元测试和真实 DagLoop 层级集成测试。 -- `openspec/specs/dag-scheduler-recovery`、`openspec/specs/dag-execution-engine`:修订恢复契约。 -- 不修改数据库 schema、HTTP API、SDK 或 TUI 数据结构。 -- 行为变化:进程重启时仍显示 active/unknown、但没有当前进程执行所有权的节点将失败并进入既有级联/唤醒流程,而不是无限保持 `running`。 diff --git a/openspec/changes/dag-post-crash-continuation/specs/dag-execution-engine/spec.md b/openspec/changes/dag-post-crash-continuation/specs/dag-execution-engine/spec.md deleted file mode 100644 index 5bd759398..000000000 --- a/openspec/changes/dag-post-crash-continuation/specs/dag-execution-engine/spec.md +++ /dev/null @@ -1,71 +0,0 @@ -## ADDED Requirements - -### Requirement: DAG node execution ownership is process-local - -A node SHALL be considered actively executing only while the current process owns its tracked node execution fiber. Persisted node status, child Session identity, and a non-terminal child Session projection SHALL NOT by themselves constitute execution ownership after a process restart. - -#### Scenario: persisted running status does not restore execution ownership - -- **WHEN** a process restarts with a node persisted as `running` -- **AND** the new DagLoop has no tracked fiber for that node -- **THEN** the system SHALL treat the previous execution attempt as having lost ownership -- **AND** SHALL reconcile it through `dag-scheduler-recovery` - -#### Scenario: current-process fiber proves active execution - -- **WHEN** `spawnReady` creates a node execution fiber in the current process -- **AND** stores it in `WorkflowEntry.fibers` -- **THEN** the node MAY be treated as actively making progress until that fiber terminates or is interrupted - -### Requirement: Crash recovery does not retry provider work implicitly - -The DAG runtime MUST NOT automatically retry or continue provider/tool execution merely because a recovered child Session is non-terminal. A new execution attempt SHALL require an explicit scheduling transition produced by normal workflow control, such as replan/restart, after the lost attempt has reached a DAG terminal state. - -#### Scenario: recovery does not invoke provider continuation - -- **WHEN** recovery finds a child Session classified as `active` or `unknown` -- **THEN** it SHALL NOT call `SessionExecution.wake`, `SessionPrompt.prompt`, or `spawnNode` for that lost attempt -- **AND** SHALL terminalize the attempt according to `dag-scheduler-recovery` - -#### Scenario: explicit replan can create a new attempt - -- **WHEN** the parent orchestrator observes a recovery failure -- **AND** explicitly replans or restarts the node through the workflow control surface -- **THEN** the normal `NodeRestarted` and scheduling path MAY create a new child Session and execution fiber - -## MODIFIED Requirements - -### Requirement: Crash-recovery re-attachment inherits the node's timeout - -A node's resolved timeout deadline (absolute `spawnedAt + timeout_ms`) SHALL be persisted as durable node state. Crash recovery SHALL use `reconcileWorkflow` as a one-time startup scan to classify every node left in `running`. - -If the child Session already completed or failed, recovery SHALL publish the corresponding DAG terminal event. If the child Session remains `active` or `unknown`, recovery SHALL recognize that the crashed process's timeout and prompt fibers no longer exist. It SHALL best-effort cancel the old child Session and terminalize the node: expired deadlines use `NodeFailed(timeout)`; future or unset deadlines use `NodeFailed(exec_failed)` with an execution-ownership-loss reason. - -Recovery SHALL NOT install a persistent polling watcher and SHALL NOT retry provider work implicitly. - -#### Scenario: recovered node with completed child session - -- **WHEN** a node is recovered in `running` after restart -- **AND** its child Session has completed -- **THEN** `reconcileWorkflow` SHALL publish `NodeCompleted`, or `NodeFailed(verdict_fail)` when its structured-output contract is unsatisfied - -#### Scenario: recovered node with failed child session - -- **WHEN** a node is recovered in `running` after restart -- **AND** its child Session has failed -- **THEN** `reconcileWorkflow` SHALL publish `NodeFailed(exec_failed)` - -#### Scenario: recovered node with active child session and expired deadline - -- **WHEN** a recovered node's child Session is `active` or `unknown` -- **AND** its persisted deadline has expired -- **THEN** recovery SHALL best-effort cancel the child Session -- **AND** SHALL publish `NodeFailed(timeout)` - -#### Scenario: recovered node with active child session and future or absent deadline - -- **WHEN** a recovered node's child Session is `active` or `unknown` -- **AND** its deadline is in the future or absent -- **THEN** recovery SHALL best-effort cancel the child Session -- **AND** SHALL publish `NodeFailed(exec_failed)` with an execution-ownership-loss reason -- **AND** SHALL NOT leave the node in `running` diff --git a/openspec/changes/dag-post-crash-continuation/specs/dag-scheduler-recovery/spec.md b/openspec/changes/dag-post-crash-continuation/specs/dag-scheduler-recovery/spec.md deleted file mode 100644 index 591f29ca9..000000000 --- a/openspec/changes/dag-post-crash-continuation/specs/dag-scheduler-recovery/spec.md +++ /dev/null @@ -1,73 +0,0 @@ -## MODIFIED Requirements - -### Requirement: No double-spawn for still-live child sessions - -The rehydrated DagLoop MUST NOT spawn replacement child sessions implicitly for nodes whose execution attempt belonged to the crashed process. A persisted child Session status of `active` or `unknown` SHALL NOT be treated as proof that the current process owns an execution fiber. - -For every recovered node in `running`, `reconcileWorkflow` SHALL inspect the child Session once. If the Session has already completed or failed, it SHALL publish the corresponding DAG terminal event. If the Session remains `active` or `unknown`, recovery SHALL best-effort cancel that child Session and publish `NodeFailed` for the lost execution attempt. Recovery SHALL NOT call `spawnNode`, reset the node to `pending`, invoke `SessionExecution.wake`, or otherwise retry provider/tool execution implicitly. - -A child session with zero messages MUST be classified as `unknown`. Both `active` and `unknown` mean that the durable Session projection is non-terminal; neither state restores process-local DAG execution ownership after restart. - -#### Scenario: active child session is terminalized without replacement spawn - -- **WHEN** a workflow is recovered with a `running` node whose child Session is classified `active` -- **AND** no current-process fiber owns that node -- **THEN** recovery SHALL best-effort cancel the child Session -- **AND** SHALL publish `NodeFailed` with trigger `exec_failed` and a reason indicating execution ownership was lost on recovery -- **AND** SHALL NOT spawn a replacement child Session - -#### Scenario: unknown child session is terminalized without replacement spawn - -- **WHEN** a workflow is recovered with a `running` node whose child Session is classified `unknown` -- **THEN** recovery SHALL best-effort cancel the child Session -- **AND** SHALL publish `NodeFailed` for the lost execution attempt -- **AND** SHALL NOT leave the node in `running` - -#### Scenario: completed child session is projected instead of failed - -- **WHEN** `reconcileWorkflow` finds a `running` node whose child Session completed before the crash -- **THEN** recovery SHALL publish `NodeCompleted` when its output contract is satisfied -- **AND** SHALL publish `NodeFailed` with trigger `verdict_fail` when an output schema exists but no valid captured output exists - -#### Scenario: zero-message child session is not adopted - -- **WHEN** `reconcileWorkflow` checks a running node's child Session and the Session has zero messages -- **THEN** the Session SHALL be classified as `unknown` -- **AND** recovery SHALL fail the lost execution attempt rather than adopting or restarting it - -### Requirement: Recovered running nodes are bounded by deadline re-enforcement - -When `reconcileWorkflow()` encounters a node left in `running` after a crash, it MUST compare the current time with persisted `deadline_ms` before classifying execution ownership loss. An expired deadline SHALL produce `NodeFailed` with reason `"deadline exceeded on recovery"` and trigger `"timeout"`. - -A future or unset deadline SHALL NOT authorize recovery to leave the node `running`, because the timeout fiber died with the crashed process. If the child Session is still `active` or `unknown`, recovery SHALL best-effort cancel it and publish `NodeFailed` with trigger `"exec_failed"` and reason indicating execution ownership loss. - -#### Scenario: recovered node past its deadline fails as timeout - -- **WHEN** `reconcileWorkflow()` finds a `running` node whose `deadline_ms` is earlier than the recovery time -- **AND** its child Session is `active` or `unknown` -- **THEN** recovery SHALL best-effort cancel the child Session -- **AND** SHALL publish `NodeFailed` with reason `"deadline exceeded on recovery"` and trigger `"timeout"` - -#### Scenario: recovered node before its deadline fails as ownership loss - -- **WHEN** `reconcileWorkflow()` finds a `running` node whose deadline is in the future -- **AND** its child Session is `active` or `unknown` -- **THEN** recovery SHALL NOT wait for the future deadline -- **AND** SHALL best-effort cancel the child Session -- **AND** SHALL publish `NodeFailed` with trigger `"exec_failed"` and a reason indicating execution ownership was lost - -#### Scenario: recovered node with no deadline cannot remain running - -- **WHEN** `reconcileWorkflow()` finds a `running` node with no persisted deadline -- **AND** its child Session is `active` or `unknown` -- **THEN** recovery SHALL best-effort cancel the child Session -- **AND** SHALL publish `NodeFailed` -- **AND** SHALL NOT leave the node indefinitely in `running` - -## REMOVED Requirements - -### Requirement: The orchestrator_unresponsive safety net remains reachable for recovered workflows - -**Reason**: Recovery no longer leaves nodes in `running` without a current-process execution fiber. The old requirement attempted to bound an invalid intermediate state through the parent wake safety net, but no node-result wake is guaranteed to exist for an execution that never reaches a DAG terminal event. - -**Migration**: Recovered-running nodes are now terminalized during reconciliation. Their normal `NodeFailed` projection, dependency cascade, workflow terminalization, and durable wake eligibility replace the orphan-specific `orchestrator_unresponsive` fallback. diff --git a/openspec/changes/dag-post-crash-continuation/tasks.md b/openspec/changes/dag-post-crash-continuation/tasks.md deleted file mode 100644 index 780ec589a..000000000 --- a/openspec/changes/dag-post-crash-continuation/tasks.md +++ /dev/null @@ -1,42 +0,0 @@ -## 1. 恢复契约回归测试 - -- [x] 1.1 更新 `packages/opencode/test/dag/dag-recovery.test.ts`:active child + future deadline 预期 cancel 后 `NodeFailed(exec_failed)`,不再增加 `leftRunning` -- [x] 1.2 增加 active child + 无 deadline 的 ownership-loss 失败测试,验证节点不会保持 `running` -- [x] 1.3 更新 unknown/零消息 child Session 测试,验证 cancel、ownership-loss reason 和单一 `NodeFailed` -- [x] 1.4 保留并强化 expired deadline 测试,验证先 best-effort cancel、再发布 `NodeFailed(timeout)` -- [x] 1.5 保留 completed/failed child Session 与 structured output 恢复测试,确认已结算结果不会被误判为 ownership loss -- [x] 1.6 增加 cancel 失败测试,验证记录失败不会阻止 DAG 节点终态化 - -## 2. 确定性恢复实现 - -- [x] 2.1 在 `packages/opencode/src/dag/runtime/recovery.ts` 中将 recovered-running 的 active/unknown 分支改为确定性 ownership-loss 终态化 -- [x] 2.2 对 active/unknown child Session 在发布 DAG 终态前调用注入的 cancel 操作,并将取消错误降级为结构化 warning -- [x] 2.3 保持 expired deadline 优先映射到 `timeout`,future/unset deadline 映射到 `exec_failed` -- [x] 2.4 使用稳定、可断言的 ownership-loss reason,并确保恢复流程不调用 `spawnNode`、`SessionPrompt.prompt` 或 `SessionExecution.wake` -- [x] 2.5 调整 `reconcileWorkflow()` 返回统计,移除或废弃误导性的 `leftRunning`,增加 ownership-loss 可观测计数 -- [x] 2.6 更新 `packages/opencode/src/dag/runtime/loop.ts` 的恢复注释和日志,删除“旧执行会通过 normal wake 自行结算”的失效假设 - -## 3. DagLoop 真实恢复集成测试 - -- [x] 3.1 新建 DagLoop 恢复集成 fixture,组合 Database、EventV2Bridge、DagProjector、DagStore、Dag.Service、DagLoop,并通过 Effect Layer 注入可控 Session/SessionPrompt 服务 -- [x] 3.2 测试启动恢复 active child + future/unset deadline:旧 child 被取消、节点投影为 failed、无 replacement Session 被创建 -- [x] 3.3 测试启动恢复 expired deadline:节点投影为 failed、trigger/reason 保持 timeout 语义 -- [x] 3.4 测试启动恢复 completed child + captured output:节点投影为 completed 且 output 保持一致 -- [x] 3.5 测试 required recovered node 失败后的依赖级联和 workflow 终态,确认复用标准事件链路 -- [x] 3.6 测试 `report_to_parent` 节点的恢复失败仍出现在 unreported wake 查询中 -- [x] 3.7 测试恢复结束后 read model 中不存在无当前进程 fiber 所有权的 `running` 节点 -- [x] 3.8 测试迟到 `NodeStarted`/`NodeCompleted` 不会复活已因 ownership loss 终态化的节点 - -## 4. 清理旧假设与文档 - -- [x] 4.1 搜索并修订代码注释、测试名称和 fixture 中关于 recovered active child 会自行产生 DAG terminal/wake 的表述 -- [x] 4.2 确认没有重新引入 persistent polling watcher、detached fiber 或自动 provider retry -- [x] 4.3 确认恢复行为不需要数据库迁移、HTTP API 变更或 SDK 重新生成 - -## 5. 验证 - -- [x] 5.1 在 `packages/opencode` 运行新增的 recovery 单元测试与 DagLoop 恢复集成测试 -- [x] 5.2 在 `packages/opencode` 运行 `bun test test/dag` -- [x] 5.3 在 `packages/opencode` 运行 `bun typecheck` -- [x] 5.4 在 `packages/core` 运行 `bun typecheck`,确认共享 DAG 类型与状态机契约未回归 -- [x] 5.5 运行 `openspec validate dag-post-crash-continuation --strict` diff --git a/packages/opencode/src/dag/dag.ts b/packages/opencode/src/dag/dag.ts index d89b070df..12bf5b455 100644 --- a/packages/opencode/src/dag/dag.ts +++ b/packages/opencode/src/dag/dag.ts @@ -28,7 +28,7 @@ import { transitionAdmission, validateAdmission, } from "./admission" -import { validateReviewLifecycle } from "./review-lifecycle" +import { unresolvedReviewOutcomes, validateReviewLifecycle } from "./review-lifecycle" import { conditionReference } from "./runtime/eval" // Re-export domain types @@ -87,6 +87,18 @@ export interface WorkflowConfig { nodes: NodeConfig[] } +export class ReviewGateError extends Error { + readonly dagID: string + readonly reviewIDs: string[] + + constructor(dagID: string, reviewIDs: string[]) { + super(`Cannot complete deep workflow ${dagID}: unresolved review outcome(s): ${reviewIDs.join(", ")}`) + this.name = "ReviewGateError" + this.dagID = dagID + this.reviewIDs = reviewIDs + } +} + export function normalizeModel(model: NodeConfig["model"]) { if (!model) return undefined const prefix = `${model.providerID}/` @@ -452,6 +464,12 @@ export const layer = Layer.effect( }) const complete = Effect.fn("Dag.complete")(function* (dagID: string) { yield* guardWorkflow(dagID, WorkflowStatus.COMPLETED) + const workflow = yield* store.getWorkflow(dagID).pipe(Effect.orDie) + const config = workflow ? parseWorkflowConfig(workflow.config) : undefined + const unresolvedReviews = config + ? unresolvedReviewOutcomes(config, yield* store.getNodes(dagID)) + : [] + if (unresolvedReviews.length > 0) yield* Effect.fail(new ReviewGateError(dagID, unresolvedReviews)) yield* terminateNonTerminalNodes(dagID, "agent_complete", "", false) yield* events.publish(DagEvent.WorkflowCompleted, { dagID: dagID as ID, durationMs: 0 as never, timestamp: yield* DateTime.now }) }) diff --git a/packages/opencode/src/dag/review-lifecycle.ts b/packages/opencode/src/dag/review-lifecycle.ts index a8a5eae4b..0fe416a9f 100644 --- a/packages/opencode/src/dag/review-lifecycle.ts +++ b/packages/opencode/src/dag/review-lifecycle.ts @@ -1,16 +1,22 @@ export * as DagReviewLifecycle from "./review-lifecycle" import type { NodeConfig, WorkflowConfig } from "./dag" +import { evaluateCondition } from "./runtime/eval" export function validateReviewLifecycle(config: WorkflowConfig) { const issues = config.nodes.flatMap((node) => { - if (!isReviewWorker(node.worker_type)) return [] if (!node.review) { + if (!isReviewWorker(node.worker_type)) return [] if ((config.mode ?? "standard") === "standard") return [] return [`${node.id}: deep review worker must declare review.phase as "design" or "diff"`] } if (node.review.phase === "design") return [] - return validateDiffReview(config, node.id) + return [ + ...validateDiffReview(config, node.id), + ...((config.mode ?? "standard") === "deep" + ? validateFinalReviewGate(config, node.id) + : []), + ] }) if ((config.mode ?? "standard") === "standard") { @@ -140,6 +146,37 @@ export function validateReviewResult(output: unknown, currentFingerprint: string } } +export function unresolvedReviewOutcomes( + config: WorkflowConfig, + nodes: ReadonlyArray<{ id: string; status: string; output: unknown }>, +) { + if ((config.mode ?? "standard") !== "deep") return [] + const rows = new Map(nodes.map((node) => [node.id, node])) + const reviews = config.nodes.filter((node) => node.review?.phase === "diff") + return reviews.flatMap((review) => { + if ( + isCorrectionReview(config, reviews, review) + && rows.get(review.id)?.status !== "completed" + ) return [] + if (reviewAccepted(config, rows, review)) return [] + const corrected = reviews.some((candidate) => { + const implementationID = candidate.review?.implementation_node_id + if (!implementationID || !dependsTransitively(config, implementationID, review.id)) return false + return reviewAccepted(config, rows, candidate) + }) + return corrected ? [] : [review.id] + }) +} + +function isCorrectionReview(config: WorkflowConfig, reviews: NodeConfig[], review: NodeConfig) { + const implementationID = review.review?.implementation_node_id + return implementationID !== undefined + && reviews.some((candidate) => + candidate.id !== review.id + && dependsTransitively(config, implementationID, candidate.id), + ) +} + export function reviewContractForNode(node: NodeConfig) { if (!node.review) return undefined if (node.review.phase === "design") { @@ -215,6 +252,45 @@ function validateDiffReview(config: WorkflowConfig, reviewID: string) { ] } +function validateFinalReviewGate(config: WorkflowConfig, reviewID: string) { + const gate = finalReviewGates(config, reviewID).length > 0 + return gate + ? [] + : [`${reviewID}: deep diff review must feed a required final gate conditioned on verdict ACCEPT`] +} + +function finalReviewGates(config: WorkflowConfig, reviewID: string) { + return config.nodes.filter((node) => + node.id !== reviewID + && node.required + && dependsTransitively(config, node.id, reviewID) + && Object.values(node.input_mapping ?? {}).some((source) => + source === `${reviewID}.output` || source.startsWith(`${reviewID}.output.`), + ) + && acceptsReviewVerdict(node.condition, reviewID), + ) +} + +function reviewAccepted( + config: WorkflowConfig, + rows: ReadonlyMap, + review: NodeConfig, +) { + const row = rows.get(review.id) + return row?.status === "completed" + && reviewVerdict(row.output) === "ACCEPT" + && finalReviewGates(config, review.id).some((gate) => rows.get(gate.id)?.status === "completed") +} + +function acceptsReviewVerdict(condition: string | undefined, reviewID: string) { + const output = (verdict: "ACCEPT" | "REJECT") => ({ + [reviewID]: { output: { verdict } }, + }) + const accepted = evaluateCondition(condition, output("ACCEPT")) + const rejected = evaluateCondition(condition, output("REJECT")) + return accepted.ok && accepted.value && rejected.ok && !rejected.value +} + function hasReviewResultSchema(schema: Record | undefined) { if (!schema || schema.type !== "object") return false const required = schema.required @@ -242,6 +318,12 @@ function hasPassVerdict(value: unknown): boolean { return "verdict" in value && value.verdict === "PASS" } +function reviewVerdict(value: unknown): "ACCEPT" | "REJECT" | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined + if (!("verdict" in value)) return undefined + return value.verdict === "ACCEPT" || value.verdict === "REJECT" ? value.verdict : undefined +} + function dependsTransitively(config: WorkflowConfig, startID: string, targetID: string) { const visited = new Set() const visit = (nodeID: string): boolean => { diff --git a/packages/opencode/src/dag/runtime/loop.ts b/packages/opencode/src/dag/runtime/loop.ts index 860b6cf29..def308e10 100644 --- a/packages/opencode/src/dag/runtime/loop.ts +++ b/packages/opencode/src/dag/runtime/loop.ts @@ -16,6 +16,7 @@ import { reviewContractForNode, validateReviewExecutionInput, reviewEvidenceKeys, + unresolvedReviewOutcomes, } from "../review-lifecycle" import { Agent } from "@/agent/agent" import { Session } from "@/session/session" @@ -252,7 +253,8 @@ export const layer = Layer.effect( // cancellation handler can reach this point before WorkflowReplanned // rebuilds the in-memory graph. Durable active nodes prove that the // apparent completion belongs to an obsolete graph generation. - const hasUnseenActiveNode = (yield* store.getNodes(dagID)).some( + const nodes = yield* store.getNodes(dagID) + const hasUnseenActiveNode = nodes.some( (node) => !isNodeTerminalStatus(node.status as never) && !entry.runtime.containsNode(node.id), ) if (hasUnseenActiveNode) return @@ -265,6 +267,13 @@ export const layer = Layer.effect( yield* dag.fail(dagID, `required node(s) failed: ${entry.runtime.getRequiredFailures().join(", ")}`) return } + const unresolvedReviews = entry.config + ? unresolvedReviewOutcomes(entry.config, nodes) + : [] + if (unresolvedReviews.length > 0) { + yield* dag.fail(dagID, `unresolved review outcome(s): ${unresolvedReviews.join(", ")}`) + return + } yield* dag.complete(dagID) }) diff --git a/packages/opencode/src/dag/runtime/summary-publisher.ts b/packages/opencode/src/dag/runtime/summary-publisher.ts index 0f1af5bf0..2860bfe69 100644 --- a/packages/opencode/src/dag/runtime/summary-publisher.ts +++ b/packages/opencode/src/dag/runtime/summary-publisher.ts @@ -61,8 +61,8 @@ export const layer = Layer.effect( const state = yield* InstanceState.make( Effect.fn("DagSummaryPublisher.state")(function* (ctx) { const scope = yield* Scope.Scope - // Per-session debounce map. Keys are sessionIDs with an in-flight - // recompute; a bounded window collapses a burst into one read. + // Per workspace/session debounce map. Keys identify a routed session + // with an in-flight recompute; a bounded window collapses a burst. // // NOTE: This is request-coalescing state for I/O deduplication, NOT // a parallel projection of DAG state. The summary is always recomputed @@ -72,19 +72,20 @@ export const layer = Layer.effect( // reads would occur. This satisfies the "stateless derived view" // contract: no cached summary is ever served from this map. const pending = new Set() - // Second coalescing tier keyed by dagID: node events don't carry a - // sessionID, and resolving it eagerly meant one getWorkflow query PER - // EVENT before the debounce window could absorb the burst (P1-4). - // Coalesce by dagID first, resolve the sessionID once after the - // window, then hand off to the session-level debounce. + // Second coalescing tier keyed by workspace/dagID: node events don't + // carry a sessionID, and resolving it eagerly meant one getWorkflow + // query PER EVENT before the debounce window could absorb the burst + // (P1-4). Resolve the sessionID once after the window, then hand off + // to the workspace/session debounce. const pendingByDag = new Set() - const publishForSession = (sessionID: string) => + const publishForSession = (sessionID: string, workspace: string | undefined) => Effect.gen(function* () { const summaries = yield* store.getWorkflowSummaries(sessionID) GlobalBus.emit("event", { directory: ctx.directory, project: ctx.project.id, + workspace, payload: { type: "dag.workflow.summary.updated", properties: { sessionID, summaries }, @@ -92,44 +93,52 @@ export const layer = Layer.effect( }) }) - const schedulePublish = (sessionID: string) => + const schedulePublish = (sessionID: string, workspace: string | undefined) => Effect.gen(function* () { - // Coalesce: if a recompute is already scheduled for this session, + const key = `${workspace ?? ""}\0${sessionID}` + // Coalesce: if a recompute is already scheduled for this route, // let it absorb this trigger rather than queueing a second read. // The coalesced early return MUST NOT touch `pending` — only the // owning fiber clears its own slot, otherwise a coalesced caller // would delete the owner's entry and reopen the window. - if (pending.has(sessionID)) return - pending.add(sessionID) + if (pending.has(key)) return + pending.add(key) yield* Effect.gen(function* () { yield* Effect.sleep("50 millis") - yield* publishForSession(sessionID) - }).pipe(Effect.ensuring(Effect.sync(() => pending.delete(sessionID)))) + yield* publishForSession(sessionID, workspace) + }).pipe(Effect.ensuring(Effect.sync(() => pending.delete(key)))) }) - const schedulePublishByDag = (dagID: string) => + const schedulePublishByDag = (dagID: string, workspace: string | undefined) => Effect.gen(function* () { - if (pendingByDag.has(dagID)) return - pendingByDag.add(dagID) + const key = `${workspace ?? ""}\0${dagID}` + if (pendingByDag.has(key)) return + pendingByDag.add(key) yield* Effect.gen(function* () { yield* Effect.sleep("50 millis") const wf = yield* store.getWorkflow(dagID) + if (wf?.projectId !== ctx.project.id) return // Hand off to the session-level debounce (not publishForSession // directly) so a concurrent session-keyed window absorbs this // trigger instead of producing a duplicate read. - if (wf) yield* schedulePublish(wf.sessionId) - }).pipe(Effect.ensuring(Effect.sync(() => pendingByDag.delete(dagID)))) + yield* schedulePublish(wf.sessionId, workspace) + }).pipe(Effect.ensuring(Effect.sync(() => pendingByDag.delete(key)))) }) const unsubscribe = yield* events.listen((evt) => { if (!SUMMARY_TRIGGER_EVENTS.some((def) => def.type === evt.type)) return Effect.void - const data = evt.data as { dagID: string; sessionID?: string } - const publish = data.sessionID - ? schedulePublish(data.sessionID) - : schedulePublishByDag(data.dagID) - return publish.pipe( + if (evt.location?.directory !== ctx.directory) return Effect.void + const data = evt.data + if ( + typeof data !== "object" + || data === null + || !("dagID" in data) + || typeof data.dagID !== "string" + ) return Effect.void + const dagID = data.dagID + return schedulePublishByDag(dagID, evt.location.workspaceID).pipe( Effect.catchCause((cause) => - Effect.logWarning("DagSummaryPublisher: failed to publish summaries", { dagID: data.dagID, cause }), + Effect.logWarning("DagSummaryPublisher: failed to publish summaries", { dagID, cause }), ), Effect.forkIn(scope), Effect.asVoid, diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/dag.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/dag.ts index e5cf5c192..f82a2732c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/dag.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/dag.ts @@ -5,6 +5,7 @@ import { InvalidRequestError, ConflictError, notFound } from "../errors" import { Dag } from "@/dag/dag" import { Session } from "@/session/session" import { SessionID } from "@/session/schema" +import { InstanceState } from "@/effect/instance-state" import { InvalidTransitionError, TerminalViolationError } from "@opencode-ai/core/dag/core/types" import type { DagStore } from "@opencode-ai/core/dag/store" @@ -12,7 +13,11 @@ import type { DagStore } from "@opencode-ai/core/dag/store" function mapTransitionConflict(effect: Effect.Effect) { return effect.pipe( Effect.catch((error: unknown) => { - if (error instanceof InvalidTransitionError || error instanceof TerminalViolationError) { + if ( + error instanceof InvalidTransitionError + || error instanceof TerminalViolationError + || error instanceof Dag.ReviewGateError + ) { return Effect.fail(new ConflictError({ message: error.message, resource: "workflow" })) } // Any other Error is re-thrown as a defect (truly unexpected — surfaces as 500). @@ -63,17 +68,42 @@ export const dagHandlers = HttpApiBuilder.group(InstanceHttpApi, "dag", (handler ...(r.completedAt !== null ? { completed_at: r.completedAt } : {}), }) + const workflowInProject = Effect.fn("DagHttpApi.workflowInProject")(function* (dagID: string) { + const row = yield* dag.store.getWorkflow(dagID).pipe(Effect.orDie) + if (row?.projectId !== (yield* InstanceState.context).project.id) return undefined + return row + }) + + const requireWorkflow = Effect.fn("DagHttpApi.requireWorkflow")(function* (dagID: string) { + const row = yield* workflowInProject(dagID) + if (!row) return yield* Effect.fail(notFound(`Workflow not found: ${dagID}`)) + return row + }) + + const requireSession = Effect.fn("DagHttpApi.requireSession")(function* (sessionID: string) { + const session = yield* sessions.get(SessionID.make(sessionID)).pipe( + Effect.catch(() => Effect.fail(notFound(`Session not found: ${sessionID}`))), + ) + if (session.projectID !== (yield* InstanceState.context).project.id) { + return yield* Effect.fail(notFound(`Session not found: ${sessionID}`)) + } + return session + }) + const list = Effect.fn("DagHttpApi.list")(function* () { const rows = yield* dag.store.listWorkflows().pipe(Effect.orDie) - return rows.map(wf) + const projectID = (yield* InstanceState.context).project.id + return rows.filter((row) => row.projectId === projectID).map(wf) }) const bySession = Effect.fn("DagHttpApi.bySession")(function* (ctx: { params: { sessionID: string } }) { + yield* requireSession(ctx.params.sessionID) const rows = yield* dag.store.listBySession(ctx.params.sessionID).pipe(Effect.orDie) return rows.map(wf) }) const summary = Effect.fn("DagHttpApi.summary")(function* (ctx: { params: { sessionID: string } }) { + yield* requireSession(ctx.params.sessionID) const summaries = yield* dag.store.getWorkflowSummaries(ctx.params.sessionID).pipe(Effect.orDie) return summaries.map((s) => ({ id: s.id, @@ -89,17 +119,17 @@ export const dagHandlers = HttpApiBuilder.group(InstanceHttpApi, "dag", (handler }) const detail = Effect.fn("DagHttpApi.detail")(function* (ctx: { params: { dagID: string } }) { - const row = yield* dag.store.getWorkflow(ctx.params.dagID).pipe(Effect.orDie) - if (!row) return yield* Effect.fail(notFound(`Workflow not found: ${ctx.params.dagID}`)) - return wf(row) + return wf(yield* requireWorkflow(ctx.params.dagID)) }) const nodes = Effect.fn("DagHttpApi.nodes")(function* (ctx: { params: { dagID: string } }) { + yield* requireWorkflow(ctx.params.dagID) const rows = yield* dag.store.getNodes(ctx.params.dagID).pipe(Effect.orDie) return rows.map(node) }) const nodeDetail = Effect.fn("DagHttpApi.nodeDetail")(function* (ctx: { params: { dagID: string; nodeID: string } }) { + yield* requireWorkflow(ctx.params.dagID) const row = yield* dag.store.getNode(ctx.params.dagID, ctx.params.nodeID).pipe(Effect.orDie) if (!row) return yield* Effect.fail(notFound(`Node not found: ${ctx.params.nodeID}`)) return node(row) @@ -110,9 +140,7 @@ export const dagHandlers = HttpApiBuilder.group(InstanceHttpApi, "dag", (handler if (!config || typeof config !== "object" || !Array.isArray((config as Record).nodes)) { return yield* Effect.fail(new InvalidRequestError({ message: "start requires 'config' with a 'nodes' array" })) } - const session = yield* sessions.get(SessionID.make(ctx.payload.session_id)).pipe( - Effect.catch(() => Effect.fail(notFound(`Session not found: ${ctx.payload.session_id}`))), - ) + const session = yield* requireSession(ctx.payload.session_id) const cfg = config as Dag.WorkflowConfig // Same code path as the workflow tool's start action — create validates // the config (duplicate ids / dangling deps / condition refs / ceiling) @@ -135,8 +163,7 @@ export const dagHandlers = HttpApiBuilder.group(InstanceHttpApi, "dag", (handler const op = ctx.payload.operation // Pre-check existence so non-existent workflows return 404, not a 500 defect. - const existing = yield* dag.store.getWorkflow(dagID).pipe(Effect.orDie) - if (!existing) return yield* Effect.fail(notFound(`Workflow not found: ${dagID}`)) + yield* requireWorkflow(dagID) // Control ops may fail with InvalidTransitionError/TerminalViolationError for // semantically invalid operations (e.g. pause on a completed workflow). Map those diff --git a/packages/opencode/test/dag/dag-review-lifecycle.test.ts b/packages/opencode/test/dag/dag-review-lifecycle.test.ts index c04f1d3cc..47e7daa22 100644 --- a/packages/opencode/test/dag/dag-review-lifecycle.test.ts +++ b/packages/opencode/test/dag/dag-review-lifecycle.test.ts @@ -6,6 +6,7 @@ import { validateReviewExecutionInput, validateReviewLifecycle, validateReviewResult, + unresolvedReviewOutcomes, } from "@/dag/review-lifecycle" function node(id: string, overrides: Partial = {}): NodeConfig { @@ -96,6 +97,12 @@ function validDiffFlow() { required: ["verdict", "implementation_fingerprint"], }, }), + node("final-audit", { + worker_type: "audit", + depends_on: ["review-diff"], + input_mapping: { review: "review-diff.output" }, + condition: 'review-diff.output.verdict == "ACCEPT"', + }), ] } @@ -135,7 +142,7 @@ describe("DAG review lifecycle", () => { ]))).toEqual({ valid: true, errors: [], warnings: [] }) }) - it("accepts implementation to verification PASS to diff review", () => { + it("accepts implementation to verification PASS to diff review to final audit", () => { expect(validateReviewLifecycle(workflow("deep", validDiffFlow()))).toEqual({ valid: true, errors: [], @@ -143,6 +150,45 @@ describe("DAG review lifecycle", () => { }) }) + it("rejects a deep diff review without a downstream final ACCEPT gate", () => { + const nodes = validDiffFlow().filter((item) => item.id !== "final-audit") + expect(validateReviewLifecycle(workflow("deep", nodes))).toEqual({ + valid: false, + errors: [ + "review-diff: deep diff review must feed a required final gate conditioned on verdict ACCEPT", + ], + warnings: [], + }) + }) + + it("validates explicit diff-review metadata even on a non-review worker", () => { + const nodes = validDiffFlow().filter((item) => item.id !== "final-audit") + const review = nodes[2] + if (!review) throw new Error("fixture is incomplete") + review.worker_type = "general" + expect(validateReviewLifecycle(workflow("deep", nodes))).toEqual({ + valid: false, + errors: [ + "review-diff: deep diff review must feed a required final gate conditioned on verdict ACCEPT", + ], + warnings: [], + }) + }) + + it("rejects a final gate that mentions ACCEPT but runs only for REJECT", () => { + const nodes = validDiffFlow() + const gate = nodes[3] + if (!gate) throw new Error("fixture is incomplete") + gate.condition = 'review-diff.output.verdict != "ACCEPT"' + expect(validateReviewLifecycle(workflow("deep", nodes))).toEqual({ + valid: false, + errors: [ + "review-diff: deep diff review must feed a required final gate conditioned on verdict ACCEPT", + ], + warnings: [], + }) + }) + it("reports every missing diff-review evidence edge and mapping", () => { const nodes = validDiffFlow() const review = nodes[2] @@ -319,6 +365,90 @@ describe("DAG review lifecycle", () => { }) }) + it("keeps a deep workflow from succeeding with an unresolved review outcome", () => { + const rejected = workflow("deep", validDiffFlow()) + expect(unresolvedReviewOutcomes(rejected, [ + { + id: "review-diff", + status: "completed", + output: { verdict: "REJECT", implementation_fingerprint: "sha256:revision-1" }, + }, + ])).toEqual(["review-diff"]) + expect(unresolvedReviewOutcomes(rejected, [ + { id: "review-diff", status: "skipped", output: undefined }, + { id: "final-audit", status: "skipped", output: undefined }, + ])).toEqual(["review-diff"]) + expect(unresolvedReviewOutcomes(rejected, [ + { + id: "review-diff", + status: "completed", + output: { verdict: "ACCEPT", implementation_fingerprint: "sha256:revision-1" }, + }, + { id: "final-audit", status: "skipped", output: undefined }, + ])).toEqual(["review-diff"]) + + const corrected = [ + ...rejected.nodes, + node("implement-fix", { + depends_on: ["review-diff"], + condition: 'review-diff.output.verdict == "REJECT"', + }), + node("verify-fix", { depends_on: ["implement-fix"] }), + node("review-diff-2", { + worker_type: "review", + depends_on: ["verify-fix"], + review: { + phase: "diff", + implementation_node_id: "implement-fix", + verification_node_id: "verify-fix", + }, + }), + node("final-audit-2", { + worker_type: "audit", + depends_on: ["review-diff-2"], + input_mapping: { review: "review-diff-2.output" }, + condition: 'review-diff-2.output.verdict == "ACCEPT"', + }), + ] + expect(unresolvedReviewOutcomes(workflow("deep", corrected), [ + { + id: "review-diff", + status: "completed", + output: { verdict: "REJECT", implementation_fingerprint: "sha256:revision-1" }, + }, + { + id: "review-diff-2", + status: "completed", + output: { verdict: "ACCEPT", implementation_fingerprint: "sha256:revision-2" }, + }, + { id: "final-audit-2", status: "completed", output: "audited" }, + ])).toEqual([]) + expect(unresolvedReviewOutcomes(workflow("deep", corrected), [ + { + id: "review-diff", + status: "completed", + output: { verdict: "ACCEPT", implementation_fingerprint: "sha256:revision-1" }, + }, + { id: "final-audit", status: "completed", output: "audited" }, + { id: "review-diff-2", status: "skipped", output: undefined }, + { id: "final-audit-2", status: "skipped", output: undefined }, + ])).toEqual([]) + expect(unresolvedReviewOutcomes(workflow("deep", corrected), [ + { + id: "review-diff", + status: "completed", + output: { verdict: "ACCEPT", implementation_fingerprint: "sha256:revision-1" }, + }, + { id: "final-audit", status: "completed", output: "audited" }, + { + id: "review-diff-2", + status: "completed", + output: { verdict: "REJECT", implementation_fingerprint: "sha256:revision-2" }, + }, + { id: "final-audit-2", status: "skipped", output: undefined }, + ])).toEqual(["review-diff-2"]) + }) + it("accepts a rejected-review correction wave only after reimplementation and verification", () => { const first = validDiffFlow() const correction = [ @@ -365,6 +495,12 @@ describe("DAG review lifecycle", () => { required: ["verdict", "implementation_fingerprint"], }, }), + node("final-audit-2", { + worker_type: "audit", + depends_on: ["review-diff-2"], + input_mapping: { review: "review-diff-2.output" }, + condition: 'review-diff-2.output.verdict == "ACCEPT"', + }), ] expect(validateReviewLifecycle(workflow("deep", [...first, ...correction]))).toEqual({ diff --git a/packages/opencode/test/dag/dag-summary-publisher-behavior.test.ts b/packages/opencode/test/dag/dag-summary-publisher-behavior.test.ts index 2983b64df..595f03a57 100644 --- a/packages/opencode/test/dag/dag-summary-publisher-behavior.test.ts +++ b/packages/opencode/test/dag/dag-summary-publisher-behavior.test.ts @@ -5,6 +5,7 @@ import { DagEvent } from "@opencode-ai/schema/dag-event" import { EventV2Bridge } from "@/event-v2-bridge" import { DagSummaryPublisher } from "@/dag/runtime/summary-publisher" import { GlobalBus } from "@/bus/global" +import { InstanceState } from "@/effect/instance-state" import { it, pollWithTimeout } from "../lib/effect" const ts = (n: number) => DateTime.makeUnsafe(n) @@ -12,12 +13,14 @@ const ts = (n: number) => DateTime.makeUnsafe(n) interface SummaryEmission { sessionID: string summaries: WorkflowSummary[] + workspace?: string } interface StoreControl { failures: number reads: Map lookups: Map + projects: Map sessions: Map summaries: Map } @@ -31,15 +34,16 @@ function control() { failures: 0, reads: new Map(), lookups: new Map(), + projects: new Map(), sessions: new Map(), summaries: new Map(), } satisfies StoreControl } -function workflow(id: string, sessionId: string): WorkflowRow { +function workflow(id: string, sessionId: string, projectId: string): WorkflowRow { return { id, - projectId: "global", + projectId, sessionId, title: id, status: "running", @@ -73,7 +77,7 @@ function runtime(state: StoreControl, bus: EventControl) { Effect.sync(() => { state.lookups.set(dagID, (state.lookups.get(dagID) ?? 0) + 1) const sid = state.sessions.get(dagID) - return sid ? workflow(dagID, sid) : undefined + return sid ? workflow(dagID, sid, state.projects.get(dagID) ?? "global") : undefined }), getWorkflowSummaries: (sessionID) => Effect.sync(() => { @@ -101,36 +105,51 @@ function runtime(state: StoreControl, bus: EventControl) { function startCollector() { const emissions: SummaryEmission[] = [] const handler = (event: { + workspace?: string payload?: { type?: string; properties?: { sessionID?: string; summaries?: WorkflowSummary[] } } }) => { if (event.payload?.type !== "dag.workflow.summary.updated") return emissions.push({ sessionID: event.payload.properties!.sessionID!, summaries: event.payload.properties!.summaries!, + ...(event.workspace ? { workspace: event.workspace } : {}), }) } GlobalBus.on("event", handler) return { emissions, stop: () => GlobalBus.off("event", handler) } } -function publishNodeEvents(bus: EventControl, dagID: string, count: number) { +function publishNodeEvents( + bus: EventControl, + dagID: string, + count: number, + foreignDirectory = false, + workspaceID?: string, +) { if (!bus.listener) return Effect.die(new Error("publisher listener is not ready")) - return Effect.forEach( - Array.from({ length: count }, (_, index) => index), - (index) => bus.listener!({ - type: DagEvent.NodeRegistered.type, - data: { - dagID, - nodeID: `${dagID}-node-${index}`, - name: `Node ${index}`, - workerType: "build", - dependsOn: [], - required: true, - timestamp: ts(index), - }, - } as never), - { discard: true }, - ) + return Effect.gen(function* () { + const instance = yield* InstanceState.context + yield* Effect.forEach( + Array.from({ length: count }, (_, index) => index), + (index) => bus.listener!({ + type: DagEvent.NodeRegistered.type, + data: { + dagID, + nodeID: `${dagID}-node-${index}`, + name: `Node ${index}`, + workerType: "build", + dependsOn: [], + required: true, + timestamp: ts(index), + }, + location: { + directory: foreignDirectory ? `${instance.directory}-foreign` : instance.directory, + ...(workspaceID ? { workspaceID } : {}), + }, + } as never), + { discard: true }, + ) + }) } function withCollector(use: (collector: ReturnType) => Effect.Effect) { @@ -243,4 +262,67 @@ describe("DagSummaryPublisher behavior", () => { }), ).pipe(Effect.provide(runtime(state, bus))) }) + + it.instance("ignores DAG events emitted by another project instance", () => { + const state = control() + const bus = {} satisfies EventControl + state.sessions.set("dag-foreign", "ses-foreign") + state.summaries.set("ses-foreign", [summary("dag-foreign", 1)]) + + return withCollector((collector) => + Effect.gen(function* () { + yield* (yield* DagSummaryPublisher.Service).init() + yield* publishNodeEvents(bus, "dag-foreign", 1, true) + yield* Effect.sleep("150 millis") + + expect(state.lookups.size).toBe(0) + expect(state.reads.size).toBe(0) + expect(collector.emissions).toEqual([]) + }), + ).pipe(Effect.provide(runtime(state, bus))) + }) + + it.instance("ignores another project even when its event uses the same directory", () => { + const state = control() + const bus = {} satisfies EventControl + state.sessions.set("dag-foreign-project", "ses-foreign-project") + state.projects.set("dag-foreign-project", "foreign-project") + state.summaries.set("ses-foreign-project", [summary("dag-foreign-project", 1)]) + + return withCollector((collector) => + Effect.gen(function* () { + yield* (yield* DagSummaryPublisher.Service).init() + yield* publishNodeEvents(bus, "dag-foreign-project", 1) + yield* Effect.sleep("150 millis") + + expect(state.lookups.get("dag-foreign-project")).toBe(1) + expect(state.reads.size).toBe(0) + expect(collector.emissions).toEqual([]) + }), + ).pipe(Effect.provide(runtime(state, bus))) + }) + + it.instance("routes summary emissions to the workspace carried by the DAG event", () => { + const state = control() + const bus = {} satisfies EventControl + state.sessions.set("dag-workspace", "ses-workspace") + state.summaries.set("ses-workspace", [summary("dag-workspace", 1)]) + + return withCollector((collector) => + Effect.gen(function* () { + yield* (yield* DagSummaryPublisher.Service).init() + yield* publishNodeEvents(bus, "dag-workspace", 1, false, "wrk-origin") + yield* pollWithTimeout( + Effect.sync(() => collector.emissions[0]), + "workspace summary was not emitted", + ) + + expect(collector.emissions[0]).toEqual({ + sessionID: "ses-workspace", + summaries: [summary("dag-workspace", 1)], + workspace: "wrk-origin", + }) + }), + ).pipe(Effect.provide(runtime(state, bus))) + }) }) diff --git a/packages/opencode/test/dag/dag-wake-integration.test.ts b/packages/opencode/test/dag/dag-wake-integration.test.ts index 7bf513acf..b5482c1d4 100644 --- a/packages/opencode/test/dag/dag-wake-integration.test.ts +++ b/packages/opencode/test/dag/dag-wake-integration.test.ts @@ -561,6 +561,104 @@ describe("DagLoop atomic wake integration", () => { ), ) + integration.live("rejects early completion while a deep diff review is unresolved", () => + runWakeTest(({ dag, store, childPrompts }) => + Effect.gen(function* () { + const brief = { + goal: "Keep explicit completion behind the deep review gate", + scope: { in: ["DAG completion"], out: ["standard workflow semantics"] }, + constraints: ["review rejection cannot become success"], + assumptions: ["the review graph is valid"], + acceptance_criteria: ["complete rejects unresolved reviews"], + evidence_required: ["integration test"], + risks: ["manual completion bypass"], + review_plan: ["verify the final ACCEPT gate"], + open_questions: [], + blocking_questions: [], + } + const dagID = yield* dag.create({ + projectID: "project-1", + sessionID: "ses_parent", + title: "Deep early completion", + config: { + name: "deep-early-completion", + mode: "deep", + admission: { + protocol_version: 1, + brief_revision: 1, + qa_mode: "STANDARD", + verdict: "READY", + state: "READY", + fingerprint: fingerprintBrief(brief), + brief, + }, + nodes: [ + { + ...node("implement"), + output_schema: { + type: "object", + properties: { + diff: { type: "string" }, + fingerprint: { type: "string" }, + }, + required: ["diff", "fingerprint"], + }, + }, + { + ...node("verify", ["implement"]), + output_schema: { + type: "object", + properties: { verdict: { enum: ["PASS", "FAIL"] } }, + required: ["verdict"], + }, + }, + { + ...node("review-diff", ["verify"]), + worker_type: "review", + review: { + phase: "diff", + implementation_node_id: "implement", + verification_node_id: "verify", + }, + input_mapping: { + diff: "implement.output.diff", + implementation_fingerprint: "implement.output.fingerprint", + verification: "verify.output", + }, + condition: 'verify.output.verdict == "PASS"', + output_schema: { + type: "object", + properties: { + verdict: { enum: ["ACCEPT", "REJECT"] }, + implementation_fingerprint: { type: "string" }, + }, + required: ["verdict", "implementation_fingerprint"], + }, + }, + { + ...node("final-audit", ["review-diff"]), + worker_type: "audit", + input_mapping: { review: "review-diff.output" }, + condition: 'review-diff.output.verdict == "ACCEPT"', + }, + ], + }, + }) + + yield* takeWithin(childPrompts, "implementation did not start") + const completion = yield* dag.complete(dagID).pipe( + Effect.as(undefined), + Effect.catch((error) => Effect.succeed(error)), + ) + expect(completion).toBeInstanceOf(Error) + if (!(completion instanceof Error)) throw new Error("deep completion unexpectedly succeeded") + expect(completion.message).toContain("unresolved review outcome") + expect((yield* store.getWorkflow(dagID))?.status).toBe("running") + expect((yield* store.getNode(dagID, "review-diff"))?.status).toBe("pending") + }), + ), + ) + integration.live("keeps a completed non-reporting leaf workflow terminal", () => runWakeTest(({ dag, store, childPrompts }) => Effect.gen(function* () { @@ -948,6 +1046,151 @@ describe("DagLoop atomic wake integration", () => { ) }) + it("fails a recovered deep workflow when verification skips every diff review", async () => { + await Effect.runPromise( + runWakeTest( + ({ store, parentPrompts }) => + Effect.gen(function* () { + const workflow = yield* pollWithTimeout( + store.getWorkflow("dag_recovered_review_rejection").pipe( + Effect.map((row) => row?.status === "failed" ? row : undefined), + ), + "recovered workflow without an accepted review did not fail", + ) + expect((yield* store.getNode(workflow.id, "review-diff"))?.status).toBe("skipped") + expect((yield* store.getNode(workflow.id, "final-audit"))?.status).toBe("skipped") + + const parent = yield* takeWithin(parentPrompts, "review rejection failure did not wake the parent") + expect(promptText(parent.input)).toContain( + '[DAG Workflow failed] Workflow "Recovered review rejection" has reached terminal status.', + ) + yield* Deferred.succeed(parent.release, "success") + }), + ({ database }) => + database.db.transaction((tx) => + Effect.gen(function* () { + const nodes = [ + { + ...node("implement"), + output_schema: { + type: "object", + properties: { + diff: { type: "string" }, + fingerprint: { type: "string" }, + }, + required: ["diff", "fingerprint"], + }, + }, + { + ...node("verify", ["implement"]), + output_schema: { + type: "object", + properties: { verdict: { enum: ["PASS", "FAIL"] } }, + required: ["verdict"], + }, + }, + { + ...node("review-diff", ["verify"]), + worker_type: "review", + review: { + phase: "diff" as const, + implementation_node_id: "implement", + verification_node_id: "verify", + }, + input_mapping: { + diff: "implement.output.diff", + implementation_fingerprint: "implement.output.fingerprint", + verification: "verify.output", + }, + condition: 'verify.output.verdict == "PASS"', + output_schema: { + type: "object", + properties: { + verdict: { enum: ["ACCEPT", "REJECT"] }, + implementation_fingerprint: { type: "string" }, + }, + required: ["verdict", "implementation_fingerprint"], + }, + }, + { + ...node("final-audit", ["review-diff"]), + worker_type: "audit", + input_mapping: { review: "review-diff.output" }, + condition: 'review-diff.output.verdict == "ACCEPT"', + }, + ] + yield* tx.insert(WorkflowTable).values({ + id: "dag_recovered_review_rejection", + project_id: "project-1" as never, + session_id: "ses_parent" as never, + title: "Recovered review rejection", + status: "running", + config: JSON.stringify({ + name: "dag_recovered_review_rejection", + mode: "deep", + nodes, + }), + seq: 10, + wake_reported: false, + }).run() + yield* tx.insert(WorkflowNodeTable).values([ + { + id: "implement", + workflow_id: "dag_recovered_review_rejection", + name: "implement", + worker_type: "build", + status: "completed", + required: true, + depends_on: [], + output: { diff: "diff --git a/a b/a", fingerprint: "fp-1" }, + wake_eligible: false, + wake_reported: true, + seq: 6, + }, + { + id: "verify", + workflow_id: "dag_recovered_review_rejection", + name: "verify", + worker_type: "build", + status: "completed", + required: true, + depends_on: ["implement"], + output: { verdict: "FAIL" }, + wake_eligible: false, + wake_reported: true, + seq: 5, + }, + { + id: "review-diff", + workflow_id: "dag_recovered_review_rejection", + name: "review-diff", + worker_type: "review", + status: "pending", + required: true, + depends_on: ["verify"], + wake_eligible: false, + wake_reported: false, + seq: 4, + }, + { + id: "final-audit", + workflow_id: "dag_recovered_review_rejection", + name: "final-audit", + worker_type: "audit", + status: "pending", + required: true, + depends_on: ["review-diff"], + wake_eligible: false, + wake_reported: false, + seq: 3, + }, + ]).run() + }), + ).pipe(Effect.orDie), + ), + ) + }) + it("wakes at paused and stepping decision boundaries", async () => { await Effect.runPromise( runWakeTest(({ dag, store, childPrompts, parentPrompts, parentSettled }) => diff --git a/packages/opencode/test/dag/dag-workflow-lock.test.ts b/packages/opencode/test/dag/dag-workflow-lock.test.ts new file mode 100644 index 000000000..58c80a5ea --- /dev/null +++ b/packages/opencode/test/dag/dag-workflow-lock.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "bun:test" +import { Effect, Layer } from "effect" +import { DagStore } from "@opencode-ai/core/dag/store" +import { Dag } from "@/dag/dag" +import { EventV2Bridge } from "@/event-v2-bridge" + +describe("Dag.Service workflow lock", () => { + it("serializes concurrent extend operations for the same workflow", async () => { + let activeReads = 0 + let maxActiveReads = 0 + const config = JSON.stringify({ name: "lock-test", nodes: [] }) + const store = Layer.mock(DagStore.Service, { + getWorkflow: () => + Effect.gen(function* () { + yield* Effect.sync(() => { + activeReads += 1 + maxActiveReads = Math.max(maxActiveReads, activeReads) + }) + yield* Effect.sleep("25 millis").pipe( + Effect.ensuring( + Effect.sync(() => { + activeReads -= 1 + }), + ), + ) + return { id: "wf1", status: "running", config } + }) as never, + getNodes: () => Effect.succeed([]) as never, + }) + const events = Layer.succeed( + EventV2Bridge.Service, + EventV2Bridge.Service.of({ + publish: () => Effect.succeed({ seq: 1 }), + } as never), + ) + const layer = Dag.layer.pipe(Layer.provide(events), Layer.provide(store)) + + await Effect.runPromise( + Effect.gen(function* () { + const dag = yield* Dag.Service + const node = (id: string) => ({ + id, + name: id, + worker_type: "build", + depends_on: [], + required: true, + prompt_template: { inline: id }, + }) + + yield* Effect.all( + [dag.extend("wf1", [node("first")]), dag.extend("wf1", [node("second")])], + { concurrency: "unbounded" }, + ) + + expect(maxActiveReads).toBe(1) + }).pipe(Effect.provide(layer)) as Effect.Effect, + ) + }) +}) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index a685537e1..f25390c1b 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -1920,6 +1920,142 @@ const scenarios: Scenario[] = [ }), ), + // Project-isolation regressions: the instance API must treat foreign + // sessions/workflows as nonexistent even though they share the same DB. + http.protected + .get("/dag", "dag.list.isolation") + .seeded((ctx) => + ctx.foreignDag({ + title: "Foreign list workflow", + nodes: [{ id: "foreign", name: "Foreign", worker_type: "general", depends_on: [], required: true }], + }), + ) + .jsonEffect(200, (body, ctx) => + Effect.sync(() => { + array(body) + check( + !body.some((item) => isRecord(item) && item.id === ctx.state.dagID), + "dag.list must not expose workflows owned by another project", + ) + }), + ), + + http.protected + .get("/dag/{dagID}", "dag.detail.isolation") + .seeded((ctx) => + ctx.foreignDag({ + title: "Foreign detail workflow", + nodes: [{ id: "foreign", name: "Foreign", worker_type: "general", depends_on: [], required: true }], + }), + ) + .at((ctx) => ({ + path: route("/dag/{dagID}", { dagID: ctx.state.dagID }), + headers: ctx.headers(), + })) + .status(404), + + http.protected + .get("/dag/session/{sessionID}", "dag.bySession.isolation") + .seeded((ctx) => + ctx.foreignDag({ + title: "Foreign session workflows", + nodes: [{ id: "foreign", name: "Foreign", worker_type: "general", depends_on: [], required: true }], + }), + ) + .at((ctx) => ({ + path: route("/dag/session/{sessionID}", { sessionID: ctx.state.sessionID }), + headers: ctx.headers(), + })) + .status(404), + + http.protected + .get("/dag/session/{sessionID}/summary", "dag.summary.isolation") + .seeded((ctx) => + ctx.foreignDag({ + title: "Foreign session summary", + nodes: [{ id: "foreign", name: "Foreign", worker_type: "general", depends_on: [], required: true }], + }), + ) + .at((ctx) => ({ + path: route("/dag/session/{sessionID}/summary", { sessionID: ctx.state.sessionID }), + headers: ctx.headers(), + })) + .status(404), + + http.protected + .get("/dag/{dagID}/nodes", "dag.nodes.isolation") + .seeded((ctx) => + ctx.foreignDag({ + title: "Foreign workflow nodes", + nodes: [{ id: "foreign", name: "Foreign", worker_type: "general", depends_on: [], required: true }], + }), + ) + .at((ctx) => ({ + path: route("/dag/{dagID}/nodes", { dagID: ctx.state.dagID }), + headers: ctx.headers(), + })) + .status(404), + + http.protected + .get("/dag/{dagID}/nodes/{nodeID}", "dag.nodeDetail.isolation") + .seeded((ctx) => + ctx.foreignDag({ + title: "Foreign workflow node detail", + nodes: [{ id: "foreign", name: "Foreign", worker_type: "general", depends_on: [], required: true }], + }), + ) + .at((ctx) => ({ + path: route("/dag/{dagID}/nodes/{nodeID}", { dagID: ctx.state.dagID, nodeID: "foreign" }), + headers: ctx.headers(), + })) + .status(404), + + http.protected + .post("/dag/{dagID}/control", "dag.control.isolation") + .mutating() + .seeded((ctx) => + ctx.foreignDag({ + title: "Foreign control workflow", + nodes: [{ id: "foreign", name: "Foreign", worker_type: "general", depends_on: [], required: true }], + }), + ) + .at((ctx) => ({ + path: route("/dag/{dagID}/control", { dagID: ctx.state.dagID }), + headers: ctx.headers(), + body: { operation: "pause" }, + })) + .status(404), + + http.protected + .post("/dag", "dag.start.isolation") + .mutating() + .seeded((ctx) => + ctx.foreignDag({ + title: "Foreign start owner", + nodes: [{ id: "foreign", name: "Foreign", worker_type: "general", depends_on: [], required: true }], + }), + ) + .at((ctx) => ({ + path: "/dag", + headers: ctx.headers(), + body: { + session_id: ctx.state.sessionID, + title: "Cross-project start", + config: { + name: "cross-project-start", + nodes: [{ + id: "n1", + name: "N1", + worker_type: "general", + depends_on: [], + required: true, + prompt_template: { inline: "noop" }, + }], + }, + }, + })) + .status(404), + // 404 scenarios (pre-existing, retained) http.protected .get("/dag/{dagID}", "dag.detail") @@ -1928,7 +2064,7 @@ const scenarios: Scenario[] = [ http.protected .get("/dag/{dagID}/nodes", "dag.nodes") .at(() => ({ path: route("/dag/{dagID}/nodes", { dagID: "dag_nonexistent" }), headers: {} as Record })) - .json(200, (body) => { array(body); check(body.length === 0, "nonexistent workflow should have no nodes") }), + .status(404), http.protected .get("/dag/{dagID}/nodes/{nodeID}", "dag.nodeDetail") .at(() => ({ path: route("/dag/{dagID}/nodes/{nodeID}", { dagID: "dag_nonexistent", nodeID: "n1" }), headers: {} as Record })) diff --git a/packages/opencode/test/server/httpapi-exercise/runner.ts b/packages/opencode/test/server/httpapi-exercise/runner.ts index 188be6c9b..0a84cd5cb 100644 --- a/packages/opencode/test/server/httpapi-exercise/runner.ts +++ b/packages/opencode/test/server/httpapi-exercise/runner.ts @@ -1,19 +1,21 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" +import { Database } from "@opencode-ai/core/database/database" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Cause, Duration, Effect, Layer, Scope } from "effect" import { TestLLMServer } from "../../lib/llm-server" -import type { Config } from "../../../src/config/config" -import type { MessageV2 } from "../../../src/session/message-v2" -import { MessageID, PartID } from "../../../src/session/schema" +import { MessageID, PartID, SessionID } from "../../../src/session/schema" import { call, callAuthProbe, disposeApps } from "./backend" import { original } from "./environment" import { runtime } from "./runtime" import type { ActiveScenario, Options, ProjectOptions, Result, Scenario, ScenarioContext, SeededContext, DagNodeSeed } from "./types" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -import type { SessionID } from "../../../src/session/schema" export function runScenario(options: Options) { return (scenario: Scenario) => { @@ -189,6 +191,7 @@ function withContext( llmWait: (count) => Effect.suspend(() => llm().wait(count)), tuiRequest: (request) => Effect.sync(() => modules.Tui.submitTuiRequest(request)), dag: (input) => run(createDagFixture(input.sessionID, input.title, input.nodes)), + foreignDag: (input) => run(createForeignDagFixture(input.title, input.nodes)), } yield* trace(options, scenario, `${label} seed start`) const state = yield* scenario.seed(base) @@ -333,3 +336,43 @@ function createDagFixture(sessionID: SessionID, title: string | undefined, nodes return { dagID, sessionID } as const }) } + +function createForeignDagFixture(title: string | undefined, nodes: DagNodeSeed[]) { + return Effect.gen(function* () { + const modules = yield* Effect.promise(() => runtime()) + const dag = yield* modules.Dag.Service + const { db } = yield* Database.Service + const projectID = Project.ID.make("prj_httpapi_foreign") + const sessionID = SessionID.make("ses_httpapi_foreign") + yield* db.insert(ProjectTable).values({ + id: projectID, + worktree: AbsolutePath.make("/foreign-httpapi-project"), + sandboxes: [], + }).onConflictDoNothing().run().pipe(Effect.orDie) + yield* db.insert(SessionTable).values({ + id: sessionID, + project_id: projectID, + slug: "foreign-httpapi", + directory: AbsolutePath.make("/foreign-httpapi-project"), + title: "Foreign HTTP API session", + version: "test", + }).run().pipe(Effect.orDie) + const dagID = yield* dag.create({ + projectID, + sessionID, + title: title ?? "Foreign DAG fixture", + config: { + name: title ?? "Foreign DAG fixture", + nodes: nodes.map((item) => ({ + id: item.id, + name: item.name, + worker_type: item.worker_type, + depends_on: item.depends_on, + required: item.required, + prompt_template: { inline: item.id }, + })), + }, + }).pipe(Effect.orDie) + return { dagID, sessionID } as const + }) +} diff --git a/packages/opencode/test/server/httpapi-exercise/types.ts b/packages/opencode/test/server/httpapi-exercise/types.ts index 7914a8227..5eed5b5b6 100644 --- a/packages/opencode/test/server/httpapi-exercise/types.ts +++ b/packages/opencode/test/server/httpapi-exercise/types.ts @@ -1,10 +1,8 @@ import type { Duration, Effect } from "effect" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { SessionV1 } from "@opencode-ai/core/v1/session" -import type { Config } from "../../../src/config/config" import type { Project } from "../../../src/project/project" import type { Worktree } from "../../../src/worktree" -import type { MessageV2 } from "../../../src/session/message-v2" import type { SessionID } from "../../../src/session/schema" export const OpenApiMethods = ["get", "post", "put", "delete", "patch"] as const export const Methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const @@ -67,6 +65,8 @@ export type ScenarioContext = { tuiRequest: (request: { path: string; body: unknown }) => Effect.Effect /** Create a DAG workflow owned by sessionID with the given node configs. Returns the dagID. */ dag: (input: { sessionID: SessionID; title?: string; nodes: DagNodeSeed[] }) => Effect.Effect + /** Create a DAG workflow under a different project in the shared database. */ + foreignDag: (input: { title?: string; nodes: DagNodeSeed[] }) => Effect.Effect } /** Scenario context after `.seeded(...)`; `state` preserves the seed return type in the DSL. */ diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 96b35af91..50a83222b 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -262,6 +262,7 @@ export const { // WorkflowSummary[] for a session whenever any dag.* event changes // its visible progress. We just store it — no client-side aggregation. case "dag.workflow.summary.updated": + if (workspace !== undefined && workspace !== project.workspace.current()) break setStore("dag", event.properties.sessionID, event.properties.summaries) break diff --git a/packages/tui/test/cli/cmd/tui/sync-dag.test.tsx b/packages/tui/test/cli/cmd/tui/sync-dag.test.tsx index 3b7c75152..7008dc42b 100644 --- a/packages/tui/test/cli/cmd/tui/sync-dag.test.tsx +++ b/packages/tui/test/cli/cmd/tui/sync-dag.test.tsx @@ -8,10 +8,11 @@ import type { DagWorkflowSummary } from "@opencode-ai/sdk/v2" const sid = "ses_dag_1" -function summaryEvent(summaries: DagWorkflowSummary[], sessionID = sid): GlobalEvent { +function summaryEvent(summaries: DagWorkflowSummary[], sessionID = sid, workspace?: string): GlobalEvent { return { directory, project: "proj_test", + ...(workspace ? { workspace } : {}), payload: { id: `evt_dag_summary_${Date.now()}_${Math.random()}`, type: "dag.workflow.summary.updated", @@ -74,6 +75,24 @@ describe("tui sync dag slice", () => { } }) + test("ignores summary events routed to another workspace", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const { app, emit, project, sync } = await mount(undefined, tmp.path) + + try { + project.workspace.set("wrk-current") + emit(summaryEvent([summary(1, 2)], sid, "wrk-foreign")) + await Bun.sleep(50) + expect(sync.data.dag[sid] ?? []).toEqual([]) + + emit(summaryEvent([summary(2, 2)], sid, "wrk-current")) + await wait(() => sync.data.dag[sid]?.[0]?.completedNodes === 2) + } finally { + app.renderer.destroy() + } + }) + test("bootstrap fetches GET /dag/session/:sid/summary as the baseline safety net", async () => { await using tmp = await tmpdir() await Bun.write(`${tmp.path}/kv.json`, "{}")