From 5a83a6c01b292c92ce9b25a4f30e52493720cf5a Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 5 Sep 2026 06:39:33 +0800 Subject: [PATCH 1/6] chore: record delivery binding for dag-audit-reliability --- .specgit.yaml | 34 +++++++++++++++++++++++++++------- spec_git/policy.yaml | 3 +++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index 76c3984ba..3f76692cf 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,12 +1,32 @@ version: 1 -delivery: native-tool-settlement +delivery: dag-audit-reliability context: - kind: branch - branch: fix/538-native-tool-settlement + kind: worktree + label: opencode-dag-delivery-2026-09-05 + branch: fix/545-dag-audit-reliability issues: - - 538 - - 540 + - 545 + - 546 + - 547 + - 548 + - 549 + - 550 + - 551 + - 552 issueKinds: - - issue: 538 + - issue: 545 kind: kind::fix -pr: 539 + - issue: 546 + kind: kind::fix + - issue: 547 + kind: kind::fix + - issue: 548 + kind: kind::fix + - issue: 549 + kind: kind::fix + - issue: 550 + kind: kind::fix + - issue: 551 + kind: kind::fix + - issue: 552 + kind: kind::chore diff --git a/spec_git/policy.yaml b/spec_git/policy.yaml index fe3768c27..3d6b9f65a 100644 --- a/spec_git/policy.yaml +++ b/spec_git/policy.yaml @@ -2,3 +2,6 @@ version: 1 required_checks: - Typecheck - Unit Tests (linux) +automation: + merge: false + close_issues: false From b1073cb4be806498b3be0798a58445b346d82fb8 Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 5 Sep 2026 06:39:51 +0800 Subject: [PATCH 2/6] chore: record delivery binding for dag-audit-reliability --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index 3f76692cf..01e731c77 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -30,3 +30,4 @@ issueKinds: kind: kind::fix - issue: 552 kind: kind::chore +pr: 553 From 779d5ea913db7d21488b2665c455e1454a9d9952 Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 5 Sep 2026 07:18:39 +0800 Subject: [PATCH 3/6] chore(specgit): refresh delivery harness (#552) --- .github/workflows/specgit-accept.yml | 186 +++++++++++++++++++++------ .gitignore | 13 ++ .opencode/command/specgit-finish.md | 26 +++- .opencode/command/specgit-issue.md | 21 ++- .opencode/command/specgit-pr.md | 16 ++- AGENTS.md | 70 +++++++--- 6 files changed, 265 insertions(+), 67 deletions(-) diff --git a/.github/workflows/specgit-accept.yml b/.github/workflows/specgit-accept.yml index 93d6a6166..96f4d84cd 100644 --- a/.github/workflows/specgit-accept.yml +++ b/.github/workflows/specgit-accept.yml @@ -2,22 +2,19 @@ name: SpecGit Acceptance on: pull_request: - branches: [main] + branches: ["main"] # A draft PR fails the verdict (pr_draft), so the draft→ready # transition must re-verdict. Listing types replaces the defaults, - # so the default activity types are listed alongside. - types: [opened, synchronize, reopened, ready_for_review] - # No workflow_dispatch (local specialization): dispatch is the privileged - # context that fires CodeQL's cache-poisoning taint rule on the head_ref - # checkout (false positive: no cache use, read-only token, - # persist-credentials: false), and on dispatch events head_ref is empty so - # the verdict would evaluate the default branch — the wrong tree. Delivery - # here always goes through a PR. + # so the default activity types are listed alongside. Title and body + # edits change live acceptance evidence even when the head is unchanged. + types: [opened, synchronize, reopened, ready_for_review, edited] + workflow_dispatch: permissions: contents: read issues: read pull-requests: read + actions: read # One verdict per head at a time (#319): a newer trigger event (a push # after the draft opened, then ready_for_review) supersedes the older @@ -35,33 +32,47 @@ jobs: # project's own toolchain (package manager, lockfile, build, layout) # is never assumed and never invoked. runs-on: ubuntu-latest - # Local specialization: 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. + # This repository's required Linux unit suite normally takes about 28 + # minutes, so leave enough time for it to finish and for the verdict. timeout-minutes: 45 steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - # Check out the PR head branch by name so HEAD is on the branch - # (not the detached merge ref): the execution context gate reads - # live git. Falls back to the default ref on non-PR events. - ref: ${{ github.head_ref || github.ref }} + # Pin execution to this event; a newer branch push must not change + # the code tested by an older run. Manual dispatch uses its own SHA. + ref: ${{ github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 persist-credentials: false + - name: Restore the event branch + if: github.event_name == 'pull_request' || github.ref_type == 'branch' + env: + SPECGIT_BRANCH: ${{ github.head_ref || github.ref_name }} + run: | + git check-ref-format --branch "$SPECGIT_BRANCH" >/dev/null + git switch --create "$SPECGIT_BRANCH" + - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: '22' + node-version: '20.19' + package-manager-cache: false - name: Install pinned SpecGit CLI - # Local specialization — install GLOBALLY, not `npm install --no-save - # specgit@X`: a workspace-local install reads this bun workspace's - # package.json and dies on the `catalog:` protocol (EUNSUPPORTEDPROTOCOL, - # #434/#459). Exact pin on purpose: the gate must evaluate with the - # same CLI generation that wrote the binding (1.10.1 re-init). - run: npm install -g --no-audit --no-fund specgit@1.10.1 + # Exact version on purpose (no ^): the gate must evaluate with the + # same CLI generation that wrote the binding; upgrades are a + # deliberate re-init. An isolated prefix avoids installing the + # adopting project's dependencies or running its lifecycle scripts. + run: npm install --prefix "$RUNNER_TEMP/specgit-cli" --no-save --no-audit --no-fund specgit@1.13.1 + + - name: Prepare approved policy for acceptance + env: + GH_TOKEN: ${{ github.token }} + SPECGIT_WAIT_POLICY: ${{ runner.temp }}/specgit-policy.yaml + run: | + gh auth setup-git + node "$RUNNER_TEMP/specgit-cli/node_modules/specgit/dist/automation/workflow-policy.js" - name: Wait for sibling checks # The verdict must see the OTHER required checks in a terminal @@ -78,21 +89,23 @@ jobs: WAIT_REPO: ${{ github.repository }} WAIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} WAIT_PR: ${{ github.event.pull_request.number || '' }} + WAIT_POLICY: ${{ runner.temp }}/specgit-policy.yaml + SPECGIT_CLI_DIR: ${{ runner.temp }}/specgit-cli run: | node --input-type=module <<'EOF' import { existsSync, readFileSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; - if (!existsSync('spec_git/policy.yaml')) { + import { createRequire } from 'node:module'; + const { parse } = process.env.SPECGIT_CLI_DIR + ? createRequire(process.env.SPECGIT_CLI_DIR + '/node_modules/specgit/package.json')('yaml') + : await import('yaml'); + const policyPath = process.env.WAIT_POLICY || 'spec_git/policy.yaml'; + if (!existsSync(policyPath)) { console.error('spec_git/policy.yaml is absent at this head — an adoption PR carries no binding commit yet (expected once; merge it before enabling branch protection), and a delivery PR must carry it via specgit issue.'); process.exit(1); } - // Local specialization — minimal hand parse of policy.yaml's - // required_checks block: this bun-based repo does not expose a - // root-reachable `yaml` package (workspace catalog isolation), so - // `import { parse } from 'yaml'` would fail to resolve here. - 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 policy = parse(readFileSync(policyPath, 'utf8')); + const required = policy.required_checks ?? []; // gh.cmd needs a shell on Windows; POSIX execs the binary // directly (shell stays off where it is not needed). The @@ -169,6 +182,109 @@ jobs: } } }; + /** @param {unknown} value @returns {value is number} */ + function positiveIdentity(value) { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0; + } + + /** @param {unknown} app */ + export function isGithubActionsApp(app) { + return typeof app === 'object' && app !== null && + (('slug' in app && app.slug === 'github-actions') || ('id' in app && app.id === 15368)); + } + + /** + * One ownership decision for the provider and embedded wait program. + * Callers prove the head and list completeness before passing normalized rows. + * Pending owners stay pending; callers decide which jobs must wait for them. + * @template {import('./actions-ownership.mjs').ActionsWorkflow} T + * @param {readonly T[]} workflows + * @returns {import('./actions-ownership.mjs').ActionsOwnership} + */ + export function createActionsOwnership(workflows) { + /** @type {Map} */ + const latest = new Map(); + /** @type {Map} */ + const owners = new Map(); + const ids = new Set(); + for (const workflow of workflows) { + const check = workflow.check; + if (typeof workflow.key !== 'string' || !workflow.key || + !positiveIdentity(check.id) || !positiveIdentity(workflow.checkSuiteId) || + !positiveIdentity(workflow.runAttempt) || typeof check.startedAt !== 'string' || + !Number.isFinite(Date.parse(check.startedAt)) || owners.has(workflow.checkSuiteId) || + ids.has(check.id) || !['queued', 'in_progress', 'completed', 'waiting', 'pending', 'requested'].includes(check.status)) { + throw new Error('GitHub returned incomplete or ambiguous Actions workflow ownership.'); + } + owners.set(workflow.checkSuiteId, workflow); + ids.add(check.id); + const previous = latest.get(workflow.key); + const started = Date.parse(check.startedAt); + if (!previous || started > Date.parse(previous.check.startedAt ?? '') || + (started === Date.parse(previous.check.startedAt ?? '') && check.id > previous.check.id)) { + latest.set(workflow.key, workflow); + } + } + return { + latest: [...latest.values()], + currentFor(checkSuiteId) { + if (!positiveIdentity(checkSuiteId)) { + throw new Error('GitHub returned an Actions check without a check-suite identity.'); + } + const owner = owners.get(checkSuiteId); + if (!owner) throw new Error('The Actions check has no proven owning workflow run.'); + return latest.get(owner.key)?.check.id === owner.check.id ? owner : null; + }, + }; + } + const isActions = (check) => isGithubActionsApp(check.app); + const listWorkflowsWithRetry = async (page) => { + for (let attempt = 1; ; attempt++) { + try { + return JSON.parse(execFileSync('gh', [ + 'api', 'repos/' + process.env.WAIT_REPO + '/actions/runs', '--method', 'GET', + '--field', 'head_sha=' + process.env.WAIT_SHA, '--field', 'per_page=' + PER_PAGE, '--field', 'page=' + page, + ], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32' })); + } catch (error) { + const text = String(error) + ' ' + String(error && error.stderr ? error.stderr : ''); + if (attempt >= 5 || !/HTTP 5\d\d|HTTP 429|ETIMEDOUT|ECONNRESET|ENOTFOUND|timed out/i.test(text)) throw error; + await new Promise((resolve) => setTimeout(resolve, Math.min(30000, 2000 * 2 ** (attempt - 1)))); + } + } + }; + const currentExecutionChecks = async (checks) => { + if (!checks.some(isActions)) return checks; + const workflows = []; + const positive = (value) => Number.isSafeInteger(value) && value > 0; + let total; + for (let page = 1; page <= 10; page++) { + const payload = await listWorkflowsWithRetry(page); + if (!Array.isArray(payload?.workflow_runs) || payload.workflow_runs.length > PER_PAGE + || !Number.isSafeInteger(payload.total_count) || payload.total_count < 0) throw new Error('GitHub Actions workflow evidence is malformed.'); + if (payload.total_count > 1000) throw new Error('GitHub Actions workflow evidence exceeds the 1000-run API limit.'); + if (total !== undefined && total !== payload.total_count) throw new Error('GitHub Actions workflow evidence changed during pagination.'); + total = payload.total_count; + for (const run of payload.workflow_runs) { + if (!positive(run?.workflow_id) || run.head_sha !== process.env.WAIT_SHA + || typeof run.event !== 'string' || run.event.length === 0) throw new Error('GitHub Actions workflow ownership is missing, invalid, or ambiguous.'); + workflows.push({ key: JSON.stringify([run.workflow_id, run.event]), checkSuiteId: run.check_suite_id, + runAttempt: run.run_attempt, check: { id: run.id, startedAt: run.run_started_at, status: run.status } }); + } + if (payload.workflow_runs.length < PER_PAGE) break; + if (page === 10) throw new Error('GitHub Actions workflow evidence reached the pagination limit.'); + } + if (workflows.length !== total) throw new Error('GitHub Actions workflow evidence is incomplete.'); + const ownership = createActionsOwnership(workflows); + return checks.flatMap((check) => { + if (!isActions(check)) return [check]; + let owner; + try { owner = ownership.currentFor(check.check_suite?.id); } + catch { throw new Error('A GitHub Actions check has no verified workflow owner.'); } + if (owner === null) return []; + // A rerun can retain completed jobs from its prior attempt until new jobs register. + return [owner.check.status === 'completed' ? check : { ...check, status: 'in_progress', conclusion: null }]; + }); + }; const terminal = new Set(['completed']); const PER_PAGE = 100; // #300: page the listing to exhaustion — a head with more than @@ -227,9 +343,7 @@ jobs: // being killed by the job timeout mid-line. - // Local specialization: 40min because the slowest required - - // sibling (Unit Tests (linux)) runs ~28min on PRs. + // The required Linux unit suite normally takes about 28 minutes. const deadline = Date.now() + 40 * 60 * 1000; while (Date.now() < deadline) { @@ -247,7 +361,7 @@ jobs: + (error && error.message ? error.message : String(error))); process.exit(1); } - const runs = await fetchAllCheckRuns(); + const runs = await currentExecutionChecks(await fetchAllCheckRuns()); // #119: re-runs keep every same-name run; terminality is // decided on the truth run — latest started_at, ties broken // by the higher check-run id (docs/reference.md) — never on @@ -304,6 +418,6 @@ jobs: - name: specgit finish - run: specgit finish --json + run: '"$RUNNER_TEMP/specgit-cli/node_modules/.bin/specgit" finish --json' env: GH_TOKEN: ${{ github.token }} diff --git a/.gitignore b/.gitignore index f2caa4f7c..56e3391c7 100644 --- a/.gitignore +++ b/.gitignore @@ -62,4 +62,17 @@ tsconfig.tsbuildinfo # >>> specgit: local delivery assets (managed by specgit init) >>> /.specgit.yaml /spec_git/ +/.opencode/hooks/specgit-merge-guard.sh +/.opencode/command/specgit-issue.md +/.agents/skills/specgit-issue/SKILL.md +/.opencode/command/specgit-finish.md +/.agents/skills/specgit-finish/SKILL.md +/.opencode/command/specgit-doctor.md +/.agents/skills/specgit-doctor/SKILL.md +/.opencode/command/specgit-pr.md +/.agents/skills/specgit-pr/SKILL.md +/.opencode/command/specgit-status.md +/.agents/skills/specgit-status/SKILL.md +/.local/state/ +/.local/cache/ # <<< specgit: local delivery assets (managed by specgit init) <<< diff --git a/.opencode/command/specgit-finish.md b/.opencode/command/specgit-finish.md index e828bcdd5..4a3df38d9 100644 --- a/.opencode/command/specgit-finish.md +++ b/.opencode/command/specgit-finish.md @@ -11,19 +11,33 @@ AGENTS.md SpecGit block; this command only launches it. ## Steps -1. Run from the delivery branch: +1. Complete the authorized PR body and mark the PR ready for review, then + 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 0` → report issues, PR, CI run links, and the verdict; continue + the authorized merge through the guidance below. - `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. + - `exit 3` → run `specgit doctor --json`, repair the named evidence + failure within your permissions, and retry the verdict. 3. Iron rules: never weaken `spec_git/policy.yaml` to pass; `--json` is the only parse surface; a non-zero verdict never merges. + +Continue within existing user authorization. With automation enabled, the +trusted remote completion workflow continues after CI without another user +confirmation. `specgit pr --merge --json` is its recovery path. It requires +the approved target policy, `finish` exit 0, and all CI checks passing at the +current PR head. Completion means the merge and every bound issue closure are +confirmed; a partial closure remains recoverable. `finish` is read-only and +exit 0 means accepted, not necessarily completed. A failed delivery is tracked +by a repair issue; retries reuse that cause and preserve the original PR. +Automation defaults to no. Only the user's own yes enables it through +`specgit init --automation yes --merge-target `; ordinary `init --force` +preserves that choice and target. An agent must not choose yes for the user. When an +action lacks user authorization or platform permission, report the specific +missing permission with the prepared result. diff --git a/.opencode/command/specgit-issue.md b/.opencode/command/specgit-issue.md index 7ad133cde..0905dc6e8 100644 --- a/.opencode/command/specgit-issue.md +++ b/.opencode/command/specgit-issue.md @@ -9,10 +9,20 @@ description: Start a SpecGit delivery from a title or existing issue number Thin trigger for the delivery bootstrap. The canonical behavior lives in the AGENTS.md SpecGit block; this command only launches it. +Local CLI installation, upgrades, and `init` / `setup` refreshes need no +issue, PR, product build, or release when no product or shared-rule change is +intended for commit. Review tracked diffs before choosing what to share. +For intended deliveries, follow the host project's verification policy for +the actual changed inputs; documentation may itself be a product input. +Ignore rules are never CI exemptions. Publishing requires explicit authorization. + ## Steps 1. Collect the argument: `$ARGUMENTS` is either an issue title (create) or a pure number (reuse). Multiple arguments = N issues in one delivery. + If policy selects body validation or required sections, prepare complete + content first and include `--body-file ` per title and + `--pr-body-file ` for the request. 2. Run from the repo root — keep `$ARGUMENTS` UNQUOTED so each quoted title arrives as its own argument: @@ -20,11 +30,12 @@ AGENTS.md SpecGit block; this command only launches it. specgit issue $ARGUMENTS --json ``` -3. On success report the brief: issue URL(s), PR URL (draft), branch name — - then fill each issue body it created (Why / Scope / Approach / - Acceptance) from the discussion with `gh issue edit `, then +3. On success report the issue URL(s), draft PR URL and branch name. + Verify the issue bodies contain the discussed Why / Scope / Approach / + Acceptance, fill only missing content, preserve remote edits, then implement. Fill in the draft PR's scaffold (Why / What changed / - Evidence / Checklist) as you deliver; its placeholders are advisory, - never gates, and the closing references stay intact. + Evidence / Checklist) as you deliver when no body rules were selected. + Enabled content rules must pass; preserve every closing reference and + existing remote body on resume. 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/command/specgit-pr.md b/.opencode/command/specgit-pr.md index 63150c805..bbb16b361 100644 --- a/.opencode/command/specgit-pr.md +++ b/.opencode/command/specgit-pr.md @@ -1,5 +1,5 @@ --- -description: Repair the SpecGit PR binding — auto-discover by head branch or bind explicitly +description: Repair the SpecGit PR binding or complete a configured automatic merge --- @@ -25,3 +25,17 @@ AGENTS.md SpecGit block; this command only launches it. explicitly: `specgit pr `. 3. `specgit pr` owns the PR binding; never hand-edit `.specgit.yaml`. `--json` is the only parse surface. + +Continue within existing user authorization. With automation enabled, the +trusted remote completion workflow continues after CI without another user +confirmation. `specgit pr --merge --json` is its recovery path. It requires +the approved target policy, `finish` exit 0, and all CI checks passing at the +current PR head. Completion means the merge and every bound issue closure are +confirmed; a partial closure remains recoverable. `finish` is read-only and +exit 0 means accepted, not necessarily completed. A failed delivery is tracked +by a repair issue; retries reuse that cause and preserve the original PR. +Automation defaults to no. Only the user's own yes enables it through +`specgit init --automation yes --merge-target `; ordinary `init --force` +preserves that choice and target. An agent must not choose yes for the user. When an +action lacks user authorization or platform permission, report the specific +missing permission with the prepared result. diff --git a/AGENTS.md b/AGENTS.md index 34724ac48..0a9e2fe6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -296,27 +296,39 @@ already exists); keep manual guidance outside them. deterministic scaffold (the `Closes #n` line for every bound issue, then Why / What changed / Evidence / Checklist sections), and writes `.specgit.yaml`. Re-running resumes; it is idempotent. -- Issue bodies are filled at bootstrap, from the conversation: right after - `specgit issue` succeeds, edit each issue it created (`gh issue edit `) - with the discussed Why / Scope / Approach / Acceptance, then implement. - The PR scaffold's placeholders are advisory — fill those sections in as - you deliver; the closing references are the only body gate. The PR body - is written once at creation; no SpecGit command edits an existing PR - body, and the repository's own pull-request template is never read. +- Use the issue/PR templates explicitly selected by policy. With + `validation.bodies` or `required_sections`, prepare complete content from + the discussion before bootstrap and supply `--body-file ` per new + title and `--pr-body-file `. Without body rules, built-in scaffolds + can be filled after creation. Preserve every `Closes #n`; enabled body + rules apply at creation and acceptance. Resume keeps existing remote bodies + and user edits. Unselected repository templates are not silently loaded. - A draft pull request always fails the verdict (`pr_draft`): before `specgit finish`, mark it ready for review — `gh pr ready ` on GitHub, `glab mr update --ready` on GitLab. -- Finish with `specgit finish`: the verdict, derived from real git, PR, - and CI evidence. Exit code 0 is the only "done". +- `specgit finish` is read-only: its verdict comes from real git, PR, + and CI evidence; exit 0 means accepted. With automation enabled, the trusted + remote workflow continues after CI without another confirmation. + `specgit pr --merge --json` is the recovery path: it verifies the approved + `target_branch`, fresh acceptance, and all current-head CI, then confirms + the merge and every bound issue closure before reporting completed. + A failed closure remains recoverable and is never reported as completed. ### Issue tags +- Follow the project's `language` for issues and PRs. Enabled `validation` + rules check titles and labels before creation and during `finish`. + `kind` mode requires one catalog kind and only declared extras; + `project` mode selects only policy `tags`. Users choose rule changes with + `specgit init --force --configure-rules`. - Every bootstrap applies the title's `kind::` member automatically; pass `--tags ` to choose the full set explicitly. - Selection is pool-first: existing on-spec labels win verbatim; anything missing is seeded from the built-in `kind::` catalog or the policy's `tags:` declarations. Unknown vocabulary exits 2 naming the universe. -- Choose with restraint: at most one label per axis, none when unsure — +- Choose at most one label per axis; omit uncertain optional labels and + keep every label required by the selected policy. Existing pool labels + cannot override that policy — off-spec pool labels are reported (`tag_pool_dirty` warnings are for humans) and never renamed by SpecGit. @@ -337,6 +349,9 @@ already exists); keep manual guidance outside them. - `specgit setup` installs the agent entry points (commands for opencode, portable skills for other tools); `specgit bind`, `specgit unbind`, and `specgit accept` are automation aliases for scripts and CI. +- Automation defaults to off (`--automation no`). Only when the user personally chooses + yes may `specgit init --automation yes --merge-target ` enable it; + ordinary `init --force` preserves that choice and target. An agent must not answer yes for the user. ### Before creating an issue, check for duplicates @@ -366,23 +381,40 @@ verified on its own evidence, split it before binding. ### Agent contract essentials -- **SpecGit is the default way of working here.** Any non-trivial - task — a feature, a fix, a refactor, a docs change — is a delivery: +- **SpecGit is the default delivery workflow here.** An intended tracked + change — a feature, a fix, a refactor, a docs change, or shared rules — is a delivery: work items live in this tracker as issues, never in private task lists or conversational checklists. The trigger is the decision to start: the moment the conversation settles and you begin turning the plan into changes, the FIRST action is - `specgit issue : ...` — before any file edit. + `specgit issue <type>: <title>...` — before tracked implementation edits. + Preparing temporary body files for bootstrap is part of this first step. Working without a binding is a contract violation, not a style - choice. Immediately after bootstrap, fill each issue body - (Why / Scope / Approach / Acceptance) from the discussion with - `gh issue edit`, then implement. Mid-conversation inventories + choice. After bootstrap, verify each issue contains the discussed + Why / Scope / Approach / Acceptance and fill only missing content with + `gh issue edit` or `glab issue update`, + then implement. Mid-conversation inventories ("let me list everything to do") become issues, not chat artifacts. Trivial replies and read-only questions need none of this. -- The one rule: a delivery is done if and only if `specgit finish` - exits `0`. Never declare completion from task lists, file states, or - test runs you performed yourself. +- Local maintenance: installing or upgrading the CLI and running `init` / + `setup` to refresh local configuration and entry points need no issue, PR, + product build, or release when no product or shared-rule change is intended + for commit. Review tracked diffs before choosing what to share; ignore rules + are never CI exemptions. Follow the host project's verification policy for + the actual changed inputs; documentation may itself be a product input. + Publishing requires explicit release intent within existing user authorization; + local maintenance and merging do not imply publication. +- `specgit finish` exit `0` means accepted. Report completed only after + the configured target merge and every bound issue closure are confirmed. + Never declare completion from task lists, file states, or tests alone. + Track a failed PR with a new repair issue; repeated causes reuse an open + repair issue and do not require abandoning the original PR. +- Use existing user authorization to complete issue bodies, the PR body + and ready transition, CI repairs or retries, acceptance, and the authorized + merge. When user authorization or platform permission is missing, present + the prepared result and name the specific gap. Documentation and entry + points do not grant permission themselves. - Branch on exit codes, not phrasing: `1` = evidence complete, fix what the gates named; `3` = evidence missing, fix the environment first (`specgit doctor`). Never present exit `3` as success. From 79dd180695b9cb091e9c80f7568be76fe02cd2d3 Mon Sep 17 00:00:00 2001 From: Lex <sunsan05@Gmail.com> Date: Sat, 5 Sep 2026 07:34:40 +0800 Subject: [PATCH 4/6] fix(dag): preserve execution and recovery invariants Fixes #545, #546, #547, #548, #549, #550, and #551. --- packages/core/src/dag/core/replan.ts | 33 +- packages/core/src/dag/core/types.ts | 26 +- packages/core/src/dag/store.ts | 23 ++ packages/core/test/dag-core.test.ts | 16 + .../test/dag-store-checkpoint-control.test.ts | 88 ++++ packages/opencode/script/dag-core-coverage.ts | 1 + packages/opencode/src/dag/dag.ts | 348 +++++++++++----- .../opencode/src/dag/replan-definition.ts | 70 ++++ packages/opencode/src/dag/runtime/capture.ts | 12 + .../opencode/src/dag/runtime/checkpoint.ts | 32 ++ packages/opencode/src/dag/runtime/eval.ts | 92 ++++- packages/opencode/src/dag/runtime/loop.ts | 385 ++++++++++++------ packages/opencode/src/dag/runtime/recovery.ts | 95 +++-- packages/opencode/src/dag/runtime/spawn.ts | 100 ++++- packages/opencode/src/dag/validation.ts | 76 +++- packages/opencode/src/event-v2-bridge.ts | 32 +- .../test/dag/dag-attempt-fencing.test.ts | 281 +++++++++++++ .../test/dag/dag-checkpoint-recovery.test.ts | 337 +++++++++++++++ .../dag/dag-input-mapping-runtime.test.ts | 277 +++++++++++++ .../dag/dag-input-mapping-validation.test.ts | 237 +++++++++++ .../opencode/test/dag/dag-loop-guards.test.ts | 76 +++- .../opencode/test/dag/dag-output-ref.test.ts | 5 +- .../dag/dag-plain-text-settlement.test.ts | 185 +++++++++ .../opencode/test/dag/dag-recovery.test.ts | 51 ++- .../test/dag/dag-replan-atomicity.test.ts | 279 +++++++++++++ .../dag/dag-replan-definition-guard.test.ts | 316 ++++++++++++++ .../dag/dag-replan-stale-nodefailed.test.ts | 208 +++++++++- .../test/dag/dag-step-convergence.test.ts | 283 +++++++++++++ .../test/dag/dag-structured-output.test.ts | 5 +- .../test/dag/dag-timeout-escalation.test.ts | 93 ++++- .../test/dag/dag-workflow-lock.test.ts | 2 + .../test/dag/spawn-completion.test.ts | 44 +- .../opencode/test/dag/workflow-tool.test.ts | 22 +- 33 files changed, 3788 insertions(+), 342 deletions(-) create mode 100644 packages/core/test/dag-store-checkpoint-control.test.ts create mode 100644 packages/opencode/src/dag/replan-definition.ts create mode 100644 packages/opencode/src/dag/runtime/checkpoint.ts create mode 100644 packages/opencode/test/dag/dag-attempt-fencing.test.ts create mode 100644 packages/opencode/test/dag/dag-checkpoint-recovery.test.ts create mode 100644 packages/opencode/test/dag/dag-input-mapping-runtime.test.ts create mode 100644 packages/opencode/test/dag/dag-input-mapping-validation.test.ts create mode 100644 packages/opencode/test/dag/dag-plain-text-settlement.test.ts create mode 100644 packages/opencode/test/dag/dag-replan-atomicity.test.ts create mode 100644 packages/opencode/test/dag/dag-replan-definition-guard.test.ts create mode 100644 packages/opencode/test/dag/dag-step-convergence.test.ts diff --git a/packages/core/src/dag/core/replan.ts b/packages/core/src/dag/core/replan.ts index caca0e24b..8c9045a09 100644 --- a/packages/core/src/dag/core/replan.ts +++ b/packages/core/src/dag/core/replan.ts @@ -16,15 +16,22 @@ * - terminal nodes (done/cancelled/failed) in the fragment → IGNORED (iron law #2) * - running nodes: * - absent from fragment → kept unchanged (let finish) - * - present, no marker → kept unchanged + * - present, no marker → classified as replace; the host must first prove + * the definition is equivalent or the update is safe * - restart: true → pause + discard child session + re-spawn with fragment's def * - cancel: true → cancelled; downstream becomes orphan (auto-failed via cascade) + * - queued/paused nodes: + * - absent from fragment → cancelled (superseded) + * - present → classified as replace; the host must first prove + * captured execution fields are unchanged * - pending nodes: * - absent from fragment → cancelled (superseded) * - present → replaced with fragment's def * - new ids (not in old graph) → added * * After merge the full graph MUST be acyclic; validation fails otherwise. + * This config-free planner cannot compare complete node definitions. Callers + * must validate execution-field changes before applying the returned plan. */ import { CycleError, DependencyGraph } from "./graph" @@ -36,7 +43,7 @@ export interface ReplanNodeInput { depends_on: string[] /** Marker: re-spawn this running node's child session with the fragment's def. */ restart?: boolean - /** Marker: cancel this running/pending node; downstream is auto-failed via cascade. */ + /** Marker: cancel this non-terminal node; downstream is auto-failed via cascade. */ cancel?: boolean } @@ -51,11 +58,11 @@ export interface CurrentNodeState { export interface ReplanMergePlan { /** Non-empty means the replan is REJECTED; the runtime must not apply it. */ errors: string[] - /** Node ids to cancel (pending-not-in-fragment + running-with-cancel). */ + /** Node ids to cancel (superseded pending/queued/paused + explicit cancel). */ cancel: string[] /** Node ids to restart (running-with-restart); def comes from the fragment. */ restart: string[] - /** Pending nodes to replace with the fragment's def. */ + /** Non-terminal nodes to replace after the host validates their definitions. */ replace: string[] /** New node ids to add. */ add: string[] @@ -189,13 +196,11 @@ export function planReplan( for (const n of current.nodes) { if (!survivingIds.has(n.id)) continue const frag = fragmentNodeById.get(n.id) - // P1a: the CHECK graph must equal the EXECUTION graph. A running node - // present without a restart marker is replaced (its definition is - // re-published via NodeRegistered and the projector upserts the fragment's - // depends_on into the durable row the runtime rebuilds from), so it takes - // the fragment's deps here too — otherwise a cycle only reachable through - // the replaced deps passes the check and crashes the runtime's - // rebuildGraph. Terminal nodes are immutable and keep their current deps. + // P1a: the CHECK graph must equal the proposed post-merge graph. A running + // node present without a restart marker is classified as replaced, so it + // takes the fragment's deps here. The host rejects changed admitted deps + // before applying this plan; keeping the proposed edge here also makes this + // config-free planner safe for every caller. Terminal nodes are immutable. const deps = frag && !isNodeTerminalStatus(n.status) ? frag.depends_on : n.depends_on for (const depId of deps) tryAddEdge(n.id, depId) } @@ -233,10 +238,8 @@ export function planReplan( restart.push(n.id) continue } - // A running node present in the fragment (no restart marker) gets its - // definition replaced without re-executing — this is the timeout - // extension path: the merged config carries the new worker_config.timeout_ms, - // and the runtime recomputes the absolute deadline from it. + // The host must prevalidate this replacement. Dag._replan admits only an + // equivalent definition or its explicit running-time timeout update. if (frag) replace.push(n.id) continue } diff --git a/packages/core/src/dag/core/types.ts b/packages/core/src/dag/core/types.ts index e74655d6c..62d6b1e38 100644 --- a/packages/core/src/dag/core/types.ts +++ b/packages/core/src/dag/core/types.ts @@ -64,6 +64,7 @@ export enum SkipReason { export enum ErrorCode { INVALID_TRANSITION = "INVALID_TRANSITION", + STALE_NODE_ATTEMPT = "STALE_NODE_ATTEMPT", TERMINAL_VIOLATION = "TERMINAL_VIOLATION", STATE_MACHINE_VIOLATION = "STATE_MACHINE_VIOLATION", EVENT_NOT_BROADCAST = "EVENT_NOT_BROADCAST", @@ -109,9 +110,30 @@ export class TerminalViolationError extends DagCoreError { } } +export class StaleNodeAttemptError extends DagCoreError { + constructor( + nodeId: string, + expected: { replanAttempts: number; nodeSeq?: number; childSessionID?: string; graphRev?: number }, + actual: { replanAttempts: number; nodeSeq: number; childSessionID: string | null; graphRev: number }, + ) { + super(ErrorCode.STALE_NODE_ATTEMPT, `Stale execution attempt rejected: ${nodeId}`, { + nodeId, + expected, + actual, + }) + this.name = "StaleNodeAttemptError" + } +} + /** Returns true when a concurrent status change made the requested transition obsolete. */ -export function isTransitionRejection(error: unknown): error is InvalidTransitionError | TerminalViolationError { - return error instanceof InvalidTransitionError || error instanceof TerminalViolationError +export function isTransitionRejection( + error: unknown, +): error is InvalidTransitionError | TerminalViolationError | StaleNodeAttemptError { + return ( + error instanceof InvalidTransitionError || + error instanceof TerminalViolationError || + error instanceof StaleNodeAttemptError + ) } export class StateNotPersistedError extends DagCoreError { diff --git a/packages/core/src/dag/store.ts b/packages/core/src/dag/store.ts index 1b459101e..58c4fd0d2 100644 --- a/packages/core/src/dag/store.ts +++ b/packages/core/src/dag/store.ts @@ -5,7 +5,10 @@ export * as DagStore from "./store" import { and, asc, count, desc, eq, gt, inArray, or } from "drizzle-orm" import { Context, Effect, Layer } from "effect" +import { DagEvent } from "@opencode-ai/schema/dag-event" import { Database } from "../database/database" +import { versionedType } from "../event" +import { EventTable } from "../event/sql" import { LayerNode } from "../effect/layer-node" import { WorkflowNodeTable, WorkflowTable } from "./sql" @@ -150,6 +153,13 @@ const wakeDeliverableNodePredicate = or( gt(WorkflowNodeTable.timeout_extensions, 0), ) +const checkpointControlTypes = [DagEvent.WorkflowResumed, DagEvent.WorkflowReplanned, DagEvent.WorkflowStepped].map( + (definition) => { + if (!definition.durable) throw new Error(`Checkpoint control event is not durable: ${definition.type}`) + return versionedType(definition.type, definition.durable.version) + }, +) + // ============================================================================ // Service interface // ============================================================================ @@ -162,6 +172,8 @@ export interface Interface { readonly listByProject: (projectId: string) => Effect.Effect<WorkflowRow[]> readonly listByStatus: (status: string) => Effect.Effect<WorkflowRow[]> readonly getWorkflowSummaries: (sessionId: string) => Effect.Effect<WorkflowSummary[]> + /** Latest explicit parent control that can dispose of a checkpoint veto. */ + readonly getLatestCheckpointControlSeq: (workflowId: string) => Effect.Effect<number | undefined> readonly getNodes: (workflowId: string) => Effect.Effect<NodeRow[]> /** @@ -200,6 +212,17 @@ export const layer = Layer.effect( return row ? mapWorkflow(row) : undefined }), + getLatestCheckpointControlSeq: Effect.fn("DagStore.getLatestCheckpointControlSeq")(function* (workflowId) { + const row = yield* db + .select({ seq: EventTable.seq }) + .from(EventTable) + .where(and(eq(EventTable.aggregate_id, workflowId), inArray(EventTable.type, checkpointControlTypes))) + .orderBy(desc(EventTable.seq)) + .get() + .pipe(Effect.orDie) + return row?.seq + }), + // #270 atomic-adoption fence (C2). The adoption sites previously re-read the // row (ownsWorkflow) and then published their entry into the in-memory map — // a check-then-act pair a deletion cascade could commit between. The claim diff --git a/packages/core/test/dag-core.test.ts b/packages/core/test/dag-core.test.ts index 4fff69640..9384e4900 100644 --- a/packages/core/test/dag-core.test.ts +++ b/packages/core/test/dag-core.test.ts @@ -7,9 +7,11 @@ import { getValidNextNodeStatuses, getValidNextWorkflowStatuses, InvalidTransitionError, + isTransitionRejection, isNodeTerminalStatus, isWorkflowTerminalStatus, NodeStatus, + StaleNodeAttemptError, TerminalViolationError, WorkflowStatus, } from "@opencode-ai/core/dag/core/types" @@ -211,6 +213,20 @@ describe("iron laws (transition tables)", () => { ) }) + it("classifies stale execution attempts as concurrent transition rejections", () => { + const error = new StaleNodeAttemptError( + "n1", + { replanAttempts: 0, nodeSeq: 4, childSessionID: "ses_old", graphRev: 2 }, + { replanAttempts: 1, nodeSeq: 9, childSessionID: "ses_new", graphRev: 3 }, + ) + expect(isTransitionRejection(error)).toBe(true) + expect(error.context).toEqual({ + nodeId: "n1", + expected: { replanAttempts: 0, nodeSeq: 4, childSessionID: "ses_old", graphRev: 2 }, + actual: { replanAttempts: 1, nodeSeq: 9, childSessionID: "ses_new", graphRev: 3 }, + }) + }) + it("assertValidWorkflowTransition allows PAUSED → RUNNING (resume)", () => { expect(() => assertValidWorkflowTransition("w1", WorkflowStatus.PAUSED, WorkflowStatus.RUNNING)).not.toThrow() }) diff --git a/packages/core/test/dag-store-checkpoint-control.test.ts b/packages/core/test/dag-store-checkpoint-control.test.ts new file mode 100644 index 000000000..9f1767a5b --- /dev/null +++ b/packages/core/test/dag-store-checkpoint-control.test.ts @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: 2026 LeXwDeX +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { DagStore } from "@opencode-ai/core/dag/store" +import { Database } from "@opencode-ai/core/database/database" +import { EventV2 } from "@opencode-ai/core/event" +import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql" +import { DagEvent } from "@opencode-ai/schema/dag-event" +import { testEffect } from "./lib/effect" + +const database = Database.layerFromPath(":memory:") +const store = DagStore.layer.pipe(Layer.provide(database)) +const it = testEffect(Layer.merge(database, store)) + +function durableType(definition: { readonly type: string; readonly durable?: { readonly version: number } }) { + if (!definition.durable) throw new Error(`Event is not durable: ${definition.type}`) + return EventV2.versionedType(definition.type, definition.durable.version) +} + +describe("DagStore checkpoint control sequence", () => { + it.effect("returns only the latest explicit checkpoint-disposition control", () => + Effect.gen(function* () { + const { db } = yield* Database.Service + const dagStore = yield* DagStore.Service + const dagID = "dag-checkpoint-control" + yield* db.insert(EventSequenceTable).values({ aggregate_id: dagID, seq: 12 }).run().pipe(Effect.orDie) + yield* db + .insert(EventTable) + .values([ + { + id: EventV2.ID.create(), + aggregate_id: dagID, + seq: 4, + type: EventV2.versionedType(DagEvent.WorkflowPaused.type, 1), + data: {}, + }, + { + id: EventV2.ID.create(), + aggregate_id: dagID, + seq: 5, + type: durableType(DagEvent.WorkflowResumed), + data: {}, + }, + { + id: EventV2.ID.create(), + aggregate_id: dagID, + seq: 8, + type: durableType(DagEvent.WorkflowStepped), + data: {}, + }, + { + id: EventV2.ID.create(), + aggregate_id: dagID, + seq: 9, + type: durableType(DagEvent.WorkflowReplanned), + data: {}, + }, + { + id: EventV2.ID.create(), + aggregate_id: dagID, + seq: 12, + type: EventV2.versionedType(DagEvent.NodeCompleted.type, 1), + data: {}, + }, + ]) + .run() + .pipe(Effect.orDie) + yield* db.insert(EventSequenceTable).values({ aggregate_id: "dag-pause-only", seq: 3 }).run().pipe(Effect.orDie) + yield* db + .insert(EventTable) + .values({ + id: EventV2.ID.create(), + aggregate_id: "dag-pause-only", + seq: 3, + type: EventV2.versionedType(DagEvent.WorkflowPaused.type, 1), + data: {}, + }) + .run() + .pipe(Effect.orDie) + + expect(yield* dagStore.getLatestCheckpointControlSeq(dagID)).toBe(9) + expect(yield* dagStore.getLatestCheckpointControlSeq("dag-pause-only")).toBeUndefined() + expect(yield* dagStore.getLatestCheckpointControlSeq("dag-without-controls")).toBeUndefined() + }), + ) +}) diff --git a/packages/opencode/script/dag-core-coverage.ts b/packages/opencode/script/dag-core-coverage.ts index 9eb0404a0..8d18943e9 100644 --- a/packages/opencode/script/dag-core-coverage.ts +++ b/packages/opencode/script/dag-core-coverage.ts @@ -65,6 +65,7 @@ export async function runDagCoreCoverageGate() { "test/dag-store-wake.test.ts", "test/dag-node-cancelled-projection.test.ts", "test/dag-store-summaries.test.ts", + "test/dag-store-checkpoint-control.test.ts", "test/dag-projector-drift.test.ts", ], thresholds: [ diff --git a/packages/opencode/src/dag/dag.ts b/packages/opencode/src/dag/dag.ts index 75263d79b..d1e306b42 100644 --- a/packages/opencode/src/dag/dag.ts +++ b/packages/opencode/src/dag/dag.ts @@ -9,6 +9,7 @@ import { DagEvent } from "@opencode-ai/schema/dag-event" import { DagProjector } from "@opencode-ai/core/dag/projector" import { DagStore } from "@opencode-ai/core/dag/store" import { EventV2Bridge } from "@/event-v2-bridge" +import type { BatchEvent } from "@opencode-ai/core/event" import { Database } from "@opencode-ai/core/database/database" import { KeyedMutex } from "@opencode-ai/core/effect/keyed-mutex" import { isRecord } from "@/util/record" @@ -20,6 +21,7 @@ import { isWorkflowTerminalStatus, isNodeTerminalStatus, InvalidTransitionError, + StaleNodeAttemptError, TerminalViolationError, WorkflowStatus, NodeStatus, @@ -31,6 +33,7 @@ import { validateAdmission, } from "./admission" import { unresolvedReviewOutcomes } from "./review-lifecycle" +import { changedAdmittedNodeFields } from "./replan-definition" import { DagValidation, StructuralValidationError } from "./validation" import { DagLocation } from "./location" import { SessionLocation } from "@/session/location" @@ -44,6 +47,16 @@ export type ID = typeof ID.Type export const NodeID = DagEvent.NodeID export type NodeID = typeof NodeID.Type +export interface NodeExecutionAttempt { + readonly replanAttempts: number + /** Admission-only definition snapshot; later node events advance seq. */ + readonly nodeSeq?: number + /** Settlement identity after NodeStarted assigns the child session. */ + readonly childSessionID?: string + /** Admission-only config generation; running settlements intentionally omit it. */ + readonly graphRev?: number +} + export const DEFAULT_WORKFLOW_CONFIG = { maxConcurrency: 5, maxNodeReplanAttempts: 5, @@ -259,6 +272,10 @@ export interface Interface { }) => Effect.Effect<ID, Error> readonly store: DagStore.Interface readonly pause: (dagID: string) => Effect.Effect<void, Error> + readonly pauseForCheckpoint: ( + dagID: string, + checkpointSeq: number, + ) => Effect.Effect<"paused" | "acknowledged" | "inactive", Error> readonly resume: (dagID: string) => Effect.Effect<void, Error> readonly step: (dagID: string) => Effect.Effect<{ status: "stepping"; nodeID?: string } | { status: "no_ready_nodes" }, Error> readonly cancel: (dagID: string) => Effect.Effect<void, Error> @@ -272,15 +289,15 @@ export interface Interface { { cancel: string[]; restart: string[]; replace: string[]; add: string[]; ignore: string[] }, Error > - readonly nodeQueued: (dagID: string, nodeID: string, deadlineMs?: number) => Effect.Effect<void, Error> - readonly nodeStarted: (dagID: string, nodeID: string, childSessionID: string, deadlineMs?: number, wakeEligible?: boolean) => Effect.Effect<void, Error> - readonly nodeCompleted: (dagID: string, nodeID: string, output: unknown) => Effect.Effect<void, Error> - readonly nodeFailed: (dagID: string, nodeID: string, reason: string, trigger: string) => Effect.Effect<void, Error> - readonly nodeSkipped: (dagID: string, nodeID: string, reason: string) => Effect.Effect<void, Error> + readonly nodeQueued: (dagID: string, nodeID: string, deadlineMs?: number, attempt?: NodeExecutionAttempt) => Effect.Effect<void, Error> + readonly nodeStarted: (dagID: string, nodeID: string, childSessionID: string, deadlineMs?: number, wakeEligible?: boolean, attempt?: NodeExecutionAttempt) => Effect.Effect<void, Error> + readonly nodeCompleted: (dagID: string, nodeID: string, output: unknown, attempt?: NodeExecutionAttempt) => Effect.Effect<void, Error> + readonly nodeFailed: (dagID: string, nodeID: string, reason: string, trigger: string, attempt?: NodeExecutionAttempt) => Effect.Effect<void, Error> + readonly nodeSkipped: (dagID: string, nodeID: string, reason: string, attempt?: NodeExecutionAttempt) => Effect.Effect<void, Error> readonly nodeCancelled: (dagID: string, nodeID: string) => Effect.Effect<void, Error> readonly nodeRestarted: (dagID: string, nodeID: string, childSessionID: string) => Effect.Effect<void, Error> - readonly nodeTimeoutEscalated: (dagID: string, nodeID: string, childSessionID: string, timeoutExtensions: number, staleDeadlineMs?: number | null) => Effect.Effect<void, Error> - readonly nodeExtendTimeout: (dagID: string, nodeID: string, newDeadlineMs: number) => Effect.Effect<number, Error> + readonly nodeTimeoutEscalated: (dagID: string, nodeID: string, childSessionID: string, timeoutExtensions: number, staleDeadlineMs?: number | null, attempt?: NodeExecutionAttempt) => Effect.Effect<void, Error> + readonly nodeExtendTimeout: (dagID: string, nodeID: string, newDeadlineMs: number, attempt?: NodeExecutionAttempt) => Effect.Effect<number, Error> } export class Service extends Context.Service<Service, Interface>()("@opencode/Dag") {} @@ -324,10 +341,39 @@ export const layer = Layer.effect( return workflow }) - const guardNode = Effect.fn("Dag.guardNode")(function* (dagID: string, nodeID: string, target: NodeStatus) { - yield* guardWorkflowNotTerminal(dagID, target) + const guardNodeAttempt = (node: DagStore.NodeRow, graphRev: number, attempt?: NodeExecutionAttempt) => { + if (!attempt) return Effect.void + const stale = + node.replanAttempts !== attempt.replanAttempts || + (attempt.nodeSeq !== undefined && node.seq !== attempt.nodeSeq) || + (attempt.childSessionID !== undefined && node.childSessionId !== attempt.childSessionID) || + (attempt.graphRev !== undefined && graphRev !== attempt.graphRev) + return stale + ? Effect.fail( + new StaleNodeAttemptError( + node.id, + attempt, + { + replanAttempts: node.replanAttempts, + nodeSeq: node.seq, + childSessionID: node.childSessionId, + graphRev, + }, + ), + ) + : Effect.void + } + + const guardNode = Effect.fn("Dag.guardNode")(function* ( + dagID: string, + nodeID: string, + target: NodeStatus, + attempt?: NodeExecutionAttempt, + ) { + const workflow = yield* guardWorkflowNotTerminal(dagID, target) const node = yield* store.getNode(dagID, nodeID).pipe(Effect.orDie) if (!node) return yield* Effect.fail(new Error(`Node not found: ${nodeID}`)) + yield* guardNodeAttempt(node, workflow.graphRev, attempt) const current = node.status as NodeStatus if (isNodeTerminalStatus(current)) { return yield* Effect.fail(new TerminalViolationError(nodeID, current, target)) @@ -335,6 +381,7 @@ export const layer = Layer.effect( if (!getValidNextNodeStatuses(current).includes(target)) { return yield* Effect.fail(new InvalidTransitionError(nodeID, current, target)) } + return node }) const create = Effect.fn("Dag.create")(function* (input: { @@ -437,6 +484,31 @@ export const layer = Layer.effect( yield* guardWorkflow(dagID, WorkflowStatus.PAUSED) yield* events.publish(DagEvent.WorkflowPaused, { dagID: dagID as ID, timestamp: yield* DateTime.now }) }) + const pauseForCheckpoint = Effect.fn("Dag.pauseForCheckpoint")(function* ( + lock: WorkflowLock, + dagID: string, + checkpointSeq: number, + ) { + const acknowledgedSeq = yield* store.getLatestCheckpointControlSeq(dagID) + if (acknowledgedSeq !== undefined && acknowledgedSeq >= checkpointSeq) return "acknowledged" as const + + const workflow = yield* store.getWorkflow(dagID).pipe(Effect.orDie) + if ( + !workflow || + workflow.status === "completed" || + workflow.status === "failed" || + workflow.status === "cancelled" || + workflow.status === "archived" + ) + return "inactive" as const + if (workflow.status === "paused") return "paused" as const + if (workflow.status !== "running" && workflow.status !== "stepping") { + return "inactive" as const + } + + yield* pause(lock, dagID) + return "paused" as const + }) const resume = Effect.fn("Dag.resume")(function* (lock: WorkflowLock, dagID: string) { yield* guardWorkflow(dagID, WorkflowStatus.RUNNING) yield* events.publish(DagEvent.WorkflowResumed, { dagID: dagID as ID, timestamp: yield* DateTime.now }) @@ -462,8 +534,12 @@ export const layer = Layer.effect( const maxConcurrency = Math.max(1, config?.max_concurrency ?? DEFAULT_WORKFLOW_CONFIG.maxConcurrency) const runtime = new WorkflowRuntime(schedulingNodes, maxConcurrency) const ready = runtime.getReadyNodes() - if (ready.length === 0) return { status: "no_ready_nodes" as const } - const nodeID = ready.slice().sort()[0] + // A previously skipped dependency can leave a pending cascade node as + // the only legal work. Give the loop a control event so it can converge + // that non-executing state instead of stranding the workflow forever. + const cascade = runtime.getCascadeSkipNodes() + const nodeID = (ready.length > 0 ? ready : cascade).slice().sort()[0] + if (!nodeID) return { status: "no_ready_nodes" as const } yield* events.publish(DagEvent.WorkflowStepped, { dagID: dagID as ID, nodeID: nodeID as never, timestamp: yield* DateTime.now }) return { status: "stepping" as const, nodeID } }) @@ -543,8 +619,11 @@ export const layer = Layer.effect( return yield* Effect.fail(new TerminalViolationError(dagID, workflow.status, "replan")) } const wfConfig = parseWorkflowConfig(workflow.config) - const defaults = normalizeNodeDefaults(wfConfig?.node_defaults) - const cfgById = new Map((wfConfig?.nodes ?? []).map((n) => [n.id, n])) + if (!wfConfig) { + return yield* Effect.fail(new Error(`Replan rejected: current workflow config is invalid: ${dagID}`)) + } + const defaults = normalizeNodeDefaults(wfConfig.node_defaults) + const cfgById = new Map(wfConfig.nodes.map((n) => [n.id, n])) const normalizedFragment = { nodes: fragment.nodes.map((node) => normalizeFragmentNode(node, cfgById.get(node.id)?.worker_config?.timeout_ms, defaults), @@ -557,11 +636,37 @@ export const layer = Layer.effect( ) if (plan.errors.length > 0) return yield* Effect.fail(new Error(`Replan rejected: ${plan.errors.join("; ")}`)) + const nodeStatusById = new Map(nodes.map((n) => [n.id, n.status])) + const definitionErrors: string[] = [] + for (const next of normalizedFragment.nodes) { + if (next.cancel || next.restart) continue + const status = nodeStatusById.get(next.id) + if (status !== NodeStatus.RUNNING && status !== NodeStatus.QUEUED && status !== NodeStatus.PAUSED) continue + const current = cfgById.get(next.id) + if (!current) { + definitionErrors.push( + `Node "${next.id}" is ${status}, but its current execution definition is unavailable and cannot be safely replaced`, + ) + continue + } + const fields = changedAdmittedNodeFields(normalizeNodeConfig(current, defaults), next, { + allowTimeoutUpdate: status === NodeStatus.RUNNING, + }) + if (fields.length === 0) continue + definitionErrors.push( + status === NodeStatus.RUNNING + ? `Node "${next.id}" is running and changes admitted execution fields without restart: ${fields.join(", ")}; set restart: true to apply the new definition` + : `Node "${next.id}" is ${status} and changes already captured execution fields: ${fields.join(", ")}; ${status} nodes cannot restart, so cancel it and add a replacement node under a new id`, + ) + } + if (definitionErrors.length > 0) { + return yield* Effect.fail(new Error(`Replan rejected: ${definitionErrors.join("; ")}`)) + } + // Fragment nodes that will actually (re)run must satisfy the same // condition-reference rule as create. Terminal nodes in the fragment are // ignored by the plan and keep their immutable definitions; cancelled // nodes never evaluate a condition again. - const nodeStatusById = new Map(nodes.map((n) => [n.id, n.status])) const rerunNodes = normalizedFragment.nodes.filter((n) => { if (n.cancel) return false const status = nodeStatusById.get(n.id) @@ -574,15 +679,15 @@ export const layer = Layer.effect( // the exact same helper functions as structuralDiagnostics). This is the // create/replan parity the spec requires: one authority, two entry points // that differ only in scoping (fragment + rerun-only vs whole-graph). - const maxReplanAttempts = wfConfig?.max_node_replan_attempts ?? DEFAULT_WORKFLOW_CONFIG.maxNodeReplanAttempts + const maxReplanAttempts = wfConfig.max_node_replan_attempts ?? DEFAULT_WORKFLOW_CONFIG.maxNodeReplanAttempts const replanDiagnostics = DagValidation.replanStructuralDiagnostics({ fragmentNodes: normalizedFragment.nodes, rerunNodes, existingNodeIds: new Set(nodes.map((n) => n.id)), existingNodeCount: nodes.length, addCount: plan.add.length, - merged: wfConfig ? computeMergedConfig(wfConfig, normalizedFragment, plan) : { nodes: normalizedFragment.nodes }, - config: { mode: wfConfig?.mode, max_total_nodes: wfConfig?.max_total_nodes }, + merged: computeMergedConfig(wfConfig, normalizedFragment, plan), + config: { mode: wfConfig.mode, max_total_nodes: wfConfig.max_total_nodes }, terminalNodeIds: new Set( nodes.filter((n) => isNodeTerminalStatus(n.status as NodeStatus)).map((n) => n.id), ), @@ -597,10 +702,20 @@ export const layer = Layer.effect( const nodeById = new Map(nodes.map((n) => [n.id, n])) const ceilingBreached: string[] = [] + const batch: BatchEvent[] = [] for (const id of plan.restart) { const existing = nodeById.get(id) if (existing && existing.replanAttempts >= maxReplanAttempts) { - yield* nodeFailed(lock, dagID, id, "replan attempt ceiling exceeded", "exec_failed").pipe(Effect.ignore) + batch.push({ + definition: DagEvent.NodeFailed, + data: { + dagID: DagEvent.DagID.make(dagID), + nodeID: DagEvent.NodeID.make(id), + reason: "replan attempt ceiling exceeded", + trigger: "exec_failed", + timestamp: yield* DateTime.now, + }, + }) ceilingBreached.push(id) } } @@ -609,15 +724,18 @@ export const layer = Layer.effect( const fragmentById = new Map(normalizedFragment.nodes.map((n) => [n.id, n])) for (const id of plan.add) { const node = fragmentById.get(id)! - yield* events.publish(DagEvent.NodeRegistered, { - dagID: dagID as ID, - nodeID: id as never, - name: node.name, - workerType: node.worker_type, - dependsOn: node.depends_on.map((d) => d as never), - required: node.required, - model: node.model as never, - timestamp: yield* DateTime.now, + batch.push({ + definition: DagEvent.NodeRegistered, + data: { + dagID: dagID as ID, + nodeID: id as never, + name: node.name, + workerType: node.worker_type, + dependsOn: node.depends_on.map((d) => d as never), + required: node.required, + model: node.model as never, + timestamp: yield* DateTime.now, + }, }) } // Replaced nodes: re-publish NodeRegistered so the projector upserts the @@ -625,22 +743,28 @@ export const layer = Layer.effect( for (const id of plan.replace) { const node = fragmentById.get(id) if (!node) continue - yield* events.publish(DagEvent.NodeRegistered, { - dagID: dagID as ID, - nodeID: id as never, - name: node.name, - workerType: node.worker_type, - dependsOn: node.depends_on.map((d) => d as never), - required: node.required, - model: node.model as never, - timestamp: yield* DateTime.now, + batch.push({ + definition: DagEvent.NodeRegistered, + data: { + dagID: dagID as ID, + nodeID: id as never, + name: node.name, + workerType: node.worker_type, + dependsOn: node.depends_on.map((d) => d as never), + required: node.required, + model: node.model as never, + timestamp: yield* DateTime.now, + }, }) } for (const id of plan.cancel) { - yield* events.publish(DagEvent.NodeCancelled, { - dagID: dagID as ID, - nodeID: id as never, - timestamp: yield* DateTime.now, + batch.push({ + definition: DagEvent.NodeCancelled, + data: { + dagID: dagID as ID, + nodeID: id as never, + timestamp: yield* DateTime.now, + }, }) } for (const id of effectiveRestart) { @@ -651,22 +775,28 @@ export const layer = Layer.effect( // Mirrors the replace bucket's NodeRegistered re-publish. const node = fragmentById.get(id) if (node) { - yield* events.publish(DagEvent.NodeRegistered, { + batch.push({ + definition: DagEvent.NodeRegistered, + data: { + dagID: dagID as ID, + nodeID: id as never, + name: node.name, + workerType: node.worker_type, + dependsOn: node.depends_on.map((d) => d as never), + required: node.required, + model: node.model as never, + timestamp: yield* DateTime.now, + }, + }) + } + batch.push({ + definition: DagEvent.NodeRestarted, + data: { dagID: dagID as ID, nodeID: id as never, - name: node.name, - workerType: node.worker_type, - dependsOn: node.depends_on.map((d) => d as never), - required: node.required, - model: node.model as never, + childSessionID: (nodeById.get(id)?.childSessionId ?? "") as never, timestamp: yield* DateTime.now, - }) - } - yield* events.publish(DagEvent.NodeRestarted, { - dagID: dagID as ID, - nodeID: id as never, - childSessionID: (nodeById.get(id)?.childSessionId ?? "") as never, - timestamp: yield* DateTime.now, + }, }) } @@ -674,38 +804,41 @@ export const layer = Layer.effect( const effectivePlan = { ...plan, restart: effectiveRestart } // Persist the merged config using the effective plan (without ceiling-breached restarts) - if (wfConfig) { - const mergedConfig = computeMergedConfig(wfConfig, normalizedFragment, effectivePlan) - yield* events.publish(DagEvent.WorkflowConfigUpdated, { + const mergedConfig = computeMergedConfig(wfConfig, normalizedFragment, effectivePlan) + batch.push({ + definition: DagEvent.WorkflowConfigUpdated, + data: { dagID: dagID as ID, config: JSON.stringify(mergedConfig), timestamp: yield* DateTime.now, - }) - } else { - yield* Effect.logWarning("Dag.replan: failed to parse current config JSON — node definitions from fragment may be lost", { dagID }) - } + }, + }) // #7: max_total_nodes check is non-atomic (read-then-publish). This is // acceptable because the ceiling is a fail-safe, not a correctness // invariant — concurrent replans slightly exceeding the limit is better // than serializing all replans. The projector's INSERT ON CONFLICT // ensures no duplicate node IDs. - yield* events.publish(DagEvent.WorkflowReplanned, { - dagID: dagID as ID, - added: effectivePlan.add.length as never, - removed: effectivePlan.cancel.length as never, - replaced: effectivePlan.replace.length as never, - restarted: effectivePlan.restart.length as never, - // Rev-view (v1.0.15 Train A): the terminal-FAILED rows at replan time - // are the segment the new revision replaces — left in the rebuild - // input they re-seed as required-unsatisfied and weld the workflow to - // failure (the wake-up bug this train breaks). plan.cancel rows are - // marked superseded by the NodeCancelled projection instead; this - // list carries the genuine failures the fragment bypasses, which the - // engine never cancels. Durable rows stay untouched. - superseded: nodes.filter((n) => n.status === "failed").map((n) => DagEvent.NodeID.make(n.id)), - timestamp: yield* DateTime.now, + batch.push({ + definition: DagEvent.WorkflowReplanned, + data: { + dagID: dagID as ID, + added: effectivePlan.add.length as never, + removed: effectivePlan.cancel.length as never, + replaced: effectivePlan.replace.length as never, + restarted: effectivePlan.restart.length as never, + // Rev-view (v1.0.15 Train A): the terminal-FAILED rows at replan time + // are the segment the new revision replaces — left in the rebuild + // input they re-seed as required-unsatisfied and weld the workflow to + // failure (the wake-up bug this train breaks). plan.cancel rows are + // marked superseded by the NodeCancelled projection instead; this + // list carries the genuine failures the fragment bypasses, which the + // engine never cancels. Durable rows stay untouched. + superseded: nodes.filter((n) => n.status === "failed").map((n) => DagEvent.NodeID.make(n.id)), + timestamp: yield* DateTime.now, + }, }) + yield* events.publishMany(batch) return { cancel: effectivePlan.cancel, restart: effectivePlan.restart, replace: effectivePlan.replace, add: effectivePlan.add, ignore: effectivePlan.ignore } }) @@ -786,24 +919,24 @@ export const layer = Layer.effect( return yield* _replan(lock, dagID, { nodes: [...preserved, ...newNodes] }, reopenCompleted) }) - const nodeQueued = Effect.fn("Dag.nodeQueued")(function* (lock: WorkflowLock, dagID: string, nodeID: string, deadlineMs?: number) { - yield* guardNode(dagID, nodeID, NodeStatus.QUEUED) + const nodeQueued = Effect.fn("Dag.nodeQueued")(function* (lock: WorkflowLock, dagID: string, nodeID: string, deadlineMs?: number, attempt?: NodeExecutionAttempt) { + yield* guardNode(dagID, nodeID, NodeStatus.QUEUED, attempt) yield* events.publish(DagEvent.NodeQueued, { dagID: dagID as ID, nodeID: nodeID as never, deadlineMs, timestamp: yield* DateTime.now }) }) - const nodeStarted = Effect.fn("Dag.nodeStarted")(function* (lock: WorkflowLock, dagID: string, nodeID: string, childSessionID: string, deadlineMs?: number, wakeEligible?: boolean) { - yield* guardNode(dagID, nodeID, NodeStatus.RUNNING) + const nodeStarted = Effect.fn("Dag.nodeStarted")(function* (lock: WorkflowLock, dagID: string, nodeID: string, childSessionID: string, deadlineMs?: number, wakeEligible?: boolean, attempt?: NodeExecutionAttempt) { + yield* guardNode(dagID, nodeID, NodeStatus.RUNNING, attempt) yield* events.publish(DagEvent.NodeStarted, { dagID: dagID as ID, nodeID: nodeID as never, childSessionID: childSessionID as never, deadlineMs, wakeEligible, timestamp: yield* DateTime.now }) }) - const nodeCompleted = Effect.fn("Dag.nodeCompleted")(function* (lock: WorkflowLock, dagID: string, nodeID: string, output: unknown) { - yield* guardNode(dagID, nodeID, NodeStatus.COMPLETED) + const nodeCompleted = Effect.fn("Dag.nodeCompleted")(function* (lock: WorkflowLock, dagID: string, nodeID: string, output: unknown, attempt?: NodeExecutionAttempt) { + yield* guardNode(dagID, nodeID, NodeStatus.COMPLETED, attempt) yield* events.publish(DagEvent.NodeCompleted, { dagID: dagID as ID, nodeID: nodeID as never, output, durationMs: 0 as never, timestamp: yield* DateTime.now }) }) - const nodeFailed = Effect.fn("Dag.nodeFailed")(function* (lock: WorkflowLock, dagID: string, nodeID: string, reason: string, trigger: string) { - yield* guardNode(dagID, nodeID, NodeStatus.FAILED) + const nodeFailed = Effect.fn("Dag.nodeFailed")(function* (lock: WorkflowLock, dagID: string, nodeID: string, reason: string, trigger: string, attempt?: NodeExecutionAttempt) { + yield* guardNode(dagID, nodeID, NodeStatus.FAILED, attempt) yield* events.publish(DagEvent.NodeFailed, { dagID: dagID as ID, nodeID: nodeID as never, reason, trigger: trigger as never, timestamp: yield* DateTime.now }) }) - const nodeSkipped = Effect.fn("Dag.nodeSkipped")(function* (lock: WorkflowLock, dagID: string, nodeID: string, reason: string) { - yield* guardNode(dagID, nodeID, NodeStatus.SKIPPED) + const nodeSkipped = Effect.fn("Dag.nodeSkipped")(function* (lock: WorkflowLock, dagID: string, nodeID: string, reason: string, attempt?: NodeExecutionAttempt) { + yield* guardNode(dagID, nodeID, NodeStatus.SKIPPED, attempt) yield* events.publish(DagEvent.NodeSkipped, { dagID: dagID as ID, nodeID: nodeID as never, reason: reason as never, timestamp: yield* DateTime.now }) }) const nodeCancelled = Effect.fn("Dag.nodeCancelled")(function* (lock: WorkflowLock, dagID: string, nodeID: string) { @@ -845,11 +978,21 @@ export const layer = Layer.effect( // watcher's self-renewal loop (S1) keeps supervising — a running node is // never orphaned (N1). When staleDeadlineMs is omitted (existing callers, // test setups) the guard is inert: back-compat is unconditional publish. - const nodeTimeoutEscalated = Effect.fn("Dag.nodeTimeoutEscalated")(function* (lock: WorkflowLock, dagID: string, nodeID: string, childSessionID: string, timeoutExtensions: number, staleDeadlineMs?: number | null) { - yield* guardWorkflowNotTerminal(dagID, "timeout escalation") - if (staleDeadlineMs != null) { + const nodeTimeoutEscalated = Effect.fn("Dag.nodeTimeoutEscalated")(function* (lock: WorkflowLock, dagID: string, nodeID: string, childSessionID: string, timeoutExtensions: number, staleDeadlineMs?: number | null, attempt?: NodeExecutionAttempt) { + const workflow = yield* guardWorkflowNotTerminal(dagID, "timeout escalation") + if (attempt || staleDeadlineMs != null) { const node = yield* store.getNode(dagID, nodeID).pipe(Effect.orDie) - if (node && node.status === "running" && node.deadlineMs != null && node.deadlineMs > staleDeadlineMs) return + if (!node) return yield* Effect.fail(new InvalidTransitionError(nodeID, "missing", "timeout escalation")) + yield* guardNodeAttempt(node, workflow.graphRev, attempt) + if (attempt && node.status !== "running") { + return yield* Effect.fail(new InvalidTransitionError(nodeID, node.status, "timeout escalation")) + } + if ( + staleDeadlineMs != null && + node.status === "running" && + node.deadlineMs != null && + node.deadlineMs > staleDeadlineMs + ) return } yield* events.publish(DagEvent.NodeTimeoutEscalated, { dagID: dagID as ID, @@ -881,11 +1024,16 @@ export const layer = Layer.effect( // caller's catchCause), === 0 clears it. The only typed-error channel // beyond this explicit 1/0/-2 is withWorkflowLock (getNode/publish orDie // their work). - const nodeExtendTimeout = Effect.fn("Dag.nodeExtendTimeout")(function* (lock: WorkflowLock, dagID: string, nodeID: string, newDeadlineMs: number) { + const nodeExtendTimeout = Effect.fn("Dag.nodeExtendTimeout")(function* (lock: WorkflowLock, dagID: string, nodeID: string, newDeadlineMs: number, attempt?: NodeExecutionAttempt) { const node = yield* store.getNode(dagID, nodeID).pipe(Effect.orDie) // running-guard: a node that terminalized between the caller's read and // this command is rejected (race-free — we hold the workflow lock). if (!node || node.status !== "running") return 0 + if ( + attempt && + (node.replanAttempts !== attempt.replanAttempts || + (attempt.childSessionID !== undefined && node.childSessionId !== attempt.childSessionID)) + ) return 0 // Q2 delivery gate (ADR-0002): never re-time an escalation the main agent // has not seen. Defense in depth — the primary gate is loop.ts:800, but // the command stays self-protecting so a future caller cannot bypass it. @@ -906,6 +1054,8 @@ export const layer = Layer.effect( create, store, pause: (dagID) => withWorkflowLock(dagID)((lock) => pause(lock, dagID)), + pauseForCheckpoint: (dagID, checkpointSeq) => + withWorkflowLock(dagID)((lock) => pauseForCheckpoint(lock, dagID, checkpointSeq)), resume: (dagID) => withWorkflowLock(dagID)((lock) => resume(lock, dagID)), step: (dagID) => withWorkflowLock(dagID)((lock) => step(lock, dagID)), cancel: (dagID) => withWorkflowLock(dagID)((lock) => cancel(lock, dagID)), @@ -913,17 +1063,17 @@ export const layer = Layer.effect( fail: (dagID, reason) => withWorkflowLock(dagID)((lock) => fail(lock, dagID, reason)), replan: (dagID, fragment) => withWorkflowLock(dagID)((lock) => _replan(lock, dagID, fragment)), extend: (dagID, nodes) => withWorkflowLock(dagID)((lock) => _extend(lock, dagID, nodes)), - nodeQueued: (dagID, nodeID, deadlineMs) => withWorkflowLock(dagID)((lock) => nodeQueued(lock, dagID, nodeID, deadlineMs)), - nodeStarted: (dagID, nodeID, childSessionID, deadlineMs, wakeEligible) => - withWorkflowLock(dagID)((lock) => nodeStarted(lock, dagID, nodeID, childSessionID, deadlineMs, wakeEligible)), - nodeCompleted: (dagID, nodeID, output) => withWorkflowLock(dagID)((lock) => nodeCompleted(lock, dagID, nodeID, output)), - nodeFailed: (dagID, nodeID, reason, trigger) => withWorkflowLock(dagID)((lock) => nodeFailed(lock, dagID, nodeID, reason, trigger)), - nodeSkipped: (dagID, nodeID, reason) => withWorkflowLock(dagID)((lock) => nodeSkipped(lock, dagID, nodeID, reason)), + nodeQueued: (dagID, nodeID, deadlineMs, attempt) => withWorkflowLock(dagID)((lock) => nodeQueued(lock, dagID, nodeID, deadlineMs, attempt)), + nodeStarted: (dagID, nodeID, childSessionID, deadlineMs, wakeEligible, attempt) => + withWorkflowLock(dagID)((lock) => nodeStarted(lock, dagID, nodeID, childSessionID, deadlineMs, wakeEligible, attempt)), + nodeCompleted: (dagID, nodeID, output, attempt) => withWorkflowLock(dagID)((lock) => nodeCompleted(lock, dagID, nodeID, output, attempt)), + nodeFailed: (dagID, nodeID, reason, trigger, attempt) => withWorkflowLock(dagID)((lock) => nodeFailed(lock, dagID, nodeID, reason, trigger, attempt)), + nodeSkipped: (dagID, nodeID, reason, attempt) => withWorkflowLock(dagID)((lock) => nodeSkipped(lock, dagID, nodeID, reason, attempt)), nodeCancelled: (dagID, nodeID) => withWorkflowLock(dagID)((lock) => nodeCancelled(lock, dagID, nodeID)), nodeRestarted: (dagID, nodeID, childSessionID) => withWorkflowLock(dagID)((lock) => nodeRestarted(lock, dagID, nodeID, childSessionID)), - nodeTimeoutEscalated: (dagID, nodeID, childSessionID, timeoutExtensions, staleDeadlineMs) => - withWorkflowLock(dagID)((lock) => nodeTimeoutEscalated(lock, dagID, nodeID, childSessionID, timeoutExtensions, staleDeadlineMs)), - nodeExtendTimeout: (dagID, nodeID, newDeadlineMs) => withWorkflowLock(dagID)((lock) => nodeExtendTimeout(lock, dagID, nodeID, newDeadlineMs)), + nodeTimeoutEscalated: (dagID, nodeID, childSessionID, timeoutExtensions, staleDeadlineMs, attempt) => + withWorkflowLock(dagID)((lock) => nodeTimeoutEscalated(lock, dagID, nodeID, childSessionID, timeoutExtensions, staleDeadlineMs, attempt)), + nodeExtendTimeout: (dagID, nodeID, newDeadlineMs, attempt) => withWorkflowLock(dagID)((lock) => nodeExtendTimeout(lock, dagID, nodeID, newDeadlineMs, attempt)), }) }), ) diff --git a/packages/opencode/src/dag/replan-definition.ts b/packages/opencode/src/dag/replan-definition.ts new file mode 100644 index 000000000..762ae7ec8 --- /dev/null +++ b/packages/opencode/src/dag/replan-definition.ts @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: 2026 LeXwDeX +// SPDX-License-Identifier: AGPL-3.0-or-later + +function isObject(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function structurallyEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true + if (Array.isArray(left) || Array.isArray(right)) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((value, index) => structurallyEqual(value, right[index])) + ) + } + if (!isObject(left) || !isObject(right)) return false + + const leftKeys = Object.keys(left) + .filter((key) => left[key] !== undefined) + .sort() + const rightKeys = Object.keys(right) + .filter((key) => right[key] !== undefined) + .sort() + return ( + leftKeys.length === rightKeys.length && + leftKeys.every((key, index) => key === rightKeys[index] && structurallyEqual(left[key], right[key])) + ) +} + +function changedObjectFields( + prefix: string, + left: Record<string, unknown>, + right: Record<string, unknown>, + ignored: ReadonlySet<string>, +): string[] { + const keys = new Set([...Object.keys(left), ...Object.keys(right)]) + return [...keys] + .filter((key) => !ignored.has(key) && !structurallyEqual(left[key], right[key])) + .map((key) => `${prefix}.${key}`) +} + +/** + * Execution fields changed by a replan replacement after a node was admitted. + * Control markers are not part of the node definition. A running attempt may + * hot-update only timeout_ms; queued attempts have already fixed that deadline. + */ +export function changedAdmittedNodeFields<T extends object>( + current: T, + next: T, + options: { allowTimeoutUpdate: boolean }, +): string[] { + const left = Object.fromEntries(Object.entries(current)) + const right = Object.fromEntries(Object.entries(next)) + const ignored = new Set(["id", "restart", "cancel", "worker_config"]) + const changed = changedObjectFields("", left, right, ignored).map((field) => field.slice(1)) + + const leftWorker = isObject(left.worker_config) ? left.worker_config : {} + const rightWorker = isObject(right.worker_config) ? right.worker_config : {} + changed.push( + ...changedObjectFields( + "worker_config", + leftWorker, + rightWorker, + options.allowTimeoutUpdate ? new Set(["timeout_ms"]) : new Set(), + ), + ) + return changed.sort() +} diff --git a/packages/opencode/src/dag/runtime/capture.ts b/packages/opencode/src/dag/runtime/capture.ts index 80251908f..fa108543a 100644 --- a/packages/opencode/src/dag/runtime/capture.ts +++ b/packages/opencode/src/dag/runtime/capture.ts @@ -175,6 +175,18 @@ export type CapturedSettlement = | { readonly kind: "complete"; readonly output: unknown } | { readonly kind: "fail"; readonly reason: string } +export type PlainTextSettlement = + | { readonly kind: "complete"; readonly output: string } + | { readonly kind: "fail"; readonly reason: string } + +/** Shared live/recovery decision for nodes without an output schema. */ +export function settlePlainTextOutput(text: string | undefined): PlainTextSettlement { + if (text === undefined || text.trim() === "") { + return { kind: "fail", reason: "provider returned empty output" } + } + return { kind: "complete", output: text } +} + export function settleCapturedOutput(captured: unknown, reviewFingerprint: string | undefined, suffix = ""): CapturedSettlement { if (captured === undefined || captured === null) return { kind: "fail", reason: `output_schema declared but submit_result was never successfully called${suffix}` } diff --git a/packages/opencode/src/dag/runtime/checkpoint.ts b/packages/opencode/src/dag/runtime/checkpoint.ts new file mode 100644 index 000000000..9a1e456b9 --- /dev/null +++ b/packages/opencode/src/dag/runtime/checkpoint.ts @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2026 LeXwDeX +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { Option, Schema } from "effect" +import type { DagStore } from "@opencode-ai/core/dag/store" +import type { WorkflowConfig } from "../dag" + +const ReplanVerdict = Schema.Struct({ verdict: Schema.Literal("replan") }) +const parseJsonOption = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) + +export interface ReplanCheckpoint { + readonly id: string + readonly seq: number +} + +export function isReplanVerdict(output: unknown) { + const value = typeof output === "string" ? Option.getOrElse(parseJsonOption(output), () => output) : output + return Option.isSome(Schema.decodeUnknownOption(ReplanVerdict)(value)) +} + +export function latestReplanCheckpoint( + config: WorkflowConfig | undefined, + nodes: readonly DagStore.NodeRow[], +): ReplanCheckpoint | undefined { + if (!config) return undefined + const reporting = new Set(config.nodes.filter((node) => node.report_to_parent === true).map((node) => node.id)) + return nodes.reduce<ReplanCheckpoint | undefined>((latest, node) => { + if (node.status !== "completed" || !reporting.has(node.id) || !isReplanVerdict(node.output)) return latest + if (latest && latest.seq >= node.seq) return latest + return { id: node.id, seq: node.seq } + }, undefined) +} diff --git a/packages/opencode/src/dag/runtime/eval.ts b/packages/opencode/src/dag/runtime/eval.ts index be1570cb9..ca7388076 100644 --- a/packages/opencode/src/dag/runtime/eval.ts +++ b/packages/opencode/src/dag/runtime/eval.ts @@ -12,8 +12,6 @@ * by the scheduling layer. */ -import type { DagStore } from "@opencode-ai/core/dag/store" - const CONDITION_RE = /^(.+?)\s*(==|!=|>=|<=|>|<)\s*(.+)$/ /** @@ -91,7 +89,66 @@ export function conditionReference(condition: string | undefined): string | null if (!condition || condition.trim() === "") return null const match = condition.match(CONDITION_RE) if (!match) return null - return match[1]!.trim().split(".")[0] || null + return match[1].trim().split(".")[0] || null +} + +export type InputMappingReference = { + nodeID: string + path: string[] +} + +/** Parse the documented input_mapping source forms without inventing any + * scheduling semantics: `node-id`, `node-id.output`, or a dotted path below + * `node-id.output`. */ +export function parseInputMappingReference( + source: string, +): ({ ok: true } & InputMappingReference) | { ok: false; error: string } { + if (source.length === 0) return { ok: false, error: "source is empty" } + const parts = source.split(".") + const nodeID = parts.shift()! + if (!nodeID) return { ok: false, error: "source node id is empty" } + if (parts.length === 0) return { ok: true, nodeID, path: [] } + if (parts.shift() !== "output") return { ok: false, error: 'the first path segment must be "output"' } + if (parts.some((part) => part.length === 0)) return { ok: false, error: "output path contains an empty segment" } + return { ok: true, nodeID, path: parts } +} + +export type InputMappingOutput = { found: true; output: unknown } | { found: false } + +/** Strict execution-boundary resolution. Structural validation guarantees + * ordering for new graphs; this result also protects resumed historical + * configs and catches a declared field that is absent from durable output. */ +export function resolveInputMappingChecked( + mapping: Record<string, string> | undefined, + getOutput: (nodeID: string) => InputMappingOutput, +): { ok: true; value: Record<string, unknown> } | { ok: false; error: string } { + if (!mapping) return { ok: true, value: {} } + const value: Record<string, unknown> = {} + for (const [variable, source] of Object.entries(mapping)) { + const parsed = parseInputMappingReference(source) + if (!parsed.ok) { + return { + ok: false, + error: `input_mapping variable "${variable}" has invalid source "${source}": ${parsed.error}`, + } + } + const resolved = getOutput(parsed.nodeID) + if (!resolved.found) { + return { + ok: false, + error: `input_mapping variable "${variable}" source "${source}" has no durable output for node "${parsed.nodeID}"`, + } + } + const output = resolveMappedOutput(resolved.output, parsed.path) + if (output === undefined) { + return { + ok: false, + error: `input_mapping variable "${variable}" source "${source}" resolved to undefined`, + } + } + value[variable] = output + } + return { ok: true, value } } /** @@ -115,21 +172,32 @@ export function resolveInputMapping( if (!mapping) return {} const result: Record<string, unknown> = {} for (const [varName, ref] of Object.entries(mapping)) { - // ref format: "nodeID" or "nodeID.output" or "nodeID.output.field" - const parts = ref.split(".") - const nodeID = parts[0]! - const base = getOutput(nodeID) - if (parts.length === 1) { - result[varName] = base - } else { - result[varName] = resolvePath(parts.slice(1).join("."), { output: base }) + const parsed = parseInputMappingReference(ref) + if (!parsed.ok) { + result[varName] = undefined + continue } + const base = getOutput(parsed.nodeID) + result[varName] = resolveMappedOutput(base, parsed.path) } return result } // -------------------------------------------------------------------------- +function resolveMappedOutput(output: unknown, path: readonly string[]): unknown { + let current = output + for (const part of path) { + if (current == null) return undefined + current = readProperty(current, part) + } + return current +} + +function readProperty(value: unknown, key: string): unknown { + return Reflect.get(Object(value), key) +} + function resolvePath(path: string, source: Record<string, unknown>): unknown { const parts = path.split(".") let current: unknown = source @@ -142,7 +210,7 @@ function resolvePath(path: string, source: Record<string, unknown>): unknown { for (const part of parts) { if (current == null) return undefined - current = (current as Record<string, unknown>)[part] + current = readProperty(current, part) } return current } diff --git a/packages/opencode/src/dag/runtime/loop.ts b/packages/opencode/src/dag/runtime/loop.ts index f32fa00bf..581a13e79 100644 --- a/packages/opencode/src/dag/runtime/loop.ts +++ b/packages/opencode/src/dag/runtime/loop.ts @@ -14,7 +14,11 @@ import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event" import { DagStore } from "@opencode-ai/core/dag/store" import { DagLocation } from "../location" import { WorkflowRuntime, toSchedulingNodes } from "@opencode-ai/core/dag/core/scheduling" -import { isNodeTerminalStatus, isWorkflowTerminalStatus } from "@opencode-ai/core/dag/core/types" +import { + isNodeTerminalStatus, + isTransitionRejection, + isWorkflowTerminalStatus, +} from "@opencode-ai/core/dag/core/types" import { Dag, type WorkflowConfig, parseWorkflowConfig } from "../dag" import { projectBriefForNode } from "../admission" import { @@ -33,14 +37,10 @@ import { renderTemplate } from "../templates/resolve" import { sanitizeInput } from "../templates/sanitize" import { DagConfig } from "../config" import { spawnNode, makeDeadlineWatcher } from "./spawn" -import { evaluateCondition, resolveInputMapping } from "./eval" +import { evaluateCondition, resolveInputMapping, resolveInputMappingChecked } from "./eval" import { reconcileWorkflow, makeSessionStatusChecker, makeLastAssistantTextReader } from "./recovery" +import { latestReplanCheckpoint, type ReplanCheckpoint } from "./checkpoint" -// A reporting checkpoint's replan verdict vetoes the current direction: the -// workflow pauses durably before any downstream spawn (see NodeCompleted -// handler). Only the verdict shape matters — any node whose submitted output -// matches triggers the gate, so non-reporting nodes can never trip it. -const GateReplanVerdict = Schema.Struct({ verdict: Schema.Literal("replan") }) const parseJsonOption = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) export interface Interface { @@ -55,6 +55,8 @@ interface WorkflowEntry { evalLock: Semaphore.Semaphore parentSessionID: string config: WorkflowConfig | undefined + /** Durable graph revision paired with config and the scheduling runtime. */ + graphRev: number fibers: Map<string, Fiber.Fiber<unknown, unknown>> watchers: Map<string, Fiber.Fiber<unknown, unknown>> /** DAG-03/F2: a replan-verdict veto whose durable pause could not be @@ -65,9 +67,9 @@ interface WorkflowEntry { * from every hold state; resume only when the durable row is not running * (paused/stepping — a held row reads "running" and resume is an invalid * transition there until a durable pause lands; control(replan) is the - * disposition the verdict asked for). Process-local: a restart while the - * durable pause never landed rebuilds the flags from the durable row (the - * audit's DAG-03 scope was the in-process fail-open, which this closes). */ + * disposition the verdict asked for). This is only the process-local + * fallback: restart reconstruction re-evaluates the durable checkpoint and + * its later control-event sequence before scheduling or completion. */ vetoHold: boolean } @@ -109,19 +111,71 @@ const serviceLayer = Layer.effect( // scheduling hot path never writes to the user's config dir. yield* DagConfig.load(ctx.directory, { autoSeed: true }).pipe(Effect.ignore) - const spawnReady = Effect.fn("DagLoop.spawnReady")(function* (dagID: string) { - const entry = runtimes.get(dagID) - if (!entry) return - // P2-C execution-location revalidation: every spawn call site funnels - // through here. A workflow whose durable identity was repainted - // (identity migration) or cascade-deleted must not keep scheduling - // children under this instance's directory context — drop the stale - // entry so no later stimulus acts on it either (its watchers - // self-exit on terminal rows; its prompt fibers finish naturally). - if (!(yield* DagLocation.ownsWorkflow(dagID, ctx.directory))) { - runtimes.delete(dagID) - return + const checkpointDisposition = Effect.fn("DagLoop.checkpointDisposition")(function* ( + dagID: string, + checkpoint: ReplanCheckpoint, + ) { + const attempt = dag + .pauseForCheckpoint(dagID, checkpoint.seq) + .pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) ? Effect.failCause(cause) : Effect.succeed(undefined), + ), + ) + const first = yield* attempt + if (first) return first + const second = yield* attempt + if (second) return second + + const workflow = yield* store.getWorkflow(dagID).pipe( + Effect.catchCause(() => Effect.succeed(undefined)), + ) + if ( + !workflow || + workflow.status === "completed" || + workflow.status === "failed" || + workflow.status === "cancelled" || + workflow.status === "archived" + ) + return "inactive" as const + if (workflow.status === "paused") return "paused" as const + yield* Effect.logError( + "DagLoop checkpoint pause failed after retries — holding scheduling in memory (fail-closed)", + { dagID, nodeID: checkpoint.id, checkpointSeq: checkpoint.seq, durableStatus: workflow.status }, + ) + return "held" as const + }) + + const enforceCheckpointGate = Effect.fn("DagLoop.enforceCheckpointGate")(function* ( + dagID: string, + entry: WorkflowEntry, + nodes?: readonly DagStore.NodeRow[], + ) { + const checkpoint = latestReplanCheckpoint(entry.config, nodes ?? (yield* store.getCurrentNodes(dagID))) + if (!checkpoint) return false + + const disposition = yield* checkpointDisposition(dagID, checkpoint) + if (disposition === "paused" || disposition === "held") { + entry.vetoHold = disposition === "held" + entry.runtime.setPaused(true) + return true } + + // A newer resume/replan/step disposed of the verdict while this + // event waited for the loop lock. Re-read the current control state: + // an even newer manual pause still wins, while a running workflow + // may continue converging normally. + entry.vetoHold = false + const workflow = yield* store.getWorkflow(dagID) + entry.runtime.setPaused(workflow?.status === "paused") + entry.runtime.setStepMode(workflow?.status === "stepping") + return false + }) + + const convergeSkippedNodes = Effect.fn("DagLoop.convergeSkippedNodes")(function* ( + dagID: string, + entry: WorkflowEntry, + ) { // D13: settle cascade-skips before spawning. A node whose dependencies // are all skipped can never receive a real input; publish a durable // NodeSkipped(orphan_cascade) wave by wave until a fixpoint so gated @@ -130,12 +184,47 @@ const serviceLayer = Layer.effect( // NodeSkipped handler's isActive guard then no-ops on these events. for (;;) { const cascade = entry.runtime.getCascadeSkipNodes() - if (cascade.length === 0) break + if (cascade.length === 0) return for (const nodeID of cascade) { + const node = yield* store.getNode(dagID, nodeID) + if (!node) return + const attempt = { + replanAttempts: node.replanAttempts, + nodeSeq: node.seq, + graphRev: entry.graphRev, + } + const skipped = yield* dag.nodeSkipped(dagID, nodeID, "orphan_cascade", attempt).pipe( + Effect.as(true), + Effect.catchIf(isTransitionRejection, () => Effect.succeed(false)), + ) + // A replan can replace this row between the read and publish. + // Only advance the old runtime after the durable guarded write + // succeeds; its WorkflowReplanned handler will rebuild later. + if (!skipped) return entry.runtime.markSkipped(nodeID) - yield* dag.nodeSkipped(dagID, nodeID, "orphan_cascade").pipe(Effect.ignore) } } + }) + + const spawnReady = Effect.fn("DagLoop.spawnReady")(function* (dagID: string) { + const entry = runtimes.get(dagID) + if (!entry) return + // P2-C execution-location revalidation: every spawn call site funnels + // through here. A workflow whose durable identity was repainted + // (identity migration) or cascade-deleted must not keep scheduling + // children under this instance's directory context — drop the stale + // entry so no later stimulus acts on it either (its watchers + // self-exit on terminal rows; its prompt fibers finish naturally). + if (!(yield* DagLocation.ownsWorkflow(dagID, ctx.directory))) { + runtimes.delete(dagID) + return + } + // Checkpoint vetoes are a scheduling boundary, not merely a + // NodeCompleted-handler side effect. A different event stream can + // reach spawnReady first after the durable completion, so arbitrate + // from durable rows before every dispatch round. + if (yield* enforceCheckpointGate(dagID, entry)) return + yield* convergeSkippedNodes(dagID, entry) const ready = entry.runtime.getReadyNodes() // P1-3: one snapshot per scheduling round. Every ready node's // dependencies are already terminal (that's what made it ready), so @@ -148,9 +237,26 @@ const serviceLayer = Layer.effect( for (const nodeID of ready) { const node = nodesSnapshot.find((n) => n.id === nodeID) if (!node) continue + const admissionAttempt = { + replanAttempts: node.replanAttempts, + nodeSeq: node.seq, + graphRev: entry.graphRev, + } const nodeConfig = entry.config?.nodes.find((n) => n.id === nodeID) + if (!nodeConfig) { + yield* dag + .nodeFailed( + dagID, + nodeID, + `Node configuration missing for active node: ${nodeID}`, + "exec_failed", + admissionAttempt, + ) + .pipe(Effect.ignore) + continue + } - if (nodeConfig?.condition) { + if (nodeConfig.condition) { const outputs: Record<string, unknown> = {} for (const dep of node.dependsOn) { const depNode = nodesSnapshot.find((n) => n.id === dep) @@ -176,11 +282,28 @@ const serviceLayer = Layer.effect( } const condResult = evaluateCondition(nodeConfig.condition, outputs) if (!condResult.ok) { - yield* dag.nodeFailed(dagID, nodeID, condResult.error, "exec_failed").pipe(Effect.ignore) + yield* dag + .nodeFailed(dagID, nodeID, condResult.error, "exec_failed", admissionAttempt) + .pipe(Effect.ignore) continue } if (!condResult.value) { - yield* dag.nodeSkipped(dagID, nodeID, "condition_false").pipe(Effect.ignore) + const skipped = yield* dag.nodeSkipped( + dagID, + nodeID, + "condition_false", + admissionAttempt, + ).pipe( + Effect.as(true), + Effect.catchIf(isTransitionRejection, () => Effect.succeed(false)), + ) + // Keep the in-memory graph in lockstep only after the durable + // guarded write succeeds. This still exposes the skip cascade + // synchronously while WorkflowStepped holds evalLock. + if (skipped) { + entry.runtime.markSkipped(nodeID) + yield* convergeSkippedNodes(dagID, entry) + } continue } } @@ -188,8 +311,28 @@ const serviceLayer = Layer.effect( const promptParts: { type: "text"; text: string }[] = [] let resolvedMapping: Record<string, unknown> = {} - const inputMapping = nodeConfig?.input_mapping ?? Object.fromEntries(node.dependsOn.map((dependency) => [dependency, dependency])) - if (Object.keys(inputMapping).length > 0) { + const inputMapping = nodeConfig.input_mapping ?? Object.fromEntries(node.dependsOn.map((dependency) => [dependency, dependency])) + if (nodeConfig.input_mapping) { + const resolved = resolveInputMappingChecked(inputMapping, (dependency) => { + const source = nodesSnapshot.find((candidate) => candidate.id === dependency) + return source?.status === "completed" + ? { found: true, output: source.output } + : { found: false } + }) + if (!resolved.ok) { + yield* dag + .nodeFailed( + dagID, + nodeID, + `Input mapping resolution failed: ${resolved.error}`, + "exec_failed", + admissionAttempt, + ) + .pipe(Effect.ignore) + continue + } + resolvedMapping = resolved.value + } else if (Object.keys(inputMapping).length > 0) { resolvedMapping = resolveInputMapping(inputMapping, (depId) => { const depNode = nodesSnapshot.find((n) => n.id === depId) if (!depNode) return null @@ -220,6 +363,7 @@ const serviceLayer = Layer.effect( nodeID, `Review input contract failed: ${reviewInput.errors.join("; ")}`, "verdict_fail", + admissionAttempt, ).pipe(Effect.ignore) continue } @@ -235,7 +379,15 @@ const serviceLayer = Layer.effect( Effect.map((result) => ({ ok: true as const, ...result })), Effect.catch((err: unknown) => Effect.gen(function* () { - yield* dag.nodeFailed(dagID, nodeID, `Template resolution failed: ${String(err)}`, "exec_failed").pipe(Effect.ignore) + yield* dag + .nodeFailed( + dagID, + nodeID, + `Template resolution failed: ${String(err)}`, + "exec_failed", + admissionAttempt, + ) + .pipe(Effect.ignore) return { ok: false as const, text: "", unresolvedPlaceholders: [] } }), ), @@ -253,6 +405,7 @@ const serviceLayer = Layer.effect( nodeID, `Unresolved template placeholders: ${resolved.unresolvedPlaceholders.join(", ")}`, "verdict_fail", + admissionAttempt, ).pipe(Effect.ignore) continue } @@ -282,21 +435,22 @@ const serviceLayer = Layer.effect( }) } - entry.runtime.markRunning(nodeID) - const oldFiber = entry.fibers.get(nodeID) - const oldWatcher = entry.watchers.get(nodeID) - yield* abortChild(nodeID, node.childSessionId).pipe(Effect.ignore) - if (oldFiber) yield* Fiber.interrupt(oldFiber).pipe(Effect.ignore) - // Interrupt the old watcher BEFORE spawning a new one — otherwise - // the old self-renewing watcher survives as a phantom (it is - // unreachable from the map after the overwrite below) and keeps - // escalating against the stale deadline, double-counting - // timeout_extensions and sending duplicate wake notifications. - if (oldWatcher) yield* Fiber.interrupt(oldWatcher).pipe(Effect.ignore) yield* spawnNode(entry.semaphore, { dagID, nodeID, node, + graphRev: entry.graphRev, + onAdmitted: Effect.gen(function* () { + entry.runtime.markRunning(nodeID) + const oldFiber = entry.fibers.get(nodeID) + const oldWatcher = entry.watchers.get(nodeID) + yield* abortChild(nodeID, node.childSessionId).pipe(Effect.ignore) + if (oldFiber) yield* Fiber.interrupt(oldFiber).pipe(Effect.ignore) + // Interrupt the old watcher before installing a new one. The + // old self-renewing watcher would otherwise become unreachable + // after the map overwrite and keep escalating stale deadlines. + if (oldWatcher) yield* Fiber.interrupt(oldWatcher).pipe(Effect.ignore) + }), parentSessionID: entry.parentSessionID, directory: ctx.directory, promptParts, @@ -323,7 +477,7 @@ const serviceLayer = Layer.effect( Effect.catchCause((cause) => Cause.hasInterrupts(cause) ? Effect.failCause(cause) - : dag.nodeFailed(dagID, nodeID, Cause.pretty(cause), "exec_failed"), + : dag.nodeFailed(dagID, nodeID, Cause.pretty(cause), "exec_failed", admissionAttempt), ), Effect.ignore, ) @@ -334,6 +488,9 @@ const serviceLayer = Layer.effect( const entry = runtimes.get(dagID) if (!entry) return if (!entry.runtime.isComplete()) return + // A reporting leaf checkpoint is already runtime-complete. Recover + // and enforce its veto before publishing WorkflowCompleted. + if (yield* enforceCheckpointGate(dagID, entry)) return // Replan registers replacement nodes before cancelling nodes from the // old runtime graph. Event types are consumed independently, so the // cancellation handler can reach this point before WorkflowReplanned @@ -486,14 +643,36 @@ const serviceLayer = Layer.effect( // WorkflowReplanned handler). Durable truth (the unfiltered read // in recovery reconcile above) is untouched. const nodes = yield* store.getCurrentNodes(dagID) + const checkpoint = latestReplanCheckpoint(config, nodes) + const checkpointState = checkpoint ? yield* checkpointDisposition(dagID, checkpoint) : undefined + const pausedForCheckpoint = checkpointState === "paused" || checkpointState === "held" + if (pausedForCheckpoint) { + yield* Effect.logWarning("DagLoop recovered unresolved checkpoint verdict: replan", { + dagID, + nodeID: checkpoint?.id, + checkpointSeq: checkpoint?.seq, + durable: checkpointState === "paused", + }) + } + const currentWorkflow = yield* store.getWorkflow(dagID).pipe(Effect.orDie) const maxConcurrency = Math.max(1, config?.max_concurrency ?? Dag.DEFAULT_WORKFLOW_CONFIG.maxConcurrency) const runtime = new WorkflowRuntime(toSchedulingNodes(nodes), maxConcurrency) const semaphore = Semaphore.makeUnsafe(maxConcurrency) - const isPaused = wf.status === "paused" || pausedForRecovery - const isStepping = wf.status === "stepping" + const isPaused = currentWorkflow?.status === "paused" || pausedForRecovery || pausedForCheckpoint + const isStepping = currentWorkflow?.status === "stepping" && !isPaused if (isPaused) runtime.setPaused(true) if (isStepping) runtime.setStepMode(true) - const entry: WorkflowEntry = { runtime, semaphore, evalLock: Semaphore.makeUnsafe(1), parentSessionID: wf.sessionId, config, fibers: new Map(), watchers: new Map(), vetoHold: false } + const entry: WorkflowEntry = { + runtime, + semaphore, + evalLock: Semaphore.makeUnsafe(1), + parentSessionID: wf.sessionId, + config, + graphRev: currentWorkflow?.graphRev ?? wf.graphRev, + fibers: new Map(), + watchers: new Map(), + vetoHold: checkpointState === "held", + } // P2-E deletion-race re-check: the SessionV1.Event.Deleted sweep // only removes entries already published into `runtimes`. If the // FK cascade deleted this workflow's row while reconciliation ran, @@ -523,7 +702,7 @@ const serviceLayer = Layer.effect( // Deliver the invented-failure wake rows now instead of waiting for // the next idle event — the workflow just paused itself and the // parent is the only actor that can dispose of it. - if (pausedForRecovery) { + if (pausedForRecovery || pausedForCheckpoint) { yield* tryDeliverWake(wf.sessionId).pipe(Effect.ignore, Effect.forkScoped) } }).pipe(Effect.ensuring(Effect.sync(() => recovering.delete(dagID)))) @@ -622,7 +801,17 @@ const serviceLayer = Layer.effect( const maxConcurrency = Math.max(1, config?.max_concurrency ?? Dag.DEFAULT_WORKFLOW_CONFIG.maxConcurrency) const runtime = new WorkflowRuntime(toSchedulingNodes(nodes), maxConcurrency) const semaphore = Semaphore.makeUnsafe(maxConcurrency) - const entry: WorkflowEntry = { runtime, semaphore, evalLock: Semaphore.makeUnsafe(1), parentSessionID: wf.sessionId, config, fibers: new Map(), watchers: new Map(), vetoHold: false } + const entry: WorkflowEntry = { + runtime, + semaphore, + evalLock: Semaphore.makeUnsafe(1), + parentSessionID: wf.sessionId, + config, + graphRev: wf.graphRev, + fibers: new Map(), + watchers: new Map(), + vetoHold: false, + } // P2-E deletion-race re-check (same window as recoverWorkflow): // the Deleted sweep only removes entries already in `runtimes`, // and getNodes above is an awaited yield a deletion can slip @@ -712,75 +901,21 @@ const serviceLayer = Layer.effect( // Guard against stale events: a node already cancelled // (markUnsatisfied) or already satisfied must not be flipped // back. Mirrors the NodeFailed handler's isActive guard. + let checkpointHeld = false if (confirmed && entry.runtime.isActive(nodeID)) { settle(entry, nodeID) - const nodeConfig = entry.config?.nodes.find((n) => n.id === nodeID) - // A checkpoint output can arrive as a raw string (no - // output_schema, or a string-typed child reply); parse it - // before matching the verdict so a string-typed - // {"verdict":"replan"} cannot bypass the gate (the spin - // behind issue #322). - const gateOutput = typeof node?.output === "string" - ? Option.getOrUndefined(parseJsonOption(node.output)) - : node?.output - const gateReplan = def === DagEvent.NodeCompleted - && nodeConfig?.report_to_parent === true - && Option.isSome(Schema.decodeUnknownOption(GateReplanVerdict)(gateOutput)) - if (gateReplan) { - // Verdict gate (issue #322): a reporting checkpoint that - // submits verdict "replan" vetoes the direction. Pause - // durably BEFORE any spawn round so dependents can never - // run on the rejected direction; the parent is woken by - // the report_to_parent wake and control(replan) applies - // corrective nodes — a paused workflow resumes as part - // of replan (workflow tool) so corrections can run. - const paused = yield* Effect.gen(function* () { - // DAG-03: the checkpoint VETOED this direction — the - // pause must fail CLOSED. Pause can fail transiently - // (e.g. the workflow lock is held by a concurrent - // long replan); retry once, and if it still cannot - // be persisted, HOLD the in-memory pause anyway. - // Pre-fix this returned `wf?.status === "paused"` — - // fail-OPEN: it explicitly un-paused the runtime, so - // the next stimulus calling spawnReady (a NodeFailed - // handler, a step, a resume) spawned the vetoed - // direction with no gate, no pause, no diagnostic. - // catchCause (not catch): a DEFECT from dag.pause - // must fold into the same path — pre-fix it escaped - // to guarded() and dropped this whole handler, so - // the pause was never even attempted and the gate's - // own warning was lost. Interrupts (scope disposal) - // still propagate. - 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 wf = yield* store.getWorkflow(dagID).pipe(Effect.orDie) - if (wf?.status !== "paused") { - // F2: record the hold so the durable-row re-syncs - // below (node terminal prologues, refreshControlFlags) - // cannot lift it until the parent acts. - entry.vetoHold = true - yield* Effect.logError( - "DagLoop pause on replan verdict failed — holding in-memory pause (fail-closed)", - { dagID, nodeID, durableStatus: wf?.status ?? "missing" }, - ) - } - return true + checkpointHeld = yield* enforceCheckpointGate(dagID, entry) + if (checkpointHeld) { + yield* Effect.logWarning("DagLoop paused workflow after gate verdict: replan", { + dagID, + nodeID, }) - entry.runtime.setPaused(paused) - yield* Effect.logWarning("DagLoop paused workflow after gate verdict: replan", { dagID, nodeID }) } // In stepMode, do NOT auto-advance — wait for the next - // explicit step command. checkCompletion still runs so - // required-node failure / early completion is detected. - if (!gateReplan && !entry.runtime.isStepMode()) yield* spawnReady(dagID) + // explicit step command. + if (!checkpointHeld && !entry.runtime.isStepMode()) yield* spawnReady(dagID) } - yield* checkCompletion(dagID) + if (!checkpointHeld) yield* checkCompletion(dagID) }), ) // P1-2: trigger wake check directly on node terminal — @@ -939,6 +1074,12 @@ const serviceLayer = Layer.effect( // explicit step command instead. if (entry.runtime.hasRunning()) return yield* spawnReady(dagID) + // spawnReady may have paused on a durable checkpoint. Only + // converge and terminalize when that scheduling gate stayed + // open. These operations dispatch no additional child. + if (entry.runtime.isPaused()) return + yield* convergeSkippedNodes(dagID, entry) + yield* checkCompletion(dagID) }), ) }).pipe(guarded("WorkflowStepped")), @@ -995,7 +1136,10 @@ const serviceLayer = Layer.effect( entry.runtime.setPaused(wf?.status === "paused") entry.runtime.setStepMode(wf?.status === "stepping") const oldConfig = entry.config - if (wf) entry.config = parseWorkflowConfig(wf.config) + if (wf) { + entry.config = parseWorkflowConfig(wf.config) + entry.graphRev = wf.graphRev + } // Rev-view (v1.0.15 Train A): THE aggregation filter point. // The rebuild input is the CURRENT graph revision only — // superseded rows (cancelled via replan, or terminal @@ -1018,8 +1162,14 @@ const serviceLayer = Layer.effect( if (node.status !== "running") continue const frag = newConfig?.nodes.find((candidate) => candidate.id === node.id) if (!frag) continue - const oldTimeoutMs = oldConfig?.nodes.find((candidate) => candidate.id === node.id)?.worker_config?.timeout_ms - const fragTimeoutMs = frag.worker_config?.timeout_ms + const oldTimeoutMs = + oldConfig?.nodes.find((candidate) => candidate.id === node.id)?.worker_config?.timeout_ms + ?? oldConfig?.node_defaults?.worker_config?.timeout_ms + ?? Dag.DEFAULT_WORKFLOW_CONFIG.nodeTimeoutMs + const fragTimeoutMs = + frag.worker_config?.timeout_ms + ?? newConfig?.node_defaults?.worker_config?.timeout_ms + ?? Dag.DEFAULT_WORKFLOW_CONFIG.nodeTimeoutMs // §3.7: re-time only when the replan carries a NEW // timeout_ms. The persisted config behind WorkflowReplanned // is the MERGED config — every non-cancel survivor keeps its @@ -1076,7 +1226,10 @@ const serviceLayer = Layer.effect( // a structural check, whereas Cause.interruptors collects // only DEFINED fiber IDs and ignores interrupt reasons // carrying none — those would be swallowed as errors here. - const written = yield* dag.nodeExtendTimeout(dagID, node.id, now + fragTimeoutMs).pipe( + const written = yield* dag.nodeExtendTimeout(dagID, node.id, now + fragTimeoutMs, { + replanAttempts: node.replanAttempts, + ...(node.childSessionId ? { childSessionID: node.childSessionId } : {}), + }).pipe( Effect.catchCause((cause) => Cause.hasInterrupts(cause) ? Effect.failCause(cause) @@ -1123,6 +1276,10 @@ const serviceLayer = Layer.effect( const newWatcher = yield* makeDeadlineWatcher({ dagID, nodeID: node.id, + attempt: { + replanAttempts: node.replanAttempts, + ...(node.childSessionId ? { childSessionID: node.childSessionId } : {}), + }, timeoutMs: fragTimeoutMs, maxTimeoutExtensions: newConfig?.max_timeout_extensions ?? Dag.DEFAULT_WORKFLOW_CONFIG.maxTimeoutExtensions, }).pipe( diff --git a/packages/opencode/src/dag/runtime/recovery.ts b/packages/opencode/src/dag/runtime/recovery.ts index 5013b39a5..b7ef2aefa 100644 --- a/packages/opencode/src/dag/runtime/recovery.ts +++ b/packages/opencode/src/dag/runtime/recovery.ts @@ -30,7 +30,7 @@ import type { DagStore } from "@opencode-ai/core/dag/store" import { isTransitionRejection } from "@opencode-ai/core/dag/core/types" import { reviewImplementationFingerprint } from "../review-lifecycle" import { resolveInputMapping } from "./eval" -import { settleCapturedOutput } from "./capture" +import { settleCapturedOutput, settlePlainTextOutput } from "./capture" import type { CapturedSettlement } from "./capture" import { captureOutputFileRef, ensureReportAreaGitignore } from "./output-ref" @@ -47,6 +47,7 @@ export function reconcileWorkflow( const nodes = yield* dag.store.getNodes(dagID) const settle = (nodeID: string, action: Effect.Effect<void, Error>) => action.pipe( + Effect.as(true), Effect.catchIf( isTransitionRejection, (error) => @@ -54,13 +55,17 @@ export function reconcileWorkflow( dagID, nodeID, error, - }), + }).pipe(Effect.as(false)), ), ) let reconciled = 0 let ownershipLost = 0 for (const node of nodes) { + const attempt = { + replanAttempts: node.replanAttempts, + ...(node.childSessionId ? { childSessionID: node.childSessionId } : {}), + } // Pending/queued nodes have no live execution attempt — a queued node // never created its child session (P0-2: sessions materialize inside // the permit), so both re-enter scheduling after runtime reconstruction @@ -91,12 +96,14 @@ export function reconcileWorkflow( if (!node.childSessionId) { // Crash landed between admission and session creation — no durable // outcome exists, so this is an invented failure like ownership loss. - ownershipLost++ - yield* settle( + const settled = yield* settle( node.id, - dag.nodeFailed(dagID, node.id, "node was running but had no child session on recovery", "exec_failed"), + dag.nodeFailed(dagID, node.id, "node was running but had no child session on recovery", "exec_failed", attempt), ) - reconciled++ + if (settled) { + ownershipLost++ + reconciled++ + } continue } @@ -110,17 +117,20 @@ export function reconcileWorkflow( // carrying node would bypass settleCapturedOutput and land as an // undefined output. Fail loudly instead of inventing a settlement. if (workflowConfig === null) { - ownershipLost++ - yield* settle( + const settled = 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", + attempt, ), ) - reconciled++ + if (settled) { + ownershipLost++ + reconciled++ + } continue } const nodeConfig = workflowConfig?.nodes.find((n) => n.id === node.id) @@ -128,30 +138,30 @@ export function reconcileWorkflow( // Same settlement decision as spawn's completion gate — recovery // must not become a bypass of the review-result contract again (B1). const settlement = recoveredSettlement(nodeConfig, nodes, node.capturedOutput) - yield* settle( + const settled = yield* settle( node.id, settlement.kind === "complete" - ? dag.nodeCompleted(dagID, node.id, settlement.output) - : dag.nodeFailed(dagID, node.id, settlement.reason, "verdict_fail"), + ? dag.nodeCompleted(dagID, node.id, settlement.output, attempt) + : dag.nodeFailed(dagID, node.id, settlement.reason, "verdict_fail", attempt), ) + if (settled) reconciled++ } else { - // #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 - // #388 parity with the live path: when the recovered reply IS one - // existing absolute file path, capture the same {content_ref, size, - // sha256, summary} receipt submit-time detection records, so live - // and recovered settlement produce identical durable output - // metadata. Best-effort like the live path — any anomaly keeps the - // plain inline completion and never fails the node. - if (rawText) { + const settlement = settlePlainTextOutput( + lastAssistantText ? yield* lastAssistantText(node.childSessionId) : undefined, + ) + if (settlement.kind === "fail") { + if (yield* settle( + node.id, + dag.nodeFailed(dagID, node.id, settlement.reason, "verdict_fail", attempt), + )) reconciled++ + } else { + const rawText = settlement.output + // #388 parity with the live path: when the recovered reply IS one + // existing absolute file path, capture the same {content_ref, size, + // sha256, summary} receipt submit-time detection records, so live + // and recovered settlement produce identical durable output + // metadata. Best-effort like the live path — any anomaly keeps the + // plain inline completion and never fails the node. const fileRef = yield* captureOutputFileRef(rawText) if (fileRef) { yield* dag.store.setCapturedOutput(node.childSessionId, fileRef).pipe( @@ -165,18 +175,15 @@ export function reconcileWorkflow( ) if (directory) yield* ensureReportAreaGitignore(directory, fileRef.path) } + if (yield* settle(node.id, dag.nodeCompleted(dagID, node.id, rawText, attempt))) reconciled++ } - yield* settle(node.id, dag.nodeCompleted(dagID, node.id, rawText)) } - reconciled++ } else if (sessionStatus === "failed") { - yield* settle( + if (yield* settle( node.id, - dag.nodeFailed(dagID, node.id, "child session failed (recovered)", "exec_failed"), - ) - reconciled++ + dag.nodeFailed(dagID, node.id, "child session failed (recovered)", "exec_failed", attempt), + )) reconciled++ } else { - ownershipLost++ if (cancelSession) { yield* cancelSession(node.childSessionId).pipe( Effect.catchCause((cause) => @@ -197,7 +204,7 @@ export function reconcileWorkflow( // was never re-extended, and the durable escalation count proves // it. Failure reason records the escalation so the parent can tell // "ran out of time after N extensions" from "never escalated". - yield* settle( + const settled = yield* settle( node.id, dag.nodeFailed( dagID, @@ -206,22 +213,30 @@ export function reconcileWorkflow( ? `timeout escalated (${node.timeoutExtensions} extension(s)) node failed on recovery` : "deadline exceeded on recovery", "timeout", + attempt, ), ) - reconciled++ + if (settled) { + ownershipLost++ + reconciled++ + } continue } } - yield* settle( + const settled = yield* settle( node.id, dag.nodeFailed( dagID, node.id, "execution ownership lost on recovery", "exec_failed", + attempt, ), ) - reconciled++ + if (settled) { + ownershipLost++ + reconciled++ + } } } diff --git a/packages/opencode/src/dag/runtime/spawn.ts b/packages/opencode/src/dag/runtime/spawn.ts index b1397aef8..a7e654cb1 100644 --- a/packages/opencode/src/dag/runtime/spawn.ts +++ b/packages/opencode/src/dag/runtime/spawn.ts @@ -37,7 +37,7 @@ import { Session } from "@/session/session" import { SessionID, MessageID } from "@/session/schema" import { deriveSubagentSessionPermission } from "@/agent/subagent-permissions" import { SessionPrompt } from "@/session/prompt" -import { Dag } from "../dag" +import { Dag, type NodeExecutionAttempt } from "../dag" import { DagModel } from "../model" import { DagLocation } from "../location" import { InstanceRef } from "@/effect/instance-ref" @@ -45,7 +45,7 @@ import { isTransitionRejection, isNodeTerminalStatus } from "@opencode-ai/core/d import type { DagStore } from "@opencode-ai/core/dag/store" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" -import { registerCaptureSlot, clearCaptureSlot, settleCapturedOutput } from "./capture" +import { registerCaptureSlot, clearCaptureSlot, settleCapturedOutput, settlePlainTextOutput } from "./capture" import { captureOutputFileRef, ensureReportAreaGitignore } from "./output-ref" type PromptParts = SessionPrompt.PromptInput["parts"] @@ -54,6 +54,10 @@ export interface NodeSpawnInput { dagID: string nodeID: string node: DagStore.NodeRow + /** Config generation paired with the node snapshot during scheduler admission. */ + graphRev?: number + /** Runs after durable admission succeeds and before any watcher or execution fiber starts. */ + onAdmitted?: Effect.Effect<void, never> parentSessionID: string promptParts: PromptParts /** Workflow execution directory — keys the report-area gitignore guarantee (Train B, B4). */ @@ -79,6 +83,8 @@ export interface NodeSpawnResult { export interface DeadlineWatcherInput { dagID: string nodeID: string + /** Durable attempt captured when this watcher was created. */ + attempt?: NodeExecutionAttempt /** * The node's effective execution timeout. Doubles as the escalation * interval (S1): after escalating, the watcher waits one timeout period @@ -174,6 +180,11 @@ export function makeDeadlineWatcher( yield* Effect.sleep(5_000) continue } + if ( + input.attempt && + (node.replanAttempts !== input.attempt.replanAttempts || + (input.attempt.childSessionID !== undefined && node.childSessionId !== input.attempt.childSessionID)) + ) return if (isNodeTerminalStatus(node.status as never)) return if (yield* ownershipLost) return const now = yield* Clock.currentTimeMillis @@ -193,20 +204,31 @@ export function makeDeadlineWatcher( const extensions = node.timeoutExtensions const maxExtensions = input.maxTimeoutExtensions ?? Dag.DEFAULT_WORKFLOW_CONFIG.maxTimeoutExtensions if (extensions >= maxExtensions) { - yield* promptSvc.cancel(node.childSessionId as never).pipe(Effect.ignore) // Enforcing the cap IS the watcher's contract (§5-5), so a transient // failure here must retry rather than end supervision: returning would // leave a RUNNING node past its cap with nobody left to fail it. A // rejected guard means someone else already terminalized the node, // which counts as done. - const outcome = yield* dag.nodeFailed(input.dagID, input.nodeID, `timeout extensions exhausted (${extensions}/${maxExtensions})`, "timeout").pipe( + const attempt = { + replanAttempts: node.replanAttempts, + ...(node.childSessionId ? { childSessionID: node.childSessionId } : {}), + } + const outcome = yield* dag.nodeFailed(input.dagID, input.nodeID, `timeout extensions exhausted (${extensions}/${maxExtensions})`, "timeout", attempt).pipe( + Effect.as("failed" as const), Effect.catchIf( isTransitionRejection, - () => Effect.logWarning("nodeFailed (timeout extensions exhausted) guard rejected — node already terminal"), + () => + Effect.logWarning("nodeFailed (timeout extensions exhausted) guard rejected — node attempt is no longer current").pipe( + Effect.as("stale" as const), + ), ), Effect.exit, ) - if (Exit.isSuccess(outcome)) return + if (Exit.isSuccess(outcome)) { + if (outcome.value === "stale") return + if (node.childSessionId) yield* promptSvc.cancel(node.childSessionId as never).pipe(Effect.ignore) + return + } if (Cause.hasInterrupts(outcome.cause)) return yield* Effect.failCause(outcome.cause) yield* Effect.logWarning("DAG deadline watcher cap enforcement failed — retrying", { dagID: input.dagID, nodeID: input.nodeID, cause: outcome.cause }) yield* Effect.sleep(escalateIntervalMs) @@ -226,7 +248,11 @@ export function makeDeadlineWatcher( // moved past this observed value (ticket B — spurious T8 suppression), // so a budget unit is only charged when the node is genuinely still // overdue. Pass node.deadlineMs, the value this snapshot read. - const escalated = yield* dag.nodeTimeoutEscalated(input.dagID, input.nodeID, node.childSessionId as never, extensions + 1, node.deadlineMs).pipe( + const attempt = { + replanAttempts: node.replanAttempts, + ...(node.childSessionId ? { childSessionID: node.childSessionId } : {}), + } + const escalated = yield* dag.nodeTimeoutEscalated(input.dagID, input.nodeID, node.childSessionId as never, extensions + 1, node.deadlineMs, attempt).pipe( Effect.catchIf( isTransitionRejection, () => Effect.logWarning("nodeTimeoutEscalated guard rejected — node already terminal"), @@ -280,6 +306,14 @@ export function spawnNode( const sessions = yield* Session.Service const promptSvc = yield* SessionPrompt.Service const scope = yield* Scope.Scope + const admissionAttempt = { + replanAttempts: input.node.replanAttempts, + nodeSeq: input.node.seq, + ...(input.graphRev !== undefined ? { graphRev: input.graphRev } : {}), + } satisfies NodeExecutionAttempt + const executionAttempt = { + replanAttempts: input.node.replanAttempts, + } satisfies NodeExecutionAttempt // Pre-admission failures settle here and return an empty fiber (same // shape as the !admitted path below) instead of Effect.fail — failing @@ -287,7 +321,7 @@ export function spawnNode( // NodeFailed (noise). const failWithoutFiber = (reason: string, label: string) => Effect.gen(function* () { - yield* dag.nodeFailed(input.dagID, input.nodeID, reason, "exec_failed").pipe( + yield* dag.nodeFailed(input.dagID, input.nodeID, reason, "exec_failed", admissionAttempt).pipe( Effect.catchIf( isTransitionRejection, () => Effect.logWarning(`nodeFailed (${label}) guard rejected — node already terminal`), @@ -303,7 +337,12 @@ export function spawnNode( return yield* failWithoutFiber(`unknown worker_type: ${input.node.workerType}`, "unknown worker_type") } - const parent = yield* sessions.get(SessionID.make(input.parentSessionID)) + const parentOutcome = yield* sessions.get(SessionID.make(input.parentSessionID)).pipe(Effect.exit) + if (Exit.isFailure(parentOutcome)) { + if (Cause.hasInterrupts(parentOutcome.cause)) return yield* Effect.failCause(parentOutcome.cause) + return yield* failWithoutFiber(Cause.pretty(parentOutcome.cause), "parent session lookup") + } + const parent = parentOutcome.value const persistedNodeModel = input.node.modelId && input.node.modelProviderId ? Dag.normalizeModel({ @@ -347,12 +386,12 @@ export function spawnNode( // async window above (agent/model resolution), the queued guard rejects. // The winning control op is the sole terminalization — no spurious // NodeFailed, no execution fiber. - const admitted = yield* dag.nodeQueued(input.dagID, input.nodeID, deadlineMs).pipe( + const admitted = yield* dag.nodeQueued(input.dagID, input.nodeID, deadlineMs, admissionAttempt).pipe( Effect.as(true), Effect.catchIf( isTransitionRejection, () => - Effect.logWarning(`Node ${input.nodeID} was terminalized before queueing — no execution attempt started`).pipe( + Effect.logWarning(`Node ${input.nodeID} admission was rejected — no execution attempt started`).pipe( Effect.as(false), ), ), @@ -362,6 +401,7 @@ export function spawnNode( const watcherFiber = yield* Effect.forkIn(scope)(Effect.void) return { fiber, watcherFiber } } + if (input.onAdmitted) yield* input.onAdmitted // Assigned inside the fiber once the child session materializes; read by // the ensuring/onInterrupt cleanups below. @@ -372,6 +412,7 @@ export function spawnNode( makeDeadlineWatcher({ dagID: input.dagID, nodeID: input.nodeID, + attempt: executionAttempt, timeoutMs, maxTimeoutExtensions: input.maxTimeoutExtensions, }), @@ -385,7 +426,7 @@ export function spawnNode( const queueTime = yield* Clock.currentTimeMillis const queueRemaining = deadlineMs - queueTime if (queueRemaining <= 0) { - yield* dag.nodeFailed(input.dagID, input.nodeID, `node exceeded timeout before acquiring execution permit`, "timeout").pipe( + yield* dag.nodeFailed(input.dagID, input.nodeID, `node exceeded timeout before acquiring execution permit`, "timeout", executionAttempt).pipe( Effect.catchIf( isTransitionRejection, () => Effect.logWarning("nodeFailed (pre-permit timeout) guard rejected — node already terminal"), @@ -398,7 +439,7 @@ export function spawnNode( Effect.timeoutOption(queueRemaining), ) if (Option.isNone(permitAcquired)) { - yield* dag.nodeFailed(input.dagID, input.nodeID, `node exceeded timeout while waiting for execution permit`, "timeout").pipe( + yield* dag.nodeFailed(input.dagID, input.nodeID, `node exceeded timeout while waiting for execution permit`, "timeout", executionAttempt).pipe( Effect.catchIf( isTransitionRejection, () => Effect.logWarning("nodeFailed (permit-wait timeout) guard rejected — node already terminal"), @@ -460,7 +501,7 @@ export function spawnNode( // while it waited for the permit. nodeStarted's guard rejects; cancel // the just-created child session and stop — the winning control op is // the sole terminalization, no spurious NodeFailed. - const terminalized = yield* dag.nodeStarted(input.dagID, input.nodeID, childSession.id, deadlineMs, input.reportToParent).pipe( + const terminalized = yield* dag.nodeStarted(input.dagID, input.nodeID, childSession.id, deadlineMs, input.reportToParent, executionAttempt).pipe( Effect.map(() => false), Effect.catchIf( isTransitionRejection, @@ -475,6 +516,11 @@ export function spawnNode( ) if (terminalized) return + const settlementAttempt = { + replanAttempts: input.node.replanAttempts, + childSessionID: childSession.id, + } satisfies NodeExecutionAttempt + if (input.outputSchema) registerCaptureSlot(childSession.id, input.outputSchema) // The prompt runs WITHOUT a timeout — the deadline watcher owns the @@ -492,6 +538,11 @@ export function spawnNode( if (input.outputSchema) { const readSettlement = Effect.fn("DagRuntime.spawn.readSettlement")(function* () { const updatedNode = yield* dag.store.getNode(input.dagID, input.nodeID).pipe(Effect.orDie) + if ( + !updatedNode || + updatedNode.replanAttempts !== settlementAttempt.replanAttempts || + updatedNode.childSessionId !== settlementAttempt.childSessionID + ) return undefined const captured = updatedNode?.capturedOutput return { neverCalled: captured === undefined || captured === null, @@ -502,6 +553,7 @@ export function spawnNode( }) clearCaptureSlot(childSession.id) let verdict = yield* readSettlement() + if (!verdict) return // Issue #436 minimal step: a child that ended its whole turn // without ever calling submit_result gets exactly one nudge // turn in the same session — the work is already done, only the @@ -525,11 +577,12 @@ export function spawnNode( }) clearCaptureSlot(childSession.id) verdict = yield* readSettlement() + if (!verdict) return } const settlement = verdict.settlement yield* (settlement.kind === "complete" - ? dag.nodeCompleted(input.dagID, input.nodeID, settlement.output) - : dag.nodeFailed(input.dagID, input.nodeID, settlement.reason, "verdict_fail") + ? dag.nodeCompleted(input.dagID, input.nodeID, settlement.output, settlementAttempt) + : dag.nodeFailed(input.dagID, input.nodeID, settlement.reason, "verdict_fail", settlementAttempt) ).pipe( Effect.catchIf( isTransitionRejection, @@ -537,13 +590,14 @@ export function spawnNode( ), ) } else { - const rawText = result.parts.findLast((p) => p.type === "text")?.text ?? "" - if (rawText.trim() === "") { + const settlement = settlePlainTextOutput(result.parts.findLast((p) => p.type === "text")?.text) + if (settlement.kind === "fail") { yield* dag.nodeFailed( input.dagID, input.nodeID, - "provider returned empty output", + settlement.reason, "verdict_fail", + settlementAttempt, ).pipe( Effect.catchIf( isTransitionRejection, @@ -552,6 +606,7 @@ export function spawnNode( ) return } + const rawText = settlement.output // Train B (v1.0.15 B2): submit-time file-ref detection — when the // reply IS an existing non-empty absolute path, record // {content_ref, size, sha256, summary} in captured_output (the @@ -569,7 +624,7 @@ export function spawnNode( ) if (input.directory) yield* ensureReportAreaGitignore(input.directory, fileRef.path) } - yield* dag.nodeCompleted(input.dagID, input.nodeID, rawText).pipe( + yield* dag.nodeCompleted(input.dagID, input.nodeID, rawText, settlementAttempt).pipe( Effect.catchIf( isTransitionRejection, () => Effect.logWarning("nodeCompleted guard rejected — node already terminal"), @@ -601,7 +656,10 @@ export function spawnNode( Effect.catchCause((cause) => Effect.gen(function* () { if (Cause.hasInterrupts(cause)) return - yield* dag.nodeFailed(input.dagID, input.nodeID, Cause.pretty(cause), "exec_failed").pipe( + const attempt = childSessionID + ? { replanAttempts: input.node.replanAttempts, childSessionID } + : executionAttempt + yield* dag.nodeFailed(input.dagID, input.nodeID, Cause.pretty(cause), "exec_failed", attempt).pipe( Effect.catchIf( isTransitionRejection, () => Effect.logWarning("nodeFailed guard rejected — node already terminal"), diff --git a/packages/opencode/src/dag/validation.ts b/packages/opencode/src/dag/validation.ts index 195027ee1..2bd2528af 100644 --- a/packages/opencode/src/dag/validation.ts +++ b/packages/opencode/src/dag/validation.ts @@ -29,7 +29,7 @@ import { DEFAULT_WORKFLOW_CONFIG } from "./dag" import { DagBlocks } from "./blocks" import { AdmissionInput, ExecutionMode } from "./admission" import { validateReviewLifecycle } from "./review-lifecycle" -import { conditionReference } from "./runtime/eval" +import { conditionReference, parseInputMappingReference } from "./runtime/eval" import { unsupportedSchemaKeywords } from "./runtime/capture" import { placeholderKeys, templateSourceById } from "./templates/resolve" @@ -581,6 +581,78 @@ function conditionDiagnostics(nodes: readonly NodeConfig[]): Diagnostic[] { ] } +/** input_mapping is a data dependency, not a scheduling edge. Every declared + * source therefore has to name an existing transitive predecessor; otherwise + * the scheduler may start the consumer before the value exists. */ +export function inputMappingDiagnostics( + consumers: readonly NodeConfig[], + graphNodes: readonly NodeConfig[] = consumers, +): Diagnostic[] { + const byID = new Map(graphNodes.map((node) => [node.id, node])) + + const dependencyClosure = (consumer: NodeConfig) => { + const closure = new Set<string>() + const pending = [...consumer.depends_on] + while (pending.length > 0) { + const dependency = pending.pop()! + if (closure.has(dependency)) continue + closure.add(dependency) + const node = byID.get(dependency) + if (node) pending.push(...node.depends_on) + } + return closure + } + + return consumers.flatMap((consumer) => { + const closure = dependencyClosure(consumer) + return Object.entries(consumer.input_mapping ?? {}).flatMap(([variable, source]) => { + const path = `nodes[${consumer.id}].input_mapping.${variable}` + const parsed = parseInputMappingReference(source) + if (!parsed.ok) { + return [ + diagnostic({ + code: DIAGNOSTIC_CODES.dagInvalid, + path, + message: `node "${consumer.id}" input_mapping variable "${variable}" has invalid source "${source}": ${parsed.error}`, + hint: 'Use "node-id", "node-id.output", or "node-id.output.field"', + }), + ] + } + if (parsed.nodeID === consumer.id) { + return [ + diagnostic({ + code: DIAGNOSTIC_CODES.dagInvalid, + path, + message: `node "${consumer.id}" input_mapping variable "${variable}" references itself via "${source}"`, + hint: "Map inputs from a direct or transitive dependency", + }), + ] + } + if (!byID.has(parsed.nodeID)) { + return [ + diagnostic({ + code: DIAGNOSTIC_CODES.dagInvalid, + path, + message: `node "${consumer.id}" input_mapping variable "${variable}" references unknown source node "${parsed.nodeID}" via "${source}"`, + hint: "Map inputs from a node declared in this workflow", + }), + ] + } + if (!closure.has(parsed.nodeID)) { + return [ + diagnostic({ + code: DIAGNOSTIC_CODES.dagInvalid, + path, + message: `node "${consumer.id}" input_mapping variable "${variable}" references unordered source node "${parsed.nodeID}" via "${source}"`, + hint: `Add a direct or transitive depends_on path from "${consumer.id}" to "${parsed.nodeID}"`, + }), + ] + } + return [] + }) + }) +} + /** A report_to_parent node wakes the parent for adjudication; a dependent * without a condition on that node's output is spawned the moment the * checkpoint completes, so the checkpoint verdict can never act first. @@ -797,6 +869,7 @@ export function structuralDiagnostics(input: StructuralInput): Diagnostic[] { return sortDiagnostics([ ...tagLegacyClass(duplicateIdDiagnostics(duplicates), 0), ...tagLegacyClass(danglingDependencyDiagnostics(input.nodes, input.known_node_ids), 1), + ...tagLegacyClass(inputMappingDiagnostics(input.nodes), 1), ...tagLegacyClass(conditionDiagnostics(input.nodes), 2), ...tagLegacyClass(bindingDiagnostics(input.nodes), 3), ...tagLegacyClass(ceilingDiagnostics(input), 4), @@ -849,6 +922,7 @@ export function replanStructuralDiagnostics(input: ReplanStructuralInput): Diagn return sortDiagnostics([ ...tagLegacyClass(duplicateIdDiagnostics(duplicates), 0), ...tagLegacyClass(danglingDependencyDiagnostics(input.rerunNodes, knownIds), 1), + ...tagLegacyClass(inputMappingDiagnostics(input.rerunNodes, input.merged.nodes), 1), ...tagLegacyClass(conditionDiagnostics(input.rerunNodes), 2), ...tagLegacyClass(bindingDiagnostics(input.rerunNodes), 3), ...tagLegacyClass(ceilingExceeded(input.existingNodeCount + input.addCount, input.config.max_total_nodes), 4), diff --git a/packages/opencode/src/event-v2-bridge.ts b/packages/opencode/src/event-v2-bridge.ts index 836f408e0..b57570619 100644 --- a/packages/opencode/src/event-v2-bridge.ts +++ b/packages/opencode/src/event-v2-bridge.ts @@ -11,6 +11,15 @@ import { Context, Effect, Layer } from "effect" export class Service extends Context.Service<Service, EventV2.Interface>()("@opencode/EventV2Bridge") {} +function projectIDFromLocation(location: Location.Ref | undefined) { + if (!location || !("project" in location)) return undefined + const project = location.project + if (!project || typeof project !== "object" || !("id" in project) || typeof project.id !== "string") { + return undefined + } + return project.id +} + export const layer = Layer.effect( Service, Effect.gen(function* () { @@ -32,20 +41,37 @@ export const layer = Layer.effect( }) }) + const publishMany: EventV2.Interface["publishMany"] = (entries, options) => + Effect.gen(function* () { + if (options?.location) return yield* events.publishMany(entries, options) + const ctx = yield* InstanceRef + if (!ctx) return yield* events.publishMany(entries, options) + const workspaceID = yield* WorkspaceRef + return yield* events.publishMany(entries, { + ...options, + location: new Location.Info({ + directory: AbsolutePath.make(ctx.directory), + ...(workspaceID ? { workspaceID } : {}), + project: { id: Project.ID.make(ctx.project.id), directory: AbsolutePath.make(ctx.worktree) }, + }), + }) + }) + const unsubscribe = yield* events.listen((event) => Effect.gen(function* () { const ctx = yield* InstanceRef const workspaceID = (yield* WorkspaceRef) ?? event.location?.workspaceID + const projectID = ctx?.project.id ?? projectIDFromLocation(event.location) GlobalBus.emit("event", { directory: event.location?.directory ?? ctx?.directory, - project: ctx?.project.id, + project: projectID, workspace: workspaceID, payload: { id: event.id, type: event.type, properties: event.data }, }) if (event.durable === undefined) return GlobalBus.emit("event", { directory: event.location?.directory ?? ctx?.directory, - project: ctx?.project.id, + project: projectID, workspace: workspaceID, payload: { type: "sync", @@ -62,7 +88,7 @@ export const layer = Layer.effect( ) yield* Effect.addFinalizer(() => unsubscribe) - return Service.of({ ...events, publish }) + return Service.of({ ...events, publish, publishMany }) }), ) diff --git a/packages/opencode/test/dag/dag-attempt-fencing.test.ts b/packages/opencode/test/dag/dag-attempt-fencing.test.ts new file mode 100644 index 000000000..b3efef343 --- /dev/null +++ b/packages/opencode/test/dag/dag-attempt-fencing.test.ts @@ -0,0 +1,281 @@ +import { describe, expect } from "bun:test" +import { Deferred, Effect, Exit, Fiber, Layer } from "effect" +import { Database } from "@opencode-ai/core/database/database" +import { DagProjector } from "@opencode-ai/core/dag/projector" +import { DagStore } from "@opencode-ai/core/dag/store" +import { EventV2 } from "@opencode-ai/core/event" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { Dag, type NodeConfig, type NodeExecutionAttempt } from "@/dag/dag" +import { reconcileWorkflow } from "@/dag/runtime/recovery" +import { makeDeadlineWatcher } from "@/dag/runtime/spawn" +import { InstanceRef } from "@/effect/instance-ref" +import { EventV2Bridge } from "@/event-v2-bridge" +import type { InstanceContext } from "@/project/instance-context" +import { SessionPrompt } from "@/session/prompt" +import { SessionID } from "@/session/schema" +import { testEffect } from "../lib/effect" + +const directory = process.cwd() +const projectID = Project.ID.make("project-attempt-fence") +const sessionID = SessionID.make("ses_attempt_fence") +const instance = { + directory, + worktree: directory, + project: { + id: projectID, + worktree: AbsolutePath.make(directory), + time: { created: 0, updated: 0 }, + sandboxes: [], + }, +} satisfies InstanceContext +const cancellations: string[] = [] + +function node(name = "a"): NodeConfig { + return { + id: "a", + name, + worker_type: "build", + depends_on: [], + required: true, + prompt_template: { inline: name }, + } +} + +const harness = (() => { + const database = Database.layerFromPath(":memory:") + const events = EventV2.layer.pipe(Layer.provide(database)) + const bridge = EventV2Bridge.layer.pipe(Layer.provide(events)) + const store = DagStore.layer.pipe(Layer.provide(database)) + const projector = DagProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) + const dag = Dag.layer.pipe(Layer.provide(bridge), Layer.provide(store)) + const prompt = Layer.mock(SessionPrompt.Service, { + cancel: (childSessionID) => + Effect.sync(() => { + cancellations.push(childSessionID) + }), + }) + return Layer.mergeAll(database, events, bridge, store, projector, dag, prompt) +})() + +function setup() { + return Effect.gen(function* () { + cancellations.length = 0 + const database = yield* Database.Service + yield* database.db + .insert(ProjectTable) + .values({ id: projectID, worktree: AbsolutePath.make(directory), sandboxes: [] }) + .run() + .pipe(Effect.orDie) + yield* database.db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: projectID, + slug: "attempt-fence", + directory: AbsolutePath.make(directory), + title: "Attempt fence", + version: "test", + }) + .run() + .pipe(Effect.orDie) + const dag = yield* Dag.Service + const dagID = yield* dag.create({ + projectID, + sessionID, + title: "Attempt fence", + config: { name: "attempt-fence", nodes: [node()] }, + }) + return { dag, dagID, store: yield* DagStore.Service } + }) +} + +function attempt(row: DagStore.NodeRow, childSessionID?: string): NodeExecutionAttempt { + return { + replanAttempts: row.replanAttempts, + ...(childSessionID ? { childSessionID } : {}), + } +} + +function requireValue<A>(value: A | null | undefined, message: string): Effect.Effect<A> { + return value == null ? Effect.die(new Error(message)) : Effect.succeed(value) +} + +describe("DAG execution-attempt fencing (DAG-A01)", () => { + const it = testEffect(harness) + + it.live("rejects delayed success and failure from the replaced child while the current attempt can complete", () => + Effect.gen(function* () { + const { dag, dagID, store } = yield* setup() + const initial = (yield* store.getNode(dagID, "a"))! + yield* dag.nodeQueued(dagID, "a", Date.now() + 60_000, { + ...attempt(initial), + nodeSeq: initial.seq, + }) + yield* dag.nodeStarted(dagID, "a", "ses_old", Date.now() + 60_000, false, attempt(initial)) + const oldAttempt = attempt(initial, "ses_old") + + yield* dag.replan(dagID, { nodes: [{ ...node(), restart: true }] }) + const replacement = (yield* store.getNode(dagID, "a"))! + yield* dag.nodeQueued(dagID, "a", Date.now() + 60_000, { + ...attempt(replacement), + nodeSeq: replacement.seq, + }) + yield* dag.nodeStarted(dagID, "a", "ses_new", Date.now() + 60_000, false, attempt(replacement)) + + expect(Exit.isFailure(yield* dag.nodeCompleted(dagID, "a", "old success", oldAttempt).pipe(Effect.exit))).toBe(true) + expect( + Exit.isFailure( + yield* dag.nodeFailed(dagID, "a", "old failure", "exec_failed", oldAttempt).pipe(Effect.exit), + ), + ).toBe(true) + expect(yield* store.getNode(dagID, "a")).toEqual( + expect.objectContaining({ status: "running", childSessionId: "ses_new", replanAttempts: 1 }), + ) + + yield* dag.nodeCompleted(dagID, "a", "current success", attempt(replacement, "ses_new")) + expect(yield* store.getNode(dagID, "a")).toEqual( + expect.objectContaining({ status: "completed", output: "current success" }), + ) + }).pipe(Effect.provideService(InstanceRef, instance)), + ) + + it.live("rejects admission and pre-admission failure from a stale pending definition snapshot", () => + Effect.gen(function* () { + const { dag, dagID, store } = yield* setup() + const stale = (yield* store.getNode(dagID, "a"))! + const staleWorkflow = yield* requireValue( + yield* store.getWorkflow(dagID), + "workflow row missing before replan", + ) + const staleGraphRev = staleWorkflow.graphRev + yield* dag.replan(dagID, { nodes: [node("replacement definition")] }) + + const staleAdmission = { ...attempt(stale), nodeSeq: stale.seq } + expect( + Exit.isFailure(yield* dag.nodeQueued(dagID, "a", Date.now() + 60_000, staleAdmission).pipe(Effect.exit)), + ).toBe(true) + expect( + Exit.isFailure( + yield* dag.nodeFailed(dagID, "a", "stale setup failure", "exec_failed", staleAdmission).pipe(Effect.exit), + ), + ).toBe(true) + expect( + Exit.isFailure(yield* dag.nodeSkipped(dagID, "a", "condition_false", staleAdmission).pipe(Effect.exit)), + ).toBe(true) + const current = (yield* store.getNode(dagID, "a"))! + expect(current).toEqual(expect.objectContaining({ status: "pending", name: "replacement definition" })) + const currentWorkflow = yield* requireValue( + yield* store.getWorkflow(dagID), + "workflow row missing after replan", + ) + expect( + Exit.isFailure( + yield* dag.nodeQueued(dagID, "a", Date.now() + 60_000, { + ...attempt(current), + nodeSeq: current.seq, + graphRev: staleGraphRev, + }).pipe(Effect.exit), + ), + ).toBe(true) + yield* dag.nodeQueued(dagID, "a", Date.now() + 60_000, { + ...attempt(current), + nodeSeq: current.seq, + graphRev: currentWorkflow.graphRev, + }) + expect((yield* store.getNode(dagID, "a"))?.status).toBe("queued") + }).pipe(Effect.provideService(InstanceRef, instance)), + ) + + it.live("ends a stale deadline watcher before it can fail or cancel the replacement child", () => + Effect.gen(function* () { + const { dag, dagID, store } = yield* setup() + const initial = (yield* store.getNode(dagID, "a"))! + yield* dag.nodeQueued(dagID, "a", Date.now() - 1, { ...attempt(initial), nodeSeq: initial.seq }) + yield* dag.nodeStarted(dagID, "a", "ses_old", Date.now() - 1, false, attempt(initial)) + const oldAttempt = attempt(initial, "ses_old") + + yield* dag.replan(dagID, { nodes: [{ ...node(), restart: true }] }) + const replacement = (yield* store.getNode(dagID, "a"))! + yield* dag.nodeQueued(dagID, "a", Date.now() + 60_000, { + ...attempt(replacement), + nodeSeq: replacement.seq, + }) + yield* dag.nodeStarted(dagID, "a", "ses_new", Date.now() + 60_000, false, attempt(replacement)) + + yield* makeDeadlineWatcher({ + dagID, + nodeID: "a", + attempt: oldAttempt, + timeoutMs: 1, + maxTimeoutExtensions: 0, + }) + expect(cancellations).toEqual([]) + expect(yield* store.getNode(dagID, "a")).toEqual( + expect.objectContaining({ status: "running", childSessionId: "ses_new", timeoutExtensions: 0 }), + ) + }).pipe(Effect.provideService(InstanceRef, instance)), + ) + + it.live("rejects a late timeout escalation after the current attempt completed", () => + Effect.gen(function* () { + const { dag, dagID, store } = yield* setup() + const initial = (yield* store.getNode(dagID, "a"))! + yield* dag.nodeQueued(dagID, "a", Date.now() - 1, { ...attempt(initial), nodeSeq: initial.seq }) + yield* dag.nodeStarted(dagID, "a", "ses_current", Date.now() - 1, false, attempt(initial)) + const currentAttempt = attempt(initial, "ses_current") + yield* dag.nodeCompleted(dagID, "a", "done", currentAttempt) + + expect( + Exit.isFailure( + yield* dag + .nodeTimeoutEscalated(dagID, "a", "ses_current", 1, Date.now() - 1, currentAttempt) + .pipe(Effect.exit), + ), + ).toBe(true) + expect(yield* store.getNode(dagID, "a")).toEqual( + expect.objectContaining({ status: "completed", timeoutExtensions: 0 }), + ) + }).pipe(Effect.provideService(InstanceRef, instance)), + ) + + it.live("rejects a recovery result captured before restart", () => + Effect.gen(function* () { + const { dag, dagID, store } = yield* setup() + const initial = (yield* store.getNode(dagID, "a"))! + yield* dag.nodeQueued(dagID, "a", Date.now() + 60_000, { ...attempt(initial), nodeSeq: initial.seq }) + yield* dag.nodeStarted(dagID, "a", "ses_old", Date.now() + 60_000, false, attempt(initial)) + const checked = yield* Deferred.make<void>() + const release = yield* Deferred.make<void>() + const recovery = yield* reconcileWorkflow( + dagID, + () => + Effect.gen(function* () { + yield* Deferred.succeed(checked, undefined) + yield* Deferred.await(release) + return "completed" as const + }), + undefined, + { nodes: [node()] }, + () => Effect.succeed("old recovered output"), + ).pipe(Effect.forkScoped) + yield* Deferred.await(checked) + + yield* dag.replan(dagID, { nodes: [{ ...node(), restart: true }] }) + const replacement = (yield* store.getNode(dagID, "a"))! + yield* dag.nodeQueued(dagID, "a", Date.now() + 60_000, { + ...attempt(replacement), + nodeSeq: replacement.seq, + }) + yield* dag.nodeStarted(dagID, "a", "ses_new", Date.now() + 60_000, false, attempt(replacement)) + yield* Deferred.succeed(release, undefined) + expect(yield* Fiber.join(recovery)).toEqual({ reconciled: 0, ownershipLost: 0 }) + + expect(yield* store.getNode(dagID, "a")).toEqual( + expect.objectContaining({ status: "running", childSessionId: "ses_new", output: null }), + ) + }).pipe(Effect.provideService(InstanceRef, instance)), + ) +}) diff --git a/packages/opencode/test/dag/dag-checkpoint-recovery.test.ts b/packages/opencode/test/dag/dag-checkpoint-recovery.test.ts new file mode 100644 index 000000000..a9e11371a --- /dev/null +++ b/packages/opencode/test/dag/dag-checkpoint-recovery.test.ts @@ -0,0 +1,337 @@ +// SPDX-FileCopyrightText: 2026 LeXwDeX +// SPDX-License-Identifier: AGPL-3.0-or-later + +// oxlint-disable typescript-eslint/no-unsafe-type-assertion -- service mocks expose only the runtime surface exercised here +import { describe, expect } from "bun:test" +import { Deferred, Effect, Layer, Option } from "effect" +import { and, eq } from "drizzle-orm" +import type { SessionV1 } from "@opencode-ai/core/v1/session" +import { Database } from "@opencode-ai/core/database/database" +import { DagProjector } from "@opencode-ai/core/dag/projector" +import { DagStore } from "@opencode-ai/core/dag/store" +import { EventV2 } from "@opencode-ai/core/event" +import { EventTable } from "@opencode-ai/core/event/sql" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { DagEvent } from "@opencode-ai/schema/dag-event" +import { Agent } from "@/agent/agent" +import { Dag, type NodeConfig } from "@/dag/dag" +import { DagLoop } from "@/dag/runtime/loop" +import { InstanceRef } from "@/effect/instance-ref" +import { EventV2Bridge } from "@/event-v2-bridge" +import { MessageID, SessionID } from "@/session/schema" +import { Session } from "@/session/session" +import { SessionPrompt } from "@/session/prompt" +import { SessionStatus } from "@/session/status" +import { awaitWithTimeout, it, pollWithTimeout } from "../lib/effect" +import { withIdleAdmission } from "../lib/session-prompt" + +interface Probe { + readonly childCreated: Deferred.Deferred<string> + readonly parentNotified: Deferred.Deferred<void> + readonly created: string[] +} + +function checkpointNodes(): NodeConfig[] { + return [ + { + id: "checkpoint", + name: "Checkpoint", + worker_type: "review", + depends_on: [], + required: true, + report_to_parent: true, + prompt_template: { inline: "decide" }, + output_schema: { + type: "object", + properties: { verdict: { type: "string" } }, + required: ["verdict"], + }, + }, + { + id: "downstream", + name: "Downstream", + worker_type: "build", + depends_on: ["checkpoint"], + required: true, + condition: 'checkpoint.output.verdict == "continue"', + prompt_template: { inline: "continue" }, + }, + ] +} + +function reply(sessionID: string): SessionV1.WithParts { + return { + info: { + id: MessageID.ascending(), + sessionID, + role: "assistant", + time: { created: Date.now() }, + }, + parts: [{ type: "text", text: "acknowledged" }], + } as never +} + +function checkpointLayer(probe: Probe) { + const database = Database.layerFromPath(":memory:") + const events = EventV2.layer.pipe(Layer.provide(database)) + const bridge = EventV2Bridge.layer.pipe(Layer.provide(events)) + const store = DagStore.layer.pipe(Layer.provide(database)) + const projector = DagProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) + const status = SessionStatus.layer.pipe(Layer.provide(bridge)) + const dag = Dag.layer.pipe(Layer.provide(bridge), Layer.provide(store)) + const base = Layer.mergeAll(database, events, bridge, store, projector, status, dag) + const session = Layer.mock(Session.Service, { + get: () => Effect.succeed({ id: "ses_checkpoint_parent", permission: [], agent: "build" } as never), + create: (input) => + Effect.gen(function* () { + const id = `ses_checkpoint_child_${probe.created.length + 1}` + probe.created.push(input?.title ?? id) + yield* Deferred.succeed(probe.childCreated, id) + return { id } as never + }), + messages: () => Effect.succeed([]), + }) + const prompt = Layer.mock( + SessionPrompt.Service, + withIdleAdmission({ + cancel: () => Effect.void, + prompt: (input: SessionPrompt.PromptInput) => + input.sessionID === "ses_checkpoint_parent" + ? Deferred.succeed(probe.parentNotified, undefined).pipe(Effect.as(reply(input.sessionID))) + : Effect.never, + promptIfIdle: (input: SessionPrompt.PromptInput) => + input.sessionID === "ses_checkpoint_parent" + ? Deferred.succeed(probe.parentNotified, undefined).pipe(Effect.as(Option.some(reply(input.sessionID)))) + : Effect.never, + }), + ) + const agent = Layer.mock(Agent.Service, { + get: () => + Effect.succeed({ + name: "build", + mode: "all", + permission: [], + options: {}, + description: "", + prompt: "", + model: { providerID: "test" as never, modelID: "test-model" as never }, + tools: {}, + hooks: {}, + }), + }) + const loop = DagLoop.layer.pipe( + Layer.provide(base), + Layer.provide(session), + Layer.provide(prompt), + Layer.provide(agent), + ) + return Layer.merge(base, loop) +} + +function runCheckpointTest<A>( + test: (services: { + readonly dag: Dag.Interface + readonly loop: DagLoop.Interface + readonly store: DagStore.Interface + readonly database: Database.Interface + readonly probe: Probe + }) => Effect.Effect<A, Error>, +) { + return Effect.gen(function* () { + const probe: Probe = { + childCreated: yield* Deferred.make<string>(), + parentNotified: yield* Deferred.make<void>(), + created: [], + } + return yield* Effect.gen(function* () { + const dag = yield* Dag.Service + const loop = yield* DagLoop.Service + const store = yield* DagStore.Service + const database = yield* Database.Service + yield* database.db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make(process.cwd()), sandboxes: [] }) + .run() + .pipe(Effect.orDie) + yield* database.db + .insert(SessionTable) + .values({ + id: SessionID.make("ses_checkpoint_parent"), + project_id: Project.ID.global, + slug: "checkpoint-parent", + directory: process.cwd(), + title: "Checkpoint parent", + version: "test", + }) + .run() + .pipe(Effect.orDie) + return yield* test({ dag, loop, store, database, probe }) + }).pipe( + Effect.provide(checkpointLayer(probe)), + Effect.provideService(InstanceRef, { + directory: process.cwd(), + worktree: process.cwd(), + project: { id: Project.ID.global }, + } as never), + Effect.scoped, + ) + }) +} + +function persistCrashWindow(dag: Dag.Interface, output: unknown) { + return Effect.gen(function* () { + const dagID = yield* dag.create({ + projectID: Project.ID.global, + sessionID: "ses_checkpoint_parent", + title: "Checkpoint crash window", + config: { name: "checkpoint-crash-window", nodes: checkpointNodes() }, + }) + yield* dag.nodeStarted(dagID, "checkpoint", "ses_checkpoint_worker", undefined, true) + yield* dag.nodeCompleted(dagID, "checkpoint", output) + return dagID + }) +} + +function pauseEventCount(database: Database.Interface, dagID: string) { + return database.db + .select({ id: EventTable.id }) + .from(EventTable) + .where( + and( + eq(EventTable.aggregate_id, dagID), + eq(EventTable.type, EventV2.versionedType(DagEvent.WorkflowPaused.type, 1)), + ), + ) + .all() + .pipe( + Effect.orDie, + Effect.map((rows) => rows.length), + ) +} + +describe("DagLoop checkpoint crash recovery", () => { + for (const [label, output] of [ + ["object", { verdict: "replan" }], + ["JSON string", '{"verdict":"replan"}'], + ] as const) { + it.live(`recovers an unhandled ${label} veto before dispatch or completion`, () => + runCheckpointTest(({ dag, loop, store, probe }) => + Effect.gen(function* () { + const dagID = yield* persistCrashWindow(dag, output) + + yield* loop.init() + yield* awaitWithTimeout( + Deferred.await(probe.parentNotified), + "recovered checkpoint did not notify the parent", + ) + + expect(yield* store.getWorkflow(dagID)).toEqual(expect.objectContaining({ status: "paused" })) + expect(yield* store.getNode(dagID, "checkpoint")).toEqual( + expect.objectContaining({ status: "completed", output }), + ) + expect(yield* store.getNode(dagID, "downstream")).toEqual(expect.objectContaining({ status: "pending" })) + expect(probe.created).toEqual([]) + }), + ), + ) + } + + it.live("does not re-pause a veto acknowledged by resume", () => + runCheckpointTest(({ dag, loop, store, probe }) => + Effect.gen(function* () { + const dagID = yield* persistCrashWindow(dag, { verdict: "replan" }) + yield* dag.pause(dagID) + yield* dag.resume(dagID) + + yield* loop.init() + yield* pollWithTimeout( + store + .getWorkflow(dagID) + .pipe(Effect.map((workflow) => (workflow?.status === "completed" ? workflow : undefined))), + "acknowledged checkpoint did not converge after recovery", + ) + + expect(yield* store.getNode(dagID, "downstream")).toEqual( + expect.objectContaining({ status: "skipped", errorReason: "condition_false" }), + ) + expect(probe.created).toEqual([]) + }), + ), + ) + + it.live("preserves an explicit step after the checkpoint verdict", () => + runCheckpointTest(({ dag, loop, store, probe }) => + Effect.gen(function* () { + const dagID = yield* persistCrashWindow(dag, { verdict: "replan" }) + expect(yield* dag.step(dagID)).toEqual({ status: "stepping", nodeID: "downstream" }) + + yield* loop.init() + + expect(yield* store.getWorkflow(dagID)).toEqual(expect.objectContaining({ status: "stepping" })) + expect(yield* store.getNode(dagID, "downstream")).toEqual(expect.objectContaining({ status: "pending" })) + expect(probe.created).toEqual([]) + }), + ), + ) + + it.live("keeps a later manual pause after replan without adding another pause", () => + runCheckpointTest(({ dag, loop, store, database, probe }) => + Effect.gen(function* () { + const dagID = yield* persistCrashWindow(dag, { verdict: "replan" }) + yield* dag.pause(dagID) + yield* dag.replan(dagID, { nodes: checkpointNodes() }) + expect(yield* pauseEventCount(database, dagID)).toBe(1) + + yield* loop.init() + + expect(yield* store.getWorkflow(dagID)).toEqual(expect.objectContaining({ status: "paused" })) + expect(yield* pauseEventCount(database, dagID)).toBe(1) + expect(probe.created).toEqual([]) + }), + ), + ) + + it.live("treats a pause after the verdict as an unresolved hold without duplicating it", () => + runCheckpointTest(({ dag, loop, store, database, probe }) => + Effect.gen(function* () { + const dagID = yield* persistCrashWindow(dag, { verdict: "replan" }) + yield* dag.pause(dagID) + expect(yield* pauseEventCount(database, dagID)).toBe(1) + + yield* loop.init() + yield* awaitWithTimeout(Deferred.await(probe.parentNotified), "paused checkpoint did not notify the parent") + + expect(yield* store.getWorkflow(dagID)).toEqual(expect.objectContaining({ status: "paused" })) + expect(yield* store.getNode(dagID, "downstream")).toEqual(expect.objectContaining({ status: "pending" })) + expect(yield* pauseEventCount(database, dagID)).toBe(1) + expect(probe.created).toEqual([]) + }), + ), + ) + + it.live("continues through recovery when the checkpoint verdict passes", () => + runCheckpointTest(({ dag, loop, store, probe }) => + Effect.gen(function* () { + const dagID = yield* persistCrashWindow(dag, { verdict: "continue" }) + + yield* loop.init() + yield* awaitWithTimeout( + Deferred.await(probe.childCreated), + "passing checkpoint did not dispatch its downstream node", + ) + yield* pollWithTimeout( + store + .getNode(dagID, "downstream") + .pipe(Effect.map((node) => (node?.status === "running" ? node : undefined))), + "passing checkpoint downstream did not start", + ) + + expect(yield* store.getWorkflow(dagID)).toEqual(expect.objectContaining({ status: "running" })) + expect(yield* store.getNode(dagID, "downstream")).toEqual(expect.objectContaining({ status: "running" })) + }), + ), + ) +}) diff --git a/packages/opencode/test/dag/dag-input-mapping-runtime.test.ts b/packages/opencode/test/dag/dag-input-mapping-runtime.test.ts new file mode 100644 index 000000000..f7f629e49 --- /dev/null +++ b/packages/opencode/test/dag/dag-input-mapping-runtime.test.ts @@ -0,0 +1,277 @@ +// SPDX-FileCopyrightText: 2026 LeXwDeX +// SPDX-License-Identifier: AGPL-3.0-or-later + +// oxlint-disable typescript-eslint/no-unsafe-type-assertion -- service mocks use the narrow runtime surface exercised here +import { describe, expect } from "bun:test" +import { Deferred, Effect, Layer, Option, Queue } from "effect" +import type { SessionV1 } from "@opencode-ai/core/v1/session" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { Database } from "@opencode-ai/core/database/database" +import { DagProjector } from "@opencode-ai/core/dag/projector" +import { DagStore } from "@opencode-ai/core/dag/store" +import { EventV2 } from "@opencode-ai/core/event" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { Agent } from "@/agent/agent" +import { Dag, type NodeConfig } from "@/dag/dag" +import { DagLoop } from "@/dag/runtime/loop" +import { InstanceRef } from "@/effect/instance-ref" +import { EventV2Bridge } from "@/event-v2-bridge" +import { SessionPrompt } from "@/session/prompt" +import { MessageID, SessionID } from "@/session/schema" +import { Session } from "@/session/session" +import { SessionStatus } from "@/session/status" +import { awaitWithTimeout, it, pollWithTimeout } from "../lib/effect" +import { withIdleAdmission } from "../lib/session-prompt" + +interface PromptRecord { + readonly title: string + readonly text: string + readonly release: Deferred.Deferred<string> +} + +function node(id: string, dependsOn: string[] = [], inputMapping?: Record<string, string>): NodeConfig { + return { + id, + name: id, + worker_type: "build", + depends_on: dependsOn, + required: true, + prompt_template: { inline: id }, + ...(inputMapping ? { input_mapping: inputMapping } : {}), + } +} + +function reply(sessionID: string): SessionV1.WithParts { + return { + info: { + id: MessageID.ascending(), + sessionID, + role: "assistant", + time: { created: Date.now() }, + }, + parts: [{ type: "text", text: "done" }], + } as never +} + +function runtimeLayer(records: Queue.Queue<PromptRecord>, created: string[]) { + const database = Database.layerFromPath(":memory:") + const events = EventV2.layer.pipe(Layer.provide(database)) + const bridge = EventV2Bridge.layer.pipe(Layer.provide(events)) + const store = DagStore.layer.pipe(Layer.provide(database)) + const projector = DagProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) + const status = SessionStatus.layer.pipe(Layer.provide(bridge)) + const dag = Dag.layer.pipe(Layer.provide(bridge), Layer.provide(store)) + const base = Layer.mergeAll(database, events, bridge, store, projector, status, dag) + const titles = new Map<string, string>() + const session = Layer.mock(Session.Service, { + get: () => Effect.succeed({ id: "ses_mapping_parent", permission: [], agent: "build" } as never), + create: (input) => + Effect.sync(() => { + const id = `ses_mapping_child_${created.length + 1}` + created.push(id) + titles.set(id, (input?.title ?? id).replace(" (DAG node)", "")) + return { id } as never + }), + messages: () => Effect.succeed([]), + }) + const deliver = Effect.fn("test.SessionPrompt.mapping")(function* (input: SessionPrompt.PromptInput) { + const sessionID = input.sessionID as string + if (sessionID === "ses_mapping_parent") return reply(sessionID) + const release = yield* Deferred.make<string>() + yield* Queue.offer(records, { + title: titles.get(sessionID) ?? sessionID, + text: input.parts + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n"), + release, + }) + yield* Deferred.await(release) + return reply(sessionID) + }) + const prompt = Layer.mock( + SessionPrompt.Service, + withIdleAdmission({ + cancel: () => Effect.void, + prompt: deliver, + promptIfIdle: (input) => deliver(input).pipe(Effect.map(Option.some)), + }), + ) + const agent = Layer.mock(Agent.Service, { + get: () => + Effect.succeed({ + name: "build", + mode: "all", + permission: [], + options: {}, + description: "", + prompt: "", + model: { providerID: "test" as never, modelID: "test-model" as never }, + tools: {}, + hooks: {}, + }), + }) + const loop = DagLoop.layer.pipe( + Layer.provide(base), + Layer.provide(session), + Layer.provide(prompt), + Layer.provide(agent), + ) + return Layer.merge(base, loop) +} + +function runLoopTest<A>( + test: (services: { + dag: Dag.Interface + loop: DagLoop.Interface + store: DagStore.Interface + records: Queue.Queue<PromptRecord> + created: string[] + }) => Effect.Effect<A, Error>, +) { + return Effect.gen(function* () { + const records = yield* Queue.unbounded<PromptRecord>() + const created: string[] = [] + return yield* Effect.gen(function* () { + const dag = yield* Dag.Service + const loop = yield* DagLoop.Service + const store = yield* DagStore.Service + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make(process.cwd()), sandboxes: [] }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: SessionID.make("ses_mapping_parent"), + project_id: Project.ID.global, + slug: "mapping-parent", + directory: process.cwd(), + title: "Mapping parent", + version: "test", + }) + .run() + .pipe(Effect.orDie) + return yield* test({ dag, loop, store, records, created }) + }).pipe( + Effect.provide(runtimeLayer(records, created)), + Effect.provideService(InstanceRef, { + directory: process.cwd(), + worktree: process.cwd(), + project: { id: Project.ID.global }, + } as never), + Effect.scoped, + ) + }) +} + +describe("DagLoop input_mapping execution boundary", () => { + it.live("fails a missing declared field before creating a child session", () => + runLoopTest(({ dag, loop, store, records, created }) => + Effect.gen(function* () { + yield* loop.init() + const dagID = yield* dag.create({ + projectID: Project.ID.global, + sessionID: "ses_mapping_parent", + title: "Missing mapping field", + config: { + name: "missing-mapping-field", + nodes: [node("producer"), node("consumer", ["producer"], { requiredValue: "producer.output.value" })], + }, + }) + const producer = yield* awaitWithTimeout(Queue.take(records), "producer did not start") + expect(producer.title).toBe("producer") + yield* dag.nodeCompleted(dagID, "producer", { other: 1 }) + + const failed = yield* pollWithTimeout( + store.getNode(dagID, "consumer").pipe(Effect.map((row) => (row?.status === "failed" ? row : undefined))), + "consumer did not fail its missing input mapping", + ) + expect(failed.errorReason).toContain( + 'input_mapping variable "requiredValue" source "producer.output.value" resolved to undefined', + ) + expect(failed.errorClass).toBe("exec_failed") + expect(created).toEqual(["ses_mapping_child_1"]) + expect(Option.isNone(yield* Queue.poll(records))).toBe(true) + }), + ), + ) + + it.live("preserves a completed null whole output as a declared value", () => + runLoopTest(({ dag, loop, store, records }) => + Effect.gen(function* () { + yield* loop.init() + const dagID = yield* dag.create({ + projectID: Project.ID.global, + sessionID: "ses_mapping_parent", + title: "Null whole output", + config: { + name: "null-whole-output", + nodes: [node("producer"), node("consumer", ["producer"], { whole: "producer.output" })], + }, + }) + const producer = yield* awaitWithTimeout(Queue.take(records), "producer did not start") + expect(producer.title).toBe("producer") + yield* dag.nodeCompleted(dagID, "producer", null) + + const consumer = yield* awaitWithTimeout(Queue.take(records), "null-output consumer did not start") + expect(consumer.title).toBe("consumer") + expect(consumer.text).toContain('"whole": null') + yield* Deferred.succeed(consumer.release, "consumer done") + yield* pollWithTimeout( + store.getNode(dagID, "consumer").pipe(Effect.map((row) => (row?.status === "completed" ? row : undefined))), + "null-output consumer did not complete", + ) + }), + ), + ) + + it.live("resolves direct and transitive sources while preserving a null leaf", () => + runLoopTest(({ dag, loop, store, records }) => + Effect.gen(function* () { + yield* loop.init() + const dagID = yield* dag.create({ + projectID: Project.ID.global, + sessionID: "ses_mapping_parent", + title: "Valid mappings", + config: { + name: "valid-mappings", + nodes: [ + node("producer"), + node("middle", ["producer"]), + node("direct", ["producer"], { value: "producer.output.value" }), + node("transitive", ["middle"], { nullable: "producer.output.nullable" }), + ], + }, + }) + const producer = yield* awaitWithTimeout(Queue.take(records), "producer did not start") + expect(producer.title).toBe("producer") + yield* dag.nodeCompleted(dagID, "producer", { value: 7, nullable: null }) + + const first = yield* awaitWithTimeout(Queue.take(records), "first direct dependent did not start") + const second = yield* awaitWithTimeout(Queue.take(records), "second direct dependent did not start") + const direct = [first, second].find((record) => record.title === "direct") + const middle = [first, second].find((record) => record.title === "middle") + expect(middle).toBeDefined() + expect(direct?.text).toContain('"value": 7') + yield* Deferred.succeed(direct!.release, "direct done") + yield* Deferred.succeed(middle!.release, "middle done") + + const transitive = yield* awaitWithTimeout(Queue.take(records), "transitive dependent did not start") + expect(transitive.title).toBe("transitive") + expect(transitive.text).toContain('"nullable": null') + yield* Deferred.succeed(transitive.release, "transitive done") + yield* pollWithTimeout( + Effect.all([store.getNode(dagID, "direct"), store.getNode(dagID, "transitive")]).pipe( + Effect.map((rows) => (rows.every((row) => row?.status === "completed") ? rows : undefined)), + ), + "valid mapped consumers did not complete", + ) + }), + ), + ) +}) diff --git a/packages/opencode/test/dag/dag-input-mapping-validation.test.ts b/packages/opencode/test/dag/dag-input-mapping-validation.test.ts new file mode 100644 index 000000000..fd333e8f4 --- /dev/null +++ b/packages/opencode/test/dag/dag-input-mapping-validation.test.ts @@ -0,0 +1,237 @@ +// SPDX-FileCopyrightText: 2026 LeXwDeX +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Database } from "@opencode-ai/core/database/database" +import { DagProjector } from "@opencode-ai/core/dag/projector" +import { DagStore } from "@opencode-ai/core/dag/store" +import { EventV2 } from "@opencode-ai/core/event" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { WorkflowAuthoring } from "@/dag/authoring" +import { Dag, type NodeConfig } from "@/dag/dag" +import { resolveInputMappingChecked } from "@/dag/runtime/eval" +import { EventV2Bridge } from "@/event-v2-bridge" +import { SessionID } from "@/session/schema" +import { testEffect } from "../lib/effect" + +function node(id: string, dependsOn: string[] = []): NodeConfig { + return { + id, + name: id, + worker_type: "build", + depends_on: dependsOn, + required: true, + prompt_template: { inline: id }, + } +} + +const authoring = testEffect(CrossSpawnSpawner.defaultLayer) + +describe("input_mapping authoring validation", () => { + authoring.effect("rejects unknown, self, unordered, and malformed sources with exact mapping paths", () => + Effect.gen(function* () { + const result = yield* WorkflowAuthoring.make().prepare({ + action: "start", + source: { + kind: "inline", + value: { + config: { + name: "bad-mappings", + nodes: [ + node("producer"), + { ...node("unknown"), input_mapping: { value: "typo.output.value" } }, + { ...node("self"), input_mapping: { value: "self.output" } }, + { ...node("unordered"), input_mapping: { value: "producer.output.value" } }, + { ...node("malformed", ["producer"]), input_mapping: { value: "producer.value" } }, + ], + }, + }, + }, + profile: "portable", + }) + + expect(result.valid).toBe(false) + expect(result.errors.map((error) => error.path)).toEqual( + expect.arrayContaining([ + "nodes[unknown].input_mapping.value", + "nodes[self].input_mapping.value", + "nodes[unordered].input_mapping.value", + "nodes[malformed].input_mapping.value", + ]), + ) + expect(result.errors.map((error) => error.message).join("\n")).toContain('unknown source node "typo"') + expect(result.errors.map((error) => error.message).join("\n")).toContain("references itself") + expect(result.errors.map((error) => error.message).join("\n")).toContain('unordered source node "producer"') + expect(result.errors.map((error) => error.message).join("\n")).toContain('first path segment must be "output"') + }), + ) + + authoring.effect("accepts documented source forms through direct and transitive dependencies", () => + Effect.gen(function* () { + const result = yield* WorkflowAuthoring.make().prepare({ + action: "start", + source: { + kind: "inline", + value: { + config: { + name: "valid-mappings", + nodes: [ + node("producer"), + node("middle", ["producer"]), + { + ...node("consumer", ["middle"]), + input_mapping: { + wholeNodeOutput: "producer", + explicitOutput: "producer.output", + nestedField: "producer.output.value", + }, + }, + ], + }, + }, + }, + profile: "portable", + }) + + expect(result.errors).toEqual([]) + expect(result.valid).toBe(true) + }), + ) +}) + +describe("strict input_mapping resolution", () => { + const found = (output: unknown) => ({ found: true as const, output }) + + authoring.effect("keeps explicit null values but rejects missing producers and fields", () => + Effect.sync(() => { + expect(resolveInputMappingChecked({ value: "producer.output.value" }, () => found({ value: null }))).toEqual({ + ok: true, + value: { value: null }, + }) + expect(resolveInputMappingChecked({ value: "producer.output.value" }, () => ({ found: false }))).toEqual({ + ok: false, + error: + 'input_mapping variable "value" source "producer.output.value" has no durable output for node "producer"', + }) + expect(resolveInputMappingChecked({ value: "producer.output.value" }, () => found({ other: 1 }))).toEqual({ + ok: false, + error: 'input_mapping variable "value" source "producer.output.value" resolved to undefined', + }) + }), + ) +}) + +const persistence = Layer.mergeAll( + Database.defaultLayer, + EventV2.defaultLayer, + DagProjector.defaultLayer, + DagStore.defaultLayer, + EventV2Bridge.defaultLayer, +) +const runtime = testEffect(Layer.provideMerge(Dag.layer, persistence)) + +function setupPersistence(sessionID: string) { + return Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ + id: Project.ID.global, + worktree: AbsolutePath.make("/project"), + sandboxes: [], + }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: SessionID.make(sessionID), + project_id: Project.ID.global, + slug: sessionID, + directory: "/project", + title: sessionID, + version: "test", + }) + .run() + .pipe(Effect.orDie) + }) +} + +describe("input_mapping replan validation", () => { + runtime.effect("reuses a completed historical producer when it is in the new node's dependency closure", () => + Effect.gen(function* () { + yield* setupPersistence("ses_mapping_replan") + + const dag = yield* Dag.Service + const dagID = yield* dag + .create({ + projectID: Project.ID.global, + sessionID: "ses_mapping_replan", + title: "mapping-replan", + config: { name: "mapping-replan", nodes: [node("producer")] }, + }) + .pipe(Effect.orDie) + yield* dag.nodeQueued(dagID, "producer").pipe(Effect.orDie) + yield* dag.nodeStarted(dagID, "producer", "ses_mapping_producer").pipe(Effect.orDie) + yield* dag.nodeCompleted(dagID, "producer", { value: 42 }).pipe(Effect.orDie) + + yield* dag + .replan(dagID, { + nodes: [ + { + ...node("consumer", ["producer"]), + input_mapping: { answer: "producer.output.value" }, + }, + ], + }) + .pipe(Effect.orDie) + + expect((yield* dag.store.getNode(dagID, "consumer").pipe(Effect.orDie))?.status).toBe("pending") + }), + ) + + runtime.effect("rejects unknown and unordered sources even when the unordered producer is historical", () => + Effect.gen(function* () { + yield* setupPersistence("ses_mapping_replan_reject") + const dag = yield* Dag.Service + const dagID = yield* dag + .create({ + projectID: Project.ID.global, + sessionID: "ses_mapping_replan_reject", + title: "mapping-replan-reject", + config: { name: "mapping-replan-reject", nodes: [node("producer"), node("anchor")] }, + }) + .pipe(Effect.orDie) + for (const id of ["producer", "anchor"]) { + yield* dag.nodeQueued(dagID, id).pipe(Effect.orDie) + yield* dag.nodeStarted(dagID, id, `ses_mapping_${id}`).pipe(Effect.orDie) + yield* dag.nodeCompleted(dagID, id, { value: id }).pipe(Effect.orDie) + } + + const error = yield* dag + .replan(dagID, { + nodes: [ + { + ...node("consumer", ["anchor"]), + input_mapping: { + unordered: "producer.output.value", + unknown: "ghost.output.value", + }, + }, + ], + }) + .pipe(Effect.catch((failure: Error) => Effect.succeed(failure))) + + expect(error).toBeInstanceOf(Dag.StructuralValidationError) + if (!(error instanceof Error)) throw new Error("replan unexpectedly succeeded") + expect(error.message).toContain('unordered source node "producer"') + expect(error.message).toContain('unknown source node "ghost"') + expect(yield* dag.store.getNode(dagID, "consumer").pipe(Effect.orDie)).toBeUndefined() + }), + ) +}) diff --git a/packages/opencode/test/dag/dag-loop-guards.test.ts b/packages/opencode/test/dag/dag-loop-guards.test.ts index ab44d6f3b..948fb6be8 100644 --- a/packages/opencode/test/dag/dag-loop-guards.test.ts +++ b/packages/opencode/test/dag/dag-loop-guards.test.ts @@ -25,6 +25,7 @@ import { DagProjector } from "@opencode-ai/core/dag/projector" import { WorkflowNodeTable, WorkflowTable } from "@opencode-ai/core/dag/sql" import { DagStore } from "@opencode-ai/core/dag/store" import { EventV2 } from "@opencode-ai/core/event" +import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { SessionTable } from "@opencode-ai/core/session/sql" import { Agent } from "@/agent/agent" @@ -33,7 +34,7 @@ import { DagLoop } from "@/dag/runtime/loop" import { InstanceRef } from "@/effect/instance-ref" import { EventV2Bridge } from "@/event-v2-bridge" import { SessionPrompt } from "@/session/prompt" -import { MessageID } from "@/session/schema" +import { MessageID, SessionID } from "@/session/schema" import { Session } from "@/session/session" import { SessionStatus } from "@/session/status" import { pollWithTimeout } from "../lib/effect" @@ -82,6 +83,7 @@ function reply(sessionID: string, text: string): SessionV1.WithParts { function guardLayer(input: { readonly childPrompts: Queue.Queue<PromptGate> readonly cancels: string[] + readonly created: string[] /** Injected one-shot defects for DagStore.getWorkflow (P1 survival test). */ readonly failGetWorkflow?: { remaining: number } /** Injected Dag.pause failures (typed or defect) for the DAG-03 gate test. */ @@ -136,19 +138,32 @@ function guardLayer(input: { } return real.pause(id) }), + pauseForCheckpoint: (id, checkpointSeq) => + Effect.gen(function* () { + const acknowledgedSeq = yield* real.store.getLatestCheckpointControlSeq(id) + if (acknowledgedSeq !== undefined && acknowledgedSeq >= checkpointSeq) { + return yield* real.pauseForCheckpoint(id, checkpointSeq) + } + if (input.failPause!.remaining > 0) { + input.failPause!.remaining-- + return yield* input.failPause!.defect + ? Effect.die(new Error("injected checkpoint pause defect")) + : Effect.fail(new Error("injected checkpoint pause failure")) + } + return yield* real.pauseForCheckpoint(id, checkpointSeq) + }), }) }), ).pipe(Layer.provide(realDag)) : realDag const base = Layer.mergeAll(database, events, bridge, store, projector, dag, status) const childTitles = new Map<string, string>() - const created: string[] = [] const session = Layer.mock(Session.Service, { get: () => Effect.succeed({ id: "ses_parent", permission: [], agent: "build" } as never), create: (value) => Effect.sync(() => { - const id = `ses_child_${created.length + 1}` - created.push(id) + const id = `ses_child_${input.created.length + 1}` + input.created.push(id) childTitles.set(id, (value?.title ?? id).replace(" (DAG node)", "")) return { id } as never }), @@ -207,12 +222,14 @@ function runGuardTest<A>( readonly store: DagStore.Interface readonly childPrompts: Queue.Queue<PromptGate> readonly cancels: string[] + readonly created: string[] }) => Effect.Effect<A, Error>, beforeInit?: (services: { readonly database: Database.Interface }) => Effect.Effect<void>, ) { return Effect.gen(function* () { const childPrompts = yield* Queue.unbounded<PromptGate>() const cancels: string[] = [] + const created: string[] = [] return yield* Effect.gen(function* () { const dag = yield* Dag.Service const loop = yield* DagLoop.Service @@ -236,9 +253,9 @@ function runGuardTest<A>( } if (beforeInit) yield* beforeInit({ database }) yield* loop.init() - return yield* test({ dag, loop, store, childPrompts, cancels }) + return yield* test({ dag, loop, store, childPrompts, cancels, created }) }).pipe( - Effect.provide(guardLayer({ childPrompts, cancels, failGetWorkflow: options.failGetWorkflow, failPause: options.failPause })), + Effect.provide(guardLayer({ childPrompts, cancels, created, failGetWorkflow: options.failGetWorkflow, failPause: options.failPause })), Effect.provideService(InstanceRef, { directory: process.cwd(), worktree: process.cwd(), @@ -333,6 +350,53 @@ describe("DagLoop cross-instance adoption guard", () => { }) }) +describe("DagLoop missing node config guard (DAG-A03)", () => { + it("fails an active row closed without creating or prompting a child session", async () => { + await Effect.runPromise( + runGuardTest( + { instanceProject: "project-1" }, + ({ store, created }) => + Effect.gen(function* () { + const failed = yield* pollWithTimeout( + store.getNode("dag_missing_config", "orphan-node").pipe( + Effect.map((row) => row?.status === "failed" ? row : undefined), + ), + "missing-config node was not failed closed", + ) + expect(failed.errorReason).toBe("Node configuration missing for active node: orphan-node") + expect(failed.errorClass).toBe("exec_failed") + expect(created).toEqual([]) + }), + ({ database }) => + database.db.transaction((tx) => + Effect.gen(function* () { + yield* tx.insert(WorkflowTable).values({ + id: "dag_missing_config", + project_id: Project.ID.make("project-1"), + session_id: SessionID.make("ses_project-1"), + directory: process.cwd(), + title: "Missing config", + status: "running", + config: JSON.stringify({ name: "missing-config", nodes: [] }), + seq: 10, + }).run() + yield* tx.insert(WorkflowNodeTable).values({ + id: "orphan-node", + workflow_id: "dag_missing_config", + name: "orphan-node", + worker_type: "build", + status: "pending", + required: true, + depends_on: [], + seq: 9, + }).run() + }), + ).pipe(Effect.orDie), + ), + ) + }) +}) + describe("DagLoop subscription survival", () => { it("keeps processing WorkflowStarted after a handler defect", async () => { await Effect.runPromise( diff --git a/packages/opencode/test/dag/dag-output-ref.test.ts b/packages/opencode/test/dag/dag-output-ref.test.ts index ece144f13..77ff4dee6 100644 --- a/packages/opencode/test/dag/dag-output-ref.test.ts +++ b/packages/opencode/test/dag/dag-output-ref.test.ts @@ -83,7 +83,10 @@ function makeEventTracker() { const storeStub: Partial<DagStore.Interface> = { tryClaimAdoption: () => Effect.succeed(true), getNode: Effect.fn("s")((_workflowID: string, nodeID: string) => - Effect.sync(() => ({ ...makeNodeRow({ id: nodeID }), capturedOutput: capturedStore.get(nodeID) }))), + Effect.sync(() => ({ + ...makeNodeRow({ id: nodeID, status: "running", childSessionId: "ses_child" }), + capturedOutput: capturedStore.get(nodeID), + }))), setCapturedOutput: Effect.fn("s")((_childSessionID: string, payload: unknown) => Effect.sync(() => { capturedCalls.push(payload) diff --git a/packages/opencode/test/dag/dag-plain-text-settlement.test.ts b/packages/opencode/test/dag/dag-plain-text-settlement.test.ts new file mode 100644 index 000000000..6d6364e7e --- /dev/null +++ b/packages/opencode/test/dag/dag-plain-text-settlement.test.ts @@ -0,0 +1,185 @@ +// SPDX-FileCopyrightText: 2026 LeXwDeX +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { describe, expect } from "bun:test" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { Effect, Layer } from "effect" +import { Database } from "@opencode-ai/core/database/database" +import { DagProjector } from "@opencode-ai/core/dag/projector" +import { DagStore } from "@opencode-ai/core/dag/store" +import { EventV2 } from "@opencode-ai/core/event" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { Dag, type NodeConfig } from "@/dag/dag" +import { reconcileWorkflow } from "@/dag/runtime/recovery" +import { EventV2Bridge } from "@/event-v2-bridge" +import { SessionID } from "@/session/schema" +import { testEffect } from "../lib/effect" + +const persistence = Layer.mergeAll( + Database.defaultLayer, + EventV2.defaultLayer, + DagProjector.defaultLayer, + DagStore.defaultLayer, + EventV2Bridge.defaultLayer, +) +const it = testEffect(Layer.provideMerge(Dag.layer, persistence)) + +function node(id: string, outputSchema?: Record<string, unknown>): NodeConfig { + return { + id, + name: id, + worker_type: "build", + depends_on: [], + required: true, + prompt_template: { inline: id }, + ...(outputSchema ? { output_schema: outputSchema } : {}), + } +} + +function setupProject() { + return Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ + id: Project.ID.global, + worktree: AbsolutePath.make("/project"), + sandboxes: [], + }) + .run() + .pipe(Effect.orDie) + }) +} + +function createRunning(dag: Dag.Interface, id: string, outputSchema?: Record<string, unknown>) { + return Effect.gen(function* () { + const sessionID = SessionID.make(`ses_parent_${id}`) + const childSessionID = `ses_child_${id}` + const { db } = yield* Database.Service + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: id, + directory: AbsolutePath.make("/project"), + title: id, + version: "test", + }) + .run() + .pipe(Effect.orDie) + const config = node(id, outputSchema) + const dagID = yield* dag.create({ + projectID: Project.ID.global, + sessionID, + title: id, + config: { name: id, nodes: [config] }, + }) + yield* dag.nodeQueued(dagID, id, Date.now() + 60_000) + yield* dag.nodeStarted(dagID, id, childSessionID, Date.now() + 60_000) + return { childSessionID, dagID } + }) +} + +const completed = () => Effect.succeed<"active" | "completed" | "failed" | "unknown">("completed") + +describe("plain-text settlement parity", () => { + it.effect("fails missing, empty, and whitespace-only recovered output with verdict_fail", () => + Effect.gen(function* () { + yield* setupProject() + const dag = yield* Dag.Service + const cases: ReadonlyArray<{ + readonly id: string + readonly reader?: () => Effect.Effect<string | undefined, Error> + }> = [ + { id: "missing-reader" }, + { id: "missing-text", reader: () => Effect.succeed(undefined) }, + { id: "empty-text", reader: () => Effect.succeed("") }, + { id: "whitespace-text", reader: () => Effect.succeed(" \n\t ") }, + ] + + for (const item of cases) { + const current = yield* createRunning(dag, item.id) + yield* reconcileWorkflow(current.dagID, completed, undefined, { nodes: [{ id: item.id }] }, item.reader) + const row = yield* dag.store.getNode(current.dagID, item.id) + expect(row?.status).toBe("failed") + expect(row?.errorReason).toBe("provider returned empty output") + expect(row?.errorClass).toBe("verdict_fail") + expect(row?.output).toBeNull() + } + }), + ) + + it.effect("preserves valid recovered text byte-for-byte and keeps file-ref capture best-effort", () => + Effect.gen(function* () { + yield* setupProject() + const dag = yield* Dag.Service + + const exact = "\n exact result with surrounding whitespace\t" + const inline = yield* createRunning(dag, "inline") + yield* reconcileWorkflow(inline.dagID, completed, undefined, { nodes: [{ id: "inline" }] }, () => + Effect.succeed(exact), + ) + const inlineRow = yield* dag.store.getNode(inline.dagID, "inline") + expect(inlineRow?.status).toBe("completed") + expect(inlineRow?.output).toBe(exact) + + const dir = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "dag-text-settlement-"))) + yield* Effect.gen(function* () { + const reportPath = path.join(dir, "report.md") + yield* Effect.promise(() => fs.writeFile(reportPath, "durable report")) + const file = yield* createRunning(dag, "file") + yield* reconcileWorkflow( + file.dagID, + completed, + undefined, + { nodes: [{ id: "file" }] }, + () => Effect.succeed(reportPath), + dir, + ) + const fileRow = yield* dag.store.getNode(file.dagID, "file") + expect(fileRow?.status).toBe("completed") + expect(fileRow?.output).toBe(reportPath) + expect(fileRow?.capturedOutput).toEqual( + expect.objectContaining({ + kind: "file_ref", + content_ref: reportPath, + path: reportPath, + }), + ) + }).pipe(Effect.ensuring(Effect.promise(() => fs.rm(dir, { recursive: true, force: true })))) + }), + ) + + it.effect("leaves structured-output recovery on its existing captured settlement contract", () => + Effect.gen(function* () { + yield* setupProject() + const dag = yield* Dag.Service + const schema = { + type: "object", + required: ["summary"], + properties: { summary: { type: "string" } }, + } + const current = yield* createRunning(dag, "structured", schema) + const output = { summary: "structured result" } + yield* dag.store.setCapturedOutput(current.childSessionID, output) + yield* reconcileWorkflow( + current.dagID, + completed, + undefined, + { nodes: [{ id: "structured", output_schema: schema }] }, + () => Effect.succeed("plain text must stay irrelevant"), + ) + + const row = yield* dag.store.getNode(current.dagID, "structured") + expect(row?.status).toBe("completed") + expect(row?.output).toEqual(output) + expect(row?.errorClass).toBeNull() + }), + ) +}) diff --git a/packages/opencode/test/dag/dag-recovery.test.ts b/packages/opencode/test/dag/dag-recovery.test.ts index a24275bf0..388456b60 100644 --- a/packages/opencode/test/dag/dag-recovery.test.ts +++ b/packages/opencode/test/dag/dag-recovery.test.ts @@ -66,14 +66,22 @@ function makeDagLayer( describe("reconcileWorkflow", () => { it("publishes NodeCompleted for running node with completed child session", async () => { - const events: { type: string; nodeID: string }[] = [] + const events: TrackedEvent[] = [] const nodes = [makeNodeRow({ id: "n1", status: "running", childSessionId: "ses_1" })] const dagLayer = makeDagLayer(nodes, events) const checkStatus = () => Effect.succeed("completed" as const) - await Effect.runPromise(reconcileWorkflow("wf-1", checkStatus).pipe(Effect.provide(dagLayer))) + await Effect.runPromise( + reconcileWorkflow( + "wf-1", + checkStatus, + undefined, + { nodes: [{ id: "n1" }] }, + () => Effect.succeed("recovered output"), + ).pipe(Effect.provide(dagLayer)), + ) - expect(events).toContainEqual({ type: "nodeCompleted", nodeID: "n1" }) + expect(events).toContainEqual({ type: "nodeCompleted", nodeID: "n1", output: "recovered output" }) expect(events).not.toContainEqual({ type: "nodeFailed", nodeID: "n1" }) }) @@ -360,7 +368,13 @@ describe("reconcileWorkflow", () => { const checkStatus = () => Effect.succeed("completed" as const) const exit = await Effect.runPromiseExit( - reconcileWorkflow("wf-1", checkStatus).pipe(Effect.provide(dagLayer)), + reconcileWorkflow( + "wf-1", + checkStatus, + undefined, + { nodes: [{ id: "n1" }] }, + () => Effect.succeed("recovered output"), + ).pipe(Effect.provide(dagLayer)), ) expect(Exit.isSuccess(exit)).toBe(true) @@ -377,7 +391,13 @@ describe("reconcileWorkflow", () => { const checkStatus = () => Effect.succeed("completed" as const) const exit = await Effect.runPromiseExit( - reconcileWorkflow("wf-1", checkStatus).pipe(Effect.provide(dagLayer)), + reconcileWorkflow( + "wf-1", + checkStatus, + undefined, + { nodes: [{ id: "n1" }] }, + () => Effect.succeed("recovered output"), + ).pipe(Effect.provide(dagLayer)), ) expect(Exit.isFailure(exit)).toBe(true) @@ -481,7 +501,7 @@ describe("rehydration via toSchedulingNodes", () => { expect(events).not.toContainEqual({ type: "nodeFailed", nodeID: "cp" }) }) - it("floors a missing text part to the live path's empty string, not undefined", async () => { + it("fails a completed schemaless node whose text part is missing", async () => { const events: TrackedEvent[] = [] const nodes = [makeNodeRow({ id: "n1", status: "running", childSessionId: "ses_1" })] const dagLayer = makeDagLayer(nodes, events) @@ -493,7 +513,13 @@ describe("rehydration via toSchedulingNodes", () => { ), ) - expect(events).toContainEqual({ type: "nodeCompleted", nodeID: "n1", output: "" }) + expect(events).toContainEqual({ + type: "nodeFailed", + nodeID: "n1", + reason: "provider returned empty output", + trigger: "verdict_fail", + }) + expect(events).not.toContainEqual(expect.objectContaining({ type: "nodeCompleted", nodeID: "n1" })) }) // #345 degenerate branch: an unparseable workflow row (explicit null) must @@ -521,8 +547,7 @@ describe("rehydration via toSchedulingNodes", () => { 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 () => { + it("fails safely when a completed schemaless node has no text reader", async () => { const events: TrackedEvent[] = [] const nodes = [makeNodeRow({ id: "n1", status: "running", childSessionId: "ses_1" })] const dagLayer = makeDagLayer(nodes, events) @@ -532,7 +557,13 @@ describe("rehydration via toSchedulingNodes", () => { reconcileWorkflow("wf-1", checkStatus, undefined, { nodes: [{ id: "n1" }] }).pipe(Effect.provide(dagLayer)), ) - expect(events).toContainEqual({ type: "nodeCompleted", nodeID: "n1" }) + expect(events).toContainEqual({ + type: "nodeFailed", + nodeID: "n1", + reason: "provider returned empty output", + trigger: "verdict_fail", + }) + expect(events).not.toContainEqual(expect.objectContaining({ type: "nodeCompleted", nodeID: "n1" })) }) }) diff --git a/packages/opencode/test/dag/dag-replan-atomicity.test.ts b/packages/opencode/test/dag/dag-replan-atomicity.test.ts new file mode 100644 index 000000000..a8ddf4910 --- /dev/null +++ b/packages/opencode/test/dag/dag-replan-atomicity.test.ts @@ -0,0 +1,279 @@ +import { describe, expect } from "bun:test" +import { Effect, Exit, Fiber, Layer, Stream } from "effect" +import { eq } from "drizzle-orm" +import { Database } from "@opencode-ai/core/database/database" +import { DagProjector } from "@opencode-ai/core/dag/projector" +import { DagStore } from "@opencode-ai/core/dag/store" +import { WorkflowTable } from "@opencode-ai/core/dag/sql" +import { EventV2 } from "@opencode-ai/core/event" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { DagEvent } from "@opencode-ai/schema/dag-event" +import { GlobalBus, type GlobalEvent } from "@/bus/global" +import { Dag, type NodeConfig } from "@/dag/dag" +import { InstanceRef } from "@/effect/instance-ref" +import { EventV2Bridge } from "@/event-v2-bridge" +import type { InstanceContext } from "@/project/instance-context" +import { SessionID } from "@/session/schema" +import { isRecord } from "@/util/record" +import { pollWithTimeout, testEffect } from "../lib/effect" + +interface BatchProbe { + failAtConfig: boolean +} + +const directory = process.cwd() +const projectID = Project.ID.make("project-1") +const instance = { + directory, + worktree: directory, + project: { + id: projectID, + worktree: AbsolutePath.make(directory), + time: { created: 0, updated: 0 }, + sandboxes: [], + }, +} satisfies InstanceContext + +function node(id: string, prompt: string): NodeConfig { + return { + id, + name: id, + worker_type: "build", + depends_on: [], + required: true, + prompt_template: { inline: prompt }, + } +} + +function atomicLayer(probe: BatchProbe) { + const database = Database.layerFromPath(":memory:") + const events = EventV2.layer.pipe(Layer.provide(database)) + const rawBridge = EventV2Bridge.layer.pipe(Layer.provide(events)) + const bridge = Layer.effect( + EventV2Bridge.Service, + Effect.gen(function* () { + const original = yield* EventV2Bridge.Service + return EventV2Bridge.Service.of({ + ...original, + publishMany: (entries, options) => { + const staged = entries.map((entry) => + probe.failAtConfig && entry.definition === DagEvent.WorkflowConfigUpdated + ? { + ...entry, + options: { + ...entry.options, + // This hook runs after the config projector and before the + // event/sequence inserts. A defect here exercises rollback + // at the real transaction boundary, after earlier projectors + // in the replan batch have already executed. + commit: () => Effect.die(new Error("injected replan commit failure")), + }, + } + : entry, + ) + return original.publishMany(staged, options) + }, + }) + }), + ).pipe(Layer.provide(rawBridge)) + const store = DagStore.layer.pipe(Layer.provide(database)) + const projector = DagProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) + const dag = Dag.layer.pipe(Layer.provide(bridge), Layer.provide(store)) + return Layer.mergeAll(database, events, bridge, store, projector, dag) +} + +function setup() { + return Effect.gen(function* () { + const database = yield* Database.Service + yield* database.db + .insert(ProjectTable) + .values({ id: projectID, worktree: AbsolutePath.make(directory), sandboxes: [] }) + .run() + .pipe(Effect.orDie) + yield* database.db + .insert(SessionTable) + .values({ + id: SessionID.make("ses_parent"), + project_id: projectID, + slug: "parent", + directory: AbsolutePath.make(directory), + title: "Parent", + version: "test", + }) + .run() + .pipe(Effect.orDie) + }) +} + +describe("Dag.replan atomic transaction (DAG-A03)", () => { + const probe: BatchProbe = { failAtConfig: true } + const it = testEffect(atomicLayer(probe)) + + it.live("rejects an unreadable durable definition before changing any graph row or revision", () => + Effect.gen(function* () { + probe.failAtConfig = false + yield* setup() + const dag = yield* Dag.Service + const store = yield* DagStore.Service + const database = yield* Database.Service + const dagID = yield* dag.create({ + projectID, + sessionID: SessionID.make("ses_parent"), + title: "Invalid config replan", + config: { name: "invalid-config", nodes: [node("old", "Original task")] }, + }) + yield* database.db.update(WorkflowTable).set({ config: "{broken" }).where(eq(WorkflowTable.id, dagID)).run() + const before = { + workflow: yield* store.getWorkflow(dagID), + nodes: yield* store.getNodes(dagID), + } + const result = yield* dag.replan(dagID, { nodes: [node("new", "Replacement task")] }).pipe(Effect.result) + expect(result._tag).toBe("Failure") + if (result._tag === "Failure") expect(result.failure.message).toContain("current workflow config is invalid") + expect({ + workflow: yield* store.getWorkflow(dagID), + nodes: yield* store.getNodes(dagID), + }).toEqual(before) + }).pipe(Effect.provideService(InstanceRef, instance)), + ) + + it.live("rolls back the complete graph and only exposes a successful committed batch", () => + Effect.gen(function* () { + probe.failAtConfig = true + yield* setup() + + const dag = yield* Dag.Service + const events = yield* EventV2.Service + const store = yield* DagStore.Service + const dagID = yield* dag.create({ + projectID, + sessionID: SessionID.make("ses_parent"), + title: "Atomic replan", + config: { name: "atomic-replan", nodes: [node("old", "Original task")] }, + }) + const before = { + workflow: yield* store.getWorkflow(dagID), + nodes: yield* store.getNodes(dagID), + } + const globalEvents = new Array<{ + kind: "event" | "sync" + type: string + seq?: number + directory?: string + project?: string + }>() + const onGlobal = (event: GlobalEvent) => { + const payload: unknown = event.payload + if (!isRecord(payload)) return + const sync = isRecord(payload.syncEvent) ? payload.syncEvent : undefined + const properties = isRecord(payload.properties) ? payload.properties : undefined + const aggregateID = typeof sync?.aggregateID === "string" ? sync.aggregateID : undefined + const propertiesDagID = typeof properties?.dagID === "string" ? properties.dagID : undefined + if (aggregateID !== dagID && propertiesDagID !== dagID) return + const syncType = typeof sync?.type === "string" ? sync.type : undefined + const syncSeq = typeof sync?.seq === "number" ? sync.seq : undefined + globalEvents.push({ + kind: sync ? "sync" : "event", + type: syncType ?? (typeof payload.type === "string" ? payload.type : "unknown"), + ...(syncSeq === undefined ? {} : { seq: syncSeq }), + directory: event.directory, + project: event.project, + }) + } + yield* Effect.acquireRelease( + Effect.sync(() => GlobalBus.on("event", onGlobal)), + () => Effect.sync(() => GlobalBus.off("event", onGlobal)), + ) + const observer = yield* events.all().pipe( + Stream.filter((event) => event.durable?.aggregateID === dagID), + Stream.take(4), + Stream.runCollect, + Effect.forkScoped, + ) + yield* Effect.yieldNow + + const failed = yield* dag + .replan(dagID, { nodes: [node("replacement", "Perform the approved replacement task")] }) + .pipe(Effect.exit) + expect(Exit.isFailure(failed)).toBe(true) + expect({ + workflow: yield* store.getWorkflow(dagID), + nodes: yield* store.getNodes(dagID), + }).toEqual(before) + expect(globalEvents).toEqual([]) + + probe.failAtConfig = false + const plan = yield* dag.replan(dagID, { + nodes: [node("replacement", "Perform the approved replacement task")], + }) + expect(plan).toEqual({ + cancel: ["old"], + restart: [], + replace: [], + add: ["replacement"], + ignore: [], + }) + + const workflow = yield* store.getWorkflow(dagID) + const rows = yield* store.getNodes(dagID) + expect(workflow?.graphRev).toBe((before.workflow?.graphRev ?? 0) + 1) + expect(JSON.parse(workflow?.config ?? "{}").nodes).toEqual([ + expect.objectContaining({ + id: "replacement", + prompt_template: { inline: "Perform the approved replacement task" }, + }), + ]) + expect(rows.find((row) => row.id === "old")).toEqual( + expect.objectContaining({ status: "failed", superseded: true, errorReason: "cancelled via replan" }), + ) + expect(rows.find((row) => row.id === "replacement")).toEqual( + expect.objectContaining({ status: "pending", superseded: false }), + ) + + const observed = Array.from(yield* Fiber.join(observer)) + expect(observed.map((event) => event.type)).toEqual([ + DagEvent.NodeRegistered.type, + DagEvent.NodeCancelled.type, + DagEvent.WorkflowConfigUpdated.type, + DagEvent.WorkflowReplanned.type, + ]) + expect(observed.map((event) => event.durable?.seq)).toEqual( + observed.map((_, index) => observed[0].durable!.seq + index), + ) + for (const event of observed) { + const location = event.location as + | { directory?: string; project?: { id: string; directory: string } } + | undefined + expect(location?.directory).toBe(directory) + expect(location?.project?.id).toBe(projectID) + expect(location?.project?.directory).toBe(directory) + } + + const forwarded = yield* pollWithTimeout( + Effect.sync(() => (globalEvents.length === 8 ? globalEvents : undefined)), + "EventV2Bridge did not forward the complete committed replan batch", + ) + const expectedTypes = [ + DagEvent.NodeRegistered.type, + DagEvent.NodeCancelled.type, + DagEvent.WorkflowConfigUpdated.type, + DagEvent.WorkflowReplanned.type, + ] + expect(forwarded.map((event) => ({ kind: event.kind, type: event.type }))).toEqual( + expectedTypes.flatMap((type) => [ + { kind: "event", type }, + { kind: "sync", type: EventV2.versionedType(type, 1) }, + ]), + ) + const sync = forwarded.filter((event) => event.kind === "sync") + expect(sync.map((event) => event.seq)).toEqual(sync.map((_, index) => sync[0].seq! + index)) + for (const event of forwarded) { + expect(event.directory).toBe(directory) + expect(event.project).toBe(projectID) + } + }).pipe(Effect.provideService(InstanceRef, instance)), + ) +}) diff --git a/packages/opencode/test/dag/dag-replan-definition-guard.test.ts b/packages/opencode/test/dag/dag-replan-definition-guard.test.ts new file mode 100644 index 000000000..9e7d98117 --- /dev/null +++ b/packages/opencode/test/dag/dag-replan-definition-guard.test.ts @@ -0,0 +1,316 @@ +// SPDX-FileCopyrightText: 2026 LeXwDeX +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { describe, expect, it as bunIt } from "bun:test" +import { Deferred, Effect, Fiber, Layer } from "effect" +import { Database } from "@opencode-ai/core/database/database" +import { DagProjector } from "@opencode-ai/core/dag/projector" +import { DagStore } from "@opencode-ai/core/dag/store" +import { EventV2 } from "@opencode-ai/core/event" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { Dag, type NodeConfig, parseWorkflowConfig } from "@/dag/dag" +import { changedAdmittedNodeFields } from "@/dag/replan-definition" +import { EventV2Bridge } from "@/event-v2-bridge" +import { SessionID } from "@/session/schema" +import { testEffect } from "../lib/effect" + +const persistence = Layer.mergeAll( + Database.defaultLayer, + EventV2.defaultLayer, + DagProjector.defaultLayer, + DagStore.defaultLayer, + EventV2Bridge.defaultLayer, +) +const it = testEffect(Layer.provideMerge(Dag.layer, persistence)) + +function node(id: string, dependsOn: string[] = []): NodeConfig { + return { + id, + name: id, + worker_type: "build", + depends_on: dependsOn, + required: true, + prompt_template: { inline: id }, + report_to_parent: true, + } +} + +function setup(sessionID: string) { + return Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ + id: Project.ID.global, + worktree: AbsolutePath.make("/project"), + sandboxes: [], + }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: SessionID.make(sessionID), + project_id: Project.ID.global, + slug: sessionID, + directory: AbsolutePath.make("/project"), + title: sessionID, + version: "test", + }) + .run() + .pipe(Effect.orDie) + }) +} + +function create(dag: Dag.Interface, sessionID: string, nodes: NodeConfig[]) { + return dag.create({ + projectID: Project.ID.global, + sessionID, + title: sessionID, + config: { name: sessionID, nodes }, + }) +} + +function start(dag: Dag.Interface, dagID: string, nodeID: string, childSessionID: string) { + return Effect.gen(function* () { + yield* dag.nodeQueued(dagID, nodeID, Date.now() + 60_000) + yield* dag.nodeStarted(dagID, nodeID, childSessionID, Date.now() + 60_000) + }) +} + +describe("replan admitted-definition guard", () => { + it.effect("rejects running execution-field changes before mutation and preserves the old attempt", () => + Effect.gen(function* () { + const sessionID = "ses_replan_definition_reject" + yield* setup(sessionID) + const dag = yield* Dag.Service + const target: NodeConfig = { + ...node("target", ["dep"]), + name: "old name", + prompt_template: { inline: "old {{value}}", input: { alpha: 1, nested: { left: true, right: false } } }, + worker_config: { timeout_ms: 5_000 }, + input_mapping: { value: "dep.output.value" }, + condition: "dep.output.value == 1", + model: { providerID: "test", modelID: "old-model" }, + output_schema: { type: "string", description: "old output" }, + } + const dagID = yield* create(dag, sessionID, [node("dep"), target]) + yield* start(dag, dagID, "dep", "ses_replan_definition_dep") + yield* dag.nodeCompleted(dagID, "dep", { value: 1 }) + yield* start(dag, dagID, "target", "ses_replan_definition_old") + + const releaseOld = yield* Deferred.make<void>() + const oldFiber = yield* Deferred.await(releaseOld).pipe(Effect.as("old execution returned"), Effect.forkScoped) + const workflowBefore = yield* dag.store.getWorkflow(dagID) + const nodeBefore = yield* dag.store.getNode(dagID, "target") + + const error = yield* dag + .replan(dagID, { + nodes: [ + { + ...node("target"), + name: "new name", + worker_type: "explore", + required: false, + prompt_template: { inline: "new prompt" }, + worker_config: { timeout_ms: 5_000 }, + model: { providerID: "other", modelID: "new-model" }, + report_to_parent: false, + output_schema: { type: "object", properties: { answer: { type: "number" } } }, + }, + ], + }) + .pipe(Effect.catch((failure: Error) => Effect.succeed(failure))) + + expect(error).toBeInstanceOf(Error) + if (!(error instanceof Error)) throw new Error("replan unexpectedly succeeded") + expect(error.message).toContain('Node "target" is running') + for (const field of [ + "condition", + "depends_on", + "input_mapping", + "model", + "name", + "output_schema", + "prompt_template", + "report_to_parent", + "required", + "worker_type", + ]) { + expect(error.message).toContain(field) + } + expect(error.message).toContain("restart: true") + expect(yield* dag.store.getWorkflow(dagID)).toEqual(workflowBefore) + expect(yield* dag.store.getNode(dagID, "target")).toEqual(nodeBefore) + + yield* Deferred.succeed(releaseOld, undefined) + expect(yield* Fiber.join(oldFiber)).toBe("old execution returned") + yield* dag.nodeCompleted(dagID, "target", "old result") + expect((yield* dag.store.getNode(dagID, "target"))?.output).toBe("old result") + const persisted = parseWorkflowConfig((yield* dag.store.getWorkflow(dagID))?.config ?? "") + expect(persisted?.nodes.find((value) => value.id === "target")).toEqual(target) + }), + ) + + it.effect("allows a running timeout update and definitions that differ only by object key order", () => + Effect.gen(function* () { + const sessionID = "ses_replan_definition_timeout" + yield* setup(sessionID) + const dag = yield* Dag.Service + const target: NodeConfig = { + ...node("target", ["dep"]), + prompt_template: { inline: "{{first}} {{second}}", input: { first: 1, second: { a: true, b: false } } }, + worker_config: { timeout_ms: 5_000 }, + input_mapping: { first: "dep.output.first", second: "dep.output.second" }, + model: { providerID: "test", modelID: "model" }, + output_schema: { + type: "object", + properties: { first: { type: "number" }, second: { type: "number" } }, + required: ["first", "second"], + }, + } + const dagID = yield* create(dag, sessionID, [node("dep"), target]) + yield* start(dag, dagID, "dep", "ses_replan_timeout_dep") + yield* dag.nodeCompleted(dagID, "dep", { first: 1, second: 2 }) + yield* start(dag, dagID, "target", "ses_replan_timeout_target") + + const plan = yield* dag.replan(dagID, { + nodes: [ + { + id: "target", + name: "target", + worker_type: "build", + depends_on: ["dep"], + required: true, + prompt_template: { input: { second: { b: false, a: true }, first: 1 }, inline: "{{first}} {{second}}" }, + worker_config: { timeout_ms: 9_000 }, + input_mapping: { second: "dep.output.second", first: "dep.output.first" }, + report_to_parent: true, + model: { modelID: "model", providerID: "test" }, + output_schema: { + required: ["first", "second"], + properties: { second: { type: "number" }, first: { type: "number" } }, + type: "object", + }, + }, + ], + }) + + expect(plan.replace).toEqual(["target"]) + expect((yield* dag.store.getNode(dagID, "target"))?.status).toBe("running") + const persisted = parseWorkflowConfig((yield* dag.store.getWorkflow(dagID))?.config ?? "") + expect(persisted?.nodes.find((value) => value.id === "target")?.worker_config?.timeout_ms).toBe(9_000) + yield* dag.nodeCompleted(dagID, "target", { first: 1, second: 2 }) + }), + ) + + it.effect("rejects queued changes with an actionable replacement path and allows an equivalent fragment", () => + Effect.gen(function* () { + const sessionID = "ses_replan_definition_queued" + yield* setup(sessionID) + const dag = yield* Dag.Service + const queued = { ...node("queued"), worker_config: { timeout_ms: 5_000 } } + const dagID = yield* create(dag, sessionID, [queued]) + yield* dag.nodeQueued(dagID, "queued", Date.now() + 60_000) + const workflowBefore = yield* dag.store.getWorkflow(dagID) + const nodeBefore = yield* dag.store.getNode(dagID, "queued") + + const error = yield* dag + .replan(dagID, { + nodes: [ + { + ...node("queued"), + prompt_template: { inline: "changed" }, + worker_config: { timeout_ms: 9_000 }, + }, + ], + }) + .pipe(Effect.catch((failure: Error) => Effect.succeed(failure))) + expect(error).toBeInstanceOf(Error) + if (!(error instanceof Error)) throw new Error("queued replan unexpectedly succeeded") + expect(error.message).toContain('Node "queued" is queued') + expect(error.message).toContain("prompt_template") + expect(error.message).toContain("worker_config.timeout_ms") + expect(error.message).toContain("replacement node under a new id") + expect(error.message).not.toContain("restart: true") + expect(yield* dag.store.getWorkflow(dagID)).toEqual(workflowBefore) + expect(yield* dag.store.getNode(dagID, "queued")).toEqual(nodeBefore) + + const plan = yield* dag.replan(dagID, { nodes: [queued] }) + expect(plan.replace).toEqual(["queued"]) + expect((yield* dag.store.getNode(dagID, "queued"))?.status).toBe("queued") + }), + ) + + it.effect("allows pending edits, ignores terminal edits, and restarts a running node onto its new definition", () => + Effect.gen(function* () { + const sessionID = "ses_replan_definition_lifecycle" + yield* setup(sessionID) + const dag = yield* Dag.Service + const dagID = yield* create(dag, sessionID, [node("done"), node("pending"), node("running")]) + yield* start(dag, dagID, "done", "ses_replan_definition_done") + yield* dag.nodeCompleted(dagID, "done", "done") + yield* start(dag, dagID, "running", "ses_replan_definition_running") + + const releaseOld = yield* Deferred.make<void>() + const oldFiber = yield* Deferred.await(releaseOld).pipe(Effect.as("old attempt returned"), Effect.forkScoped) + const plan = yield* dag.replan(dagID, { + nodes: [ + { ...node("done"), prompt_template: { inline: "ignored terminal edit" } }, + { ...node("pending"), prompt_template: { inline: "new pending prompt" } }, + { + ...node("running"), + name: "restarted name", + worker_type: "explore", + prompt_template: { inline: "new running prompt" }, + restart: true, + }, + ], + }) + + expect(plan.ignore).toEqual(["done"]) + expect(plan.replace).toEqual(["pending"]) + expect(plan.restart).toEqual(["running"]) + const persisted = parseWorkflowConfig((yield* dag.store.getWorkflow(dagID))?.config ?? "") + expect(persisted?.nodes.find((value) => value.id === "done")?.prompt_template.inline).toBe("done") + expect(persisted?.nodes.find((value) => value.id === "pending")?.prompt_template.inline).toBe( + "new pending prompt", + ) + expect(persisted?.nodes.find((value) => value.id === "running")?.prompt_template.inline).toBe( + "new running prompt", + ) + expect((yield* dag.store.getNode(dagID, "running"))?.status).toBe("pending") + + yield* Deferred.succeed(releaseOld, undefined) + expect(yield* Fiber.join(oldFiber)).toBe("old attempt returned") + yield* start(dag, dagID, "running", "ses_replan_definition_restarted") + yield* dag.nodeCompleted(dagID, "running", "new result") + expect((yield* dag.store.getNode(dagID, "running"))?.output).toBe("new result") + }), + ) + + bunIt("compares nested future execution fields while ignoring control markers", () => { + const current = { + ...node("worker"), + permissions: { write: false, read: true }, + review: { phase: "diff", implementation_node_id: "impl" }, + worker_config: { timeout_ms: 5_000, sandbox: { network: false, filesystem: "read" } }, + } + const next = { + ...node("worker"), + restart: true, + permissions: { read: true, write: true }, + review: { implementation_node_id: "impl", phase: "design" }, + worker_config: { sandbox: { filesystem: "write", network: false }, timeout_ms: 9_000 }, + } + + expect(changedAdmittedNodeFields(current, next, { allowTimeoutUpdate: true })).toEqual([ + "permissions", + "review", + "worker_config.sandbox", + ]) + }) +}) diff --git a/packages/opencode/test/dag/dag-replan-stale-nodefailed.test.ts b/packages/opencode/test/dag/dag-replan-stale-nodefailed.test.ts index c95276154..c2ff8c31a 100644 --- a/packages/opencode/test/dag/dag-replan-stale-nodefailed.test.ts +++ b/packages/opencode/test/dag/dag-replan-stale-nodefailed.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test" -import { Deferred, Effect, Layer, Option, Queue } from "effect" +import { Cause, Deferred, Effect, Layer, Option, Queue, Stream } from "effect" import type { SessionV1 } from "@opencode-ai/core/v1/session" import { Database } from "@opencode-ai/core/database/database" import { DagProjector } from "@opencode-ai/core/dag/projector" @@ -7,8 +7,9 @@ import { DagStore } from "@opencode-ai/core/dag/store" import { EventV2 } from "@opencode-ai/core/event" import { ProjectTable } from "@opencode-ai/core/project/sql" import { SessionTable } from "@opencode-ai/core/session/sql" +import { DagEvent } from "@opencode-ai/schema/dag-event" import { Agent } from "@/agent/agent" -import { Dag, type NodeConfig } from "@/dag/dag" +import { Dag, type NodeConfig, type NodeExecutionAttempt } from "@/dag/dag" import { DagLoop } from "@/dag/runtime/loop" import { InstanceRef } from "@/effect/instance-ref" import { EventV2Bridge } from "@/event-v2-bridge" @@ -21,6 +22,7 @@ import { withIdleAdmission } from "../lib/session-prompt" interface PromptGate { readonly title: string + readonly text: string readonly release: Deferred.Deferred<string> } @@ -28,6 +30,16 @@ interface ParentPromptGate { readonly release: Deferred.Deferred<"success" | "failure"> } +interface AttemptSettlement { + readonly kind: "completed" | "failed" + readonly attempt?: NodeExecutionAttempt +} + +interface AdmissionAttempt { + readonly nodeID: string + readonly attempt?: NodeExecutionAttempt +} + function takeWithin<A>(queue: Queue.Queue<A>, message: string) { return Queue.take(queue).pipe( Effect.timeoutOption("2 seconds"), @@ -38,6 +50,10 @@ function takeWithin<A>(queue: Queue.Queue<A>, message: string) { ) } +function requireValue<A>(value: A | null | undefined, message: string): Effect.Effect<A> { + return value == null ? Effect.die(new Error(message)) : Effect.succeed(value) +} + function reply(sessionID: string, text: string): SessionV1.WithParts { return { info: { @@ -75,20 +91,61 @@ function node(id: string, dependsOn: string[] = [], timeoutMs?: number): NodeCon function loopLayer(input: { readonly childPrompts: Queue.Queue<PromptGate> readonly parentPrompts: Queue.Queue<ParentPromptGate> + readonly settlements: Queue.Queue<AttemptSettlement> + readonly admissions: Queue.Queue<AdmissionAttempt> + readonly holdReplan?: Deferred.Deferred<void> }) { const database = Database.layerFromPath(":memory:") const events = EventV2.layer.pipe(Layer.provide(database)) - const bridge = EventV2Bridge.layer.pipe(Layer.provide(events)) + const rawBridge = EventV2Bridge.layer.pipe(Layer.provide(events)) + const bridge = Layer.effect( + EventV2Bridge.Service, + Effect.gen(function* () { + const original = yield* EventV2Bridge.Service + return EventV2Bridge.Service.of({ + ...original, + subscribe: (definition) => + original.subscribe(definition).pipe( + Stream.tap(() => + definition.type === DagEvent.WorkflowReplanned.type && input.holdReplan + ? Deferred.await(input.holdReplan) + : Effect.void, + ), + ), + }) + }), + ).pipe(Layer.provide(rawBridge)) const store = DagStore.layer.pipe(Layer.provide(database)) const status = SessionStatus.layer.pipe(Layer.provide(bridge)) const projector = DagProjector.layer.pipe( Layer.provide(events), Layer.provide(database), ) - const dag = Dag.layer.pipe( + const rawDag = Dag.layer.pipe( Layer.provide(bridge), Layer.provide(store), ) + const dag = Layer.effect( + Dag.Service, + Effect.gen(function* () { + const original = yield* Dag.Service + return Dag.Service.of({ + ...original, + nodeQueued: (dagID, nodeID, deadlineMs, attempt) => + original.nodeQueued(dagID, nodeID, deadlineMs, attempt).pipe( + Effect.ensuring(Queue.offer(input.admissions, { nodeID, attempt })), + ), + nodeCompleted: (dagID, nodeID, output, attempt) => + original.nodeCompleted(dagID, nodeID, output, attempt).pipe( + Effect.ensuring(Queue.offer(input.settlements, { kind: "completed", attempt })), + ), + nodeFailed: (dagID, nodeID, reason, trigger, attempt) => + original.nodeFailed(dagID, nodeID, reason, trigger, attempt).pipe( + Effect.ensuring(Queue.offer(input.settlements, { kind: "failed", attempt })), + ), + }) + }), + ).pipe(Layer.provide(rawDag)) const base = Layer.mergeAll(database, events, bridge, store, projector, dag, status) const childTitles = new Map<string, string>() const created: string[] = [] @@ -115,6 +172,7 @@ function loopLayer(input: { const release = yield* Deferred.make<string>() yield* Queue.offer(input.childPrompts, { title: childTitles.get(sessionID) ?? sessionID, + text: value.parts.find((part) => part.type === "text")?.text ?? "", release, }) return reply(sessionID, yield* Deferred.await(release)) @@ -152,11 +210,16 @@ function runLoopTest<A>( readonly store: DagStore.Interface readonly childPrompts: Queue.Queue<PromptGate> readonly parentPrompts: Queue.Queue<ParentPromptGate> + readonly settlements: Queue.Queue<AttemptSettlement> + readonly admissions: Queue.Queue<AdmissionAttempt> }) => Effect.Effect<A, Error>, + options: { readonly holdReplan?: Deferred.Deferred<void> } = {}, ) { return Effect.gen(function* () { const childPrompts = yield* Queue.unbounded<PromptGate>() const parentPrompts = yield* Queue.unbounded<ParentPromptGate>() + const settlements = yield* Queue.unbounded<AttemptSettlement>() + const admissions = yield* Queue.unbounded<AdmissionAttempt>() return yield* Effect.gen(function* () { const dag = yield* Dag.Service const loop = yield* DagLoop.Service @@ -176,9 +239,9 @@ function runLoopTest<A>( version: "test", }).run().pipe(Effect.orDie) yield* loop.init() - return yield* test({ dag, store, childPrompts, parentPrompts }) + return yield* test({ dag, store, childPrompts, parentPrompts, settlements, admissions }) }).pipe( - Effect.provide(loopLayer({ childPrompts, parentPrompts })), + Effect.provide(loopLayer({ childPrompts, parentPrompts, settlements, admissions, holdReplan: options.holdReplan })), Effect.provideService(InstanceRef, { directory: process.cwd(), worktree: process.cwd(), @@ -190,6 +253,139 @@ function runLoopTest<A>( } describe("DagLoop replan vs stale NodeFailed", () => { + for (const outcome of ["completed", "failed"] as const) { + it(`fences a delayed old prompt ${outcome} before WorkflowReplanned cleanup`, async () => { + const holdReplan = await Effect.runPromise(Deferred.make<void>()) + await Effect.runPromise( + runLoopTest( + ({ dag, store, childPrompts, settlements }) => + Effect.gen(function* () { + const dagID = yield* dag.create({ + projectID: "project-1", + sessionID: "ses_parent", + title: `Attempt fence ${outcome}`, + config: { name: `attempt-fence-${outcome}`, nodes: [node("a")] }, + }) + const first = yield* takeWithin(childPrompts, "old attempt did not reach its prompt") + const old = yield* requireValue(yield* store.getNode(dagID, "a"), "old attempt row missing") + + yield* dag.replan(dagID, { nodes: [{ ...node("a"), restart: true }] }) + const replacement = yield* requireValue( + yield* store.getNode(dagID, "a"), + "replacement attempt row missing", + ) + yield* dag.nodeQueued(dagID, "a", Date.now() + 60_000, { + replanAttempts: replacement.replanAttempts, + nodeSeq: replacement.seq, + }) + yield* dag.nodeStarted(dagID, "a", "ses_current", Date.now() + 60_000, true, { + replanAttempts: replacement.replanAttempts, + }) + + if (outcome === "completed") { + yield* Deferred.succeed(first.release, "old result") + } else { + yield* Deferred.failCause(first.release, Cause.die(new Error("old prompt failed after restart"))) + } + const observed = yield* takeWithin(settlements, "old prompt never attempted settlement") + expect(observed.kind).toBe(outcome) + expect(observed.attempt).toEqual({ + replanAttempts: old.replanAttempts, + childSessionID: "ses_child_1", + }) + expect(yield* store.getNode(dagID, "a")).toEqual( + expect.objectContaining({ + status: "running", + childSessionId: "ses_current", + replanAttempts: replacement.replanAttempts, + }), + ) + + yield* dag.nodeCompleted(dagID, "a", "current result", { + replanAttempts: replacement.replanAttempts, + childSessionID: "ses_current", + }) + expect(yield* store.getNode(dagID, "a")).toEqual( + expect.objectContaining({ status: "completed", output: "current result" }), + ) + yield* Deferred.succeed(holdReplan, undefined) + }), + { holdReplan }, + ), + ) + }) + } + + it("does not admit a fresh node row with the cached config from the prior graph revision", async () => { + const holdReplan = await Effect.runPromise(Deferred.make<void>()) + await Effect.runPromise( + runLoopTest( + ({ dag, store, childPrompts, admissions }) => + Effect.gen(function* () { + const dagID = yield* dag.create({ + projectID: "project-1", + sessionID: "ses_parent", + title: "Admission config generation fence", + config: { + name: "admission-config-generation-fence", + nodes: [ + { ...node("a"), report_to_parent: false }, + { ...node("b", ["a"]), prompt_template: { inline: "old b prompt" } }, + ], + }, + }) + const first = yield* takeWithin(childPrompts, "a did not reach its prompt") + expect(first.title).toBe("a") + const firstAdmission = yield* takeWithin(admissions, "a admission was not observed") + expect(firstAdmission.nodeID).toBe("a") + const originalGraphRev = firstAdmission.attempt?.graphRev + + yield* dag.replan(dagID, { + nodes: [ + { ...node("a"), report_to_parent: false }, + { + ...node("b", ["a"]), + name: "replacement b", + prompt_template: { inline: "replacement b prompt" }, + }, + ], + }) + const replacement = yield* requireValue( + yield* store.getNode(dagID, "b"), + "replacement node row missing", + ) + const workflow = yield* requireValue( + yield* store.getWorkflow(dagID), + "replacement workflow row missing", + ) + expect(workflow.graphRev).toBeGreaterThan(originalGraphRev ?? 0) + + yield* Deferred.succeed(first.release, "a done") + const staleAdmission = yield* takeWithin(admissions, "stale b admission was not attempted") + expect(staleAdmission).toEqual({ + nodeID: "b", + attempt: { + replanAttempts: replacement.replanAttempts, + nodeSeq: replacement.seq, + graphRev: originalGraphRev, + }, + }) + expect((yield* store.getNode(dagID, "b"))?.status).toBe("pending") + expect(Option.isNone(yield* Queue.poll(childPrompts))).toBe(true) + + yield* Deferred.succeed(holdReplan, undefined) + const currentAdmission = yield* takeWithin(admissions, "current b admission was not attempted") + expect(currentAdmission.attempt?.graphRev).toBe(workflow.graphRev) + const current = yield* takeWithin(childPrompts, "replacement b did not reach its prompt") + expect(current.title).toBe("replacement b") + expect(current.text).toBe("replacement b prompt") + yield* Deferred.succeed(current.release, "b done") + }), + { holdReplan }, + ), + ) + }) + it("does not terminalize the old graph while a replacement graph is being applied", async () => { await Effect.runPromise( runLoopTest(({ dag, store, childPrompts }) => diff --git a/packages/opencode/test/dag/dag-step-convergence.test.ts b/packages/opencode/test/dag/dag-step-convergence.test.ts new file mode 100644 index 000000000..1775e0a2e --- /dev/null +++ b/packages/opencode/test/dag/dag-step-convergence.test.ts @@ -0,0 +1,283 @@ +/* oxlint-disable typescript-eslint/no-unsafe-type-assertion -- + * Branded test fixtures and Effect service mocks use the established DAG harness narrowing pattern. */ +import { describe, expect, it } from "bun:test" +import { Deferred, Effect, Layer, Option, Queue } from "effect" +import type { SessionV1 } from "@opencode-ai/core/v1/session" +import { Database } from "@opencode-ai/core/database/database" +import { DagProjector } from "@opencode-ai/core/dag/projector" +import { DagStore } from "@opencode-ai/core/dag/store" +import { EventV2 } from "@opencode-ai/core/event" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { Agent } from "@/agent/agent" +import { Dag, type NodeConfig } from "@/dag/dag" +import { DagLoop } from "@/dag/runtime/loop" +import { InstanceRef } from "@/effect/instance-ref" +import { EventV2Bridge } from "@/event-v2-bridge" +import { SessionPrompt } from "@/session/prompt" +import { MessageID } from "@/session/schema" +import { Session } from "@/session/session" +import { SessionStatus } from "@/session/status" +import { pollWithTimeout } from "../lib/effect" +import { withIdleAdmission } from "../lib/session-prompt" + +interface PromptGate { + readonly title: string + readonly release: Deferred.Deferred<string> +} + +function node(id: string, dependsOn: string[] = [], condition?: string): NodeConfig { + return { + id, + name: id, + worker_type: "build", + depends_on: dependsOn, + required: true, + prompt_template: { inline: id }, + report_to_parent: false, + ...(condition ? { condition } : {}), + } +} + +function reply(sessionID: string, text: string): SessionV1.WithParts { + return { + info: { + id: MessageID.ascending(), + role: "assistant", + sessionID: sessionID as never, + time: { created: Date.now() }, + }, + parts: [{ type: "text", text }] as never, + } as never +} + +function takeWithin<A>(queue: Queue.Queue<A>, message: string) { + return Queue.take(queue).pipe( + Effect.timeoutOption("2 seconds"), + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(new Error(message)), + onSome: Effect.succeed, + }), + ), + ) +} + +function stepLayer(childPrompts: Queue.Queue<PromptGate>) { + const database = Database.layerFromPath(":memory:") + const events = EventV2.layer.pipe(Layer.provide(database)) + const bridge = EventV2Bridge.layer.pipe(Layer.provide(events)) + const store = DagStore.layer.pipe(Layer.provide(database)) + const status = SessionStatus.layer.pipe(Layer.provide(bridge)) + const projector = DagProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) + const dag = Dag.layer.pipe(Layer.provide(bridge), Layer.provide(store)) + const base = Layer.mergeAll(database, events, bridge, store, projector, dag, status) + const titles = new Map<string, string>() + let created = 0 + const session = Layer.mock(Session.Service, { + get: () => Effect.succeed({ id: "ses_parent", permission: [], agent: "build" } as never), + create: (value) => + Effect.sync(() => { + const id = `ses_child_${++created}` + titles.set(id, (value?.title ?? id).replace(" (DAG node)", "")) + return { id } as never + }), + messages: () => Effect.succeed([]), + }) + const deliver = Effect.fn("test.SessionPrompt.deliver")(function* (value: SessionPrompt.PromptInput) { + const sessionID = value.sessionID as string + if (sessionID === "ses_parent") return reply(sessionID, "parent handled wake") + const release = yield* Deferred.make<string>() + yield* Queue.offer(childPrompts, { title: titles.get(sessionID) ?? sessionID, release }) + return reply(sessionID, yield* Deferred.await(release)) + }) + const prompt = Layer.mock( + SessionPrompt.Service, + withIdleAdmission({ + cancel: () => Effect.void, + prompt: deliver, + promptIfIdle: (value) => deliver(value).pipe(Effect.map(Option.some)), + }), + ) + const agent = Layer.mock(Agent.Service, { + get: () => + Effect.succeed({ + name: "build", + mode: "all", + permission: [], + options: {}, + description: "", + prompt: "", + model: { providerID: "test" as never, modelID: "test-model" as never }, + tools: {}, + hooks: {}, + }), + }) + const loop = DagLoop.layer.pipe( + Layer.provide(base), + Layer.provide(session), + Layer.provide(prompt), + Layer.provide(agent), + ) + return Layer.merge(base, loop) +} + +function runStepTest<A>( + test: (services: { + readonly dag: Dag.Interface + readonly loop: DagLoop.Interface + readonly store: DagStore.Interface + readonly childPrompts: Queue.Queue<PromptGate> + }) => Effect.Effect<A, Error>, +) { + return Effect.gen(function* () { + const childPrompts = yield* Queue.unbounded<PromptGate>() + return yield* Effect.gen(function* () { + const dag = yield* Dag.Service + const loop = yield* DagLoop.Service + const store = yield* DagStore.Service + const database = yield* Database.Service + yield* database.db + .insert(ProjectTable) + .values({ id: "project-1" as never, worktree: process.cwd() as never, sandboxes: [] }) + .run() + .pipe(Effect.orDie) + yield* database.db + .insert(SessionTable) + .values({ + id: "ses_parent" as never, + project_id: "project-1" as never, + slug: "parent", + directory: process.cwd() as never, + title: "Parent", + version: "test", + }) + .run() + .pipe(Effect.orDie) + return yield* test({ dag, loop, store, childPrompts }) + }).pipe( + Effect.provide(stepLayer(childPrompts)), + Effect.provideService(InstanceRef, { + directory: process.cwd(), + worktree: process.cwd(), + project: { id: "project-1" }, + } as never), + Effect.scoped, + ) + }) +} + +function createGatedWorkflow(dag: Dag.Interface, withSibling = false) { + return dag.create({ + projectID: "project-1", + sessionID: "ses_parent", + title: "Step convergence", + config: { + name: "step-convergence", + nodes: [node("p"), node("a", ["p"], 'p.output == "yes"'), node("b", ["a"]), ...(withSibling ? [node("c")] : [])], + }, + }) +} + +function completeParent(dag: Dag.Interface, dagID: string) { + return Effect.gen(function* () { + yield* dag.nodeQueued(dagID, "p") + yield* dag.nodeStarted(dagID, "p", "ses_parent_result") + yield* dag.nodeCompleted(dagID, "p", "no") + }) +} + +describe("Dag single-step skip convergence", () => { + it("converges condition-false and dependent skips to workflow completion in one step", async () => { + await Effect.runPromise( + runStepTest(({ dag, loop, store, childPrompts }) => + Effect.gen(function* () { + const dagID = yield* createGatedWorkflow(dag) + yield* completeParent(dag, dagID) + + // Reproduce a step event that occurred before the loop subscribed. + expect(yield* dag.step(dagID)).toEqual({ status: "stepping", nodeID: "a" }) + yield* loop.init() + expect(yield* dag.step(dagID)).toEqual({ status: "stepping", nodeID: "a" }) + + const settled = yield* pollWithTimeout( + Effect.gen(function* () { + const workflow = yield* store.getWorkflow(dagID) + const a = yield* store.getNode(dagID, "a") + const b = yield* store.getNode(dagID, "b") + return workflow?.status === "completed" ? { a, b } : undefined + }), + "step did not converge the skipped branch to completion", + ) + expect(settled.a?.status).toBe("skipped") + expect(settled.b?.status).toBe("skipped") + expect(Option.isNone(yield* Queue.poll(childPrompts))).toBe(true) + }), + ), + ) + }) + + it("uses a later step to recover an already-stranded skip cascade", async () => { + await Effect.runPromise( + runStepTest(({ dag, loop, store, childPrompts }) => + Effect.gen(function* () { + const dagID = yield* createGatedWorkflow(dag) + yield* completeParent(dag, dagID) + yield* dag.nodeSkipped(dagID, "a", "condition_false") + + expect(yield* dag.step(dagID)).toEqual({ status: "stepping", nodeID: "b" }) + yield* loop.init() + expect(yield* dag.step(dagID)).toEqual({ status: "stepping", nodeID: "b" }) + + yield* pollWithTimeout( + Effect.gen(function* () { + const workflow = yield* store.getWorkflow(dagID) + const b = yield* store.getNode(dagID, "b") + return workflow?.status === "completed" && b?.status === "skipped" ? true : undefined + }), + "repeated step did not recover the stranded skip cascade", + ) + expect(Option.isNone(yield* Queue.poll(childPrompts))).toBe(true) + }), + ), + ) + }) + + it("does not dispatch a runnable sibling while the selected skip branch converges", async () => { + await Effect.runPromise( + runStepTest(({ dag, loop, store, childPrompts }) => + Effect.gen(function* () { + const dagID = yield* createGatedWorkflow(dag, true) + yield* completeParent(dag, dagID) + expect(yield* dag.step(dagID)).toEqual({ status: "stepping", nodeID: "a" }) + yield* loop.init() + + expect(yield* dag.step(dagID)).toEqual({ status: "stepping", nodeID: "a" }) + yield* pollWithTimeout( + Effect.gen(function* () { + const a = yield* store.getNode(dagID, "a") + const b = yield* store.getNode(dagID, "b") + return a?.status === "skipped" && b?.status === "skipped" ? true : undefined + }), + "selected skip branch did not converge", + ) + expect((yield* store.getNode(dagID, "c"))?.status).toBe("pending") + expect(Option.isNone(yield* Queue.poll(childPrompts))).toBe(true) + + expect(yield* dag.step(dagID)).toEqual({ status: "stepping", nodeID: "c" }) + const child = yield* takeWithin(childPrompts, "runnable sibling did not start on its own step") + expect(child.title).toBe("c") + expect(Option.isNone(yield* Queue.poll(childPrompts))).toBe(true) + yield* Deferred.succeed(child.release, "done") + yield* pollWithTimeout( + Effect.gen(function* () { + const workflow = yield* store.getWorkflow(dagID) + return workflow?.status === "completed" ? true : undefined + }), + "workflow did not complete after the single dispatched sibling", + ) + }), + ), + ) + }) +}) diff --git a/packages/opencode/test/dag/dag-structured-output.test.ts b/packages/opencode/test/dag/dag-structured-output.test.ts index 2bc0175f4..211fdc3a6 100644 --- a/packages/opencode/test/dag/dag-structured-output.test.ts +++ b/packages/opencode/test/dag/dag-structured-output.test.ts @@ -22,7 +22,10 @@ function makeEventTracker() { const storeStub: Partial<DagStore.Interface> = { tryClaimAdoption: () => Effect.succeed(true), getNode: Effect.fn("s")((_workflowID: string, nodeID: string) => - Effect.sync(() => ({ ...makeNodeRow({ id: nodeID }), capturedOutput: capturedStore.get(nodeID) }))), + Effect.sync(() => ({ + ...makeNodeRow({ id: nodeID, status: "running", childSessionId: "ses_child" }), + capturedOutput: capturedStore.get(nodeID), + }))), setCapturedOutput: Effect.fn("s")((_childSessionID: string, payload: unknown) => Effect.sync(() => { capturedStore.set("node-1", payload) })), } diff --git a/packages/opencode/test/dag/dag-timeout-escalation.test.ts b/packages/opencode/test/dag/dag-timeout-escalation.test.ts index 0167ab318..a97407f86 100644 --- a/packages/opencode/test/dag/dag-timeout-escalation.test.ts +++ b/packages/opencode/test/dag/dag-timeout-escalation.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "bun:test" +import { eq } from "drizzle-orm" import { Deferred, Effect, Layer, Option, Queue } from "effect" import type { SessionV1 } from "@opencode-ai/core/v1/session" import { Database } from "@opencode-ai/core/database/database" import { DagProjector } from "@opencode-ai/core/dag/projector" +import { WorkflowTable } from "@opencode-ai/core/dag/sql" import { DagStore } from "@opencode-ai/core/dag/store" import { EventV2 } from "@opencode-ai/core/event" import { Project } from "@opencode-ai/core/project" @@ -12,7 +14,7 @@ import { SessionTable } from "@opencode-ai/core/session/sql" import { Model } from "@opencode-ai/schema/model" import { Provider } from "@opencode-ai/schema/provider" import { Agent } from "@/agent/agent" -import { Dag, type NodeConfig } from "@/dag/dag" +import { Dag, type NodeConfig, parseWorkflowConfig } from "@/dag/dag" import { DagLoop } from "@/dag/runtime/loop" import { InstanceRef } from "@/effect/instance-ref" import { EventV2Bridge } from "@/event-v2-bridge" @@ -203,6 +205,11 @@ function runLoopTest<A>( opts?: { readonly nodeExtendTimeout?: (dagID: string, nodeID: string, newDeadlineMs: number) => Effect.Effect<number, Error> readonly nodeTimeoutEscalated?: (dagID: string, nodeID: string, childSessionID: string, timeoutExtensions: number) => Effect.Effect<void, Error> + readonly beforeInit?: (services: { + readonly dag: Dag.Interface + readonly store: DagStore.Interface + readonly database: Database.Interface + }) => Effect.Effect<void, Error> }, ) { return Effect.gen(function* () { @@ -228,6 +235,7 @@ function runLoopTest<A>( title: "Parent", version: "test", }).run().pipe(Effect.orDie) + if (opts?.beforeInit) yield* opts.beforeInit({ dag, store, database }) yield* loop.init() return yield* test({ dag, @@ -596,6 +604,89 @@ describe("DagLoop timeout escalation", () => { ) }) + it("does not re-time a legacy running node when the fragment omits its inherited timeout", async () => { + let dagID = "" + let extendCalls = 0 + await Effect.runPromise( + runLoopTest( + ({ dag, store, childPrompts, parentPrompts }) => + Effect.gen(function* () { + const first = yield* takeWithin(childPrompts, "legacy node did not start") + expect(first.title).toBe("a") + const running = yield* pollWithTimeout( + store.getNode(dagID, "a").pipe( + Effect.map((current) => + current?.status === "running" && current.childSessionId ? current : undefined, + ), + ), + "legacy node did not reach running", + ) + const deadlineBefore = running.deadlineMs + + // Put the node at the exact adjudication boundary where a real + // timeout change is allowed to re-time it, then prove an omitted + // inherited timeout is still treated as unchanged. + yield* dag.nodeTimeoutEscalated( + dagID, + "a", + running.childSessionId!, + running.timeoutExtensions + 1, + ) + const wake = yield* takeWithin(parentPrompts, "legacy timeout wake did not reach the parent") + yield* Deferred.succeed(wake.release, "success") + yield* pollWithTimeout( + store.getNode(dagID, "a").pipe( + Effect.map((current) => + current?.escalationPending && current.wakeReported ? current : undefined, + ), + ), + "legacy timeout wake was not durably delivered", + ) + + yield* dag.replan(dagID, { nodes: [node("a"), node("b")] }) + const second = yield* takeWithin(childPrompts, "added node did not start after replan") + expect(second.title).toBe("b") + expect(extendCalls).toBe(0) + expect((yield* store.getNode(dagID, "a"))?.deadlineMs).toBe(deadlineBefore) + }), + { + nodeExtendTimeout: () => + Effect.sync(() => { + extendCalls++ + return 1 + }), + beforeInit: ({ dag, store, database }) => + Effect.gen(function* () { + dagID = yield* dag.create({ + projectID: "project-1", + sessionID: "ses_parent", + title: "Inherited timeout omission", + config: { + name: "inherited-timeout", + max_concurrency: 2, + node_defaults: { worker_config: { timeout_ms: 1_200 } }, + nodes: [node("a")], + }, + }) + const workflow = + (yield* store.getWorkflow(dagID)) + ?? (yield* Effect.fail(new Error("workflow was not created"))) + const legacy = + parseWorkflowConfig(workflow.config) + ?? (yield* Effect.fail(new Error("workflow config was not readable"))) + delete legacy.nodes[0]?.worker_config + yield* database.db + .update(WorkflowTable) + .set({ config: JSON.stringify(legacy) }) + .where(eq(WorkflowTable.id, dagID)) + .run() + .pipe(Effect.orDie) + }), + }, + ), + ) + }) + it("refuses a pre-escalation re-time (A1: proactive re-time cannot bypass the cap) but admits it once escalated", async () => { await Effect.runPromise( runLoopTest(({ dag, store, childPrompts, parentPrompts }) => diff --git a/packages/opencode/test/dag/dag-workflow-lock.test.ts b/packages/opencode/test/dag/dag-workflow-lock.test.ts index 60c07b685..e8f709af1 100644 --- a/packages/opencode/test/dag/dag-workflow-lock.test.ts +++ b/packages/opencode/test/dag/dag-workflow-lock.test.ts @@ -31,6 +31,7 @@ const lockTimeoutLayer = (getWorkflow: () => Effect.Effect<unknown>) => { EventV2Bridge.Service, EventV2Bridge.Service.of({ publish: () => Effect.succeed({ seq: 1 }), + publishMany: () => Effect.succeed([]), } as never), ) return Layer.mergeAll( @@ -66,6 +67,7 @@ describe("Dag.Service workflow lock", () => { EventV2Bridge.Service, EventV2Bridge.Service.of({ publish: () => Effect.succeed({ seq: 1 }), + publishMany: () => Effect.succeed([]), } as never), ) const layer = Dag.layer.pipe(Layer.provide(events), Layer.provide(store)) diff --git a/packages/opencode/test/dag/spawn-completion.test.ts b/packages/opencode/test/dag/spawn-completion.test.ts index 799379e39..2c7dcd468 100644 --- a/packages/opencode/test/dag/spawn-completion.test.ts +++ b/packages/opencode/test/dag/spawn-completion.test.ts @@ -11,7 +11,14 @@ import { spawnNode, type NodeSpawnInput } from "@/dag/runtime/spawn" import { TerminalViolationError } from "@opencode-ai/core/dag/core/types" import { makeNodeRow } from "./fixtures" -type TrackedEvent = { type: string; dagID: string; nodeID: string; output?: unknown; reason?: string } +type TrackedEvent = { + type: string + dagID: string + nodeID: string + output?: unknown + reason?: string + trigger?: string +} function makeEventTracker() { const events: TrackedEvent[] = [] @@ -26,8 +33,8 @@ function makeEventTracker() { nodeCompleted: Effect.fn("stub.nodeCompleted")((dagID: string, nodeID: string, output: unknown) => Effect.sync(() => events.push({ type: "nodeCompleted", dagID, nodeID, output })), ), - nodeFailed: Effect.fn("stub.nodeFailed")((dagID: string, nodeID: string, reason: string) => - Effect.sync(() => events.push({ type: "nodeFailed", dagID, nodeID, reason })), + nodeFailed: Effect.fn("stub.nodeFailed")((dagID: string, nodeID: string, reason: string, trigger: string) => + Effect.sync(() => events.push({ type: "nodeFailed", dagID, nodeID, reason, trigger })), ), }) return { events, dagLayer } @@ -50,7 +57,7 @@ const sessionLayer = Layer.mock(Session.Service, { messages: () => Effect.succeed([]), }) -function reply(text: string): SessionV1.WithParts { +function reply(text: string, includeText = text !== ""): SessionV1.WithParts { return { info: { id: MessageID.ascending(), role: "assistant", parentID: MessageID.ascending(), @@ -60,7 +67,7 @@ function reply(text: string): SessionV1.WithParts { modelID: "test-model" as never, providerID: "test" as never, time: { created: Date.now() }, finish: "stop", }, - parts: text ? [{ type: "text", text }] as never : [], + parts: includeText ? [{ type: "text", text }] as never : [], } } @@ -232,12 +239,35 @@ describe("spawnNode completion bridge", () => { expect(started).toBeDefined() }) - it("publishes NodeFailed when the provider returns no text output", async () => { + it("publishes verdict_fail when the provider returns no text output", async () => { const { events, dagLayer } = makeEventTracker() await runSpawn(dagLayer, makePromptLayer(reply(""))) expect(findEvent(events, "nodeCompleted")).toBeUndefined() - expect(findEvent(events, "nodeFailed")?.reason).toContain("empty output") + expect(findEvent(events, "nodeFailed")).toEqual(expect.objectContaining({ + reason: "provider returned empty output", + trigger: "verdict_fail", + })) + }) + + it("classifies empty and whitespace-only text parts like missing text", async () => { + for (const text of ["", " \n\t "]) { + const { events, dagLayer } = makeEventTracker() + await runSpawn(dagLayer, makePromptLayer(reply(text, true))) + expect(findEvent(events, "nodeCompleted")).toBeUndefined() + expect(findEvent(events, "nodeFailed")).toEqual(expect.objectContaining({ + reason: "provider returned empty output", + trigger: "verdict_fail", + })) + } + }) + + it("preserves non-empty text byte-for-byte", async () => { + const { events, dagLayer } = makeEventTracker() + const text = "\n exact output\t" + await runSpawn(dagLayer, makePromptLayer(reply(text, true))) + + expect(findEvent(events, "nodeCompleted")?.output).toBe(text) }) it("publishes NodeFailed when prompt fails", async () => { diff --git a/packages/opencode/test/dag/workflow-tool.test.ts b/packages/opencode/test/dag/workflow-tool.test.ts index aed9d638a..d371e2f9c 100644 --- a/packages/opencode/test/dag/workflow-tool.test.ts +++ b/packages/opencode/test/dag/workflow-tool.test.ts @@ -202,6 +202,18 @@ const mockNodes = (id: string) => ] : [] +const mockWorkflowConfig = (id: string) => JSON.stringify({ + name: id, + nodes: mockNodes(id).map((node) => ({ + id: node.id, + name: node.name, + worker_type: node.workerType, + depends_on: node.dependsOn, + required: node.required, + prompt_template: { inline: node.name }, + })), +}) + const store = Layer.mock(DagStore.Service, { getWorkflow: (id: string) => Effect.succeed( @@ -213,7 +225,7 @@ const store = Layer.mock(DagStore.Service, { title: "Status workflow", directory: null, status: "running", - config: "{}", + config: mockWorkflowConfig(id), seq: 1, wakeReported: false, graphRev: 1, @@ -230,7 +242,7 @@ const store = Layer.mock(DagStore.Service, { title: "Result workflow", directory: null, status: "completed", - config: "{}", + config: mockWorkflowConfig(id), seq: 1, wakeReported: true, graphRev: 1, @@ -247,7 +259,7 @@ const store = Layer.mock(DagStore.Service, { title: "Control workflow", directory: null, status: id === "dag_paused" ? "paused" : "running", - config: "{}", + config: mockWorkflowConfig(id), seq: 1, wakeReported: false, graphRev: 1, @@ -323,6 +335,10 @@ const store = Layer.mock(DagStore.Service, { Effect.succeed(resultNodes.find((node) => node.workflowId === workflowID && node.id === nodeID)), }) const events = Layer.mock(EventV2Bridge.Service, { + publishMany: (entries) => Effect.sync(() => { + for (const entry of entries) published.push({ type: entry.definition.type, data: entry.data }) + return [] + }), publish: (definition, data) => Effect.sync(() => { published.push({ type: definition.type, data }) From 287b6f51d02b6ec568b364b5d1b0bffaccdbcaf6 Mon Sep 17 00:00:00 2001 From: Lex <sunsan05@Gmail.com> Date: Sat, 5 Sep 2026 07:58:33 +0800 Subject: [PATCH 5/6] fix(specgit): preserve the full bootstrap reconciliation surface Support body-file arguments and align local delivery guidance with SpecGit 1.13.1. The offline bootstrap suite passes all 283 assertions. Refs #552. --- AGENTS.md | 18 +++--- script/specgit-bootstrap.sh | 30 ++++++---- script/specgit-bootstrap.test.sh | 100 +++++++++++++++++++++++++++++-- 3 files changed, 121 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0a9e2fe6b..65c4c4d79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ feat/**, fix/** ──PR(Typecheck + Unit Tests 门禁)──▶ dev ──push **CI 配置**: - `ci-typecheck.yml`:push 到 `main`/`dev` + PR → `main`/`dev` 时触发;除 lint + typecheck 外还跑 `test:dag-core` DAG 核心行为/覆盖率门禁(10min 超时) - `ci-test.yml`:push 到 `main`/`dev` + PR → `main`/`dev` 时触发全量测试(`cancel-in-progress: false` 保证跑完);Linux unit-tests job 额外校验生成物新鲜度(`packages/client` 与 `packages/sdk/js` 的 `check:generated`)并跑 HttpAPI 契约门禁(`test:httpapi:ci`) -- `specgit-accept.yml`:仅 PR → `main` 时触发;全局安装 `specgit@1.10.1`(`npm install -g`;workspace 内安装会因 bun `catalog:` 协议失败),等 `spec_git/policy.yaml` `required_checks` 全部到终态(等待脚本手工解析 YAML,不依赖 `yaml` 包)后运行 `specgit finish --json` 产出 SpecGit Acceptance 裁决 +- `specgit-accept.yml`:PR → `main` 和手动 dispatch 时运行当前提交的 SpecGit Acceptance;CLI 隔离安装、分支恢复和等待预算见下方 "SpecGit harness local specializations"。 - `release-fork.yml`:手动 `workflow_dispatch` 是唯一真实构建路径(push 到 `main`/`dev` 仅注册不构建);从 `dev` 发布自动产出 `X.Y.Z-dev.N` prerelease,从 `main` 发布 `X.Y.Z` 并标 Latest ## Standard Delivery Workflow (标准交付流程) @@ -43,7 +43,7 @@ feat/**, fix/** ──PR(Typecheck + Unit Tests 门禁)──▶ dev ──push 1. **确立条目**:明确条目的内容、范围、类型(`feat`/`fix`/…)。一个 issue = 一个可独立验证的 WHY,无法独立验证的先拆分再立项。 2. **SpecGit 立项**:`script/specgit-bootstrap.sh <title-or-number>` 创建/复用 issues 批次,确立交付分支与草稿 PR 脚手架(`.specgit.yaml` 绑定);立项前先查重,避免同一 WHY 双开。wrapper 是 canonical 入口(见 "SpecGit harness local specializations");直跑裸 `specgit issue` 预期被 harness currency gate 以 `harness_stale` (exit 2) 拒绝。 3. **超流执行**:安排 DAG workflow(超流)承载实现——并行开发 + 多角度 Review + 复合(synthesize),其产出作为交付证据基线。 -4. **PR 过门禁**:SpecGit 发起/推进 PR,过 TDD 与 CI 门禁(Typecheck、Unit Tests、DAG gate;`specgit finish` exit 0 是唯一 "done")。 +4. **PR 过门禁**:SpecGit 发起/推进 PR,过 TDD 与 CI 门禁(Typecheck、Unit Tests、DAG gate);`specgit finish` exit 0 表示 accepted,合并和所有绑定 issue 关闭后才完成交付。 5. **修复门禁问题**:门禁失败在交付分支修代码/测试,永远不削弱门禁本身。 6. **合并收尾**:完成 PR 合并(目标分支遵循 Git Workflow,dev 为集成层),PR 正文 `Closes #n` 自动关闭绑定 issues;版本确立与发布按 release train 既有节奏推进。 @@ -264,20 +264,18 @@ This repository uses a multi-context domain-document layout rooted at `CONTEXT-M ### SpecGit harness local specializations -Kept OUTSIDE the managed block so `specgit init`/`--force` never rewrites them; re-apply each deviation after every re-init: +Kept outside the managed block so routine bootstrap preserves the reviewed harness. The 1.13.1 refresh in #552 replaces the former 1.10.1 workarounds: -- `specgit-accept.yml` drops the template's `workflow_dispatch` trigger. Dispatch is the privileged context that fires CodeQL's cache-poisoning taint rule on the `head_ref` checkout (false positive: no cache use, read-only token, `persist-credentials: false`), and on dispatch events `head_ref` is empty so the verdict would evaluate the default branch — the wrong tree. Delivery here always goes through a PR. The head-ref checkout itself must NOT be replaced with a SHA: `specgit finish` requires HEAD on the delivery branch (detached_head otherwise). -- `specgit-accept.yml` installs the CLI globally (`npm install -g specgit@1.10.1`), NOT the template's `npm install --no-save specgit@X`: a workspace-local install reads this bun workspace's package.json and dies on the `catalog:` protocol (EUNSUPPORTEDPROTOCOL, #434, again on #459). Re-apply after every `specgit init --force`. -- `specgit-accept.yml` runs the job with `timeout-minutes: 45` while the wait script's own poll deadline is 40 minutes (`Date.now() + 40 * 60 * 1000`): the deadline must stay BELOW the job timeout so a lost race against a slow sibling check exits with its own diagnosis instead of being killed mid-line by the job timeout. -- `specgit-accept.yml` pins `node-version: '22'` for the wait script and the CLI. -- The wait script hand-parses `spec_git/policy.yaml` (minimal line-based parse) instead of importing the `yaml` package: no root-reachable `yaml` exists under workspace catalog isolation, so `import { parse } from 'yaml'` would fail to resolve on the runner. -- `spec_git/policy.yaml` `required_checks` uses the template's canonical check IDs (`unit-tests`, `e2e-tests`), not display names. +- Use the generated event-SHA checkout and branch-restoration steps together: the verdict evaluates the triggering commit while SpecGit sees the delivery branch. The generated dispatch path supplies its own ref and SHA. +- Install the pinned CLI under the generated isolated prefix, outside this Bun workspace, and use its bundled YAML parser. This avoids the workspace `catalog:` installation failure and root-package resolution assumptions. Keep the generated Node version with that CLI. +- Preserve the repository's 45-minute job timeout and 40-minute sibling-check deadline. The inner deadline stays below the job timeout so slow or missing checks produce a verdict diagnosis. This is the remaining template deviation; the bootstrap wrapper restores it after transient initialization. +- Read check identities and automation choices from `spec_git/policy.yaml`. Keep the configured checks and current-head ownership checks intact; a harness refresh does not authorize enabling automatic merge. #### specgit-bootstrap wrapper (canonical `specgit issue` entry, #521) `script/specgit-bootstrap.sh <specgit issue args...>` is THE canonical way to run `specgit issue` in this repository. Bare `specgit issue` is expected to fail with `harness_stale` (exit 2) whenever the pinned CLI's harness template moves — the wrapper satisfies that gate safely: it snapshots the full init write surface to a temp dir outside the repo, runs `specgit init --force --no-protect` (hardcoded, offline), then `specgit issue "$@"` with arguments, exit status, and diagnostics passed through verbatim, and restores the specialized bytes above on success and every failure path (EXIT/INT/TERM/HUP), verifying each file byte-for-byte via `git hash-object`. -- Never run bare `specgit init --force` here: it overwrites the six specialized bytes; the wrapper exists to make that refresh transient. +- Use the wrapper for ordinary issue bootstrap. Intentional CLI/harness upgrades are tracked changes: review the generated diff and update these specializations together, while preserving the timeout budget and policy. - Fail-closed rejections: dirty write-surface paths (tracked/staged/untracked) → exit 2 with the offending paths listed; no SpecGit binding (`.specgit.yaml` or `spec_git/policy.yaml` missing) → exit 3; restore hash mismatch → exit 3 with the snapshot kept for forensics. Rejection paths print plain `specgit-bootstrap:` stderr lines and NEVER produce a `--json` envelope. - The inner `.specgit.yaml` delivery record is rolled back to its pre-run bytes when the inner `specgit issue` exits nonzero (or a signal/init failure interrupts); a successful call keeps the new binding. Record-restore failure keeps the forensic snapshot and exits 3, overriding the inner exit code. Branches, commits, and remote side effects are never undone (#530). - Managed-block guidance referencing bare `specgit issue` commands is superseded by this section for this repository. Behavior tests: `bash script/specgit-bootstrap.test.sh` (stubbed CLI, zero network; not CI-wired). diff --git a/script/specgit-bootstrap.sh b/script/specgit-bootstrap.sh index 1037d60d6..cf62c0c0a 100755 --- a/script/specgit-bootstrap.sh +++ b/script/specgit-bootstrap.sh @@ -1,9 +1,9 @@ #!/bin/sh # specgit-bootstrap — repository-local fail-safe wrapper around `specgit issue` (#521). # -# Why: `specgit issue` (1.10.1) runs an unconditional harness-currency gate that +# Why: `specgit issue` (1.13.1) runs an unconditional harness-currency gate that # exits 2 (`harness_stale`) unless the managed harness was refreshed by -# `specgit init --force`. But `init --force` overwrites this repository's six +# `specgit init --force`. But `init --force` overwrites this repository's # hand-applied specializations (see AGENTS.md, "SpecGit harness local # specializations"). This wrapper makes the refresh safe: # @@ -41,19 +41,27 @@ # # Usage: script/specgit-bootstrap.sh <specgit issue args...> # -# Write surface below mirrors specgit 1.10.1 harness-placement; it is +# Write surface below mirrors specgit 1.13.1 init reconciliation; it is # version-coupled to the pinned CLI in .github/workflows/specgit-accept.yml. +# Generated command and portable-skill entry points belong to `specgit setup`, +# which this wrapper does not invoke. set -u SURFACE=' .github/workflows/specgit-accept.yml +.github/workflows/specgit-complete.yml +.gitlab-ci.yml +.gitlab/specgit-business.yml +.gitlab/specgit-complete.yml +.gitignore AGENTS.md CLAUDE.md .opencode/hooks.json .opencode/hooks/specgit-merge-guard.sh .git/hooks/pre-push .husky/_/pre-push +spec_git/policy.yaml ' say() { @@ -104,12 +112,12 @@ pf_hint() { say "edit the title type to an allowed one, then re-run (no type mapping is performed)" } -# Scans the wrapped argv left to right, mirroring the specgit 1.10.1 option -# grammar: --delivery/--tags consume one value each, --name= carries it -# inline, -- ends options, and -h/--help is answered by the CLI without -# operand validation. Positional operands are issue numbers (pure digits - -# the reuse path, "007" -> 7) or titles that must start with -# '<allowed type>: '. The first violation exits 2. +# Scans the wrapped argv left to right, mirroring the specgit 1.13.1 option +# grammar: --delivery/--tags/--body-file/--pr-body-file consume one value +# each, --name= carries it inline, -- ends options, and -h/--help is answered +# by the CLI without operand validation. Positional operands are issue +# numbers (pure digits - the reuse path, "007" -> 7) or titles that must +# start with '<allowed type>: '. The first violation exits 2. pf_check_issue_titles() { pf_seen_dd=0 pf_swallow=0 @@ -124,11 +132,11 @@ pf_check_issue_titles() { pf_seen_dd=1 continue ;; - --delivery|--tags) + --delivery|--tags|--body-file|--pr-body-file) pf_swallow=1 continue ;; - --delivery=*|--tags=*) + --delivery=*|--tags=*|--body-file=*|--pr-body-file=*) continue ;; -h|--help) diff --git a/script/specgit-bootstrap.test.sh b/script/specgit-bootstrap.test.sh index 51820179a..a2dd0ffbe 100755 --- a/script/specgit-bootstrap.test.sh +++ b/script/specgit-bootstrap.test.sh @@ -31,10 +31,18 @@ fi SURFACE_FILES=( .github/workflows/specgit-accept.yml + .github/workflows/specgit-complete.yml + .gitlab-ci.yml + .gitlab/specgit-business.yml + .gitlab/specgit-complete.yml + .gitignore AGENTS.md + CLAUDE.md .opencode/hooks.json .opencode/hooks/specgit-merge-guard.sh .git/hooks/pre-push + .husky/_/pre-push + spec_git/policy.yaml ) ok() { PASS=$((PASS + 1)); printf 'ok - %s\n' "$1"; } @@ -61,11 +69,12 @@ assert_rc() { # <expected> <actual> <label> new_fixture() { # <name> [no-binding] local fx="$WORK/$1" - mkdir -p "$fx/.github/workflows" "$fx/.opencode/hooks" "$fx/spec_git" "$fx/.git/hooks" + mkdir -p "$fx/.github/workflows" "$fx/.gitlab" "$fx/.opencode/hooks" "$fx/spec_git" "$fx/.git/hooks" "$fx/.husky/_" git -C "$fx" -c init.defaultBranch=main init -q git -C "$fx" config user.email test@example.invalid git -C "$fx" config user.name test printf 'version: 1\nkind: probe\n' > "$fx/spec_git/policy.yaml" + printf '# fixture ignore rules\n' > "$fx/.gitignore" # pr: 535 mirrors a bound repo mid-delivery (resume): default-mode inner # successes carry a verifiable PR through #528 verification via the gh stub. printf 'version: 1\nbranch: feat/probe\nissues: []\npr: 535\n' > "$fx/.specgit.yaml" @@ -74,10 +83,16 @@ new_fixture() { # <name> [no-binding] spec-a: drop-dispatch spec-b: global-install YAML + printf '# Managed by SpecGit: trusted delivery completion.\n' > "$fx/.github/workflows/specgit-complete.yml" + printf '# Managed by SpecGit: isolated GitLab routing.\n' > "$fx/.gitlab-ci.yml" + printf '# fixture GitLab business workflow\n' > "$fx/.gitlab/specgit-business.yml" + printf '# Managed by SpecGit: include in a trusted default-branch pipeline.\n' > "$fx/.gitlab/specgit-complete.yml" printf '# fixture agents\n' > "$fx/AGENTS.md" + printf '# fixture claude instructions\n' > "$fx/CLAUDE.md" printf '{\n "hooks": []\n}\n' > "$fx/.opencode/hooks.json" printf '#!/bin/sh\nexit 0\n' > "$fx/.opencode/hooks/specgit-merge-guard.sh" printf '#!/bin/sh\nexit 0\n' > "$fx/.git/hooks/pre-push" + printf '#!/bin/sh\nexit 0\n' > "$fx/.husky/_/pre-push" git -C "$fx" add -A git -C "$fx" commit -qm "fixture $1" if [ "${2:-}" = "no-binding" ]; then @@ -104,14 +119,48 @@ cmd="${1:-}" case "$cmd" in init) printf 'specgit init: refreshing harness files\n' - printf '\n# canonical harness refresh (stub init)\n' >> .github/workflows/specgit-accept.yml + while IFS= read -r path; do + [ -n "$path" ] || continue + printf '\n# canonical harness refresh (stub init)\n' >> "$path" + done <<'SURFACE' +.github/workflows/specgit-accept.yml +.github/workflows/specgit-complete.yml +.gitlab-ci.yml +.gitlab/specgit-business.yml +.gitlab/specgit-complete.yml +.gitignore +AGENTS.md +CLAUDE.md +.opencode/hooks.json +.opencode/hooks/specgit-merge-guard.sh +.git/hooks/pre-push +.husky/_/pre-push +spec_git/policy.yaml +SURFACE exit "${STUB_INIT_EXIT:-0}" ;; issue) - grep -q 'canonical harness refresh' .github/workflows/specgit-accept.yml || { - printf 'stub: refreshed harness bytes were not visible to the inner CLI\n' >&2 - exit 99 - } + while IFS= read -r path; do + [ -n "$path" ] || continue + grep -q 'canonical harness refresh' "$path" || { + printf 'stub: refreshed harness bytes were not visible in %s\n' "$path" >&2 + exit 99 + } + done <<'SURFACE' +.github/workflows/specgit-accept.yml +.github/workflows/specgit-complete.yml +.gitlab-ci.yml +.gitlab/specgit-business.yml +.gitlab/specgit-complete.yml +.gitignore +AGENTS.md +CLAUDE.md +.opencode/hooks.json +.opencode/hooks/specgit-merge-guard.sh +.git/hooks/pre-push +.husky/_/pre-push +spec_git/policy.yaml +SURFACE case "${STUB_ISSUE_MODE:-exit}" in exit) [ "${1:-}" = "--json" ] && printf '{"stub":"issue"}\n' @@ -639,6 +688,45 @@ rc=$? assert_rc 0 "$rc" && ok "c39: inline --tags= forms pass preflight" || bad "c39: exit $rc, want 0" assert_argv c39 +# ---- case 55 (#552): 1.13.1 body-file values are never parsed as titles ----- +new_fixture c55 +make_stub c55 +surface_digests "$WORK/c55" > "$WORK/c55.baseline" +cp "$WORK/c55/.gitignore" "$WORK/c55.ignore-baseline" +cp "$WORK/c55/spec_git/policy.yaml" "$WORK/c55.policy-baseline" +want_argv "$WORK/c55.wantargv" --json --body-file "ci: first issue body.md" "fix: first" \ + --body-file="perf: second issue body.md" "docs: second" \ + --pr-body-file "dogfood: pull request body.md" --delivery=body-files +run_wrapper c55 --json --body-file "ci: first issue body.md" "fix: first" \ + --body-file="perf: second issue body.md" "docs: second" \ + --pr-body-file "dogfood: pull request body.md" --delivery=body-files +rc=$? +assert_rc 0 "$rc" && ok "c55: 1.13.1 body-file options pass preflight" || bad "c55: exit $rc, want 0" +assert_argv c55 +assert_surface_restored "$WORK/c55" "$WORK/c55.baseline" \ + && ok "c55: full 1.13.1 init surface restored" \ + || bad "c55: init surface bytes differ" +cmp -s "$WORK/c55/.gitignore" "$WORK/c55.ignore-baseline" \ + && cmp -s "$WORK/c55/spec_git/policy.yaml" "$WORK/c55.policy-baseline" \ + && ok "c55: .gitignore and policy restored byte-exactly" \ + || bad "c55: .gitignore or policy bytes differ" +assert_clean "$WORK/c55" && ok "c55: fixture clean" || bad "c55: fixture dirty" + +# ---- case 56 (#552): newly covered init assets participate in dirty guard --- +new_fixture c56 +make_stub c56 +printf '# dirty ignore rule\n' >> "$WORK/c56/.gitignore" +printf '# dirty policy note\n' >> "$WORK/c56/spec_git/policy.yaml" +run_wrapper c56 "fix: probe" +rc=$? +assert_rc 2 "$rc" && ok "c56: dirty new init assets exit 2" || bad "c56: exit $rc, want 2" +[ ! -s "$WORK/stubs/c56/log" ] \ + && ok "c56: dirty guard makes zero stub calls" \ + || bad "c56: stub was called: $(tr '\n' '|' < "$WORK/stubs/c56/log")" +grep -q '\.gitignore' "$WORK/c56.err" && grep -q 'spec_git/policy.yaml' "$WORK/c56.err" \ + && ok "c56: dirty diagnostic names both new init assets" \ + || bad "c56: dirty diagnostic omitted a new init asset: $(tr '\n' '|' < "$WORK/c56.err")" + # ---- case 40 (#529): operands after -- are still validated but pass --------- new_fixture c40 make_stub c40 From e742eec4cef4339e54c6c72aedd5dbe7a7594fdd Mon Sep 17 00:00:00 2001 From: Lex <sunsan05@Gmail.com> Date: Sat, 5 Sep 2026 08:01:18 +0800 Subject: [PATCH 6/6] fix(dag): retain committed event order through the bridge Consume the ordered event stream before layer acquisition completes and isolate forwarding failures. Cover a yielding prior listener and align the audit regressions with repository Effect conventions. Refs #545, #546, #547, #548, #549, #550, #551. --- packages/core/src/event.ts | 7 +- packages/opencode/src/dag/dag.ts | 4 +- .../opencode/src/dag/replan-definition.ts | 2 + .../opencode/src/dag/runtime/checkpoint.ts | 2 + packages/opencode/src/dag/runtime/loop.ts | 12 +- packages/opencode/src/event-v2-bridge.ts | 19 +- .../dag/dag-plain-text-settlement.test.ts | 52 +++--- .../test/dag/dag-replan-atomicity.test.ts | 74 +++++++- .../dag/dag-replan-definition-guard.test.ts | 4 +- .../test/dag/dag-step-convergence.test.ts | 169 +++++++++--------- .../test/dag/dag-timeout-escalation.test.ts | 4 +- 11 files changed, 207 insertions(+), 142 deletions(-) diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index d5ee2e775..d2e23c627 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -410,10 +410,9 @@ export const layerWith = (options?: LayerOptions) => // the next event's listeners. // Consumers that need ordered, lossless delivery must use `subscribe` / // `all` (synchronous FIFO pubsub in publish order) or the durable - // stream; `listen` is for synchronous side effects (GlobalBus.emit, - // SSE queue offer) and fire-and-forget work. All current listeners - // (EventV2Bridge, SSE handler, summary publisher, plugins, VCS/project - // watchers) are of this shape, so the fork is semantics-preserving. + // stream; `listen` is for independent side effects and + // fire-and-forget work. EventV2Bridge consumes `all` because its + // GlobalBus notifications must retain committed event order. function notify(event: Payload) { return Effect.gen(function* () { const snapshot = Array.from(listeners) diff --git a/packages/opencode/src/dag/dag.ts b/packages/opencode/src/dag/dag.ts index d1e306b42..5c7d10399 100644 --- a/packages/opencode/src/dag/dag.ts +++ b/packages/opencode/src/dag/dag.ts @@ -33,7 +33,7 @@ import { validateAdmission, } from "./admission" import { unresolvedReviewOutcomes } from "./review-lifecycle" -import { changedAdmittedNodeFields } from "./replan-definition" +import { ReplanDefinition } from "./replan-definition" import { DagValidation, StructuralValidationError } from "./validation" import { DagLocation } from "./location" import { SessionLocation } from "@/session/location" @@ -649,7 +649,7 @@ export const layer = Layer.effect( ) continue } - const fields = changedAdmittedNodeFields(normalizeNodeConfig(current, defaults), next, { + const fields = ReplanDefinition.changedAdmittedNodeFields(normalizeNodeConfig(current, defaults), next, { allowTimeoutUpdate: status === NodeStatus.RUNNING, }) if (fields.length === 0) continue diff --git a/packages/opencode/src/dag/replan-definition.ts b/packages/opencode/src/dag/replan-definition.ts index 762ae7ec8..92f405a96 100644 --- a/packages/opencode/src/dag/replan-definition.ts +++ b/packages/opencode/src/dag/replan-definition.ts @@ -68,3 +68,5 @@ export function changedAdmittedNodeFields<T extends object>( ) return changed.sort() } + +export * as ReplanDefinition from "./replan-definition" diff --git a/packages/opencode/src/dag/runtime/checkpoint.ts b/packages/opencode/src/dag/runtime/checkpoint.ts index 9a1e456b9..1ed8ceba2 100644 --- a/packages/opencode/src/dag/runtime/checkpoint.ts +++ b/packages/opencode/src/dag/runtime/checkpoint.ts @@ -30,3 +30,5 @@ export function latestReplanCheckpoint( return { id: node.id, seq: node.seq } }, undefined) } + +export * as Checkpoint from "./checkpoint" diff --git a/packages/opencode/src/dag/runtime/loop.ts b/packages/opencode/src/dag/runtime/loop.ts index 581a13e79..5889a6a3d 100644 --- a/packages/opencode/src/dag/runtime/loop.ts +++ b/packages/opencode/src/dag/runtime/loop.ts @@ -39,7 +39,7 @@ import { DagConfig } from "../config" import { spawnNode, makeDeadlineWatcher } from "./spawn" import { evaluateCondition, resolveInputMapping, resolveInputMappingChecked } from "./eval" import { reconcileWorkflow, makeSessionStatusChecker, makeLastAssistantTextReader } from "./recovery" -import { latestReplanCheckpoint, type ReplanCheckpoint } from "./checkpoint" +import { Checkpoint } from "./checkpoint" const parseJsonOption = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) @@ -113,13 +113,13 @@ const serviceLayer = Layer.effect( const checkpointDisposition = Effect.fn("DagLoop.checkpointDisposition")(function* ( dagID: string, - checkpoint: ReplanCheckpoint, + checkpoint: Checkpoint.ReplanCheckpoint, ) { const attempt = dag .pauseForCheckpoint(dagID, checkpoint.seq) .pipe( Effect.catchCause((cause) => - Cause.hasInterrupts(cause) ? Effect.failCause(cause) : Effect.succeed(undefined), + Cause.hasInterrupts(cause) ? Effect.failCause(cause) : Effect.void, ), ) const first = yield* attempt @@ -128,7 +128,7 @@ const serviceLayer = Layer.effect( if (second) return second const workflow = yield* store.getWorkflow(dagID).pipe( - Effect.catchCause(() => Effect.succeed(undefined)), + Effect.catchCause(() => Effect.void), ) if ( !workflow || @@ -151,7 +151,7 @@ const serviceLayer = Layer.effect( entry: WorkflowEntry, nodes?: readonly DagStore.NodeRow[], ) { - const checkpoint = latestReplanCheckpoint(entry.config, nodes ?? (yield* store.getCurrentNodes(dagID))) + const checkpoint = Checkpoint.latestReplanCheckpoint(entry.config, nodes ?? (yield* store.getCurrentNodes(dagID))) if (!checkpoint) return false const disposition = yield* checkpointDisposition(dagID, checkpoint) @@ -643,7 +643,7 @@ const serviceLayer = Layer.effect( // WorkflowReplanned handler). Durable truth (the unfiltered read // in recovery reconcile above) is untouched. const nodes = yield* store.getCurrentNodes(dagID) - const checkpoint = latestReplanCheckpoint(config, nodes) + const checkpoint = Checkpoint.latestReplanCheckpoint(config, nodes) const checkpointState = checkpoint ? yield* checkpointDisposition(dagID, checkpoint) : undefined const pausedForCheckpoint = checkpointState === "paused" || checkpointState === "held" if (pausedForCheckpoint) { diff --git a/packages/opencode/src/event-v2-bridge.ts b/packages/opencode/src/event-v2-bridge.ts index b57570619..c40a75b18 100644 --- a/packages/opencode/src/event-v2-bridge.ts +++ b/packages/opencode/src/event-v2-bridge.ts @@ -7,7 +7,7 @@ import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { Project } from "@opencode-ai/core/project" import { AbsolutePath } from "@opencode-ai/core/schema" -import { Context, Effect, Layer } from "effect" +import { Cause, Context, Effect, Layer, Stream } from "effect" export class Service extends Context.Service<Service, EventV2.Interface>()("@opencode/EventV2Bridge") {} @@ -57,8 +57,8 @@ export const layer = Layer.effect( }) }) - const unsubscribe = yield* events.listen((event) => - Effect.gen(function* () { + const forward = Effect.fnUntraced( + function* (event: EventV2.Payload) { const ctx = yield* InstanceRef const workspaceID = (yield* WorkspaceRef) ?? event.location?.workspaceID const projectID = ctx?.project.id ?? projectIDFromLocation(event.location) @@ -84,9 +84,18 @@ export const layer = Layer.effect( }, }, }) - }), + }, + (effect, event) => + Effect.catchCauseIf( + effect, + (cause) => !Cause.hasInterrupts(cause), + (cause) => Effect.logError("EventV2 bridge forwarding failed", { eventID: event.id, cause }), + ), ) - yield* Effect.addFinalizer(() => unsubscribe) + + // startImmediately runs the stream through its PubSub subscription before + // the layer is acquired, so the first publish cannot race bridge startup. + yield* events.all().pipe(Stream.runForEach(forward), Effect.forkScoped({ startImmediately: true })) return Service.of({ ...events, publish, publishMany }) }), diff --git a/packages/opencode/test/dag/dag-plain-text-settlement.test.ts b/packages/opencode/test/dag/dag-plain-text-settlement.test.ts index 6d6364e7e..bca3bf33d 100644 --- a/packages/opencode/test/dag/dag-plain-text-settlement.test.ts +++ b/packages/opencode/test/dag/dag-plain-text-settlement.test.ts @@ -3,9 +3,9 @@ import { describe, expect } from "bun:test" import fs from "node:fs/promises" -import os from "node:os" import path from "node:path" import { Effect, Layer } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Database } from "@opencode-ai/core/database/database" import { DagProjector } from "@opencode-ai/core/dag/projector" import { DagStore } from "@opencode-ai/core/dag/store" @@ -18,6 +18,7 @@ import { Dag, type NodeConfig } from "@/dag/dag" import { reconcileWorkflow } from "@/dag/runtime/recovery" import { EventV2Bridge } from "@/event-v2-bridge" import { SessionID } from "@/session/schema" +import { tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const persistence = Layer.mergeAll( @@ -26,6 +27,7 @@ const persistence = Layer.mergeAll( DagProjector.defaultLayer, DagStore.defaultLayer, EventV2Bridge.defaultLayer, + CrossSpawnSpawner.defaultLayer, ) const it = testEffect(Layer.provideMerge(Dag.layer, persistence)) @@ -115,7 +117,7 @@ describe("plain-text settlement parity", () => { }), ) - it.effect("preserves valid recovered text byte-for-byte and keeps file-ref capture best-effort", () => + it.live("preserves valid recovered text byte-for-byte and keeps file-ref capture best-effort", () => Effect.gen(function* () { yield* setupProject() const dag = yield* Dag.Service @@ -129,30 +131,28 @@ describe("plain-text settlement parity", () => { expect(inlineRow?.status).toBe("completed") expect(inlineRow?.output).toBe(exact) - const dir = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "dag-text-settlement-"))) - yield* Effect.gen(function* () { - const reportPath = path.join(dir, "report.md") - yield* Effect.promise(() => fs.writeFile(reportPath, "durable report")) - const file = yield* createRunning(dag, "file") - yield* reconcileWorkflow( - file.dagID, - completed, - undefined, - { nodes: [{ id: "file" }] }, - () => Effect.succeed(reportPath), - dir, - ) - const fileRow = yield* dag.store.getNode(file.dagID, "file") - expect(fileRow?.status).toBe("completed") - expect(fileRow?.output).toBe(reportPath) - expect(fileRow?.capturedOutput).toEqual( - expect.objectContaining({ - kind: "file_ref", - content_ref: reportPath, - path: reportPath, - }), - ) - }).pipe(Effect.ensuring(Effect.promise(() => fs.rm(dir, { recursive: true, force: true })))) + const dir = yield* tmpdirScoped() + const reportPath = path.join(dir, "report.md") + yield* Effect.promise(() => fs.writeFile(reportPath, "durable report")) + const file = yield* createRunning(dag, "file") + yield* reconcileWorkflow( + file.dagID, + completed, + undefined, + { nodes: [{ id: "file" }] }, + () => Effect.succeed(reportPath), + dir, + ) + const fileRow = yield* dag.store.getNode(file.dagID, "file") + expect(fileRow?.status).toBe("completed") + expect(fileRow?.output).toBe(reportPath) + expect(fileRow?.capturedOutput).toEqual( + expect.objectContaining({ + kind: "file_ref", + content_ref: reportPath, + path: reportPath, + }), + ) }), ) diff --git a/packages/opencode/test/dag/dag-replan-atomicity.test.ts b/packages/opencode/test/dag/dag-replan-atomicity.test.ts index a8ddf4910..8029699b5 100644 --- a/packages/opencode/test/dag/dag-replan-atomicity.test.ts +++ b/packages/opencode/test/dag/dag-replan-atomicity.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { Effect, Exit, Fiber, Layer, Stream } from "effect" +import { Deferred, Effect, Exit, Fiber, Layer, Stream } from "effect" import { eq } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" import { DagProjector } from "@opencode-ai/core/dag/projector" @@ -10,10 +10,11 @@ import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionTable } from "@opencode-ai/core/session/sql" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { DagEvent } from "@opencode-ai/schema/dag-event" import { GlobalBus, type GlobalEvent } from "@/bus/global" import { Dag, type NodeConfig } from "@/dag/dag" -import { InstanceRef } from "@/effect/instance-ref" +import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import { EventV2Bridge } from "@/event-v2-bridge" import type { InstanceContext } from "@/project/instance-context" import { SessionID } from "@/session/schema" @@ -22,10 +23,15 @@ import { pollWithTimeout, testEffect } from "../lib/effect" interface BatchProbe { failAtConfig: boolean + holdRegistered?: { + entered: Deferred.Deferred<void> + release: Deferred.Deferred<void> + } } const directory = process.cwd() const projectID = Project.ID.make("project-1") +const workspaceID = WorkspaceV2.ID.make("wrk_1") const instance = { directory, worktree: directory, @@ -51,7 +57,23 @@ function node(id: string, prompt: string): NodeConfig { function atomicLayer(probe: BatchProbe) { const database = Database.layerFromPath(":memory:") const events = EventV2.layer.pipe(Layer.provide(database)) - const rawBridge = EventV2Bridge.layer.pipe(Layer.provide(events)) + const eventsWithPriorListener = Layer.effect( + EventV2.Service, + Effect.gen(function* () { + const service = yield* EventV2.Service + const unsubscribe = yield* service.listen((event) => { + const hold = probe.holdRegistered + const data: unknown = event.data + if (!hold || event.type !== DagEvent.NodeRegistered.type || !isRecord(data) || data.nodeID !== "replacement") { + return Effect.void + } + return Deferred.succeed(hold.entered, undefined).pipe(Effect.andThen(Deferred.await(hold.release))) + }) + yield* Effect.addFinalizer(() => unsubscribe) + return service + }), + ).pipe(Layer.provide(events)) + const rawBridge = EventV2Bridge.layer.pipe(Layer.provide(eventsWithPriorListener)) const bridge = Layer.effect( EventV2Bridge.Service, Effect.gen(function* () { @@ -80,9 +102,9 @@ function atomicLayer(probe: BatchProbe) { }), ).pipe(Layer.provide(rawBridge)) const store = DagStore.layer.pipe(Layer.provide(database)) - const projector = DagProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) + const projector = DagProjector.layer.pipe(Layer.provide(eventsWithPriorListener), Layer.provide(database)) const dag = Dag.layer.pipe(Layer.provide(bridge), Layer.provide(store)) - return Layer.mergeAll(database, events, bridge, store, projector, dag) + return Layer.mergeAll(database, eventsWithPriorListener, bridge, store, projector, dag) } function setup() { @@ -140,9 +162,18 @@ describe("Dag.replan atomic transaction (DAG-A03)", () => { }).pipe(Effect.provideService(InstanceRef, instance)), ) - it.live("rolls back the complete graph and only exposes a successful committed batch", () => + it.live("rolls back the graph and forwards a committed batch past a yielding prior listener", () => Effect.gen(function* () { probe.failAtConfig = true + const priorListenerEntered = yield* Deferred.make<void>() + const releasePriorListener = yield* Deferred.make<void>() + probe.holdRegistered = { entered: priorListenerEntered, release: releasePriorListener } + yield* Effect.addFinalizer(() => + Deferred.succeed(releasePriorListener, undefined).pipe( + Effect.andThen(Effect.sync(() => delete probe.holdRegistered)), + Effect.asVoid, + ), + ) yield* setup() const dag = yield* Dag.Service @@ -164,6 +195,7 @@ describe("Dag.replan atomic transaction (DAG-A03)", () => { seq?: number directory?: string project?: string + workspace?: string }>() const onGlobal = (event: GlobalEvent) => { const payload: unknown = event.payload @@ -175,12 +207,21 @@ describe("Dag.replan atomic transaction (DAG-A03)", () => { if (aggregateID !== dagID && propertiesDagID !== dagID) return const syncType = typeof sync?.type === "string" ? sync.type : undefined const syncSeq = typeof sync?.seq === "number" ? sync.seq : undefined + const type = syncType ?? (typeof payload.type === "string" ? payload.type : "unknown") + const data = sync ? (isRecord(sync.data) ? sync.data : undefined) : properties + if ( + (type === DagEvent.NodeRegistered.type || type === EventV2.versionedType(DagEvent.NodeRegistered.type, 1)) && + data?.nodeID !== "replacement" + ) { + return + } globalEvents.push({ kind: sync ? "sync" : "event", - type: syncType ?? (typeof payload.type === "string" ? payload.type : "unknown"), + type, ...(syncSeq === undefined ? {} : { seq: syncSeq }), directory: event.directory, project: event.project, + workspace: event.workspace, }) } yield* Effect.acquireRelease( @@ -216,6 +257,18 @@ describe("Dag.replan atomic transaction (DAG-A03)", () => { add: ["replacement"], ignore: [], }) + yield* Deferred.await(priorListenerEntered).pipe( + Effect.timeoutOrElse({ + duration: "1 second", + orElse: () => Effect.fail(new Error("prior EventV2 listener did not block NodeRegistered")), + }), + ) + + const firstForwarded = yield* pollWithTimeout( + Effect.sync(() => globalEvents[0]), + "EventV2Bridge did not forward while the prior listener was blocked", + ) + expect(firstForwarded).toEqual(expect.objectContaining({ kind: "event", type: DagEvent.NodeRegistered.type })) const workflow = yield* store.getWorkflow(dagID) const rows = yield* store.getNodes(dagID) @@ -245,11 +298,12 @@ describe("Dag.replan atomic transaction (DAG-A03)", () => { ) for (const event of observed) { const location = event.location as - | { directory?: string; project?: { id: string; directory: string } } + | { directory?: string; project?: { id: string; directory: string }; workspaceID?: string } | undefined expect(location?.directory).toBe(directory) expect(location?.project?.id).toBe(projectID) expect(location?.project?.directory).toBe(directory) + expect(location?.workspaceID).toBe(workspaceID) } const forwarded = yield* pollWithTimeout( @@ -273,7 +327,9 @@ describe("Dag.replan atomic transaction (DAG-A03)", () => { for (const event of forwarded) { expect(event.directory).toBe(directory) expect(event.project).toBe(projectID) + expect(event.workspace).toBe(workspaceID) } - }).pipe(Effect.provideService(InstanceRef, instance)), + yield* Deferred.succeed(releasePriorListener, undefined) + }).pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspaceID)), ) }) diff --git a/packages/opencode/test/dag/dag-replan-definition-guard.test.ts b/packages/opencode/test/dag/dag-replan-definition-guard.test.ts index 9e7d98117..2d033747a 100644 --- a/packages/opencode/test/dag/dag-replan-definition-guard.test.ts +++ b/packages/opencode/test/dag/dag-replan-definition-guard.test.ts @@ -12,7 +12,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionTable } from "@opencode-ai/core/session/sql" import { Dag, type NodeConfig, parseWorkflowConfig } from "@/dag/dag" -import { changedAdmittedNodeFields } from "@/dag/replan-definition" +import { ReplanDefinition } from "@/dag/replan-definition" import { EventV2Bridge } from "@/event-v2-bridge" import { SessionID } from "@/session/schema" import { testEffect } from "../lib/effect" @@ -307,7 +307,7 @@ describe("replan admitted-definition guard", () => { worker_config: { sandbox: { filesystem: "write", network: false }, timeout_ms: 9_000 }, } - expect(changedAdmittedNodeFields(current, next, { allowTimeoutUpdate: true })).toEqual([ + expect(ReplanDefinition.changedAdmittedNodeFields(current, next, { allowTimeoutUpdate: true })).toEqual([ "permissions", "review", "worker_config.sandbox", diff --git a/packages/opencode/test/dag/dag-step-convergence.test.ts b/packages/opencode/test/dag/dag-step-convergence.test.ts index 1775e0a2e..558306a47 100644 --- a/packages/opencode/test/dag/dag-step-convergence.test.ts +++ b/packages/opencode/test/dag/dag-step-convergence.test.ts @@ -1,6 +1,6 @@ /* oxlint-disable typescript-eslint/no-unsafe-type-assertion -- * Branded test fixtures and Effect service mocks use the established DAG harness narrowing pattern. */ -import { describe, expect, it } from "bun:test" +import { describe, expect } from "bun:test" import { Deferred, Effect, Layer, Option, Queue } from "effect" import type { SessionV1 } from "@opencode-ai/core/v1/session" import { Database } from "@opencode-ai/core/database/database" @@ -18,9 +18,11 @@ import { SessionPrompt } from "@/session/prompt" import { MessageID } from "@/session/schema" import { Session } from "@/session/session" import { SessionStatus } from "@/session/status" -import { pollWithTimeout } from "../lib/effect" +import { pollWithTimeout, testEffect } from "../lib/effect" import { withIdleAdmission } from "../lib/session-prompt" +const it = testEffect(Layer.empty) + interface PromptGate { readonly title: string readonly release: Deferred.Deferred<string> @@ -162,7 +164,6 @@ function runStepTest<A>( worktree: process.cwd(), project: { id: "project-1" }, } as never), - Effect.scoped, ) }) } @@ -188,96 +189,90 @@ function completeParent(dag: Dag.Interface, dagID: string) { } describe("Dag single-step skip convergence", () => { - it("converges condition-false and dependent skips to workflow completion in one step", async () => { - await Effect.runPromise( - runStepTest(({ dag, loop, store, childPrompts }) => - Effect.gen(function* () { - const dagID = yield* createGatedWorkflow(dag) - yield* completeParent(dag, dagID) + it.live("converges condition-false and dependent skips to workflow completion in one step", () => + runStepTest(({ dag, loop, store, childPrompts }) => + Effect.gen(function* () { + const dagID = yield* createGatedWorkflow(dag) + yield* completeParent(dag, dagID) - // Reproduce a step event that occurred before the loop subscribed. - expect(yield* dag.step(dagID)).toEqual({ status: "stepping", nodeID: "a" }) - yield* loop.init() - expect(yield* dag.step(dagID)).toEqual({ status: "stepping", nodeID: "a" }) + // Reproduce a step event that occurred before the loop subscribed. + expect(yield* dag.step(dagID)).toEqual({ status: "stepping", nodeID: "a" }) + yield* loop.init() + expect(yield* dag.step(dagID)).toEqual({ status: "stepping", nodeID: "a" }) - const settled = yield* pollWithTimeout( - Effect.gen(function* () { - const workflow = yield* store.getWorkflow(dagID) - const a = yield* store.getNode(dagID, "a") - const b = yield* store.getNode(dagID, "b") - return workflow?.status === "completed" ? { a, b } : undefined - }), - "step did not converge the skipped branch to completion", - ) - expect(settled.a?.status).toBe("skipped") - expect(settled.b?.status).toBe("skipped") - expect(Option.isNone(yield* Queue.poll(childPrompts))).toBe(true) - }), - ), - ) - }) + const settled = yield* pollWithTimeout( + Effect.gen(function* () { + const workflow = yield* store.getWorkflow(dagID) + const a = yield* store.getNode(dagID, "a") + const b = yield* store.getNode(dagID, "b") + return workflow?.status === "completed" ? { a, b } : undefined + }), + "step did not converge the skipped branch to completion", + ) + expect(settled.a?.status).toBe("skipped") + expect(settled.b?.status).toBe("skipped") + expect(Option.isNone(yield* Queue.poll(childPrompts))).toBe(true) + }), + ), + ) - it("uses a later step to recover an already-stranded skip cascade", async () => { - await Effect.runPromise( - runStepTest(({ dag, loop, store, childPrompts }) => - Effect.gen(function* () { - const dagID = yield* createGatedWorkflow(dag) - yield* completeParent(dag, dagID) - yield* dag.nodeSkipped(dagID, "a", "condition_false") + it.live("uses a later step to recover an already-stranded skip cascade", () => + runStepTest(({ dag, loop, store, childPrompts }) => + Effect.gen(function* () { + const dagID = yield* createGatedWorkflow(dag) + yield* completeParent(dag, dagID) + yield* dag.nodeSkipped(dagID, "a", "condition_false") - expect(yield* dag.step(dagID)).toEqual({ status: "stepping", nodeID: "b" }) - yield* loop.init() - expect(yield* dag.step(dagID)).toEqual({ status: "stepping", nodeID: "b" }) + expect(yield* dag.step(dagID)).toEqual({ status: "stepping", nodeID: "b" }) + yield* loop.init() + expect(yield* dag.step(dagID)).toEqual({ status: "stepping", nodeID: "b" }) - yield* pollWithTimeout( - Effect.gen(function* () { - const workflow = yield* store.getWorkflow(dagID) - const b = yield* store.getNode(dagID, "b") - return workflow?.status === "completed" && b?.status === "skipped" ? true : undefined - }), - "repeated step did not recover the stranded skip cascade", - ) - expect(Option.isNone(yield* Queue.poll(childPrompts))).toBe(true) - }), - ), - ) - }) + yield* pollWithTimeout( + Effect.gen(function* () { + const workflow = yield* store.getWorkflow(dagID) + const b = yield* store.getNode(dagID, "b") + return workflow?.status === "completed" && b?.status === "skipped" ? true : undefined + }), + "repeated step did not recover the stranded skip cascade", + ) + expect(Option.isNone(yield* Queue.poll(childPrompts))).toBe(true) + }), + ), + ) - it("does not dispatch a runnable sibling while the selected skip branch converges", async () => { - await Effect.runPromise( - runStepTest(({ dag, loop, store, childPrompts }) => - Effect.gen(function* () { - const dagID = yield* createGatedWorkflow(dag, true) - yield* completeParent(dag, dagID) - expect(yield* dag.step(dagID)).toEqual({ status: "stepping", nodeID: "a" }) - yield* loop.init() + it.live("does not dispatch a runnable sibling while the selected skip branch converges", () => + runStepTest(({ dag, loop, store, childPrompts }) => + Effect.gen(function* () { + const dagID = yield* createGatedWorkflow(dag, true) + yield* completeParent(dag, dagID) + expect(yield* dag.step(dagID)).toEqual({ status: "stepping", nodeID: "a" }) + yield* loop.init() - expect(yield* dag.step(dagID)).toEqual({ status: "stepping", nodeID: "a" }) - yield* pollWithTimeout( - Effect.gen(function* () { - const a = yield* store.getNode(dagID, "a") - const b = yield* store.getNode(dagID, "b") - return a?.status === "skipped" && b?.status === "skipped" ? true : undefined - }), - "selected skip branch did not converge", - ) - expect((yield* store.getNode(dagID, "c"))?.status).toBe("pending") - expect(Option.isNone(yield* Queue.poll(childPrompts))).toBe(true) + expect(yield* dag.step(dagID)).toEqual({ status: "stepping", nodeID: "a" }) + yield* pollWithTimeout( + Effect.gen(function* () { + const a = yield* store.getNode(dagID, "a") + const b = yield* store.getNode(dagID, "b") + return a?.status === "skipped" && b?.status === "skipped" ? true : undefined + }), + "selected skip branch did not converge", + ) + expect((yield* store.getNode(dagID, "c"))?.status).toBe("pending") + expect(Option.isNone(yield* Queue.poll(childPrompts))).toBe(true) - expect(yield* dag.step(dagID)).toEqual({ status: "stepping", nodeID: "c" }) - const child = yield* takeWithin(childPrompts, "runnable sibling did not start on its own step") - expect(child.title).toBe("c") - expect(Option.isNone(yield* Queue.poll(childPrompts))).toBe(true) - yield* Deferred.succeed(child.release, "done") - yield* pollWithTimeout( - Effect.gen(function* () { - const workflow = yield* store.getWorkflow(dagID) - return workflow?.status === "completed" ? true : undefined - }), - "workflow did not complete after the single dispatched sibling", - ) - }), - ), - ) - }) + expect(yield* dag.step(dagID)).toEqual({ status: "stepping", nodeID: "c" }) + const child = yield* takeWithin(childPrompts, "runnable sibling did not start on its own step") + expect(child.title).toBe("c") + expect(Option.isNone(yield* Queue.poll(childPrompts))).toBe(true) + yield* Deferred.succeed(child.release, "done") + yield* pollWithTimeout( + Effect.gen(function* () { + const workflow = yield* store.getWorkflow(dagID) + return workflow?.status === "completed" ? true : undefined + }), + "workflow did not complete after the single dispatched sibling", + ) + }), + ), + ) }) diff --git a/packages/opencode/test/dag/dag-timeout-escalation.test.ts b/packages/opencode/test/dag/dag-timeout-escalation.test.ts index a97407f86..f75a0f912 100644 --- a/packages/opencode/test/dag/dag-timeout-escalation.test.ts +++ b/packages/opencode/test/dag/dag-timeout-escalation.test.ts @@ -622,6 +622,8 @@ describe("DagLoop timeout escalation", () => { "legacy node did not reach running", ) const deadlineBefore = running.deadlineMs + const childSessionID = + running.childSessionId ?? (yield* Effect.fail(new Error("legacy node lost its child session"))) // Put the node at the exact adjudication boundary where a real // timeout change is allowed to re-time it, then prove an omitted @@ -629,7 +631,7 @@ describe("DagLoop timeout escalation", () => { yield* dag.nodeTimeoutEscalated( dagID, "a", - running.childSessionId!, + childSessionID, running.timeoutExtensions + 1, ) const wake = yield* takeWithin(parentPrompts, "legacy timeout wake did not reach the parent")