diff --git a/.github/actions/record-verification/action.yml b/.github/actions/record-verification/action.yml new file mode 100644 index 0000000000..9737fa58a5 --- /dev/null +++ b/.github/actions/record-verification/action.yml @@ -0,0 +1,40 @@ +name: Record successful verification +description: Save evidence after every verification step in the caller has succeeded +inputs: + key: + description: Exact evidence key from verified-content + required: true + path: + description: Evidence path from verified-content + required: true + job-name: + description: Exact GitHub job name used to locate the authoritative result + required: true +runs: + using: composite + steps: + - name: Record tested product content + shell: bash + env: + EVIDENCE: ${{ inputs.path }} + run: node script/ci-evidence.mjs proof + - name: Upload immutable verification proof + id: proof + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ci-verification-${{ github.job }}-${{ runner.os }}-${{ runner.arch }}-${{ github.run_attempt }} + path: ${{ inputs.path }}.artifact/verification.json + retention-days: 2 + if-no-files-found: error + - name: Record verification source + shell: bash + env: + EVIDENCE: ${{ inputs.path }} + CHECK_JOB: ${{ inputs.job-name }} + ARTIFACT_ID: ${{ steps.proof.outputs.artifact-id }} + run: node script/ci-evidence.mjs record + - name: Save successful verification + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ${{ inputs.path }} + key: ${{ inputs.key }} diff --git a/.github/actions/verified-content/action.yml b/.github/actions/verified-content/action.yml new file mode 100644 index 0000000000..a0f1ddbe33 --- /dev/null +++ b/.github/actions/verified-content/action.yml @@ -0,0 +1,57 @@ +name: Previously verified content +description: Restore exact successful verification evidence, excluding only the SpecGit delivery record +inputs: + check: + description: Stable job identifier, separate for every verification suite + required: true + job-name: + description: Exact GitHub job name whose successful conclusion must be confirmed + required: true + runner-label: + description: Runner label which must appear in the source job's platform evidence + required: true +outputs: + reused: + value: ${{ steps.verify.outputs.reused }} + description: Whether this exact content has already passed this suite today + key: + value: ${{ steps.key.outputs.value }} + description: Exact evidence key + path: + value: ${{ steps.key.outputs.path }} + description: Evidence file path +runs: + using: composite + steps: + - name: Fingerprint verification inputs + id: content + shell: bash + run: node script/ci-fingerprint.mjs + - name: Identify verification evidence + id: key + shell: bash + env: + CHECK: ${{ inputs.check }} + FINGERPRINT: ${{ steps.content.outputs.fingerprint }} + DAY: ${{ steps.content.outputs.day }} + run: | + echo "value=verified-v4-${RUNNER_OS}-${RUNNER_ARCH}-${DAY}-${CHECK}-${FINGERPRINT}" >> "$GITHUB_OUTPUT" + echo "path=${RUNNER_TEMP}/verified-${CHECK}.txt" >> "$GITHUB_OUTPUT" + - name: Restore successful verification + id: restore + if: github.event_name != 'workflow_dispatch' + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ${{ steps.key.outputs.path }} + key: ${{ steps.key.outputs.value }} + - name: Verify the source job and product content + id: verify + if: steps.restore.outputs.cache-hit == 'true' + shell: bash + env: + EVIDENCE: ${{ steps.key.outputs.path }} + CHECK_JOB: ${{ inputs.job-name }} + CHECK_RUNNER: ${{ inputs.runner-label }} + PRODUCT_FINGERPRINT: ${{ steps.content.outputs.fingerprint }} + GH_TOKEN: ${{ github.token }} + run: node script/ci-evidence.mjs verify diff --git a/.github/releases/v1.0.42-branch-audit.md b/.github/releases/v1.0.42-branch-audit.md new file mode 100644 index 0000000000..895bf84ab1 --- /dev/null +++ b/.github/releases/v1.0.42-branch-audit.md @@ -0,0 +1,133 @@ +# v1.0.42 branch reconciliation + +Snapshot: 2026-09-07, before creating the release preparation branches. Scope: all 100 local branches and seven `origin` branches of `LeXwDeX/OpenCode-GraphAgent`. Upstream development branches belong to the upstream project and are outside this fork release. + +The original main worktree contains uncommitted local documentation and SpecGit maintenance changes. Those were preserved and are not treated as branch commits. No branch or worktree was deleted. + +## Required integration + +- `origin/dev` was `345e5c13e7bdce2c739bace6327a896ebed88075`, 17 commits behind `origin/main` (`b37b3368accb2d0cc6aecc4f576581f97900d97b`). The main history includes accepted Hooks fixes and v1.0.40/v1.0.41 release records. +- Goal source `9a651339b1df92e0739a9653e7f08f4c807f7a58` adds the Goal runtime, concurrency, command, budget, reconnect, and PTY regression repairs tracked by PR #573. +- Candidate PR #577 preserves that source commit as an ancestor, includes the main history, and retains all seven Goal issue closing references plus #576 and the requested CI optimization #578. Product content after the integration merge is identical to the verified Goal source; subsequent candidate changes add release documentation and exact-content CI evidence reuse for SpecGit record updates. + +## Historical differences + +- `c417e1ee374c31d210dd101a973115f65ce61630` occurs on six stale test-created `opencode/*` branches. Main already contains replacement `0c46896bfe`; teardown implementation and regression tests are byte-identical. The replacement additionally asserts DAG graph revision. Reapplying the old commit would add no missing behavior. +- `ci/519-auto-close-linked`, `feat/475-sdk-openapi-race`, `fix/440-assemble-google-key`, and `origin/feat/473-git-head-watcher` have only old `.specgit.yaml` binding commits outside main. Their implementation ancestors are already contained. +- `docs/460-release-notes-v1-0-35` has one old commit untracking the delivery record. Current main explicitly carries delivery records for CI and already ignores local assets; this superseded metadata policy is not reapplied. + +## Full inventory + +| Branch | Outside dev | Outside main | Disposition | +| --- | ---: | ---: | --- | +| `chore/476-sync-main-to-dev-before-repair-wave` | 0 | 0 | Already contained in dev and main | +| `chore/517-sync-v1-0-39` | 0 | 0 | Already contained in dev and main | +| `chore/543-stable-release` | 3 | 0 | Contained in main; restored to dev through main ancestry | +| `chore/554-dag-release` | 7 | 0 | Contained in main; restored to dev through main ancestry | +| `chore/refresh-specgit-harness` | 0 | 0 | Already contained in dev and main | +| `chore/sync-main-to-dev` | 0 | 0 | Already contained in dev and main | +| `chore/sync-main-to-dev-before-repair-wave` | 0 | 0 | Already contained in dev and main | +| `ci/519-auto-close-linked` | 1 | 1 | Historical binding only; no missing implementation | +| `codex/dag-audit-base` | 0 | 0 | Already contained in dev and main | +| `codex/dag-release-staging` | 0 | 0 | Already contained in dev and main | +| `codex/hooks-runtime-reliability` | 4 | 0 | Contained in main; restored to dev through main ancestry | +| `dev` | 0 | 0 | Already contained in dev and main | +| `docs/460-release-notes-v1-0-35` | 1 | 1 | Superseded delivery-record tracking policy | +| `docs/470-release-v1-0-37-notes` | 0 | 0 | Already contained in dev and main | +| `docs/477-remove-legacy-artifacts` | 0 | 0 | Already contained in dev and main | +| `docs/510-release-notes-v1-0-38` | 0 | 0 | Already contained in dev and main | +| `docs/release-notes-v1.0.34` | 0 | 0 | Already contained in dev and main | +| `docs/release-notes-v1.0.35` | 0 | 0 | Already contained in dev and main | +| `feat/433-issue433` | 0 | 0 | Already contained in dev and main | +| `feat/433-shell-silence-guard` | 0 | 0 | Already contained in dev and main | +| `feat/435-dag-project-discovery` | 0 | 0 | Already contained in dev and main | +| `feat/458-giant-summary-guard` | 0 | 0 | Already contained in dev and main | +| `feat/468-replan-topology-refresh` | 0 | 0 | Already contained in dev and main | +| `feat/472-issue472` | 0 | 0 | Already contained in dev and main | +| `feat/475-sdk-openapi-race` | 1 | 1 | Historical binding only; no missing implementation | +| `feat/477-issue477` | 0 | 0 | Already contained in dev and main | +| `feat/498-macos-integrity-boundary` | 0 | 0 | Already contained in dev and main | +| `feat/519-auto-close-linked` | 0 | 0 | Already contained in dev and main | +| `feat/521-specgit-bootstrap-wrapper` | 0 | 0 | Already contained in dev and main | +| `feat/524-event-retention-reclamation` | 0 | 0 | Already contained in dev and main | +| `feat/528-specgit-pr-base-dev` | 0 | 0 | Already contained in dev and main | +| `feat/529-specgit-branch-type-preflight` | 0 | 0 | Already contained in dev and main | +| `feat/530-specgit-bootstrap-rollback` | 0 | 0 | Already contained in dev and main | +| `feat/541-preserve-npm-lock` | 0 | 0 | Already contained in dev and main | +| `feat/dag-project-discovery` | 0 | 0 | Already contained in dev and main | +| `feat/shell-silence-guard` | 0 | 0 | Already contained in dev and main | +| `feat/sync-upstream` | 0 | 0 | Already contained in dev and main | +| `feat/sync-upstream-dev` | 0 | 0 | Already contained in dev and main | +| `fix/440-assemble-google-key` | 2 | 2 | Historical binding only; no missing implementation | +| `fix/465-tui-crashes-on` | 0 | 0 | Already contained in dev and main | +| `fix/474-stabilize-dirty-worktree` | 0 | 0 | Already contained in dev and main | +| `fix/475-sdk-openapi-race` | 0 | 0 | Already contained in dev and main | +| `fix/500-hook-command-grandchildren` | 0 | 0 | Already contained in dev and main | +| `fix/506-accept-builtin-spec` | 0 | 0 | Already contained in dev and main | +| `fix/538-native-tool-settlement` | 0 | 0 | Already contained in dev and main | +| `fix/545-dag-audit-reliability` | 0 | 0 | Already contained in dev and main | +| `fix/557-hooks-runtime-reliability` | 14 | 0 | Contained in main; restored to dev through main ancestry | +| `fix/568-goal-reliability` | 22 | 5 | Integrate Goal source and its main ancestry | +| `fix/dirty-worktree-classify` | 0 | 0 | Already contained in dev and main | +| `fix/goal-reliability` | 17 | 0 | Contained in main; restored to dev through main ancestry | +| `fix/httpapi-composite-exit` | 0 | 0 | Already contained in dev and main | +| `fix/sdk-openapi-race` | 0 | 0 | Already contained in dev and main | +| `fix/specgit-harness-assets` | 0 | 0 | Already contained in dev and main | +| `fix/tui-jsx-runtime` | 0 | 0 | Already contained in dev and main | +| `main` | 4 | 0 | Contained in main; restored to dev through main ancestry | +| `merge-scratch/probe-upstream-dev` | 0 | 0 | Already contained in dev and main | +| `opencode/brave-panda` | 0 | 0 | Already contained in dev and main | +| `opencode/calm-falcon` | 0 | 0 | Already contained in dev and main | +| `opencode/calm-island` | 0 | 0 | Already contained in dev and main | +| `opencode/cosmic-eagle` | 3 | 3 | HttpAPI fix already integrated as 0c46896bfe; old binding only | +| `opencode/crisp-comet` | 0 | 0 | Already contained in dev and main | +| `opencode/eager-orchid` | 0 | 0 | Already contained in dev and main | +| `opencode/gentle-wizard` | 0 | 0 | Already contained in dev and main | +| `opencode/glowing-meadow` | 0 | 0 | Already contained in dev and main | +| `opencode/happy-engine` | 0 | 0 | Already contained in dev and main | +| `opencode/happy-otter` | 0 | 0 | Already contained in dev and main | +| `opencode/hidden-engine` | 0 | 0 | Already contained in dev and main | +| `opencode/hidden-garden` | 0 | 0 | Already contained in dev and main | +| `opencode/hidden-river` | 0 | 0 | Already contained in dev and main | +| `opencode/kind-mountain` | 0 | 0 | Already contained in dev and main | +| `opencode/kind-pixel` | 0 | 0 | Already contained in dev and main | +| `opencode/misty-moon` | 3 | 3 | HttpAPI fix already integrated as 0c46896bfe; old binding only | +| `opencode/neon-garden` | 0 | 0 | Already contained in dev and main | +| `opencode/neon-moon` | 0 | 0 | Already contained in dev and main | +| `opencode/nimble-otter` | 0 | 0 | Already contained in dev and main | +| `opencode/nimble-panda` | 0 | 0 | Already contained in dev and main | +| `opencode/playful-knight` | 3 | 3 | HttpAPI fix already integrated as 0c46896bfe; old binding only | +| `opencode/playful-nebula` | 0 | 0 | Already contained in dev and main | +| `opencode/playful-planet` | 0 | 0 | Already contained in dev and main | +| `opencode/proud-cabin` | 0 | 0 | Already contained in dev and main | +| `opencode/proud-pixel` | 0 | 0 | Already contained in dev and main | +| `opencode/quick-canyon` | 0 | 0 | Already contained in dev and main | +| `opencode/quiet-panda` | 0 | 0 | Already contained in dev and main | +| `opencode/quiet-pixel` | 3 | 3 | HttpAPI fix already integrated as 0c46896bfe; old binding only | +| `opencode/quiet-river` | 0 | 0 | Already contained in dev and main | +| `opencode/silent-otter` | 0 | 0 | Already contained in dev and main | +| `opencode/silent-tiger` | 0 | 0 | Already contained in dev and main | +| `opencode/stellar-circuit` | 3 | 3 | HttpAPI fix already integrated as 0c46896bfe; old binding only | +| `opencode/sunny-canyon` | 3 | 3 | HttpAPI fix already integrated as 0c46896bfe; old binding only | +| `opencode/sunny-comet` | 0 | 0 | Already contained in dev and main | +| `opencode/sunny-garden` | 0 | 0 | Already contained in dev and main | +| `opencode/swift-forest` | 0 | 0 | Already contained in dev and main | +| `opencode/swift-island` | 0 | 0 | Already contained in dev and main | +| `opencode/swift-otter` | 0 | 0 | Already contained in dev and main | +| `opencode/tidy-panda` | 0 | 0 | Already contained in dev and main | +| `opencode/witty-forest` | 0 | 0 | Already contained in dev and main | +| `test/512-leftover-hardening` | 0 | 0 | Already contained in dev and main | +| `test/git-head-watcher` | 0 | 0 | Already contained in dev and main | +| `tmp/lane-473-base-pointer` | 0 | 0 | Already contained in dev and main | +| `origin` | 17 | 0 | Contained in main; restored to dev through main ancestry | +| `origin/dev` | 0 | 0 | Already contained in dev and main | +| `origin/feat/473-git-head-watcher` | 2 | 2 | Historical binding only; no missing implementation | +| `origin/feat/475-sdk-openapi-race` | 0 | 0 | Already contained in dev and main | +| `origin/fix/423-open-security-alerts` | 0 | 0 | Already contained in dev and main | +| `origin/fix/440-assemble-google-key` | 2 | 2 | Historical binding only; no missing implementation | +| `origin/fix/568-goal-reliability` | 21 | 4 | Integrate Goal source and its main ancestry | +| `origin/main` | 17 | 0 | Contained in main; restored to dev through main ancestry | + +## Verification + +Before merging, verify candidate ancestry for `9a651339b1`, `b37b3368ac`, `b56d931b28`, and `0c46896bfe`; require current-head CI and SpecGit acceptance. After dev integration and main promotion, verify those same required commits are ancestors of both remote branches. The actual published tag and assets are verified after the official release workflow finishes. diff --git a/.github/releases/v1.0.42.md b/.github/releases/v1.0.42.md new file mode 100644 index 0000000000..2b127c47a3 --- /dev/null +++ b/.github/releases/v1.0.42.md @@ -0,0 +1,54 @@ +## opencode {VERSION} + +{Prerelease/Stable} release from `{branch}` branch. Goal execution now preserves user intent across concurrent turns, and Hooks enforce their execution, decision, and cancellation contracts. + +--- + +### 🎯 Features + +- **Goal turn budgets**: use `/goal --max-turns N ` to set a total budget and `/goal resume --max-turns N` to increase it while preserving turns already used ([#572](https://github.com/LeXwDeX/OpenCode-GraphAgent/issues/572)). + +--- + +### 🐛 Bug Fixes + +- **Goal verdict races**: serialize judgment commits with user, loop, shell, and control-command admission; recheck the current message boundary, Goal revision, and automation lease before accepting a delayed result ([#568](https://github.com/LeXwDeX/OpenCode-GraphAgent/issues/568)). +- **Uninterrupted Goal progress**: autonomous questions return without waiting for interactive input; ordinary user turns keep normal questions. Repeated status queries and subgoal edits no longer replace work boundaries, consume extra turns, or strand a continuation ([#569](https://github.com/LeXwDeX/OpenCode-GraphAgent/issues/569), [#570](https://github.com/LeXwDeX/OpenCode-GraphAgent/issues/570)). +- **Production Goal commands and reconnects**: explicitly provide Goal to the production prompt runtime. Preserve the last known Goal after a reconnect error, clearing it only on a confirmed 404 ([#571](https://github.com/LeXwDeX/OpenCode-GraphAgent/issues/571), [#575](https://github.com/LeXwDeX/OpenCode-GraphAgent/issues/575)). +- **Hooks enforcement and feedback**: honor prompt stop/block and permission denials, retain valid siblings after malformed configuration, preserve feedback from failed tools and MCP calls, and claim one-shot hooks atomically ([PR #567](https://github.com/LeXwDeX/OpenCode-GraphAgent/pull/567)). +- **Hooks lifecycle**: constrain inspection commands to validated read-only operations, propagate timeout/cancellation to child work, preserve interrupted Bash output, and carry file-change events and asynchronous rewake across runtime boundaries ([PR #567](https://github.com/LeXwDeX/OpenCode-GraphAgent/pull/567)). + +--- + +### ⚙️ CI / Engineering + +- **SpecGit record updates**: reuse successful verification for identical product content, suite, platform, runner label, and UTC day. Only the root `.specgit.yaml` record is excluded; source, policy, workflow, dependency, and documentation changes remain inputs. Cache misses and manual runs execute the full checks ([#578](https://github.com/LeXwDeX/OpenCode-GraphAgent/issues/578)). + +- **PTY environment verification**: wait for the output assertion before allowing the shell to exit, removing the fixed five-second connection window. A six-second delayed connection passes with the new handshake ([#574](https://github.com/LeXwDeX/OpenCode-GraphAgent/issues/574)). +- **Branch reconciliation**: review all 100 existing local branches and seven origin branches. Restore the 17 main-only commits to dev alongside the Goal delivery. Remaining historical differences are obsolete delivery metadata or an already-integrated HttpAPI fix; see the candidate branch audit ([#576](https://github.com/LeXwDeX/OpenCode-GraphAgent/issues/576)). + +--- + +### 🧪 Test Summary + +``` +opencode suite: 4571 pass, 0 fail, 23 existing skips, 1 existing todo +other workspaces: 1760 pass, 0 fail +Goal TUI reconnect: 6 pass, 0 fail +DAG coverage gate: 820 pass, all existing per-file floors met +typecheck: 29/29 packages green +lint: 4842 warnings, 0 errors, unchanged 4850 cap +HttpAPI composite: 3 x 230 pass, 0 fail, 0 skip, 0 missing +``` + +--- + +### 🔍 Verification + +Local verification used Bun 1.3.14. Real AppRuntime and SessionPrompt/GoalLoop integration tests cover production dependency wiring, delayed done/blocked/continue verdicts, 25 consecutive status queries, subgoal edits, concurrent user/loop/shell admission, and both autonomous and interactive questions. The final dependency grouping was separately rechecked with 88 passing production/prompt tests and the workspace typechecks. Standards and Spec reviews found no remaining blockers. + +The release candidate includes the already-accepted Hooks delivery from [PR #567](https://github.com/LeXwDeX/OpenCode-GraphAgent/pull/567). Each integration and promotion is gated by current-head CI and SpecGit acceptance. The official release workflow validates the reference templates against the releasing runtime, builds Linux/macOS/Windows archives, verifies macOS installation behavior, and publishes SHA256SUMS with the release assets. Live external model providers and MCP services are not claimed as validated. + +--- + +**Full changelog:** [`{previous_tag}`...`{current_tag}`](https://github.com/LeXwDeX/OpenCode-GraphAgent/compare/{previous_tag}...{current_tag}) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index d801a21937..eb7e7df730 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -42,6 +42,7 @@ concurrency: permissions: contents: read + actions: read checks: write env: @@ -72,19 +73,29 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} + - name: Check previously verified content + id: evidence + uses: ./.github/actions/verified-content + with: + check: unit + job-name: Unit Tests (${{ matrix.settings.name }}) + runner-label: ${{ matrix.settings.host }} + - name: Setup Node + if: steps.evidence.outputs.reused != 'true' uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "24" - name: Setup Go - if: runner.os == 'Linux' + if: steps.evidence.outputs.reused != 'true' && (runner.os == 'Linux') uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: config_assistant/go.mod cache-dependency-path: config_assistant/go.sum - name: Setup Bun + if: steps.evidence.outputs.reused != 'true' uses: ./.github/actions/setup-bun with: # ci-typecheck.yml's Linux job is the designated Linux cache @@ -93,17 +104,20 @@ jobs: save-cache: false - name: Configure Git Identity + if: steps.evidence.outputs.reused != 'true' run: | git config --global user.email "bot@opencode.ai" git config --global user.name "opencode" - name: Install ripgrep + if: steps.evidence.outputs.reused != 'true' # tool.glob tests hit ripgrep; without the system binary, binary.ts # downloads rg from GitHub releases every run (temp XDG per preload), # gambling on network stability — ECONNRESET fails the test. run: sudo apt-get update && sudo apt-get install -y ripgrep - name: Cache Turbo + if: steps.evidence.outputs.reused != 'true' uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: node_modules/.cache/turbo @@ -113,6 +127,7 @@ jobs: turbo-${{ runner.os }}- - name: Run unit tests + if: steps.evidence.outputs.reused != 'true' # opencode:test alone (244 files / 3048 tests, many spawning real CLI # subprocesses via cliIt) measured at 803s / 13m24s locally, and this # job has been seen at 16-17m in CI. turbo buffers a concurrent @@ -126,22 +141,22 @@ jobs: OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} - name: Run config assistant tests - if: runner.os == 'Linux' + if: steps.evidence.outputs.reused != 'true' && (runner.os == 'Linux') working-directory: config_assistant run: go test ./... - name: Check generated client - if: runner.os == 'Linux' + if: steps.evidence.outputs.reused != 'true' && (runner.os == 'Linux') working-directory: packages/client run: bun run check:generated - name: Check generated SDK - if: runner.os == 'Linux' + if: steps.evidence.outputs.reused != 'true' && (runner.os == 'Linux') working-directory: packages/sdk/js run: bun run check:generated - name: Run HttpAPI Exerciser Gates - if: runner.os == 'Linux' + if: steps.evidence.outputs.reused != 'true' && (runner.os == 'Linux') working-directory: packages/opencode # The exerciser aggregates its report and prints it in one flush, so # this step is silent while it runs. A latent uninterruptible hang @@ -159,6 +174,14 @@ jobs: timeout-minutes: 15 run: bun run test:httpapi:ci + - name: Record successful verification + if: success() && steps.evidence.outputs.reused != 'true' + uses: ./.github/actions/record-verification + with: + key: ${{ steps.evidence.outputs.key }} + path: ${{ steps.evidence.outputs.path }} + job-name: Unit Tests (${{ matrix.settings.name }}) + e2e-tests: name: E2E Tests (${{ matrix.settings.name }}) strategy: @@ -181,13 +204,23 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} + - name: Check previously verified content + id: evidence + uses: ./.github/actions/verified-content + with: + check: e2e + job-name: E2E Tests (${{ matrix.settings.name }}) + runner-label: ${{ matrix.settings.host }} + - name: Setup Node + if: steps.evidence.outputs.reused != 'true' uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: # Playwright 1.59 hangs while extracting Chromium with Node 24.16. node-version: "24.15" - name: Setup Bun + if: steps.evidence.outputs.reused != 'true' uses: ./.github/actions/setup-bun with: # Only Windows saves (sole Windows job). Linux e2e skips — @@ -196,12 +229,14 @@ jobs: save-cache: ${{ matrix.settings.name == 'windows' }} - name: Read Playwright version + if: steps.evidence.outputs.reused != 'true' id: playwright-version run: | version=$(node -e 'console.log(require("./package.json").workspaces.catalog["@playwright/test"])') echo "version=$version" >> "$GITHUB_OUTPUT" - name: Cache Playwright browsers + if: steps.evidence.outputs.reused != 'true' id: playwright-cache uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: @@ -209,23 +244,24 @@ jobs: key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium - name: Install Playwright system dependencies - if: runner.os == 'Linux' + if: steps.evidence.outputs.reused != 'true' && (runner.os == 'Linux') working-directory: packages/app run: bunx playwright install-deps chromium - name: Install Playwright browsers - if: steps.playwright-cache.outputs.cache-hit != 'true' + if: steps.evidence.outputs.reused != 'true' && (steps.playwright-cache.outputs.cache-hit != 'true') working-directory: packages/app run: bunx playwright install chromium - name: Run app e2e tests + if: steps.evidence.outputs.reused != 'true' run: bun --cwd packages/app test:e2e:local env: CI: true timeout-minutes: 30 - name: Upload Playwright artifacts - if: always() + if: steps.evidence.outputs.reused != 'true' && (always()) uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }} @@ -234,3 +270,11 @@ jobs: path: | packages/app/e2e/test-results packages/app/e2e/playwright-report + + - name: Record successful verification + if: success() && steps.evidence.outputs.reused != 'true' + uses: ./.github/actions/record-verification + with: + key: ${{ steps.evidence.outputs.key }} + path: ${{ steps.evidence.outputs.path }} + job-name: E2E Tests (${{ matrix.settings.name }}) diff --git a/.github/workflows/ci-typecheck.yml b/.github/workflows/ci-typecheck.yml index 5ac01932b4..2c426d05c8 100644 --- a/.github/workflows/ci-typecheck.yml +++ b/.github/workflows/ci-typecheck.yml @@ -32,6 +32,7 @@ on: permissions: contents: read + actions: read jobs: typecheck: @@ -41,16 +42,31 @@ jobs: - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Check previously verified content + id: evidence + uses: ./.github/actions/verified-content + with: + check: typecheck + job-name: Typecheck + runner-label: ubuntu-latest + - name: Setup Bun + if: steps.evidence.outputs.reused != 'true' uses: ./.github/actions/setup-bun + - name: Test CI verification fingerprint + run: node --test script/ci-fingerprint.test.mjs script/ci-evidence.test.mjs + - name: Run lint + if: steps.evidence.outputs.reused != 'true' run: bun run lint - name: Run typecheck + if: steps.evidence.outputs.reused != 'true' run: bun typecheck - name: Run DAG core behavior and coverage gate + if: steps.evidence.outputs.reused != 'true' working-directory: packages/opencode run: bun run test:dag-core timeout-minutes: 10 @@ -59,5 +75,14 @@ jobs: # must be verified before extraction and fail closed on mismatch. # Zero network (stub curl); portable across bash hosts. - name: Run oc install boundary tests + if: steps.evidence.outputs.reused != 'true' run: bash script/oc-install-boundary.test.sh timeout-minutes: 5 + + - name: Record successful verification + if: success() && steps.evidence.outputs.reused != 'true' + uses: ./.github/actions/record-verification + with: + key: ${{ steps.evidence.outputs.key }} + path: ${{ steps.evidence.outputs.path }} + job-name: Typecheck diff --git a/.specgit.yaml b/.specgit.yaml index 2995f89572..4f88adecbb 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,38 +1,11 @@ version: 1 -delivery: hooks-runtime-reliability +delivery: main-release-1042 context: kind: branch - branch: fix/557-hooks-runtime-reliability + branch: chore/579-main-release-1042 issues: - - 557 - - 558 - - 559 - - 560 - - 561 - - 562 - - 563 - - 564 - - 565 - - 566 + - 579 issueKinds: - - issue: 557 - kind: kind::fix - - issue: 558 - kind: kind::fix - - issue: 559 - kind: kind::fix - - issue: 560 - kind: kind::fix - - issue: 561 - kind: kind::fix - - issue: 562 - kind: kind::fix - - issue: 563 - kind: kind::fix - - issue: 564 - kind: kind::fix - - issue: 565 - kind: kind::fix - - issue: 566 - kind: kind::fix -pr: 567 + - issue: 579 + kind: kind::chore +pr: 580 diff --git a/README.md b/README.md index 5dba3b7383..5935c94fc1 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,7 @@ its single-session complement: one durable goal that the agent works toward autonomously across turns of the current session. - Commands: `/goal ` sets a goal and starts the loop; `/goal status|pause|resume|done|clear|stop` controls it; `/subgoal |list|remove |clear` manages subgoals attached to the active goal. +- Budget: `/goal --max-turns 30 ` sets a positive integer total budget (default 20). `/goal resume --max-turns 50` changes the total budget without resetting used turns; the new total must exceed used turns. Plain `/goal resume` retains the budget and permits one more execution when exhausted. - Judge loop: after each turn an external judge evaluates progress — `done` clears the goal, `continue` injects the next continuation turn against a configurable turn budget (budget exhaustion pauses the goal; it stays resumable). The agent can self-declare completion with the `goal(action: "complete")` tool, which bypasses the judge; `goal(action: "status")` inspects state. - Visibility: while a goal is active or paused, the system prompt carries a live goal block (text, status, turns used/remaining, subgoals, last judge verdict); the TUI sidebar shows a compact goal widget; `GET /session/:sessionID/goal` exposes the state (`404` when no goal is set). - Durability: goal state is persisted per session (`goal_state`), survives restarts, and is cleared automatically when the session is deleted. diff --git a/README.zh.md b/README.zh.md index 5e2e0482f4..d41422a9e7 100644 --- a/README.zh.md +++ b/README.zh.md @@ -228,6 +228,7 @@ DAG 相关的东西都放在 `.opencode/` 下,在 opencode 配置目录(`OPE 图编排把任务拆给多个子会话;目标循环是它的单会话互补形态:一个持久目标,智能体在当前会话里跨回合自主推进。 - **命令**:`/goal <文本>` 设定目标并启动循环;`/goal status|pause|resume|done|clear|stop` 控制;`/subgoal <文本>|list|remove |clear` 管理挂在当前目标下的子目标。 +- **轮次预算**:`/goal --max-turns 30 <文本>` 指定正整数总轮数(默认 20)。`/goal resume --max-turns 50` 恢复并调整总轮数,保留已用轮数,新总数必须大于已用轮数。不带参数的 `/goal resume` 保留预算,耗尽后仍可再执行一轮。 - **评审循环**:每回合结束后由外部评审判定进展——`done` 清除目标,`continue` 注入下一轮续跑提示,受可配置的回合预算约束(预算耗尽转暂停,可随时恢复)。智能体也可以用 `goal(action: "complete")` 工具自我宣告完成(绕过评审);`goal(action: "status")` 查询状态。 - **可见性**:目标激活或暂停期间,系统提示里带实时目标块(目标文本、状态、已用/总回合、子目标、最近一次评审判定);TUI 侧边栏有简洁的目标组件;`GET /session/:sessionID/goal` 暴露状态(未设目标时返回 `404`)。 - **持久化**:目标状态按会话持久化(`goal_state`),重启不丢,会话删除时自动清除。 diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 4e4b50e02a..bc109df45c 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -96,7 +96,7 @@ export const layer = Layer.effect( } commands[Default.GOAL] = { name: Default.GOAL, - description: "设定持久目标,自动循环执行直到完成 [status|pause|resume|done|clear|stop]", + description: "设定持久目标 [--max-turns N] <目标>;控制 [status|pause|resume [--max-turns N]|done|clear|stop]", source: "command", template: "", hints: ["$ARGUMENTS"], diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index 3caf56cffa..b242a4c980 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -6,6 +6,10 @@ export interface Runner { readonly ensureRunning: (work: Effect.Effect) => Effect.Effect readonly ensureRunningHandle: (work: Effect.Effect) => Effect.Effect> readonly startIfIdle: (work: Effect.Effect) => Effect.Effect>> + readonly startShellHandle: ( + work: Effect.Effect, + ready?: Latch.Latch, + ) => Effect.Effect> readonly startShell: (work: Effect.Effect, ready?: Latch.Latch) => Effect.Effect readonly cancel: Effect.Effect } @@ -154,7 +158,10 @@ export const make = ( }), ) - const startShell = (work: Effect.Effect, ready?: Latch.Latch): Effect.Effect => + const startShellHandle = ( + work: Effect.Effect, + ready?: Latch.Latch, + ): Effect.Effect> => SynchronizedRef.modifyEffect( ref, Effect.fnUntraced(function* (st) { @@ -183,7 +190,10 @@ export const make = ( { _tag: "Shell", shell }, ] as const }), - ).pipe(Effect.flatten) + ) + + const startShell = (work: Effect.Effect, ready?: Latch.Latch) => + startShellHandle(work, ready).pipe(Effect.flatten) const cancel = SynchronizedRef.modify(ref, (st) => { switch (st._tag) { @@ -229,6 +239,7 @@ export const make = ( ensureRunningHandle, startIfIdle, startShell, + startShellHandle, cancel, } } diff --git a/packages/opencode/src/goal/goal.ts b/packages/opencode/src/goal/goal.ts index ea46ddf37a..d8f198490a 100644 --- a/packages/opencode/src/goal/goal.ts +++ b/packages/opencode/src/goal/goal.ts @@ -56,7 +56,7 @@ export interface Interface { readonly lastOutcome: (sessionID: SessionID) => Effect.Effect readonly set: (sessionID: SessionID, goal: string, maxTurns?: number) => Effect.Effect readonly pause: (sessionID: SessionID, reason: string) => Effect.Effect - readonly resume: (sessionID: SessionID) => Effect.Effect + readonly resume: (sessionID: SessionID, maxTurns?: number) => Effect.Effect readonly clear: (sessionID: SessionID) => Effect.Effect /** Session-deletion cleanup: remove goal_state AND all goal_outcome rows. */ readonly purgeSession: (sessionID: SessionID) => Effect.Effect @@ -596,11 +596,14 @@ const serviceLayer = Layer.effect( }) }) - const resume = Effect.fn("Goal.resume")(function* (sessionID: SessionID) { + const resume = Effect.fn("Goal.resume")(function* (sessionID: SessionID, maxTurns?: number) { const updated = yield* transition(sessionID, (state) => { if (!state || state.status !== "paused") return { tag: "noop", value: undefined } + if (maxTurns !== undefined && (!Number.isSafeInteger(maxTurns) || maxTurns <= state.turns_used)) + return { tag: "noop", value: undefined } const updated = GoalState.advance(state, { status: "active", + max_turns: GoalState.nni(maxTurns ?? state.max_turns), consecutive_parse_failures: GoalState.nni(0), paused_reason: undefined, last_turn_at: Date.now(), @@ -814,7 +817,22 @@ const serviceLayer = Layer.effect( const dispatch = Effect.fn("Goal.dispatch")(function* (sessionID: SessionID, args: string) { const trimmed = args.trim() - const lower = trimmed.toLowerCase() + const budget = /^(?:(resume)\s+)?--max-turns(?:\s+(\S+))?(?:\s+([\s\S]*))?$/i.exec(trimmed) + const maxTurns = budget ? Number(budget[2]) : undefined + const invalidBudget = + budget && + (!/^\d+$/.test(budget[2] ?? "") || + !Number.isSafeInteger(maxTurns) || + (maxTurns ?? 0) < 1 || + (budget[1] ? !!budget[3] : !budget[3]?.trim())) + if (invalidBudget || (!budget && /^(?:resume\s+)?--/i.test(trimmed))) { + return { + type: "message" as const, + text: "轮预算必须是正整数。用法:/goal --max-turns <目标> 或 /goal resume --max-turns 。", + } + } + const text = budget && !budget[1] ? (budget[3] ?? "").trim() : trimmed + const lower = budget ? (budget[1] ? "resume" : undefined) : trimmed.toLowerCase() const isControlCommand = lower === "" || @@ -867,7 +885,12 @@ const serviceLayer = Layer.effect( text: "Session 正在执行中。请先 /stop 中断后再 /goal resume。", } } - const result = yield* resume(sessionID) + if (maxTurns !== undefined) { + const current = yield* loadState(sessionID) + if (current?.status === "paused" && maxTurns <= current.turns_used) + return { type: "message" as const, text: `总轮预算必须大于已用的 ${current.turns_used} 轮。` } + } + const result = yield* resume(sessionID, maxTurns) if (!result) return { type: "message" as const, text: "没有已暂停的目标可以恢复。" } // Warning UX for budget-exhaustion pauses: we kept turns_used intact // (see resume()), so a goal paused because turns >= max will resume @@ -876,7 +899,7 @@ const serviceLayer = Layer.effect( // text a second later, which looks like resume didn't work. const announceMsg = result.turns_used >= result.max_turns - ? `⚠ 目标已恢复,但轮预算已耗尽(${result.turns_used}/${result.max_turns} 轮)。resume 会重启一整轮执行:本轮内任务完成才会计为达成,否则 judge 会再次暂停。建议 /goal clear 后用更大的 maxTurns 重新设定。` + ? `⚠ 目标已恢复,但轮预算已耗尽(${result.turns_used}/${result.max_turns} 轮)。resume 会重启一整轮执行:本轮内任务完成才会计为达成,否则 judge 会再次暂停。再次暂停后可用 /goal resume --max-turns ${result.turns_used + GoalPrompts.DEFAULT_MAX_TURNS} 增加总轮预算。` : undefined return { type: "kick" as const, @@ -915,8 +938,7 @@ const serviceLayer = Layer.effect( // done row leftover (loop.ts usually auto-clears; defensive guard) yield* clear(sessionID) } - const maxTurns = GoalPrompts.DEFAULT_MAX_TURNS - const state = yield* set(sessionID, trimmed, maxTurns) + const state = yield* set(sessionID, text, maxTurns ?? GoalPrompts.DEFAULT_MAX_TURNS) return { type: "kick" as const, text: state.goal, diff --git a/packages/opencode/src/goal/loop.ts b/packages/opencode/src/goal/loop.ts index fc8b8c704f..cb9b68ef73 100644 --- a/packages/opencode/src/goal/loop.ts +++ b/packages/opencode/src/goal/loop.ts @@ -7,6 +7,7 @@ import { InstanceState } from "@/effect/instance-state" import { EventV2Bridge } from "@/event-v2-bridge" import { SessionStatus } from "@/session/status" import { Session } from "@/session/session" +import { MessageV2 } from "@/session/message-v2" import { SessionPrompt } from "@/session/prompt" import { Provider } from "@/provider/provider" import { Goal } from "./goal" @@ -59,11 +60,15 @@ export class GoalLoopJudgeLLM extends Context.Service, + msgs: ReadonlyArray<{ + info: { role: "user" | "assistant"; time: { created: number } } + parts?: ReadonlyArray<{ type: string; ignored?: boolean }> + }>, ): boolean { let lastUserAt = -1 let lastAsstAt = -1 for (const m of msgs) { + if (MessageV2.isIgnoredUser(m)) continue const t = m.info.time?.created if (typeof t !== "number") continue if (m.info.role === "user" && t > lastUserAt) lastUserAt = t @@ -118,6 +123,16 @@ const serviceLayer = Layer.effect( const status = yield* SessionStatus.Service const automation = yield* SessionAutomationLease.Service + const workMessages = Effect.fn("GoalLoop.workMessages")(function* (sessionID: SessionID, limit = 20) { + let count = limit + for (;;) { + const messages = yield* sessions.messages({ sessionID, limit: count }) + const work = messages.filter((message) => !MessageV2.isIgnoredUser(message)) + if (work.length >= limit || messages.length < count) return work.slice(-limit) + count *= 2 + } + }) + const pauseGoal = Effect.fnUntraced(function* (sessionID: SessionID, reason: string) { const paused = yield* goal.pauseAndPublish(sessionID, reason) if (paused) @@ -240,6 +255,7 @@ const serviceLayer = Layer.effect( // at the same terminal points where afterIdle unregisters the goal // automation. const evaluatedRevisions = new Map() + const inFlight = new Map() const afterIdle = Effect.fn("GoalLoop.afterIdle")(function* (sessionID: SessionID, scanResume?: boolean) { // GOAL-TURN-SCOPE: the goal-driven turn that produced this idle has @@ -257,37 +273,17 @@ const serviceLayer = Layer.effect( // newer revision). If this process already evaluated the CURRENT // revision, the scan trigger is stale — skip. if (scanResume && evaluatedRevisions.get(sessionID) === (goalState.revision ?? 0)) return - // issue #285 — durable boundary gate (scan path only). The - // evaluatedRevisions map above is process-local and dies with the - // process; the goal row's last_judged_msg is the crash-surviving record - // of which boundary was already judged and committed. While the session - // window still ends on that same message, no new progress has landed — - // re-judging would inflate turns_used. Live idle events are never gated - // here: every dispatched continuation produces a fresh assistant - // message, so the live path always judges a new boundary. - // - // GOAL-01: the gate suppresses RE-JUDGMENT, never the drive. The old - // behavior `return`ed here, which permanently stranded goals whose - // committed continue evaluation lost its continuation to a crash - // (process died after the commit, before the next assistant message): - // every boot scan re-hit this gate, nothing ever dispatched another - // turn, and last_judged_msg (only written by judge commits) never - // advanced. Now the gate sets suppressJudge and falls through — the - // judge call and its updateAfterJudge commit below are skipped (the - // boundary is already judged; re-judging is what would inflate - // turns_used), but the shared continuation dispatch still runs and - // restores the driver. A second crash repeats this safely: a fresh - // process starts with an empty evaluatedRevisions map and an unchanged - // last_judged_msg, so the gate fires and re-dispatches again. + // A committed work boundary survives restart and control-command idle events. + // Reuse its verdict without charging another turn, but restore a continuation + // lost to a crash or invalidated by a subgoal edit. let suppressJudge = false - if (scanResume && goalState.last_judged_msg) { - const win = yield* sessions - .messages({ sessionID, limit: 20 }) - .pipe( - Effect.catchIf((e) => NotFoundError.isInstance(e), () => - Effect.succeed([] as SessionV1.WithParts[]), - ), - ) + if (goalState.last_judged_msg) { + const win = yield* workMessages(sessionID).pipe( + Effect.catchIf( + (e) => NotFoundError.isInstance(e), + () => Effect.succeed([] as SessionV1.WithParts[]), + ), + ) const lastSeen = [...win].reverse().find((m) => m.info.role === "assistant") if (lastSeen && lastSeen.info.id === goalState.last_judged_msg) suppressJudge = true } @@ -310,17 +306,13 @@ const serviceLayer = Layer.effect( // fires. Uses pauseAndPublish (fiber-safe) — NOT goal.pause — because // we ARE the loop fiber tracked in the fibers map (same self-interrupt // hazard discipline as the done / shouldPreempt branches below). - if ( - goalState.turns_used === 0 && - Date.now() - goalState.created_at > GoalPrompts.FRESHNESS_THRESHOLD - ) { - const probeMsgs = yield* sessions - .messages({ sessionID, limit: 1 }) - .pipe( - Effect.catchIf((e) => NotFoundError.isInstance(e), () => - Effect.succeed([] as SessionV1.WithParts[]), - ), - ) + if (goalState.turns_used === 0 && Date.now() - goalState.created_at > GoalPrompts.FRESHNESS_THRESHOLD) { + const probeMsgs = yield* workMessages(sessionID, 1).pipe( + Effect.catchIf( + (e) => NotFoundError.isInstance(e), + () => Effect.succeed([] as SessionV1.WithParts[]), + ), + ) const hasAssistant = probeMsgs.some((m) => m.info.role === "assistant") if (isStaleZombie(goalState, hasAssistant)) { yield* pauseGoal( @@ -336,13 +328,12 @@ const serviceLayer = Layer.effect( // (same pattern as MessageV2.stream) so the no-lastAssistant branch // below pauses visibly instead of this typed failure escaping and // leaving the goal permanently "active". - const msgs = yield* sessions - .messages({ sessionID, limit: 20 }) - .pipe( - Effect.catchIf((e) => NotFoundError.isInstance(e), () => - Effect.succeed([] as SessionV1.WithParts[]), - ), - ) + const msgs = yield* workMessages(sessionID).pipe( + Effect.catchIf( + (e) => NotFoundError.isInstance(e), + () => Effect.succeed([] as SessionV1.WithParts[]), + ), + ) const lastAssistant = [...msgs].reverse().find((m) => m.info.role === "assistant") if (!lastAssistant) { // No assistant message in the last 20 — the conversation may have @@ -405,20 +396,34 @@ const serviceLayer = Layer.effect( : { verdict: "continue" as const, reason: "上一轮无文本输出(纯工具调用),跳过判定直接继续", parseFailed: false } const updateResult = Option.getOrUndefined( - yield* automation.use( - observedLease, - goal.updateAfterJudge( - sessionID, - verdict.verdict, - verdict.reason, - verdict.parseFailed, - { - goalID: goalState.goal_id ?? "legacy", - revision: goalState.revision ?? 0, - }, - lastAssistant.info.id, + yield* automation + .use( + observedLease, + promptSvc + .withIdle( + sessionID, + Effect.gen(function* () { + const current = yield* workMessages(sessionID) + const latest = [...current].reverse().find((m) => m.info.role === "assistant") + if (latest?.info.id !== lastAssistant.info.id || shouldPreempt(current)) return undefined + return yield* goal.updateAfterJudge( + sessionID, + verdict.verdict, + verdict.reason, + verdict.parseFailed, + { goalID: goalState.goal_id ?? "legacy", revision: goalState.revision ?? 0 }, + lastAssistant.info.id, + ) + }), + ) + .pipe(Effect.map(Option.getOrUndefined)), + ) + .pipe( + Effect.catchIf( + (error) => NotFoundError.isInstance(error), + () => pauseGoal(sessionID, "评审期间会话消息已不可用,目标已暂停").pipe(Effect.as(Option.none())), + ), ), - ), ) if (!updateResult) return @@ -483,22 +488,7 @@ const serviceLayer = Layer.effect( } const currentStatus = yield* status.get(sessionID) - if (currentStatus.type !== "idle") { - // Session is no longer idle by the time dispatch resumes — it flipped - // during the judge call (5-30s latency), or between the gate and here - // on the GOAL-01 judge-less fall-through. - // Previously this was a bare `return` that left the goal silently - // "active" with no continuation. Pause with a visible reason so the - // user knows the loop was interrupted by a status change. - // Neutral wording on purpose: this pause is reachable both after a - // real judge call AND via the GOAL-01 gate-hit fall-through, where - // the judge was suppressed — the user-visible reason must not claim - // a judge was running. - const pauseMsg = `会话状态变化(${currentStatus.type}),目标已暂停` - yield* pauseGoal(sessionID, pauseMsg).pipe(Effect.ignore) - yield* promptSvc.prompt({ sessionID, noReply: true, parts: [{ type: "text", text: `⏸ 目标已暂停 — ${pauseMsg}` }] }).pipe(Effect.ignore) - return - } + if (currentStatus.type !== "idle") return // Reload messages before dispatch — the pre-judge snapshot may be stale // (the user can send messages during the 5-30s judge latency, or during @@ -506,13 +496,12 @@ const serviceLayer = Layer.effect( // Same vanished-session tolerance as the pre-judge window: NotFoundError // becomes an empty window (shouldPreempt is defensively false for it), // never a typed failure escaping the fork. - const freshMsgs = yield* sessions - .messages({ sessionID, limit: 20 }) - .pipe( - Effect.catchIf((e) => NotFoundError.isInstance(e), () => - Effect.succeed([] as SessionV1.WithParts[]), - ), - ) + const freshMsgs = yield* workMessages(sessionID).pipe( + Effect.catchIf( + (e) => NotFoundError.isInstance(e), + () => Effect.succeed([] as SessionV1.WithParts[]), + ), + ) if (shouldPreempt(freshMsgs)) { // Same self-interrupt hazard as the done branch above: we ARE the @@ -651,6 +640,15 @@ const serviceLayer = Layer.effect( const scope = yield* Scope.Scope const goalState = yield* goal.load(sessionID) if (!goalState || goalState.status !== "active") return + const messages = yield* workMessages(sessionID).pipe( + Effect.catchIf( + (e) => NotFoundError.isInstance(e), + () => Effect.succeed([] as SessionV1.WithParts[]), + ), + ) + const last = [...messages].reverse().find((m) => m.info.role === "assistant") + const boundary = `${goalState.goal_id}:${goalState.revision}:${last?.info.id}` + if (inFlight.get(sessionID) === boundary) return // D-4 gate (scan path only): skip when this process already evaluated // the CURRENT revision — the boot snapshot went stale after a // legitimate evaluation (e.g. the session's own idle event ran before @@ -669,7 +667,13 @@ const serviceLayer = Layer.effect( // zero logs — an invisible stall. Interrupts (fiber replacement by a // newer idle, scope disposal) stay silent: they are the normal // overwrite path, same F1 discipline as the continuation catch below. + inFlight.set(sessionID, boundary) const fiber = yield* afterIdle(sessionID, scanResume).pipe( + Effect.ensuring( + Effect.sync(() => { + if (inFlight.get(sessionID) === boundary) inFlight.delete(sessionID) + }), + ), Effect.catchCause((cause) => Cause.hasInterrupts(cause) ? Effect.void diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 4ae3957a25..392c7b2083 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -577,6 +577,18 @@ export const filterCompactedEffect = Effect.fnUntraced(function* (sessionID: Ses return filterCompacted(yield* stream(sessionID)) }) +/** Presentation-only user messages do not create a new model work boundary. */ +export function isIgnoredUser(message: { + info: { role: string } + parts?: ReadonlyArray<{ type: string; ignored?: boolean }> +}) { + return ( + message.info.role === "user" && + !!message.parts?.length && + message.parts.every((part) => part.type === "text" && part.ignored) + ) +} + // filterCompacted reorders messages for model consumption // ([compaction-user, summary, ...retained tail..., continue-user]), so array // position is not chronological. Derive each binding by max (time.created, id) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 540b9f3018..de1995152b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -119,6 +119,11 @@ function isOrphanedInterruptedTool(part: SessionV1.ToolPart) { export interface Interface { readonly cancel: (sessionID: SessionID) => Effect.Effect readonly prompt: (input: PromptInput) => Effect.Effect + /** Run a short commit while idle, serialized with prompt admission. Never start or await a turn here. */ + readonly withIdle: ( + sessionID: SessionID, + work: Effect.Effect, + ) => Effect.Effect, E, R> readonly prepareIfIdle: (input: PromptInput) => Effect.Effect, Image.Error> readonly promptIfIdle: (input: PromptInput) => Effect.Effect, Image.Error> readonly loop: (input: LoopInput) => Effect.Effect @@ -1479,6 +1484,7 @@ export const layer = Layer.effect( )(function* (input: PromptInput) { const wait = yield* promptLocks.withLock(input.sessionID)( Effect.gen(function* () { + if (goal && input.noReply !== true) yield* goal.clearTurnDriven(input.sessionID) const admitted = yield* admitPrompt(input) if (!admitted.run) return Effect.succeed(admitted.message) return yield* state.ensureRunningHandle( @@ -1491,6 +1497,16 @@ export const layer = Layer.effect( return yield* wait }) + const withIdle: Interface["withIdle"] = Effect.fn("SessionPrompt.withIdle")( + (sessionID: SessionID, work: Effect.Effect) => + promptLocks.withLock(sessionID)( + Effect.gen(function* () { + if ((yield* status.get(sessionID)).type !== "idle") return Option.none() + return Option.some(yield* work) + }), + ), + ) + const prepareIfIdle: Interface["prepareIfIdle"] = Effect.fn("SessionPrompt.prepareIfIdle")(function* ( input: PromptInput, ) { @@ -1584,6 +1600,7 @@ export const layer = Layer.effect( yield* Effect.logInfo("loop", { "session.id": sessionID, step }) let msgs = yield* MessageV2.filterCompactedEffect(sessionID).pipe( + Effect.map((messages) => messages.filter((message) => !MessageV2.isIgnoredUser(message))), Effect.provideService(Database.Service, database), ) @@ -1952,14 +1969,20 @@ export const layer = Layer.effect( const loop: (input: LoopInput) => Effect.Effect = Effect.fn("SessionPrompt.loop")(function* ( input: LoopInput, ) { - return yield* state.ensureRunning(input.sessionID, lastAssistant(input.sessionID), runLoop(input.sessionID)) + const result = yield* promptLocks.withLock(input.sessionID)( + state.ensureRunningHandle(input.sessionID, lastAssistant(input.sessionID), runLoop(input.sessionID)), + ) + return yield* result }) const shell: (input: ShellInput) => Effect.Effect = Effect.fn( "SessionPrompt.shell", )(function* (input: ShellInput) { const ready = yield* Latch.make() - return yield* state.startShell(input.sessionID, lastAssistant(input.sessionID), shellImpl(input, ready), ready) + const result = yield* promptLocks.withLock(input.sessionID)( + state.startShellHandle(input.sessionID, lastAssistant(input.sessionID), shellImpl(input, ready), ready), + ) + return yield* result }) // #409: early-return command dispatches (/memory, /trust, /goal non-kick) @@ -1968,8 +1991,12 @@ export const layer = Layer.effect( // startIfIdle keeps today's inline semantics while another turn is running // (no queueing, no second idle); the in-flight turn re-emits idle itself. const commandTurn = Effect.fnUntraced(function* (sessionID: SessionID, work: Effect.Effect) { - const handle = yield* state.startIfIdle(sessionID, lastAssistant(sessionID), work) - return yield* Option.getOrElse(handle, () => work) + return yield* promptLocks.withLock(sessionID)( + Effect.gen(function* () { + const handle = yield* state.startIfIdle(sessionID, lastAssistant(sessionID), work) + return yield* Option.getOrElse(handle, () => work) + }), + ) }) const command = Effect.fn("SessionPrompt.command")(function* (input: CommandInput) { @@ -2165,6 +2192,7 @@ export const layer = Layer.effect( messageID: userMsg.id, sessionID: input.sessionID, type: "text", + ignored: true, text: `⚠️ /${input.command} 执行失败,请检查日志。`, synthetic: true, time: { start: now, end: now }, @@ -2179,6 +2207,7 @@ export const layer = Layer.effect( messageID: userMsg.id, sessionID: input.sessionID, type: "text", + ignored: true, text: `/${input.command} ${input.arguments}`.trim(), } yield* sessions.updatePart(cmdText) @@ -2193,6 +2222,7 @@ export const layer = Layer.effect( messageID: userMsg.id, sessionID: input.sessionID, type: "text", + ignored: true, text: dispatchText, time: { start: now, end: now }, } @@ -2312,6 +2342,7 @@ export const layer = Layer.effect( return Service.of({ cancel, prompt, + withIdle, prepareIfIdle, promptIfIdle, loop, @@ -2324,7 +2355,7 @@ export const layer = Layer.effect( export const defaultLayer = Layer.suspend(() => layer.pipe( - Layer.provide(SessionRunState.defaultLayer), + Layer.provide(Layer.mergeAll(Goal.defaultLayer, SessionRunState.defaultLayer)), Layer.provide(SessionStatus.defaultLayer), Layer.provide(SessionCompaction.defaultLayer), Layer.provide(SessionProcessor.defaultLayer), diff --git a/packages/opencode/src/session/prompt/goal.txt b/packages/opencode/src/session/prompt/goal.txt index d3a59883d4..5d709c7c51 100644 --- a/packages/opencode/src/session/prompt/goal.txt +++ b/packages/opencode/src/session/prompt/goal.txt @@ -9,9 +9,11 @@ A `goal` **tool** is also available. Use `goal(action: "complete")` to self-decl ## Commands (user-facing; only you or the user can issue these) - `/goal ` — Set a new autonomous goal. The agent will work in a loop until the goal is achieved or the turn budget is exhausted. +- `/goal --max-turns ` — Set a goal with a positive integer total turn budget (default 20). - `/goal status` — Show the current goal state (active/paused/achieved, turns used/total). - `/goal pause` — Pause the current goal. Use `/goal resume` to continue. -- `/goal resume` — Resume a paused goal. +- `/goal resume` — Resume a paused goal without resetting used turns or changing the budget. If exhausted, this permits one additional execution before the judge may pause it again. +- `/goal resume --max-turns ` — Resume with a new total budget greater than the turns already used. - `/goal clear` or `/goal stop` — Clear the current goal and stop the loop. - `/goal done` — Explicitly mark the goal as finished (same as the `goal` tool with `action=complete`). - `/subgoal ` — Add a subgoal to the current goal. diff --git a/packages/opencode/src/session/run-state.ts b/packages/opencode/src/session/run-state.ts index e88fdd2992..a8ffc422fe 100644 --- a/packages/opencode/src/session/run-state.ts +++ b/packages/opencode/src/session/run-state.ts @@ -26,6 +26,12 @@ export interface Interface { onInterrupt: Effect.Effect, work: Effect.Effect, ) => Effect.Effect>> + readonly startShellHandle: ( + sessionID: SessionID, + onInterrupt: Effect.Effect, + work: Effect.Effect, + ready?: Latch.Latch, + ) => Effect.Effect> readonly startShell: ( sessionID: SessionID, onInterrupt: Effect.Effect, @@ -119,18 +125,26 @@ export const layer = Layer.effect( return yield* (yield* runner(sessionID, onInterrupt)).startIfIdle(work) }) - const startShell = Effect.fn("SessionRunState.startShell")(function* ( - sessionID: SessionID, - onInterrupt: Effect.Effect, - work: Effect.Effect, - ready?: Latch.Latch, - ) { - return yield* (yield* runner(sessionID, onInterrupt)) - .startShell(work, ready) - .pipe(Effect.catchTag("RunnerBusy", () => Effect.fail(busyError(sessionID)))) - }) + const startShellHandle: Interface["startShellHandle"] = Effect.fn("SessionRunState.startShellHandle")( + function* (sessionID, onInterrupt, work, ready) { + const result = yield* (yield* runner(sessionID, onInterrupt)).startShellHandle(work, ready) + return result.pipe(Effect.catchTag("RunnerBusy", () => Effect.fail(busyError(sessionID)))) + }, + ) - return Service.of({ assertNotBusy, cancel, ensureRunning, ensureRunningHandle, startIfIdle, startShell }) + const startShell: Interface["startShell"] = Effect.fn("SessionRunState.startShell")((...args) => + startShellHandle(...args).pipe(Effect.flatten), + ) + + return Service.of({ + assertNotBusy, + cancel, + ensureRunning, + ensureRunningHandle, + startIfIdle, + startShell, + startShellHandle, + }) }), ) diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts index 51f1e71e28..b73747ceaf 100644 --- a/packages/opencode/src/tool/question.ts +++ b/packages/opencode/src/tool/question.ts @@ -1,6 +1,7 @@ -import { Effect, Schema } from "effect" +import { Effect, Option, Schema } from "effect" import * as Tool from "./tool" import { Question } from "../question" +import { Goal } from "../goal/goal" import DESCRIPTION from "./question.txt" export const Parameters = Schema.Struct({ @@ -21,6 +22,15 @@ export const QuestionTool = Tool.define, ctx: Tool.Context) => Effect.gen(function* () { + const goal = Option.getOrUndefined(yield* Effect.serviceOption(Goal.Service)) + if (goal && (yield* goal.isTurnDriven(ctx.sessionID))) { + return { + title: "Autonomous turn: question unavailable", + output: + "Interactive questions are disabled during Goal execution. Make a reasonable decision and continue. If user input is essential, explain the blocker in your final response so the goal can pause.", + metadata: { answers: [] }, + } + } const answers = yield* question.ask({ sessionID: ctx.sessionID, questions: params.questions, diff --git a/packages/opencode/test/dag/dag-goal-wake-retrigger.test.ts b/packages/opencode/test/dag/dag-goal-wake-retrigger.test.ts index 245676836c..2ced7b0ce0 100644 --- a/packages/opencode/test/dag/dag-goal-wake-retrigger.test.ts +++ b/packages/opencode/test/dag/dag-goal-wake-retrigger.test.ts @@ -154,6 +154,7 @@ function goalWakeLayer(input: { childPrompts: Queue.Queue; fail const base = Layer.mergeAll(database, events, bridge, store, projector, dag, goal, status) const childTitles = new Map() const created: string[] = [] + let lastAssistant = mkAssistant() const session = Layer.mock(Session.Service, { get: (_sessionID) => Effect.succeed({ @@ -183,7 +184,7 @@ function goalWakeLayer(input: { childPrompts: Queue.Queue; fail // GoalLoop.afterIdle reads the last-20 message window: an assistant // message must exist so the judge is reached (no stale-zombie / no-assistant // early pauses). - messages: () => Effect.succeed([mkAssistant()]), + messages: () => Effect.succeed([lastAssistant]), }) const deliver = Effect.fn("test.goalWake.SessionPrompt.deliver")(function* (value: SessionPrompt.PromptInput) { const sessionID = value.sessionID as string @@ -203,6 +204,7 @@ function goalWakeLayer(input: { childPrompts: Queue.Queue; fail // dag — the blocked claim the unregister re-trigger exists to retry // (GOAL-FP-01-02 / R1). Later parent prompts are goal continuations and // must not re-emit (the mock has no real runner turn). + lastAssistant = reply(sessionID, "parent turn") if (parentPromptCalls === 0) { parentPromptCalls += 1 yield* Effect.serviceOption(EventV2Bridge.Service).pipe( @@ -216,7 +218,7 @@ function goalWakeLayer(input: { childPrompts: Queue.Queue; fail ), ) } - return reply(sessionID, "parent turn") + return lastAssistant } const release = yield* Deferred.make() yield* Queue.offer(input.childPrompts, { title: childTitles.get(sessionID) ?? sessionID, release }) @@ -387,13 +389,21 @@ describe("DagLoop final wake delivery re-triggers the goal (GOAL-FP-01-02)", () "wake was never reported", ) - // Public contract: with NO further idle events, the dag release must - // itself re-trigger the goal evaluation. judgeCalls > 0 proves - // GoalLoop.afterIdle ran a full cycle (lease claimed → judge → - // updateAfterJudge → continuation dispatch). + // Observe the committed Goal state and continuation, not merely entry + // into the judge: the durable commit is asynchronous after that call. yield* pollWithTimeout( - Effect.sync(() => (judgeCalls >= 1 ? true : undefined)), - "goal was not re-evaluated after the dag lease release (GOAL-FP-01-02)", + goal + .load(sid) + .pipe( + Effect.map((state) => + state && + state.turns_used >= 1 && + promptCalls.some((p) => !p.noReply && p.text.includes("ship the feature")) + ? state + : undefined, + ), + ), + "goal did not progress after the dag lease release (GOAL-FP-01-02)", "5 seconds", ) diff --git a/packages/opencode/test/effect/runner.test.ts b/packages/opencode/test/effect/runner.test.ts index afa76cc6da..3c9882651b 100644 --- a/packages/opencode/test/effect/runner.test.ts +++ b/packages/opencode/test/effect/runner.test.ts @@ -130,6 +130,21 @@ describe("Runner", () => { }), ) + it.live( + "startShellHandle reserves the runner before its result is awaited", + Effect.gen(function* () { + const scope = yield* Scope.Scope + const runner = Runner.make(scope) + const release = yield* Deferred.make() + const result = yield* runner.startShellHandle(Deferred.await(release).pipe(Effect.as("shell-done"))) + expect(runner.busy).toBe(true) + expect(runner.state._tag).toBe("Shell") + yield* Deferred.succeed(release, undefined) + expect(yield* result).toBe("shell-done") + expect(runner.busy).toBe(false) + }), + ) + it.live( "startIfIdle atomically rejects replacement work while the first run is active", Effect.gen(function* () { diff --git a/packages/opencode/test/goal/bootstrap-wiring.test.ts b/packages/opencode/test/goal/bootstrap-wiring.test.ts index 99d1c95346..3c01522508 100644 --- a/packages/opencode/test/goal/bootstrap-wiring.test.ts +++ b/packages/opencode/test/goal/bootstrap-wiring.test.ts @@ -2,6 +2,8 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { NodeFileSystem } from "@effect/platform-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Session } from "@/session/session" +import { SessionPrompt } from "@/session/prompt" import { Goal } from "@/goal/goal" import { SessionStatus } from "@/session/status" import { SessionID } from "@/session/schema" @@ -68,3 +70,56 @@ describe("GoalLoop production wiring — idle must drive afterIdle", () => { 20_000, ) }) + +it.live( + "production AppLayer dispatches Goal controls without invoking the model", + () => + provideTmpdirInstance((directory) => + Effect.promise(async () => { + await Bun.write( + `${directory}/opencode.json`, + JSON.stringify({ + model: "test/test-model", + formatter: false, + lsp: false, + provider: { + test: { + npm: "@ai-sdk/openai-compatible", + models: { "test-model": { name: "Test Model" } }, + options: { apiKey: "test", baseURL: "http://127.0.0.1:1/v1" }, + }, + }, + }), + ) + const { AppRuntime } = await import("@/effect/app-runtime") + await AppRuntime.runPromise( + Effect.gen(function* () { + const store = yield* InstanceStore.Service + yield* store.provide( + { directory }, + Effect.gen(function* () { + const session = yield* (yield* Session.Service).create({ title: "Goal wiring" }) + const goal = yield* Goal.Service + yield* goal.set(session.id, "production Goal wiring", 7) + yield* goal.pause(session.id, "smoke test") + const result = yield* (yield* SessionPrompt.Service).command({ + sessionID: session.id, + command: "goal", + arguments: "status", + }) + const text = result.parts + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n") + expect(text).toContain("production Goal wiring") + expect(text).toContain("0/7") + expect((yield* goal.load(session.id))?.status).toBe("paused") + yield* goal.clear(session.id) + }), + ) + }), + ) + }), + ), + 20_000, +) diff --git a/packages/opencode/test/goal/e2e-loop.test.ts b/packages/opencode/test/goal/e2e-loop.test.ts index 5635ac8dad..6547d10cc4 100644 --- a/packages/opencode/test/goal/e2e-loop.test.ts +++ b/packages/opencode/test/goal/e2e-loop.test.ts @@ -759,8 +759,8 @@ describe("GoalLoop — real SessionRunState admission seam (GOAL-FP-01-13)", () }) const promptMock = Layer.mock(SessionPrompt.Service, { prompt: () => Effect.die("the direct prompt path is not exercised in this scenario"), + ...withIdleAdmission({ promptIfIdle }), prepareIfIdle, - promptIfIdle, }) const judgeMock = Layer.succeed( GoalLoopJudgeLLM, @@ -983,84 +983,6 @@ describe("GoalLoop — empty assistant text → synthetic continue, no stall (br ) }) -// Branch 3 (loop.ts): after the judge call returns, the session status is no -// longer idle (5-30s of judge latency). The loop now pauses visibly instead of -// bare-returning. Status is pre-set to busy so afterIdle's post-judge status -// check observes a non-idle state; the raw idle-event publish drives afterIdle -// without clearing the stored busy entry. -describe("GoalLoop — status changed during judge → visible pause (branch 3)", () => { - let judgeCalls = 0 - const promptCalls: { noReply?: boolean; text: string }[] = [] - const reset = () => { - judgeCalls = 0 - promptCalls.length = 0 - } - - const sessionMock = Layer.succeed(Session.Service, { - messages: () => Effect.succeed([mkAssistant()]), - } as never) - const providerMock = Layer.succeed(Provider.Service, {} as never) - const judgeMock = Layer.succeed( - GoalLoopJudgeLLM, - GoalLoopJudgeLLM.of({ - call: () => - Effect.sync(() => { - judgeCalls += 1 - return JSON.stringify({ done: false, reason: "more steps" }) - }), - }), - ) - - const branchLayer = GoalLoop.layer.pipe( - Layer.provide(sessionMock), - Layer.provide(recordingPrompt(promptCalls)), - Layer.provide(providerMock), - Layer.provide(judgeMock), - Layer.provideMerge(Goal.defaultLayer), - // provideMerge (not provide): the test body yields SessionStatus.Service to - // pre-set busy, and afterIdle must read that SAME instance — a consumed - // (non-merged) SessionStatus would be invisible to the test body AND could - // diverge from the one afterIdle uses. - Layer.provideMerge(SessionStatus.defaultLayer), - Layer.provideMerge(EventV2Bridge.defaultLayer), - ) - const it = testEffect(branchLayer) - - it.instance("judge 期间 status 变非 idle → goal paused + 可见提示", () => - Effect.gen(function* () { - reset() - const loop = yield* GoalLoop.Service - const goal = yield* Goal.Service - const status = yield* SessionStatus.Service - const events = yield* EventV2Bridge.Service - yield* loop.init() - const sid = SessionID.descending() - yield* goal.set(sid, "ship the feature", 10) - // Make the session non-idle so afterIdle's post-judge status check sees - // busy. The raw idle-event publish below drives afterIdle WITHOUT - // touching the status map, so the busy entry persists. - yield* status.set(sid, { type: "busy" }) - yield* Effect.yieldNow - - yield* events.publish(SessionStatus.Event.Status, { sessionID: sid, status: { type: "idle" } }) - yield* pollWithTimeout( - Effect.gen(function* () { - const g = yield* goal.load(sid) - return g?.status === "paused" ? true : undefined - }), - "branch 3 never paused the goal", - "5 seconds", - ) - - expect(judgeCalls).toBeGreaterThanOrEqual(1) - const paused = yield* goal.load(sid) - expect(paused?.status).toBe("paused") - expect(String(paused?.paused_reason)).toContain("状态变化") - expect(promptCalls.some((p) => p.noReply)).toBe(true) - }), - ) -}) - // Branch 4 (loop.ts): the continuation dispatch fails with an INTERRUPT cause // (user pressed ESC mid-dispatch). The loop logs and returns WITHOUT pausing, // relying on the session always re-emitting idle (SessionStatus.set publishes @@ -1583,11 +1505,10 @@ describe("GoalLoop — startup scan scoping and hardening (GOAL-FP-01-04 follow- "the scan's evaluation never reached the judge", "5 seconds", ) - // Now a second evaluation races it: the idle event drives an - // independent trigger for the SAME turn boundary. The fiber map's - // interrupt-on-replace kills the parked scan evaluation, and exactly - // ONE commit for the boundary must land. + // A second idle for the same boundary must share the pending judge. + // Releasing that judge must commit the boundary exactly once. yield* events.publish(SessionStatus.Event.Status, { sessionID: sid, status: { type: "idle" } }) + yield* Deferred.succeed(judgeRelease, undefined) yield* pollWithTimeout( Effect.gen(function* () { @@ -1597,8 +1518,7 @@ describe("GoalLoop — startup scan scoping and hardening (GOAL-FP-01-04 follow- "no racing evaluation committed", "5 seconds", ) - yield* Effect.sleep("50 millis") - yield* Deferred.succeed(judgeRelease, undefined) + expect(judgeCalls).toBe(1) const g = yield* goal.load(sid) expect(g?.status).toBe("active") // The single-writer commit point (matchesExpected + record gate) must @@ -1681,24 +1601,29 @@ describe("GoalLoop — NotFoundError messages window pauses instead of stalling // continuation branch (shouldPreempt is defensively false on an empty window). describe("GoalLoop — NotFoundError on the post-judge reload must not stall (GOAL-FP-01-18)", () => { let messageCall = 0 + let vanished = false const sessionMock = Layer.mock(Session.Service, { messages: () => Effect.suspend(() => { messageCall += 1 - return messageCall === 1 + return !vanished ? Effect.succeed([mkAssistant()]) : Effect.fail(new NotFoundError({ message: "Session not found" })) }), }) const promptMock = Layer.mock(SessionPrompt.Service, { prompt: () => Effect.die(new Error("unreachable - paused branch not expected")), - prepareIfIdle: () => Effect.succeed(Option.none()), + ...withIdleAdmission({ promptIfIdle: () => Effect.succeed(Option.none()) }), }) const providerMock = Layer.mock(Provider.Service, {}) const judgeMock = Layer.succeed( GoalLoopJudgeLLM, GoalLoopJudgeLLM.of({ - call: () => Effect.succeed(JSON.stringify({ verdict: "continue", reason: "more work" })), + call: () => + Effect.sync(() => { + vanished = true + return JSON.stringify({ verdict: "continue", reason: "more work" }) + }), }), ) @@ -1713,9 +1638,10 @@ describe("GoalLoop — NotFoundError on the post-judge reload must not stall (GO ) const it = testEffect(reloadLayer) - it.instance("a vanished session during judge still commits the turn", () => + it.instance("a vanished session during judge pauses without charging the turn", () => Effect.gen(function* () { messageCall = 0 + vanished = false const loop = yield* GoalLoop.Service const goal = yield* Goal.Service const events = yield* EventV2Bridge.Service @@ -1730,13 +1656,13 @@ describe("GoalLoop — NotFoundError on the post-judge reload must not stall (GO const committed = yield* pollWithTimeout( Effect.gen(function* () { const g = yield* goal.load(sid) - return g && g.turns_used >= 1 ? g : undefined + return g && g.status === "paused" ? g : undefined }), - "turn never committed — the post-judge reload failure escaped", + "missing boundary did not pause", "5 seconds", ) - expect(committed.turns_used).toBe(1) - expect(committed.status).toBe("active") + expect(committed.turns_used).toBe(0) + expect(committed.status).toBe("paused") expect(messageCall).toBeGreaterThanOrEqual(2) }), ) @@ -1757,7 +1683,7 @@ describe("GoalLoop — judge-chain defect degrades into the parse budget (GOAL-F }) const promptMock = Layer.mock(SessionPrompt.Service, { prompt: () => Effect.die(new Error("unreachable - paused branch not expected")), - prepareIfIdle: () => Effect.succeed(Option.none()), + ...withIdleAdmission({ promptIfIdle: () => Effect.succeed(Option.none()) }), }) const providerMock = Layer.mock(Provider.Service, {}) const judgeMock = Layer.succeed( diff --git a/packages/opencode/test/goal/goal.test.ts b/packages/opencode/test/goal/goal.test.ts index 876070ee07..e3de77c099 100644 --- a/packages/opencode/test/goal/goal.test.ts +++ b/packages/opencode/test/goal/goal.test.ts @@ -1025,3 +1025,51 @@ describe("Goal.dispatch resume — busy guard (D5)", () => { }), ) }) + +it.instance("Goal commands configure and extend a total turn budget", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const id = SessionID.descending() + expect((yield* goal.dispatch(id, "--max-turns 3 write docs")).type).toBe("kick") + const state = yield* goal.load(id) + expect(state).toMatchObject({ goal: "write docs", max_turns: 3 }) + yield* goal.updateAfterJudge(id, "continue", "more", false, { goalID: state!.goal_id!, revision: state!.revision! }) + yield* goal.pause(id, "user-paused") + expect((yield* goal.dispatch(id, "resume --max-turns 6")).type).toBe("kick") + expect(yield* goal.load(id)).toMatchObject({ status: "active", max_turns: 6, turns_used: 1 }) + }), +) + +for (const value of ["0", "-1", "1.5", "abc", "9007199254740992", ""]) { + it.instance(`Goal rejects invalid budget ${JSON.stringify(value)} without creating state`, () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const id = SessionID.descending() + expect((yield* goal.dispatch(id, `--max-turns ${value} write docs`)).type).toBe("message") + expect(yield* goal.load(id)).toBeUndefined() + }), + ) +} + +it.instance("Goal invalid resume budgets preserve the entire paused state", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const id = SessionID.descending() + const initial = yield* goal.set(id, "write docs", 3) + yield* goal.updateAfterJudge(id, "continue", "more work", false, { + goalID: initial.goal_id ?? "legacy", + revision: initial.revision ?? 0, + }) + const paused = yield* goal.pause(id, "user-paused") + for (const argument of [ + "resume --max-turns 1", + "resume --max-turns 0", + "resume --max-turns abc", + "resume --max-turns 5 extra", + "resume --max-turns=5", + ]) { + expect((yield* goal.dispatch(id, argument)).type).toBe("message") + expect(yield* goal.load(id)).toEqual(paused) + } + }), +) diff --git a/packages/opencode/test/lib/session-prompt.ts b/packages/opencode/test/lib/session-prompt.ts index 88dd287b27..22182a9ce4 100644 --- a/packages/opencode/test/lib/session-prompt.ts +++ b/packages/opencode/test/lib/session-prompt.ts @@ -1,4 +1,6 @@ import { Cause, Effect, Option } from "effect" +import { SessionStatus } from "@/session/status" +import { SessionID } from "@/session/schema" import { SessionPrompt } from "@/session/prompt" import { SessionV1 } from "@opencode-ai/core/v1/session" @@ -11,6 +13,12 @@ export function withIdleAdmission( ) { return { ...service, + withIdle: (sessionID: SessionID, work: Effect.Effect) => + Effect.gen(function* () { + const status = yield* Effect.serviceOption(SessionStatus.Service) + if (Option.isSome(status) && (yield* status.value.get(sessionID)).type !== "idle") return Option.none() + return Option.some(yield* work) + }), prepareIfIdle: (input: SessionPrompt.PromptInput) => Effect.succeed( Option.some({ diff --git a/packages/opencode/test/server/httpapi-v2-pty.test.ts b/packages/opencode/test/server/httpapi-v2-pty.test.ts index ea4b02dc83..1ef6bdaf8f 100644 --- a/packages/opencode/test/server/httpapi-v2-pty.test.ts +++ b/packages/opencode/test/server/httpapi-v2-pty.test.ts @@ -209,7 +209,10 @@ describe("v2 pty HttpApi", () => { directoryHeader(dir), HttpClientRequest.bodyJson({ command: "/bin/sh", - args: ["-c", 'printf "%s|%s|%s|%s|%s\\n" "$CALLER" "$SHARED" "$PLUGIN" "$TERM" "$HOOK_CWD"; sleep 5'], + args: [ + "-c", + 'printf "%s|%s|%s|%s|%s\\n" "$CALLER" "$SHARED" "$PLUGIN" "$TERM" "$HOOK_CWD"; read -r release', + ], cwd, env: { CALLER: "caller", SHARED: "caller", TERM: "caller" }, }), @@ -243,6 +246,7 @@ describe("v2 pty HttpApi", () => { expect(yield* takeUntil(`caller|plugin|plugin|xterm-256color|${cwd}`)).toContain( `caller|plugin|plugin|xterm-256color|${cwd}`, ) + yield* write("done\n") yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void)) yield* HttpClientRequest.delete(`/api/pty/${info.id}`).pipe(directoryHeader(dir), HttpClient.execute) }), diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 411fdb2fb7..476d10cf7e 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -6,7 +6,7 @@ import { eq } from "drizzle-orm" import { EventV2Bridge } from "@/event-v2-bridge" import { FetchHttpClient } from "effect/unstable/http" import { expect } from "bun:test" -import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer, Option } from "effect" +import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer, Option, Stream } from "effect" import path from "path" import { fileURLToPath, pathToFileURL } from "url" import { NamedError } from "@opencode-ai/core/util/error" @@ -48,6 +48,7 @@ import { Snapshot } from "../../src/snapshot" import { ToolRegistry } from "@/tool/registry" import { Dag } from "@/dag/dag" import { Goal } from "@/goal/goal" +import { GoalLoop, GoalLoopJudgeLLM } from "@/goal/loop" import { Truncate } from "@/tool/truncate" import { SettingsHook, type HookPayload } from "@/hook/settings" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -235,13 +236,16 @@ type PromptLayerOptions = { mcpInstructions?: MCP.ServerInstructions[] processor?: "blocking" goal?: boolean + goalLayer?: Layer.Layer memoryContext?: string[] } function makePrompt(input?: PromptLayerOptions) { // goal: false exercises the Goal-absent degradation path (serviceOption None) const goalLayer: Layer.Layer = - input?.goal === false ? (Layer.empty as unknown as Layer.Layer) : Goal.defaultLayer + input?.goal === false + ? (Layer.empty as unknown as Layer.Layer) + : (input?.goalLayer ?? Goal.defaultLayer) const memoryLayer = Layer.mock(Memory.Service, { init: () => Effect.void, prepare: () => Effect.void, @@ -3166,3 +3170,251 @@ noLLMServer.instance( }), 30_000, ) + +function goalRuntime(call: GoalLoopJudgeLLM["Service"]["call"], onSettled = () => {}) { + const goalLayer = Layer.effect( + Goal.Service, + Effect.gen(function* () { + const goal = yield* Goal.Service + return Goal.Service.of({ + ...goal, + clearLoopFiberIf: (sessionID, fiber) => + goal.clearLoopFiberIf(sessionID, fiber).pipe(Effect.tap(() => Effect.sync(onSettled))), + }) + }), + ).pipe(Layer.provide(Goal.defaultLayer)) + return GoalLoop.layer.pipe( + Layer.provideMerge(makeHttp({ goalLayer })), + Layer.provideMerge(Layer.succeed(GoalLoopJudgeLLM, { call })), + ) +} + +for (const verdict of ["done", "blocked", "continue"] as const) { + let started = false + let settled = false + let release: Deferred.Deferred + const race = testEffect( + goalRuntime( + () => + Effect.gen(function* () { + started = true + yield* Deferred.await(release) + return JSON.stringify({ verdict, reason: "old response" }) + }), + () => { + settled = true + }, + ), + ) + race.instance(`Goal rejects stale ${verdict} while a new human turn is running`, () => + Effect.gen(function* () { + started = false + settled = false + release = yield* Deferred.make() + const { llm } = yield* useServerConfig(providerCfg) + const { prompt, chat } = yield* boot() + const goal = yield* Goal.Service + yield* seed(chat.id, { finish: "stop" }) + yield* goal.set(chat.id, "write the docs") + yield* (yield* GoalLoop.Service).init() + yield* pollWithTimeout( + Effect.sync(() => started || undefined), + "judge never started", + ) + yield* llm.hang + const work = yield* prompt + .prompt({ sessionID: chat.id, parts: [{ type: "text", text: "Add the missing section first." }] }) + .pipe(Effect.forkChild) + yield* llm.wait(1) + yield* waitForBusy(chat.id) + yield* Deferred.succeed(release, undefined) + yield* pollWithTimeout( + Effect.sync(() => settled || undefined), + "judge never settled", + ) + expect(yield* goal.lastOutcome(chat.id)).toBeUndefined() + expect(yield* goal.load(chat.id)).toMatchObject({ status: "active", turns_used: 0 }) + yield* prompt.cancel(chat.id) + yield* Fiber.await(work) + }), + ) +} + +for (const command of [ + { command: "goal", arguments: "status" }, + { command: "subgoal", arguments: "additional criterion" }, +]) { + let calls: string[] = [] + let release: Deferred.Deferred + const controls = testEffect( + goalRuntime((opts) => + Effect.gen(function* () { + calls.push(opts.user) + yield* Deferred.await(release) + return JSON.stringify({ verdict: "continue", reason: "more work remains" }) + }), + ), + ) + controls.instance(`Goal /${command.command} control preserves the pending work boundary`, () => + Effect.gen(function* () { + calls = [] + release = yield* Deferred.make() + const { llm } = yield* useServerConfig(providerCfg) + const { prompt, chat } = yield* boot() + const goal = yield* Goal.Service + yield* seed(chat.id, { finish: "stop" }) + yield* goal.set(chat.id, "write the docs") + yield* (yield* GoalLoop.Service).init() + yield* pollWithTimeout( + Effect.sync(() => calls.length || undefined), + "judge never started", + ) + for (let i = 0; i < (command.command === "goal" ? 25 : 1); i++) { + yield* prompt.command({ sessionID: chat.id, ...command }) + } + if (command.command === "subgoal") { + yield* pollWithTimeout( + Effect.sync(() => calls.some((x) => x.includes("additional criterion")) || undefined), + "new subgoal was not judged", + ) + } + yield* llm.hang + yield* Deferred.succeed(release, undefined) + yield* awaitWithTimeout(llm.wait(1), "control prevented continuation") + expect(yield* goal.load(chat.id)).toMatchObject({ status: "active", turns_used: 1 }) + if (command.command === "goal") expect(calls).toHaveLength(1) + const inputs = JSON.stringify( + (yield* llm.inputs).map((input) => + Array.isArray(input.messages) ? input.messages.filter((m: { role: string }) => m.role === "user") : [], + ), + ) + expect(inputs).not.toContain("/goal status") + if (command.command === "subgoal") expect(inputs).toContain("additional criterion") + yield* prompt.cancel(chat.id) + }), + ) +} + +let goalJudges = 0 +const autonomous = testEffect( + goalRuntime(() => + Effect.sync(() => + JSON.stringify({ + verdict: ++goalJudges === 1 ? "continue" : "done", + reason: "scripted progress", + }), + ), + ), +) +autonomous.instance("Goal question cannot leave an autonomous continuation waiting for input", () => + Effect.gen(function* () { + goalJudges = 0 + const { llm } = yield* useServerConfig(providerCfg) + const { prompt, chat } = yield* boot() + const goal = yield* Goal.Service + const questions = yield* Question.Service + yield* (yield* GoalLoop.Service).init() + yield* llm.text("First step done.") + yield* llm.tool("question", { + questions: [ + { + question: "Which option?", + header: "Choice", + options: [ + { label: "A", description: "First" }, + { label: "B", description: "Second" }, + ], + }, + ], + }) + yield* llm.text("I chose A and completed the work.") + yield* prompt.command({ sessionID: chat.id, command: "goal", arguments: "write the docs" }) + const outcome = yield* pollWithTimeout(goal.lastOutcome(chat.id), "Goal stuck waiting for a question", "5 seconds") + expect(outcome.status).toBe("done") + expect(yield* questions.list()).toHaveLength(0) + expect((yield* llm.inputs).length).toBe(3) + }), +) + +for (const entry of ["prompt", "loop", "shell"] as const) { + it.instance(`Goal idle commit serializes ${entry} admission without holding the execution`, () => + withSh(() => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const { prompt, chat } = yield* boot() + yield* user(chat.id, "start work") + const entered = yield* Deferred.make() + const attempted = yield* Deferred.make() + const release = yield* Deferred.make() + const order: string[] = [] + const events = yield* EventV2Bridge.Service + yield* events.subscribe(SessionStatus.Event.Status).pipe( + Stream.runForEach((event) => + Effect.sync(() => { + if (event.data.sessionID === chat.id && event.data.status.type === "busy") order.push("busy") + }), + ), + Effect.forkScoped({ startImmediately: true }), + ) + const commit = yield* prompt + .withIdle( + chat.id, + Effect.gen(function* () { + yield* Deferred.succeed(entered, undefined) + yield* Deferred.await(release) + order.push("commit") + }), + ) + .pipe(Effect.forkChild) + yield* Deferred.await(entered) + yield* llm.hang + const input = + entry === "shell" + ? prompt.shell({ sessionID: chat.id, agent: "build", command: "sleep 30" }).pipe(Effect.orDie) + : entry === "loop" + ? prompt.loop({ sessionID: chat.id }) + : prompt.prompt({ sessionID: chat.id, parts: [{ type: "text", text: "continue" }] }).pipe(Effect.orDie) + const work = yield* Deferred.succeed(attempted, undefined).pipe(Effect.andThen(input), Effect.forkChild) + yield* Deferred.await(attempted) + yield* Effect.yieldNow + expect((yield* (yield* SessionStatus.Service).get(chat.id)).type).toBe("idle") + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(commit) + yield* waitForBusy(chat.id) + expect(order[0]).toBe("commit") + // The commit lock is released before the provider/shell finishes. + expect(Option.isNone(yield* prompt.withIdle(chat.id, Effect.void))).toBe(true) + yield* prompt.cancel(chat.id) + yield* Fiber.await(work) + }), + ), + ) +} + +it.instance("human prompt retains question interaction with an active Goal", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const { prompt, chat } = yield* boot() + const goal = yield* Goal.Service + const questions = yield* Question.Service + yield* goal.set(chat.id, "write docs") + yield* goal.markTurnDriven(chat.id) + yield* llm.tool("question", { + questions: [ + { question: "Which section?", header: "Section", options: [{ label: "API", description: "Document the API" }] }, + ], + }) + yield* llm.text("Documented the API.") + const work = yield* prompt + .prompt({ sessionID: chat.id, parts: [{ type: "text", text: "Ask me which section to document." }] }) + .pipe(Effect.forkChild) + const pending = yield* pollWithTimeout( + questions.list().pipe(Effect.map((items) => items[0])), + "human question was suppressed", + ) + expect(yield* goal.isTurnDriven(chat.id)).toBe(false) + yield* questions.reply({ requestID: pending.id, answers: [["API"]] }) + yield* Fiber.join(work) + expect(yield* questions.list()).toHaveLength(0) + }), +) diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts index 0bbc58d442..1ac7708635 100644 --- a/packages/opencode/test/tool/question.test.ts +++ b/packages/opencode/test/tool/question.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test" import { Effect, Fiber, Layer, Queue } from "effect" +import { Goal } from "@/goal/goal" import { QuestionTool } from "../../src/tool/question" import { Question } from "../../src/question" import { SessionID, MessageID } from "../../src/session/schema" @@ -136,3 +137,19 @@ describe("tool.question", () => { // } // }) }) + +it.instance("question reads Goal from the execution context, not registry construction", () => + Effect.gen(function* () { + // This test layer intentionally has no Goal at tool construction time. + const tool = yield* (yield* QuestionTool).init() + const questions = yield* Question.Service + const result = yield* tool + .execute({ questions: [] }, ctx) + .pipe( + Effect.provide(Layer.mock(Goal.Service, { isTurnDriven: () => Effect.succeed(true) })), + Effect.timeout("2 seconds"), + ) + expect(result.output).toContain("Interactive questions are disabled") + expect(yield* questions.list()).toHaveLength(0) + }), +) diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 83b38dd38e..7554b88b10 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -636,9 +636,11 @@ export const { if (sessionIDs.length === 0) return Promise.resolve() return Promise.all( sessionIDs.map((sessionID) => - sdk.client.session.goal({ sessionID }, { throwOnError: false }) + sdk.client.session + .goal({ sessionID }, { throwOnError: false }) .then((response) => { - setStore("goal", sessionID, response.data ?? undefined) + if (response.data) setStore("goal", sessionID, response.data) + else if (response.response?.status === 404) setStore("goal", sessionID, undefined) }) .catch(() => {}), ), diff --git a/packages/tui/test/cli/cmd/tui/sync-goal.test.tsx b/packages/tui/test/cli/cmd/tui/sync-goal.test.tsx index 9fcd5a4609..1a7179b3f0 100644 --- a/packages/tui/test/cli/cmd/tui/sync-goal.test.tsx +++ b/packages/tui/test/cli/cmd/tui/sync-goal.test.tsx @@ -98,3 +98,46 @@ describe("tui sync goal slice", () => { } }) }) + +for (const status of [200, 404, 500]) { + test(`Goal reconnect handles HTTP ${status}`, async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + let reads = 0 + const { app, emit, reconnect, sync } = await mount((url) => { + if (!url.pathname.endsWith("/goal")) return + reads++ + return Response.json( + status === 200 + ? { + goal: "updated goal", + status: "paused", + turnsUsed: 3, + maxTurns: 20, + subgoals: [], + } + : { message: "failed" }, + { status }, + ) + }, tmp.path) + try { + emit(goalUpdated()) + await wait(() => sync.data.goal[sid] !== undefined) + reconnect() + await wait(() => reads === 1) + if (status === 404) { + await wait(() => sync.data.goal[sid] === undefined) + expect(sync.data.goal[sid]).toBeUndefined() + } else { + // Starting another request proves the first reconnect finished. + await wait(() => { + reconnect() + return reads >= 2 + }) + expect(sync.data.goal[sid]?.goal).toBe(status === 200 ? "updated goal" : "ship the feature") + } + } finally { + app.renderer.destroy() + } + }) +} diff --git a/script/ci-evidence.mjs b/script/ci-evidence.mjs new file mode 100644 index 0000000000..ac04a020ae --- /dev/null +++ b/script/ci-evidence.mjs @@ -0,0 +1,130 @@ +import { execFileSync } from "node:child_process" +import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { pathToFileURL } from "node:url" +import { fingerprint } from "./ci-fingerprint.mjs" + +export function verifyEvidence(locator, expected, source) { + if (!Number.isSafeInteger(locator.run) || locator.run <= 0) return false + if (!Number.isSafeInteger(locator.attempt) || locator.attempt <= 0) return false + if (!Number.isSafeInteger(locator.artifact) || locator.artifact <= 0) return false + if (locator.job !== expected.job) return false + const run = source.run(locator.run, locator.attempt) + if (run.id !== locator.run || run.run_attempt !== locator.attempt || run.path !== expected.workflow) return false + if (!/^[a-f0-9]{40}$/.test(run.head_sha)) return false + if (!["push", "pull_request", "workflow_dispatch"].includes(run.event)) return false + const jobs = source.jobs(locator.run, locator.attempt).filter((job) => job.name === expected.job) + if (jobs.length !== 1 || jobs[0].status !== "completed" || jobs[0].conclusion !== "success") return false + if (jobs[0].started_at?.slice(0, 10) !== expected.day) return false + if (!jobs[0].labels?.includes(expected.runner)) return false + const artifact = source.artifact(locator.artifact) + if (artifact.expired || artifact.size_in_bytes > 10000) return false + if (artifact.name !== `${expected.artifactPrefix}-${locator.attempt}`) return false + if (artifact.workflow_run?.id !== run.id || artifact.workflow_run.head_sha !== run.head_sha) return false + // API run.head_sha is immutable; run.pull_requests is live PR data and must + // never stand in for the tree that an earlier run actually checked out. + // Check the trusted workflow/source tree, then its immutable uploaded proof + // of the tested merge tree. A forged cache can only point to these records. + if (source.fingerprint(run.head_sha) !== expected.fingerprint) return false + return source.proof(locator.artifact).fingerprint === expected.fingerprint +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const path = process.env.EVIDENCE + if (process.argv[2] === "proof") { + mkdirSync(`${path}.artifact`, { recursive: true }) + writeFileSync(`${path}.artifact/verification.json`, JSON.stringify({ fingerprint: fingerprint() })) + } else if (process.argv[2] === "record") { + writeFileSync( + path, + JSON.stringify({ + run: Number(process.env.GITHUB_RUN_ID), + attempt: Number(process.env.GITHUB_RUN_ATTEMPT), + job: process.env.CHECK_JOB, + artifact: Number(process.env.ARTIFACT_ID), + }), + ) + } else { + let reused = false + try { + const repo = process.env.GITHUB_REPOSITORY + const api = (path) => + JSON.parse( + execFileSync("gh", ["api", `repos/${repo}/${path}`], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 30000, + }), + ) + const fetched = new Set() + const fetch = (sha) => { + if (fetched.has(sha)) return + execFileSync("git", ["fetch", "--no-tags", "--depth=1", "origin", sha], { + stdio: ["ignore", "pipe", "pipe"], + timeout: 30000, + }) + fetched.add(sha) + } + reused = verifyEvidence( + JSON.parse(readFileSync(path, "utf8")), + { + job: process.env.CHECK_JOB, + runner: process.env.CHECK_RUNNER, + day: new Date().toISOString().slice(0, 10), + workflow: process.env.GITHUB_WORKFLOW_REF.split("@")[0].slice(repo.length + 1), + fingerprint: process.env.PRODUCT_FINGERPRINT, + artifactPrefix: `ci-verification-${process.env.GITHUB_JOB}-${process.env.RUNNER_OS}-${process.env.RUNNER_ARCH}`, + }, + { + run: (run, attempt) => api(`actions/runs/${run}/attempts/${attempt}`), + jobs: (run, attempt) => { + const jobs = [] + for (let page = 1; ; page++) { + const result = api(`actions/runs/${run}/attempts/${attempt}/jobs?per_page=100&page=${page}`) + jobs.push(...result.jobs) + if (result.jobs.length < 100) return jobs + } + }, + artifact: (id) => api(`actions/artifacts/${id}`), + proof: (id) => { + const zip = execFileSync("gh", ["api", `repos/${repo}/actions/artifacts/${id}/zip`], { + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: 65536, + timeout: 30000, + }) + // Python is already part of the hosted runners used by these jobs. + // Read one bounded JSON member in memory; never extract archive paths. + const json = execFileSync( + "python3", + [ + "-c", + "import io,sys,zipfile; z=zipfile.ZipFile(io.BytesIO(sys.stdin.buffer.read())); i=z.getinfo('verification.json'); assert i.file_size <= 1024; sys.stdout.buffer.write(z.read(i))", + ], + { + input: zip, + encoding: "utf8", + maxBuffer: 2048, + timeout: 10000, + stdio: ["pipe", "pipe", "pipe"], + }, + ) + return JSON.parse(json) + }, + fingerprint: (sha) => { + fetch(sha) + return fingerprint(sha) + }, + }, + ) + } catch { + console.log("Previous verification could not be proven; running the full suite.") + } + appendFileSync(process.env.GITHUB_OUTPUT, `reused=${reused}\n`) + if (reused) { + const locator = JSON.parse(readFileSync(path, "utf8")) + appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + `### Reused successful product verification\n\nGitHub confirms the source job completed successfully today on the requested runner label, and its product tree matches this checkout. Only the ordinary root SpecGit record is excluded.\n\nSource: ${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${locator.run}/attempts/${locator.attempt}\n`, + ) + } + } +} diff --git a/script/ci-evidence.test.mjs b/script/ci-evidence.test.mjs new file mode 100644 index 0000000000..93e4c87de1 --- /dev/null +++ b/script/ci-evidence.test.mjs @@ -0,0 +1,141 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { verifyEvidence } from "./ci-evidence.mjs" + +const head = "a".repeat(40) +const base = "b".repeat(40) +function evidence() { + const locator = { run: 12, attempt: 2, job: "Unit Tests (linux)", artifact: 34 } + const expected = { + job: locator.job, + workflow: ".github/workflows/ci-test.yml", + fingerprint: "tree-b", + day: "2026-09-07", + runner: "ubuntu-latest", + artifactPrefix: "ci-verification-unit-tests-Linux-X64", + } + const run = { id: 12, run_attempt: 2, path: expected.workflow, head_sha: head, event: "push" } + const jobs = [ + { + name: locator.job, + status: "completed", + conclusion: "success", + started_at: "2026-09-07T01:00:00Z", + labels: ["ubuntu-latest"], + }, + ] + const artifact = { + name: `${expected.artifactPrefix}-2`, + size_in_bytes: 180, + expired: false, + workflow_run: { id: 12, head_sha: head }, + } + const source = { + run: () => run, + jobs: () => jobs, + artifact: () => artifact, + proof: () => ({ fingerprint: "tree-b" }), + fingerprint: (sha) => { + assert.equal(sha, head) + return "tree-b" + }, + } + return { locator, expected, run, jobs, source, artifact } +} + +test("exact successful job and platform-associated source can be reused", () => { + const e = evidence() + assert.equal(verifyEvidence(e.locator, e.expected, e.source), true) +}) + +for (const conclusion of ["failure", "cancelled", "timed_out", "skipped", null]) { + test(`source conclusion ${conclusion} cannot provide success evidence`, () => { + const e = evidence() + e.jobs[0].conclusion = conclusion + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) + }) +} + +test("a still-running job cannot lend its early saved locator", () => { + const e = evidence() + e.jobs[0].status = "in_progress" + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) +}) + +test("a forged cache key for B pointing to a successful A tree is rejected", () => { + const e = evidence() + e.source.fingerprint = () => "tree-a" + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) +}) + +test("workflow and attempt identities cannot be borrowed", () => { + const e = evidence() + e.run.path = ".github/workflows/other.yml" + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) + e.run.path = e.expected.workflow + e.run.run_attempt = 1 + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) +}) + +test("missing, ambiguous, or mismatched jobs are rejected", () => { + const e = evidence() + e.locator.job = "Typecheck" + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) + e.locator.job = e.expected.job + e.jobs.push({ ...e.jobs[0] }) + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) + e.jobs.length = 0 + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) +}) + +test("live PR head updates do not overwrite the source run's immutable proof", () => { + const e = evidence() + e.run.event = "pull_request" + e.run.pull_requests = [{ head: { sha: base }, base: { sha: base } }] + assert.equal(verifyEvidence(e.locator, e.expected, e.source), true) +}) + +test("a formerly tested merge tree cannot lend evidence to different current content", () => { + const e = evidence() + e.source.proof = () => ({ fingerprint: "different-merge-tree" }) + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) +}) + +test("malformed source identities fail closed", () => { + const e = evidence() + e.locator.run = "12" + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) + e.locator.run = 12 + e.run.head_sha = "untrusted-revision" + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) +}) + +test("a forged current-day key cannot reuse an old source job", () => { + const e = evidence() + e.jobs[0].started_at = "2026-09-06T01:00:00Z" + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) +}) + +test("the source job must use the expected runner label", () => { + const e = evidence() + e.jobs[0].labels = ["windows-latest"] + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) +}) + +test("an artifact from another run or job cannot be borrowed", () => { + const e = evidence() + e.artifact.workflow_run.id = 99 + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) + e.artifact.workflow_run.id = 12 + e.artifact.name = "ci-verification-e2e-tests-Linux-X64-2" + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) +}) + +test("expired or unexpectedly large artifacts fall back to full verification", () => { + const e = evidence() + e.artifact.expired = true + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) + e.artifact.expired = false + e.artifact.size_in_bytes = 10001 + assert.equal(verifyEvidence(e.locator, e.expected, e.source), false) +}) diff --git a/script/ci-fingerprint.mjs b/script/ci-fingerprint.mjs new file mode 100644 index 0000000000..699e2d3a40 --- /dev/null +++ b/script/ci-fingerprint.mjs @@ -0,0 +1,34 @@ +import { execFileSync } from "node:child_process" +import { createHash } from "node:crypto" +import { appendFileSync } from "node:fs" +import { pathToFileURL } from "node:url" + +// Hash Git objects, including modes and submodules, without checkout or newline +// normalization. Only the ordinary root delivery record is not a product input. +export function fingerprint(ref = "HEAD", cwd = process.cwd()) { + const tree = execFileSync("git", ["ls-tree", "-rz", "--full-tree", ref], { cwd }) + const hash = createHash("sha256") + let start = 0 + for (let end = 0; end < tree.length; end++) { + if (tree[end] !== 0) continue + const entry = tree.subarray(start, end) + const tab = entry.indexOf(9) + if (tab < 0) throw new Error("Invalid Git tree entry") + const record = entry.subarray(tab + 1).equals(Buffer.from(".specgit.yaml")) + if (!record || !entry.subarray(0, 12).equals(Buffer.from("100644 blob "))) { + hash.update(entry) + hash.update(Buffer.from([0])) + } + start = end + 1 + } + return hash.digest("hex") +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const value = fingerprint() + const day = new Date().toISOString().slice(0, 10) + if (process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, `fingerprint=${value}\nday=${day}\n`) + } + console.log(value) +} diff --git a/script/ci-fingerprint.test.mjs b/script/ci-fingerprint.test.mjs new file mode 100644 index 0000000000..dd5b3c26ce --- /dev/null +++ b/script/ci-fingerprint.test.mjs @@ -0,0 +1,82 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { fingerprint } from "./ci-fingerprint.mjs" + +function repository(t) { + const cwd = mkdtempSync(join(tmpdir(), "ci-fingerprint-")) + t.after(() => rmSync(cwd, { recursive: true, force: true })) + const git = (...args) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim() + git("init", "--quiet") + const write = (path, content) => { + mkdirSync(join(cwd, path, ".."), { recursive: true }) + writeFileSync(join(cwd, path), content) + } + const snapshot = () => { + git("add", "--all") + return fingerprint(git("write-tree"), cwd) + } + write("product.ts", "export const answer = 42\n") + return { cwd, git, write, snapshot } +} + +test("record creation, updates, and deletion reuse unchanged product evidence", (t) => { + const repo = repository(t) + const baseline = repo.snapshot() + repo.write(".specgit.yaml", "issues: [1]\n") + assert.equal(repo.snapshot(), baseline) + repo.write(".specgit.yaml", "issues: [2, 3]\n") + assert.equal(repo.snapshot(), baseline) + rmSync(join(repo.cwd, ".specgit.yaml")) + assert.equal(repo.snapshot(), baseline) +}) + +for (const path of [ + "product.ts", + "bun.lock", + "spec_git/policy.yaml", + ".github/workflows/ci-test.yml", + "README.md", + "nested/.specgit.yaml", + "file\twith\nwhitespace", +]) { + test(`${JSON.stringify(path)} changes invalidate verification`, (t) => { + const repo = repository(t) + const before = repo.snapshot() + repo.write(path, "changed\n") + repo.write(".specgit.yaml", "issues: [4]\n") + assert.notEqual(repo.snapshot(), before) + }) +} + +test("file modes, symlinks, and submodule revisions remain inputs", (t) => { + const repo = repository(t) + const baseline = repo.snapshot() + repo.git("update-index", "--chmod=+x", "product.ts") + assert.notEqual(fingerprint(repo.git("write-tree"), repo.cwd), baseline) + const first = repo.git("hash-object", "-w", "product.ts") + repo.write("other.ts", "other content\n") + const second = repo.git("hash-object", "-w", "other.ts") + for (const mode of ["120000", "160000"]) { + repo.git("update-index", "--add", "--cacheinfo", `${mode},${first},linked`) + const before = fingerprint(repo.git("write-tree"), repo.cwd) + repo.git("update-index", "--cacheinfo", `${mode},${second},linked`) + assert.notEqual(fingerprint(repo.git("write-tree"), repo.cwd), before) + } +}) + +test("a delivery record with executable mode is not exempt", (t) => { + const repo = repository(t) + repo.write(".specgit.yaml", "issues: [1]\n") + const baseline = repo.snapshot() + repo.git("update-index", "--chmod=+x", ".specgit.yaml") + assert.notEqual(fingerprint(repo.git("write-tree"), repo.cwd), baseline) +}) + +test("missing revision fails instead of manufacturing reusable evidence", (t) => { + const repo = repository(t) + assert.throws(() => fingerprint("missing-revision", repo.cwd)) +})