From 58a4f8cb9e4e5ab1dd6e0a1d26eb1de93a725694 Mon Sep 17 00:00:00 2001 From: eveselove Date: Sat, 20 Jun 2026 17:43:17 +0300 Subject: [PATCH] fix(forge): report zero-change tasks as 'nochange', not done/gate=ok MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Designed by forge (agy/Opus, batch bf_d96b7574 / task-3a13769e); applied cleanly by hand (forge could not commit it: pre-commit black --check blocks the unformatted repo and ruff --fix mutated ~30 .py incl. dropping a still-used `import hashlib` — a separate forge-unblock needed for .py tasks). _commit_work now returns (sha, has_changes). When an agy run yields zero file changes, the worker sets node status='nochange' (added to ST_TOKEN + TERMINAL_BAD, NOT in TERMINAL_GOOD) and reconciles the gateway as failed, instead of empty-commit -> gate-ok -> done. Stops no-op/silently-failed agent runs from being reported as completed/green in `forge status`. Real-change tasks keep commit -> gate -> done. Empty commit still created for diffability. task 7894af1c Co-Authored-By: Claude Opus 4.8 --- forge_lib.py | 4 +++- forge_run.py | 18 ++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/forge_lib.py b/forge_lib.py index 0477b71..021a578 100755 --- a/forge_lib.py +++ b/forge_lib.py @@ -726,11 +726,13 @@ def detect_auth_failure(task_id, exit_code, duration, local_log=None): "rejected": "REJ", "reclaimed": "RECL", "unclaimable": "UNCL", + "nochange": "NOCH", } TERMINAL_GOOD = {"done", "review"} TERMINAL_BAD = {"failed", "timeout", "gatefail", "gate_timeout", "rate_limited", - "blocked", "auth", "rejected", "reclaimed", "unclaimable"} + "blocked", "auth", "rejected", "reclaimed", "unclaimable", + "nochange"} TERMINAL = TERMINAL_GOOD | TERMINAL_BAD diff --git a/forge_run.py b/forge_run.py index b21359d..5b46732 100755 --- a/forge_run.py +++ b/forge_run.py @@ -279,11 +279,24 @@ def _execute_task_inner(manifest, batch_id, repo, base_ref, gate_profile, return # --- SUCCESS path: COMMIT, then GATE --- - commit_sha = _commit_work(worktree_dir, task_id, title) + commit_sha, has_changes = _commit_work(worktree_dir, task_id, title) files, add, rem = F.numstat(repo, base_ref, forge_branch) summary = F.extract_summary(task_id) or _synth_summary(files, add, rem) + # Zero-change detection: agent exited 0 but produced no diff. The empty + # commit is kept for diffability, but we must NOT report a clean done/gate=ok + # for what is effectively a no-op (or silently-failed) agent run. + if not has_changes: + update_node(batch_id, task_id, status="nochange", + duration_s=round(duration, 1), commit_sha=commit_sha, + files_changed_count=0, lines_added=0, lines_removed=0, + summary=summary or "agent produced no file changes") + reconcile_gateway(task_id, "failed", + f"forge: no file changes (model={model}, {int(duration)}s)", + duration, agent_id) + return + update_node(batch_id, task_id, status="review", duration_s=round(duration, 1), commit_sha=commit_sha, files_changed_count=files, lines_added=add, lines_removed=rem, summary=summary) @@ -385,7 +398,8 @@ def _commit_work(worktree_dir, task_id, title): msg = f"forge {F.truncate(title,55)}\n\n{trailer}" F.git(worktree_dir, *base_args, "-m", msg) rc, sha, _ = F.git(worktree_dir, "rev-parse", "HEAD") - return sha.strip() if rc == 0 else "" + has_changes = bool(out.strip()) + return (sha.strip() if rc == 0 else "", has_changes) def _synth_summary(files, add, rem):