Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 11 additions & 13 deletions .github/scripts/agent_delegation_policy.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ function decideNextAgent({ state = {}, labels = [], secrets = {}, registry = {},

// Current agent exists - check if we should continue or switch
const effectiveness = calculateEffectiveness({ history, lookbackRounds: 3, core });
const stall = detectStall({ history, threshold: 3, core });
const stall = detectStall({ history, threshold: 2, core });
const roundsSinceSwitch = currentIteration - lastSwitchIteration;
const inCooldown = roundsSinceSwitch < 5;

Expand Down Expand Up @@ -224,15 +224,13 @@ function calculateEffectiveness({ history = [], lookbackRounds = 3, core }) {
const tasks = recentRounds.reduce((sum, round) => sum + (round.tasks || 0), 0);
const gatePassed = recentRounds.some((round) => round.gate === 'pass');

// Agent is effective only when it produced real forward motion:
// - Made at least 1 commit in lookback window, OR
// - Completed at least 1 task in lookback window.
// A green Gate with zero commits and zero tasks is NOT progress: a normal
// `run` dispatch only happens on a green Gate (Activation Guardrail §2), so a
// genuinely stuck agent records `gate: 'pass'` every round. Counting that as
// effective made the stall detector unable to ever fire (#2268). `gatePassed`
// is still returned/reported below, just not treated as progress.
const effective = commits >= 1 || tasks >= 1;
// Agent is effective only when it produced verified forward motion:
// - Completed at least 1 task in the lookback window, OR
// - Made commits and has a green Gate signal in the lookback window.
// Bare commits with no checkbox progress and a non-green Gate are churn, not
// progress; otherwise an agent can commit indefinitely without advancing
// acceptance criteria or CI and never trip delegation.
const effective = tasks >= 1 || (commits >= 1 && gatePassed);

const summary = [
commits > 0 ? `${commits} commits` : null,
Expand Down Expand Up @@ -262,7 +260,7 @@ function calculateEffectiveness({ history = [], lookbackRounds = 3, core }) {
* @param {Object} [options.core] - GitHub Actions core for logging
* @returns {Object} - { isStalled, consecutiveRounds, reason }
*/
function detectStall({ history = [], threshold = 3, core }) {
function detectStall({ history = [], threshold = 2, core }) {
if (history.length < threshold) {
return {
isStalled: false,
Expand All @@ -280,8 +278,8 @@ function detectStall({ history = [], threshold = 3, core }) {
// keeps a green Gate while making zero commits never trips the stall
// threshold and `agent:auto` delegation can never switch (#2268).
const hasProgress =
(round.commits || 0) > 0 ||
(round.tasks || 0) > 0;
(round.tasks || 0) > 0 ||
((round.commits || 0) > 0 && round.gate === 'pass');

if (hasProgress) {
break; // Found progress, stop counting
Expand Down
45 changes: 29 additions & 16 deletions .github/scripts/keepalive_loop.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,24 @@ function toNumber(value, fallback = 0) {
return Number.isFinite(fallback) ? Number(fallback) : 0;
}

function toPositiveInteger(value, fallback = 0) {
const fallbackValue = Number.isSafeInteger(fallback) && fallback > 0 ? fallback : 0;

if (typeof value === 'number') {
return Number.isSafeInteger(value) && value > 0 ? value : fallbackValue;
}

if (typeof value === 'string') {
const trimmed = value.trim();
if (/^[1-9]\d*$/.test(trimmed)) {
const parsed = Number(trimmed);
return Number.isSafeInteger(parsed) ? parsed : fallbackValue;
}
}

return fallbackValue;
}

function toOptionalNumber(value) {
if (value === null || value === undefined || value === '') {
return null;
Expand Down Expand Up @@ -1593,7 +1611,7 @@ function normaliseConfig(config = {}) {
),
autofix_enabled: toBool(cfg.autofix_enabled ?? cfg.autofix, false),
iteration: toNumber(cfg.iteration ?? cfg.keepalive_iteration, 0),
max_iterations: toNumber(cfg.max_iterations ?? cfg.keepalive_max_iterations, 5),
max_iterations: cfg.max_iterations ?? cfg.keepalive_max_iterations,
failure_threshold: toNumber(cfg.failure_threshold ?? cfg.keepalive_failure_threshold, 3),
trace,
prompt_mode: promptMode,
Expand Down Expand Up @@ -2503,7 +2521,9 @@ async function evaluateKeepaliveLoop({ github: rawGithub, context, core, payload
// Prefer state iteration unless config explicitly sets it (0 from config is default, not explicit)
const configHasExplicitIteration = config.iteration > 0;
const iteration = configHasExplicitIteration ? config.iteration : toNumber(state.iteration, 0);
const maxIterations = toNumber(config.max_iterations ?? state.max_iterations, 5);
const configMaxIterations = toPositiveInteger(config.max_iterations, 0);
const stateMaxIterations = toPositiveInteger(state.max_iterations, 0);
const maxIterations = configMaxIterations || stateMaxIterations || 12;
const failureThreshold = toNumber(config.failure_threshold ?? state.failure_threshold, 3);
const progressReviewThreshold = toNumber(config.progress_review_threshold ?? state.progress_review_threshold, 4);
// Default 3 rounds allows 2 fix attempts before stopping (round 1 = fix,
Expand Down Expand Up @@ -2595,14 +2615,11 @@ async function evaluateKeepaliveLoop({ github: rawGithub, context, core, payload
// An iteration is productive if it has a reasonable productivity score
const isProductive = productivityScore >= 20 && !hasRecentFailures;

// max_iterations is a soft cap on agent runs. Productive agents
// (recent file changes, no persistent failures) with remaining tasks
// continue in "extended mode" (reason: ready-extended) past the cap.
// Unproductive agents are hard-stopped to avoid wasting compute.
// Use the agent:retry label to force-continue regardless.
// max_iterations is a hard per-PR budget. Once reached, stop dispatching
// and require a human to raise the budget or remove the blocker.
const hasMaxIterations = maxIterations > 0;
const reachedMaxIterations = hasMaxIterations && iteration >= maxIterations;
const shouldStopForMaxIterations = reachedMaxIterations && !isProductive;
const shouldStopForMaxIterations = reachedMaxIterations;

// Build task appendix for the agent prompt (after state load for reconciliation info)
const taskAppendix = buildTaskAppendix(normalisedSections, checkboxCounts, state, { prBody: pr.body });
Expand Down Expand Up @@ -2663,6 +2680,9 @@ async function evaluateKeepaliveLoop({ github: rawGithub, context, core, payload
} else if (!tasksPresent) {
action = 'stop';
reason = 'no-checklists';
} else if (shouldStopForMaxIterations) {
action = 'stop';
reason = 'round-budget-exhausted';
} else if (gateNormalized !== 'success') {
// Handle cancelled gate first (transient — should not consume fix budget)
if (gateNormalized === 'cancelled') {
Expand Down Expand Up @@ -2768,13 +2788,6 @@ async function evaluateKeepaliveLoop({ github: rawGithub, context, core, payload
`Agent produced 0 file changes and 0 tasks completed for ${persistedConsecutiveZeroActivityRounds} consecutive rounds — likely infrastructure failure (auth, permissions, sandbox). Stopping.`,
);
}
} else if (shouldStopForMaxIterations && forceRetry && tasksRemaining) {
action = 'run';
reason = 'force-retry-max-iterations';
if (core) core.info('Force retry enabled: bypassing max-iterations stop');
} else if (shouldStopForMaxIterations) {
action = 'stop';
reason = isProductive ? 'max-iterations' : 'max-iterations-unproductive';
} else if (needsProgressReview) {
// Trigger LLM-based progress review when agent is active but not completing tasks
// This allows legitimate prep work while catching scope drift early
Expand All @@ -2783,7 +2796,7 @@ async function evaluateKeepaliveLoop({ github: rawGithub, context, core, payload
reason = `progress-review-${roundsWithoutTaskCompletion}`;
} else if (tasksRemaining) {
action = 'run';
reason = iteration >= maxIterations ? 'ready-extended' : 'ready';
reason = 'ready';
}

// Scope enforcement: if all tasks appear complete but there are scope
Expand Down
116 changes: 116 additions & 0 deletions .github/scripts/verifier_verdict_json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Extract an unspoofable verifier verdict from structured agent output."""

from __future__ import annotations

import argparse
import json
import re
from pathlib import Path
from typing import Any

VERDICT_RE = re.compile(
r"\b[\"']?verdict[\"']?\s*:\s*[\"']?(pass|fail)[\"']?\b",
re.IGNORECASE,
)
FENCED_BLOCK_RE = re.compile(
r"^[ \t]*```(?P<lang>[^\n`]*)\n(?P<body>.*?)^[ \t]*```[ \t]*$",
re.MULTILINE | re.DOTALL,
)

VALID_VERDICTS = {"pass", "concerns", "fail", "error"}


def _normalize_verdict(value: object) -> str:
verdict = str(value or "").strip().lower().replace("_", "-")
if verdict in {"passed", "success"}:
return "pass"
if verdict in {"needs-review", "needs review", "review", "concern", "concerns"}:
return "concerns"
if verdict in {"failed", "failure"}:
return "fail"
return verdict if verdict in VALID_VERDICTS else ""


def _diff_regions(markdown: str) -> list[str]:
regions: list[str] = []
for match in FENCED_BLOCK_RE.finditer(markdown):
lang = match.group("lang").strip().lower()
if lang in {"diff", "patch"}:
regions.append(match.group("body"))
return regions


def _without_diff_regions(markdown: str) -> str:
def replace(match: re.Match[str]) -> str:
lang = match.group("lang").strip().lower()
return "\n" if lang in {"diff", "patch"} else match.group(0)

return FENCED_BLOCK_RE.sub(replace, markdown)


def _json_candidates(markdown: str) -> list[dict[str, Any]]:
candidates: list[dict[str, Any]] = []
for match in FENCED_BLOCK_RE.finditer(markdown):
if match.group("lang").strip().lower() != "json":
continue
try:
parsed = json.loads(match.group("body"))
except json.JSONDecodeError:
continue
if isinstance(parsed, dict):
candidates.append(parsed)

return candidates


def build_verdict(output: str) -> dict[str, Any]:
for region in _diff_regions(output):
if VERDICT_RE.search(region):
return {
"verdict": "fail",
"source": "diff-tamper",
"needs_attention": True,
"reason": "verdict marker appeared inside a diff/patch region",
}

output_without_diff = _without_diff_regions(output)
for candidate in _json_candidates(output_without_diff):
verdict = _normalize_verdict(candidate.get("verdict"))
if not verdict:
continue
return {
**candidate,
"verdict": verdict,
"source": "structured-json",
"needs_attention": bool(candidate.get("needs_attention", verdict != "pass")),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Coerce needs_attention explicitly instead of using bool() on arbitrary JSON values

At Line 86, bool(candidate.get("needs_attention", ...)) misinterprets string values like "false" as True. That can flip verifier routing/gating behavior.

Suggested fix
+def _coerce_optional_bool(value: object) -> bool | None:
+    if isinstance(value, bool):
+        return value
+    if isinstance(value, str):
+        normalized = value.strip().lower()
+        if normalized in {"true", "1", "yes", "on"}:
+            return True
+        if normalized in {"false", "0", "no", "off"}:
+            return False
+    return None
+
@@
-        return {
+        parsed_attention = _coerce_optional_bool(candidate.get("needs_attention"))
+        return {
             **candidate,
             "verdict": verdict,
             "source": "structured-json",
-            "needs_attention": bool(candidate.get("needs_attention", verdict != "pass")),
+            "needs_attention": parsed_attention if parsed_attention is not None else (verdict != "pass"),
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"needs_attention": bool(candidate.get("needs_attention", verdict != "pass")),
def _coerce_optional_bool(value: object) -> bool | None:
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"true", "1", "yes", "on"}:
return True
if normalized in {"false", "0", "no", "off"}:
return False
return None
parsed_attention = _coerce_optional_bool(candidate.get("needs_attention"))
return {
**candidate,
"verdict": verdict,
"source": "structured-json",
"needs_attention": parsed_attention if parsed_attention is not None else (verdict != "pass"),
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/scripts/verifier_verdict_json.py at line 86, The "needs_attention"
field assignment at line 86 incorrectly uses bool() on candidate.get() which
misinterprets string values like "false" as True since any non-empty string is
truthy in Python. Replace the bool() coercion with explicit string comparison to
check if the value equals "true" (case-insensitive) or use proper JSON boolean
coercion instead of relying on Python's truthiness evaluation. This ensures that
string values from JSON are properly converted to their intended boolean
representation.

}

return {
"verdict": "error",
"source": "missing-structured-json",
"needs_attention": True,
"reason": "no structured verifier JSON verdict found outside diff regions",
}


def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--output", required=True, help="Verifier agent output markdown")
parser.add_argument("--json", required=True, help="Destination verdict JSON path")
args = parser.parse_args()

output = Path(args.output).read_text(encoding="utf-8") if Path(args.output).is_file() else ""
verdict = build_verdict(output)
destination = Path(args.json)
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(json.dumps(verdict, sort_keys=True) + "\n", encoding="utf-8")
print(f"verdict={verdict['verdict']}")
print(f"source={verdict['source']}")
if verdict.get("reason"):
print(f"reason={verdict['reason']}")
return 0


if __name__ == "__main__":
raise SystemExit(main())
26 changes: 26 additions & 0 deletions .github/workflows/agents-71-codex-belt-dispatcher.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ on:
required: false
default: false
type: boolean
orchestrator_skill_pack:
description: >-
Optional reference-pack name override for exported Orchestrator skill context on
downstream Codex opener/closer runs (repo config remains primary).
required: false
default: ''
type: string
orchestrator_skill_enabled:
description: >-
Optional enabled override for exported Orchestrator skill context (true/false).
required: false
default: ''
type: string
secrets:
ACTIONS_BOT_PAT:
required: false
Expand Down Expand Up @@ -63,6 +76,19 @@ on:
required: false
default: false
type: boolean
orchestrator_skill_pack:
description: >-
Optional reference-pack name override for exported Orchestrator skill context on
downstream Codex opener/closer runs (repo config remains primary).
required: false
default: ''
type: string
orchestrator_skill_enabled:
description: >-
Optional enabled override for exported Orchestrator skill context (true/false).
required: false
default: ''
type: string

permissions:
contents: write
Expand Down
22 changes: 16 additions & 6 deletions .github/workflows/agents-72-codex-belt-worker-dispatch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,24 @@ on:
required: false
default: false
type: boolean
max_parallel:
description: 'Maximum concurrent worker runs permitted'
required: false
default: '1'
type: string
keepalive:
description: 'True when invocation originates from a keepalive sweep'
required: false
default: false
type: boolean
orchestrator_skill_pack:
description: >-
Optional reference-pack name override for exported Orchestrator skill context on
downstream Codex opener/closer runs (repo config remains primary).
required: false
default: ''
type: string
orchestrator_skill_enabled:
description: >-
Optional enabled override for exported Orchestrator skill context (true/false).
required: false
default: ''
type: string

permissions:
contents: write
Expand All @@ -66,8 +74,10 @@ jobs:
source: ${{ inputs.source }}
dry_run: ${{ inputs.dry_run }}
use_step_branch: ${{ inputs.use_step_branch }}
max_parallel: ${{ fromJSON(inputs.max_parallel) }}
max_parallel: 1
keepalive: ${{ inputs.keepalive }}
orchestrator_skill_pack: ${{ inputs.orchestrator_skill_pack }}
orchestrator_skill_enabled: ${{ inputs.orchestrator_skill_enabled }}
secrets:
WORKFLOWS_APP_ID: ${{ secrets.WORKFLOWS_APP_ID }}
WORKFLOWS_APP_PRIVATE_KEY: ${{ secrets.WORKFLOWS_APP_PRIVATE_KEY }}
Expand Down
13 changes: 13 additions & 0 deletions .github/workflows/agents-72-codex-belt-worker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,19 @@ on:
required: false
default: false
type: boolean
orchestrator_skill_pack:
description: >-
Optional reference-pack name override for exported Orchestrator skill context on
downstream Codex opener/closer runs (repo config remains primary).
required: false
default: ''
type: string
orchestrator_skill_enabled:
description: >-
Optional enabled override for exported Orchestrator skill context (true/false).
required: false
default: ''
type: string
secrets:
WORKFLOWS_APP_ID:
required: false
Expand Down
Loading
Loading