diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 04ff0a7295..9484aac7ab 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -2,7 +2,7 @@ # 🧪 CI · Test # ---------------------------------------------------------------------------- # Purpose : Run unit + Playwright e2e tests across Linux & Windows -# Trigger : Push to `main`/`dev`, PRs targeting `main`, manual dispatch +# Trigger : Push to `main`/`dev`, PRs targeting `main` and `dev`, manual dispatch # Jobs : unit — `bun turbo test` + config_assistant Go tests on linux # only (windows dropped — see # unit-tests matrix comment; free windows-latest runners @@ -10,8 +10,10 @@ # e2e — Playwright chromium on linux + windows (matrix) # Gate : Required status check on the `main` ruleset — full suite gates # dev → main PRs. Pushes to `dev` also get a full run (dev is the -# integration/testing branch), but feat/fix → dev PRs are gated by -# typecheck only (see ci-typecheck.yml) to keep CI budget sane. +# integration/testing branch). feat/fix → dev PRs run the unit +# matrix as a required check (#370: the Typecheck-only gate let an +# assertion-level regression merge and keep dev red for 75min); +# E2E stays push-on-dev + dev→main only to keep CI budget sane. # Notes : `cancel-in-progress: false` — every main/dev push gets a full run # No trigger on feat/* or fix/* (frequent changes). # ============================================================================ @@ -26,6 +28,7 @@ on: pull_request: branches: - main + - dev workflow_dispatch: concurrency: diff --git a/.github/workflows/specgit-accept.yml b/.github/workflows/specgit-accept.yml new file mode 100644 index 0000000000..ff9080efd3 --- /dev/null +++ b/.github/workflows/specgit-accept.yml @@ -0,0 +1,100 @@ +name: SpecGit Acceptance + +on: + pull_request: + # Delivery PRs target dev (fast-integration layer) and are promoted to + # main via the release PR — main's legacy branch protection also requires + # the SpecGit Acceptance check, so the verdict must run on both targets. + # dev→main promotion stays governed by the protect-main Ruleset's four + # required checks. + branches: [dev, main] + +permissions: + contents: read + +jobs: + specgit-acceptance: + name: SpecGit Acceptance + runs-on: ubuntu-latest + # Must exceed the slowest required sibling (Unit Tests (linux) runs + # ~28min on PRs): the verdict waits for every policy check to reach a + # terminal state before evaluating. + timeout-minutes: 45 + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Check out the PR head branch by name so HEAD is on the branch + # (not the detached merge ref): the execution context gate reads + # live git. Falls back to the default ref on non-PR events. + ref: ${{ github.head_ref || github.ref }} + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + + # This repo is a bun workspace and does not vendor the SpecGit CLI; + # install the published CLI instead of building from source. Pinned + # with a caret floor (#366): the CLI releases multiple times a day and + # an unpinned install would let an unnoticed upstream change flip CI + # acceptance verdicts repo-wide. + - name: Install specgit CLI + run: npm install -g specgit@^0.5.0 + + - name: Wait for sibling checks + # The verdict must see the OTHER required checks in a terminal + # state. Sibling jobs start in parallel AND may not have registered + # their check-runs yet, so an empty poll is not "done": wait until + # every name in spec_git/policy.yaml is present with a terminal + # conclusion. This job is not in the policy, so no self-deadlock. + env: + GH_TOKEN: ${{ github.token }} + WAIT_REPO: ${{ github.repository }} + WAIT_SHA: ${{ github.event.pull_request.head.sha }} + run: | + node --input-type=module <<'EOF' + import { readFileSync } from 'node:fs'; + // Minimal parse of policy.yaml's required_checks block list — + // avoids a yaml dependency in this bun-based repo. + const policy = readFileSync('spec_git/policy.yaml', 'utf8'); + const section = policy.slice(policy.indexOf('required_checks:')); + const required = [...section.matchAll(/^\s*-\s*(.+)$/gm)].map((m) => m[1].trim()); + const headers = { + authorization: 'Bearer ' + process.env.GH_TOKEN, + accept: 'application/vnd.github+json', + }; + const url = 'https://api.github.com/repos/' + process.env.WAIT_REPO + + '/commits/' + process.env.WAIT_SHA + '/check-runs?per_page=100'; + const terminal = new Set(['completed']); + const terminalHas = (byName, name) => { + if (byName.has(name)) return terminal.has(byName.get(name)); + const retried = [...byName.keys()].find((k) => k.startsWith(name + ' (')); + return retried !== undefined && terminal.has(byName.get(retried)); + }; + // Must outlast the slowest required sibling (Unit Tests (linux) + // runs ~28min on PRs); the job timeout above bounds this too. + const deadline = Date.now() + 40 * 60 * 1000; + while (Date.now() < deadline) { + const res = await fetch(url, { headers }); + if (!res.ok) throw new Error('check-runs API ' + res.status); + const payload = await res.json(); + const byName = new Map(payload.check_runs.map((r) => [r.name, r.status])); + const missing = required.filter((n) => !terminalHas(byName, n)); + if (missing.length === 0) { + console.log('All required checks are in a terminal state.'); + process.exit(0); + } + console.log('Waiting for: ' + missing.join(', ')); + await new Promise((r) => setTimeout(r, 10000)); + } + console.error('Timed out waiting for sibling checks.'); + process.exit(1); + EOF + + - name: specgit finish + run: specgit finish --json + env: + GH_TOKEN: ${{ github.token }} diff --git a/.opencode/command/specgit-finish.md b/.opencode/command/specgit-finish.md new file mode 100644 index 0000000000..63e86e2cc3 --- /dev/null +++ b/.opencode/command/specgit-finish.md @@ -0,0 +1,27 @@ +--- +description: Run the SpecGit evidence verdict and drive the fix loop to exit 0 +--- + +# /specgit-finish + +Thin trigger for the acceptance verdict. The canonical behavior lives in the +AGENTS.md SpecGit block; this command only launches it. + +## Steps + +1. Run from the delivery branch: + + ```bash + specgit finish --json + ``` + +2. Branch on the exit code: + - `exit 0` → produce the merge brief (issues + PR + CI run links + the + verdict) and ask the user to approve the merge. Do not merge yourself + without approval. + - `exit 1` → read `errors[].fix` / gate failures, fix exactly what they + name, re-run. Loop until exit 0. + - `exit 3` → report the environment problem (gh auth / network); never + edit the record or the policy to work around it. +3. Iron rules: never weaken `spec_git/policy.yaml` to pass; `--json` is the + only parse surface; a non-zero verdict never merges. diff --git a/.opencode/command/specgit-issue.md b/.opencode/command/specgit-issue.md new file mode 100644 index 0000000000..6ec61c6e55 --- /dev/null +++ b/.opencode/command/specgit-issue.md @@ -0,0 +1,22 @@ +--- +description: Start a SpecGit delivery from a title or existing issue number +--- + +# /specgit-issue + +Thin trigger for the delivery bootstrap. The canonical behavior lives in the +AGENTS.md SpecGit block; this command only launches it. + +## Steps + +1. Collect the argument: `$ARGUMENTS` is either an issue title (create) or a + pure number (reuse). Multiple arguments = N issues in one delivery. +2. Run from the repo root: + + ```bash + specgit issue "$ARGUMENTS" --json + ``` + +3. On success report the brief: issue URL(s), PR URL (draft), branch name. +4. Switch to the delivery branch and begin the TDD loop. +5. On error, read `errors[].fix` and follow it — never bypass the record. diff --git a/.opencode/hooks/specgit-merge-guard.sh b/.opencode/hooks/specgit-merge-guard.sh new file mode 100755 index 0000000000..50a8a0d5db --- /dev/null +++ b/.opencode/hooks/specgit-merge-guard.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# SpecGit merge guard (managed by specgit init). Exit 2 = block with reason. +command=$(printf '%s' "$1" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{const j=JSON.parse(s);process.stdout.write((j.tool_input&&j.tool_input.command)||'')}catch{process.stdout.write('')}})") + +case "$command" in + gh\ pr\ merge*) + # Real-time verdict: re-evaluate the delivery before letting a merge + # through. Verdicts are never persisted, so compute one now. + if specgit finish >/dev/null 2>&1; then + exit 0 + fi + echo "specgit: merge blocked - 'specgit finish' does not exit 0 right now. Fix what the failures name; never weaken spec_git/policy.yaml to pass." >&2 + exit 2 + ;; + git\ push\ origin\ main*|git\ push\ origin\ +main*|git\ push\ origin\ HEAD:main*) + echo "specgit: direct push to main is not the delivery path. Deliveries go: specgit issue -> PR -> CI -> specgit finish (exit 0) -> merge." >&2 + exit 2 + ;; +esac +exit 0 diff --git a/.specgit.yaml b/.specgit.yaml new file mode 100644 index 0000000000..300153e43b --- /dev/null +++ b/.specgit.yaml @@ -0,0 +1,8 @@ +version: 1 +delivery: issue374 +context: + kind: branch + branch: feat/375-issue375 +issues: + - 374 +pr: 375 diff --git a/AGENTS.md b/AGENTS.md index 720f15959e..a8dd70ee60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,22 +4,22 @@ ## Git Workflow (铁律) ``` -feat/**, fix/** ──PR(Typecheck 门禁)──▶ dev ──push 触发全量测试──▶ +feat/**, fix/** ──PR(Typecheck + Unit Tests 门禁)──▶ dev ──push 触发全量测试──▶ dev ──手动 release-fork──▶ prerelease 测试版 dev ──PR(全量测试门禁)──▶ main ──手动 release-fork──▶ 正式版 ``` -**分层门禁**:`dev` 是快速集成层(仅 Typecheck),`main` 是正式质量门禁(Typecheck + 全量 Unit Tests + E2E)。所有改动通过 PR 流转,禁止直推 `main` 和 `dev`(由 GitHub Rulesets 强制)。 +**分层门禁**:`dev` 是快速集成层(Typecheck + Unit Tests (linux);E2E 不阻塞),`main` 是正式质量门禁(Typecheck + 全量 Unit Tests + E2E)。所有改动通过 PR 流转,禁止直推 `main` 和 `dev`(由 GitHub Rulesets 强制)。 | Branch | 直推 | PR 门禁 | CI 触发 | Purpose | |--------|------|---------|---------|---------| | `{type}/**` | ✅ 允许 | — | ❌ 不跑 | 开发分支,频繁变更 | -| `dev` | ❌ 禁止 | PR 必须通过 **Typecheck** | ✅ push 触发 Typecheck + 全量测试 | 快速集成层 | +| `dev` | ❌ 禁止 | PR 必须通过 **Typecheck + Unit Tests (linux)** | ✅ push 触发 Typecheck + 全量测试 | 快速集成层 | | `main` | ❌ 禁止 | PR 必须通过 **Typecheck + Unit Tests + E2E (linux + windows)** | ✅ push 触发全量 | 正式质量门禁 + 发版 | **流程**: 1. 从 `main` 切出 `feat/**` 或 `fix/**` 分支开发 -2. PR → `dev`(Typecheck 门禁,快速合并) +2. PR → `dev`(Typecheck + Unit Tests (linux) 门禁,快速合并) 3. push 到 `dev` 自动触发全量测试验证 4. 从 `dev` 手动 `release-fork` → 产出 **prerelease** 测试版 5. PR `dev` → `main`(全量测试门禁:Typecheck + Unit Tests + E2E) @@ -240,3 +240,51 @@ Triage uses the five canonical labels `needs-triage`, `needs-info`, `ready-for-a ### Domain docs This repository uses a multi-context domain-document layout rooted at `CONTEXT-MAP.md`. See `docs/agents/domain.md`. + + +## SpecGit delivery harness + +Managed by `specgit init`. Everything between the markers is rewritten on +re-init; keep manual guidance outside them. + +### The delivery story + +- Start with `specgit issue ...`: it creates or reuses + the issues, branches, opens the draft pull request that closes every + bound issue, and writes `.specgit.yaml`. Re-running resumes; it is + idempotent. +- Finish with `specgit finish`: the verdict, derived from real git, PR, + and CI evidence. Exit code 0 is the only "done". + +### Repair and diagnostics + +- `specgit pr` repairs the pull-request binding: with no arguments it + auto-discovers the pull request for this head branch, errors with a fix + when none is found, and refuses with a list when several match. +- `specgit status` shows local evidence only: record, state, drift, + origin. `specgit doctor` probes git, repository, origin, gh, and + policy. + +### Issue granularity + +One issue = one independently verifiable WHY. If a deliverable cannot be +verified on its own evidence, split it before binding. + +### Iron rules + +- `specgit finish` exit code other than 0: never request merge. Fix the + delivery, not the gate. +- Never weaken `spec_git/policy.yaml` to make a verdict pass. +- `--json` is the only parse surface: stdout is exactly one JSON + document; never scrape human-readable output. + + +## Tool-call discipline (hard rules) + +- Never fan out duplicate or near-duplicate queries. One question, one + tool call; if the answer is already in context, make zero calls. +- Parallel tool batches must contain distinct, independently justified + calls. Before sending a batch, verify no two calls answer the same + question. A repeated identical call is a bug regardless of intent. +- Long CI waits use `sleep N && `, never repeated watches + of the same resource. One watch command, one result. diff --git a/CLAUDE.md b/CLAUDE.md index 4b6352ab73..04434d7803 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,3 +175,41 @@ pushes to `main`/`dev` are blocked by GitHub Rulesets. Branch names: `{type}/{sh (`feat`, `fix`, `chore`, `docs`, `refactor`, `test`, `release`, `hotfix`), enforced by Ruleset. Commits/PR titles: conventional `type(scope): summary`. All PRs must reference an existing issue (`Fixes #N`). Curated DAG configs are owned by the `opencode-dag-config` repo. + + +## SpecGit delivery harness + +Managed by `specgit init`. Everything between the markers is rewritten on +re-init; keep manual guidance outside them. + +### The delivery story + +- Start with `specgit issue ...`: it creates or reuses + the issues, branches, opens the draft pull request that closes every + bound issue, and writes `.specgit.yaml`. Re-running resumes; it is + idempotent. +- Finish with `specgit finish`: the verdict, derived from real git, PR, + and CI evidence. Exit code 0 is the only "done". + +### Repair and diagnostics + +- `specgit pr` repairs the pull-request binding: with no arguments it + auto-discovers the pull request for this head branch, errors with a fix + when none is found, and refuses with a list when several match. +- `specgit status` shows local evidence only: record, state, drift, + origin. `specgit doctor` probes git, repository, origin, gh, and + policy. + +### Issue granularity + +One issue = one independently verifiable WHY. If a deliverable cannot be +verified on its own evidence, split it before binding. + +### Iron rules + +- `specgit finish` exit code other than 0: never request merge. Fix the + delivery, not the gate. +- Never weaken `spec_git/policy.yaml` to make a verdict pass. +- `--json` is the only parse surface: stdout is exactly one JSON + document; never scrape human-readable output. + diff --git a/docs/audit-dag-deepdive-2026-08-19.md b/docs/audit-dag-deepdive-2026-08-19.md new file mode 100644 index 0000000000..0a8822c530 --- /dev/null +++ b/docs/audit-dag-deepdive-2026-08-19.md @@ -0,0 +1,191 @@ +# 功能深挖审计(第二批):#316 触发源 / loop.ts 全量 / DAG 未审文件 / 组合根装配 / Effect v4 语义 + +深挖日期:2026-08-19 +深挖对象:`dev` HEAD = `31bd2d4eb`(含首批审计基线 `f1c2c8c33` 之后全部 45 个提交,即首批 DAG-01..04 / MEM-01..03 / GOAL-01..04 修复的落点 + PR #338 supervision sweep) +前置文档:`docs/audit-dag-memory-goal-2026-08-18.md`(首批审计,其「局限」节 5 项遗留缺口即本次深挖范围) + +## 方法与证据纪律 + +1. 五路只读 auditor 子代理并行:#316 触发源 + PR #338 sweep 审查、`loop.ts` 1738 行逐行重读、DAG 未审文件批、`packages/opencode` 之外组合根装配、effect-smol 参考实现语义查证(`Effect-TS/effect-smol@3a1128c`,`effect` 4.0.0-beta.98)。 +2. 主会话交叉复核:子代理结论之间相互矛盾处以下文「交叉修正」节为准;每条 `file:line` 均出自子代理直读当前工作区源码,关键机制由第二路独立验证。 +3. 测试未运行;并发/竞态结论为静态控制流阅读 + Effect 参考实现源码语义(不再是无依据推理,见第五路)。 + +## 首批修复复核结论 + +首批 11 项缺陷(DAG-01..04、MEM-01..03、GOAL-01..04)**全部已修或已按成文取舍处置**,修复签名逐项核对在位(`loop.ts:172` 字符串归一化、`validation.ts:621` output_schema 义务、`dag.ts:578` replanStructuralDiagnostics、`loop.ts:743` vetoHold、`summary-publisher.ts:170` interrupt 再抛、`global-lifecycle.ts:38` 有界 dispose、`memory.ts:506-529` 锁外 matcher + 后台维护、`goal/loop.ts` GOAL-01..04 各修复点、MEM-03 随 MEM-01 重构结构性抵消并记录于 `docs/findings/memory-batch-findings.md`)。首轮记忆/目标修复的正确性另由 effect-smol 语义查证反向确认(见「验证为正确」)。 + +## 交叉修正(子代理结论相互验证的关键产出) + +**NEW-1 降级**。loop.ts 重读路发现 `loop.ts:323-325`(spawn 失败边界 `catchCause` 无 `Cause.hasInterrupts` 再抛)并判为 Medium:「teardown 中断 handler fiber → 中断被转换成持久 `nodeFailed`」。effect-smol 查证路**推翻其触发机制**:`exitFailCause`(`effect.ts` internal C:528-546)在 `fiber.interruptible && fiber._interruptedCause` 时逐层丢弃错误续延——**外部中断天然绕过 catchCause**(`Effect.test.ts:1303-1321` 钉死:外部 `Fiber.interrupt` 下 catchCause 不执行);只有「自抛的 interrupt cause」(`Effect.failCause(Cause.interrupt(...))`,普通失败通道)才会被捕获。teardown 中断是外部的 → 该点不会在 dispose 期间发 `dag.nodeFailed`。残留价值为风格一致性 + 对未来自抛模式的防御,降级为 Low(收入低危批次)。 + +此修正同时巩固了 #316 判定(下节):全仓没有能在 dispose 期间发出 `dag.*` 事件的路径。 + +--- + +## #316 判定:**可以关闭**(验收项逐条对账见下) + +验收 1(exerciser 有界退出)/ 3(真实 server 关停):`db626d4ba` 有界 dispose(10s `timeoutOption`)+ publisher interrupt 再抛已落地,CI(ci-test.yml)自该提交起 dev 分支连续 success(2026-08-18 起 5+ runs)。验收 2(根因证明)由本次深挖补齐: + +1. **穷尽阅读后不存在「dispose 期间自主持续发 `dag.*` 事件的组件」**。生产代码 `dag.*` 发布点仅 `dag.ts` 命令方法(398-895,经 `withWorkflowLock`)与 `loop.ts:543`(init 期孤儿-pending 合法化);调用方要么在实例闭包内(随 teardown 死),要么是请求上下文。审计首批未读的三组件全部直读:`EventV2Bridge`(`event-v2-bridge.ts:35-62`)是纯消费者/转发器(host 级存活但自身不产生 `dag.*`);`InstanceStore.disposeAll`(`instance-store.ts:166-192`)仅发 `server.instance.disposed`;`InstanceState` scope-close(`instance-state.ts:26-51`)= ScopedCache invalidate + interrupt。 +2. **首批观察到的「警告流不止」机制唯一成立路径是 publisher 内层 exit-重抛的自抛 interrupt cause 被外层 catchCause 捕获**——即 `summary-publisher.ts:111-113` 内层刻意再抛的 interrupt 在旧外层 `catchCause`(无 hasInterrupts 检查)下变成 "failed to publish" 日志行。`db626d4ba` 已修(`summary-publisher.ts:169-173`),且该修复经 effect-smol 语义验证修的正是可捕获的那条路径(自抛 cause),行为测试注入 `Effect.failCause(Cause.interrupt(0))` 与语义吻合。 +3. **外部中断路径**(真正的 teardown)在 Effect v4 下处处绕过 catchCause → 静默死亡而非事件风暴。dispose 期间迟到事件的 publisher `forkIn` 走「已关闭 scope → fiber 生而为死」语义(`E:5196-5198`),不 defect、不残留。 +4. **PR #338 生产事故是 #316 的镜像补集,不是触发源**:事故签名是 dispose 后**彻底静默**(订阅/spawn fiber/watcher 全被收割、计数器冻结,`dag-node-supervision.test.ts:313-316` 断言冻结)——饿死,不是放大。sweep 治「节点 rot」;「工作流 rot」残留为新缺陷 SW-2(下文)。 + +验收 4(10 轮抓网):未重跑抓网脚本;以 DAG-04 修复后 dev 全量 CI 连续绿 + 机制证明替代。若维护者要求严格对账可补跑。 + +--- + +## 缺陷汇总 + +| ID | 严重性 | 置信度 | 模块 | 一句话描述 | +|---|---|---|---|---| +| F1 | High | Confirmed(静态装配链) | GOAL/装配 | GoalLoop 未接 server 请求 node 图:headless serve/web/desktop 的 standing goal 首轮后停摆 | +| F2 | Medium | Confirmed(静态装配链) | DAG/装配 | Desktop sidecar 不建 AppLayer → DagSupervisionSweep 在桌面默认路径缺席 | +| SW-1 | Medium | Confirmed(机制)/量化未复现 | DAG/sweep | freeze window 按当前 config cadence 计算,replan 下调 timeout + re-time 闸门跳过 → 活节点被提前 `nodeFailed` 误杀 | +| SW-2 | Medium | Confirmed | DAG/sweep | sweep 只 settle 节点,无 host 级工作流终局推进/wake 投递 → 工作流 rot + 父会话永不知情 | +| DAG-05 | Medium | Confirmed | DAG/httpapi | `dag.start` HTTP 路由完全绕过 Workflow Authoring(checkpoint 门/output_schema 义务/profile 检查全缺席,deep 准入门客户端自证) | +| DAG-06 | Medium | Confirmed | DAG/recovery | 崩溃恢复把无 schema 的 running 节点以 `undefined` 完成——live/恢复不对称,replan 否决裁决凭空消失 | +| DAG-07 | Medium | Confirmed | DAG/capture | `validateAgainstSchema` 对无 `type` 的 object 型 schema 放行任意类型值——DAG-01 同后果藏在 schema 写法内部 | +| BLK-01 | Medium | Confirmed | DAG/blocks | 并行 writer 聚合的「mechanical」表述失实;未申报写入零检测,逃逸 union+fingerprint 绑定 | +| INV-A | Medium | Confirmed | DAG/文档 | 「一个用户目标至多一个 live DAG」在 CONTEXT.md 是 Invariant,实现是纯 convention(无任何引擎强制) | +| LOW 批 | Low | 各条见正文 | 多模块 | NEW-1(降级)/NEW-2/REC-1/BLK-02/BLK-03/CAP-02/SW-L1/SW-L2/F3/F6/F4/F5 | + +--- + +## F1(High)GoalLoop 未接入 server 请求上下文 node 图 + +**位置**:`packages/opencode/src/goal/loop.ts:803`(`GoalLoop.node` 定义);`packages/opencode/src/server/routes/instance/httpapi/server.ts:215-300`(app 组节点清单);`packages/opencode/src/project/bootstrap.ts:80-85`(唯一消费点) + +**证据链**:`GoalLoop.Service` 全仓唯一消费点是 `bootstrap.ts:80-85`(`Effect.serviceOption(GoalLoop.Service)` + `init()`);`GoalLoop.node` 无任何图引用(全仓引用仅 `app-runtime.ts:133` / `bootstrap-runtime.ts:22` 两个 defaultLayer,而 `BootstrapRuntime` 零使用方)。httpapi `server.ts` 的请求上下文 app 组含 `Dag.node`/`Goal.node`/`Memory.node`/`SettingsHook.node`,**无 `GoalLoop.node`**。 + +**机制**:凡实例经 HTTP 请求加载(`instanceContextLayer` → node 图版 InstanceStore → bootstrap.run 在请求 fiber 上下文执行),`serviceOption(GoalLoop.Service)` 恒 None → idle 订阅与启动恢复扫描(`goal/loop.ts:688-781`)静默跳过。这正是 `server.ts:273-299` 注释里 SettingsHook/Memory(#311)刚修过的同一失败类,GoalLoop 被遗漏。 + +**受影响入口**:`opencode serve`、`opencode web`(`instance:false`,无人经 AppRuntime 加载实例);Desktop sidecar(见 F2);TUI/ACP 的**非 CWD 目录**请求(x-opencode-directory)。TUI/ACP 的 CWD 因启动副作用(`cli/cmd/tui.ts:261-263` → `worker.ts:87-90` checkUpgrade 加载 CWD 实例)碰巧被 arm。 + +**运行时影响**:headless server / web / desktop 上创建的 standing goal 在第一回合结束后停摆;崩溃恢复扫描不运行(GOAL-04 的 busy-retry 也随之缺席)。与首批 GOAL-01 的「silent stall」同类,但成因是装配缺失而非状态机缺陷。 + +**建议修法**:`GoalLoop.node` 加入 `server.ts` app 组(与 `SettingsHook.node` 同位),配 `test/server` wiring 回归断言(`server.ts:211-214` 注释的探针机制:断言请求上下文中 `GoalLoop.Service` 为 Some)。 + +## F2(Medium)Desktop sidecar 从不构建 AppLayer → DagSupervisionSweep 缺席 + +**位置**:`desktop/src/main/sidecar.ts:57-65`(直接 `import("virtual:opencode-server")` → `Server.listen`,不经 effectCmd);`packages/opencode/src/dag/runtime/supervision-sweep.ts:239-241`(sweep fiber 只在 layer 构造时 fork 进 AppLayer scope);`app-runtime.ts:140`(全仓唯一构建点) + +**机制**:sweep 是 host 级防线(2026-08-18 生产事故的直接回应),但其存活绑定在 `AppLayer` 构造上;sidecar utility process 只调 `Server.listen`,AppLayer 永不构建 → 桌面默认路径(mac/win 内置 sidecar)上超时升级节点的 deadline 监督**不运行**。WSL 路径(拉起外部 `opencode serve`)不受影响(serve 的 effectCmd 保活 AppLayer)。 + +**建议修法**:sweep 的接线从「AppLayer 独占」改为 server 组装路径可达(或 sidecar 显式构建所需 host 层);与 F1 同批修,共用 wiring 探针。 + +## SW-1(Medium)sweep freeze window 的 cadence 前提被 replan 打破 → 活节点误杀 + +**位置**:`packages/opencode/src/dag/runtime/supervision-sweep.ts:87-91`(窗口从当前持久化 config 推导);`spawn.ts:120`(活 watcher cadence 固定在 spawn/re-time 时刻);`loop.ts:1034-1037`(A1/Q2 re-time 闸门故意跳过);`dag.ts:679`(replan replace 桶可改 running 节点 timeout_ms) + +**机制**:窗口数学在 cadence 不变时成立(60s tick,needed = ⌈I/60s⌉+1,1 tick 余量)。但 replan 的 replace 桶可下调 running 节点的 `timeout_ms` 并持久化新 config,而 re-time 闸门在「deadline 未到且无 pending 升级」时**故意跳过 re-time、保留旧 watcher 旧 cadence**(N1 纪律)。此后 sweep 按新 config 算窗口、旧 watcher 按旧 cadence 动计数器:例 spawn 时 30min cadence、replan 改 10min → sweep 窗口 ≈11 ticks,父代理在 30min cadence 默许的裁决窗内、~11 分钟即被 `nodeFailed("timeout","swept")` 终局,子会话经 DagLoop handler 的 abortChild 被真实取消,进行中工作丢失。 + +**建议修法**:窗口取 `max(config cadence, DEFAULT)`;或从 durable 行(`NodeDeadlineExtended`/`NodeStarted` 的 deadline − timeout)反推实际 cadence。 + +## SW-2(Medium)sweep 只 settle 节点,不推进工作流终局——「工作流 rot」残留 + +**位置**:`supervision-sweep.ts` 全局;对照 `loop.ts:331-369`(checkCompletion/dag.fail 全在 DagLoop 内)、`loop.ts:1281+`(wake 投递)、`loop.ts:1182-1195`(automation unregister 在实例 handler 内) + +**机制**:实例已 teardown 时,sweep 的 NodeFailed 落库后没有任何 host 级角色推进工作流终局或唤醒父会话:工作流行停留 `running`、required 节点已 failed、wake 行永未投递、automation lease 注册泄漏(unregister 在已死的 handler 里)。要等实例重新 load 才由 `recoverWorkflow`(`loop.ts:1645-1654`)收敛。事故最痛的「节点 rot」被治好,「工作流 rot + 父永远不知道」还在。 + +**建议修法**:sweep settle 后追加 host 级 workflow 完成性检查 + wake 投递 + lease 清理(复用 `withWorkflowLock` 串行点),或在 sweep 判死时标记工作流需恢复、由下一实例 load 之外的路径推进。 + +## DAG-05(Medium)httpapi `dag.start` 绕过 Workflow Authoring + +**位置**:`packages/opencode/src/server/routes/instance/httpapi/handlers/dag.ts:147-168`;对照 `dag.ts:340-364` + +**证据**:payload config 是 `Schema.Unknown`,仅查 `nodes` 是数组后直接断言 `as Dag.WorkflowConfig`;`dag.create` 按设计只跑 `structuralDiagnostics`(不含 checkpoint 门与 output_schema 义务)。该豁免的安全性前提是「工具动作先过 authoring」(工具路径 `authoring.prepare(profile:"environment")` → `create`),此路由打破前提。handler 注释自称 "Same code path as the workflow tool's start action"——与事实不符。 + +**后果**:(1) 无 schema reporting checkpoint + 门控 dependent(DAG-01 危险形状)可零诊断创建——运行时字符串归一化救不了散文回复 → `condition_false` 静默跳子树、COMPLETED;(2) `mode:"deep"` 的 admission 记录可由调用端伪造(`fingerprintBrief` 是纯函数可自行计算)——deep 准入门变成客户端自证;(3) worker/model/prompt 资产解析全跳过。触发面:SDK 已生成该路由(identifier `dag.start`)、httpapi-exercise 契约演练在用;第一方 TUI 目前只用 `dag.control`。control(replan/extend)虽同样绕过 authoring,但被 `replanStructuralDiagnostics` 合并图复查兜住——唯 **start** 无任何等价复查。 + +**建议修法**:handler 内走 `authoring.prepare` + `validatePostCompile`(environment profile),或给 `dag.create` 加可选的等价校验入口;同步修正注释与 `test/server/httpapi-exercise/index.ts` 契约。 + +## DAG-06(Medium)崩溃恢复以 `undefined` 完成无 schema 的 running 节点——裁决丢失 + +**位置**:`packages/opencode/src/dag/runtime/recovery.ts:92-106`;对照 `spawn.ts:483-521`(live 路径取最后 text part 完成) + +**证据**:恢复路径判定 sessionStatus=completed 后对无 schema 节点 `nodeCompleted(dagID, node.id, undefined)`,从不回读子会话消息。崩溃窗口 = 子会话已产出终稿但 NodeCompleted 未发布。后果:(a) 无 schema checkpoint 以裸字符串 `{"verdict":"replan"}` 回复的否决在恢复后凭空消失(`loop.ts:699-704` 读 `node.output` 为 undefined)——不暂停、不告警;(b) 门控 dependent `condition_false` 跳过;(c) 下游 input_mapping 降级占位符。可达面:运行时 replan 缝显式豁免 schema 义务(`requireOutputSchema:false`)+ DAG-05 的 HTTP start,无 schema reporting 节点仍是合法可达形状。退化旁支:`parseWorkflowConfig` 返回 undefined(行损坏)时有 schema 节点也落同分支。 + +**建议修法**:恢复时回读子会话最后 assistant text part(与 live 路径对称);config 不可解析时按有 schema 处理(fail 而非 undefined 完成)。 + +## DAG-07(Medium)`validateAgainstSchema` 对无 `type` 的 object schema 放行任意值 + +**位置**:`packages/opencode/src/dag/runtime/capture.ts:96-117` + +**证据**:required/properties 检查均以 `isSchemaObject(value)` 为前提——值非 object 时**整组跳过**而非报错;`{required:[...], properties:{...}}`(无 `type:"object"`)是合法常见写法。子代理 `submit_result` 提交字符串 → `ok:true` → 以字符串完成 → 门控 dependent 字段解析 undefined → `condition_false` 静默跳子树。与 DAG-01 同后果但 checkpoint **已声明** output_schema,authoring 检查满足,缺陷藏在 schema 写法内。`{type:"object", additionalProperties:false}`(无 properties)同样不设防;未知类型名(拼错 `strng`)permissive 通过(`capture.ts:207-208`)。 + +**建议修法**:schema 含 required/properties/additionalProperties 任一 object 语义关键字时,值非 object 即 fail;未知类型名报错。 + +## BLK-01(Medium)聚合器「mechanical」表述失实 + 未申报写入零检测 + +**位置**:`packages/opencode/src/dag/blocks.ts:407-412`(注释 + AGGREGATOR_CONTRACT)、ADR-0002 + +**证据**:注释宣称 "mechanically detects declared write-set overlap"——引擎从不计算交集/并集;检测由 explore 型 LLM worker 依契约执行,fingerprint 亦是 worker 对**已申报列表**自算 sha256。盲区:(1) **丢失写入**——writer 写了文件但未申报(或写完后节点失败,nodeFailed 路径不回滚工作区编辑),这些文件不在 union、不进 fingerprint,verify/review 绑定的「合并后状态」系统性遗漏;aggregator 有 shell 权限、本可机械 `git status` 对账而契约未要求;(2) 交叠漏检时无引擎侧二次校验。LLM-worker 聚合本身是 ADR-0002 成文取舍(可接受);缺陷是保证表述失实 + 未申报写入无任何检测层。 + +**建议修法**:最小修 = 改注释/ADR 措辞为行为约定;进阶修 = aggregator 契约加 `git status` 机械对账(申报集 vs 实际变更集,差集即 fail)。 + +## INV-A(Medium)「一个用户目标至多一个 live DAG」是文档 Invariant,实现是 convention + +**证据**:`dag.create`(`dag.ts:340-434`)不检查 session 是否已有 live workflow;workflow 工具 start(`tool/workflow.ts:607-675`)同样不查;automation lease 天然容忍多 DAG 并存(逐 workflow register,`owner()` 返回「任意 dag」);跨进程无约束;崩溃恢复无差别收养。唯一「强制」在模型指引(workflow-routing.md / orchestration-policy.md)。违规后果有界(wake 模型容忍、goal 被「任一 dag」阻塞),但两个 live DAG 共享同一工作区时,ADR-0002 交给 plan discipline 的三不相交纪律跨工作流完全失配。 + +**处置选项**:(a) 引擎强制(create/工具 start 拒绝同 session 第二个 live workflow,或警告);(b) CONTEXT.md 降格为 convention 并写明后果。二选一,消除分歧。 + +--- + +## 低危批次(LOW) + +| ID | 位置 | 机制 | +|---|---|---| +| NEW-1(降级自 Medium) | `loop.ts:323-325` | spawn 失败边界 catchCause 无 hasInterrupts 再抛。外部中断经 Effect 语义绕过 catchCause,teardown 场景不触发;残留为风格一致性 + 对自抛 interrupt cause 模式的防御(对照同文件 730-737/1055-1062/1483-1490 惯例) | +| NEW-2 | `loop.ts:431-451` | recovery-pause 被**非终态原因**(30s 锁超时/store defect)拒绝后收养被放弃:NodeFailed 已持久化但无 runtime entry → 事件被 `runtimes.has` 过滤(802)、wake 边界要求 entry(1244)→ 静默搁死至重启(仅一条 WARN)。对照 verdict 门同型场景有两次重试 + fail-closed | +| REC-1 | `recovery.ts:69-74` | pending 节点 `cancelSession` 裸 yield,持续失败中止整个 reconcile → 该工作流本进程内永不被收养;同文件 else 分支(116-127)已有 catchCause 加固,属遗漏 | +| BLK-02 | `blocks.ts:265-270` | 聚合器 input_mapping 键碰撞:`foo-bar` 与 `foo_bar` 两个 writer 的 `-→_` 规范化映射到同一键,`Object.fromEntries` 后者静默覆盖前者——丢一个 writer 的 changed_files/summary(叠加 BLK-01 逃逸检测) | +| BLK-03 | `blocks.ts:277-278, 299-304` | 被改接到 ≥2 个聚合器的共享 verify 节点只映射**第一个**聚合器的 changed_files/fingerprint——双路由形状下第二条路由写集逃逸指纹绑定;无诊断 | +| CAP-02 | `capture.ts:77-93, 220-225`、`output-ref.ts:91-96` | 结构化输出关键路径无界计算成本:病态回溯 pattern `new RegExp` 应用于不限长输出可挂起校验;`uniqueItems` O(n²);`captureOutputFileRef` 整读任意大小文件无上限。`draft` 动作让模型近乎零成本成为 pattern 作者 | +| SW-L1 | `supervision-sweep.ts` + `event-v2-bridge.ts:39-44` | sweep 上下文无 InstanceRef → 其 NodeFailed 事件 location 为空 → 活实例的 summary-publisher 按 directory 过滤跳过 → TUI 收不到该 settle 的 summary 推送(bootstrap 重取可见;durable 折叠不受影响) | +| SW-L2 | `prompt.ts:191` + sweep cancel 路径 | sweep 的 `promptSvc.cancel` 在无 ambient 实例时恒 die(代码已自认、cause 级恢复 + 专项测试):意味着 sweep 永远无法自己取消仍存活的子会话,误杀路径(SW-1)的真实取消依赖活 DagLoop 的 abortChild 兜底 | +| F3 | `packages/tui/src/context/sync.tsx:286-292, 626-632` | goal.updated/cleared 是 ephemeral 事件(不进 durable 重放),`reconnected` 钩子只刷新 DAG 不刷新 goal → 断线期间错过的 `goal.cleared` 让侧栏**永久**显示过期目标(与 DAG 的 `refreshDagSummaries` 不对称) | +| F6 | `dag-inspector.tsx:726-734` + `config/keybind.ts` | 插件级第二条 palette 命令 `dag.cancel.active` 不在 keybind Definitions/CommandMap——不可重绑、不进 keybind 配置 schema(违反 AGENTS.md「plugin 级只注册 *.open」指引) | +| F4(记录) | `handlers/global.ts:16-23, 150` | `/global/event` 是 handleRaw + 裸 JSON.stringify,GlobalEventSchema 仅文档;summary payload 缺 schema 必需的 `id` 字段——未来切 schema 编码会整体丢事件(当前无害) | +| F5(记录) | `dag-event.ts:344-365` vs `event-manifest.ts` | 20 个 durable `dag.*` 事件被 bridge 广播上 GlobalBus 但不在 Definitions/SDK 事件联合——wire 上存在、类型面不可见的漂移(TUI 按设计只消费 summary,不受影响) | + +--- + +## 四条不变量判定(首批遗留) + +| 不变量 | 判定 | 证据 | +|---|---|---| +| A. 一个用户目标至多一个 live DAG | **不成立(结构性)**——INV-A,见上 | 无引擎强制,仅模型指引 | +| B. portable 不加载环境目录 / environment 验证模型可用性 | **成立** | `authoring.ts:121-124`(catalogs 仅 environment+loadEnvironment);portable 的 prompt_template.id 不便携错误;environment 逐节点解析 dag-prompts/worker_types/`resolveModel` 对照真实 provider(`tool/workflow.ts:207-239`);portable 按内容缓存、environment 永不缓存 | +| C. model-facing schema 隐藏身份字段 | **成立** | 工具 Parameters 无身份字段 + `onExcessProperty:"error"`(`tool/workflow.ts:265-279`);子会话调用 die;`requireOwnedWorkflow` 拦跨会话;NodeSchema 无 `model`;admission 审计字段边界剥离(`authoring.ts:281-314`) | +| D. Runtime Admission 与 Authoring Check 职责分离 | **对工具/CLI 面成立;被 DAG-05 侵蚀一个入口** | 分离本身干净;replan 后新图有运行时合并图复查(`validation.ts:862-865`,terminal 豁免 + schema 义务豁免为有界成文取舍);唯 httpapi start 两层同时缺席 | + +## 验证为正确(本轮特意检查) + +- **首批全部修复在位且完整**(见「首批修复复核结论」);悬空依赖角落已覆盖(fragment 依赖已取消节点被 `planReplan` 拒绝,`core/dag/core/replan.ts:159`)。 +- **loop.ts 排除的疑点**:`spawnReady` 未过滤 `getNodes`(upsert 同 id 不双行 + 陈旧图竞态被投影守卫响亮拒绝);upsert 不重置状态(terminal 进 ignore 桶不重注册);evalLock 内等信号量(permit 在 fork fiber 内获取,7 处调用点均在 evalLock 内);双重 `automation.claim`(持锁只读快照不消耗注册);cascade 定点循环(排除已 skipped 集合,单调收敛);孤儿 pending 收养(三重守卫:recovering 预留/状态守卫/跨实例 ownsWorkflow)。 +- **effect-smol 五项语义钉死**(`Effect-TS/effect-smol@3a1128c`):scope finalizer 严格 LIFO、先置 Closed 再跑 finalizer;forkIn 已关闭 scope → 子 fiber 未启动即死(非 defect);catchCause 与 interrupt(外部绕过/自抛可捕获,`Effect.test.ts:1303-1321/1873-1877`);timeoutOption 超时返回 None 且**等落败方死透**(软上限,硬切断需 disconnect);`Effect.cached`(TTL=∞)**缓存任何 exit 包括失败与中断**。 +- **三处业务用法与语义一致**:DAG-04 修复成立(且修的正是可捕获路径);`global-lifecycle` 注释与 v4 语义逐字对应;memory in-flight Deferred 是对 `Effect.cached` 失败缓存缺陷的刻意规避(只缓存成功、失败不毒化后续查询)。 +- **SDK 事件面无断链**:TUI 消费的 19 个事件类型全部在 `EventManifest.Definitions` + 生成 SDK;TUI 无手写复制类型(全部 re-export SDK);异步获取均有 stale guard + onCleanup。 +- **capture/admission/workflows/output-ref/错误映射**各正确面见 DAG 批审计「验证为正确」节(capture 槽生命周期对称、review 指纹恢复侧保守 fail、workflows 遮蔽优先级一致、httpapi 错误映射 409/404/500 分类正确)。 +- **组合根全量矩阵**:`run`/`export`/`import`/`github`/`pr`/`stats`/`debug`/`models`/`mcp`/`agent`/`session`/`plugin` 等 effectCmd 默认 instance:true 入口 Memory/Dag/Goal/GoalLoop/Sweep 齐备;`attach`/`account`/`providers`/`db` 纯客户端无消费。 + +## 局限 + +1. 测试未运行;F1/F2 是静态装配推导(推导链每跳有 file:line 依据,但未跑进程实证),建议以 wiring 探针测试补存在性断言后定案。 +2. SW-1 的量化(30→10min、~11 分钟误杀)是窗口数学推演,未写复现用例。 +3. graph 索引 generation `2026-08-19T01:37:28Z`(full,metadata_match),仅用于导航;coverage 为 best-effort 信号。 +4. 「不存在持续发布组件」基于 `events.publish(DagEvent.` 模式 grep + 导入结构推断,应读作「未找到」而非「证明不存在」。 +5. 首批审计未覆盖的 `templates/*`、`config.ts`、`model.ts`、`review-lifecycle.ts`、httpapi 中间件实现等仍未审计(DAG 批只审了指定文件)。 +6. TUI worker 内 node 图与 AppLayer 双 InstanceStore 的双缓存/去重范围未深挖(多目录 + 重载场景值得单独立项);`BootstrapRuntime` 疑似死代码未判定。 + +## 处置顺序 + +Issue 映射:#340=F1、#341=F2、#342=SW-1、#343=SW-2、#344=DAG-05、#345=DAG-06、#346=DAG-07、#347=BLK-01、#348=INV-A、#349=LOW 批、#350=/memory-on UX;#316 已补根因证明评论(见上)。 + +| 优先级 | 动作 | +|---|---| +| P0 | F1 + F2 一并修(GoalLoop.node 入 server app 组 + sweep 可达性;共用 wiring 探针回归) | +| P1 | SW-1(窗口取 max/从 durable 行反推);SW-2(sweep 后工作流终局 + wake + lease 清理);DAG-05(start 过 authoring);DAG-06(恢复回读子会话);DAG-07(object 语义关键字收紧) | +| P2 | BLK-01(措辞 + 可选 git status 对账);INV-A 决策(强制或降格);NEW-2;REC-1 | +| P3 | LOW 批其余(BLK-02/03、CAP-02、SW-L1/L2、F3、F6、NEW-1 防御性修补);F4/F5 记录性观察转维护决策 | diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 1f97194ad5..003a8c2936 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -41,7 +41,7 @@ export class Info extends Schema.Class("Config.Info")({ autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")]) .pipe(Schema.optional) .annotate({ - description: "Automatically update or notify when a new version is available", + description: "Notify when a new fork version is available on GitHub releases. Automatic updates are disabled; set to false to disable the notification", }), share: Schema.Literals(["manual", "auto", "disabled"]).pipe(Schema.optional).annotate({ description: "Control whether sessions may be shared manually, automatically, or not at all", diff --git a/packages/opencode/src/cli/upgrade.ts b/packages/opencode/src/cli/upgrade.ts index 62b230a633..5f03394493 100644 --- a/packages/opencode/src/cli/upgrade.ts +++ b/packages/opencode/src/cli/upgrade.ts @@ -5,49 +5,22 @@ import { Installation } from "@/installation" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { GlobalBus } from "@/bus/global" +// This fork never auto-updates: it only checks the fork's GitHub releases and +// notifies. `autoupdate: false` (or OPENCODE_DISABLE_AUTOUPDATE) silences the +// notification entirely. export async function upgrade() { const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())) if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return - const method = await Installation.method() - const latest = await Installation.latest(method).catch(() => {}) + const latest = await Installation.latest().catch(() => {}) if (!latest) return - if (Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) { - GlobalBus.emit("event", { - directory: "global", - payload: { - type: Installation.Event.UpdateAvailable.type, - properties: { version: latest }, - }, - }) - return - } + if (!Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE && InstallationVersion === latest) return - if (InstallationVersion === latest) return - - const kind = Installation.getReleaseType(InstallationVersion, latest) - - if (config.autoupdate === "notify" || kind !== "patch") { - GlobalBus.emit("event", { - directory: "global", - payload: { - type: Installation.Event.UpdateAvailable.type, - properties: { version: latest }, - }, - }) - return - } - - if (method === "unknown") return - await Installation.upgrade(method, latest) - .then(() => - GlobalBus.emit("event", { - directory: "global", - payload: { - type: Installation.Event.Updated.type, - properties: { version: latest }, - }, - }), - ) - .catch(() => {}) + GlobalBus.emit("event", { + directory: "global", + payload: { + type: Installation.Event.UpdateAvailable.type, + properties: { version: latest }, + }, + }) } diff --git a/packages/opencode/src/dag/CONTEXT.md b/packages/opencode/src/dag/CONTEXT.md index 87f180fa95..34f5871da1 100644 --- a/packages/opencode/src/dag/CONTEXT.md +++ b/packages/opencode/src/dag/CONTEXT.md @@ -22,7 +22,6 @@ Workflow Orchestration turns one user objective into one durable DAG. Its model- ## Invariants -- One user objective has at most one live DAG; route expansion stays inside that DAG. - Block composition is the recommended authoring path and is selected heuristically from the objective; custom Blocks/Nodes remain supported. - Workflow Authoring Check is the only raw source-to-Prepared Workflow Graph authority used by tool actions, CLI, generation, and packaging. - Parsing, file-only compatibility, strict action decoding, Block compilation, and profile diagnostics are not reimplemented by callers. @@ -34,6 +33,10 @@ Workflow Orchestration turns one user objective into one durable DAG. Its model- - Runtime Admission and Workflow Authoring Check have separate names, state, and responsibilities. - Dependents of a reporting checkpoint must be gated on its output; authoring rejects ungated shapes at start/validate AND at replan/extend fragment actions, and the runtime replan/extend mutation seam re-checks the merged graph (exempting checkpoints already terminal in the durable graph — they are settled and immutable, the spawn-before-verdict race is past; runtime create remains deliberately unchanged). A gated checkpoint must declare `output_schema` (authoring obligation). +## Conventions + +- One user objective has at most one live DAG; route expansion stays inside that DAG (issue #348: a modeling convention enforced by orchestrator guidance — workflow-routing and orchestration-policy — not by the engine; `dag.create` and the workflow tool's start accept a session with a live workflow. The runtime tolerates the violation with bounded consequences: the wake model aggregates across workflows and a goal is blocked by any DAG lease. When two live DAGs share one workspace, the plan block's disjoint-write-set discipline does NOT carry across workflows — authors must keep concurrent workflows on disjoint worktrees or serialize them). + ## Boundaries - `WorkflowAuthoring` owns source interpretation and authoring diagnostics. diff --git a/packages/opencode/src/dag/blocks.ts b/packages/opencode/src/dag/blocks.ts index 42be90b106..fc010a014e 100644 --- a/packages/opencode/src/dag/blocks.ts +++ b/packages/opencode/src/dag/blocks.ts @@ -262,12 +262,7 @@ function compileBlock( contract: AGGREGATOR_CONTRACT, required: true, reportToParent: false, - inputMapping: Object.fromEntries( - aggregation.writerIDs.flatMap((writerID: string) => [ - [`${writerID.replace(/-/g, "_")}_changed_files`, `${writerID}.output.changed_files`], - [`${writerID.replace(/-/g, "_")}_summary`, `${writerID}.output.summary`], - ]), - ), + inputMapping: aggregatorEvidenceMapping(aggregation.writerIDs), outputSchema: IMPLEMENTATION_SCHEMA, }), ...lanes, @@ -276,6 +271,17 @@ function compileBlock( const verifyAggregatorIDs = verifyAggregators.get(block.id) const verifyAggregator = verifyAggregatorIDs && verifyAggregatorIDs.length > 0 ? verifyAggregatorIDs[0] : undefined + // #349/BLK-3: one verify node serving two parallel-writer review routes + // would be rewired onto two aggregators, but the verify contract binds ONE + // implementation reference and ONE fingerprint — mapping only the first + // (the old silent behavior) lets the second route's write-set escape the + // review binding. Reject the shape instead: fan the routes together + // first, exactly like multi-review-gate dependencies. + if (verifyAggregatorIDs && verifyAggregatorIDs.length > 1) { + throw new Error( + `Verify block "${block.id}" serves multiple parallel-writer review routes (${verifyAggregatorIDs.join(", ")}) — the verification contract binds a single implementation fingerprint. Fan the routes into one review block first, or give each route its own verify block`, + ) + } // A synthesize that follows a review is the route's final gate: it must map // the review output so unresolvedReviewOutcomes/finalReviewGates recognize // an ACCEPTed review as resolved (issue #304) — the same binding contract @@ -405,11 +411,14 @@ function requireValidBlockGraph(graph: WorkflowBlockGraph, options: WorkflowBloc } // Injected between parallel implementation writers and their verification -// gate: mechanically detects declared write-set overlap (loud node failure) -// and publishes the union with one fingerprint computed at the convergence -// point, so diff review binds to a single post-merge state. +// gate: an explore-type worker enforcing the contract below. Detection is a +// behavioral contract, not an engine guarantee (#347) — the engine computes +// no intersection or union itself. The contract makes the worker reconcile +// the declared write-sets against the workspace's actual git status so +// undeclared edits fail loudly instead of escaping the union+fingerprint +// review binding, and computes the fingerprint over the actually-changed set. const AGGREGATOR_CONTRACT = - "Collect the supplied changed-file lists and summaries from each parallel implementation writer. If any file path appears in more than one list, do not submit; fail the node naming the exact overlapping paths. Otherwise submit the union of all changed files and one stable fingerprint computed at this convergence point (for example a sha256 over the sorted union of current file contents, reporting the exact commands used). Do not modify any file." + "Collect the supplied changed-file lists and summaries from each parallel implementation writer. Run git status --porcelain in the workspace to observe the actually-changed set. If any file path appears in more than one declared list, do not submit; fail the node naming the exact overlapping paths. If the actually-changed set contains paths no writer declared, do not submit; fail the node naming the undeclared paths — undeclared edits must not slip past the review binding. Otherwise submit the union of the actually-changed set and one stable fingerprint computed at this convergence point over exactly that set (for example a sha256 over the sorted union of current file contents, reporting the exact commands used). Do not modify any file." interface WriterAggregation { aggregatorID: string @@ -417,6 +426,33 @@ interface WriterAggregation { verificationID: string } +/** + * The aggregator's per-writer evidence mapping. #349/BLK-02: writer ids may + * mix hyphens and underscores ("foo-bar" vs "foo_bar") whose -→_ normalization + * collides on the same mapping key — Object.fromEntries would silently drop + * one writer's evidence (and its files escape the aggregator's overlap + * detection), so the shape is rejected at compile time. + */ +function aggregatorEvidenceMapping(writerIDs: string[]): Record { + const seen = new Map() + for (const writerID of writerIDs) { + const key = writerID.replace(/-/g, "_") + const prior = seen.get(key) + if (prior !== undefined) { + throw new Error( + `Parallel implementation writers "${prior}" and "${writerID}" normalize to the same input-mapping key "${key}" — their aggregator evidence keys would collide. Rename one of the writers so the ids differ beyond hyphens vs underscores`, + ) + } + seen.set(key, writerID) + } + return Object.fromEntries( + writerIDs.flatMap((writerID: string) => [ + [`${writerID.replace(/-/g, "_")}_changed_files`, `${writerID}.output.changed_files`], + [`${writerID.replace(/-/g, "_")}_summary`, `${writerID}.output.summary`], + ]), + ) +} + function aggregateParallelWriters(blocks: WorkflowBlock[]) { const aggregations = new Map() for (const block of blocks) { diff --git a/packages/opencode/src/dag/docs/adr/0002-parallel-writers-aggregator.md b/packages/opencode/src/dag/docs/adr/0002-parallel-writers-aggregator.md index 29f0f50c0f..034d3882b8 100644 --- a/packages/opencode/src/dag/docs/adr/0002-parallel-writers-aggregator.md +++ b/packages/opencode/src/dag/docs/adr/0002-parallel-writers-aggregator.md @@ -31,8 +31,11 @@ compiler injects one aggregation node per review route: - The aggregator depends on every writer of the route, runs read-only with shell access, is required, and reuses the implementation output schema. - It receives each writer's declared `changed_files` and fails its node - loudly on any non-empty write-set intersection; otherwise it publishes the - union plus one fingerprint computed at the convergence point. + loudly on any non-empty declared write-set intersection. It also observes + the workspace's actual `git status --porcelain` output: any + actually-changed path no writer declared fails the node (undeclared edits + must not escape the review binding), and the published union plus the + convergence-point fingerprint are computed over the actually-changed set. - The verify block's writer dependencies are re-pointed to the aggregator, the diff review's implementation reference points at the aggregator, and the verify node receives the implementation fingerprint binding. @@ -44,9 +47,12 @@ check. Author discipline for parallel writers is the triple-disjoint rule — source files, generated artifacts, and lockfiles disjoint, and no shared build — -owned by the plan block's work packages. Mechanical enforcement is the -aggregator's changed-file intersection check; shared-cache and lock-contention -races remain plan discipline. +owned by the plan block's work packages. Enforcement is the aggregator +worker's behavioral contract (declared-set intersection plus the git-status +reconciliation of actual workspace changes); the engine itself computes no +intersection or union, so the contract is only as strong as the worker's +compliance with it. Shared-cache and lock-contention races remain plan +discipline. ## Consequences diff --git a/packages/opencode/src/dag/environment-catalogs.ts b/packages/opencode/src/dag/environment-catalogs.ts new file mode 100644 index 0000000000..fe1669acb3 --- /dev/null +++ b/packages/opencode/src/dag/environment-catalogs.ts @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2026 LeXwDeX +// SPDX-License-Identifier: AGPL-3.0-or-later + +export * as DagEnvironmentCatalogs from "./environment-catalogs" + +import { Effect } from "effect" +import type { Agent } from "@/agent/agent" +import type { Provider } from "@/provider/provider" +import { Dag } from "./dag" +import { DagConfig } from "./config" +import { DagModel } from "./model" +import { DagValidation } from "./validation" + +/** + * The environment-profile catalog loader shared by the workflow tool and the + * httpapi dag.start handler (#344): agent names, model availability, and + * tier resolution from the project's dag.jsonc. Passing the services in + * keeps this module layer-free — callers resolve Agent/Provider from their + * own composition context. + */ +export const makeCatalogLoader = ( + agents: Agent.Interface, + provider: Provider.Interface, +): ((context: { directory?: string; parent?: { id: string; providerID: string } }) => Effect.Effect) => + (context) => + Effect.gen(function* () { + if (!context.directory) return {} + const agentCatalog = yield* agents.list().pipe(Effect.orDie) + const providerCatalog = yield* provider.list() + const config = yield* DagConfig.load(context.directory) + const agentsByName = new Map(agentCatalog.map((agent) => [agent.name, agent])) + const availableModels = new Set( + Object.values(providerCatalog).flatMap((info) => + Object.values(info.models).map((model) => `${model.providerID}/${model.id}`), + ), + ) + const resolveModel: NonNullable = (node, defaults) => + Effect.sync(() => { + const resolved = DagModel.resolve({ + node: node.model ?? defaults?.model, + tier: DagConfig.tierModel(config, { + required: node.required ?? defaults?.required ?? Dag.DEFAULT_WORKFLOW_CONFIG.nodeRequired, + workerType: node.worker_type, + }), + agent: agentsByName.get(node.worker_type)?.model, + parent: context.parent + ? { modelID: context.parent.id, providerID: context.parent.providerID } + : undefined, + }) + return Boolean(resolved && availableModels.has(`${resolved.providerID}/${resolved.modelID}`)) + }) + return { + worker_types: new Set(agentCatalog.map((agent) => agent.name)), + resolveModel, + } + }) diff --git a/packages/opencode/src/dag/runtime/capture.ts b/packages/opencode/src/dag/runtime/capture.ts index 8c0ec0572c..80251908f4 100644 --- a/packages/opencode/src/dag/runtime/capture.ts +++ b/packages/opencode/src/dag/runtime/capture.ts @@ -87,12 +87,41 @@ export function validateAgainstSchema(value: unknown, schema: Record maxItems) return { ok: false, error: `expected maxItems ${maxItems}, got ${value.length}` } if (schema["uniqueItems"] === true) { + // #349/CAP-02: the pairwise deepEqual scan is O(n²); model outputs + // with more items than this are pathological — fail loudly instead of + // burning the validation path. + if (value.length > UNIQUE_ITEMS_MAX) { + return { + ok: false, + error: `uniqueItems validation is capped at ${UNIQUE_ITEMS_MAX} items, got ${value.length}`, + } + } const duplicate = value.findIndex((item, index) => value.slice(0, index).some((prev) => deepEqual(prev, item))) if (duplicate !== -1) return { ok: false, error: `expected uniqueItems, found duplicate at index ${duplicate}` } } } + // #346: object-semantic keywords imply an object value even without an + // explicit `type: "object"` — `{required, properties}` without a type is a + // fully legal, common JSON Schema spelling, and a non-object value used to + // skip the whole group silently (ok:true). A bare string could then slip + // past a gated checkpoint's declared schema and resolve no fields + // downstream (the DAG-01 consequence hiding inside the schema spelling). + const hasRequired = Array.isArray(schema["required"]) + const hasProperties = isSchemaObject(schema["properties"]) + const hasAdditionalProperties = + "additionalProperties" in schema + && (typeof schema["additionalProperties"] === "boolean" || isSchemaObject(schema["additionalProperties"])) + if ((hasRequired || hasProperties || hasAdditionalProperties) && !isSchemaObject(value)) { + const keywords = [ + hasRequired && "required", + hasProperties && "properties", + hasAdditionalProperties && "additionalProperties", + ].filter(Boolean).join("/") + return { ok: false, error: `schema constrains object fields (${keywords}) but the value is ${describeType(value)}` } + } + const required = schema["required"] if (Array.isArray(required) && isSchemaObject(value)) { for (const field of required) { @@ -102,18 +131,25 @@ export function validateAgainstSchema(value: unknown, schema: Record !(key in properties)) - if (extra !== undefined) - return { ok: false, error: `unexpected additional property: "${extra}"` } - } + } + + // #346: `additionalProperties: false` fences the value's keys against the + // declared properties even when `properties` itself is absent (an empty + // allowed set) — previously the check was nested inside the properties + // branch and never ran for this spelling. + if (schema["additionalProperties"] === false && isSchemaObject(value)) { + const allowed: Record = narrowedProperties ?? {} + const extra = Object.keys(value).find((key) => !(key in allowed)) + if (extra !== undefined) + return { ok: false, error: `unexpected additional property: "${extra}"` } } const items = schema["items"] @@ -204,8 +240,9 @@ function matchesScalarType(value: unknown, type: string): boolean { if (type === "integer") return typeof value === "number" && Number.isInteger(value) if (type === "boolean") return typeof value === "boolean" if (type === "null") return value === null - // Unknown type name: permissive, consistent with subset semantics. - return true + // #346: an unrecognized type name is a schema authoring error (e.g. a + // misspelled "strng") — the old permissive pass accepted ANY value for it. + return false } function describeType(value: unknown): string { @@ -217,9 +254,15 @@ function describeType(value: unknown): string { // Schema patterns come from workflow config; a malformed regex must not crash // validation, it just fails the constraint. +// #349/CAP-02: patterns may also be PATHOLOGICAL (the draft action lets a +// model author them) — cap the tested span so catastrophic backtracking +// against an unbounded model output cannot hang submit_result validation. +const REGEX_TEST_MAX_CHARS = 100_000 +// #349/CAP-02: bound for the O(n²) uniqueItems pairwise scan. +const UNIQUE_ITEMS_MAX = 1_000 function safeRegexTest(pattern: string, value: string): boolean { try { - return new RegExp(pattern).test(value) + return new RegExp(pattern).test(value.slice(0, REGEX_TEST_MAX_CHARS)) } catch { return false } diff --git a/packages/opencode/src/dag/runtime/loop.ts b/packages/opencode/src/dag/runtime/loop.ts index e2c3f316bc..27e1fb2895 100644 --- a/packages/opencode/src/dag/runtime/loop.ts +++ b/packages/opencode/src/dag/runtime/loop.ts @@ -34,7 +34,7 @@ import { sanitizeInput } from "../templates/sanitize" import { DagConfig } from "../config" import { spawnNode, makeDeadlineWatcher } from "./spawn" import { evaluateCondition, resolveInputMapping } from "./eval" -import { reconcileWorkflow, makeSessionStatusChecker } from "./recovery" +import { reconcileWorkflow, makeSessionStatusChecker, makeLastAssistantTextReader } from "./recovery" // A reporting checkpoint's replan verdict vetoes the current direction: the // workflow pauses durably before any downstream spawn (see NodeCompleted @@ -321,7 +321,9 @@ const serviceLayer = Layer.effect( Effect.provideService(Session.Service, sessionSvc), Effect.provideService(SessionPrompt.Service, promptSvc), Effect.catchCause((cause) => - dag.nodeFailed(dagID, nodeID, Cause.pretty(cause), "exec_failed"), + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : dag.nodeFailed(dagID, nodeID, Cause.pretty(cause), "exec_failed"), ), Effect.ignore, ) @@ -369,6 +371,9 @@ const serviceLayer = Layer.effect( }) const checkSessionStatus = makeSessionStatusChecker(sessionSvc) + // #345: schemaless recovered nodes settle with the child's last + // assistant text, mirroring the live spawn path. + const lastAssistantText = makeLastAssistantTextReader(sessionSvc) // Best-effort abort of a durable child session, independent of whether // a local wrapper fiber still exists. Used at every replacement, @@ -414,7 +419,10 @@ const serviceLayer = Layer.effect( dagID, checkSessionStatus, (sid) => promptSvc.cancel(sid as never), - config, + // null (not undefined) marks an unparseable row so recovery + // fails such nodes loudly instead of undefined-completing them. + config ?? null, + lastAssistantText, ).pipe( Effect.provideService(Dag.Service, dag), ) @@ -430,18 +438,33 @@ const serviceLayer = Layer.effect( // explicit workflow control. const pausedForRecovery = recovery.ownershipLost > 0 && wf.status === "running" if (pausedForRecovery) { - // A concurrent control op (cancel/fail) can terminalize the - // workflow while reconciliation runs — the pause guard then - // rejects. Abandon adoption instead of tracking a workflow this - // instance no longer controls. - const pauseAccepted = yield* dag.pause(dagID).pipe( - Effect.as(true), - Effect.catchCause((cause) => - Effect.logWarning("DagLoop recovery pause rejected — abandoning adoption", { dagID, cause }).pipe( - Effect.as(false), + // #349/NEW-2: a pause rejected by a CONCURRENT TERMINAL control + // op means this instance no longer controls the workflow — + // abandoning adoption is correct. But a lock-timeout or store + // defect used to take the same silent path: the invented + // NodeFailed rows were persisted with no runtime entry, events + // filtered by runtimes.has, wake boundaries requiring an entry — + // the workflow stalled until a process restart. Mirror the + // replan-verdict gate: retry twice, fold defects in, and only + // abandon when the durable row is genuinely terminal. + const pauseAccepted = yield* Effect.gen(function* () { + const attemptPause = dag.pause(dagID).pipe( + Effect.map(() => true), + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) ? Effect.failCause(cause) : Effect.succeed(false), ), - ), - ) + ) + if (yield* attemptPause) return true + if (yield* attemptPause) return true + const row = yield* store.getWorkflow(dagID).pipe(Effect.orDie) + if (row && row.status !== "paused") { + yield* Effect.logError( + "DagLoop recovery pause failed after retries — workflow stays unadopted; it will be re-adopted on the next instance load", + { dagID, status: row.status }, + ) + } + return row?.status === "paused" + }) if (!pauseAccepted) return yield* Effect.logWarning("DagLoop paused workflow after recovery invented node failures", { dagID, diff --git a/packages/opencode/src/dag/runtime/output-ref.ts b/packages/opencode/src/dag/runtime/output-ref.ts index 0ceceda422..322ce59e1d 100644 --- a/packages/opencode/src/dag/runtime/output-ref.ts +++ b/packages/opencode/src/dag/runtime/output-ref.ts @@ -45,6 +45,10 @@ const SUMMARY_CHARS = 200 // The summary only needs the leading chars; decoding a bounded prefix keeps a // giant report from being copied twice (once for the digest, once for text). const SUMMARY_DECODE_BYTES = 4096 +// #349/CAP-02: whole-file capture bound — a giant or sparse referenced file +// must not spike memory; larger files fall back to the inline path +// (returning undefined here is the designed degradation). +const FILE_REF_MAX_BYTES = 64 * 1024 * 1024 const MAX_PATH_CHARS = 4096 export const REPORT_AREA = path.join(".opencode", "workflow-reports") @@ -88,6 +92,9 @@ export function captureOutputFileRef(rawText: string): Effect.Effect stat(candidate).catch(() => undefined)) if (!info || !info.isFile() || info.size === 0) return undefined + // #349/CAP-02: refuse oversized refs — stat already told us the size, so + // the read never happens for a pathological file. + if (info.size > FILE_REF_MAX_BYTES) return undefined const bytes = yield* Effect.promise(() => Bun.file(candidate) .arrayBuffer() diff --git a/packages/opencode/src/dag/runtime/recovery.ts b/packages/opencode/src/dag/runtime/recovery.ts index ff6a135860..cd76473244 100644 --- a/packages/opencode/src/dag/runtime/recovery.ts +++ b/packages/opencode/src/dag/runtime/recovery.ts @@ -37,7 +37,8 @@ export function reconcileWorkflow( dagID: string, checkSessionStatus: (childSessionID: string) => Effect.Effect<"active" | "completed" | "failed" | "unknown", Error>, cancelSession?: (sessionID: string) => Effect.Effect, - workflowConfig?: { nodes: Pick[] } | undefined, + workflowConfig?: { nodes: Pick[] } | null, + lastAssistantText?: (childSessionID: string) => Effect.Effect, ): Effect.Effect<{ reconciled: number; ownershipLost: number }, Error, Dag.Service> { return Effect.gen(function* () { const dag = yield* Dag.Service @@ -68,7 +69,19 @@ export function reconcileWorkflow( // never revisit it if the workflow is about to become terminal. if (node.status === "pending" || node.status === "queued") { if (node.childSessionId && cancelSession) { - yield* cancelSession(node.childSessionId) + // #349/REC-1: same hardening as the running-node branch below — a + // persistent cancel failure must not abort the whole reconcile + // (this workflow would then never be adopted by this process). + yield* cancelSession(node.childSessionId).pipe( + Effect.catchCause((cause) => + Effect.logWarning("DAG recovery failed to cancel stale child session", { + dagID, + nodeID: node.id, + childSessionID: node.childSessionId, + cause, + }), + ), + ) } continue } @@ -90,6 +103,24 @@ export function reconcileWorkflow( const sessionStatus = yield* checkSessionStatus(node.childSessionId) if (sessionStatus === "completed") { + // #345 parity with the live path: an unparseable workflow row must + // not degrade into the schemaless completion below — a schema- + // carrying node would bypass settleCapturedOutput and land as an + // undefined output. Fail loudly instead of inventing a settlement. + if (workflowConfig === null) { + ownershipLost++ + yield* settle( + node.id, + dag.nodeFailed( + dagID, + node.id, + "child session completed but the workflow config is unparseable on recovery — cannot settle safely", + "exec_failed", + ), + ) + reconciled++ + continue + } const nodeConfig = workflowConfig?.nodes.find((n) => n.id === node.id) if (nodeConfig?.output_schema) { // Same settlement decision as spawn's completion gate — recovery @@ -102,7 +133,17 @@ export function reconcileWorkflow( : dag.nodeFailed(dagID, node.id, settlement.reason, "verdict_fail"), ) } else { - yield* settle(node.id, dag.nodeCompleted(dagID, node.id, undefined)) + // #345: the live path (spawn.ts) completes a schemaless node with + // the child's last assistant text; recovery must mirror it instead + // of completing with undefined — a schemaless checkpoint's string + // verdict (e.g. a bare {"verdict":"replan"} reply) would silently + // vanish after a crash otherwise: no pause, no warning, and gated + // dependents resolve no fields. Callers that inject no reader keep + // the legacy undefined settlement. + const rawText = lastAssistantText + ? (yield* lastAssistantText(node.childSessionId)) ?? "" + : undefined + yield* settle(node.id, dag.nodeCompleted(dagID, node.id, rawText)) } reconciled++ } else if (sessionStatus === "failed") { @@ -217,3 +258,21 @@ export function makeSessionStatusChecker( return "completed" as const }) } + +/** + * #345: the schemaless-node completion mirror of the live path — the child's + * last assistant text part, the exact value spawn.ts settles a schemaless + * node with. Recovery reads it so a crash cannot erase a string verdict. + */ +export function makeLastAssistantTextReader( + sessions: Session.Interface, +): (childSessionID: string) => Effect.Effect { + return (childSessionID) => + Effect.gen(function* () { + const msgs = yield* sessions + .messages({ sessionID: SessionID.make(childSessionID), limit: 20 }) + .pipe(Effect.catchTag("NotFoundError", () => Effect.succeed([] as SessionV1.WithParts[]))) + const last = [...msgs].reverse().find((msg) => msg.info.role === "assistant") + return last?.parts.findLast((part): part is Extract => part.type === "text")?.text + }) +} diff --git a/packages/opencode/src/dag/runtime/supervision-sweep.ts b/packages/opencode/src/dag/runtime/supervision-sweep.ts index fbfbb0ad60..8fa913150b 100644 --- a/packages/opencode/src/dag/runtime/supervision-sweep.ts +++ b/packages/opencode/src/dag/runtime/supervision-sweep.ts @@ -8,9 +8,13 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Database } from "@opencode-ai/core/database/database" import { DagStore } from "@opencode-ai/core/dag/store" import { WorkflowNodeTable } from "@opencode-ai/core/dag/sql" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { isNodeTerminalStatus, isTransitionRejection, isWorkflowTerminalStatus } from "@opencode-ai/core/dag/core/types" import { and, eq, sql } from "drizzle-orm" import { Dag, parseWorkflowConfig } from "@/dag/dag" import { SessionPrompt } from "@/session/prompt" +import { SessionAutomationLease } from "@/session/automation-lease" +import { InstanceRef } from "@/effect/instance-ref" /** * Host-level deadline-supervision sweep — the fallback retry for the @@ -90,6 +94,36 @@ export const escalateIntervalFromConfig = (raw: string | undefined, nodeId: stri return Math.max(1_000, typeof timeoutMs === "number" ? timeoutMs : Dag.DEFAULT_WORKFLOW_CONFIG.nodeTimeoutMs) } +/** + * An upper bound on the cadence the LIVE watcher actually runs at, + * back-derived from durable columns. deadline_ms is only ever written as + * `grant time + timeout_ms` — at spawn (started_at + T0) and at each + * deadline extension (now + Ti) — while escalations move only the counter, + * never the deadline. The granted total (deadline − started_at) is therefore + * the sum of the initial grant plus every extension grant, which is always + * ≥ the LAST grant, and the last grant's timeout IS the live watcher's + * cadence (a re-time replaces the watcher at the new timeout). Issue #342: + * replan can lower a running node's persisted timeout_ms while the A1/Q2 + * re-time gate deliberately keeps the old watcher on its old (longer) + * cadence — a config-only window would then be shorter than the live + * watcher's cycle and sweep a healthy node. Taking the max with the config + * cadence covers both shapes: re-timed watchers match the config (the + * durable value merely over-estimates by the accumulated grants, delaying — + * never causing — a settle), gate-skipped ones are caught by the durable + * bound. Returns 0 when the columns are missing (legacy rows) so the config + * value decides alone. + */ +export const escalateIntervalDurable = ( + deadlineMs: number | null | undefined, + startedAt: number | null | undefined, +) => { + if (deadlineMs == null || startedAt == null) return 0 + if (!Number.isFinite(deadlineMs) || !Number.isFinite(startedAt)) return 0 + const granted = deadlineMs - startedAt + if (granted <= 0) return 0 + return Math.max(1_000, granted) +} + const serviceLayer = Layer.effect( Service, Effect.gen(function* () { @@ -97,6 +131,7 @@ const serviceLayer = Layer.effect( const store = yield* DagStore.Service const dag = yield* Dag.Service const promptSvc = yield* SessionPrompt.Service + const automation = yield* SessionAutomationLease.Service const scope = yield* Scope.Scope // nodeKey -> {extensions, flatTicks}: the counter value last observed and @@ -117,6 +152,37 @@ const serviceLayer = Layer.effect( return escalateIntervalFromConfig(wf?.config, nodeId) }) + // #349/SW-L1: publish-side location stamping. The sweep's layer context + // has no ambient InstanceRef, so its durable events used to carry an + // empty location — live instances' summary publishers filter by + // directory, so the TUI never got a summary push for a swept settle + // (bootstrap refetch only). Providing a reference derived from the + // workflow's own durable row (directory + project) stamps the events so + // the owning directory's consumers see them. Falls back to unstamped + // when the row cannot be resolved — same visibility as before, never + // worse. + const withWorkflowLocation = Effect.fnUntraced(function* (workflowId: string, body: Effect.Effect) { + const wf = yield* store.getWorkflow(workflowId).pipe( + Effect.catchCause((cause) => (Cause.hasInterrupts(cause) ? Effect.interrupt : Effect.succeed(undefined))), + ) + if (!wf?.directory) return yield* body + const project = yield* db + .select() + .from(ProjectTable) + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- the durable column is typed string; ProjectTable.id is branded. + .where(eq(ProjectTable.id, wf.projectId as never)) + .get() + .pipe(Effect.catchCause((cause) => (Cause.hasInterrupts(cause) ? Effect.interrupt : Effect.succeed(undefined)))) + yield* body.pipe( + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- partial InstanceContext: only directory/worktree/project.id are read by the publish-side location stamp. + Effect.provideService(InstanceRef, { + directory: wf.directory, + worktree: project?.worktree ?? wf.directory, + project: { id: wf.projectId }, + } as never), + ) + }) + const sweepOnce = Effect.fn("DagSupervisionSweep.sweepOnce")(function* () { const rows = yield* db .select({ @@ -124,6 +190,8 @@ const serviceLayer = Layer.effect( nodeId: WorkflowNodeTable.id, childSessionId: WorkflowNodeTable.child_session_id, extensions: WorkflowNodeTable.timeout_extensions, + deadlineMs: WorkflowNodeTable.deadline_ms, + startedAt: WorkflowNodeTable.started_at, }) .from(WorkflowNodeTable) .where( @@ -158,7 +226,14 @@ const serviceLayer = Layer.effect( // Only nodes already flat for a tick pay the config lookup. if (flatTicks < 1) continue const escalateIntervalMs = yield* escalateIntervalFor(row.workflowId, row.nodeId) - if (flatTicks < frozenTicksNeeded(escalateIntervalMs)) continue + // #342: the window must cover the LIVE watcher's actual cadence, not + // just the current config's — replan may have lowered the persisted + // timeout while the re-time gate kept the old watcher. + const windowIntervalMs = Math.max( + escalateIntervalMs, + escalateIntervalDurable(row.deadlineMs, row.startedAt), + ) + if (flatTicks < frozenTicksNeeded(windowIntervalMs)) continue // Frozen across the full window: cancel the (possibly dead) child and // settle the node. Same-host races (a live watcher) are serialized by // the workflow's in-process lock; another host's sweep is collapsed @@ -177,28 +252,31 @@ const serviceLayer = Layer.effect( Effect.catchCause((cause) => (Cause.hasInterrupts(cause) ? Effect.interrupt : Effect.void)), ) } - const settled = yield* dag - .nodeFailed( - row.workflowId, - row.nodeId, - `deadline supervision lost (no escalation progress across ${flatTicks} sweep ticks, escalate cadence ${escalateIntervalMs}ms) — swept, extensions ${row.extensions}`, - "timeout", - ) - .pipe( - Effect.as(true), - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? Effect.interrupt - : Effect.gen(function* () { - yield* Effect.logWarning("DagSupervisionSweep nodeFailed failed — retrying next tick", { - dagID: row.workflowId, - nodeID: row.nodeId, - cause, - }) - return false - }), - ), - ) + const settled = yield* withWorkflowLocation( + row.workflowId, + dag + .nodeFailed( + row.workflowId, + row.nodeId, + `deadline supervision lost (no escalation progress across ${flatTicks} sweep ticks, escalate cadence ${escalateIntervalMs}ms) — swept, extensions ${row.extensions}`, + "timeout", + ) + .pipe(Effect.asVoid), + ).pipe( + Effect.as(true), + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.interrupt + : Effect.gen(function* () { + yield* Effect.logWarning("DagSupervisionSweep nodeFailed failed — retrying next tick", { + dagID: row.workflowId, + nodeID: row.nodeId, + cause, + }) + return false + }), + ), + ) // On a failed settle keep the streak so the next tick retries // immediately instead of deferring by a full freeze window. if (!settled) continue @@ -208,6 +286,19 @@ const serviceLayer = Layer.effect( extensions: row.extensions, }) observed.delete(key) + // #343: node rot is fixed, workflow rot is next. checkCompletion + // lives inside DagLoop — with the owning instance torn down nothing + // advances the workflow's terminal state, releases its automation + // lease, or tells the parent. Terminalize durably from the host + // level once EVERY current-revision node is terminal; parent wake + // delivery stays with the owning instance (session context) and + // converges through the DagLoop init drain on the next instance + // load. A live DagLoop racing this is serialized by the same + // workflow lock and its terminal-status guards — double settles + // collapse to one. + yield* withWorkflowLocation(row.workflowId, settleWorkflowIfComplete(row.workflowId)).pipe( + Effect.catchCause((cause) => (Cause.hasInterrupts(cause) ? Effect.interrupt : Effect.void)), + ) } // Retain only what is still overdue-running so settled/restarted nodes // do not accumulate. @@ -215,6 +306,48 @@ const serviceLayer = Layer.effect( for (const [key, streak] of observed) flatStreak.set(key, streak) }) + // #343 host-level mirror of DagLoop.checkCompletion's durable half: when + // every current-revision node is terminal and the workflow row is not, + // land the terminal transition (fail on required-node failure, complete + // otherwise) and release the workflow's automation lease registration. + // Wake delivery to the parent session stays with the owning instance — + // it needs session context (ownsSession guard, prompt injection) — and + // converges through the DagLoop init drain on the next instance load. + // Transition rejections (a live DagLoop completed first, or a replan + // re-registered nodes between the reads and the write) are expected and + // silent; the next tick re-evaluates from the durable rows. + const settleWorkflowIfComplete = Effect.fnUntraced(function* (workflowId: string) { + const wf = yield* store.getWorkflow(workflowId).pipe( + Effect.catchCause((cause) => (Cause.hasInterrupts(cause) ? Effect.interrupt : Effect.succeed(undefined))), + ) + if (!wf || isWorkflowTerminalStatus(wf.status as never)) return + const nodes = yield* store.getCurrentNodes(workflowId).pipe( + Effect.catchCause((cause) => (Cause.hasInterrupts(cause) ? Effect.interrupt : Effect.succeed([]))), + ) + if (nodes.length === 0 || nodes.some((node) => !isNodeTerminalStatus(node.status as never))) return + const failed = nodes.filter((node) => node.status === "failed" && node.required).map((node) => node.id) + const transition = failed.length > 0 + ? yield* dag.fail(workflowId, `required node(s) failed: ${failed.join(", ")}`).pipe( + Effect.as("failed" as const), + Effect.catchIf(isTransitionRejection, () => Effect.succeed(undefined)), + ) + : yield* dag.complete(workflowId, { skipReviewGate: true }).pipe( + Effect.as("completed" as const), + Effect.catchIf(isTransitionRejection, () => Effect.succeed(undefined)), + ) + if (transition === undefined) return + yield* Effect.logWarning("DagSupervisionSweep terminalized a workflow whose instance is gone", { + dagID: workflowId, + outcome: transition, + }) + // The lease registration outlived the owning DagLoop's handlers; this + // process's registry entry must not pin the parent session's automation + // forever. Cross-process registries are untouched (a no-op here). + yield* automation + .unregister(wf.sessionId as never, { kind: "dag", id: workflowId }) + .pipe(Effect.catchCause((cause) => (Cause.hasInterrupts(cause) ? Effect.interrupt : Effect.void))) + }) + let sweepFiber: Fiber.Fiber | undefined const init = Effect.fn("DagSupervisionSweep.init")(function* () { @@ -252,8 +385,15 @@ export const layer = serviceLayer.pipe( Layer.provide(DagStore.defaultLayer), Layer.provide(Dag.defaultLayer), Layer.provide(SessionPrompt.defaultLayer), + Layer.provide(SessionAutomationLease.defaultLayer), ) export const defaultLayer = layer -export const node = LayerNode.make(serviceLayer, [Database.node, DagStore.node, Dag.node, SessionPrompt.node]) +export const node = LayerNode.make(serviceLayer, [ + Database.node, + DagStore.node, + Dag.node, + SessionPrompt.node, + SessionAutomationLease.node, +]) diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index b4b888ed78..03b98c1372 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -11,7 +11,6 @@ import path from "path" import { makeRuntime } from "@opencode-ai/core/effect/runtime" import semver from "semver" import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version" -import { NpmConfig } from "@opencode-ai/core/npm-config" import { InstallationEvent } from "@opencode-ai/schema/installation-event" export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown" @@ -59,22 +58,18 @@ export class UpgradeFailedError extends Schema.TaggedErrorClass Effect.Effect readonly method: () => Effect.Effect - readonly latest: (method?: Method) => Effect.Effect + readonly latest: () => Effect.Effect readonly upgrade: (method: Method, target: string) => Effect.Effect } @@ -204,62 +199,14 @@ export const layer: Layer.Layer Effect.Effect readonly checkpoint: (input: { sessionID: SessionID; messages: SessionV1.WithParts[] }) => Effect.Effect - readonly setEnabled: (enabled: boolean) => Effect.Effect<"Memory on" | "Memory off" | "Memory remains off"> + readonly setEnabled: (enabled: boolean) => Effect.Effect + /** #350: why Memory is inert for the current project — undefined when the + * project passes every activation gate. Surface this wherever a silent + * "remains off" would leave the user guessing (e.g. /memory on). */ + readonly statusReason: () => Effect.Effect } export class Service extends Context.Service()("@opencode/Memory") {} @@ -767,9 +771,32 @@ export const layer: Layer.Layer< ), ) + // #350: the why-is-Memory-inert companion of configuration()'s fail-closed + // gates. Mirrors their order; only the gates a user can act on produce a + // reason (identity retirement and admission repair stay log-only — they + // are operator concerns, not /memory on guidance). + const statusReason = Effect.fn("Memory.statusReason")(function* () { + const ctx = yield* InstanceState.context + const current = yield* project.get(ctx.project.id) + if (!current) return "Memory is unavailable for this project: its identity is retired or unregistered." + if (current.id === ProjectV2.ID.global) + return "Memory is unavailable until this repository has a real identity: commit once or add a remote, then run /init." + if (current.vcs !== "git") return "Memory requires a git repository." + if (!current.time.initialized) + return "Memory is unavailable until the project is initialized — run /init first, then /memory on." + return undefined + }) + const setEnabledUnsafe = Effect.fn("Memory.setEnabledUnsafe")(function* (enabled: boolean) { const initial = yield* configuration() - if (!initial) return "Memory remains off" as const + if (!initial) { + if (!enabled) return "Memory remains off" + // #350: a /memory on that cannot activate must say WHY — the bare + // "remains off" sent users to guess (real case: an initialized git + // project whose /init stamp was missing looked identical to a + // disabled Memory). + return (yield* statusReason()) ?? "Memory remains off" + } const value = initial.loaded ? initial : yield* Effect.gen(function* () { @@ -800,13 +827,13 @@ export const layer: Layer.Layer< Effect.catchCause((cause) => Effect.gen(function* () { yield* Effect.logWarning("MEMORY command failed", { cause }) - return "Memory remains off" as const + return "Memory remains off" }), ), ), ) - return Service.of({ init, prepare, context, search, checkpoint, setEnabled }) + return Service.of({ init, prepare, context, search, checkpoint, setEnabled, statusReason }) }), ) 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 bc069897ec..04ef202fd6 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/dag.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/dag.ts @@ -6,6 +6,12 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" import { InvalidRequestError, ConflictError, notFound } from "../errors" import { Dag } from "@/dag/dag" +import { DagValidation } from "@/dag/validation" +import { WorkflowAuthoring } from "@/dag/authoring" +import { DagEnvironmentCatalogs } from "@/dag/environment-catalogs" +import { createAdmissionRecord } from "@/dag/admission" +import { Agent } from "@/agent/agent" +import { Provider } from "@/provider/provider" import { Session } from "@/session/session" import { SessionID } from "@/session/schema" import { InstanceState } from "@/effect/instance-state" @@ -37,6 +43,11 @@ export const dagHandlers = HttpApiBuilder.group(InstanceHttpApi, "dag", (handler Effect.gen(function* () { const dag = yield* Dag.Service const sessions = yield* Session.Service + const agents = yield* Agent.Service + const provider = yield* Provider.Service + const authoring = WorkflowAuthoring.make({ + loadEnvironment: DagEnvironmentCatalogs.makeCatalogLoader(agents, provider), + }) const wf = (r: DagStore.WorkflowRow) => ({ id: r.id, @@ -150,18 +161,54 @@ export const dagHandlers = HttpApiBuilder.group(InstanceHttpApi, "dag", (handler return yield* Effect.fail(new InvalidRequestError({ message: "start requires 'config' with a 'nodes' array" })) } 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) - // and fail-fast errors surface as 400, not 500 defects. - const dagID = yield* dag.create({ - projectID: session.projectID, - sessionID: session.id, - title: ctx.payload.title ?? cfg.name, - config: cfg, - }).pipe( - Effect.catch((error) => Effect.fail(new InvalidRequestError({ message: error.message }))), + // #344: same authority as the workflow tool's start action — every start + // passes Workflow Authoring (environment profile): checkpoint gating, + // output_schema obligations on gated checkpoints, worker/model/prompt + // asset resolution, and server-side minting of the deep-mode admission + // record. dag.create alone runs only structural checks, which is safe + // only when authoring has already vetted the graph. + const result = yield* authoring.prepare({ + action: "start", + source: { + kind: "inline", + value: { title: ctx.payload.title, config }, + source: "httpapi:dag.start", + }, + profile: "environment", + environment: { directory: session.directory, parent: session.model ?? undefined }, + }) + // Parity with the workflow tool's start action: model resolution is + // advisory over HTTP — the tool asks a question (no model configured + // yet), an API caller has no such interaction; the spawn path fails + // loudly (failWithoutFiber) at execution time if a model never + // resolves. Every other diagnostic class stays blocking, and a graph + // that did not COMPILE (prepared === undefined) is always blocking + // regardless of diagnostic classes. + const blocking = result.errors.filter( + (diagnostic) => diagnostic.code !== DagValidation.DIAGNOSTIC_CODES.modelUnavailable, ) + if (result.prepared?.action !== "start" || blocking.length > 0) { + const diagnostics = blocking + .map((diagnostic) => `- [${diagnostic.code}] ${diagnostic.path}: ${diagnostic.message}${diagnostic.hint ? ` (${diagnostic.hint})` : ""}`) + .join("\n") + return yield* Effect.fail( + new InvalidRequestError({ message: `start rejected by workflow validation:\n${diagnostics || "no prepared graph"}` }), + ) + } + const prepared = result.prepared + const dagID = yield* dag + .create({ + projectID: session.projectID, + sessionID: session.id, + title: ctx.payload.title ?? prepared.title, + config: { + ...prepared.config, + ...(prepared.admission ? { admission: createAdmissionRecord(prepared.admission) } : {}), + }, + }) + .pipe( + Effect.catch((error) => Effect.fail(new InvalidRequestError({ message: error.message }))), + ) const row = yield* dag.store.getWorkflow(dagID).pipe(Effect.orDie) if (!row) return yield* Effect.die(new Error(`created workflow missing from store: ${dagID}`)) return wf(row) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts index c1f588d5a1..96c9e66779 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts @@ -102,7 +102,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl body: { success: false as const, error: "Unknown installation method" }, } } - const target = ctx.payload.target || (yield* installation.latest(method)) + const target = ctx.payload.target || (yield* installation.latest()) const result = yield* installation.upgrade(method, target).pipe( Effect.as({ status: 200, body: { success: true as const, version: target } }), Effect.catch((err) => diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index accb6318c4..0e7f278937 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -48,6 +48,8 @@ import { Discovery } from "@/skill/discovery" import { Snapshot } from "@/snapshot" import { Storage } from "@/storage/storage" import { Goal } from "@/goal/goal" +import { GoalLoop } from "@/goal/loop" +import { DagSupervisionSweep } from "@/dag/runtime/supervision-sweep" import { SettingsHook } from "@/hook/settings" import { HookRewakeLive } from "@/hook/rewake-live" import { SessionHooks } from "@/hook/session-hooks" @@ -297,6 +299,25 @@ export const app = LayerNode.group([ // here, so live sessions silently degraded to "Memory remains off" / // "Memory search is unavailable for this session" (issue #311). Memory.node, + // GoalLoop: same failure class again (issue #340) — the only consumer is + // InstanceBootstrap's `serviceOption(GoalLoop.Service).init()` (idle-event + // subscription + startup goal scan), which runs in the request fiber's + // ambient context. GoalLoop.node was listed only in AppLayer + // (app-runtime.ts provideMerge), so headless serve/web, the desktop + // sidecar, and non-CWD TUI directories never armed goal continuation or + // the crash-recovery scan: standing goals stalled after their first turn. + GoalLoop.node, + // DagSupervisionSweep: host-level deadline supervision (2026-08-18 + // orphaned-nodes incident) was constructed only in AppLayer, but the + // desktop sidecar (packages/desktop/src/main/sidecar.ts) calls + // Server.listen without effectCmd, so AppLayer never existed there and + // the sweep never ran on the desktop default path (issue #341). Listing + // it here makes every serving process build it with the listener scope. + // Processes that also build AppLayer (serve/web via AppRuntime) get a + // second instance; settle is convergent (withWorkflowLock + guardNode + + // conditional projector UPDATE), so the duplicate is safe — see the sweep + // header's multi-host convergence notes. + DagSupervisionSweep.node, ]) export function createRoutes( diff --git a/packages/opencode/src/tool/memory-search.ts b/packages/opencode/src/tool/memory-search.ts index 0487719279..4574f0cf2c 100644 --- a/packages/opencode/src/tool/memory-search.ts +++ b/packages/opencode/src/tool/memory-search.ts @@ -37,7 +37,15 @@ export const MemorySearchTool = Tool.define( const current = yield* sessions.get(ctx.sessionID).pipe(Effect.option) if (Option.isNone(current) || current.value.parentID) return unavailable() - return response(yield* memory.search({ sessionID: ctx.sessionID, messages: ctx.messages, query })) + const result = yield* memory.search({ sessionID: ctx.sessionID, messages: ctx.messages, query }) + // #350: an inert Memory answers "unavailable" with no field to carry + // why — surface the actionable reason (init stamp, git identity) + // instead of leaving the caller to guess. + if (result.status === "unavailable") { + const reason = yield* memory.statusReason() + if (reason) return unavailable(reason) + } + return response(result) }), } satisfies Tool.DefWithoutID), ) @@ -81,10 +89,10 @@ function response(result: Memory.SearchResult): Tool.ExecuteResult { return unavailable() } -function unavailable(): Tool.ExecuteResult { +function unavailable(reason?: string): Tool.ExecuteResult { return { title: "memory unavailable", - output: "Memory search is unavailable for this session", + output: reason ?? "Memory search is unavailable for this session", metadata: { status: "unavailable" }, } } diff --git a/packages/opencode/src/tool/workflow.ts b/packages/opencode/src/tool/workflow.ts index afa66390a4..7266b1a2e2 100644 --- a/packages/opencode/src/tool/workflow.ts +++ b/packages/opencode/src/tool/workflow.ts @@ -6,11 +6,10 @@ import { CommandPlugin } from "@opencode-ai/core/plugin/command" import { Effect, Option, Schema } from "effect" import { Dag } from "@/dag/dag" import { DagReviewLifecycle } from "@/dag/review-lifecycle" -import { DagConfig } from "@/dag/config" import { DagWorkflows } from "@/dag/workflows" -import { DagModel } from "@/dag/model" import { DagValidation, type Diagnostic } from "@/dag/validation" import { WorkflowAuthoring } from "@/dag/authoring" +import { DagEnvironmentCatalogs } from "@/dag/environment-catalogs" import { Agent } from "@/agent/agent" import { Question } from "@/question" import { Provider } from "@/provider/provider" @@ -205,38 +204,7 @@ export const WorkflowTool = Tool.define< ) const authoring = WorkflowAuthoring.make({ - loadEnvironment: (context) => - Effect.gen(function* () { - if (!context.directory) return {} - const agentCatalog = yield* agents.list().pipe(Effect.orDie) - const providerCatalog = yield* provider.list() - const config = yield* DagConfig.load(context.directory) - const agentsByName = new Map(agentCatalog.map((agent) => [agent.name, agent])) - const availableModels = new Set( - Object.values(providerCatalog).flatMap((info) => - Object.values(info.models).map((model) => `${model.providerID}/${model.id}`), - ), - ) - const resolveModel: NonNullable = (node, defaults) => - Effect.sync(() => { - const resolved = DagModel.resolve({ - node: node.model ?? defaults?.model, - tier: DagConfig.tierModel(config, { - required: node.required ?? defaults?.required ?? Dag.DEFAULT_WORKFLOW_CONFIG.nodeRequired, - workerType: node.worker_type, - }), - agent: agentsByName.get(node.worker_type)?.model, - parent: context.parent - ? { modelID: context.parent.id, providerID: context.parent.providerID } - : undefined, - }) - return Boolean(resolved && availableModels.has(`${resolved.providerID}/${resolved.modelID}`)) - }) - return { - worker_types: new Set(agentCatalog.map((agent) => agent.name)), - resolveModel, - } - }), + loadEnvironment: DagEnvironmentCatalogs.makeCatalogLoader(agents, provider), }) const portableEntryCheck = (entry: DagWorkflows.Entry) => diff --git a/packages/opencode/test/dag/blocks-parallel-writers.test.ts b/packages/opencode/test/dag/blocks-parallel-writers.test.ts index 1630a96e43..3d530de766 100644 --- a/packages/opencode/test/dag/blocks-parallel-writers.test.ts +++ b/packages/opencode/test/dag/blocks-parallel-writers.test.ts @@ -40,7 +40,13 @@ describe("parallel workspace writers (issue #293)", () => { slice_c_changed_files: "slice-c.output.changed_files", slice_c_summary: "slice-c.output.summary", }) + // #347: the contract must make the worker reconcile the declared + // write-sets against the workspace's actual git status — undeclared + // edits fail loudly instead of escaping the union+fingerprint binding, + // and the fingerprint covers the actually-changed set. expect(aggregate?.prompt_template.inline).toContain("overlapping paths") + expect(aggregate?.prompt_template.inline).toContain("git status --porcelain") + expect(aggregate?.prompt_template.inline).toContain("undeclared paths") expect(byID.get("gates")?.depends_on).toEqual(["decision--aggregate"]) const decision = byID.get("decision") diff --git a/packages/opencode/test/dag/dag-node-supervision.test.ts b/packages/opencode/test/dag/dag-node-supervision.test.ts index a12681bd0a..a957a62320 100644 --- a/packages/opencode/test/dag/dag-node-supervision.test.ts +++ b/packages/opencode/test/dag/dag-node-supervision.test.ts @@ -22,6 +22,7 @@ import { disposeInstance } from "@/effect/instance-registry" import { DagSupervisionSweep } from "@/dag/runtime/supervision-sweep" import { EventV2Bridge } from "@/event-v2-bridge" import { SessionPrompt } from "@/session/prompt" +import { SessionAutomationLease } from "@/session/automation-lease" import { MessageID } from "@/session/schema" import { Session } from "@/session/session" import { SessionStatus } from "@/session/status" @@ -145,7 +146,14 @@ function supervisionLayer(input: { }), }) const loop = DagLoop.layer.pipe(Layer.provide(base), Layer.provide(session), Layer.provide(prompt), Layer.provide(agent)) - const sweep = DagSupervisionSweep.layerWithoutDeps.pipe(Layer.provide(base), Layer.provide(prompt)) + // #343: the sweep terminalizes workflows and releases their automation + // lease after a host-level settle — give it the real (process-level) lease + // registry so the unregister path is exercised, not a silent mock no-op. + const sweep = DagSupervisionSweep.layerWithoutDeps.pipe( + Layer.provide(base), + Layer.provide(prompt), + Layer.provide(SessionAutomationLease.defaultLayer), + ) return Layer.merge(Layer.merge(base, loop), sweep) } @@ -335,6 +343,13 @@ describe("DAG node supervision — deadline enforcement (production incident)", "5 seconds", ) expect(swept?.errorClass).toBe("timeout") + // #343 (workflow rot): with the owning instance gone, the sweep — + // not a dead DagLoop's checkCompletion — must land the workflow's + // terminal transition once every current-revision node is terminal. + // The incident graph is a single required worker, so its failure is + // a workflow FAILURE. + const wf = yield* store.getWorkflow(dagID) + expect(wf?.status).toBe("failed") }), ), ) @@ -470,4 +485,46 @@ describe("DagSupervisionSweep cadence derivation (pure)", () => { expect(DagSupervisionSweep.frozenTicksNeeded(Dag.DEFAULT_WORKFLOW_CONFIG.nodeTimeoutMs)).toBe(11) expect(DagSupervisionSweep.frozenTicksNeeded(1_800_000)).toBe(31) }) + + it("#342: back-derives a safe cadence bound from durable columns", () => { + // Spawned at a 30-minute timeout, no extensions: granted total is + // exactly one cadence. + expect(DagSupervisionSweep.escalateIntervalDurable(1_800_000, 0)).toBe(1_800_000) + // Escalations move only the counter, never the deadline — after three + // escalations with no extension the granted total is still one cadence + // (NOT the average; dividing would under-estimate and un-safety the + // window). + expect(DagSupervisionSweep.escalateIntervalDurable(1_800_000, 0)).toBe(1_800_000) + // An extension grants another timeout: the total over-estimates the + // current cadence, which delays (never causes) a settle — safe. + expect(DagSupervisionSweep.escalateIntervalDurable(3_600_000, 0)).toBe(3_600_000) + // Sub-second derivation floors to the watcher's 1s minimum. + expect(DagSupervisionSweep.escalateIntervalDurable(500, 0)).toBe(1_000) + // Legacy/edge rows are not derivable: 0 lets the config value decide. + expect(DagSupervisionSweep.escalateIntervalDurable(undefined, 0)).toBe(0) + expect(DagSupervisionSweep.escalateIntervalDurable(1_800_000, undefined)).toBe(0) + expect(DagSupervisionSweep.escalateIntervalDurable(null, null)).toBe(0) + expect(DagSupervisionSweep.escalateIntervalDurable(0, 1_800_000)).toBe(0) + }) + + it("#342: a replan-lowered config never shortens the window below the live watcher's cadence", () => { + // The incident shape: spawned at a 30-minute cadence, replan lowers the + // persisted timeout to 10 minutes, the A1/Q2 re-time gate keeps the old + // watcher. The config alone would give an 11-tick window (~11 minutes) + // and prematurely sweep a healthy node; the durable bound recovers the + // 30-minute cadence and the window stays 31 ticks. + const configInterval = DagSupervisionSweep.escalateIntervalFromConfig( + JSON.stringify({ nodes: [{ id: "worker", depends_on: [], worker_config: { timeout_ms: 600_000 } }] }), + "worker", + ) + const durableInterval = DagSupervisionSweep.escalateIntervalDurable(1_800_000, 0) + const windowInterval = Math.max(configInterval, durableInterval) + expect(configInterval).toBe(600_000) + expect(durableInterval).toBe(1_800_000) + expect(DagSupervisionSweep.frozenTicksNeeded(windowInterval)).toBe(31) + // Re-timed watcher (watcher matches the lowered config): the config + // decides, the durable over-estimate only delays detection. + const reTimedWindow = Math.max(600_000, DagSupervisionSweep.escalateIntervalDurable(600_000, 0)) + expect(DagSupervisionSweep.frozenTicksNeeded(reTimedWindow)).toBe(11) + }) }) diff --git a/packages/opencode/test/dag/dag-recovery.test.ts b/packages/opencode/test/dag/dag-recovery.test.ts index 9a6ae584e4..322e78b774 100644 --- a/packages/opencode/test/dag/dag-recovery.test.ts +++ b/packages/opencode/test/dag/dag-recovery.test.ts @@ -171,22 +171,24 @@ describe("reconcileWorkflow", () => { expect(result).toEqual({ reconciled: 0, ownershipLost: 0 }) }) - it("aborts recovery when a stale restart-orphan session cannot be cancelled", async () => { + // #349/REC-1: a persistent stale-child cancel failure no longer aborts + // the whole reconcile — that made the workflow unadoptable in this process + // (its running nodes would never be scheduled until a restart). The + // failure is logged and the reconcile continues; this test pinned the old + // abort behavior. + it("survives a stale restart-orphan cancel failure and continues the reconcile", async () => { const events: TrackedEvent[] = [] const nodes = [makeNodeRow({ id: "n1", status: "queued", childSessionId: "ses_stale" })] const dagLayer = makeDagLayer(nodes, events) const checkStatus = () => Effect.succeed("active" as const) const cancelSession = () => Effect.fail(new Error("cancel unavailable")) - const exit = await Effect.runPromise( - reconcileWorkflow("wf-1", checkStatus, cancelSession).pipe( - Effect.provide(dagLayer), - Effect.exit, - ), + const result = await Effect.runPromise( + reconcileWorkflow("wf-1", checkStatus, cancelSession).pipe(Effect.provide(dagLayer)), ) - expect(Exit.isFailure(exit)).toBe(true) expect(events).toEqual([]) + expect(result).toEqual({ reconciled: 0, ownershipLost: 0 }) }) it("cancels and fails a zero-message child classified as unknown exactly once", async () => { @@ -429,4 +431,83 @@ describe("rehydration via toSchedulingNodes", () => { expect(rt.isPaused()).toBe(true) expect(rt.getReadyNodes()).toEqual([]) }) + + // #345: the live path (spawn.ts) settles a schemaless node with the child's + // last assistant text; recovery must mirror it — a crash between the child's + // final reply and the NodeCompleted publish must not erase a string verdict + // (a bare {"verdict":"replan"} checkpoint reply would otherwise vanish). + it("settles a completed schemaless node with the child's last assistant text", async () => { + const events: TrackedEvent[] = [] + const nodes = [makeNodeRow({ id: "cp", status: "running", childSessionId: "ses_1" })] + const dagLayer = makeDagLayer(nodes, events) + const checkStatus = () => Effect.succeed<"active" | "completed" | "failed" | "unknown">("completed") + const verdict = '{"verdict":"replan","reason":"wrong file"}' + + await Effect.runPromise( + reconcileWorkflow( + "wf-1", + checkStatus, + undefined, + { nodes: [{ id: "cp" }] }, + () => Effect.succeed(verdict), + ).pipe(Effect.provide(dagLayer)), + ) + + expect(events).toContainEqual({ type: "nodeCompleted", nodeID: "cp", output: verdict }) + expect(events).not.toContainEqual({ type: "nodeFailed", nodeID: "cp" }) + }) + + it("floors a missing text part to the live path's empty string, not undefined", async () => { + const events: TrackedEvent[] = [] + const nodes = [makeNodeRow({ id: "n1", status: "running", childSessionId: "ses_1" })] + const dagLayer = makeDagLayer(nodes, events) + const checkStatus = () => Effect.succeed<"active" | "completed" | "failed" | "unknown">("completed") + + await Effect.runPromise( + reconcileWorkflow("wf-1", checkStatus, undefined, { nodes: [{ id: "n1" }] }, () => Effect.succeed(undefined)).pipe( + Effect.provide(dagLayer), + ), + ) + + expect(events).toContainEqual({ type: "nodeCompleted", nodeID: "n1", output: "" }) + }) + + // #345 degenerate branch: an unparseable workflow row (explicit null) must + // fail loudly — undefined-completing a schema-carrying node would bypass + // settleCapturedOutput's review contract. + it("fails a completed node whose workflow config is unparseable (null), not undefined-complete", async () => { + const events: TrackedEvent[] = [] + const nodes = [makeNodeRow({ id: "n1", status: "running", childSessionId: "ses_1" })] + const dagLayer = makeDagLayer(nodes, events) + const checkStatus = () => Effect.succeed<"active" | "completed" | "failed" | "unknown">("completed") + + const result = await Effect.runPromise( + reconcileWorkflow("wf-1", checkStatus, undefined, null, () => Effect.succeed("text")).pipe( + Effect.provide(dagLayer), + ), + ) + + expect(events).toContainEqual({ + type: "nodeFailed", + nodeID: "n1", + reason: expect.stringContaining("unparseable"), + trigger: "exec_failed", + }) + expect(events).not.toContainEqual({ type: "nodeCompleted", nodeID: "n1" }) + expect(result.ownershipLost).toBe(1) + }) + + // Legacy callers that inject no reader keep the undefined settlement. + it("keeps the legacy undefined settlement when no text reader is injected", async () => { + const events: TrackedEvent[] = [] + const nodes = [makeNodeRow({ id: "n1", status: "running", childSessionId: "ses_1" })] + const dagLayer = makeDagLayer(nodes, events) + const checkStatus = () => Effect.succeed<"active" | "completed" | "failed" | "unknown">("completed") + + await Effect.runPromise( + reconcileWorkflow("wf-1", checkStatus, undefined, { nodes: [{ id: "n1" }] }).pipe(Effect.provide(dagLayer)), + ) + + expect(events).toContainEqual({ type: "nodeCompleted", nodeID: "n1" }) + }) }) diff --git a/packages/opencode/test/dag/dag-review-audit-regressions.test.ts b/packages/opencode/test/dag/dag-review-audit-regressions.test.ts index e0a98b9a8e..13a07cc3c0 100644 --- a/packages/opencode/test/dag/dag-review-audit-regressions.test.ts +++ b/packages/opencode/test/dag/dag-review-audit-regressions.test.ts @@ -85,6 +85,46 @@ describe("H1: validateAgainstSchema with JSON Schema type arrays", () => { }) }) +// ============================================================================ +// #346 — object-semantic keywords without `type: "object"` used to let any +// non-object value pass silently (ok:true), hiding the DAG-01 consequence +// inside a legal schema spelling. +// ============================================================================ +describe("#346: object-semantic keywords imply an object value", () => { + it("rejects a string when the schema has required/properties but no type", () => { + const schema = { required: ["verdict"], properties: { verdict: { type: "string" } } } + expect(validateAgainstSchema("accepted", schema).ok).toBe(false) + }) + + it("rejects a number for a properties-only schema", () => { + expect(validateAgainstSchema(42, { properties: { verdict: { type: "string" } } }).ok).toBe(false) + }) + + it("still accepts a conforming object for the same schema", () => { + const schema = { required: ["verdict"], properties: { verdict: { type: "string" } } } + expect(validateAgainstSchema({ verdict: "replan" }, schema).ok).toBe(true) + }) + + it("still rejects a missing required field on a conforming-typed object", () => { + const schema = { required: ["verdict"], properties: { verdict: { type: "string" } } } + expect(validateAgainstSchema({}, schema).ok).toBe(false) + }) + + it("additionalProperties:false fences keys even without a properties block", () => { + expect(validateAgainstSchema({ rogue: 1 }, { type: "object", additionalProperties: false }).ok).toBe(false) + expect(validateAgainstSchema({}, { type: "object", additionalProperties: false }).ok).toBe(true) + }) + + it("additionalProperties as a keyword implies an object value", () => { + expect(validateAgainstSchema("str", { additionalProperties: false }).ok).toBe(false) + }) + + it("an unknown type name fails instead of permissively passing", () => { + expect(validateAgainstSchema("x", { type: "strng" }).ok).toBe(false) + expect(validateAgainstSchema(42, { type: "strng" }).ok).toBe(false) + }) +}) + // ============================================================================ // B1 — recovery path completes review nodes without validateReviewResult // ============================================================================ diff --git a/packages/opencode/test/installation/installation.test.ts b/packages/opencode/test/installation/installation.test.ts index aaf2a9ea02..cc64031e89 100644 --- a/packages/opencode/test/installation/installation.test.ts +++ b/packages/opencode/test/installation/installation.test.ts @@ -3,7 +3,6 @@ import { Effect, Layer, Stream } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { Installation } from "../../src/installation" -import { InstallationChannel } from "@opencode-ai/core/installation/version" import { AppProcess } from "@opencode-ai/core/process" import { testEffect } from "../lib/effect" @@ -58,117 +57,28 @@ function testLayer( describe("installation", () => { describe("latest", () => { - testEffect(testLayer(() => jsonResponse({ tag_name: "v1.2.3" }))).effect( - "reads release version from GitHub releases", - () => - Effect.gen(function* () { - const result = yield* Installation.use.latest("unknown") - expect(result).toBe("1.2.3") - }), - ) - - testEffect(testLayer(() => jsonResponse({ tag_name: "v4.0.0-beta.1" }))).effect( - "strips v prefix from GitHub release tag", - () => - Effect.gen(function* () { - const result = yield* Installation.use.latest("curl") - expect(result).toBe("4.0.0-beta.1") - }), - ) - - const npmCalls: string[] = [] - testEffect( - testLayer((request) => { - npmCalls.push(request.url) - return jsonResponse({ version: "1.5.0" }) - }), - ).effect("reads npm versions via registry", () => - Effect.gen(function* () { - const result = yield* Installation.use.latest("npm") - expect(result).toBe("1.5.0") - expect(npmCalls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`) - }), - ) - - const bunCalls: string[] = [] - testEffect( - testLayer((request) => { - bunCalls.push(request.url) - return jsonResponse({ version: "1.6.0" }) - }), - ).effect("reads bun versions via registry", () => - Effect.gen(function* () { - const result = yield* Installation.use.latest("bun") - expect(result).toBe("1.6.0") - expect(bunCalls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`) - }), - ) - - const pnpmCalls: string[] = [] + const urls: string[] = [] testEffect( testLayer((request) => { - pnpmCalls.push(request.url) - return jsonResponse({ version: "1.7.0" }) + urls.push(request.url) + return jsonResponse({ tag_name: "graphagent-v1.2.3" }) }), - ).effect("reads pnpm versions via registry", () => + ).effect("reads release version from the fork GitHub releases", () => Effect.gen(function* () { - const result = yield* Installation.use.latest("pnpm") - expect(result).toBe("1.7.0") - expect(pnpmCalls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`) + const result = yield* Installation.use.latest() + expect(result).toBe("1.2.3") + expect(urls).toContain("https://api.github.com/repos/LeXwDeX/OpenCode-GraphAgent/releases/latest") }), ) - testEffect(testLayer(() => jsonResponse({ version: "2.3.4" }))).effect("reads scoop manifest versions", () => - Effect.gen(function* () { - const result = yield* Installation.use.latest("scoop") - expect(result).toBe("2.3.4") - }), - ) - - testEffect(testLayer(() => jsonResponse({ d: { results: [{ Version: "3.4.5" }] } }))).effect( - "reads chocolatey feed versions", + testEffect(testLayer(() => jsonResponse({ tag_name: "graphagent-v4.0.0-beta.1" }))).effect( + "strips the graphagent-v prefix from release tags", () => Effect.gen(function* () { - const result = yield* Installation.use.latest("choco") - expect(result).toBe("3.4.5") + const result = yield* Installation.use.latest() + expect(result).toBe("4.0.0-beta.1") }), ) - - testEffect( - testLayer( - () => jsonResponse({ versions: { stable: "2.0.0" } }), - (cmd, args) => { - // getBrewFormula: return core formula (no tap) - if (cmd === "brew" && args.includes("--formula") && args.includes("anomalyco/tap/opencode")) return "" - if (cmd === "brew" && args.includes("--formula") && args.includes("opencode")) return "opencode" - return "" - }, - ), - ).effect("reads brew formulae API versions", () => - Effect.gen(function* () { - const result = yield* Installation.use.latest("brew") - expect(result).toBe("2.0.0") - }), - ) - - const brewInfoJson = JSON.stringify({ - formulae: [{ versions: { stable: "2.1.0" } }], - }) - testEffect( - testLayer( - () => jsonResponse({}), // HTTP not used for tap formula - (cmd, args) => { - if (cmd === "brew" && args.includes("anomalyco/tap/opencode") && args.includes("--formula")) return "opencode" - if (cmd === "brew" && args.includes("--json=v2")) return brewInfoJson - return "" - }, - ), - ).effect("reads brew tap info JSON via CLI", () => - Effect.gen(function* () { - const result = yield* Installation.use.latest("brew") - expect(result).toBe("2.1.0") - }), - ) }) describe("upgrade", () => { diff --git a/packages/opencode/test/memory/memory-global-identity.test.ts b/packages/opencode/test/memory/memory-global-identity.test.ts index 9d38c5672e..108272dbc1 100644 --- a/packages/opencode/test/memory/memory-global-identity.test.ts +++ b/packages/opencode/test/memory/memory-global-identity.test.ts @@ -204,7 +204,10 @@ describe("MEM-PR01-R1-03: memory is inert once the identity row is retired", () Effect.gen(function* () { const retired = yield* memory.search({ sessionID, messages: [userMessage(sessionID)], query: "任意查询" }) expect(retired.status).toBe("unavailable") - expect(yield* memory.setEnabled(true)).toBe("Memory remains off") + // #350: a /memory on that cannot activate says WHY instead of + // the bare "remains off" (retired identity / global identity + // are actionable reasons). + expect(yield* memory.setEnabled(true)).toContain("unavailable") }), ) }), @@ -369,7 +372,9 @@ describe("MEM-PR01-00: memory is inert under the shared global identity", () => yield* project.setInitialized(info.id) yield* configStore.writeGlobal(baseConfig) - expect(yield* memory.setEnabled(true)).toBe("Memory remains off") + // #350: says WHY (commit-less repo under the shared global + // identity) instead of the bare "remains off". + expect(yield* memory.setEnabled(true)).toContain("unavailable") expect(fs.existsSync(path.join(dir, ".opencode", "memory.jsonc"))).toBe(false) }), ).pipe(Effect.provide(testInstanceStoreLayer)) @@ -408,4 +413,34 @@ describe("MEM-PR01-00: memory is inert under the shared global identity", () => }), { timeout: 30_000 }, ) + + // #350: the inert gates must be self-explanatory — /memory on and + // memory_search surface the actionable reason instead of a bare "off". + it.live( + "statusReason names the missing /init stamp, and /memory on carries it", + () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* provideInstance(dir)( + Effect.gen(function* () { + const project = yield* Project.Service + const memory = yield* Memory.Service + + const { project: info } = yield* project.fromDirectory(dir) + expect(info.id).not.toBe(ProjectV2.ID.global) + // NOT setInitialized: a real git identity without the /init stamp + // is the exact shape that used to fail silently. + expect(yield* memory.statusReason()).toContain("/init") + + const turningOn = yield* memory.setEnabled(true) + expect(turningOn).toContain("/init") + expect(turningOn).not.toBe("Memory remains off") + + yield* project.setInitialized(info.id) + expect(yield* memory.statusReason()).toBeUndefined() + }), + ).pipe(Effect.provide(testInstanceStoreLayer)) + }), + { timeout: 30_000 }, + ) }) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 3e68d25a5a..842399295f 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -1913,7 +1913,14 @@ const scenarios: Scenario[] = [ http.protected .post("/dag", "dag.start") .mutating() - .seeded((ctx) => ctx.session({ title: "DAG start owner" })) + .withLlm() + .seeded((ctx) => + // environment-profile authoring resolves each node's model through + // node -> tier -> agent -> parent(session.model); the exerciser's fake + // provider only exists under withLlm, and the parent chain needs the + // session to carry the fake model explicitly. + ctx.session({ title: "DAG start owner", model: { providerID: "test", id: "test-model" } }), + ) .at((ctx) => ({ path: "/dag", headers: ctx.headers(), @@ -1935,6 +1942,38 @@ const scenarios: Scenario[] = [ }), ), + // #344: dag.start must pass Workflow Authoring — a reporting checkpoint + // gated on its output without declaring output_schema (the DAG-01 danger + // shape: an unsatisfiable gate silently skips the subtree while the + // workflow reports COMPLETED) is an authoring error, not a creatable graph. + http.protected + .post("/dag", "dag.start.schemaless-gate") + .mutating() + .seeded((ctx) => ctx.session({ title: "DAG start gate owner" })) + .at((ctx) => ({ + path: "/dag", + headers: ctx.headers(), + body: { + session_id: ctx.state.id, + config: { + name: "schemaless-gate", + nodes: [ + { id: "cp", name: "CP", worker_type: "general", depends_on: [], required: true, report_to_parent: true, prompt_template: { inline: "noop" } }, + { + id: "after", + name: "After", + worker_type: "general", + depends_on: ["cp"], + required: true, + condition: "cp.output.verdict == \"accept\"", + prompt_template: { inline: "noop" }, + }, + ], + }, + }, + })) + .status(400), + http.protected .post("/dag/{dagID}/control", "dag.control") .mutating() diff --git a/packages/opencode/test/server/httpapi-exercise/runner.ts b/packages/opencode/test/server/httpapi-exercise/runner.ts index 408360d1f2..bc3b7effee 100644 --- a/packages/opencode/test/server/httpapi-exercise/runner.ts +++ b/packages/opencode/test/server/httpapi-exercise/runner.ts @@ -141,7 +141,7 @@ function withContext( return Bun.write(`${directory()}/${name}`, content) }).pipe(Effect.asVoid), session: (input) => - run(modules.Session.Service.use((svc) => svc.create({ title: input?.title, parentID: input?.parentID }))), + run(modules.Session.Service.use((svc) => svc.create({ title: input?.title, parentID: input?.parentID, model: input?.model as never }))), sessionGet: (sessionID) => run(modules.Session.Service.use((svc) => svc.get(sessionID))).pipe( Effect.catchCause(() => Effect.succeed(undefined)), diff --git a/packages/opencode/test/server/httpapi-exercise/types.ts b/packages/opencode/test/server/httpapi-exercise/types.ts index b0dd647778..ee7a4c86b7 100644 --- a/packages/opencode/test/server/httpapi-exercise/types.ts +++ b/packages/opencode/test/server/httpapi-exercise/types.ts @@ -54,7 +54,7 @@ export type ScenarioContext = { directory: string | undefined headers: (extra?: Record) => Record file: (name: string, content: string) => Effect.Effect - session: (input?: { title?: string; parentID?: SessionID }) => Effect.Effect + session: (input?: { title?: string; parentID?: SessionID; model?: { id: string; providerID: string } }) => Effect.Effect sessionGet: (sessionID: SessionID) => Effect.Effect project: () => Effect.Effect message: (sessionID: SessionID, input?: { text?: string }) => Effect.Effect diff --git a/packages/opencode/test/server/httpapi-goalloop-wiring.test.ts b/packages/opencode/test/server/httpapi-goalloop-wiring.test.ts new file mode 100644 index 0000000000..512f896f32 --- /dev/null +++ b/packages/opencode/test/server/httpapi-goalloop-wiring.test.ts @@ -0,0 +1,70 @@ +import { describe, expect } from "bun:test" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect, Layer, Option } from "effect" +import { GoalLoop } from "@/goal/loop" +import { HttpApiApp } from "@/server/routes/instance/httpapi/server" +import { testEffect } from "../lib/effect" + +// Issue #340 regression: standing goals stalled after their first turn on +// headless serve/web, the desktop sidecar, and non-CWD TUI directories. +// GoalLoop's only consumer is InstanceBootstrap's +// `serviceOption(GoalLoop.Service).init()` (idle-event subscription + startup +// goal scan), which runs in the request fiber's ambient context. GoalLoop.node +// was listed only in AppLayer, so the service was absent from the server app +// graph and bootstrap silently skipped goal arming. These tests build the +// exact node graph the server provides to route handlers and assert the +// service is present there. + +const appLayer = LayerNode.buildLayer(HttpApiApp.app) + +const appIt = testEffect(Layer.mergeAll(appLayer, CrossSpawnSpawner.defaultLayer)) + +describe("server app graph goal loop wiring", () => { + appIt.instance("exposes GoalLoop.Service in the ambient context bootstrap runs in", () => + Effect.gen(function* () { + const goalLoop = yield* Effect.serviceOption(GoalLoop.Service) + expect(Option.isSome(goalLoop)).toBe(true) + }), + ) + + // The init seam resolves through the app-graph output (not a per-consumer + // dependency scope): a recorder replacement proves both that serviceOption + // resolves THIS instance and that the harness's instance bootstrap reaches + // GoalLoop.init() from the request-fiber ambient context (the #340 bug was + // exactly that call being a silent no-op). + const initCalls: number[] = [] + const spyIt = testEffect( + Layer.mergeAll( + LayerNode.buildLayer(HttpApiApp.app, { + replacements: [ + LayerNode.replace( + GoalLoop.node, + Layer.mock(GoalLoop.Service, { + init: () => + Effect.sync(() => { + initCalls.push(initCalls.length) + }), + }), + ), + ], + }), + CrossSpawnSpawner.defaultLayer, + ), + ) + + spyIt.instance("bootstrap's serviceOption path reaches GoalLoop.init()", () => + Effect.gen(function* () { + // The test harness performs an instance bootstrap while building this + // context; before the fix that bootstrap found GoalLoop absent and + // silently skipped init. + expect(initCalls.length).toBeGreaterThan(0) + initCalls.length = 0 + const goalLoop = yield* Effect.serviceOption(GoalLoop.Service) + expect(Option.isSome(goalLoop)).toBe(true) + if (Option.isNone(goalLoop)) return + yield* goalLoop.value.init() + expect(initCalls).toEqual([0]) + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-sweep-wiring.test.ts b/packages/opencode/test/server/httpapi-sweep-wiring.test.ts new file mode 100644 index 0000000000..76dc525f43 --- /dev/null +++ b/packages/opencode/test/server/httpapi-sweep-wiring.test.ts @@ -0,0 +1,61 @@ +import { describe, expect } from "bun:test" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect, Layer, Option } from "effect" +import { DagSupervisionSweep } from "@/dag/runtime/supervision-sweep" +import { HttpApiApp } from "@/server/routes/instance/httpapi/server" +import { testEffect } from "../lib/effect" + +// Issue #341 regression: the host-level deadline supervision sweep (response +// to the 2026-08-18 orphaned-nodes incident) was constructed only in AppLayer. +// The desktop sidecar calls Server.listen without effectCmd, so AppLayer never +// existed there — the sweep never ran on the desktop default path. Any process +// that serves HTTP builds the server app graph exactly once per listener, so +// listing DagSupervisionSweep.node there covers serve/web/TUI/sidecar alike. + +const appIt = testEffect( + Layer.mergeAll(LayerNode.buildLayer(HttpApiApp.app), CrossSpawnSpawner.defaultLayer), +) + +describe("server app graph supervision sweep wiring", () => { + appIt.instance("exposes DagSupervisionSweep.Service in the serving context", () => + Effect.gen(function* () { + const sweep = yield* Effect.serviceOption(DagSupervisionSweep.Service) + expect(Option.isSome(sweep)).toBe(true) + }), + ) + + // The real graph now forks a live sweep fiber (per-listener scope). A + // recorder replacement proves the app graph resolves THIS node's output — + // i.e. the sidecar's Server.listen path reaches the sweep construction — + // without depending on tick timing. + const sweepInits: number[] = [] + const spyIt = testEffect( + Layer.mergeAll( + LayerNode.buildLayer(HttpApiApp.app, { + replacements: [ + LayerNode.replace( + DagSupervisionSweep.node, + Layer.mock(DagSupervisionSweep.Service, { + sweepOnce: () => + Effect.sync(() => { + sweepInits.push(sweepInits.length) + }), + }), + ), + ], + }), + CrossSpawnSpawner.defaultLayer, + ), + ) + + spyIt.instance("resolves DagSupervisionSweep.Service from the app-graph output", () => + Effect.gen(function* () { + const sweep = yield* Effect.serviceOption(DagSupervisionSweep.Service) + expect(Option.isSome(sweep)).toBe(true) + if (Option.isNone(sweep)) return + yield* sweep.value.sweepOnce() + expect(sweepInits.length).toBeGreaterThanOrEqual(1) + }), + ) +}) diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index af7eada8f8..fdd88e58dd 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -87,6 +87,10 @@ export const Definitions = { dag_resume: keybind("none", "Resume selected DAG workflow"), dag_step: keybind("none", "Step selected DAG workflow (run one node)"), dag_cancel: keybind("none", "Cancel selected DAG workflow"), + // #349/F6: plugin-level palette command — without a Definitions/CommandMap + // entry it is not rebindable and never appears in the keybind config + // schema. + dag_cancel_active: keybind("none", "Cancel the session's active DAG workflow"), editor_open: keybind("e", "Open external editor"), theme_list: keybind("t", "List available themes"), @@ -306,6 +310,7 @@ export const CommandMap = { dag_resume: "dag.resume", dag_step: "dag.step", dag_cancel: "dag.cancel", + dag_cancel_active: "dag.cancel.active", editor_open: "prompt.editor", theme_list: "theme.switch", theme_switch_mode: "theme.switch_mode", diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 952713cdec..4fe07c284c 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -622,6 +622,26 @@ export const { ).then(() => undefined) } + // #349/F3: goal.updated/goal.cleared are ephemeral (not in the durable + // replay set), so a goal.cleared missed during a disconnect would leave + // a stale goal in the sidebar indefinitely — the reconnect hook must + // refresh goals too, symmetric with refreshDagSummaries. Only sessions + // with a stored goal can go stale; a missing goal has nothing to clear. + let goalReconnectInFlight = false + const refreshGoals = (): Promise => { + const sessionIDs = Object.keys(store.goal) + if (sessionIDs.length === 0) return Promise.resolve() + return Promise.all( + sessionIDs.map((sessionID) => + sdk.client.session.goal({ sessionID }, { throwOnError: false }) + .then((response) => { + setStore("goal", sessionID, response.data ?? undefined) + }) + .catch(() => {}), + ), + ).then(() => undefined) + } + let dagReconnectInFlight = false const unsubscribeReconnect = sdk.event.on("reconnected", () => { if (dagReconnectInFlight) return @@ -629,6 +649,11 @@ export const { refreshDagSummaries().finally(() => { dagReconnectInFlight = false }) + if (goalReconnectInFlight) return + goalReconnectInFlight = true + void refreshGoals().finally(() => { + goalReconnectInFlight = false + }) }) onMount(() => { diff --git a/spec_git/policy.yaml b/spec_git/policy.yaml new file mode 100644 index 0000000000..fe3768c27e --- /dev/null +++ b/spec_git/policy.yaml @@ -0,0 +1,4 @@ +version: 1 +required_checks: + - Typecheck + - Unit Tests (linux)