0.2.4: искать воркера во всех профилях ~/.claude* (#5) #6

Merged
anton merged 1 commits from fix/jobs-across-profiles-5 into main 2026-09-20 20:50:28 +03:00
3 changed files with 42 additions and 5 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "foreman-supervisor",
"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.",
"version": "0.2.4",
"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. v0.2.4 finds a worker's state.json across ALL ~/.claude* profiles, own profile first: each profile keeps its own jobs directory, and a live worker resumed from a neighbouring profile was refused with \"worker not found\" while it was working.",
"author": {
"name": "oleks",
"email": "plugins@oleks.space"
+18 -3
View File
@@ -36,6 +36,21 @@ PLUGIN = Path.home() / ".claude" / "plugins" / "cache" / "kotkan-fleet" / "forem
JOBS = Path.home() / ".claude" / "jobs"
def job_state_path(short_id: str) -> Path | None:
"""state.json воркера — в jobs ТОГО профиля, из которого его подняли.
kotkan/claude-plugin-foreman-supervisor#5: у каждого профиля (`~/.claude`, `~/.claude-anton`,
`~/.claude-kotkan`) свой каталог `jobs`. Замер 20.09: реанимированная сессия арены легла в
`~/.claude-anton/jobs/4d81cb97`, и присмотр отказывался её видеть — «воркер с таким id не
найден» при живом, работающем воркере. Свой профиль проверяется первым.
"""
for jobs in [JOBS, *sorted(Path.home().glob(".claude*/jobs"))]:
p = jobs / short_id / "state.json"
if p.exists():
return p
return None
def _plugin_bin() -> Path:
"""Самая свежая установленная версия плагина — путь с версией внутри."""
if not PLUGIN.exists():
@@ -59,9 +74,9 @@ from foreman.observation import FactoryObservation # noqa: E402
def read_state(short_id: str) -> dict:
p = JOBS / short_id / "state.json"
if not p.exists():
sys.exit("нет %s — воркер с таким id не найден" % p)
p = job_state_path(short_id)
if p is None:
sys.exit("нет jobs/%s/state.json ни в одном профиле ~/.claude* — воркер не найден" % short_id)
return json.loads(p.read_text(encoding="utf-8"))
+22
View File
@@ -78,3 +78,25 @@ def test_unreadable_proc_does_not_crash(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setattr(attach.os, "readlink", boom)
stale = tmp_path / "из-state"
assert attach.worker_repo({"sessionId": SID, "cwd": str(stale)}) == stale
def test_job_found_in_another_profile(monkeypatch, tmp_path: Path) -> None:
"""Воркер, поднятый из другого профиля (~/.claude-anton), должен находиться.
kotkan/claude-plugin-foreman-supervisor#5: у каждого профиля свой jobs; присмотр отказывал
живому воркеру «id не найден», потому что смотрел только в ~/.claude/jobs.
"""
other = tmp_path / ".claude-anton" / "jobs" / "4d81cb97"
other.mkdir(parents=True)
(other / "state.json").write_text('{"state": "working"}', encoding="utf-8")
monkeypatch.setattr(attach.Path, "home", staticmethod(lambda: tmp_path))
monkeypatch.setattr(attach, "JOBS", tmp_path / ".claude" / "jobs")
assert attach.job_state_path("4d81cb97") == other / "state.json"
def test_unknown_id_is_none(monkeypatch, tmp_path: Path) -> None:
"""Несуществующий воркер — None, а не первый попавшийся путь."""
(tmp_path / ".claude" / "jobs").mkdir(parents=True)
monkeypatch.setattr(attach.Path, "home", staticmethod(lambda: tmp_path))
monkeypatch.setattr(attach, "JOBS", tmp_path / ".claude" / "jobs")
assert attach.job_state_path("нетакого") is None