diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 86dee61..778a3e4 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -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//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//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" diff --git a/bin/foreman-attach.py b/bin/foreman-attach.py index 85de8da..20186b2 100755 --- a/bin/foreman-attach.py +++ b/bin/foreman-attach.py @@ -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")) diff --git a/tests/test_worker_repo.py b/tests/test_worker_repo.py index c0d8b3e..acfc640 100644 --- a/tests/test_worker_repo.py +++ b/tests/test_worker_repo.py @@ -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