fix: scope cluster escalation and requeue jobs

This commit is contained in:
Vincent Koc 2026-04-25 21:02:48 -07:00
parent 501197719d
commit 43d96e8f84
No known key found for this signature in database
16 changed files with 502 additions and 79 deletions

View File

@ -16,7 +16,7 @@ Use this skill for ProjectClownfish operations in this repo. It is not just a on
- Codex workers never mutate GitHub directly. They emit JSON; `scripts/execute-fix-artifact.mjs` owns guarded fix PR execution and `scripts/apply-result.mjs` owns guarded close/merge replay.
- Only the applicator may record `executed`. Worker output containing `executed` is a bug.
- Closed historical refs are evidence only. They must not receive `close_*` actions.
- Security-sensitive clusters do not belong in ProjectClownfish. Skip vulnerability, advisory, CVE/GHSA, leaked secret, credential/token/API-key, plaintext secret storage, SSRF/XSS/CSRF/RCE, security-class injection, exploitability, or sensitive-data exposure clusters and route them to central OpenClaw security handling.
- Security-sensitive refs do not belong in ProjectClownfish mutation. Quarantine vulnerability, advisory, CVE/GHSA, leaked secret, credential/token/API-key, plaintext secret storage, SSRF/XSS/CSRF/RCE, security-class injection, exploitability, or sensitive-data exposure refs with `route_security`, and keep unrelated non-security work moving.
## Recovery Check
@ -92,9 +92,10 @@ Current autonomy posture:
- Hydrate comments and PR review comments by default before model execution.
- Hydrate cluster refs and bounded first-hop linked refs so closed representative drift can often be resolved without human review.
- Treat failing checks as a merge/fixed-by-candidate blocker, not a reason to stop classifying the whole cluster.
- Treat security-sensitive refs as scoped quarantine. Emit/expect `route_security` for that ref only; keep processing unrelated non-security duplicates, bugs, provider gaps, and fix artifacts.
- Treat missing `merge_preflight` as a hard merge blocker. Merge preflight must prove security clearance, resolved human comments, resolved review-bot comments, passed Codex `/review`, addressed findings, and validation commands.
- Let `execute-fix-artifact` run the agentic merge-prep loop for fix PRs: edit, validate, Codex `/review`, address findings, revalidate, then resolve review threads when `CLOWNFISH_RESOLVE_REVIEW_THREADS=1`.
- Prefer `keep_related`, `keep_independent`, `keep_closed`, `fix_needed`, and subcluster notes over blanket `needs_human`.
- 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.
After tuning, run:
@ -152,7 +153,7 @@ node scripts/import-ghcrawl-clusters.mjs --from-ghcrawl --limit 40 \
--allow-post-merge-close
```
The importer skips existing ghcrawl IDs and security-sensitive clusters by default. Use explicit IDs only when you have inspected them first; do not pass `--include-security`.
The importer skips existing ghcrawl IDs and fully security-sensitive clusters by default. Mixed clusters are allowed so the worker can route security refs and continue ordinary bug/dedupe work.
Validate before committing:
@ -195,6 +196,17 @@ gh variable set CLOWNFISH_ALLOW_FIX_PR --repo openclaw/projectclownfish --body 0
Important: after dispatch, already-started runs keep the write gate they captured. If a new bug is found, cancel those runs.
Single-job requeue after calibration:
```bash
npm run requeue -- 24947178021
npm run requeue -- 24947178021 --execute --open-execute-window --runner ubuntu-latest
```
Use a run id when you want to replay the same source job from an artifact, or a
job path when you already know the file. The script opens both mutation gates for
live execute/autonomous requeues and closes them after the queued run starts.
For plan-only scaling, keep write gate off and dispatch with `--mode plan` or `--dry-run` where appropriate.
## Low-Signal PR Sweeps

View File

@ -22,12 +22,12 @@ anything with active maintainer signal.
Everything else stays open or is escalated for maintainer review.
Security-sensitive clusters are deliberately out of scope. Anything that smells
like a vulnerability, advisory, leaked secret, credential/token exposure,
plaintext secret storage, SSRF/XSS/CSRF/RCE, security-class injection, or sensitive-data
exposure is skipped at import time and routed to central OpenClaw security
handling. ProjectClownfish is a backlog cleanup tool, not a security triage
queue.
Security-sensitive reports are deliberately out of scope. ProjectClownfish
routes those refs to central OpenClaw security handling and keeps processing
unrelated ordinary bugs, provider gaps, and duplicate cleanup in the same
cluster. It follows OpenClaw `SECURITY.md`: trusted-operator exec behavior,
provider gaps, feature gaps, and hardening-only parity drift are not treated as
vulnerabilities unless there is a real trust-boundary bypass.
## Status
@ -124,7 +124,8 @@ Full worker prompts, Codex transcripts, and raw artifacts stay in GitHub Actions
- `plan`: produces recommendations only.
- `execute`: can apply reviewed safe close and explicit clean merge actions from structured JSON.
- `autonomous`: adds live cluster preflight and fix-artifact generation. It may recommend and drive a canonical fix path; direct mutation still goes through the fix executor and applicator gates.
- `needs_human`: any unclear canonical choice, stale cluster state, failing checks, conflict, broad fix, or independent report should land here.
- `route_security`: quarantines true security-sensitive refs without poisoning unrelated cluster work.
- `needs_human`: only product-direction, trust-boundary, canonical-choice, merge-path, or contributor-credit decisions that remain unclear after the hydrated artifact and single-item review/check/decide pass.
- Automated reviewer feedback must be cleared during autonomous PR work. Greptile, Codex, Asile, CodeRabbit, Copilot, and similar bot comments must be addressed, proven non-actionable, or escalated before any merge or post-merge closeout recommendation.
- Merge preflight: no PR can merge until security issues are cleared, comments are resolved, Codex `/review` has passed, findings are addressed, and focused validation is clean.
- Repair ladder: make the useful contributor PR mergeable when its branch is maintainer-editable; otherwise replace it with a narrow credited fix PR plan, close/supersede the uneditable PR only after that replacement path is explicit, and carry contributor credit into the PR body and changelog plan.
@ -150,12 +151,21 @@ npm run build-fix-artifact -- jobs/openclaw/autonomous-example.md --offline
npm run import-low-signal -- --limit 20 --batch-size 5 --mode autonomous --sort stale
# Stage the next largest active ghcrawl clusters, skipping already-imported and
# security-sensitive clusters by default.
# fully security-sensitive clusters by default. Mixed clusters can route security
# refs while continuing ordinary bug/dedupe work.
npm run import-ghcrawl -- --from-ghcrawl --limit 40 --mode autonomous --suffix autonomous-smoke --allow-instant-close --allow-merge --allow-fix-pr --allow-post-merge-close
# Find failed cluster jobs that have not been superseded by a later success.
npm run self-heal
# Resolve a job from a run id or job path and show the requeue plan.
npm run requeue -- 24947178021
# Requeue one reviewed job/run into the live queue. This briefly opens both
# write gates when the job is execute/autonomous, waits for the run to start,
# then closes the gates.
npm run requeue -- 24947178021 --execute --open-execute-window --runner ubuntu-latest
# Execute a reviewed fix artifact locally. Requires both execution gates and a write token.
CLOWNFISH_ALLOW_EXECUTE=1 CLOWNFISH_ALLOW_FIX_PR=1 npm run execute-fix -- jobs/openclaw/cluster-example.md --latest --dry-run

View File

@ -22,13 +22,29 @@ Evidence order:
Do not close based on title similarity alone.
Security-sensitive reports and PRs are not dedupe-cleanup work. If any item looks like a vulnerability, advisory, CVE/GHSA, leaked secret, credential, token, API key, plaintext secret storage, exploitability, security-class injection, SSRF/XSS/CSRF/RCE, or sensitive data exposure, route it to central OpenClaw security handling and do not recommend ProjectClownfish mutation.
Security-sensitive reports and PRs are not dedupe-cleanup work. If an item looks
like a vulnerability, advisory, CVE/GHSA, leaked secret, credential, token, API
key, plaintext secret storage, exploitability, security-class injection,
SSRF/XSS/CSRF/RCE, or sensitive data exposure, emit `route_security` for that
item and keep processing unrelated non-security items. Do not use one
security-linked ref as a blanket reason to avoid duplicate closeout, fix
artifact creation, or single-item review for ordinary bugs.
OpenClaw `SECURITY.md` treats trusted-operator exec behavior, provider gaps,
feature gaps, and hardening-only parity drift as non-security unless the report
demonstrates a boundary bypass. Small bug fixes in exec-adjacent code are
allowed when they do not redefine auth, approval, sandbox, or trust boundaries.
Do not use `needs_human` as a synonym for "not closable." If an item is clearly
related, independent, already closed, or a plausible follow-up fix, emit
`keep_related`, `keep_independent`, `keep_closed`, or `fix_needed` with evidence.
Use `needs_human` only for the unresolved decision point.
If an item is not a true duplicate, decide the single item instead of escalating
the whole cluster: keep it related/independent, build a narrow fix artifact if it
is a real bug with no viable PR, or use `needs_human` only for product-intent
questions that remain after checking the hydrated artifact.
Failing checks block merge and fixed-by-candidate closeout. They do not block
non-mutating classification, subcluster splitting, or a fix artifact.

View File

@ -1,14 +1,33 @@
# Security Boundary
ProjectClownfish does not triage security-sensitive clusters.
ProjectClownfish does not triage true security reports or change OpenClaw's
documented security boundary.
Security-sensitive means any issue, PR, title, body, label, comment, or changed-file context that appears to involve vulnerabilities, advisories, CVEs, GHSAs, exploitability, SSRF/XSS/CSRF/RCE, security-class injection, leaked secrets, credentials, tokens, API keys, private keys, plaintext credential storage, or exposure of sensitive data.
Use OpenClaw `SECURITY.md` as the posture: a security report needs a real
boundary-bypass claim tied to auth, approvals, allowlists, sandboxing, shipped
dependency impact, OpenClaw-owned secrets, or another documented trust boundary.
Trusted-operator exec behavior, provider capability gaps, missing model support,
ordinary bugs, hardening-only parity gaps, and small fixes inside exec-adjacent
code are not automatically security work.
Security-sensitive means an item appears to involve vulnerabilities,
advisories, CVEs, GHSAs, exploitability, SSRF/XSS/CSRF/RCE,
security-class injection, leaked secrets, credentials, tokens, API keys, private
keys, plaintext credential storage, or exposure of sensitive data.
When security-sensitive evidence appears:
- do not close, merge, label, comment, open a fix PR, or recommend broad cleanup;
- quarantine only the affected issue/PR with a non-mutating `route_security`
action;
- keep classifying unrelated non-security items in the same cluster;
- do not close, merge, label, comment on, or open a fix PR for the
security-sensitive item;
- do not let a security-linked ref poison a narrow bug/provider/fix path unless
the fix itself changes the security boundary or depends on that security ref;
- do not summarize exploit details beyond the minimum needed to say it is out of scope;
- return `needs_human` with the exact boundary reason;
- route the item to central OpenClaw security handling instead of ProjectClownfish.
This boundary is intentionally conservative. False positives are cheaper than accidentally routing security work through backlog-cleanup automation.
Use `needs_human` only when the unresolved decision is genuinely a maintainer
judgment call after quarantine. False positives are still cheaper than
accidentally routing security work through backlog-cleanup automation, but false
positives should be scoped to the item, not the whole cluster.

View File

@ -15,6 +15,7 @@
"apply-result": "node scripts/apply-result.mjs",
"execute-fix": "node scripts/execute-fix-artifact.mjs",
"dispatch": "node scripts/dispatch-jobs.mjs",
"requeue": "node scripts/requeue-job.mjs",
"self-heal": "node scripts/self-heal-failed-runs.mjs",
"publish-result": "node scripts/publish-result.mjs",
"review-results": "node scripts/review-results.mjs"

View File

@ -6,7 +6,7 @@ Scope:
- Start only from refs in the job file and refs linked from those item bodies, comments, review threads, closing refs, commits, or PR descriptions.
- Do not run broad GitHub search unless the job explicitly says so.
- If any hydrated item is security-sensitive, stop ProjectClownfish handling for that item or cluster and route to central OpenClaw security triage. Do not emit mutating actions.
- If any hydrated item is security-sensitive, quarantine that item with `route_security` and route it to central OpenClaw security triage. Do not mutate that item. Continue classifying unrelated non-security items, duplicate pairs, provider gaps, and ordinary bugs.
- Use the provided cluster preflight artifact and fix artifact as your starting inventory. It should include hydrated issue comments, PR review summaries, inline PR review comments, check state, merge state, touched files, and linked refs.
- Treat closed context refs as evidence, not targets. Do not emit close actions for them.
- If the cluster changed materially since preflight, block only the affected mutation. Keep classifying other items when the artifact is still current enough for non-mutating decisions.
@ -28,6 +28,7 @@ Before drive mode:
- if `maintainer_can_modify` is false, the branch is unsafe, or the PR contains broad/unrelated churn, do not merge it. Emit a replacement `build_fix_artifact` / `open_fix_pr` plan that preserves the contributor's credit in `credit_notes`, PR body, and changelog plan;
- when replacing a useful contributor PR, emit a `close_superseded` comment that says ProjectClownfish cannot safely update that branch, will carry the narrow fix forward separately, and will credit the contributor by username and PR URL.
8. Do not emit closure actions until the canonical path is explicit. If the cluster is over-broad, split it into subfamilies in the action matrix and use `keep_related`/`keep_independent` for clear non-targets instead of making the whole result `needs_human`.
9. If an item is not a true duplicate, run a single-item review/check/decide path: keep it related or independent when that is clear, emit a narrow fix artifact when it is a real bug or provider gap with no viable PR, and use `needs_human` only for product-direction or trust-boundary decisions that remain after checking the artifact.
Low-signal PR cleanup:
@ -46,10 +47,13 @@ Instant close actions:
- Include `target_updated_at`, `target_kind`, `canonical` or `candidate_fix`, contributor-credit preserving `comment`, evidence, and a stable `idempotency_key`.
- In action fields, `canonical`, `duplicate_of`, and `candidate_fix` must be explicit refs like `#61741`. Do not put a year, timestamp fragment, unrelated number, or only a prose URL in those fields.
- Leave independent or related reports open as `keep_independent` or `keep_related`. Use `needs_human` only when choosing among viable canonical paths, merge paths, or contributor-credit tradeoffs requires maintainer judgment.
- Do not suppress duplicate closeout only because another linked ref is security-sensitive. `route_security` the security ref and close only unrelated non-security duplicates that satisfy all closure gates.
Fix artifact actions:
- If no viable canonical PR exists and the bug still appears real from the artifact, emit `fix_needed` plus `build_fix_artifact` even when the current job cannot open the fix PR. Do not escalate solely because `allow_fix_pr` is false.
- Provider support gaps, missing model capability routing, and ordinary feature gaps reported as bugs should become a fix artifact when the artifact shows expected behavior and the patch can stay narrow.
- `validation_commands` must be executable commands using `pnpm`, `npm`, `node`, or `git`. Put manual browser checks and prose test plans in `pr_body`, `credit_notes`, or action evidence, not in `validation_commands`.
- The `build_fix_artifact` action must include affected surfaces, likely files, linked issues/PRs, validation commands, changelog requirement, credit notes, and a PR title/body plan.
- If replacing a contributor PR, `fix_artifact.credit_notes` must name the original author and PR URL, `pr_body` must explain the borrowed/credited idea, and `changelog_required` should be true when the resulting fix is user-facing.
- The fix plan must be narrow: list only the files expected to change, focused tests, review-bot findings to address, and the exact branch/PR that could not be updated if applicable.

