From f026e67024b90ce0a4b3a6b60569bb05bc10fa21 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Tue, 21 Jul 2026 13:21:25 +0100 Subject: [PATCH 1/8] ci(warden): Reduce PR review cost and enforce timeout Remove the duplicate repository Warden lane and retain only the two productive automatic PR skills. Add a ten-minute watchdog and manual guidance for targeted domain reviews. --- .../SKILL.md | 20 +- .github/workflows/warden-watchdog.yml | 32 +++ .github/workflows/warden.yml | 29 -- AGENTS.md | 1 + CLAUDE.md | 1 + scripts/__tests__/warden-watchdog.test.mjs | 125 +++++++++ scripts/warden-watchdog.mjs | 122 ++++++++ warden.toml | 264 ++---------------- 8 files changed, 322 insertions(+), 272 deletions(-) create mode 100644 .github/workflows/warden-watchdog.yml delete mode 100644 .github/workflows/warden.yml create mode 100644 scripts/__tests__/warden-watchdog.test.mjs create mode 100644 scripts/warden-watchdog.mjs diff --git a/.agents/skills/xcodebuildmcp-snapshot-fixture-review/SKILL.md b/.agents/skills/xcodebuildmcp-snapshot-fixture-review/SKILL.md index 881aac61b..497b4cf41 100644 --- a/.agents/skills/xcodebuildmcp-snapshot-fixture-review/SKILL.md +++ b/.agents/skills/xcodebuildmcp-snapshot-fixture-review/SKILL.md @@ -30,11 +30,15 @@ Review guardrails for fixture and snapshot contract integrity. - JSON fixtures preserve stable structured output envelopes. - Volatile values are normalized in code, not patched ad hoc in fixtures. - Missing fixtures are generated through the snapshot update flow. - -## Validation - -- `npm run test:snapshots` -- `npm run test:schema-fixtures` -- `npm test -- src/snapshot-tests/__tests__/fixture-io.test.ts` -- `npm test -- src/snapshot-tests/__tests__/json-normalize.test.ts` -- `npx skill-check .agents/skills/xcodebuildmcp-snapshot-fixture-review` +- Verify project-defined normalization sentinels in normalization code and tests before reporting + them as implausible fixture values. In particular, `99999` is an intentional sentinel for + volatile UI capture counts. +- Group every occurrence of the same root cause into one finding and list the affected fixtures. + Do not emit separate findings for equivalent CLI, MCP, text, or JSON drift. +- Do not repeat a finding that is already resolved by the current diff. + +## Review limits + +- Review the changed fixtures and the directly relevant normalization or contract code only. +- Do not run snapshot or smoke suites as part of an automated PR review. +- Prefer one high-confidence cross-fixture finding over multiple speculative discrepancies. diff --git a/.github/workflows/warden-watchdog.yml b/.github/workflows/warden-watchdog.yml new file mode 100644 index 000000000..34a1cde5a --- /dev/null +++ b/.github/workflows/warden-watchdog.yml @@ -0,0 +1,32 @@ +name: Warden watchdog + +on: + workflow_run: + workflows: [Warden] + types: [in_progress] + +concurrency: + group: warden-watchdog-${{ github.event.workflow_run.id }} + cancel-in-progress: true + +permissions: + actions: write + contents: read + +jobs: + enforce-timeout: + if: github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 11 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - name: Enforce Warden timeout + env: + GITHUB_TOKEN: ${{ github.token }} + TARGET_RUN_ID: ${{ github.event.workflow_run.id }} + WARDEN_MAX_RUNTIME_SECONDS: 600 + WARDEN_POLL_SECONDS: 15 + run: node scripts/warden-watchdog.mjs diff --git a/.github/workflows/warden.yml b/.github/workflows/warden.yml deleted file mode 100644 index 916f10660..000000000 --- a/.github/workflows/warden.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Warden - -on: - pull_request: - types: [opened, synchronize, reopened] - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -# contents: write required for resolving review threads via GraphQL -# See: https://github.com/orgs/community/discussions/44650 -permissions: - contents: write - pull-requests: write - checks: write - -jobs: - review: - runs-on: ubuntu-latest - timeout-minutes: 20 - env: - WARDEN_MODEL: ${{ secrets.WARDEN_MODEL }} - WARDEN_SENTRY_DSN: ${{ secrets.WARDEN_SENTRY_DSN }} - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - - uses: getsentry/warden@2130c979dec0163048d954d9599504e2d9fa2b07 - with: - anthropic-api-key: ${{ secrets.WARDEN_ANTHROPIC_API_KEY }} diff --git a/AGENTS.md b/AGENTS.md index dc471e400..ca0d42921 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,6 +92,7 @@ When reading issues: - CLI design note: do not rely on CLI session-default writes. CLI is intentionally deterministic for CI/scripting and should use explicit command arguments as the primary input surface. - When working on skill sources in `skills/`, use the `skill-creator` skill workflow. - After modifying any skill source, run `npx skill-check ` and address all errors/warnings before handoff. +- Before handoff, run the matching manual Warden review for high-risk changes: runtime/CLI/daemon boundaries → `xcodebuildmcp-runtime-boundary-review`; test infrastructure or harnesses → `xcodebuildmcp-test-boundary-review`; tool manifests, schemas, or contracts → `xcodebuildmcp-tool-contract-review`. Invoke only applicable skills with `warden --skill `. - ## Multi-process filesystem state - XcodeBuildMCP explicitly supports multiple concurrent MCP server, daemon, CLI, test, and helper processes for the same or different workspaces. diff --git a/CLAUDE.md b/CLAUDE.md index e37749de9..31e3165e3 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,7 @@ When reading issues: - CLI design note: do not rely on CLI session-default writes. CLI is intentionally deterministic for CI/scripting and should use explicit command arguments as the primary input surface. - When working on skill sources in `skills/`, use the `skill-creator` skill workflow. - After modifying any skill source, run `npx skill-check ` and address all errors/warnings before handoff. +- Before handoff, run the matching manual Warden review for high-risk changes: runtime/CLI/daemon boundaries → `xcodebuildmcp-runtime-boundary-review`; test infrastructure or harnesses → `xcodebuildmcp-test-boundary-review`; tool manifests, schemas, or contracts → `xcodebuildmcp-tool-contract-review`. Invoke only applicable skills with `warden --skill `. - ## Multi-process filesystem state - XcodeBuildMCP explicitly supports multiple concurrent MCP server, daemon, CLI, test, and helper processes for the same or different workspaces. diff --git a/scripts/__tests__/warden-watchdog.test.mjs b/scripts/__tests__/warden-watchdog.test.mjs new file mode 100644 index 000000000..2a1378e89 --- /dev/null +++ b/scripts/__tests__/warden-watchdog.test.mjs @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { isWardenPullRequestRun, monitorWardenRun, runtimeSeconds } from '../warden-watchdog.mjs'; + +function wardenRun(overrides = {}) { + return { + id: 1, + name: 'Warden', + event: 'pull_request', + status: 'in_progress', + created_at: '2026-07-21T09:00:00Z', + ...overrides, + }; +} + +test('isWardenPullRequestRun requires the workflow name and PR event', () => { + assert.equal(isWardenPullRequestRun(wardenRun()), true); + assert.equal(isWardenPullRequestRun(wardenRun({ name: 'Tests' })), false); + assert.equal(isWardenPullRequestRun(wardenRun({ event: 'push' })), false); +}); + +test('runtimeSeconds includes queue time and never returns a negative duration', () => { + const run = wardenRun(); + + assert.equal(runtimeSeconds(run, Date.parse('2026-07-21T09:10:00Z')), 600); + assert.equal(runtimeSeconds(run, Date.parse('2026-07-21T08:59:00Z')), 0); +}); + +test('monitorWardenRun ignores non-Warden runs', async () => { + const result = await monitorWardenRun({ + maxRuntimeSeconds: 600, + pollSeconds: 15, + getRun: async () => wardenRun({ name: 'Tests' }), + cancelRun: async () => assert.fail('non-Warden run must not be cancelled'), + }); + + assert.deepEqual(result, { cancelled: false, ignored: true }); +}); + +test('monitorWardenRun returns when the Warden run completes', async () => { + const runs = [wardenRun(), wardenRun({ status: 'completed' })]; + let nowMs = Date.parse('2026-07-21T09:00:00Z'); + + const result = await monitorWardenRun({ + maxRuntimeSeconds: 600, + pollSeconds: 15, + now: () => nowMs, + sleep: async (milliseconds) => { + nowMs += milliseconds; + }, + getRun: async () => runs.shift(), + cancelRun: async () => assert.fail('completed run must not be cancelled'), + }); + + assert.deepEqual(result, { cancelled: false, ignored: false }); +}); + +test('monitorWardenRun cancels a Warden run at the runtime limit', async () => { + let cancelled = false; + + const result = await monitorWardenRun({ + maxRuntimeSeconds: 600, + pollSeconds: 15, + now: () => Date.parse('2026-07-21T09:10:00Z'), + sleep: async () => assert.fail('stale run must be cancelled immediately'), + getRun: async () => wardenRun(), + cancelRun: async () => { + cancelled = true; + return true; + }, + }); + + assert.equal(cancelled, true); + assert.deepEqual(result, { cancelled: true, ignored: false }); +}); + +test('monitorWardenRun never sleeps past the runtime limit', async () => { + let nowMs = Date.parse('2026-07-21T09:09:58Z'); + let sleptMilliseconds = 0; + + const result = await monitorWardenRun({ + maxRuntimeSeconds: 600, + pollSeconds: 15, + now: () => nowMs, + sleep: async (milliseconds) => { + sleptMilliseconds += milliseconds; + nowMs += milliseconds; + }, + getRun: async () => wardenRun(), + cancelRun: async () => true, + }); + + assert.equal(sleptMilliseconds, 2_000); + assert.deepEqual(result, { cancelled: true, ignored: false }); +}); + +test('monitorWardenRun handles a run completing during cancellation', async () => { + const runs = [wardenRun(), wardenRun({ status: 'completed' })]; + + const result = await monitorWardenRun({ + maxRuntimeSeconds: 600, + pollSeconds: 15, + now: () => Date.parse('2026-07-21T09:10:00Z'), + sleep: async () => assert.fail('stale run must attempt cancellation immediately'), + getRun: async () => runs.shift(), + cancelRun: async () => false, + }); + + assert.deepEqual(result, { cancelled: false, ignored: false }); +}); + +test('monitorWardenRun fails if a rejected cancellation leaves the run active', async () => { + await assert.rejects( + monitorWardenRun({ + maxRuntimeSeconds: 600, + pollSeconds: 15, + now: () => Date.parse('2026-07-21T09:10:00Z'), + sleep: async () => assert.fail('stale run must attempt cancellation immediately'), + getRun: async () => wardenRun(), + cancelRun: async () => false, + }), + /remained active/, + ); +}); diff --git a/scripts/warden-watchdog.mjs b/scripts/warden-watchdog.mjs new file mode 100644 index 000000000..fd7cd020b --- /dev/null +++ b/scripts/warden-watchdog.mjs @@ -0,0 +1,122 @@ +import process from 'node:process'; +import { setTimeout as sleepTimer } from 'node:timers/promises'; +import { pathToFileURL } from 'node:url'; + +const WARDEN_WORKFLOW_NAME = 'Warden'; + +export function isWardenPullRequestRun(run) { + return run.name === WARDEN_WORKFLOW_NAME && run.event === 'pull_request'; +} + +export function runtimeSeconds(run, nowMs) { + return Math.max(0, Math.floor((nowMs - Date.parse(run.created_at)) / 1000)); +} + +export async function monitorWardenRun({ + getRun, + cancelRun, + maxRuntimeSeconds, + pollSeconds, + now = Date.now, + sleep = sleepTimer, +}) { + let run = await getRun(); + + if (!isWardenPullRequestRun(run)) { + return { cancelled: false, ignored: true }; + } + + const deadlineMs = Date.parse(run.created_at) + maxRuntimeSeconds * 1000; + while (run.status !== 'completed' && now() < deadlineMs) { + await sleep(Math.min(pollSeconds * 1000, deadlineMs - now())); + run = await getRun(); + } + + if (run.status === 'completed') { + return { cancelled: false, ignored: false }; + } + + if (await cancelRun()) { + return { cancelled: true, ignored: false }; + } + + run = await getRun(); + if (run.status === 'completed') { + return { cancelled: false, ignored: false }; + } + + throw new Error('Warden run remained active after cancellation was rejected'); +} + +async function githubRequest(path, options = {}) { + const response = await globalThis.fetch(`https://api.github.com${path}`, { + ...options, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + 'X-GitHub-Api-Version': '2022-11-28', + ...options.headers, + }, + signal: globalThis.AbortSignal.timeout(30_000), + }); + + if (!response.ok && response.status !== 409) { + throw new Error(`GitHub API ${options.method ?? 'GET'} ${path} failed: ${response.status}`); + } + + if (response.status === 202 || response.status === 204) { + return undefined; + } + + if (response.status === 409) { + return null; + } + + return response.json(); +} + +function requiredEnvironment(name) { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is required`); + } + return value; +} + +function positiveIntegerEnvironment(name) { + const value = Number.parseInt(requiredEnvironment(name), 10); + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer`); + } + return value; +} + +async function main() { + const repository = requiredEnvironment('GITHUB_REPOSITORY'); + const runId = positiveIntegerEnvironment('TARGET_RUN_ID'); + const maxRuntimeSeconds = positiveIntegerEnvironment('WARDEN_MAX_RUNTIME_SECONDS'); + requiredEnvironment('GITHUB_TOKEN'); + + const result = await monitorWardenRun({ + maxRuntimeSeconds, + pollSeconds: positiveIntegerEnvironment('WARDEN_POLL_SECONDS'), + getRun: () => githubRequest(`/repos/${repository}/actions/runs/${runId}`), + cancelRun: async () => { + const response = await githubRequest(`/repos/${repository}/actions/runs/${runId}/cancel`, { + method: 'POST', + }); + return response !== null; + }, + }); + + if (result.cancelled) { + throw new Error(`Cancelled Warden run ${runId} after exceeding ${maxRuntimeSeconds} seconds`); + } + + globalThis.console.log(result.ignored ? 'Ignored non-PR Warden run' : 'Warden run completed'); +} + +const isMain = process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url; +if (isMain) { + await main(); +} diff --git a/warden.toml b/warden.toml index 444e77e58..2ebfa1062 100644 --- a/warden.toml +++ b/warden.toml @@ -1,28 +1,18 @@ # Warden Configuration # https://github.com/getsentry/warden # -# Warden reviews code using AI-powered skills triggered by GitHub events. -# Skills live in .agents/skills/ or .claude/skills/, or are pulled from GitHub -# via the remote field. -# -# Add skills with: warden add +# PR reviews are executed by the organization-managed Warden workflow. The repository watchdog +# cancels any Warden run that exceeds ten minutes. Keep this PR skill set intentionally small: +# each automatic check must have demonstrated unique, actionable findings. version = 1 -# Default settings inherited by all skills [defaults] -# Severity levels: critical, high, medium, low, info -# failOn: minimum severity that fails the check failOn = "high" -# reportOn: minimum severity that creates PR annotations reportOn = "medium" -# Avoid noisy no-op PR reporting. reportOnSuccess = false -# warden-sweep is a full-repo sweep. PR-trigger is intentionally omitted so -# it does not run on every PR. It runs: -# - On schedule via .github/workflows/warden-sweep.yml (workflow_dispatch + cron) -# - Locally via `warden --skill warden-sweep` +# Full-repository review remains scheduled and never runs on PR events. [[skills]] name = "warden-sweep" paths = ["src/**/*.ts", "scripts/**/*.{js,mjs,sh,ts}"] @@ -41,98 +31,12 @@ issueTitle = "Warden weekly sweep" createFixPR = true fixBranchPrefix = "warden-sweep" -[[skills]] -name = "xcodebuildmcp-docs-release-review" -maxTurns = 10 -maxFindings = 5 -paths = [ - "README.md", - "CHANGELOG.md", - "package.json", - "scripts/build-website-manifest.mjs", - "scripts/generate-github-release-notes.mjs", - "scripts/release.sh", - "xcodebuildmcp.com/app/docs/_content/**", -] - -[[skills.triggers]] -type = "pull_request" -actions = ["opened", "synchronize", "reopened"] - -[[skills]] -name = "xcodebuildmcp-docs-command-review" -maxTurns = 8 -maxFindings = 5 -paths = [ - "CHANGELOG.md", -] - -[[skills.triggers]] -type = "pull_request" -actions = ["opened", "synchronize", "reopened"] - -[[skills]] -name = "xcodebuildmcp-packaging-resource-review" -maxTurns = 10 -maxFindings = 5 -paths = [ - "package.json", - "scripts/copy-build-assets.js", - "scripts/package-macos-portable.sh", - "scripts/build-website-manifest.mjs", - "schemas/**", - "manifests/**", - "skills/**", - "bundled/**", -] - -[[skills.triggers]] -type = "pull_request" -actions = ["opened", "synchronize", "reopened"] - -[[skills]] -name = "xcodebuildmcp-rendering-streaming-review" -maxTurns = 10 -maxFindings = 5 -paths = [ - "src/rendering/**", - "src/types/domain-fragments.ts", - "src/types/runtime-status.ts", - "src/runtime/tool-invoker.ts", - "src/runtime/__tests__/tool-invoker.test.ts", - "xcodebuildmcp.com/app/docs/_content/architecture-rendering-output.mdx", - "xcodebuildmcp.com/app/docs/_content/architecture-tool-lifecycle.mdx", - "xcodebuildmcp.com/app/docs/_content/output-formats.mdx", -] - -[[skills.triggers]] -type = "pull_request" -actions = ["opened", "synchronize", "reopened"] - -[[skills]] -name = "xcodebuildmcp-runtime-boundary-review" -maxTurns = 8 -maxFindings = 5 -paths = [ - "src/runtime/tool-catalog.ts", - "src/runtime/tool-invoker.ts", - "src/runtime/types.ts", - "src/cli/**", - "src/daemon/**", - "manifests/tools/*.yaml", - "manifests/workflows/*.yaml", - "xcodebuildmcp.com/app/docs/_content/architecture-runtime-boundaries.mdx", - "xcodebuildmcp.com/app/docs/_content/architecture-manifest-visibility.mdx", -] - -[[skills.triggers]] -type = "pull_request" -actions = ["opened", "synchronize", "reopened"] - +# High-value PR reviews. maxTurns is deliberately capped so a single skill cannot monopolize the +# ten-minute workflow budget. [[skills]] name = "xcodebuildmcp-snapshot-fixture-review" -maxTurns = 10 -maxFindings = 5 +maxTurns = 3 +maxFindings = 3 paths = [ "src/snapshot-tests/contracts.ts", "src/snapshot-tests/fixture-io.ts", @@ -140,25 +44,6 @@ paths = [ "src/snapshot-tests/__tests__/json-normalize.test.ts", "src/snapshot-tests/__tests__/json-fixture-schema.test.ts", "src/snapshot-tests/__fixtures__/**", - "xcodebuildmcp.com/app/docs/_content/testing.mdx", -] - -[[skills.triggers]] -type = "pull_request" -actions = ["opened", "synchronize", "reopened"] - -[[skills]] -name = "xcodebuildmcp-structured-output-review" -maxTurns = 8 -maxFindings = 5 -paths = [ - "schemas/structured-output/**", - "src/core/structured-output-schema.ts", - "src/core/__tests__/structured-output-schema.test.ts", - "src/snapshot-tests/__fixtures__/json/**", - "src/snapshot-tests/__tests__/json-fixture-schema.test.ts", - "xcodebuildmcp.com/app/docs/_content/schema-versioning.mdx", - "xcodebuildmcp.com/app/docs/_content/output-formats.mdx", ] [[skills.triggers]] @@ -166,46 +51,36 @@ type = "pull_request" actions = ["opened", "synchronize", "reopened"] [[skills]] -name = "xcodebuildmcp-test-boundary-review" -maxTurns = 15 -maxFindings = 10 +name = "find-bugs" +remote = "getsentry/skills@b10e2db21d3165de1904bdf3fa64285016765fe5" +maxTurns = 4 +maxFindings = 3 paths = [ - "src/**/__tests__/**", - "src/test-utils/**", - "src/snapshot-tests/**", - "package.json", - "xcodebuildmcp.com/app/docs/_content/testing.mdx", - "xcodebuildmcp.com/app/docs/_content/contributing.mdx", + "src/cli.ts", + "src/cli/**", + "src/daemon.ts", + "src/daemon/**", + "src/doctor-cli.ts", + "src/integrations/**", + "src/mcp/resources/**", + "src/mcp/tools/**", + "src/runtime/**", + "src/server/**", + "src/utils/execution/**", + "scripts/**/*.{js,mjs,ts}", ] ignorePaths = [ - "src/snapshot-tests/__fixtures__/**", -] - -[[skills.triggers]] -type = "pull_request" -actions = ["opened", "synchronize", "reopened"] - -[[skills]] -name = "xcodebuildmcp-tool-contract-review" -maxTurns = 8 -maxFindings = 5 -paths = [ - "src/mcp/tools/**", - "src/core/manifest/schema.ts", - "src/runtime/tool-catalog.ts", - "src/runtime/types.ts", - "manifests/tools/*.yaml", - "manifests/workflows/*.yaml", - "xcodebuildmcp.com/app/docs/_content/tool-authoring.mdx", - "xcodebuildmcp.com/app/docs/_content/architecture-manifest-visibility.mdx", + "**/*.test.ts", + "**/__tests__/**", + "**/__fixtures__/**", + "**/__snapshots__/**", ] [[skills.triggers]] type = "pull_request" actions = ["opened", "synchronize", "reopened"] -# Remote security skills from getsentry/warden-skills - +# Local and scheduled security reviews are retained outside the PR critical path. [[skills]] name = "wrdn-pii" remote = "getsentry/warden-skills@6f720b5c4894e374f7a09707bae0de60d8b825df" @@ -226,7 +101,6 @@ ignorePaths = [ "src/snapshot-tests/__fixtures__/**", ] -# Temporarily local-only until the Pi model selector failure seen in PR checks is resolved. [[skills.triggers]] type = "local" @@ -246,36 +120,6 @@ ignorePaths = [ [[skills.triggers]] type = "schedule" -[[skills]] -name = "wrdn-code-execution" -remote = "getsentry/warden-skills@6f720b5c4894e374f7a09707bae0de60d8b825df" -maxTurns = 10 -maxFindings = 5 -paths = [ - "src/cli.ts", - "src/cli/**", - "src/daemon.ts", - "src/daemon/**", - "src/doctor-cli.ts", - "src/integrations/**", - "src/mcp/resources/**", - "src/mcp/tools/**", - "src/runtime/tool-invoker.ts", - "src/utils/execution/**", - "src/utils/xcodemake/**", - "scripts/**/*.{js,mjs,sh,ts}", -] -ignorePaths = [ - "**/*.test.ts", - "**/__tests__/**", - "**/__fixtures__/**", - "**/__snapshots__/**", -] - -[[skills.triggers]] -type = "pull_request" -actions = ["opened", "synchronize", "reopened"] - [[skills]] name = "wrdn-data-exfil" remote = "getsentry/warden-skills@6f720b5c4894e374f7a09707bae0de60d8b825df" @@ -292,56 +136,6 @@ ignorePaths = [ [[skills.triggers]] type = "schedule" -[[skills]] -name = "wrdn-gha-workflows" -remote = "getsentry/warden-skills@6f720b5c4894e374f7a09707bae0de60d8b825df" -maxTurns = 8 -maxFindings = 5 -paths = [ - ".github/workflows/*.yml", - ".github/workflows/*.yaml", - ".github/actions/**/*.yml", - ".github/actions/**/*.yaml", - "action.yml", - "action.yaml", -] - -[[skills.triggers]] -type = "pull_request" -actions = ["opened", "synchronize", "reopened"] - -# Remote code-quality skills from getsentry/skills - -[[skills]] -name = "find-bugs" -remote = "getsentry/skills@b10e2db21d3165de1904bdf3fa64285016765fe5" -maxTurns = 10 -maxFindings = 5 -paths = [ - "src/cli.ts", - "src/cli/**", - "src/daemon.ts", - "src/daemon/**", - "src/doctor-cli.ts", - "src/integrations/**", - "src/mcp/resources/**", - "src/mcp/tools/**", - "src/runtime/**", - "src/server/**", - "src/utils/execution/**", - "scripts/**/*.{js,mjs,ts}", -] -ignorePaths = [ - "**/*.test.ts", - "**/__tests__/**", - "**/__fixtures__/**", - "**/__snapshots__/**", -] - -[[skills.triggers]] -type = "pull_request" -actions = ["opened", "synchronize", "reopened"] - [[skills]] name = "code-review" remote = "getsentry/skills@b10e2db21d3165de1904bdf3fa64285016765fe5" From 91218a8dc2b658c30f1ff2418c7e242b129ae51e Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Tue, 21 Jul 2026 13:38:19 +0100 Subject: [PATCH 2/8] fix(warden): Address watchdog review feedback Start monitoring requested runs so queue time is enforced, pin Node 24, make deadline polling deterministic, and run watchdog tests through npm test. --- .github/workflows/warden-watchdog.yml | 6 +++++- package.json | 3 ++- scripts/__tests__/warden-watchdog.test.mjs | 24 ++++++++++++++++++++-- scripts/warden-watchdog.mjs | 9 ++++++-- 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/.github/workflows/warden-watchdog.yml b/.github/workflows/warden-watchdog.yml index 34a1cde5a..75f630d6a 100644 --- a/.github/workflows/warden-watchdog.yml +++ b/.github/workflows/warden-watchdog.yml @@ -3,7 +3,7 @@ name: Warden watchdog on: workflow_run: workflows: [Warden] - types: [in_progress] + types: [requested] concurrency: group: warden-watchdog-${{ github.event.workflow_run.id }} @@ -23,6 +23,10 @@ jobs: with: ref: ${{ github.event.repository.default_branch }} persist-credentials: false + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '24' - name: Enforce Warden timeout env: GITHUB_TOKEN: ${{ github.token }} diff --git a/package.json b/package.json index 235f8fc3f..6dcfc81c6 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,8 @@ "license:report": "node scripts/generate-third-party-package-licenses.mjs", "license:check": "npx -y license-checker --production --onlyAllow 'MIT;ISC;BSD-2-Clause;BSD-3-Clause;Apache-2.0;Unlicense;FSL-1.1-MIT;BlueOak-1.0.0'", "knip": "knip", - "test": "vitest run", + "test": "vitest run && npm run test:warden-watchdog", + "test:warden-watchdog": "node --test scripts/__tests__/warden-watchdog.test.mjs", "test:schema-fixtures": "vitest run src/snapshot-tests/__tests__/json-fixture-schema.test.ts", "test:snapshot": "npm run build && vitest run --config vitest.snapshot.config.ts", "test:snapshots": "npm run test:snapshot", diff --git a/scripts/__tests__/warden-watchdog.test.mjs b/scripts/__tests__/warden-watchdog.test.mjs index 2a1378e89..04468c3c9 100644 --- a/scripts/__tests__/warden-watchdog.test.mjs +++ b/scripts/__tests__/warden-watchdog.test.mjs @@ -56,7 +56,7 @@ test('monitorWardenRun returns when the Warden run completes', async () => { assert.deepEqual(result, { cancelled: false, ignored: false }); }); -test('monitorWardenRun cancels a Warden run at the runtime limit', async () => { +test('monitorWardenRun cancels a queued Warden run at the runtime limit', async () => { let cancelled = false; const result = await monitorWardenRun({ @@ -64,7 +64,7 @@ test('monitorWardenRun cancels a Warden run at the runtime limit', async () => { pollSeconds: 15, now: () => Date.parse('2026-07-21T09:10:00Z'), sleep: async () => assert.fail('stale run must be cancelled immediately'), - getRun: async () => wardenRun(), + getRun: async () => wardenRun({ status: 'queued' }), cancelRun: async () => { cancelled = true; return true; @@ -75,6 +75,26 @@ test('monitorWardenRun cancels a Warden run at the runtime limit', async () => { assert.deepEqual(result, { cancelled: true, ignored: false }); }); +test('monitorWardenRun calculates each delay from one clock reading', async () => { + const runs = [wardenRun(), wardenRun({ status: 'completed' })]; + const times = [Date.parse('2026-07-21T09:09:59.999Z'), Date.parse('2026-07-21T09:10:00.001Z')]; + let sleptMilliseconds = 0; + + const result = await monitorWardenRun({ + maxRuntimeSeconds: 600, + pollSeconds: 15, + now: () => times.shift(), + sleep: async (milliseconds) => { + sleptMilliseconds = milliseconds; + }, + getRun: async () => runs.shift(), + cancelRun: async () => assert.fail('completed run must not be cancelled'), + }); + + assert.equal(sleptMilliseconds, 1); + assert.deepEqual(result, { cancelled: false, ignored: false }); +}); + test('monitorWardenRun never sleeps past the runtime limit', async () => { let nowMs = Date.parse('2026-07-21T09:09:58Z'); let sleptMilliseconds = 0; diff --git a/scripts/warden-watchdog.mjs b/scripts/warden-watchdog.mjs index fd7cd020b..3d2ddf0a2 100644 --- a/scripts/warden-watchdog.mjs +++ b/scripts/warden-watchdog.mjs @@ -27,8 +27,13 @@ export async function monitorWardenRun({ } const deadlineMs = Date.parse(run.created_at) + maxRuntimeSeconds * 1000; - while (run.status !== 'completed' && now() < deadlineMs) { - await sleep(Math.min(pollSeconds * 1000, deadlineMs - now())); + while (run.status !== 'completed') { + const remainingMs = deadlineMs - now(); + if (remainingMs <= 0) { + break; + } + + await sleep(Math.min(pollSeconds * 1000, remainingMs)); run = await getRun(); } From 9e00b13ab3ec9448ca310b7c4e96d92aa3cb3be7 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Tue, 21 Jul 2026 13:40:15 +0100 Subject: [PATCH 3/8] fix(warden): Restore manual domain reviews Keep runtime, test, and tool contract skills configured with local-only triggers so the agent handoff commands remain usable without adding PR CI cost. --- warden.toml | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/warden.toml b/warden.toml index 2ebfa1062..638428ab5 100644 --- a/warden.toml +++ b/warden.toml @@ -80,6 +80,61 @@ ignorePaths = [ type = "pull_request" actions = ["opened", "synchronize", "reopened"] +# Targeted domain reviews run only when explicitly selected for high-risk changes. +[[skills]] +name = "xcodebuildmcp-runtime-boundary-review" +maxTurns = 8 +maxFindings = 5 +paths = [ + "src/runtime/tool-catalog.ts", + "src/runtime/tool-invoker.ts", + "src/runtime/types.ts", + "src/cli/**", + "src/daemon/**", + "manifests/tools/*.yaml", + "manifests/workflows/*.yaml", + "xcodebuildmcp.com/app/docs/_content/architecture-runtime-boundaries.mdx", + "xcodebuildmcp.com/app/docs/_content/architecture-manifest-visibility.mdx", +] + +[[skills.triggers]] +type = "local" + +[[skills]] +name = "xcodebuildmcp-test-boundary-review" +maxTurns = 8 +maxFindings = 5 +paths = [ + "src/**/__tests__/**", + "src/test-utils/**", + "src/snapshot-tests/**", + "package.json", + "xcodebuildmcp.com/app/docs/_content/testing.mdx", + "xcodebuildmcp.com/app/docs/_content/contributing.mdx", +] +ignorePaths = ["src/snapshot-tests/__fixtures__/**"] + +[[skills.triggers]] +type = "local" + +[[skills]] +name = "xcodebuildmcp-tool-contract-review" +maxTurns = 8 +maxFindings = 5 +paths = [ + "src/mcp/tools/**", + "src/core/manifest/schema.ts", + "src/runtime/tool-catalog.ts", + "src/runtime/types.ts", + "manifests/tools/*.yaml", + "manifests/workflows/*.yaml", + "xcodebuildmcp.com/app/docs/_content/tool-authoring.mdx", + "xcodebuildmcp.com/app/docs/_content/architecture-manifest-visibility.mdx", +] + +[[skills.triggers]] +type = "local" + # Local and scheduled security reviews are retained outside the PR critical path. [[skills]] name = "wrdn-pii" From a461c9573a2c121499e0da4f69b8e5bfad3daac7 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Tue, 21 Jul 2026 13:47:40 +0100 Subject: [PATCH 4/8] fix(warden): Monitor unnamed required runs The workflow_run trigger already selects Warden and provides the exact run ID. Validate only the pull request event so org-required runs are still monitored when GitHub omits their workflow name. --- scripts/__tests__/warden-watchdog.test.mjs | 16 ++++++++-------- scripts/warden-watchdog.mjs | 8 +++----- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/scripts/__tests__/warden-watchdog.test.mjs b/scripts/__tests__/warden-watchdog.test.mjs index 04468c3c9..f4b8b50d5 100644 --- a/scripts/__tests__/warden-watchdog.test.mjs +++ b/scripts/__tests__/warden-watchdog.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { isWardenPullRequestRun, monitorWardenRun, runtimeSeconds } from '../warden-watchdog.mjs'; +import { isPullRequestRun, monitorWardenRun, runtimeSeconds } from '../warden-watchdog.mjs'; function wardenRun(overrides = {}) { return { @@ -14,10 +14,10 @@ function wardenRun(overrides = {}) { }; } -test('isWardenPullRequestRun requires the workflow name and PR event', () => { - assert.equal(isWardenPullRequestRun(wardenRun()), true); - assert.equal(isWardenPullRequestRun(wardenRun({ name: 'Tests' })), false); - assert.equal(isWardenPullRequestRun(wardenRun({ event: 'push' })), false); +test('isPullRequestRun accepts an omitted workflow name and requires a PR event', () => { + assert.equal(isPullRequestRun(wardenRun()), true); + assert.equal(isPullRequestRun(wardenRun({ name: '' })), true); + assert.equal(isPullRequestRun(wardenRun({ event: 'push' })), false); }); test('runtimeSeconds includes queue time and never returns a negative duration', () => { @@ -27,12 +27,12 @@ test('runtimeSeconds includes queue time and never returns a negative duration', assert.equal(runtimeSeconds(run, Date.parse('2026-07-21T08:59:00Z')), 0); }); -test('monitorWardenRun ignores non-Warden runs', async () => { +test('monitorWardenRun ignores non-PR runs', async () => { const result = await monitorWardenRun({ maxRuntimeSeconds: 600, pollSeconds: 15, - getRun: async () => wardenRun({ name: 'Tests' }), - cancelRun: async () => assert.fail('non-Warden run must not be cancelled'), + getRun: async () => wardenRun({ event: 'push' }), + cancelRun: async () => assert.fail('non-PR run must not be cancelled'), }); assert.deepEqual(result, { cancelled: false, ignored: true }); diff --git a/scripts/warden-watchdog.mjs b/scripts/warden-watchdog.mjs index 3d2ddf0a2..86b5db22d 100644 --- a/scripts/warden-watchdog.mjs +++ b/scripts/warden-watchdog.mjs @@ -2,10 +2,8 @@ import process from 'node:process'; import { setTimeout as sleepTimer } from 'node:timers/promises'; import { pathToFileURL } from 'node:url'; -const WARDEN_WORKFLOW_NAME = 'Warden'; - -export function isWardenPullRequestRun(run) { - return run.name === WARDEN_WORKFLOW_NAME && run.event === 'pull_request'; +export function isPullRequestRun(run) { + return run.event === 'pull_request'; } export function runtimeSeconds(run, nowMs) { @@ -22,7 +20,7 @@ export async function monitorWardenRun({ }) { let run = await getRun(); - if (!isWardenPullRequestRun(run)) { + if (!isPullRequestRun(run)) { return { cancelled: false, ignored: true }; } From 075fb1097c0838d2008ae16f9a920db668ca09c7 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Tue, 21 Jul 2026 13:49:44 +0100 Subject: [PATCH 5/8] fix(warden): Isolate watchdog test arguments Run the watchdog suite through npm posttest so targeted Vitest arguments are not forwarded to the Node test runner. --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 6dcfc81c6..c95101ccd 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,8 @@ "license:report": "node scripts/generate-third-party-package-licenses.mjs", "license:check": "npx -y license-checker --production --onlyAllow 'MIT;ISC;BSD-2-Clause;BSD-3-Clause;Apache-2.0;Unlicense;FSL-1.1-MIT;BlueOak-1.0.0'", "knip": "knip", - "test": "vitest run && npm run test:warden-watchdog", + "test": "vitest run", + "posttest": "npm run test:warden-watchdog", "test:warden-watchdog": "node --test scripts/__tests__/warden-watchdog.test.mjs", "test:schema-fixtures": "vitest run src/snapshot-tests/__tests__/json-fixture-schema.test.ts", "test:snapshot": "npm run build && vitest run --config vitest.snapshot.config.ts", From 3af57f234d972395fa07d1e24427187e435e9be3 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Tue, 21 Jul 2026 17:11:12 +0100 Subject: [PATCH 6/8] fix(warden): Close watchdog lifecycle gaps Recheck terminal state after cancellation, monitor rerun attempts from their current start time, and keep watchdog code in standard quality checks. Add concise review-readiness guidance and align the manual test-boundary review scope. --- .../SKILL.md | 1 + .github/workflows/warden-watchdog.yml | 9 ++- AGENTS.md | 4 +- CLAUDE.md | 4 +- package.json | 8 +- scripts/__tests__/warden-watchdog.test.mjs | 76 ++++++++++++++++++- scripts/warden-watchdog.mjs | 24 ++++-- warden.toml | 1 + 8 files changed, 107 insertions(+), 20 deletions(-) diff --git a/.agents/skills/xcodebuildmcp-test-boundary-review/SKILL.md b/.agents/skills/xcodebuildmcp-test-boundary-review/SKILL.md index bda14bb89..ee5c9f42a 100644 --- a/.agents/skills/xcodebuildmcp-test-boundary-review/SKILL.md +++ b/.agents/skills/xcodebuildmcp-test-boundary-review/SKILL.md @@ -17,6 +17,7 @@ Review guardrails for test isolation, scope, and contract validation. - `src/**/__tests__/**` - `src/test-utils/**` - `src/snapshot-tests/**` +- `scripts/**/__tests__/**` - `package.json` - `xcodebuildmcp.com/app/docs/_content/testing.mdx` - `xcodebuildmcp.com/app/docs/_content/contributing.mdx` diff --git a/.github/workflows/warden-watchdog.yml b/.github/workflows/warden-watchdog.yml index 75f630d6a..fe0a60160 100644 --- a/.github/workflows/warden-watchdog.yml +++ b/.github/workflows/warden-watchdog.yml @@ -3,10 +3,11 @@ name: Warden watchdog on: workflow_run: workflows: [Warden] - types: [requested] + # GitHub omits `requested` for reruns, so reruns are caught when they enter `in_progress`. + types: [requested, in_progress] concurrency: - group: warden-watchdog-${{ github.event.workflow_run.id }} + group: warden-watchdog-${{ github.event.workflow_run.id }}-${{ github.event.action }}-${{ github.event.workflow_run.run_attempt }} cancel-in-progress: true permissions: @@ -15,7 +16,9 @@ permissions: jobs: enforce-timeout: - if: github.event.workflow_run.event == 'pull_request' + if: >- + github.event.workflow_run.event == 'pull_request' && + (github.event.action == 'requested' || github.event.workflow_run.run_attempt > 1) runs-on: ubuntu-latest timeout-minutes: 11 steps: diff --git a/AGENTS.md b/AGENTS.md index ca0d42921..8c3ab6375 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,9 @@ ESM TypeScript project (`type: module`). Key layers: - NEVER remove or downgrade code to fix type errors from outdated dependencies; upgrade the dependency instead - Always ask before removing functionality or code that appears to be intentional - Do not add fallback behavior by default. If required context, configuration, runtime state, or dependencies are missing, fail loudly and fix the caller/setup instead of silently switching to an alternate path. Add a fallback only when explicitly requested or when it is a documented product requirement. -- Follow TypeScript best practices +- Review the complete merge-base diff and trace changed contracts through callers, consumers, tests, and operational configuration. +- Verify standard quality commands include every changed path and exercise exact entry points and argument variants; validate explicitly when they do not. +- For asynchronous, workflow, or process-boundary changes, enumerate lifecycle states and race transitions; test terminal outcomes and missing or optional metadata. ## Import Conventions - ESM with explicit `.ts` extensions in `src/` (tsup rewrites to `.js` at build) diff --git a/CLAUDE.md b/CLAUDE.md index 31e3165e3..4b5cee845 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,9 @@ - NEVER remove or downgrade code to fix type errors from outdated dependencies; upgrade the dependency instead - Always ask before removing functionality or code that appears to be intentional - Do not add fallback behavior by default. If required context, configuration, runtime state, or dependencies are missing, fail loudly and fix the caller/setup instead of silently switching to an alternate path. Add a fallback only when explicitly requested or when it is a documented product requirement. -- Follow TypeScript best practices +- Review the complete merge-base diff and trace changed contracts through callers, consumers, tests, and operational configuration. +- Verify standard quality commands include every changed path and exercise exact entry points and argument variants; validate explicitly when they do not. +- For asynchronous, workflow, or process-boundary changes, enumerate lifecycle states and race transitions; test terminal outcomes and missing or optional metadata. ## Test Conventions - Snapshot tests (`*.snapshot.test.ts`) must only assert generated tool output against fixtures. Move helper, parser, schema, setup, or behavior assertions to non-snapshot unit/integration tests. diff --git a/package.json b/package.json index c95101ccd..ca30ee25a 100644 --- a/package.json +++ b/package.json @@ -30,10 +30,10 @@ "package:macos:universal": "scripts/package-macos-portable.sh --universal", "verify:portable": "scripts/verify-portable-install.sh", "homebrew:formula": "scripts/create-homebrew-formula.sh", - "lint": "eslint 'src/**/*.{js,ts}' 'benchmarks/claude-ui/**/*.ts'", - "lint:fix": "eslint 'src/**/*.{js,ts}' 'benchmarks/claude-ui/**/*.ts' --fix", - "format": "prettier --write 'src/**/*.{js,ts}' 'benchmarks/claude-ui/**/*.{ts,md,yml}'", - "format:check": "prettier --check 'src/**/*.{js,ts}' 'benchmarks/claude-ui/**/*.{ts,md,yml}'", + "lint": "eslint 'src/**/*.{js,ts}' 'benchmarks/claude-ui/**/*.ts' scripts/warden-watchdog.mjs scripts/__tests__/warden-watchdog.test.mjs", + "lint:fix": "eslint 'src/**/*.{js,ts}' 'benchmarks/claude-ui/**/*.ts' scripts/warden-watchdog.mjs scripts/__tests__/warden-watchdog.test.mjs --fix", + "format": "prettier --write 'src/**/*.{js,ts}' 'benchmarks/claude-ui/**/*.{ts,md,yml}' scripts/warden-watchdog.mjs scripts/__tests__/warden-watchdog.test.mjs", + "format:check": "prettier --check 'src/**/*.{js,ts}' 'benchmarks/claude-ui/**/*.{ts,md,yml}' scripts/warden-watchdog.mjs scripts/__tests__/warden-watchdog.test.mjs", "typecheck": "npx tsc --noEmit && npx tsc -p tsconfig.test.json && npx tsc -p tsconfig.benchmarks.json", "typecheck:tests": "npx tsc -p tsconfig.test.json", "inspect": "npx @modelcontextprotocol/inspector@latest node build/cli.js mcp", diff --git a/scripts/__tests__/warden-watchdog.test.mjs b/scripts/__tests__/warden-watchdog.test.mjs index f4b8b50d5..540da322f 100644 --- a/scripts/__tests__/warden-watchdog.test.mjs +++ b/scripts/__tests__/warden-watchdog.test.mjs @@ -1,7 +1,14 @@ import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import test from 'node:test'; +import { URL } from 'node:url'; -import { isPullRequestRun, monitorWardenRun, runtimeSeconds } from '../warden-watchdog.mjs'; +import { + isPullRequestRun, + monitorWardenRun, + runStartTimeMs, + runtimeSeconds, +} from '../warden-watchdog.mjs'; function wardenRun(overrides = {}) { return { @@ -9,12 +16,25 @@ function wardenRun(overrides = {}) { name: 'Warden', event: 'pull_request', status: 'in_progress', + run_attempt: 1, created_at: '2026-07-21T09:00:00Z', + run_started_at: '2026-07-21T09:01:00Z', ...overrides, }; } -test('isPullRequestRun accepts an omitted workflow name and requires a PR event', () => { +test('workflow starts the watchdog for initial runs and reruns', () => { + const workflow = readFileSync( + new URL('../../.github/workflows/warden-watchdog.yml', import.meta.url), + 'utf8', + ); + + assert.match(workflow, /types: \[requested, in_progress\]/); + assert.match(workflow, /github\.event\.action == 'requested'/); + assert.match(workflow, /github\.event\.workflow_run\.run_attempt > 1/); +}); + +test('isPullRequestRun classifies runs using only the event', () => { assert.equal(isPullRequestRun(wardenRun()), true); assert.equal(isPullRequestRun(wardenRun({ name: '' })), true); assert.equal(isPullRequestRun(wardenRun({ event: 'push' })), false); @@ -27,6 +47,24 @@ test('runtimeSeconds includes queue time and never returns a negative duration', assert.equal(runtimeSeconds(run, Date.parse('2026-07-21T08:59:00Z')), 0); }); +test('runStartTimeMs uses the current attempt start for reruns', () => { + const rerun = wardenRun({ + run_attempt: 3, + created_at: '2026-07-20T21:07:18Z', + run_started_at: '2026-07-21T09:36:28Z', + }); + + assert.equal(runStartTimeMs(rerun), Date.parse('2026-07-21T09:36:28Z')); + assert.equal(runtimeSeconds(rerun, Date.parse('2026-07-21T09:46:28Z')), 600); +}); + +test('runStartTimeMs rejects a rerun without a valid attempt timestamp', () => { + assert.throws( + () => runStartTimeMs(wardenRun({ run_attempt: 2, run_started_at: null })), + /invalid start timestamp/, + ); +}); + test('monitorWardenRun ignores non-PR runs', async () => { const result = await monitorWardenRun({ maxRuntimeSeconds: 600, @@ -115,8 +153,38 @@ test('monitorWardenRun never sleeps past the runtime limit', async () => { assert.deepEqual(result, { cancelled: true, ignored: false }); }); -test('monitorWardenRun handles a run completing during cancellation', async () => { - const runs = [wardenRun(), wardenRun({ status: 'completed' })]; +test('monitorWardenRun handles a run completing naturally during cancellation', async () => { + const runs = [wardenRun(), wardenRun({ status: 'completed', conclusion: 'success' })]; + + const result = await monitorWardenRun({ + maxRuntimeSeconds: 600, + pollSeconds: 15, + now: () => Date.parse('2026-07-21T09:10:00Z'), + sleep: async () => assert.fail('stale run must attempt cancellation immediately'), + getRun: async () => runs.shift(), + cancelRun: async () => true, + }); + + assert.deepEqual(result, { cancelled: false, ignored: false }); +}); + +test('monitorWardenRun reports a completed cancellation', async () => { + const runs = [wardenRun(), wardenRun({ status: 'completed', conclusion: 'cancelled' })]; + + const result = await monitorWardenRun({ + maxRuntimeSeconds: 600, + pollSeconds: 15, + now: () => Date.parse('2026-07-21T09:10:00Z'), + sleep: async () => assert.fail('stale run must attempt cancellation immediately'), + getRun: async () => runs.shift(), + cancelRun: async () => true, + }); + + assert.deepEqual(result, { cancelled: true, ignored: false }); +}); + +test('monitorWardenRun handles a run completing after cancellation is rejected', async () => { + const runs = [wardenRun(), wardenRun({ status: 'completed', conclusion: 'success' })]; const result = await monitorWardenRun({ maxRuntimeSeconds: 600, diff --git a/scripts/warden-watchdog.mjs b/scripts/warden-watchdog.mjs index 86b5db22d..44e9fffa8 100644 --- a/scripts/warden-watchdog.mjs +++ b/scripts/warden-watchdog.mjs @@ -6,8 +6,17 @@ export function isPullRequestRun(run) { return run.event === 'pull_request'; } +export function runStartTimeMs(run) { + const startTime = run.run_attempt > 1 ? run.run_started_at : run.created_at; + const startTimeMs = Date.parse(startTime); + if (Number.isNaN(startTimeMs)) { + throw new Error('Warden run has an invalid start timestamp'); + } + return startTimeMs; +} + export function runtimeSeconds(run, nowMs) { - return Math.max(0, Math.floor((nowMs - Date.parse(run.created_at)) / 1000)); + return Math.max(0, Math.floor((nowMs - runStartTimeMs(run)) / 1000)); } export async function monitorWardenRun({ @@ -24,7 +33,7 @@ export async function monitorWardenRun({ return { cancelled: false, ignored: true }; } - const deadlineMs = Date.parse(run.created_at) + maxRuntimeSeconds * 1000; + const deadlineMs = runStartTimeMs(run) + maxRuntimeSeconds * 1000; while (run.status !== 'completed') { const remainingMs = deadlineMs - now(); if (remainingMs <= 0) { @@ -39,13 +48,14 @@ export async function monitorWardenRun({ return { cancelled: false, ignored: false }; } - if (await cancelRun()) { - return { cancelled: true, ignored: false }; - } - + const cancellationAccepted = await cancelRun(); run = await getRun(); if (run.status === 'completed') { - return { cancelled: false, ignored: false }; + return { cancelled: cancellationAccepted && run.conclusion === 'cancelled', ignored: false }; + } + + if (cancellationAccepted) { + return { cancelled: true, ignored: false }; } throw new Error('Warden run remained active after cancellation was rejected'); diff --git a/warden.toml b/warden.toml index 638428ab5..33b9f6892 100644 --- a/warden.toml +++ b/warden.toml @@ -108,6 +108,7 @@ paths = [ "src/**/__tests__/**", "src/test-utils/**", "src/snapshot-tests/**", + "scripts/**/__tests__/**", "package.json", "xcodebuildmcp.com/app/docs/_content/testing.mdx", "xcodebuildmcp.com/app/docs/_content/contributing.mdx", From 4d7fc993c1b46fe05efad3b624f44afd70e8c327 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Tue, 21 Jul 2026 17:24:47 +0100 Subject: [PATCH 7/8] fix(warden): Isolate watchdog run attempts --- .github/workflows/warden-watchdog.yml | 8 ++--- AGENTS.md | 2 +- CLAUDE.md | 2 +- scripts/__tests__/warden-watchdog.test.mjs | 36 +++++++++++++++++++--- scripts/warden-watchdog.mjs | 31 +++++++++++++++++-- 5 files changed, 65 insertions(+), 14 deletions(-) diff --git a/.github/workflows/warden-watchdog.yml b/.github/workflows/warden-watchdog.yml index fe0a60160..0431d69af 100644 --- a/.github/workflows/warden-watchdog.yml +++ b/.github/workflows/warden-watchdog.yml @@ -6,10 +6,6 @@ on: # GitHub omits `requested` for reruns, so reruns are caught when they enter `in_progress`. types: [requested, in_progress] -concurrency: - group: warden-watchdog-${{ github.event.workflow_run.id }}-${{ github.event.action }}-${{ github.event.workflow_run.run_attempt }} - cancel-in-progress: true - permissions: actions: write contents: read @@ -21,6 +17,9 @@ jobs: (github.event.action == 'requested' || github.event.workflow_run.run_attempt > 1) runs-on: ubuntu-latest timeout-minutes: 11 + concurrency: + group: warden-watchdog-${{ github.event.workflow_run.id }} + cancel-in-progress: true steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: @@ -34,6 +33,7 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} TARGET_RUN_ID: ${{ github.event.workflow_run.id }} + TARGET_RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }} WARDEN_MAX_RUNTIME_SECONDS: 600 WARDEN_POLL_SECONDS: 15 run: node scripts/warden-watchdog.mjs diff --git a/AGENTS.md b/AGENTS.md index 8c3ab6375..620e0b5fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,7 +50,7 @@ ESM TypeScript project (`type: module`). Key layers: - Do not add fallback behavior by default. If required context, configuration, runtime state, or dependencies are missing, fail loudly and fix the caller/setup instead of silently switching to an alternate path. Add a fallback only when explicitly requested or when it is a documented product requirement. - Review the complete merge-base diff and trace changed contracts through callers, consumers, tests, and operational configuration. - Verify standard quality commands include every changed path and exercise exact entry points and argument variants; validate explicitly when they do not. -- For asynchronous, workflow, or process-boundary changes, enumerate lifecycle states and race transitions; test terminal outcomes and missing or optional metadata. +- For asynchronous, workflow, or process-boundary changes, enumerate lifecycle states, retries, supersession, and race transitions; test terminal outcomes and missing or optional metadata. ## Import Conventions - ESM with explicit `.ts` extensions in `src/` (tsup rewrites to `.js` at build) diff --git a/CLAUDE.md b/CLAUDE.md index 4b5cee845..bc80c8f74 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ - Do not add fallback behavior by default. If required context, configuration, runtime state, or dependencies are missing, fail loudly and fix the caller/setup instead of silently switching to an alternate path. Add a fallback only when explicitly requested or when it is a documented product requirement. - Review the complete merge-base diff and trace changed contracts through callers, consumers, tests, and operational configuration. - Verify standard quality commands include every changed path and exercise exact entry points and argument variants; validate explicitly when they do not. -- For asynchronous, workflow, or process-boundary changes, enumerate lifecycle states and race transitions; test terminal outcomes and missing or optional metadata. +- For asynchronous, workflow, or process-boundary changes, enumerate lifecycle states, retries, supersession, and race transitions; test terminal outcomes and missing or optional metadata. ## Test Conventions - Snapshot tests (`*.snapshot.test.ts`) must only assert generated tool output against fixtures. Move helper, parser, schema, setup, or behavior assertions to non-snapshot unit/integration tests. diff --git a/scripts/__tests__/warden-watchdog.test.mjs b/scripts/__tests__/warden-watchdog.test.mjs index 540da322f..138b849cf 100644 --- a/scripts/__tests__/warden-watchdog.test.mjs +++ b/scripts/__tests__/warden-watchdog.test.mjs @@ -32,6 +32,14 @@ test('workflow starts the watchdog for initial runs and reruns', () => { assert.match(workflow, /types: \[requested, in_progress\]/); assert.match(workflow, /github\.event\.action == 'requested'/); assert.match(workflow, /github\.event\.workflow_run\.run_attempt > 1/); + assert.match( + workflow, + / {4}concurrency:\n {6}group: warden-watchdog-\$\{\{ github\.event\.workflow_run\.id \}\}\n {6}cancel-in-progress: true/, + ); + assert.match( + workflow, + /TARGET_RUN_ATTEMPT: \$\{\{ github\.event\.workflow_run\.run_attempt \}\}/, + ); }); test('isPullRequestRun classifies runs using only the event', () => { @@ -47,20 +55,20 @@ test('runtimeSeconds includes queue time and never returns a negative duration', assert.equal(runtimeSeconds(run, Date.parse('2026-07-21T08:59:00Z')), 0); }); -test('runStartTimeMs uses the current attempt start for reruns', () => { +test('runStartTimeMs includes queue time from attempt-scoped rerun metadata', () => { const rerun = wardenRun({ run_attempt: 3, - created_at: '2026-07-20T21:07:18Z', + created_at: '2026-07-21T09:30:00Z', run_started_at: '2026-07-21T09:36:28Z', }); - assert.equal(runStartTimeMs(rerun), Date.parse('2026-07-21T09:36:28Z')); - assert.equal(runtimeSeconds(rerun, Date.parse('2026-07-21T09:46:28Z')), 600); + assert.equal(runStartTimeMs(rerun), Date.parse('2026-07-21T09:30:00Z')); + assert.equal(runtimeSeconds(rerun, Date.parse('2026-07-21T09:40:00Z')), 600); }); test('runStartTimeMs rejects a rerun without a valid attempt timestamp', () => { assert.throws( - () => runStartTimeMs(wardenRun({ run_attempt: 2, run_started_at: null })), + () => runStartTimeMs(wardenRun({ run_attempt: 2, created_at: null })), /invalid start timestamp/, ); }); @@ -153,6 +161,21 @@ test('monitorWardenRun never sleeps past the runtime limit', async () => { assert.deepEqual(result, { cancelled: true, ignored: false }); }); +test('monitorWardenRun does not cancel a newer run attempt', async () => { + const result = await monitorWardenRun({ + maxRuntimeSeconds: 600, + pollSeconds: 15, + expectedRunAttempt: 1, + now: () => Date.parse('2026-07-21T09:10:00Z'), + sleep: async () => assert.fail('stale run must check the current attempt immediately'), + getRun: async () => wardenRun({ run_attempt: 1 }), + getCurrentRun: async () => wardenRun({ run_attempt: 2 }), + cancelRun: async () => assert.fail('a superseded attempt must not cancel the current run'), + }); + + assert.deepEqual(result, { cancelled: false, ignored: false, superseded: true }); +}); + test('monitorWardenRun handles a run completing naturally during cancellation', async () => { const runs = [wardenRun(), wardenRun({ status: 'completed', conclusion: 'success' })]; @@ -162,6 +185,7 @@ test('monitorWardenRun handles a run completing naturally during cancellation', now: () => Date.parse('2026-07-21T09:10:00Z'), sleep: async () => assert.fail('stale run must attempt cancellation immediately'), getRun: async () => runs.shift(), + getCurrentRun: async () => wardenRun(), cancelRun: async () => true, }); @@ -177,6 +201,7 @@ test('monitorWardenRun reports a completed cancellation', async () => { now: () => Date.parse('2026-07-21T09:10:00Z'), sleep: async () => assert.fail('stale run must attempt cancellation immediately'), getRun: async () => runs.shift(), + getCurrentRun: async () => wardenRun(), cancelRun: async () => true, }); @@ -192,6 +217,7 @@ test('monitorWardenRun handles a run completing after cancellation is rejected', now: () => Date.parse('2026-07-21T09:10:00Z'), sleep: async () => assert.fail('stale run must attempt cancellation immediately'), getRun: async () => runs.shift(), + getCurrentRun: async () => wardenRun(), cancelRun: async () => false, }); diff --git a/scripts/warden-watchdog.mjs b/scripts/warden-watchdog.mjs index 44e9fffa8..825cef88b 100644 --- a/scripts/warden-watchdog.mjs +++ b/scripts/warden-watchdog.mjs @@ -7,8 +7,7 @@ export function isPullRequestRun(run) { } export function runStartTimeMs(run) { - const startTime = run.run_attempt > 1 ? run.run_started_at : run.created_at; - const startTimeMs = Date.parse(startTime); + const startTimeMs = Date.parse(run.created_at); if (Number.isNaN(startTimeMs)) { throw new Error('Warden run has an invalid start timestamp'); } @@ -21,7 +20,9 @@ export function runtimeSeconds(run, nowMs) { export async function monitorWardenRun({ getRun, + getCurrentRun = getRun, cancelRun, + expectedRunAttempt, maxRuntimeSeconds, pollSeconds, now = Date.now, @@ -33,6 +34,11 @@ export async function monitorWardenRun({ return { cancelled: false, ignored: true }; } + const monitoredAttempt = expectedRunAttempt ?? run.run_attempt; + if (run.run_attempt !== monitoredAttempt) { + throw new Error(`Expected Warden run attempt ${monitoredAttempt}, received ${run.run_attempt}`); + } + const deadlineMs = runStartTimeMs(run) + maxRuntimeSeconds * 1000; while (run.status !== 'completed') { const remainingMs = deadlineMs - now(); @@ -48,6 +54,15 @@ export async function monitorWardenRun({ return { cancelled: false, ignored: false }; } + const currentRun = await getCurrentRun(); + if (currentRun.run_attempt !== monitoredAttempt) { + return { cancelled: false, ignored: false, superseded: true }; + } + + if (currentRun.status === 'completed') { + return { cancelled: false, ignored: false }; + } + const cancellationAccepted = await cancelRun(); run = await getRun(); if (run.status === 'completed') { @@ -107,13 +122,18 @@ function positiveIntegerEnvironment(name) { async function main() { const repository = requiredEnvironment('GITHUB_REPOSITORY'); const runId = positiveIntegerEnvironment('TARGET_RUN_ID'); + const runAttempt = positiveIntegerEnvironment('TARGET_RUN_ATTEMPT'); const maxRuntimeSeconds = positiveIntegerEnvironment('WARDEN_MAX_RUNTIME_SECONDS'); requiredEnvironment('GITHUB_TOKEN'); const result = await monitorWardenRun({ maxRuntimeSeconds, pollSeconds: positiveIntegerEnvironment('WARDEN_POLL_SECONDS'), - getRun: () => githubRequest(`/repos/${repository}/actions/runs/${runId}`), + expectedRunAttempt: runAttempt, + // The generic run endpoint retains attempt 1's created_at across reruns. + getRun: () => + githubRequest(`/repos/${repository}/actions/runs/${runId}/attempts/${runAttempt}`), + getCurrentRun: () => githubRequest(`/repos/${repository}/actions/runs/${runId}`), cancelRun: async () => { const response = await githubRequest(`/repos/${repository}/actions/runs/${runId}/cancel`, { method: 'POST', @@ -126,6 +146,11 @@ async function main() { throw new Error(`Cancelled Warden run ${runId} after exceeding ${maxRuntimeSeconds} seconds`); } + if (result.superseded) { + globalThis.console.log(`Warden run attempt ${runAttempt} was superseded`); + return; + } + globalThis.console.log(result.ignored ? 'Ignored non-PR Warden run' : 'Warden run completed'); } From 062afdbb3e6ad2d0d6ad83b6d6c05d8098419d8a Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Tue, 21 Jul 2026 17:32:31 +0100 Subject: [PATCH 8/8] fix(warden): Scope cancellation conflicts --- AGENTS.md | 2 +- CLAUDE.md | 2 +- scripts/__tests__/warden-watchdog.test.mjs | 12 ++++++++++++ scripts/warden-watchdog.mjs | 14 +++++++++----- 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 620e0b5fd..148b3cded 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,7 @@ ESM TypeScript project (`type: module`). Key layers: - NEVER remove or downgrade code to fix type errors from outdated dependencies; upgrade the dependency instead - Always ask before removing functionality or code that appears to be intentional - Do not add fallback behavior by default. If required context, configuration, runtime state, or dependencies are missing, fail loudly and fix the caller/setup instead of silently switching to an alternate path. Add a fallback only when explicitly requested or when it is a documented product requirement. -- Review the complete merge-base diff and trace changed contracts through callers, consumers, tests, and operational configuration. +- Review the complete merge-base diff and trace changed or reused helper contracts, including error and sentinel returns, through callers, consumers, tests, and operational configuration. - Verify standard quality commands include every changed path and exercise exact entry points and argument variants; validate explicitly when they do not. - For asynchronous, workflow, or process-boundary changes, enumerate lifecycle states, retries, supersession, and race transitions; test terminal outcomes and missing or optional metadata. diff --git a/CLAUDE.md b/CLAUDE.md index bc80c8f74..732288fef 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ - NEVER remove or downgrade code to fix type errors from outdated dependencies; upgrade the dependency instead - Always ask before removing functionality or code that appears to be intentional - Do not add fallback behavior by default. If required context, configuration, runtime state, or dependencies are missing, fail loudly and fix the caller/setup instead of silently switching to an alternate path. Add a fallback only when explicitly requested or when it is a documented product requirement. -- Review the complete merge-base diff and trace changed contracts through callers, consumers, tests, and operational configuration. +- Review the complete merge-base diff and trace changed or reused helper contracts, including error and sentinel returns, through callers, consumers, tests, and operational configuration. - Verify standard quality commands include every changed path and exercise exact entry points and argument variants; validate explicitly when they do not. - For asynchronous, workflow, or process-boundary changes, enumerate lifecycle states, retries, supersession, and race transitions; test terminal outcomes and missing or optional metadata. diff --git a/scripts/__tests__/warden-watchdog.test.mjs b/scripts/__tests__/warden-watchdog.test.mjs index 138b849cf..641da7009 100644 --- a/scripts/__tests__/warden-watchdog.test.mjs +++ b/scripts/__tests__/warden-watchdog.test.mjs @@ -4,6 +4,7 @@ import test from 'node:test'; import { URL } from 'node:url'; import { + githubRequest, isPullRequestRun, monitorWardenRun, runStartTimeMs, @@ -23,6 +24,17 @@ function wardenRun(overrides = {}) { }; } +test('githubRequest scopes conflict handling to callers that allow it', async (context) => { + const originalFetch = globalThis.fetch; + context.after(() => { + globalThis.fetch = originalFetch; + }); + globalThis.fetch = async () => ({ ok: false, status: 409 }); + + await assert.rejects(githubRequest('/run'), /GitHub API GET \/run failed: 409/); + assert.equal(await githubRequest('/cancel', { method: 'POST', allowConflict: true }), null); +}); + test('workflow starts the watchdog for initial runs and reruns', () => { const workflow = readFileSync( new URL('../../.github/workflows/warden-watchdog.yml', import.meta.url), diff --git a/scripts/warden-watchdog.mjs b/scripts/warden-watchdog.mjs index 825cef88b..8bcf3e0a1 100644 --- a/scripts/warden-watchdog.mjs +++ b/scripts/warden-watchdog.mjs @@ -76,20 +76,23 @@ export async function monitorWardenRun({ throw new Error('Warden run remained active after cancellation was rejected'); } -async function githubRequest(path, options = {}) { +export async function githubRequest(path, options = {}) { + const { allowConflict = false, ...requestOptions } = options; const response = await globalThis.fetch(`https://api.github.com${path}`, { - ...options, + ...requestOptions, headers: { Accept: 'application/vnd.github+json', Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, 'X-GitHub-Api-Version': '2022-11-28', - ...options.headers, + ...requestOptions.headers, }, signal: globalThis.AbortSignal.timeout(30_000), }); - if (!response.ok && response.status !== 409) { - throw new Error(`GitHub API ${options.method ?? 'GET'} ${path} failed: ${response.status}`); + if (!response.ok && !(allowConflict && response.status === 409)) { + throw new Error( + `GitHub API ${requestOptions.method ?? 'GET'} ${path} failed: ${response.status}`, + ); } if (response.status === 202 || response.status === 204) { @@ -137,6 +140,7 @@ async function main() { cancelRun: async () => { const response = await githubRequest(`/repos/${repository}/actions/runs/${runId}/cancel`, { method: 'POST', + allowConflict: true, }); return response !== null; },