0.2.3: судить дерево, ГДЕ воркер работает, а не откуда его запустили (#3) #4

Merged
anton merged 1 commits from fix/worktree-from-live-process-3 into main 2026-09-20 20:34:29 +03:00
3 changed files with 123 additions and 7 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "foreman-supervisor",
"version": "0.2.2",
"description": "Semantic supervision for our own Claude agents. Wraps thruwire/foreman (MIT) — a runtime that watches a coding agent with a bounded observation (job, git status, diff, output tails, repository instructions), scores ten independent yes/no questions, and lets a deterministic Python policy decide continue / stop / retry / verify / finish / escalate. This plugin replaces both of upstream's paid dependencies: the worker is `claude -p` (Worker protocol, steer() returns False so the policy degrades to stop/retry, per upstream's own docs/workers.md), and the judge is the free-model gateway (free-worker.sh) instead of TypeSafe Jev, so a supervised run costs no paid tokens. Adds two things measured here rather than assumed: the observation carries commits since the run's base commit (our agents commit, which empties `git diff` exactly when the worker does the right thing), and warning dimensions stay muted until the factory has produced evidence (a small judge escalated a healthy run at 25 seconds, before the worker touched a file). Ships foreman-calibrate.py, which replays thresholds over the runs' own JSONL timelines — the calibration upstream says it has not done. v0.2.0 adds bin/foreman-attach.py: supervision of a background worker someone ELSE already started (worker.sh / `claude agents`), which upstream cannot do because its runtime launches the worker itself. The observation is rebuilt from traces the worker already leaves — its state.json, the commits in its worktree, the tail of its transcript — so nothing is injected into the worker. Measured here on a live worker: warning dimensions read stuck 0.80 / off-track 0.80 at 362 s and 0.05 / 0.10 at 766 s on the same run with no commit in between, which is why warmup and confirmation matter more than threshold tuning. Also records that upstream's policy gates STEER_WORKER on `codex_backend == \"app-server\"` — the name of another backend rather than whether the worker accepts direction — so a steerable third-party worker can only ever be killed; the adapter works around it by printing the steering message for the lead to deliver.",
"version": "0.2.3",
"description": "Semantic supervision for our own Claude agents. Wraps thruwire/foreman (MIT) — a runtime that watches a coding agent with a bounded observation (job, git status, diff, output tails, repository instructions), scores ten independent yes/no questions, and lets a deterministic Python policy decide continue / stop / retry / verify / finish / escalate. This plugin replaces both of upstream's paid dependencies: the worker is `claude -p` (Worker protocol, steer() returns False so the policy degrades to stop/retry, per upstream's own docs/workers.md), and the judge is the free-model gateway (free-worker.sh) instead of TypeSafe Jev, so a supervised run costs no paid tokens. Adds two things measured here rather than assumed: the observation carries commits since the run's base commit (our agents commit, which empties `git diff` exactly when the worker does the right thing), and warning dimensions stay muted until the factory has produced evidence (a small judge escalated a healthy run at 25 seconds, before the worker touched a file). Ships foreman-calibrate.py, which replays thresholds over the runs' own JSONL timelines — the calibration upstream says it has not done. v0.2.0 adds bin/foreman-attach.py: supervision of a background worker someone ELSE already started (worker.sh / `claude agents`), which upstream cannot do because its runtime launches the worker itself. The observation is rebuilt from traces the worker already leaves — its state.json, the commits in its worktree, the tail of its transcript — so nothing is injected into the worker. Measured here on a live worker: warning dimensions read stuck 0.80 / off-track 0.80 at 362 s and 0.05 / 0.10 at 766 s on the same run with no commit in between, which is why warmup and confirmation matter more than threshold tuning. Also records that upstream's policy gates STEER_WORKER on `codex_backend == \"app-server\"` — the name of another backend rather than whether the worker accepts direction — so a steerable third-party worker can only ever be killed; the adapter works around it by printing the steering message for the lead to deliver. v0.2.3 resolves the worker's tree from the LIVE session process (/proc/<pid>/cwd, skipping the bg-pty-host wrapper whose cwd is where the session was launched) instead of trusting state.json's starting cwd: measured on a live worker whose state.json said /home/a/pro/siege-lines while the session worked in /data/siege-arena364, so the judge read commits from the wrong checkout and printed \"no commits\" at the moment the worker was opening a PR.",
"author": {
"name": "oleks",
"email": "plugins@oleks.space"
+41 -5
View File
@@ -158,8 +158,44 @@ def read_briefs(paths: list[str], repo: Path, budget: int = 12_000) -> str:
return "\n\n".join(parts)
def worker_repo(state: dict) -> Path:
"""Рабочее дерево воркера — по ЖИВОМУ процессу, а не по стартовому `cwd` из state.json.
kotkan/claude-plugin-foreman-supervisor#3: у воркера `6ce5556f` state.json отдавал
`/home/a/pro/siege-lines`, а сессия жила в `/data/siege-arena364` — судья строил наблюдение
по ЧУЖОМУ чекауту и печатал «коммиты: нет» ровно тогда, когда воркер делал PR. Коммиты и есть
главное свидетельство нормальной работы (грабля №1 скилла), так что промах по дереву ослепляет
судью именно на здоровом воркере. Процесс сессии узнаётся по `<sessionId>.jsonl` в cmdline.
"""
sid = str(state.get("sessionId") or "")
if sid:
try:
out = subprocess.run(
["ps", "-eo", "pid=,args="], capture_output=True, text=True, timeout=30
).stdout
except (OSError, subprocess.TimeoutExpired):
out = ""
for line in out.splitlines():
if "%s.jsonl" % sid not in line:
continue
# ⚠️ Транскрипт сессии стоит в cmdline ДВУХ процессов: обёртки `claude bg-pty-host`
# и самого интерпретатора. У обёртки cwd — тот каталог, откуда сессию ЗАПУСКАЛИ
# (главный чекаут), у интерпретатора — где она РАБОТАЕТ. Замер 20.09: 871379
# bg-pty-host cwd=/home/a/pro/siege-lines, 871385 сама сессия cwd=/data/siege-arena364.
if "bg-pty-host" in line or "bg-spare" in line:
continue
pid = line.strip().split(None, 1)[0]
try:
cwd = os.readlink("/proc/%s/cwd" % pid)
except OSError:
continue
if Path(cwd).is_dir():
return Path(cwd)
return Path(state.get("worktreePath") or state.get("cwd") or ".")
def build_observation(short_id: str, state: dict, base: str, briefs: str = "") -> FactoryObservation:
repo = Path(state.get("worktreePath") or state.get("cwd") or ".")
repo = worker_repo(state)
status = str(state.get("state", "unknown"))
started = state.get("createdAt") or ""
elapsed = 0.0
@@ -267,7 +303,7 @@ def assess(worker_id: str, base: str, channel: str | None, attempts: int,
warmup_seconds: float, briefs: list[str] | None = None) -> tuple[dict, str, str, dict, bool]:
"""Один замер: наблюдение → бесплатный судья → решение. Возвращает (state, decision, why, scores, warmup_ok)."""
state = read_state(worker_id)
repo = Path(state.get("worktreePath") or state.get("cwd") or ".")
repo = worker_repo(state)
obs = build_observation(worker_id, state, base, read_briefs(briefs or [], repo))
workers = obs.active_workers or obs.worker_history
has_commits = bool(workers and str(workers[0].get("commits_since_base", "")).strip())
@@ -366,7 +402,7 @@ def main(argv: list[str] | None = None) -> int:
return watch(args)
state = read_state(args.worker_id)
_repo = Path(state.get("worktreePath") or state.get("cwd") or ".")
_repo = worker_repo(state)
obs = build_observation(args.worker_id, state, args.base, read_briefs(args.brief, _repo))
elapsed = obs.elapsed_factory_seconds
@@ -376,7 +412,7 @@ def main(argv: list[str] | None = None) -> int:
)
warmup_ok = has_commits or elapsed >= args.warmup_seconds
repo = Path(state.get("worktreePath") or state.get("cwd") or ".")
repo = worker_repo(state)
scores = asyncio.run(judge_once(obs, repo, args.judge_channel, args.judge_attempts))
decision, why = decide(scores, warmup_ok)
@@ -385,7 +421,7 @@ def main(argv: list[str] | None = None) -> int:
"warmup_ok": warmup_ok, "scores": scores}, ensure_ascii=False, indent=1))
else:
print("воркер %s (%s)" % (args.worker_id, state.get("name", "")))
print("дерево %s" % (state.get("worktreePath") or state.get("cwd")))
print("дерево %s" % worker_repo(state))
print("возраст %.0f с · коммиты от %s: %s" % (elapsed, args.base, "есть" if has_commits else "нет"))
for k in ("implementation_complete", "meaningful_progress", "worker_stuck",
"work_off_track", "agents_md_drift", "needs_verification",
+80
View File
@@ -0,0 +1,80 @@
"""Тесты выбора рабочего дерева: судить надо дерево, ГДЕ воркер работает, а не откуда запущен.
Зачем именно так (kotkan/claude-plugin-foreman-supervisor#3): у живого воркера `6ce5556f`
state.json отдавал `/home/a/pro/siege-lines`, а сессия жила в `/data/siege-arena364`. Судья строил
наблюдение по чужому чекауту и печатал «коммиты: нет» ровно тогда, когда воркер делал PR — то есть
слеп на главном свидетельстве здоровой работы. Вторая ловушка: транскрипт стоит в cmdline ДВУХ
процессов, и у обёртки `bg-pty-host` cwd — каталог запуска, а не работы.
~/pro/foreman/.venv/bin/python -m pytest tests/test_worker_repo.py -q
"""
from __future__ import annotations
import importlib.util
import os
import sys
from pathlib import Path
MODULE_PATH = Path(__file__).resolve().parents[1] / "bin" / "foreman-attach.py"
spec = importlib.util.spec_from_file_location("foreman_attach", MODULE_PATH)
assert spec and spec.loader
attach = importlib.util.module_from_spec(spec)
sys.modules["foreman_attach"] = attach
spec.loader.exec_module(attach)
SID = "6ce5556f-3627-47f2-bb56-e2704a613fe3"
def _fake_ps(monkeypatch, lines: str) -> None:
class R:
stdout = lines
monkeypatch.setattr(attach.subprocess, "run", lambda *a, **k: R())
def test_live_process_wins_over_stale_state(monkeypatch, tmp_path: Path) -> None:
"""Дерево берётся из /proc живой сессии, а не из стартового cwd в state.json."""
live = tmp_path / "worktree"
live.mkdir()
_fake_ps(monkeypatch, "871385 /path/claude --resume /p/%s.jsonl\n" % SID)
monkeypatch.setattr(attach.os, "readlink", lambda p: str(live))
got = attach.worker_repo({"sessionId": SID, "cwd": str(tmp_path / "главный-чекаут")})
assert got == live
def test_bg_pty_host_wrapper_is_skipped(monkeypatch, tmp_path: Path) -> None:
"""У обёртки cwd — каталог ЗАПУСКА; берём процесс самой сессии, даже если обёртка идёт первой."""
launched_from = tmp_path / "главный-чекаут"
working_in = tmp_path / "worktree"
launched_from.mkdir()
working_in.mkdir()
_fake_ps(
monkeypatch,
"871379 claude bg-pty-host --bg-pty-host /tmp/x.sock -- /v/2.1.278 --resume /p/%s.jsonl\n"
"871385 /v/2.1.278 --resume /p/%s.jsonl\n" % (SID, SID),
)
monkeypatch.setattr(
attach.os, "readlink",
lambda p: str(launched_from) if p.endswith("871379/cwd") else str(working_in),
)
assert attach.worker_repo({"sessionId": SID, "cwd": str(launched_from)}) == working_in
def test_falls_back_to_state_when_process_gone(monkeypatch, tmp_path: Path) -> None:
"""Воркер уже умер — присмотр не падает, дерево берётся из state.json."""
_fake_ps(monkeypatch, "")
stale = tmp_path / "из-state"
assert attach.worker_repo({"sessionId": SID, "cwd": str(stale)}) == stale
def test_unreadable_proc_does_not_crash(monkeypatch, tmp_path: Path) -> None:
"""/proc может исчезнуть между ps и readlink — это не повод ронять надзор."""
_fake_ps(monkeypatch, "871385 /v/2.1.278 --resume /p/%s.jsonl\n" % SID)
def boom(_p):
raise OSError("процесс исчез")
monkeypatch.setattr(attach.os, "readlink", boom)
stale = tmp_path / "из-state"
assert attach.worker_repo({"sessionId": SID, "cwd": str(stale)}) == stale