View File

@ -4,13 +4,13 @@ Execute mode still returns structured JSON first. Do not mutate GitHub directly.
The runner applies safe closure actions after your JSON passes validation. Your job is to classify the cluster and emit auditable actions that the deterministic GitHub applicator can replay.
Security-sensitive clusters are out of scope. If a title, body, label, review, comment, or file context suggests vulnerability, advisory, CVE/GHSA, leaked secret, credential, token, API key, plaintext secret storage, exploitability, security-class injection, SSRF/XSS/CSRF/RCE, or sensitive data exposure, return `needs_human` and do not emit close, merge, label, comment, or fix actions.
Security-sensitive items are out of scope. If a title, body, label, review, comment, or file context suggests vulnerability, advisory, CVE/GHSA, leaked secret, credential, token, API key, plaintext secret storage, exploitability, security-class injection, SSRF/XSS/CSRF/RCE, or sensitive data exposure, emit `route_security` for that exact item and do not mutate it. Continue classifying unrelated non-security items.
For each target action, include:
- `target`: issue/PR ref like `#123`
- `action`: one of `keep_canonical`, `keep_related`, `keep_independent`, `keep_closed`, `merge_candidate`, `merge_canonical`, `fix_needed`, `build_fix_artifact`, `open_fix_pr`, `needs_human`, `close_duplicate`, `close_superseded`, `close_fixed_by_candidate`, or `close_low_signal`
- `classification`: one of `canonical`, `duplicate`, `related`, `superseded`, `independent`, `fixed_by_candidate`, `low_signal`, or `needs_human`
- `action`: one of `keep_canonical`, `keep_related`, `keep_independent`, `keep_closed`, `merge_candidate`, `merge_canonical`, `fix_needed`, `build_fix_artifact`, `open_fix_pr`, `route_security`, `needs_human`, `close_duplicate`, `close_superseded`, `close_fixed_by_candidate`, or `close_low_signal`
- `classification`: one of `canonical`, `duplicate`, `related`, `superseded`, `independent`, `fixed_by_candidate`, `low_signal`, `security_sensitive`, or `needs_human`
- `target_kind`: `issue` or `pull_request`
- `target_updated_at`: the live GitHub `updatedAt`/`updated_at` value you fetched for the target
- `canonical`, `duplicate_of`, or `candidate_fix` when the close depends on another issue/PR; use an issue/PR ref like `#123`, never a date, year, bare unrelated number, or prose-only link

