Environment note: although the problem metadata says language: go, gitreins 0.14/0.15 is a Python package (pip install gitreins, gitreins/cli.py, engine/jobstore.py). The defect and fix below are Python.
gitreins judge --async printed poll id diverges from the log-path job idEnvironment note: although the problem metadata says
language: go, gitreins 0.14/0.15 is a Python package (pip install gitreins,gitreins/cli.py,engine/job_store.py). The defect and fix below are Python.
gitreins/cli.py::_cmd_judge_async calls new_job_id() twice: once to name the worker log, and again to name the job record/worker. The two ids are independent UUIDs, so the dispatcher prints a poll id that never matches the log-path id:
# BUGGY (0.14.0 and 0.15.0)
log_path = job_log_path(new_job_id()) # id A -> job-A.log
...
job_id = new_job_id() # id B -> job-B.json + --run-job job-B
proc = subprocess.Popen([... "judge", "--run-job", job_id], ...)
...
job["id"] = job_id # record saved as job-B.json
...
print(f" log: {log_path}") # prints job-A.log
print(f" poll: gitreins judge --status {job['id']}") # prints job-B
Consequences:
job-<printed>.log, which does not exist; the real log is job-<logpath>.log. The two artefacts describe different jobs._cmd_judge_worker only retries the load for 50 × 0.02s = 1s. If the parent loses that race (loaded box, fsync on save_job), the child prints Job not found: job-<id> and exits. The parent then saves job-<id>.json with status="running" and the now-dead proc.pid, leaving a 0-byte/aborted log.engine.job_store.find_running_job() deliberately ignores pid (GR-GAP-046 single-flight), so every later gitreins judge <task> --async prints Async job already running: job-<id> and refuses to re-dispatch. The MCP judge.status path auto-resumes orphans, but the CLI does not — the task is wedged for CLI use until the record is reaped.The single-id fix removes the divergence at the source; a divergence-aware poll loop and a reaper handle records already written by the buggy build.
gitreins/cli.pyGenerate one id and derive both the log path and the record from it:
--- a/gitreins/cli.py
+++ b/gitreins/cli.py
@@ -2394,7 +2394,12 @@ def _cmd_judge_async(task_id: str) -> None:
print(f" poll: gitreins judge --status {existing['id']}")
return
- log_path = job_log_path(new_job_id())
+ # ONE id for both the record and its log: deriving them from two
+ # separate new_job_id() calls made the printed poll id diverge from the
+ # log-path job id, so a poll of the printed id could miss the record
+ # (and a stuck `running` record with a dead pid wedged re-dispatch).
+ job_id = new_job_id()
+ log_path = job_log_path(job_id)
try:
logf = open(log_path, "ab")
except OSError as e:
@@ -2403,7 +2408,6 @@ def _cmd_judge_async(task_id: str) -> None:
# Spawn the child FIRST: the job record is published with the child's
# real pid in the same save that creates it (no pid=None window).
- job_id = new_job_id()
try:
proc = subprocess.Popen(
[sys.executable, "-m", "gitreins.cli", "judge", "--run-job", job_id],
After this, job['id'] == os.path.basename(log_path)[:-len(".log")], so the printed poll id, job-<id>.json, the worker log, and the --run-job argument all name the same job.
Optional hardening (not required by the id fix): raise the child's load retry window in _cmd_judge_worker from 50 × 0.02s to, e.g., 250 × 0.02s so a slow dispatcher cannot lose the publish race.
The shipped loop must treat “Job not found on the printed id while the log-path id exists” as divergence and re-check the log-path id. In tests/test_cli.py::TestJudgeAsyncCLI._poll_job, parse the dispatch's log path and fall back to its id:
def _poll_job(self, job_id, cwd, deadline_s=None, log_path=None):
candidate_ids = [job_id]
if log_path:
log_id = os.path.basename(str(log_path))
if log_id.endswith(".log"):
log_id = log_id[: -len(".log")]
if log_id and log_id not in candidate_ids:
candidate_ids.append(log_id)
active_id = job_id
...
while True:
last = run_cli("judge", active_id, "--status", cwd=cwd)
...
if last.returncode == 1 and "Job not found" in last.stdout:
# printed id has no record: if the log-path id resolves, follow it
for alt in candidate_ids:
if alt == active_id:
continue
alt_res = run_cli("judge", alt, "--status", cwd=cwd)
if alt_res.returncode in (0, 1) and "Job not found" not in alt_res.stdout:
active_id = alt
last = alt_res
break
if last.returncode in (0, 1):
return last
if time.monotonic() >= deadline:
state = self._job_state(active_id) # diagnose the id that answered
...
The dispatch test should pass the log path into the loop and assert the ids agree:
poll = re.search(r"poll:\s+gitreins judge --status (job-[0-9a-f]+)", dispatched.stdout)
log = re.search(r"log:\s+(\S+)", dispatched.stdout)
log_id = os.path.basename(log.group(1))[:-len(".log")]
assert log_id == poll.group(1) # regression guard
scripts/reap_stale_jobs.py flips dead-running records to error (which unblocks find_running_job) after backing up the store. Only a running record whose pid is dead and whose log is missing/0-byte is reaped; live workers are left alone.
# confirm a stale wedge
cat ~/.local/share/gitreins/jobs/job-<id>.json # status=running
ps -p "$(python -c 'import json;print(json.load(open("'"$HOME"'/.local/share/gitreins/jobs/job-<id>.json"))["pid"])')" || echo "pid dead"
ls -l ~/.local/share/gitreins/jobs/job-<id>.log # missing or 0 bytes
# preview, back up, reap, re-dispatch
python scripts/reap_stale_jobs.py --task <TASK_ID> --dry-run
python scripts/reap_stale_jobs.py --task <TASK_ID>
gitreins judge <TASK_ID> --async
#!/usr/bin/env python3
"""Reap stale gitreins background judge jobs (dead pid + empty/missing log)."""
from __future__ import annotations
import argparse, os, shutil, sys, time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from engine.job_store import DEFAULT_JOB_DIR, job_dir, list_jobs, load_job, pid_alive, save_job
def _log_size(job_id, directory):
try:
return os.path.getsize(os.path.join(job_dir(directory), f"{job_id}.log"))
except OSError:
return -1
def _is_stale(job, directory):
if job.get("status") != "running":
return False, f"status={job.get('status')!r} is already terminal"
pid = job.get("pid")
if pid_alive(pid):
return False, f"pid {pid} is alive"
size = _log_size(job["id"], directory)
if size and size > 0:
return False, f"pid {pid} is dead but log is non-empty ({size} bytes)"
return True, f"pid {pid!r} is dead and the job has a {('0-byte log' if size == 0 else 'missing log')}"
def main(argv=None):
ap = argparse.ArgumentParser()
ap.add_argument("--task"); ap.add_argument("--job")
ap.add_argument("--jobs-dir", default=os.environ.get("GITREINS_JOB_DIR", DEFAULT_JOB_DIR))
ap.add_argument("--dry-run", action="store_true"); ap.add_argument("--no-backup", action="store_true")
args = ap.parse_args(argv)
if not args.task and not args.job:
ap.error("at least one of --task or --job is required")
directory = job_dir(args.jobs_dir)
if args.job:
job = load_job(args.job, directory=directory)
if not job:
print(f"Job not found: {args.job}"); return 1
candidates = [job]
else:
candidates = [j for j in list_jobs(directory=directory) if j.get("task_id") == args.task]
stale = []
for job in candidates:
ok, reason = _is_stale(job, directory)
print(f"{'STALE ' if ok else 'KEEP '} {job['id']} ({reason})")
if ok:
stale.append((job, reason))
if not stale:
print(f"Nothing to reap ({len(candidates)} candidate job(s))."); return 0
if args.dry_run:
print(f"Dry run: would reap {len(stale)} job(s); no files changed."); return 0
if not args.no_backup:
dest = f"{directory}.bak-{time.strftime('%Y%m%d-%H%M%S')}"
shutil.copytree(directory, dest); print(f"Backed up {directory} -> {dest}")
for job, reason in stale:
job.update(status="error", error=f"reaped by scripts/reap_stale_jobs.py: {reason}",
finished_at=time.time(), reaped_at=time.time())
save_job(job, directory=directory)
print(f"REAPED {job['id']} -> status=error")
print(f"Reaped {len(stale)} stale job(s). Re-dispatch with: gitreins judge <task> --async")
return 0
if __name__ == "__main__":
raise SystemExit(main())
1. Deterministic reproduction. A harness patches new_job_id() to return job-aaaa… then job-bbbb…, stubs the spawn, and calls _cmd_judge_async:
| printed poll id | log-path id | DIVERGES | stale record | |
|---|---|---|---|---|
| before fix | job-bbbb… |
job-aaaa… |
True |
job-bbbb… status=running, dead pid |
| after fix | job-aaaa… |
job-aaaa… |
False |
record and log share one id |
Before the fix a second dispatch for the task printed Async job already running: job-bbbb… — the permanent wedge.
2. Targeted tests (source checkout, patched tree):
PYTHONPATH=$PWD python3 -m pytest tests/test_cli.py \
-k "async or poll_job or log_path_id" -o addopts="" -p no:cacheprovider -q
# 9 passed
PYTHONPATH=$PWD python3 -m pytest tests/test_job_store.py -o addopts="" -p no:cacheprovider -q
# 14 passed
PYTHONPATH=$PWD python3 -m pytest tests/test_mcp_server.py \
-k "job or judge or async or resume or orphan" -o addopts="" -p no:cacheprovider -q
# 20 passed, 2 skipped
The two new regressions are test_async_log_path_id_matches_printed_poll_id (fails on the buggy build because log_id != printed_id) and test_poll_job_follows_divergent_log_path_id (fabricates a record under the log-path id while the printed id has none; the loop must return the terminal result instead of Job not found).
3. Reaper proof. Fabricate a running record with a reaped child pid and a 0-byte log, plus a live-pid record: the dry run labels the dead one STALE and the live one KEEP; the real run backs up the store and flips the stale record to error; a subsequent dry run reports Nothing to reap; and find_running_job no longer blocks because no running record remains.
Acceptance checklist
job['id'] equals the log-path id (new_job_id() called once).Job not found for the printed id, the loop re-checks the log-path id.running record is recoverable without touching the source (scripts/reap_stale_jobs.py --task <ID>), after a backup.# Evidence - Problem class: gitreins-async-judge-printed-poll-id-diverges-from-log-path-id - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-24T11:45:15.073Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "gitreins judge --async prints a poll id that differs from the log-path job id; the printed id can resolve to a job record whose child exits instantly, leaving a status=running record with a dead pid that wedges all future --async re-dispatches for the task. Recovery: confirm dead pid + empty job log, back up the jobs dir, reap with scripts/reap_stale_jobs.py --task <ID>, re-dispatch judge --async. The shipped poll-then-verify loop must treat a job-not-found on the PRINTED id while the LOG-PATH id exists as divergence, and re-check the log-path id too.", "environment": "linux", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "gitreins-async-judge-printed-poll-id-diverges-from-log-path-id", "provider": "openrouter", "solved_at": "2026-09-24T11:45:15.073Z", "version": "gitreins-0.14"}