From 8b2ef0186ed9898bf0040db0769c2e2bfc46086b Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 10:30:19 +0800 Subject: [PATCH 01/48] fix(opencode): auto-update notifies only; latest() tracks fork release feed Closes #351 --- .specgit.yaml | 7 ++ packages/core/src/config.ts | 2 +- packages/opencode/src/cli/upgrade.ts | 51 ++------ packages/opencode/src/installation/index.ts | 71 ++--------- .../instance/httpapi/handlers/global.ts | 2 +- .../test/installation/installation.test.ts | 112 ++---------------- 6 files changed, 41 insertions(+), 204 deletions(-) create mode 100644 .specgit.yaml diff --git a/.specgit.yaml b/.specgit.yaml new file mode 100644 index 0000000000..741ce82e68 --- /dev/null +++ b/.specgit.yaml @@ -0,0 +1,7 @@ +version: 1 +delivery: fix-opencode-auto +context: + kind: branch + branch: feat/351-fix-opencode-auto +issues: + - 351 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/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 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", () => { From 271a6d70cf4c249c574ff2ee60e73cdd37dd73ce Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 10:39:15 +0800 Subject: [PATCH 02/48] chore(specgit): enable SpecGit delivery harness - spec_git/policy.yaml: required check 'Typecheck' (dev-layer gate parity) - specgit-accept.yml: PR-to-dev acceptance evaluation - .opencode/command: /specgit-issue, /specgit-finish entry points - .opencode/hooks/specgit-merge-guard.sh: merge guard script (wired by specgit setup) - AGENTS.md/CLAUDE.md: managed specgit block Closes #353 --- .github/workflows/specgit-accept.yml | 90 ++++++++++++++++++++++++++ .opencode/command/specgit-finish.md | 27 ++++++++ .opencode/command/specgit-issue.md | 22 +++++++ .opencode/hooks/specgit-merge-guard.sh | 20 ++++++ .specgit.yaml | 6 +- AGENTS.md | 38 +++++++++++ CLAUDE.md | 38 +++++++++++ spec_git/policy.yaml | 3 + 8 files changed, 241 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/specgit-accept.yml create mode 100644 .opencode/command/specgit-finish.md create mode 100644 .opencode/command/specgit-issue.md create mode 100755 .opencode/hooks/specgit-merge-guard.sh create mode 100644 spec_git/policy.yaml diff --git a/.github/workflows/specgit-accept.yml b/.github/workflows/specgit-accept.yml new file mode 100644 index 0000000000..46dfb5b6eb --- /dev/null +++ b/.github/workflows/specgit-accept.yml @@ -0,0 +1,90 @@ +name: SpecGit Acceptance + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + specgit-acceptance: + name: SpecGit Acceptance + runs-on: ubuntu-latest + timeout-minutes: 15 + 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 pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '20.19.0' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build CLI + run: pnpm run build + + - 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'; + import { parse } from 'yaml'; + const policy = parse(readFileSync('spec_git/policy.yaml', 'utf8')); + const required = policy.required_checks ?? []; + 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)); + }; + const deadline = Date.now() + 15 * 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: node bin/specgit.js 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 index 741ce82e68..ab8647b621 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,7 +1,7 @@ version: 1 -delivery: fix-opencode-auto +delivery: chore-specgit-enable context: kind: branch - branch: feat/351-fix-opencode-auto + branch: feat/353-chore-specgit-enable issues: - - 351 + - 353 diff --git a/AGENTS.md b/AGENTS.md index 720f15959e..58f3e3cb37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -240,3 +240,41 @@ 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. + 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/spec_git/policy.yaml b/spec_git/policy.yaml new file mode 100644 index 0000000000..ff8aaa9c9f --- /dev/null +++ b/spec_git/policy.yaml @@ -0,0 +1,3 @@ +version: 1 +required_checks: + - Typecheck From bb93543707deadb3614ecc6c93c78be5ab40c400 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 10:46:02 +0800 Subject: [PATCH 03/48] chore(specgit): acceptance workflow triggers on PRs to dev specgit issue regenerated the harness with the repo default branch (main); delivery PRs in this repo target dev (layered gating), so the trigger must be [dev]. --- .github/workflows/specgit-accept.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/specgit-accept.yml b/.github/workflows/specgit-accept.yml index 46dfb5b6eb..f01c538205 100644 --- a/.github/workflows/specgit-accept.yml +++ b/.github/workflows/specgit-accept.yml @@ -2,7 +2,9 @@ name: SpecGit Acceptance on: pull_request: - branches: [main] + # Delivery PRs target dev (fast-integration layer); dev→main promotion + # stays governed by the protect-main Ruleset's four required checks. + branches: [dev] permissions: contents: read From b2cd8dfb4ef895a5b88435ea983ce7804cd6c1a0 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 10:48:45 +0800 Subject: [PATCH 04/48] chore(specgit): acceptance workflow installs npm specgit, drops pnpm Generated harness assumed the SpecGit repo's own pnpm toolchain. This repo is a bun workspace without packageManager/pnpm-lock: setup-node + npm i -g specgit, minimal policy.yaml parsing without a yaml dependency. --- .github/workflows/specgit-accept.yml | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/.github/workflows/specgit-accept.yml b/.github/workflows/specgit-accept.yml index f01c538205..63bbebefcf 100644 --- a/.github/workflows/specgit-accept.yml +++ b/.github/workflows/specgit-accept.yml @@ -25,20 +25,15 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Setup pnpm - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 - - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: '20.19.0' - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile + node-version: '22' - - name: Build CLI - run: pnpm run build + # This repo is a bun workspace and does not vendor the SpecGit CLI; + # install the published CLI instead of building from source. + - name: Install specgit CLI + run: npm install -g specgit - name: Wait for sibling checks # The verdict must see the OTHER required checks in a terminal @@ -53,9 +48,11 @@ jobs: run: | node --input-type=module <<'EOF' import { readFileSync } from 'node:fs'; - import { parse } from 'yaml'; - const policy = parse(readFileSync('spec_git/policy.yaml', 'utf8')); - const required = policy.required_checks ?? []; + // 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', @@ -87,6 +84,6 @@ jobs: EOF - name: specgit finish - run: node bin/specgit.js finish --json + run: specgit finish --json env: GH_TOKEN: ${{ github.token }} From e4b60ca8daf7a234d40a80c1c5d22b2e41f6eb11 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 10:55:04 +0800 Subject: [PATCH 05/48] chore(specgit): record PR binding 354 in delivery record --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index ab8647b621..578c62ae1c 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -5,3 +5,4 @@ context: branch: feat/353-chore-specgit-enable issues: - 353 +pr: 354 From e3877c0c955c1aa60cb689931a3cbb99d30d1fb9 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 11:02:40 +0800 Subject: [PATCH 06/48] docs(audit): add 2026-08-19 deep-dive audit report Five-track deep-dive closing the 2026-08-18 audit's limitation gaps: #316 trigger-source proof (closure rationale), loop.ts full re-read, DAG unaudited files + 4 invariants, out-of-package composition roots, effect-smol v4 semantics verification. 12 findings filed as #340-#350. Closes #355 --- .specgit.yaml | 7 +- docs/audit-dag-deepdive-2026-08-19.md | 191 ++++++++++++++++++++++++++ 2 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 docs/audit-dag-deepdive-2026-08-19.md diff --git a/.specgit.yaml b/.specgit.yaml index 578c62ae1c..d9197aece3 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,7 @@ version: 1 -delivery: chore-specgit-enable +delivery: docs-audit-add context: kind: branch - branch: feat/353-chore-specgit-enable + branch: feat/355-docs-audit-add issues: - - 353 -pr: 354 + - 355 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 记录性观察转维护决策 | From 43fe32cc95c6c55b221e8036f49a1444ae533b0f Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 11:03:25 +0800 Subject: [PATCH 07/48] chore(specgit): record PR binding 356 in delivery record --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index d9197aece3..57cb28cea5 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -5,3 +5,4 @@ context: branch: feat/355-docs-audit-add issues: - 355 +pr: 356 From 9cd6810ce82057e659db0a51579d95a5aff1c744 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 11:22:45 +0800 Subject: [PATCH 08/48] fix(goal): wire GoalLoop.node into the server request-context app graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GoalLoop's only consumer is InstanceBootstrap's serviceOption(GoalLoop.Service) .init(), which runs in the request fiber's ambient context. GoalLoop.node was listed only in AppLayer, so headless serve/web, the desktop sidecar, and non-CWD TUI directories never armed the idle-event subscription or the startup goal scan — standing goals stalled after their first turn (the same wiring class as SettingsHook/Memory in #311). Closes #340 --- .specgit.yaml | 7 +- .../server/routes/instance/httpapi/server.ts | 9 +++ .../server/httpapi-goalloop-wiring.test.ts | 70 +++++++++++++++++++ 3 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 packages/opencode/test/server/httpapi-goalloop-wiring.test.ts diff --git a/.specgit.yaml b/.specgit.yaml index 57cb28cea5..7122fa5852 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,7 @@ version: 1 -delivery: docs-audit-add +delivery: issue340 context: kind: branch - branch: feat/355-docs-audit-add + branch: feat/340-issue340 issues: - - 355 -pr: 356 + - 340 diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index accb6318c4..5bb6ec3b8d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -48,6 +48,7 @@ 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 { SettingsHook } from "@/hook/settings" import { HookRewakeLive } from "@/hook/rewake-live" import { SessionHooks } from "@/hook/session-hooks" @@ -297,6 +298,14 @@ 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, ]) export function createRoutes( 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]) + }), + ) +}) From a1e2a8b7ed31e07f02dd421fe7731373758554a0 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 11:23:26 +0800 Subject: [PATCH 09/48] chore(specgit): record PR binding in delivery record --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index 7122fa5852..4a7d7b3d66 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -5,3 +5,4 @@ context: branch: feat/340-issue340 issues: - 340 +pr: 357 From 86fae34cb1d2a254f38051c37981759b20c793e8 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 11:36:01 +0800 Subject: [PATCH 10/48] fix(dag): build DagSupervisionSweep in the server app graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host-level deadline supervision sweep was constructed only in AppLayer; the desktop sidecar calls Server.listen without effectCmd, so AppLayer never existed there and orphaned-node supervision never ran on the desktop default path. Every serving process builds the server app graph once per listener — listing the sweep node here covers serve/web/TUI/sidecar alike. Processes that also build AppLayer get a second instance; settle is convergent (workflow lock + guardNode + conditional projector UPDATE), so the duplicate is safe. Closes #341 --- .specgit.yaml | 7 +-- .../server/routes/instance/httpapi/server.ts | 12 ++++ .../test/server/httpapi-sweep-wiring.test.ts | 61 +++++++++++++++++++ 3 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 packages/opencode/test/server/httpapi-sweep-wiring.test.ts diff --git a/.specgit.yaml b/.specgit.yaml index 4a7d7b3d66..182b219b59 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,7 @@ version: 1 -delivery: issue340 +delivery: issue341 context: kind: branch - branch: feat/340-issue340 + branch: feat/341-issue341 issues: - - 340 -pr: 357 + - 341 diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 5bb6ec3b8d..0e7f278937 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -49,6 +49,7 @@ 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" @@ -306,6 +307,17 @@ export const app = LayerNode.group([ // 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/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) + }), + ) +}) From 2438e046b2f62ddc782d5c68cbd45c809eba31cd Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 11:36:43 +0800 Subject: [PATCH 11/48] chore(specgit): record PR binding in delivery record --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index 182b219b59..1e21fd4c34 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -5,3 +5,4 @@ context: branch: feat/341-issue341 issues: - 341 +pr: 358 From a425e14791e653711f1c2bfc39516869fc3c325c Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 11:50:35 +0800 Subject: [PATCH 12/48] fix(dag): sweep freeze window covers the live watcher's actual cadence The window derived solely from the current persisted config; replan can lower a running node's timeout_ms while the A1/Q2 re-time gate keeps the old watcher on its old cadence, so a config-only window could be shorter than the live watcher's cycle and prematurely settle a healthy node. deadline_ms is only ever written as grant-time + timeout (spawn and each extension; escalations move only the counter), so (deadline - started_at) upper-bounds the watcher's current cadence in every shape. Window = max(config cadence, durable bound). Closes #342 --- .../src/dag/runtime/supervision-sweep.ts | 41 +++++++++++++++++- .../test/dag/dag-node-supervision.test.ts | 42 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/dag/runtime/supervision-sweep.ts b/packages/opencode/src/dag/runtime/supervision-sweep.ts index fbfbb0ad60..ee097e5f45 100644 --- a/packages/opencode/src/dag/runtime/supervision-sweep.ts +++ b/packages/opencode/src/dag/runtime/supervision-sweep.ts @@ -90,6 +90,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* () { @@ -124,6 +154,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 +190,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 diff --git a/packages/opencode/test/dag/dag-node-supervision.test.ts b/packages/opencode/test/dag/dag-node-supervision.test.ts index a12681bd0a..49393ac4bf 100644 --- a/packages/opencode/test/dag/dag-node-supervision.test.ts +++ b/packages/opencode/test/dag/dag-node-supervision.test.ts @@ -470,4 +470,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) + }) }) From ca43330eeba8bf98a17e86713bcdefc607ec1926 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 11:51:33 +0800 Subject: [PATCH 13/48] chore(specgit): record delivery binding for issue 342 --- .specgit.yaml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index 1e21fd4c34..608cdb7e44 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,7 @@ version: 1 -delivery: issue341 +delivery: issue342 context: kind: branch - branch: feat/341-issue341 + branch: feat/342-issue342 issues: - - 341 -pr: 358 + - 342 From 4bb142e2879466c15e762fc0e96e618fd4953533 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 11:52:13 +0800 Subject: [PATCH 14/48] chore(specgit): record PR binding in delivery record --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index 608cdb7e44..a453220f39 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -5,3 +5,4 @@ context: branch: feat/342-issue342 issues: - 342 +pr: 359 From 175e700132348481b403597cd71fa81c89dbe1d8 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 12:06:00 +0800 Subject: [PATCH 15/48] fix(dag): sweep terminalizes workflows and releases their lease after a host-level settle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the owning instance torn down, checkCompletion/dag.fail/complete and the automation unregister all lived inside the dead DagLoop — a swept workflow stayed running forever with required nodes failed and its lease registration leaking (workflow rot). After a host-level settle lands, the sweep now mirrors checkCompletion's durable half: when every current-revision node is terminal it fails (required-node failure) or completes the workflow via the same workflow-lock-serialized command layer, then unregisters the dag lease. Parent wake delivery stays with the owning instance (session context) and converges through the DagLoop init drain on the next instance load; cascade skips of pending dependents likewise converge at adopt. Closes #343 --- .specgit.yaml | 7 +- .../src/dag/runtime/supervision-sweep.ts | 67 ++++++++++++++++++- .../test/dag/dag-node-supervision.test.ts | 17 ++++- 3 files changed, 85 insertions(+), 6 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index a453220f39..23751a5957 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,7 @@ version: 1 -delivery: issue342 +delivery: issue343 context: kind: branch - branch: feat/342-issue342 + branch: feat/343-issue343 issues: - - 342 -pr: 359 + - 343 diff --git a/packages/opencode/src/dag/runtime/supervision-sweep.ts b/packages/opencode/src/dag/runtime/supervision-sweep.ts index ee097e5f45..09adf5289a 100644 --- a/packages/opencode/src/dag/runtime/supervision-sweep.ts +++ b/packages/opencode/src/dag/runtime/supervision-sweep.ts @@ -8,9 +8,11 @@ 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 { 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" /** * Host-level deadline-supervision sweep — the fallback retry for the @@ -127,6 +129,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 @@ -247,6 +250,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* 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. @@ -254,6 +270,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* () { @@ -291,8 +349,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/test/dag/dag-node-supervision.test.ts b/packages/opencode/test/dag/dag-node-supervision.test.ts index 49393ac4bf..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") }), ), ) From 44b31392d498a7d35f644a13432933e7f2a5846c Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 12:06:44 +0800 Subject: [PATCH 16/48] chore(specgit): record PR binding in delivery record --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index 23751a5957..9ee9ca7dff 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -5,3 +5,4 @@ context: branch: feat/343-issue343 issues: - 343 +pr: 360 From d305707ff1cffee995b9dd3b4195a0336697afdc Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 12:37:47 +0800 Subject: [PATCH 17/48] fix(dag): httpapi dag.start passes Workflow Authoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handler asserted the payload as WorkflowConfig and called dag.create directly — create runs only structural checks (safe only when authoring has vetted the graph), so the checkpoint gate, output_schema obligations on gated checkpoints, worker/model/prompt asset resolution, and server-side deep-mode admission minting were all bypassed over HTTP. The handler now wraps the payload as an inline StartSpec and runs authoring.prepare(environment profile) through the same shared catalog loader the workflow tool uses (extracted to dag/environment-catalogs.ts); validation failures surface as 400 with the diagnostic list. Closes #344 --- .specgit.yaml | 7 +-- .../opencode/src/dag/environment-catalogs.ts | 53 ++++++++++++++++ .../routes/instance/httpapi/handlers/dag.ts | 60 +++++++++++++++---- packages/opencode/src/tool/workflow.ts | 34 +---------- .../test/server/httpapi-exercise/index.ts | 32 ++++++++++ 5 files changed, 138 insertions(+), 48 deletions(-) create mode 100644 packages/opencode/src/dag/environment-catalogs.ts diff --git a/.specgit.yaml b/.specgit.yaml index 9ee9ca7dff..27591a9e52 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,7 @@ version: 1 -delivery: issue343 +delivery: issue344 context: kind: branch - branch: feat/343-issue343 + branch: feat/344-issue344 issues: - - 343 -pr: 360 + - 344 diff --git a/packages/opencode/src/dag/environment-catalogs.ts b/packages/opencode/src/dag/environment-catalogs.ts new file mode 100644 index 0000000000..bdce1f5560 --- /dev/null +++ b/packages/opencode/src/dag/environment-catalogs.ts @@ -0,0 +1,53 @@ +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/server/routes/instance/httpapi/handlers/dag.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/dag.ts index bc069897ec..7915343edb 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,11 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" import { InvalidRequestError, ConflictError, notFound } from "../errors" import { Dag } from "@/dag/dag" +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 +42,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 +160,44 @@ 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 }, + }) + if (result.prepared?.action !== "start" || result.errors.length > 0) { + const diagnostics = result.errors + .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/tool/workflow.ts b/packages/opencode/src/tool/workflow.ts index afa66390a4..4ac73d2b69 100644 --- a/packages/opencode/src/tool/workflow.ts +++ b/packages/opencode/src/tool/workflow.ts @@ -11,6 +11,7 @@ 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 +206,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/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 3e68d25a5a..5f06ae9769 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -1935,6 +1935,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() From 38d41866bebc2691f4e8848fe89cd16e3b8b70fc Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 12:38:21 +0800 Subject: [PATCH 18/48] chore(specgit): record PR binding in delivery record --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index 27591a9e52..ad5c559fe0 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -5,3 +5,4 @@ context: branch: feat/344-issue344 issues: - 344 +pr: 361 From 668a2f15bd4c07015acfbf9082c55020e66897ce Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 12:44:52 +0800 Subject: [PATCH 19/48] chore(tool): drop imports unused after the catalog-loader extraction The lint budget is exactly at its cap (4850); the two now-unused imports pushed CI one warning over. --- packages/opencode/src/tool/workflow.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/opencode/src/tool/workflow.ts b/packages/opencode/src/tool/workflow.ts index 4ac73d2b69..7266b1a2e2 100644 --- a/packages/opencode/src/tool/workflow.ts +++ b/packages/opencode/src/tool/workflow.ts @@ -6,9 +6,7 @@ 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" From 80aeafbae68028438bba5b7e439f4c2c077461c6 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 12:55:10 +0800 Subject: [PATCH 20/48] fix(dag): crash recovery preserves a schemaless node's string verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recovery path completed a schemaless running node with undefined and never read the child session's messages, while the live path settles with the last assistant text — a crash between the child's final reply and the NodeCompleted publish erased the outcome: a bare {"verdict":"replan"} checkpoint reply lost its veto (no pause, no warning) and gated dependents resolved no fields. Recovery now mirrors the live path via an injected last-assistant-text reader; an explicitly unparseable workflow row (null) fails the node loudly instead of undefined-completing past settleCapturedOutput. Closes #345 --- .specgit.yaml | 7 +- packages/opencode/src/dag/runtime/loop.ts | 10 ++- packages/opencode/src/dag/runtime/recovery.ts | 51 +++++++++++- .../opencode/test/dag/dag-recovery.test.ts | 79 +++++++++++++++++++ 4 files changed, 139 insertions(+), 8 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index ad5c559fe0..fe57b37641 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,7 @@ version: 1 -delivery: issue344 +delivery: issue345 context: kind: branch - branch: feat/344-issue344 + branch: feat/345-issue345 issues: - - 344 -pr: 361 + - 345 diff --git a/packages/opencode/src/dag/runtime/loop.ts b/packages/opencode/src/dag/runtime/loop.ts index e2c3f316bc..a75fc41fa6 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 @@ -369,6 +369,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 +417,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), ) diff --git a/packages/opencode/src/dag/runtime/recovery.ts b/packages/opencode/src/dag/runtime/recovery.ts index ff6a135860..bf1849be24 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 @@ -90,6 +91,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 +121,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 +246,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/test/dag/dag-recovery.test.ts b/packages/opencode/test/dag/dag-recovery.test.ts index 9a6ae584e4..8e1a09d97e 100644 --- a/packages/opencode/test/dag/dag-recovery.test.ts +++ b/packages/opencode/test/dag/dag-recovery.test.ts @@ -429,4 +429,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" }) + }) }) From 5eb1b90c4655e9b8658a206ad9403662f8c4df36 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 12:57:21 +0800 Subject: [PATCH 21/48] chore(specgit): record PR binding in delivery record --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index fe57b37641..2adf884412 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -5,3 +5,4 @@ context: branch: feat/345-issue345 issues: - 345 +pr: 362 From 429ac787e5857646ac3766c5a1dfbc3bf4c19418 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 13:10:27 +0800 Subject: [PATCH 22/48] fix(dag): validateAgainstSchema enforces object-semantic keywords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit {required, properties} without type:"object" is a legal common JSON Schema spelling, but the required/properties checks were gated on the VALUE being an object — a non-object value skipped the whole group silently (ok:true), so a bare string could pass a gated checkpoint's declared schema and resolve no fields downstream (the DAG-01 consequence hiding inside the schema spelling). Now any object-semantic keyword (required/properties/additionalProperties) fails non-object values; additionalProperties:false fences keys even without a properties block; unknown type names (e.g. a misspelled "strng") fail instead of permissively accepting anything. Closes #346 --- .specgit.yaml | 7 ++- packages/opencode/src/dag/runtime/capture.ts | 46 +++++++++++++++---- .../dag/dag-review-audit-regressions.test.ts | 40 ++++++++++++++++ 3 files changed, 80 insertions(+), 13 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index 2adf884412..2e7f788a22 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,7 @@ version: 1 -delivery: issue345 +delivery: issue346 context: kind: branch - branch: feat/345-issue345 + branch: feat/346-issue346 issues: - - 345 -pr: 362 + - 346 diff --git a/packages/opencode/src/dag/runtime/capture.ts b/packages/opencode/src/dag/runtime/capture.ts index 8c0ec0572c..640d1431a8 100644 --- a/packages/opencode/src/dag/runtime/capture.ts +++ b/packages/opencode/src/dag/runtime/capture.ts @@ -93,6 +93,26 @@ 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 +231,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 { 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 // ============================================================================ From d5290bf5c367a265090448b53be5051183e8e12a Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 13:11:02 +0800 Subject: [PATCH 23/48] chore(specgit): record PR binding in delivery record --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index 2e7f788a22..1d23941949 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -5,3 +5,4 @@ context: branch: feat/346-issue346 issues: - 346 +pr: 363 From 36f4a0585bffcbd987dab1e446864923020cda3a Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 13:21:59 +0800 Subject: [PATCH 24/48] fix(dag): aggregator contract reconciles declared write-sets against git status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'mechanically detects' claim was false — the engine computes no intersection or union; detection is an explore-type worker's behavioral contract. Two gaps closed: (1) comment/ADR wording now state the guarantee truthfully; (2) the contract itself now requires the worker to observe the workspace's actual git status --porcelain, fail loudly on any actually-changed path no writer declared (undeclared edits previously escaped the union+fingerprint review binding entirely), and compute the union and the convergence-point fingerprint over the actually-changed set instead of the declared lists. Closes #347 --- .specgit.yaml | 7 +++---- packages/opencode/src/dag/blocks.ts | 11 +++++++---- .../docs/adr/0002-parallel-writers-aggregator.md | 16 +++++++++++----- .../test/dag/blocks-parallel-writers.test.ts | 6 ++++++ 4 files changed, 27 insertions(+), 13 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index 1d23941949..3c5bbbade1 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,7 @@ version: 1 -delivery: issue346 +delivery: issue347 context: kind: branch - branch: feat/346-issue346 + branch: feat/347-issue347 issues: - - 346 -pr: 363 + - 347 diff --git a/packages/opencode/src/dag/blocks.ts b/packages/opencode/src/dag/blocks.ts index 42be90b106..cb82f72614 100644 --- a/packages/opencode/src/dag/blocks.ts +++ b/packages/opencode/src/dag/blocks.ts @@ -405,11 +405,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 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/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") From bee2cb6a061ab262847d151c00427e7b9e858db0 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 13:22:36 +0800 Subject: [PATCH 25/48] chore(specgit): record PR binding in delivery record --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index 3c5bbbade1..fd649a098f 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -5,3 +5,4 @@ context: branch: feat/347-issue347 issues: - 347 +pr: 364 From 85fb68e51f29573f58db08cef95d921d886c1ea5 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 13:38:27 +0800 Subject: [PATCH 26/48] fix(memory): /memory on and memory_search say why Memory is inert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every fail-closed activation gate returned the same bare 'Memory remains off'/'unavailable' — a project with a real git identity but no /init stamp was indistinguishable from a disabled Memory (real case: /memory on silently staying off). New Memory.statusReason() names the gate a user can act on (missing /init stamp, commit-less global identity, non-git repo; retirement and admission repair stay log-only operator concerns); setEnabled(true) returns it, and memory_search attaches it to the unavailable answer. Closes #350 --- .specgit.yaml | 7 ++-- packages/opencode/src/memory/memory.ts | 35 +++++++++++++++-- packages/opencode/src/tool/memory-search.ts | 20 ++++++++-- .../memory/memory-global-identity.test.ts | 39 ++++++++++++++++++- 4 files changed, 87 insertions(+), 14 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index fd649a098f..cdb38c8a20 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,7 @@ version: 1 -delivery: issue347 +delivery: issue350 context: kind: branch - branch: feat/347-issue347 + branch: feat/350-issue350 issues: - - 347 -pr: 364 + - 350 diff --git a/packages/opencode/src/memory/memory.ts b/packages/opencode/src/memory/memory.ts index 89b2f99da2..2329833914 100644 --- a/packages/opencode/src/memory/memory.ts +++ b/packages/opencode/src/memory/memory.ts @@ -55,7 +55,11 @@ export interface Interface { query: string }) => 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/tool/memory-search.ts b/packages/opencode/src/tool/memory-search.ts index 0487719279..c2af0e33ff 100644 --- a/packages/opencode/src/tool/memory-search.ts +++ b/packages/opencode/src/tool/memory-search.ts @@ -33,11 +33,23 @@ export const MemorySearchTool = Tool.define( const memory = Option.getOrUndefined(yield* Effect.serviceOption(Memory.Service)) const sessions = Option.getOrUndefined(yield* Effect.serviceOption(Session.Service)) - if (!memory || !sessions) return unavailable() + // #350: when the service exists but Memory is inert, say why instead + // of a bare "unavailable" — the reason tells the user what to do + // (e.g. run /init, then /memory on). + if (!memory) return unavailable() + if (!sessions) return unavailable() 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 +93,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/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 }, + ) }) From de6621351d7f4b5d2162996c677c689f0b365ef0 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 13:39:05 +0800 Subject: [PATCH 27/48] chore(specgit): record PR binding in delivery record --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index cdb38c8a20..04bc1f688a 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -5,3 +5,4 @@ context: branch: feat/350-issue350 issues: - 350 +pr: 365 From 747d8c043dfb151101e03e4f8fdfd192ecc9cb62 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 13:55:18 +0800 Subject: [PATCH 28/48] chore(specgit): pin the acceptance workflow's specgit CLI to ^0.5.0 npm install -g specgit installs whatever is latest at run time; the CLI releases multiple times a day, so an unnoticed upstream change could flip CI acceptance verdicts repo-wide. Caret floor keeps patch/minor fixes while making the installed floor reproducible. Also restores this repo's bun/npm harness adaptation that a local 'specgit issue' run (0.5.0) had overwritten with the pnpm template. Closes #366 --- .github/workflows/specgit-accept.yml | 7 +++++-- .specgit.yaml | 7 +++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/specgit-accept.yml b/.github/workflows/specgit-accept.yml index 63bbebefcf..2f47923270 100644 --- a/.github/workflows/specgit-accept.yml +++ b/.github/workflows/specgit-accept.yml @@ -31,9 +31,12 @@ jobs: 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. + # 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 + run: npm install -g specgit@^0.5.0 - name: Wait for sibling checks # The verdict must see the OTHER required checks in a terminal diff --git a/.specgit.yaml b/.specgit.yaml index 04bc1f688a..8688de9a02 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,7 @@ version: 1 -delivery: issue350 +delivery: issue366 context: kind: branch - branch: feat/350-issue350 + branch: feat/366-issue366 issues: - - 350 -pr: 365 + - 366 From 16918ecda5fa2cf4577fda7793aeaa40a29c0c7e Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 13:56:34 +0800 Subject: [PATCH 29/48] chore(specgit): record PR binding in delivery record --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index 8688de9a02..e08ced99b1 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -5,3 +5,4 @@ context: branch: feat/366-issue366 issues: - 366 +pr: 367 From 732d30d363574495d4b8ccc1098fc3f25775397a Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 14:06:09 +0800 Subject: [PATCH 30/48] fix(license): add the SPDX headers to environment-catalogs.ts The file shipped in #361 without the AGPL copyright/license header; the core license-scope manifest test has kept dev's push-triggered full run red on every merge since 698713c0. Closes #368 --- .specgit.yaml | 7 +++---- packages/opencode/src/dag/environment-catalogs.ts | 3 +++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index e08ced99b1..5825367218 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,7 @@ version: 1 -delivery: issue366 +delivery: issue368 context: kind: branch - branch: feat/366-issue366 + branch: feat/368-issue368 issues: - - 366 -pr: 367 + - 368 diff --git a/packages/opencode/src/dag/environment-catalogs.ts b/packages/opencode/src/dag/environment-catalogs.ts index bdce1f5560..fe1669acb3 100644 --- a/packages/opencode/src/dag/environment-catalogs.ts +++ b/packages/opencode/src/dag/environment-catalogs.ts @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2026 LeXwDeX +// SPDX-License-Identifier: AGPL-3.0-or-later + export * as DagEnvironmentCatalogs from "./environment-catalogs" import { Effect } from "effect" From 495eea63678a193661b1ae6826d28e16655ea7fa Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 14:06:45 +0800 Subject: [PATCH 31/48] chore(specgit): record PR binding in delivery record --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index 5825367218..7a1959b65f 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -5,3 +5,4 @@ context: branch: feat/368-issue368 issues: - 368 +pr: 369 From e2a031f8312e6b270466a1d71c4a89291ad66903 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 14:16:07 +0800 Subject: [PATCH 32/48] chore(ci): raise the dev PR gate to Typecheck + Unit Tests (linux) The Typecheck-only PR->dev gate let an assertion-level regression (#368, missing SPDX headers) merge and keep dev's push-triggered full run red for 75 minutes. ci-test.yml now triggers on PRs to dev (the whole matrix runs, but only Unit Tests (linux) becomes required); spec_git/policy.yaml follows so specgit finish enforces the same pair. E2E stays push-on-dev + dev->main. Closes #370 --- .github/workflows/ci-test.yml | 9 ++++++--- .specgit.yaml | 7 +++---- spec_git/policy.yaml | 1 + 3 files changed, 10 insertions(+), 7 deletions(-) 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/.specgit.yaml b/.specgit.yaml index 7a1959b65f..e3ca10cf7a 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,7 @@ version: 1 -delivery: issue368 +delivery: issue370 context: kind: branch - branch: feat/368-issue368 + branch: feat/370-issue370 issues: - - 368 -pr: 369 + - 370 diff --git a/spec_git/policy.yaml b/spec_git/policy.yaml index ff8aaa9c9f..fe3768c27e 100644 --- a/spec_git/policy.yaml +++ b/spec_git/policy.yaml @@ -1,3 +1,4 @@ version: 1 required_checks: - Typecheck + - Unit Tests (linux) From 44975d7ef20e00b63fc3116861bbd9878297b6d7 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 14:16:41 +0800 Subject: [PATCH 33/48] chore(specgit): record PR binding in delivery record --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index e3ca10cf7a..3e12d9b8aa 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -5,3 +5,4 @@ context: branch: feat/370-issue370 issues: - 370 +pr: 371 From 2f15445fbcfd2c4efca0722e384ebd5cc2efb3c3 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 14:52:54 +0800 Subject: [PATCH 34/48] fix(dag): dag.start treats model.unavailable as advisory, matching the tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new PR->dev unit gate caught it (#371's own run): the exerciser's CI context has no resolvable model, and the environment-profile validation made model.unavailable a blocking 400 — while the workflow tool's start action asks a question instead (an HTTP caller has no such interaction; spawn fails loudly at execution time if a model never resolves). All other diagnostic classes stay blocking. --- .../routes/instance/httpapi/handlers/dag.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) 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 7915343edb..0ce7bc72ed 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,7 @@ 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" @@ -177,12 +178,22 @@ export const dagHandlers = HttpApiBuilder.group(InstanceHttpApi, "dag", (handler environment: { directory: session.directory, parent: session.model ?? undefined }, }) if (result.prepared?.action !== "start" || result.errors.length > 0) { - const diagnostics = result.errors - .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"}` }), + // 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. + 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 From ed3903005eb82f03ced1c3ea907162e947ff66e6 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 15:45:52 +0800 Subject: [PATCH 35/48] fix(dag): dag.start keeps model.unavailable advisory; exerciser seeds a model-bearing session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI (no providers) exposed two layers: the advisory filter let a graph that never compiled (prepared === undefined) through to a confusing 'no prepared graph' 400 — a non-compiling graph is now always blocking regardless of diagnostic class; and the dag.start happy-path scenario relied on the pre-#344 behavior of never resolving a model. The scenario now runs under withLlm with an explicit session model so the parent resolution chain has something to resolve in the provider-less CI environment. --- .../routes/instance/httpapi/handlers/dag.ts | 32 +++++++++---------- .../test/server/httpapi-exercise/index.ts | 9 +++++- .../test/server/httpapi-exercise/runner.ts | 2 +- .../test/server/httpapi-exercise/types.ts | 2 +- 4 files changed, 26 insertions(+), 19 deletions(-) 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 0ce7bc72ed..04ef202fd6 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/dag.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/dag.ts @@ -177,23 +177,23 @@ export const dagHandlers = HttpApiBuilder.group(InstanceHttpApi, "dag", (handler profile: "environment", environment: { directory: session.directory, parent: session.model ?? undefined }, }) - if (result.prepared?.action !== "start" || result.errors.length > 0) { - // 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. - const blocking = result.errors.filter( - (diagnostic) => diagnostic.code !== DagValidation.DIAGNOSTIC_CODES.modelUnavailable, + // 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"}` }), ) - 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 diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 5f06ae9769..81ed5b58d6 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", modelID: "test-model" } }), + ) .at((ctx) => ({ path: "/dag", headers: ctx.headers(), 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..06b6ea1f8b 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?: { providerID: string; modelID: string } }) => Effect.Effect sessionGet: (sessionID: SessionID) => Effect.Effect project: () => Effect.Effect message: (sessionID: SessionID, input?: { text?: string }) => Effect.Effect From 3731028ca4d9b36ccb7dbc90460e7d0f173f1d88 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 16:42:03 +0800 Subject: [PATCH 36/48] fix(test): session model field is {id, providerID}, matching Session's Model schema --- packages/opencode/test/server/httpapi-exercise/index.ts | 2 +- packages/opencode/test/server/httpapi-exercise/types.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 81ed5b58d6..842399295f 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -1919,7 +1919,7 @@ const scenarios: Scenario[] = [ // 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", modelID: "test-model" } }), + ctx.session({ title: "DAG start owner", model: { providerID: "test", id: "test-model" } }), ) .at((ctx) => ({ path: "/dag", diff --git a/packages/opencode/test/server/httpapi-exercise/types.ts b/packages/opencode/test/server/httpapi-exercise/types.ts index 06b6ea1f8b..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; model?: { providerID: string; modelID: string } }) => 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 From 6a3f3b22bed7f2ac79062f0048f3526176356eda Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 17:13:21 +0800 Subject: [PATCH 37/48] =?UTF-8?q?chore(specgit):=20acceptance=20timeout=20?= =?UTF-8?q?45min=20=E2=80=94=20must=20outlast=20Unit=20Tests=20(~28min)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verdict waits for every policy check to reach a terminal state; with Unit Tests (linux) now required on dev PRs, the 15min job cap timed out while the sibling was still running. --- .github/workflows/specgit-accept.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/specgit-accept.yml b/.github/workflows/specgit-accept.yml index 2f47923270..99eac183c8 100644 --- a/.github/workflows/specgit-accept.yml +++ b/.github/workflows/specgit-accept.yml @@ -13,7 +13,10 @@ jobs: specgit-acceptance: name: SpecGit Acceptance runs-on: ubuntu-latest - timeout-minutes: 15 + # 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 From 8d5bb3dca9b32245994741b40763e316d5ba700d Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 17:38:44 +0800 Subject: [PATCH 38/48] =?UTF-8?q?chore(specgit):=20wait-step=20deadline=20?= =?UTF-8?q?40min=20=E2=80=94=20the=2015min=20inline=20deadline=20was=20the?= =?UTF-8?q?=20real=20timeout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The job-level 45min bump was necessary but not sufficient: the sibling-wait script carries its own hardcoded 15-minute deadline and gives up while Unit Tests (linux) (~28min) is still running. --- .github/workflows/specgit-accept.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/specgit-accept.yml b/.github/workflows/specgit-accept.yml index 99eac183c8..6421b235aa 100644 --- a/.github/workflows/specgit-accept.yml +++ b/.github/workflows/specgit-accept.yml @@ -71,7 +71,9 @@ jobs: const retried = [...byName.keys()].find((k) => k.startsWith(name + ' (')); return retried !== undefined && terminal.has(byName.get(retried)); }; - const deadline = Date.now() + 15 * 60 * 1000; + // 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); From c0cf65e2ccc0aa27ac1bfd40b7bd14976da8451e Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 17:51:54 +0800 Subject: [PATCH 39/48] fix(dag): low-severity batch from the 2026-08-19 deep-dive audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NEW-1: spawn failure boundary rethrows interrupts (parity with the file's own catchCause discipline) instead of persisting a nodeFailed - NEW-2: recovery-pause retries twice and only abandons adoption when the durable row is genuinely terminal (mirrors the replan-verdict gate) - REC-1: pending-node stale-child cancel is cause-hardened like the running branch — a persistent failure no longer aborts the whole reconcile - BLK-02: hyphen/underscore writer-id key collisions rejected at compile time - BLK-03: a verify node serving multiple parallel-writer review routes is rejected (the contract binds ONE fingerprint) instead of silently mapping only the first aggregator - CAP-02: regex tests capped at 100k chars, uniqueItems scan capped at 1000 items, file-ref capture capped at 64MiB (inline fallback beyond) - SW-L1: sweep publishes stamp the workflow row's location so live instances' summary publishers push swept settles to the TUI - F3: TUI reconnect refreshes stored goals (a missed goal.cleared no longer leaves a stale sidebar indefinitely) - F6: dag.cancel.active added to the keybind Definitions/CommandMap - AGENTS.md: tool-call discipline rules (no duplicate fan-out queries, one watch per CI wait) SW-L2 (cancel dies without ambient instance — acknowledged design) and F4/F5 (record items on the event surface) are accepted as designed. Closes #349 --- .specgit.yaml | 7 +- AGENTS.md | 10 +++ packages/opencode/src/dag/blocks.ts | 37 ++++++++- packages/opencode/src/dag/runtime/capture.ts | 17 +++- packages/opencode/src/dag/runtime/loop.ts | 41 +++++++--- .../opencode/src/dag/runtime/output-ref.ts | 7 ++ packages/opencode/src/dag/runtime/recovery.ts | 14 +++- .../src/dag/runtime/supervision-sweep.ts | 82 +++++++++++++------ packages/tui/src/config/keybind.ts | 5 ++ packages/tui/src/context/sync.tsx | 25 ++++++ 10 files changed, 200 insertions(+), 45 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index 7a1959b65f..cc80eefeae 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,7 @@ version: 1 -delivery: issue368 +delivery: issue349 context: kind: branch - branch: feat/368-issue368 + branch: feat/349-issue349 issues: - - 368 -pr: 369 + - 349 diff --git a/AGENTS.md b/AGENTS.md index 58f3e3cb37..5174502253 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -278,3 +278,13 @@ verified on its own evidence, split it before binding. - `--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/packages/opencode/src/dag/blocks.ts b/packages/opencode/src/dag/blocks.ts index cb82f72614..aa2e1bfb5a 100644 --- a/packages/opencode/src/dag/blocks.ts +++ b/packages/opencode/src/dag/blocks.ts @@ -263,10 +263,28 @@ function compileBlock( 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`], - ]), + (() => { + // #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). Reject the shape at compile time. + const seen = new Map() + for (const writerID of aggregation.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 aggregation.writerIDs.flatMap((writerID: string) => [ + [`${writerID.replace(/-/g, "_")}_changed_files`, `${writerID}.output.changed_files`], + [`${writerID.replace(/-/g, "_")}_summary`, `${writerID}.output.summary`], + ]) + })(), ), outputSchema: IMPLEMENTATION_SCHEMA, }), @@ -276,6 +294,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 diff --git a/packages/opencode/src/dag/runtime/capture.ts b/packages/opencode/src/dag/runtime/capture.ts index 640d1431a8..80251908f4 100644 --- a/packages/opencode/src/dag/runtime/capture.ts +++ b/packages/opencode/src/dag/runtime/capture.ts @@ -87,6 +87,15 @@ 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}` } @@ -245,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 a75fc41fa6..27e1fb2895 100644 --- a/packages/opencode/src/dag/runtime/loop.ts +++ b/packages/opencode/src/dag/runtime/loop.ts @@ -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, ) @@ -436,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 bf1849be24..cd76473244 100644 --- a/packages/opencode/src/dag/runtime/recovery.ts +++ b/packages/opencode/src/dag/runtime/recovery.ts @@ -69,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 } diff --git a/packages/opencode/src/dag/runtime/supervision-sweep.ts b/packages/opencode/src/dag/runtime/supervision-sweep.ts index 09adf5289a..8fa913150b 100644 --- a/packages/opencode/src/dag/runtime/supervision-sweep.ts +++ b/packages/opencode/src/dag/runtime/supervision-sweep.ts @@ -8,11 +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 @@ -150,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({ @@ -219,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 @@ -260,7 +296,7 @@ const serviceLayer = Layer.effect( // load. A live DagLoop racing this is serialized by the same // workflow lock and its terminal-status guards — double settles // collapse to one. - yield* settleWorkflowIfComplete(row.workflowId).pipe( + yield* withWorkflowLocation(row.workflowId, settleWorkflowIfComplete(row.workflowId)).pipe( Effect.catchCause((cause) => (Cause.hasInterrupts(cause) ? Effect.interrupt : Effect.void)), ) } 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(() => { From f1286681d4d9e1e841f30c2ae20542591ecf7835 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 17:52:46 +0800 Subject: [PATCH 40/48] chore(specgit): record PR binding in delivery record --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index cc80eefeae..ff0f370dd1 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -5,3 +5,4 @@ context: branch: feat/349-issue349 issues: - 349 +pr: 372 From d666e3a4824e1e903b27fb51c94016bd5d7b3447 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 17:54:06 +0800 Subject: [PATCH 41/48] docs(dag): 'one objective, one live DAG' downgraded from Invariant to Convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONTEXT.md claimed an engine-enforced invariant; the implementation is orchestrator guidance only (dag.create and the tool start accept a session with a live workflow; the wake model and lease tolerate multiples). The divergence was the defect (audit INV-A) — this records the truth: a modeling convention with documented bounded consequences, including the cross-workflow write-set caveat. Engine enforcement remains available as a future decision if the convention proves insufficient. Closes #348 --- .specgit.yaml | 7 +++---- packages/opencode/src/dag/CONTEXT.md | 5 ++++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index 7a1959b65f..9cfb244c08 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,7 @@ version: 1 -delivery: issue368 +delivery: issue348 context: kind: branch - branch: feat/368-issue368 + branch: feat/348-issue348 issues: - - 368 -pr: 369 + - 348 diff --git a/packages/opencode/src/dag/CONTEXT.md b/packages/opencode/src/dag/CONTEXT.md index 87f180fa95..7f2824dd3a 100644 --- a/packages/opencode/src/dag/CONTEXT.md +++ b/packages/opencode/src/dag/CONTEXT.md @@ -22,12 +22,15 @@ 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. - `portable` validation does not load user environment catalogs. `environment` validation reads current catalogs and verifies actual model availability. - No workflow event or durable mutation occurs before a valid Prepared Workflow Graph exists. + +## 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). - The model-facing schema contains fields the model owns. Session/Project identity, admission audit state, model assignment, and other runtime-derived fields remain hidden. - Model-facing graph actions expose only `spec_path`; graph fields live in YAML so provider tool-call serialization cannot turn a nested graph into a string. - Legacy YAML may be adapted at the file boundary without making legacy fields valid inline input. From 603d3499f9987e6d312071ba1ff0b0ba6235f040 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 17:54:43 +0800 Subject: [PATCH 42/48] chore(specgit): record PR binding in delivery record --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index 9cfb244c08..e33e7aaefa 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -5,3 +5,4 @@ context: branch: feat/348-issue348 issues: - 348 +pr: 373 From b94d9ee0e08615322b4811919869d9200a940f38 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 18:08:55 +0800 Subject: [PATCH 43/48] =?UTF-8?q?test(dag):=20REC-1=20pins=20the=20hardene?= =?UTF-8?q?d=20behavior=20=E2=80=94=20cancel=20failure=20continues=20the?= =?UTF-8?q?=20reconcile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old test pinned the abort-on-cancel-failure behavior the audit flagged as the REC-1 defect (the workflow became unadoptable in-process until restart). --- packages/opencode/test/dag/dag-recovery.test.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/opencode/test/dag/dag-recovery.test.ts b/packages/opencode/test/dag/dag-recovery.test.ts index 8e1a09d97e..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 () => { From ddfa2e1fc8a4554a32807f689c83bcd454a57d74 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 19:38:44 +0800 Subject: [PATCH 44/48] =?UTF-8?q?chore:=20post-review=20cleanup=20?= =?UTF-8?q?=E2=80=94=20docs=20drift,=20CONTEXT.md=20section=20order,=20rev?= =?UTF-8?q?iew=20nits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-axis review of today's 62-commit range (both axes PASS, info findings): - AGENTS.md gate prose/table updated to Typecheck + Unit Tests (linux) (#370 drift) - CONTEXT.md: Conventions section moved after the full Invariants list — the #348 edit had demoted 5 original Invariants (incl. the engine-enforced gated-checkpoint obligation) under the new header - memory-search: identical if-branches folded back to || - blocks.ts: BLK-02 collision check extracted from an IIFE into aggregatorEvidenceMapping per the Style Guide - #340's serve-mode behavioral e2e documented as a covered-by-composition delta (e2e-loop behavior tests + the wiring probe for the failure class) Closes #374 --- .specgit.yaml | 7 ++- AGENTS.md | 8 ++-- packages/opencode/src/dag/CONTEXT.md | 8 ++-- packages/opencode/src/dag/blocks.ts | 52 +++++++++++---------- packages/opencode/src/tool/memory-search.ts | 6 +-- 5 files changed, 40 insertions(+), 41 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index e33e7aaefa..e1eb8e9071 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,7 @@ version: 1 -delivery: issue348 +delivery: issue375 context: kind: branch - branch: feat/348-issue348 + branch: feat/375-issue375 issues: - - 348 -pr: 373 + - 375 diff --git a/AGENTS.md b/AGENTS.md index 5174502253..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) diff --git a/packages/opencode/src/dag/CONTEXT.md b/packages/opencode/src/dag/CONTEXT.md index 7f2824dd3a..34f5871da1 100644 --- a/packages/opencode/src/dag/CONTEXT.md +++ b/packages/opencode/src/dag/CONTEXT.md @@ -27,16 +27,16 @@ Workflow Orchestration turns one user objective into one durable DAG. Its model- - Parsing, file-only compatibility, strict action decoding, Block compilation, and profile diagnostics are not reimplemented by callers. - `portable` validation does not load user environment catalogs. `environment` validation reads current catalogs and verifies actual model availability. - No workflow event or durable mutation occurs before a valid Prepared Workflow Graph exists. - -## 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). - The model-facing schema contains fields the model owns. Session/Project identity, admission audit state, model assignment, and other runtime-derived fields remain hidden. - Model-facing graph actions expose only `spec_path`; graph fields live in YAML so provider tool-call serialization cannot turn a nested graph into a string. - Legacy YAML may be adapted at the file boundary without making legacy fields valid inline input. - 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 aa2e1bfb5a..fc010a014e 100644 --- a/packages/opencode/src/dag/blocks.ts +++ b/packages/opencode/src/dag/blocks.ts @@ -262,30 +262,7 @@ function compileBlock( contract: AGGREGATOR_CONTRACT, required: true, reportToParent: false, - inputMapping: Object.fromEntries( - (() => { - // #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). Reject the shape at compile time. - const seen = new Map() - for (const writerID of aggregation.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 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, @@ -449,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/tool/memory-search.ts b/packages/opencode/src/tool/memory-search.ts index c2af0e33ff..4574f0cf2c 100644 --- a/packages/opencode/src/tool/memory-search.ts +++ b/packages/opencode/src/tool/memory-search.ts @@ -33,11 +33,7 @@ export const MemorySearchTool = Tool.define( const memory = Option.getOrUndefined(yield* Effect.serviceOption(Memory.Service)) const sessions = Option.getOrUndefined(yield* Effect.serviceOption(Session.Service)) - // #350: when the service exists but Memory is inert, say why instead - // of a bare "unavailable" — the reason tells the user what to do - // (e.g. run /init, then /memory on). - if (!memory) return unavailable() - if (!sessions) return unavailable() + if (!memory || !sessions) return unavailable() const current = yield* sessions.get(ctx.sessionID).pipe(Effect.option) if (Option.isNone(current) || current.value.parentID) return unavailable() From ecdc300ecd6b20ad70ccf564c2575ebca169b739 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 19:39:22 +0800 Subject: [PATCH 45/48] chore(specgit): record PR binding in delivery record --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index e1eb8e9071..ca77f6b0de 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -5,3 +5,4 @@ context: branch: feat/375-issue375 issues: - 375 +pr: 375 From 319637e1c175fd858de9359ca3acb2bea3935cde Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 20:09:52 +0800 Subject: [PATCH 46/48] chore(specgit): bind delivery to issue 374 (was mistakenly bound to the PR number) --- .specgit.yaml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index ca77f6b0de..8131bc0b0e 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,7 @@ version: 1 -delivery: issue375 +delivery: issue374 context: kind: branch - branch: feat/375-issue375 + branch: feat/374-issue374 issues: - - 375 -pr: 375 + - 374 From 583b1d1c9c04f4ccdb00c8183de285a111e5ac32 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 20:10:44 +0800 Subject: [PATCH 47/48] chore(specgit): correct delivery record context (branch + PR binding) --- .specgit.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.specgit.yaml b/.specgit.yaml index 8131bc0b0e..300153e43b 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -2,6 +2,7 @@ version: 1 delivery: issue374 context: kind: branch - branch: feat/374-issue374 + branch: feat/375-issue375 issues: - 374 +pr: 375 From 3ce5d8ba546fd4057a3a7af9040fb619b65bc1e5 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 19 Aug 2026 21:30:21 +0800 Subject: [PATCH 48/48] chore(specgit): acceptance also runs on PRs to main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main's legacy branch protection requires the SpecGit Acceptance check, but the workflow only triggered on PRs to dev — the dev->main release PR was permanently BLOCKED with all four Ruleset checks green. --- .github/workflows/specgit-accept.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/specgit-accept.yml b/.github/workflows/specgit-accept.yml index 6421b235aa..ff9080efd3 100644 --- a/.github/workflows/specgit-accept.yml +++ b/.github/workflows/specgit-accept.yml @@ -2,9 +2,12 @@ name: SpecGit Acceptance on: pull_request: - # Delivery PRs target dev (fast-integration layer); dev→main promotion - # stays governed by the protect-main Ruleset's four required checks. - branches: [dev] + # 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