View File

@ -12,11 +12,12 @@ reserve `needs_human` for the specific unresolved decision.
Evidence must come from GitHub issue/PR data, GitHub PR checks/diffs, or the job file. Do not cite external websites or mirrors.
Security-sensitive clusters are read-only and out of scope for ProjectClownfish.
If any item appears related to vulnerabilities, advisories, CVEs/GHSAs, leaked
Security-sensitive items are read-only and out of scope for ProjectClownfish.
If an item appears related to vulnerabilities, advisories, CVEs/GHSAs, leaked
secrets, credentials, tokens, API keys, plaintext secret storage, exploitability,
security-class injection, SSRF/XSS/CSRF/RCE, or sensitive data exposure, route it to central
OpenClaw security triage with `needs_human` and no mutating recommendation.
security-class injection, SSRF/XSS/CSRF/RCE, or sensitive data exposure, emit
`route_security` for that item and continue classifying unrelated non-security
items.
For each item, decide one action:
@ -30,6 +31,7 @@ For each item, decide one action:
- keep closed
- merge candidate
- fix needed
- route security
- needs human
Use closure actions only for targets that are open in live GitHub state. If a listed candidate is already closed, do not emit `close_duplicate`, `close_superseded`, `close_fixed_by_candidate`, or `close_low_signal`; use `keep_closed` with `status: "skipped"` and evidence that it is already closed.

