diff --git a/.agents/skills/projectclownfish-cluster-worker/SKILL.md b/.agents/skills/projectclownfish-cluster-worker/SKILL.md index cef5641..921bbbb 100644 --- a/.agents/skills/projectclownfish-cluster-worker/SKILL.md +++ b/.agents/skills/projectclownfish-cluster-worker/SKILL.md @@ -84,6 +84,7 @@ Use repo scripts and prompts as the control plane: - `scripts/plan-cluster.mjs`: what gets hydrated into the prompt. - `scripts/execute-fix-artifact.mjs`: deterministic branch repair/replacement PR gate. - `scripts/apply-result.mjs`: deterministic mutation gate. +- `scripts/post-flight.mjs`: deterministic post-execution finalizer for ProjectClownfish fix PRs and post-merge closeouts. - `scripts/import-ghcrawl-low-signal-prs.mjs`: local ghcrawl open-PR scanner for opt-in low-signal cleanup jobs. - `.github/workflows/cluster-worker.yml`: runner behavior and env capture. @@ -102,6 +103,7 @@ Current autonomy posture: - Useful but uneditable or unsafe source PRs are replacement candidates, not human blockers. When a canonical PR is draft, stale, unmergeable, has `maintainer_can_modify=false`, or has broad unrelated churn, emit or execute `replace_uneditable_branch` with full source PR credit instead of waiting for a maintainer decision. - Fix execution should provide Codex actual repo-discovery context before editing; repeated "no target repo changes" means tune `scripts/execute-fix-artifact.mjs` before replaying more jobs. GitHub Actions may block Codex bwrap write/review sandboxes, so write-mode and review execution default to `danger-full-access` there after tokens are stripped from the Codex environment. A Codex write preflight must fail fast before the expensive repair loop if sandbox/auth/write access is broken; do not wait through multi-attempt edits to discover startup failures. Keep canary execution bounded: default worker timeout is 30 minutes, fix Codex timeout is 30 minutes, preflight timeout is 2 minutes, Codex model is `gpt-5.5`, and Codex reasoning effort is `medium`. Worker timeout/failure and exhausted `/review` attempts must write blocked artifacts and keep the workflow reporting path alive. Fix executor runs must copy Codex debug logs into the run artifact so timeout failures are inspectable. - Match OpenClaw's CI fast lane for fix validation. Use `blacksmith-4vcpu-ubuntu-2404` for cluster planning/review and `blacksmith-16vcpu-ubuntu-2404` for fix/apply execution. The executor sets `OPENCLAW_LOCAL_CHECK=0` and treats `pnpm check:changed` plus diff checks as the default hard gate. It normalizes target validation commands to `pnpm check:changed` unless `CLOWNFISH_TARGET_VALIDATION_MODE=strict` or `CLOWNFISH_STRICT_TARGET_VALIDATION=1` is explicitly set, so unrelated flaky main CI and broad suites do not block narrow ProjectClownfish fixes. +- After fix execution, run post-flight finalization before the final closeout replay. Post-flight may merge only ProjectClownfish-opened/pushed fix PRs, only after merge preflight, security clearance, resolved review threads, and non-ignored checks are clean. Default ignored checks are `auto-response`, `Labeler`, and `Stale`; configure `CLOWNFISH_POST_FLIGHT_IGNORE_CHECKS` rather than broadening the hard gate in code. - Prefer `keep_related`, `keep_independent`, `keep_closed`, `fix_needed`, `route_security`, and subcluster notes over blanket `needs_human`. - Use `needs_human` only for the exact maintainer decision still unresolved after hydrated evidence is reviewed. - Worker results must use one action per issue/PR ref. Never emit comma-separated action targets; related follow-up subclusters should be one `keep_related` action per ref or one cluster-scoped `fix_needed` action. @@ -112,6 +114,7 @@ After tuning, run: node --check scripts/plan-cluster.mjs node --check scripts/import-ghcrawl-clusters.mjs node --check scripts/run-worker.mjs +node --check scripts/post-flight.mjs npm run validate git diff --check ``` diff --git a/.github/workflows/cluster-worker.yml b/.github/workflows/cluster-worker.yml index 8e7600d..25fa898 100644 --- a/.github/workflows/cluster-worker.yml +++ b/.github/workflows/cluster-worker.yml @@ -181,6 +181,7 @@ jobs: CLOWNFISH_RESOLVE_REVIEW_THREADS: ${{ vars.CLOWNFISH_RESOLVE_REVIEW_THREADS || '1' }} CLOWNFISH_MODEL: ${{ inputs.model || vars.CLOWNFISH_MODEL || 'gpt-5.5' }} CLOWNFISH_TARGET_VALIDATION_MODE: ${{ vars.CLOWNFISH_TARGET_VALIDATION_MODE || 'changed-only' }} + CLOWNFISH_POST_FLIGHT_IGNORE_CHECKS: ${{ vars.CLOWNFISH_POST_FLIGHT_IGNORE_CHECKS || 'auto-response,Labeler,Stale' }} CLOWNFISH_GIT_USER_NAME: ${{ vars.CLOWNFISH_GIT_USER_NAME || 'projectclownfish' }} CLOWNFISH_GIT_USER_EMAIL: ${{ vars.CLOWNFISH_GIT_USER_EMAIL || 'projectclownfish@users.noreply.github.com' }} CODEX_CLI_VERSION: ${{ vars.CLOWNFISH_CODEX_CLI_VERSION || '0.125.0' }} @@ -246,6 +247,18 @@ jobs: GH_TOKEN: ${{ secrets.CLOWNFISH_GH_TOKEN }} run: npm run apply-result -- "${{ inputs.job }}" --latest + - name: Post-flight finalize fix PRs + if: ${{ env.CLOWNFISH_ALLOW_EXECUTE == '1' && env.CLOWNFISH_ALLOW_FIX_PR == '1' }} + env: + GH_TOKEN: ${{ secrets.CLOWNFISH_GH_TOKEN }} + run: npm run post-flight -- "${{ inputs.job }}" --latest + + - name: Apply post-flight closeouts + if: ${{ env.CLOWNFISH_ALLOW_EXECUTE == '1' }} + env: + GH_TOKEN: ${{ secrets.CLOWNFISH_GH_TOKEN }} + run: npm run apply-result -- "${{ inputs.job }}" --latest + - name: Upload final worker artifacts if: always() uses: actions/upload-artifact@v7 diff --git a/package.json b/package.json index 6a6d91b..14a562f 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "worker": "node scripts/run-worker.mjs", "apply-result": "node scripts/apply-result.mjs", "execute-fix": "node scripts/execute-fix-artifact.mjs", + "post-flight": "node scripts/post-flight.mjs", "dispatch": "node scripts/dispatch-jobs.mjs", "requeue": "node scripts/requeue-job.mjs", "self-heal": "node scripts/self-heal-failed-runs.mjs", diff --git a/scripts/post-flight.mjs b/scripts/post-flight.mjs new file mode 100644 index 0000000..43f55e1 --- /dev/null +++ b/scripts/post-flight.mjs @@ -0,0 +1,386 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { assertAllowedOwner, hasSecuritySignalText, parseArgs, parseJob, repoRoot, validateJob } from "./lib.mjs"; + +const PASSING_CHECK_CONCLUSIONS = new Set(["SUCCESS", "SKIPPED", "NEUTRAL"]); +const CLEAN_MERGE_STATES = new Set(["CLEAN", "HAS_HOOKS"]); +const FIX_PR_ACTIONS = new Set(["open_fix_pr", "repair_contributor_branch"]); +const FIX_PR_READY_STATUSES = new Set(["opened", "pushed"]); +const DEFAULT_IGNORED_CHECKS = ["auto-response", "Labeler", "Stale"]; + +const args = parseArgs(process.argv.slice(2)); +const jobPath = args._[0]; +const resultPathArg = args._[1]; +const latest = Boolean(args.latest); +const dryRun = Boolean(args["dry-run"] || process.env.CLOWNFISH_POST_FLIGHT_DRY_RUN === "1"); + +if (!jobPath) { + console.error("usage: node scripts/post-flight.mjs [result.json] [--latest] [--dry-run]"); + process.exit(2); +} +if (!resultPathArg && !latest) { + console.error("result path is required unless --latest is set"); + process.exit(2); +} + +const job = parseJob(jobPath); +const errors = validateJob(job); +if (errors.length > 0) { + console.error(errors.join("\n")); + process.exit(1); +} + +assertAllowedOwner(job.frontmatter.repo, process.env.CLOWNFISH_ALLOWED_OWNER); + +if (!["execute", "autonomous"].includes(job.frontmatter.mode)) { + throw new Error("refusing post-flight: job frontmatter mode is not execute or autonomous"); +} +if (process.env.CLOWNFISH_ALLOW_EXECUTE !== "1") { + throw new Error("refusing post-flight: CLOWNFISH_ALLOW_EXECUTE must be 1"); +} + +const resultPath = resultPathArg ? path.resolve(resultPathArg) : findLatestResultPath(); +const result = JSON.parse(fs.readFileSync(resultPath, "utf8")); +if (result.repo !== job.frontmatter.repo) { + throw new Error(`result repo ${result.repo} does not match job repo ${job.frontmatter.repo}`); +} +if (result.cluster_id !== job.frontmatter.cluster_id) { + throw new Error(`result cluster ${result.cluster_id} does not match job cluster ${job.frontmatter.cluster_id}`); +} +if (!["execute", "autonomous"].includes(result.mode)) { + throw new Error(`refusing post-flight: result mode is ${result.mode}`); +} + +const fixReport = readSiblingJson(resultPath, "fix-execution-report.json"); +const report = { + repo: result.repo, + cluster_id: result.cluster_id, + dry_run: dryRun, + result_path: path.relative(repoRoot(), resultPath), + post_flight_at: new Date().toISOString(), + actions: [], +}; + +if (!fixReport) { + report.actions.push({ action: "post_flight", status: "skipped", reason: "no fix-execution-report.json" }); + writeReport(report, resultPath); + process.exit(0); +} + +for (const action of fixReport.actions ?? []) { + if (!FIX_PR_ACTIONS.has(String(action.action ?? ""))) continue; + report.actions.push(finalizeFixPr(action)); +} + +if (report.actions.length === 0) { + report.actions.push({ action: "post_flight", status: "skipped", reason: "no ProjectClownfish fix PR actions to finalize" }); +} + +writeReport(report, resultPath); + +function finalizeFixPr(action) { + const base = { + action: "finalize_fix_pr", + source_action: action.action, + source_status: action.status, + target: action.pr_url ?? action.target ?? null, + }; + + if (!FIX_PR_READY_STATUSES.has(String(action.status ?? ""))) { + return { ...base, status: "skipped", reason: `fix PR action status is ${action.status ?? "missing"}` }; + } + + const parsed = parsePullRequestUrl(action.pr_url ?? action.target); + if (!parsed || parsed.repo !== result.repo) { + return { ...base, status: "blocked", reason: "fix PR URL is missing or outside target repo" }; + } + + const policyBlock = validateMergePolicy(); + if (policyBlock) return { ...base, status: "blocked", pr: `#${parsed.number}`, reason: policyBlock }; + + const pull = fetchPullRequest(result.repo, parsed.number); + const view = fetchPullRequestView(result.repo, parsed.number); + const prBase = { ...base, pr: `#${parsed.number}`, title: view.title ?? pull.title ?? null }; + const mergedAt = pull.merged_at ?? view.mergedAt ?? null; + if (mergedAt) { + return { + ...prBase, + status: "executed", + reason: "already merged", + merged_at: mergedAt, + merge_commit_sha: pull.merge_commit_sha ?? view.mergeCommit?.oid ?? null, + }; + } + + const mergeBlock = validateMergeableFixPr({ pull, view, preflight: action.merge_preflight }); + if (mergeBlock) { + return { + ...prBase, + status: "blocked", + reason: mergeBlock, + mergeable: view.mergeable ?? null, + merge_state_status: view.mergeStateStatus ?? null, + review_decision: view.reviewDecision ?? null, + }; + } + + if (dryRun) { + return { + ...prBase, + status: "planned", + reason: "dry run", + merge_method: "squash", + }; + } + + ghWithRetry(["pr", "merge", String(parsed.number), "--repo", result.repo, "--squash"]); + const merged = fetchPullRequest(result.repo, parsed.number); + return { + ...prBase, + status: "executed", + reason: "merged by ProjectClownfish post-flight", + merged_at: merged.merged_at ?? null, + merge_commit_sha: merged.merge_commit_sha ?? null, + merge_method: "squash", + }; +} + +function validateMergePolicy() { + if (!job.frontmatter.allowed_actions.includes("merge")) return "job does not allow merge"; + if ((job.frontmatter.blocked_actions ?? []).includes("merge")) return "merge is blocked by job frontmatter"; + if (job.frontmatter.allow_merge !== true) return "merge requires allow_merge: true"; + return ""; +} + +function validateMergeableFixPr({ pull, view, preflight }) { + if (pull.state !== "open") return `pull request is ${pull.state}`; + if (pull.draft || view.isDraft) return "pull request is draft"; + if (String(view.baseRefName ?? pull.base?.ref ?? "") !== "main") return "pull request base is not main"; + if (hasSecuritySignalText(pull.title, pull.body, pull.labels ?? [])) { + return "security-sensitive PR requires central security triage"; + } + if (view.mergeable !== "MERGEABLE") return `mergeable state is ${view.mergeable || "unknown"}`; + if (!CLEAN_MERGE_STATES.has(String(view.mergeStateStatus ?? ""))) { + return `merge state status is ${view.mergeStateStatus || "unknown"}`; + } + if (["CHANGES_REQUESTED", "REVIEW_REQUIRED"].includes(String(view.reviewDecision ?? ""))) { + return `review decision is ${view.reviewDecision}`; + } + + const preflightBlock = validateMergePreflight(preflight); + if (preflightBlock) return preflightBlock; + + const threadBlock = validateResolvedReviewThreads(result.repo, pull.number); + if (threadBlock) return threadBlock; + + const checkBlock = validateStatusChecks(view.statusCheckRollup ?? []); + if (checkBlock) return checkBlock; + + return ""; +} + +function validateMergePreflight(preflight) { + if (!preflight || typeof preflight !== "object") return "merge_preflight is missing"; + if (preflight.security_status !== "cleared") return "security preflight is not cleared"; + if (!Array.isArray(preflight.security_evidence) || preflight.security_evidence.length === 0) { + return "security preflight evidence is missing"; + } + if (preflight.comments_status !== "resolved") return "review comments are not resolved"; + if (!Array.isArray(preflight.comments_evidence) || preflight.comments_evidence.length === 0) { + return "review comments resolution evidence is missing"; + } + if (preflight.bot_comments_status !== "resolved") return "review-bot comments are not resolved"; + if (!Array.isArray(preflight.bot_comments_evidence) || preflight.bot_comments_evidence.length === 0) { + return "review-bot comment resolution evidence is missing"; + } + if (!Array.isArray(preflight.validation_commands) || preflight.validation_commands.length === 0) { + return "merge validation commands are missing"; + } + const codexReview = preflight.codex_review; + if (!codexReview || codexReview.command !== "/review") return "Codex /review preflight is missing"; + if (!["passed", "clean"].includes(codexReview.status)) return `Codex /review status is ${codexReview.status || "missing"}`; + if (codexReview.findings_addressed !== true) return "Codex /review findings are not addressed"; + if (!Array.isArray(codexReview.evidence) || codexReview.evidence.length === 0) { + return "Codex /review evidence is missing"; + } + return ""; +} + +function validateStatusChecks(checks) { + if (!Array.isArray(checks) || checks.length === 0) return "no PR checks found"; + const ignored = ignoredCheckNames(); + const blockers = []; + for (const check of checks) { + const name = String(check.name ?? check.context ?? "unknown check"); + if (ignored.has(name)) continue; + const status = String(check.status ?? check.state ?? "").toUpperCase(); + const conclusion = String(check.conclusion ?? "").toUpperCase(); + if (status && !["COMPLETED", "SUCCESS"].includes(status)) { + blockers.push(`${name}: ${status}`); + continue; + } + if (conclusion && !PASSING_CHECK_CONCLUSIONS.has(conclusion)) { + blockers.push(`${name}: ${conclusion}`); + } + } + if (blockers.length > 0) return `checks are not clean: ${blockers.slice(0, 5).join(", ")}`; + return ""; +} + +function ignoredCheckNames() { + const configured = String(process.env.CLOWNFISH_POST_FLIGHT_IGNORE_CHECKS ?? DEFAULT_IGNORED_CHECKS.join(",")); + return new Set( + configured + .split(",") + .map((item) => item.trim()) + .filter(Boolean), + ); +} + +function validateResolvedReviewThreads(repo, number) { + const [owner, name] = repo.split("/"); + const query = ` + query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewThreads(first: 100) { + pageInfo { hasNextPage } + nodes { + isResolved + path + line + comments(first: 1) { + nodes { + url + author { login } + body + } + } + } + } + } + } + } + `; + const data = ghJson([ + "api", + "graphql", + "-f", + `owner=${owner}`, + "-f", + `name=${name}`, + "-F", + `number=${number}`, + "-f", + `query=${query}`, + ]); + const threads = data?.data?.repository?.pullRequest?.reviewThreads; + if (threads?.pageInfo?.hasNextPage) return "too many review threads to prove resolved"; + const unresolved = (threads?.nodes ?? []).filter((thread) => thread && !thread.isResolved); + if (unresolved.length === 0) return ""; + const examples = unresolved + .slice(0, 3) + .map((thread) => thread.comments?.nodes?.[0]?.url ?? `${thread.path}:${thread.line ?? "?"}`); + return `unresolved review threads remain: ${examples.join(", ")}`; +} + +function fetchPullRequest(repo, number) { + return ghJson(["api", `repos/${repo}/pulls/${number}`]); +} + +function fetchPullRequestView(repo, number) { + return ghJson([ + "pr", + "view", + String(number), + "--repo", + repo, + "--json", + [ + "baseRefName", + "isDraft", + "mergeable", + "mergeCommit", + "mergeStateStatus", + "mergedAt", + "reviewDecision", + "state", + "statusCheckRollup", + "title", + "updatedAt", + "url", + ].join(","), + ]); +} + +function findLatestResultPath() { + const runsRoot = path.join(repoRoot(), ".projectclownfish", "runs"); + if (!fs.existsSync(runsRoot)) throw new Error("no run directory exists"); + const candidates = []; + for (const runName of fs.readdirSync(runsRoot)) { + const candidate = path.join(runsRoot, runName, "result.json"); + if (fs.existsSync(candidate)) candidates.push({ path: candidate, mtimeMs: fs.statSync(candidate).mtimeMs }); + } + candidates.sort((left, right) => right.mtimeMs - left.mtimeMs); + if (!candidates[0]) throw new Error("no result.json files found"); + return candidates[0].path; +} + +function readSiblingJson(resultPath, name) { + const file = path.join(path.dirname(resultPath), name); + if (!fs.existsSync(file)) return null; + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +function writeReport(report, resultPath) { + const reportPath = path.join(path.dirname(resultPath), "post-flight-report.json"); + fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); + console.log(JSON.stringify(report, null, 2)); +} + +function parsePullRequestUrl(value) { + const match = String(value ?? "").match(/^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)(?:[/?#].*)?$/i); + if (!match) return null; + return { repo: match[1], number: Number(match[2]) }; +} + +function ghJson(ghArgs) { + const text = ghWithRetry(ghArgs); + return JSON.parse(text || "null"); +} + +function ghWithRetry(ghArgs, attempts = 6) { + let lastError; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + return execFileSync("gh", ghArgs, { + cwd: repoRoot(), + encoding: "utf8", + env: process.env, + maxBuffer: 64 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + } catch (error) { + lastError = error; + if (!shouldRetryGh(error) || attempt === attempts - 1) throw error; + sleepMs(Math.min(120_000, 10_000 * 2 ** attempt)); + } + } + throw lastError; +} + +function shouldRetryGh(error) { + const stderr = String(error?.stderr ?? ""); + const message = `${error instanceof Error ? error.message : String(error)}\n${stderr}`; + return ( + message.includes("was submitted too quickly") || + message.includes("secondary rate") || + message.includes("API rate limit exceeded") + ); +} + +function sleepMs(milliseconds) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds); +} diff --git a/scripts/publish-result.mjs b/scripts/publish-result.mjs index c208300..97c741d 100644 --- a/scripts/publish-result.mjs +++ b/scripts/publish-result.mjs @@ -16,6 +16,7 @@ const CLOSE_APPLICATOR_ACTIONS = new Set([ ]); const MERGE_APPLICATOR_ACTIONS = new Set(["merge_candidate", "merge_canonical"]); const APPLICATOR_ACTIONS = new Set([...CLOSE_APPLICATOR_ACTIONS, ...MERGE_APPLICATOR_ACTIONS]); +const POST_FLIGHT_MERGE_ACTIONS = new Set(["finalize_fix_pr"]); const PR_INFO_CACHE = new Map(); const ISSUE_INFO_CACHE = new Map(); @@ -40,6 +41,7 @@ function publishResult(resultPath) { const runDir = path.dirname(resultPath); const result = readJson(resultPath); const applyReport = readSiblingJson(runDir, "apply-report.json") ?? { actions: [] }; + const postFlightReport = readSiblingJson(runDir, "post-flight-report.json") ?? { actions: [] }; const clusterPlan = readSiblingJson(runDir, "cluster-plan.json"); const runId = String(args["run-id"] ?? inferRunId(resultPath) ?? ""); const metadata = runId ? metadataByRunId.get(runId) : undefined; @@ -52,7 +54,10 @@ function publishResult(resultPath) { const repo = String(result.repo ?? "unknown/unknown"); const owner = repo.split("/")[0] || "unknown"; const clusterId = String(result.cluster_id ?? path.basename(runDir)); - const applyActions = (applyReport.actions ?? []).filter(isApplicatorAction); + const applyActions = [ + ...(applyReport.actions ?? []), + ...(postFlightReport.actions ?? []).filter(isPostFlightMergeAction).map(postFlightMergeToApplyAction), + ].filter(isApplicatorAction); const report = { repo, cluster_id: clusterId, @@ -451,6 +456,25 @@ function isApplicatorAction(action) { return APPLICATOR_ACTIONS.has(String(action?.action ?? "")); } +function isPostFlightMergeAction(action) { + return POST_FLIGHT_MERGE_ACTIONS.has(String(action?.action ?? "")) && action?.pr; +} + +function postFlightMergeToApplyAction(action) { + return { + target: action.pr, + action: "merge_canonical", + status: action.status, + classification: "fix_pr", + title: action.title ?? null, + reason: action.reason ?? null, + merged_at: action.merged_at ?? null, + merge_commit_sha: action.merge_commit_sha ?? null, + live_state: action.status === "executed" ? "merged" : null, + live_updated_at: null, + }; +} + function sortNewestRecordFirst(left, right) { return String(right.published_at ?? "").localeCompare(String(left.published_at ?? "")); }