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")),
}

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 ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Consider explicit error handling for path edge cases.

If args.output points to a directory (exists but not a file), this silently treats it as empty output, which could mask configuration errors in CI pipelines. An explicit error might be more helpful for debugging.

-    output = Path(args.output).read_text(encoding="utf-8") if Path(args.output).is_file() else ""
+    output_path = Path(args.output)
+    if output_path.is_dir():
+        raise SystemExit(f"error: --output path is a directory: {args.output}")
+    output = output_path.read_text(encoding="utf-8") if output_path.is_file() else ""

That said, if the current behavior of returning an error verdict with missing-structured-json source is intentional for graceful degradation in cases where verifier output doesn't exist, feel free to dismiss this.

📝 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
output = Path(args.output).read_text(encoding="utf-8") if Path(args.output).is_file() else ""
output_path = Path(args.output)
if output_path.is_dir():
raise SystemExit(f"error: --output path is a directory: {args.output}")
output = output_path.read_text(encoding="utf-8") if output_path.is_file() else ""
🤖 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 103, In the path handling
logic where args.output is read, add explicit error handling to catch the edge
case where args.output exists but is a directory rather than a file. Instead of
silently treating a directory path as empty output, raise an error or log a
warning that clearly indicates the configuration issue, so that CI pipeline
failures are not masked by ambiguous behavior. This ensures debugging in CI
environments is more straightforward when path configuration errors occur.

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 }}
Comment on lines +77 to +80

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 | 🏗️ Heavy lift

max_parallel is now silently ignored by callers.

Line 77 hard-codes max_parallel: 1, overriding dispatch input-driven concurrency. This is a behavioral break for existing callers that pass values >1 and expect them to propagate to the worker.

Suggested fix (template-side)
-      max_parallel: 1
+      max_parallel: ${{ fromJSON(inputs.max_parallel) }}

As per coding guidelines, "**/{.github/workflows/agents-*.yml,...} and .github/workflows/agents-*.yml should be fixed in stranske/Workflows, not edited locally in the consumer repository."

📝 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
max_parallel: 1
keepalive: ${{ inputs.keepalive }}
orchestrator_skill_pack: ${{ inputs.orchestrator_skill_pack }}
orchestrator_skill_enabled: ${{ inputs.orchestrator_skill_enabled }}
max_parallel: ${{ fromJSON(inputs.max_parallel) }}
keepalive: ${{ inputs.keepalive }}
orchestrator_skill_pack: ${{ inputs.orchestrator_skill_pack }}
orchestrator_skill_enabled: ${{ inputs.orchestrator_skill_enabled }}
🤖 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/workflows/agents-72-codex-belt-worker-dispatch.yml around lines 77 -
80, The max_parallel field on line 77 is hard-coded to 1, which silently
overrides any max_parallel input value passed by callers and breaks existing
workflows expecting higher concurrency values. Replace the hard-coded
max_parallel: 1 with a reference to the input parameter using the pattern ${{
inputs.max_parallel }} to propagate the caller's concurrency preference. Note
that per coding guidelines, this fix should be applied in the stranske/Workflows
repository where the agents-*.yml workflow templates are maintained, not edited
locally in this consumer repository.

Sources: Coding guidelines, Linked repositories

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