View File

@ -23,6 +23,7 @@ Before action:
- classify the hydrated canonical and open candidate items; closed context refs are historical evidence only unless they are explicitly hydrated as primary items;
- do not assume direct GitHub CLI access from the worker. If the artifact contains the needed data, use it instead of escalating because older runs lacked comment bodies;
- if the artifact is missing a detail required for a mutating action, prefer a non-mutating `keep_related`, `keep_independent`, `fix_needed`, or `build_fix_artifact` action when the classification is still clear;
- if a security-sensitive linked ref appears, quarantine that exact item with `route_security` and continue classifying unrelated non-security items;
- use GitHub and the local job/repo artifacts as evidence; do not use web search, third-party mirrors, blogs, or copied issue pages as evidence.
- use `needs_human` only for the specific unresolved decision, not as the default result for a whole cluster.

View File

@ -70,6 +70,7 @@
"build_fix_artifact",
"open_fix_pr",
"post_merge_close",
"route_security",
"needs_human",
"close",
"close_duplicate",
@ -96,6 +97,7 @@
"independent",
"fixed_by_candidate",
"low_signal",
"security_sensitive",
"needs_human",
null
]

View File

@ -17,6 +17,7 @@ const latest = Boolean(args.latest);
const dryRun = Boolean(args["dry-run"] || process.env.CLOWNFISH_FIX_DRY_RUN === "1");
const model = String(args.model ?? process.env.CLOWNFISH_MODEL ?? "gpt-5.4");
const codexTimeoutMs = Number(process.env.CLOWNFISH_FIX_CODEX_TIMEOUT_MS ?? 45 * 60 * 1000);
const maxEditAttempts = Math.max(1, Number(process.env.CLOWNFISH_FIX_EDIT_ATTEMPTS ?? 2));
const maxReviewAttempts = Math.max(1, Number(process.env.CLOWNFISH_CODEX_REVIEW_ATTEMPTS ?? 2));
const resolveReviewThreads = process.env.CLOWNFISH_RESOLVE_REVIEW_THREADS !== "0";
@ -100,14 +101,16 @@ if (NON_EXECUTABLE_REPAIR_STRATEGIES.has(repairStrategy)) {
}
const fixArtifact = validateFixArtifact(result.fix_artifact);
if (hasSecuritySignalText(job.raw, result.summary, fixArtifact, plannedFixActions)) {
const securityBlock = validateFixSecurityScope({ job, resultPath, fixArtifact, plannedFixActions });
if (securityBlock) {
report.status = "skipped";
report.reason = "security-sensitive signal detected";
report.reason = securityBlock.reason;
report.actions.push({
action: "execute_fix",
status: "skipped",
repair_strategy: fixArtifact.repair_strategy,
reason: "security-sensitive signals are routed to central security handling; closure actions may still apply their own gates",
reason: securityBlock.reason,
evidence: securityBlock.evidence,
});
writeReport(report, resultPath);
process.exit(0);
@ -277,45 +280,58 @@ function executeReplacementBranch({ fixArtifact, targetDir, supersedeSources, fa
}
function editValidatePrepareMerge({ fixArtifact, targetDir, branch, mode, fallbackReason }) {
const prompt = buildFixPrompt({ fixArtifact, branch, mode, fallbackReason });
const summaryPath = path.join(workRoot, `${mode}-codex-summary.md`);
const codexResult = spawnSync(
"codex",
[
"exec",
"--cd",
targetDir,
"--model",
model,
"--sandbox",
"workspace-write",
"-c",
'approval_policy="never"',
"--output-last-message",
summaryPath,
"--ephemeral",
"--json",
"-",
],
{
cwd: targetDir,
input: prompt,
encoding: "utf8",
env: codexEnv(),
timeout: codexTimeoutMs,
},
);
fs.writeFileSync(path.join(workRoot, `${mode}-codex.jsonl`), codexResult.stdout ?? "");
if (codexResult.stderr) fs.writeFileSync(path.join(workRoot, `${mode}-codex.stderr.log`), codexResult.stderr);
if (codexResult.error?.code === "ETIMEDOUT") {
throw new Error(`Codex fix worker timed out after ${codexTimeoutMs}ms`);
}
if (codexResult.status !== 0) {
throw new Error(codexResult.stderr || codexResult.stdout || "Codex fix worker failed");
}
let producedChanges = false;
for (let attempt = 1; attempt <= maxEditAttempts; attempt += 1) {
const prompt = buildFixPrompt({
fixArtifact,
branch,
mode,
fallbackReason,
attempt,
previousNoDiff: attempt > 1,
});
const summaryPath = path.join(workRoot, `${mode}-codex-summary-${attempt}.md`);
const codexResult = spawnSync(
"codex",
[
"exec",
"--cd",
targetDir,
"--model",
model,
"--sandbox",
"workspace-write",
"-c",
'approval_policy="never"',
"--output-last-message",
summaryPath,
"--ephemeral",
"--json",
"-",
],
{
cwd: targetDir,
input: prompt,
encoding: "utf8",
env: codexEnv(),
timeout: codexTimeoutMs,
},
);
fs.writeFileSync(path.join(workRoot, `${mode}-codex-${attempt}.jsonl`), codexResult.stdout ?? "");
if (codexResult.stderr) fs.writeFileSync(path.join(workRoot, `${mode}-codex-${attempt}.stderr.log`), codexResult.stderr);
if (codexResult.error?.code === "ETIMEDOUT") {
throw new Error(`Codex fix worker timed out after ${codexTimeoutMs}ms`);
}
if (codexResult.status !== 0) {
throw new Error(codexResult.stderr || codexResult.stdout || "Codex fix worker failed");
}
const status = run("git", ["status", "--porcelain"], { cwd: targetDir }).trim();
if (!status) throw new Error("Codex produced no target repo changes");
producedChanges = Boolean(run("git", ["status", "--porcelain"], { cwd: targetDir }).trim());
if (producedChanges) break;
}
if (!producedChanges) {
throw new Error(`Codex produced no target repo changes after ${maxEditAttempts} edit attempt(s)`);
}
const codexReview = validateAndReviewLoop({ fixArtifact, targetDir, mode });
run("git", ["add", "--all"], { cwd: targetDir });
@ -327,7 +343,7 @@ function editValidatePrepareMerge({ fixArtifact, targetDir, branch, mode, fallba
};
}
function buildFixPrompt({ fixArtifact, branch, mode, fallbackReason }) {
function buildFixPrompt({ fixArtifact, branch, mode, fallbackReason, attempt, previousNoDiff }) {
return [
"You are editing the target repository for ProjectClownfish.",
"",
@ -339,10 +355,15 @@ function buildFixPrompt({ fixArtifact, branch, mode, fallbackReason }) {
"- resolve actionable human review comments, bot comments, and requested changes named in the artifact;",
"- prepare the PR so it can pass the ProjectClownfish merge_preflight gate;",
"- do not commit, push, open PRs, close PRs, or call gh;",
"- do not touch security-sensitive code unless the artifact explicitly proves this is non-security work.",
"- do not change auth, approval, sandbox, or trust-boundary semantics unless the artifact explicitly asks for that boundary change;",
"- exec-adjacent bugs are allowed when the fix is ordinary correctness or hardening and does not redefine the security boundary.",
"",
`Mode: ${mode}`,
`Branch: ${branch}`,
`Edit attempt: ${attempt ?? 1} of ${maxEditAttempts}`,
previousNoDiff
? "Previous attempt produced no target repo diff. This time make the smallest concrete code/test change that satisfies the artifact; do not return analysis only."
: "",
fallbackReason ? `Fallback reason: ${fallbackReason}` : "",
"",
"Fix artifact:",
@ -489,7 +510,7 @@ function buildMergePreflight({ fixArtifact, codexReview }) {
return {
target: null,
security_status: "cleared",
security_evidence: ["ProjectClownfish security signal scan found no security-sensitive cluster or fix artifact signals."],
security_evidence: ["ProjectClownfish scoped security scan found no security-sensitive fix target, source PR, or fix artifact scope."],
comments_status: "resolved",
comments_evidence: ["Agentic fix pass addressed human PR/review comments named in the fix artifact."],
bot_comments_status: "resolved",
@ -584,6 +605,77 @@ function validateFixArtifact(fixArtifact) {
return fixArtifact;
}
function validateFixSecurityScope({ job, resultPath, fixArtifact, plannedFixActions }) {
if (job.frontmatter.security_sensitive === true) {
return {
reason: "job is marked security_sensitive; route to central security handling",
evidence: ["job.frontmatter.security_sensitive=true"],
};
}
const clusterPlan = readSiblingJson(resultPath, "cluster-plan.json");
const securityRefs = new Set(
(clusterPlan?.security_boundary?.security_sensitive_items ?? [])
.map(normalizeLocalRef)
.filter(Boolean),
);
for (const action of plannedFixActions) {
const target = normalizeLocalRef(action.target);
if (target && securityRefs.has(target)) {
return {
reason: `fix action targets security-sensitive ref ${target}`,
evidence: [`${target} appears in cluster-plan.security_boundary.security_sensitive_items`],
};
}
}
for (const source of fixArtifact.source_prs ?? []) {
const sourceRef = normalizeLocalRef(source);
if (sourceRef && securityRefs.has(sourceRef)) {
return {
reason: `fix artifact source PR ${sourceRef} is security-sensitive`,
evidence: [`${sourceRef} appears in cluster-plan.security_boundary.security_sensitive_items`],
};
}
}
const fixSecurityText = {
summary: fixArtifact.summary,
affected_surfaces: fixArtifact.affected_surfaces,
likely_files: fixArtifact.likely_files,
validation_commands: fixArtifact.validation_commands,
pr_title: fixArtifact.pr_title,
pr_body: fixArtifact.pr_body,
credit_notes: fixArtifact.credit_notes,
branch_update_blockers: fixArtifact.branch_update_blockers,
};
if (hasSecuritySignalText(fixSecurityText)) {
return {
reason: "fix artifact scope itself contains security-sensitive signals",
evidence: ["security scan matched fix_artifact summary/title/body/files/validation scope"],
};
}
return null;
}
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 normalizeLocalRef(value) {
const text = String(value ?? "").trim();
if (!text) return "";
const githubMatch = text.match(/github\.com\/[^/\s]+\/[^/\s]+\/(?:issues|pull)\/(\d+)/i);
if (githubMatch) return `#${githubMatch[1]}`;
const hashMatch = text.match(/^#?(\d+)$/);
if (hashMatch) return `#${hashMatch[1]}`;
return "";
}
function ensureTargetCheckout(repo, targetDir) {
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(path.dirname(targetDir), { recursive: true });

View File

@ -79,10 +79,14 @@ for (const clusterId of clusterIds) {
continue;
}
const securitySensitive = members.some((member) =>
const securitySensitiveMembers = members.filter((member) =>
hasSecuritySignalText(member.title, member.body, safeJson(member.labels_json)),
);
if (securitySensitive && skipSecurity) {
const securitySensitive = securitySensitiveMembers.length > 0;
const nonSecurityOpenMembers = members.filter(
(member) => member.state === "open" && !securitySensitiveMembers.includes(member),
);
if (securitySensitive && nonSecurityOpenMembers.length === 0 && skipSecurity) {
console.error(`skip security-sensitive cluster: ${clusterId} ${members[0].representative_title ?? ""}`);
continue;
}
@ -134,7 +138,7 @@ for (const clusterId of clusterIds) {
"cluster_refs:",
...yamlList(members.map((member) => `#${member.number}`)),
"security_policy: central_security_only",
`security_sensitive: ${securitySensitive ? "true" : "false"}`,
"security_sensitive: false",
...(mode === "autonomous" || mode === "execute"
? [
`allow_instant_close: ${allowInstantClose ? "true" : "false"}`,
@ -145,7 +149,7 @@ for (const clusterId of clusterIds) {
]
: []),
`canonical_hint: ${quoteYaml(canonicalHint(representative))}`,
`notes: ${quoteYaml(`Generated from ghcrawl run cluster ${clusterId} on ${new Date().toISOString().slice(0, 10)}.`)}`,
`notes: ${quoteYaml(jobNotes(clusterId, securitySensitiveMembers))}`,
"---",
"",
`# GHCrawl Cluster ${clusterId}`,
@ -264,6 +268,12 @@ function goalText(mode) {
return "Run one live autonomous classification pass. Classify open candidates only, verify live GitHub state, choose the current canonical issue or PR if the representative is obsolete, and emit only high-confidence planned close/comment/label actions. Closed context refs are evidence only and must not receive close actions.";
}
function jobNotes(clusterId, securitySensitiveMembers) {
const base = `Generated from ghcrawl run cluster ${clusterId} on ${new Date().toISOString().slice(0, 10)}.`;
if (securitySensitiveMembers.length === 0) return base;
return `${base} Security-sensitive refs ${securitySensitiveMembers.map((member) => `#${member.number}`).join(", ")} must be routed with route_security and must not block unrelated non-security work.`;
}
function bulletList(members) {
if (members.length === 0) return ["- none"];
return members.map((member) => `- #${member.number} ${member.title}`);

View File

@ -104,7 +104,7 @@ const plan = {
policy: job.frontmatter.security_policy ?? "central_security_only",
security_sensitive_items: securitySensitiveItems.map((item) => item.ref),
action: securitySensitiveItems.length > 0
? "No ProjectClownfish mutation is allowed; route to central OpenClaw security handling."
? "Quarantine only listed security-sensitive refs with route_security; continue non-security classification and narrow bug/fix work."
: "No security-sensitive signal detected in hydrated job refs.",
},
scope: {
@ -126,10 +126,10 @@ const plan = {
canonical_candidates: canonicalCandidates(itemList, job),
safety_gates: [
"re-fetch live state before every close/comment/label/merge/fix action",
"security-sensitive clusters are out of scope and must route to central OpenClaw security handling",
"security-sensitive refs are out of scope and must route to central OpenClaw security handling without poisoning unrelated items",
"closed context refs are evidence only; do not emit closure actions for already-closed refs",
"stop with needs_human when canonical choice is unclear",
"stop with needs_human when checks fail, conflicts exist, or cluster state changes",
"use needs_human only for the specific unresolved maintainer or product decision",
"checks, conflicts, or changed state block only the affected merge/fixed-by-candidate mutation",
"preserve contributor credit in every closeout comment",
],
};
@ -397,10 +397,12 @@ function buildFixArtifact(plan, job) {
: "Close actions may run independently when their own safety gates pass.",
},
required_validation: [
"stop and route security-sensitive clusters to central OpenClaw security handling",
"route security-sensitive refs with route_security and keep processing unrelated non-security items",
"use OpenClaw SECURITY.md posture: trusted-operator exec behavior, provider gaps, feature gaps, and hardening-only parity drift are not vulnerabilities without a boundary bypass",
"prove current main behavior before fix, merge, fixed-by-candidate, or post-merge closeout actions",
"for pure issue-dedupe closeout, prove the canonical issue and duplicate targets are live and current",
"hydrate every provided and linked item before classification",
"if an item is not a true duplicate, run a single-item review/check/decide path before needs_human",
"fetch Greptile, Codex, Asile, CodeRabbit, Copilot, and similar review-bot comments for every canonical or candidate PR",
"address each actionable review-bot finding or mark the item needs_human with the unresolved blocker",
"before any merge recommendation, include merge_preflight proving security clearance, resolved comments, resolved bot comments, passed Codex /review, addressed review findings, and validation commands",
@ -428,7 +430,7 @@ function canonicalCandidates(items, job) {
}
function classificationHint(item, job) {
if (itemSecuritySensitive(item)) return "security_sensitive_central_triage";
if (itemSecuritySensitive(item)) return "security_sensitive_route_only";
const canonicalNumbers = new Set((job.frontmatter.canonical ?? []).map((ref) => normalizeRef(job.frontmatter.repo, ref).number));
if (canonicalNumbers.has(item.number)) return "canonical_hint";
if (item.state !== "open") return "already_closed";

215
scripts/requeue-job.mjs Normal file
View File

@ -0,0 +1,215 @@
#!/usr/bin/env node
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync, spawnSync } from "node:child_process";
import { parseArgs, parseJob, repoRoot, validateJob } from "./lib.mjs";
const DEFAULT_REPO = "openclaw/projectclownfish";
const DEFAULT_WORKFLOW = "cluster-worker.yml";
const QUEUED_STATUSES = new Set(["queued", "requested", "waiting", "pending"]);
const args = parseArgs(process.argv.slice(2));
const repo = String(args.repo ?? DEFAULT_REPO);
const workflow = String(args.workflow ?? DEFAULT_WORKFLOW);
const runner = String(args.runner ?? "ubuntu-latest");
const execute = Boolean(args.execute || args.live);
const openExecuteWindow = Boolean(args["open-execute-window"] || args.live);
const requestedMode = typeof args.mode === "string" ? args.mode : null;
const requestedRunId = args["run-id"] ?? (looksLikeRunId(args._[0]) ? args._[0] : null);
const resolved = requestedRunId
? resolveFromRunId(String(requestedRunId))
: { source_job: args._[0], mode: requestedMode };
if (!resolved.source_job) {
console.error("usage: node scripts/requeue-job.mjs <job.md|run-id> [--mode plan|execute|autonomous] [--execute] [--open-execute-window] [--runner label]");
process.exit(2);
}
const job = parseJob(resolved.source_job);
const errors = validateJob(job);
if (errors.length > 0) {
console.error(`invalid job: ${job.relativePath}`);
for (const error of errors) console.error(`- ${error}`);
process.exit(1);
}
const mode = requestedMode ?? resolved.mode ?? job.frontmatter.mode;
if (!["plan", "execute", "autonomous"].includes(mode)) {
throw new Error(`unsupported mode: ${mode}`);
}
const summary = {
status: execute ? "dispatching" : "dry_run",
repo,
workflow,
source_run_id: requestedRunId,
source_job: job.relativePath,
mode,
runner,
};
if (!execute) {
console.log(JSON.stringify(summary, null, 2));
process.exit(0);
}
let gatesOpened = false;
const headSha = currentHeadSha();
const dispatchStartedAt = new Date(Date.now() - 5000).toISOString();
try {
if (openExecuteWindow && ["execute", "autonomous"].includes(mode)) {
setGate("CLOWNFISH_ALLOW_EXECUTE", "1");
if (job.frontmatter.allow_fix_pr === true || job.frontmatter.allowed_actions.includes("fix")) {
setGate("CLOWNFISH_ALLOW_FIX_PR", "1");
}
gatesOpened = true;
}
assertGateOpenIfNeeded(mode);
dispatchJob(job.relativePath, mode);
const observedRuns = waitForStartedRuns({ headSha, since: dispatchStartedAt, expectedCount: 1 });
summary.status = "dispatched";
summary.observed_runs = observedRuns.map((run) => ({
run_id: String(run.databaseId),
status: run.status,
conclusion: run.conclusion ?? null,
created_at: run.createdAt,
url: run.url,
}));
console.log(JSON.stringify(summary, null, 2));
} finally {
if (gatesOpened) {
setGate("CLOWNFISH_ALLOW_EXECUTE", "0");
setGate("CLOWNFISH_ALLOW_FIX_PR", "0");
}
}
function resolveFromRunId(runId) {
const fromLedger = readPublishedRunRecord(runId);
if (fromLedger?.source_job) {
return { source_job: fromLedger.source_job, mode: fromLedger.mode };
}
const artifactDir = fs.mkdtempSync(path.join(os.tmpdir(), `projectclownfish-requeue-${runId}-`));
const downloaded = spawnSync(
"gh",
["run", "download", runId, "--repo", repo, "--dir", artifactDir],
{ cwd: repoRoot(), encoding: "utf8", stdio: "pipe" },
);
if (downloaded.status !== 0) {
throw new Error(`could not resolve run ${runId}: ${downloaded.stderr || downloaded.stdout}`);
}
const planPath = findFirstFile(artifactDir, "cluster-plan.json");
const resultPath = findFirstFile(artifactDir, "result.json");
if (!planPath) throw new Error(`run ${runId} artifact did not include cluster-plan.json`);
const plan = JSON.parse(fs.readFileSync(planPath, "utf8"));
const result = resultPath ? JSON.parse(fs.readFileSync(resultPath, "utf8")) : null;
return { source_job: plan.source_job, mode: result?.mode ?? plan.mode };
}
function readPublishedRunRecord(runId) {
const file = path.join(repoRoot(), "results", "runs", `${runId}.json`);
if (!fs.existsSync(file)) return null;
return JSON.parse(fs.readFileSync(file, "utf8"));
}
function findFirstFile(root, basename) {
for (const entry of fs.readdirSync(root, { recursive: true })) {
const candidate = path.join(root, String(entry));
if (path.basename(candidate) === basename && fs.statSync(candidate).isFile()) return candidate;
}
return null;
}
function dispatchJob(jobPath, mode) {
const result = spawnSync(
"gh",
["workflow", "run", workflow, "--repo", repo, "-f", `job=${jobPath}`, "-f", `mode=${mode}`, "-f", `runner=${runner}`],
{ cwd: repoRoot(), encoding: "utf8", stdio: "pipe" },
);
if (result.status !== 0) {
throw new Error(`failed to dispatch ${jobPath}: ${result.stderr || result.stdout}`);
}
}
function waitForStartedRuns({ expectedCount, headSha, since }) {
const deadline = Date.now() + 5 * 60 * 1000;
let latest = [];
while (Date.now() < deadline) {
latest = listClusterRuns()
.filter((run) => run.headSha === headSha)
.filter((run) => Date.parse(run.createdAt) >= Date.parse(since))
.sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt));
if (latest.length >= expectedCount && latest.every((run) => !QUEUED_STATUSES.has(run.status))) {
return latest.slice(-expectedCount);
}
sleepMs(5_000);
}
return latest.slice(-expectedCount);
}
function assertGateOpenIfNeeded(mode) {
if (!["execute", "autonomous"].includes(mode)) return;
if (readGate("CLOWNFISH_ALLOW_EXECUTE") !== "1") {
throw new Error("refusing write-mode requeue: CLOWNFISH_ALLOW_EXECUTE is not 1; use --open-execute-window");
}
}
function listClusterRuns() {
return ghJson([
"run",
"list",
"--repo",
repo,
"--workflow",
workflow,
"--limit",
"50",
"--json",
"databaseId,headSha,status,conclusion,createdAt,url",
]);
}
function readGate(name) {
const variables = ghJson(["variable", "list", "--repo", repo, "--json", "name,value"]);
return variables.find((variable) => variable.name === name)?.value ?? "";
}
function setGate(name, value) {
execFileSync("gh", ["variable", "set", name, "--repo", repo, "--body", value], {
cwd: repoRoot(),
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
console.log(`${name}=${value}`);
}
function currentHeadSha() {
return execFileSync("git", ["rev-parse", "origin/main"], {
cwd: repoRoot(),
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
}
function ghJson(ghArgs) {
const text = execFileSync("gh", ghArgs, {
cwd: repoRoot(),
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 64 * 1024 * 1024,
});
return JSON.parse(text || "null");
}
function looksLikeRunId(value) {
return /^[0-9]{6,}$/.test(String(value ?? ""));
}
function sleepMs(milliseconds) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
}

View File

@ -12,6 +12,7 @@ const CLOSE_ACTIONS = new Set([
"post_merge_close",
]);
const MERGE_ACTIONS = new Set(["merge_candidate", "merge_canonical"]);
const ROUTE_SECURITY_ACTIONS = new Set(["route_security"]);
const FIX_REPAIR_STRATEGIES = new Set([
"repair_contributor_branch",
"replace_uneditable_branch",
@ -124,6 +125,20 @@ function reviewResult(resultPath) {
if (item?.security_sensitive && MUTATING_ACTIONS.has(name)) {
failures.push(`${target} mutating action targets security-sensitive item`);
}
if (ROUTE_SECURITY_ACTIONS.has(name)) {
if (action.classification !== "security_sensitive") {
failures.push(`${target} route_security action must use security_sensitive classification`);
}
if (action.status !== "skipped" && action.status !== "planned") {
failures.push(`${target} route_security action status must be skipped or planned`);
}
if (item && item.security_sensitive !== true) {
warnings.push(`${target} route_security target was not marked security_sensitive in preflight`);
}
}
if (name === "needs_human" && /security-sensitive|security boundary|central .*security|security triage/i.test(String(action.reason ?? ""))) {
failures.push(`${target} security routing must use route_security instead of needs_human`);
}
if (action.status === "executed") {
failures.push(`${target} action status must not be executed; only the applicator records execution`);

View File

@ -65,6 +65,7 @@ const attempts = candidates.map((candidate) => ({
try {
if (openExecuteWindow) {
setExecuteGate("1");
setFixGate("1");
executeWindowOpened = true;
} else {
assertExecuteGateOpenIfNeeded(candidates);
@ -104,6 +105,7 @@ try {
} finally {
if (executeWindowOpened) {
setExecuteGate("0");
setFixGate("0");
}
}
@ -187,6 +189,12 @@ function assertExecuteGateOpenIfNeeded(candidates) {
"refusing write-mode self-heal: CLOWNFISH_ALLOW_EXECUTE is not 1; rerun with --open-execute-window or open the gate manually",
);
}
const fixCurrent = readFixGate();
if (fixCurrent !== "1") {
throw new Error(
"refusing write-mode self-heal: CLOWNFISH_ALLOW_FIX_PR is not 1; rerun with --open-execute-window or open both gates manually",
);
}
}
function readRunRecords() {
@ -241,6 +249,11 @@ function readExecuteGate() {
return variables.find((variable) => variable.name === "CLOWNFISH_ALLOW_EXECUTE")?.value ?? "";
}
function readFixGate() {
const variables = ghJson(["variable", "list", "--repo", repo, "--json", "name,value"]);
return variables.find((variable) => variable.name === "CLOWNFISH_ALLOW_FIX_PR")?.value ?? "";
}
function setExecuteGate(value) {
execFileSync("gh", ["variable", "set", "CLOWNFISH_ALLOW_EXECUTE", "--repo", repo, "--body", value], {
cwd: repoRoot(),
@ -250,6 +263,15 @@ function setExecuteGate(value) {
console.log(`CLOWNFISH_ALLOW_EXECUTE=${value}`);
}
function setFixGate(value) {
execFileSync("gh", ["variable", "set", "CLOWNFISH_ALLOW_FIX_PR", "--repo", repo, "--body", value], {
cwd: repoRoot(),
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
console.log(`CLOWNFISH_ALLOW_FIX_PR=${value}`);
}
function currentHeadSha() {
return execFileSync("git", ["rev-parse", "origin/main"], {
cwd: repoRoot(),