diff --git a/.agents/skills/lib_py/loop/finalize.py b/.agents/skills/lib_py/delegation/finalize.py similarity index 93% rename from .agents/skills/lib_py/loop/finalize.py rename to .agents/skills/lib_py/delegation/finalize.py index 3445502..aa03fc1 100644 --- a/.agents/skills/lib_py/loop/finalize.py +++ b/.agents/skills/lib_py/delegation/finalize.py @@ -11,6 +11,13 @@ Consequence, and it is the useful one: an agent that declares nothing leaves no manifest, a missing manifest already holds, and the explicit-event path is therefore preserved for unsupported agents without any special case. +It lives in `lib_py.delegation`, not `lib_py.loop`, for the same reason the +transition engine does: the artifact it writes sits in the job's own directory, +and the CLI an agent calls to declare a finalization is a delegation entry point. +A frozen loop can bind delegation to a different tree than the parent package, so +delegation reaching sideways into `lib_py.loop` would mix trees -- the rule that +caught this, twice now. + writing report-final.md.tmp is being written stable_candidate the file stopped changing (a filter, never an authority) finalized atomically renamed, and this manifest exists diff --git a/.agents/skills/lib_py/delegation/publish_event.py b/.agents/skills/lib_py/delegation/publish_event.py index 0a65d16..e8b2b14 100755 --- a/.agents/skills/lib_py/delegation/publish_event.py +++ b/.agents/skills/lib_py/delegation/publish_event.py @@ -240,7 +240,7 @@ def main(argv=None) -> int: # written would invite exactly the partial-report adoption the contract # forbids. Failing here fails the publish, on purpose. if args.finalize_report: - from lib_py.loop import finalize # local import: optional dependency of this CLI + from lib_py.delegation import finalize # same package: no sibling hop (see __init__) try: side_effects = finalize.parse_side_effects(args.side_effects) diff --git a/.agents/skills/lib_py/loop/autocomplete.py b/.agents/skills/lib_py/loop/autocomplete.py index 4aa88af..a93a19a 100644 --- a/.agents/skills/lib_py/loop/autocomplete.py +++ b/.agents/skills/lib_py/loop/autocomplete.py @@ -19,7 +19,8 @@ import logging import os from typing import Any, Dict, List, Optional -from lib_py.loop import finalize, policy, store +from lib_py.delegation import finalize +from lib_py.loop import policy, store logger = logging.getLogger("loop.autocomplete") diff --git a/docs/contracts/issue-5/phase2b-autocomplete.md b/docs/contracts/issue-5/phase2b-autocomplete.md index 015bd18..e2446fb 100644 --- a/docs/contracts/issue-5/phase2b-autocomplete.md +++ b/docs/contracts/issue-5/phase2b-autocomplete.md @@ -77,6 +77,12 @@ `attempt_id` 승격은 열린 질문으로 남긴다. 승격하면 엔진에 2차 방어선이 생기지만 동결 계약을 바꿔야 하며, 현재는 필요하지 않다. `test_x03_the_record_carries_iteration_not_an_attempt_id`가 이 전제를 감시한다 — 레코드가 `attempt_id`를 갖게 되는 순간 실패하며, 그때가 재검토 시점이다. +## 9-1. 구현 중 정정 1건 — `finalize`의 패키지 위치 + +처음에는 `finalize.py`를 `lib_py/loop/`에 두었는데, 그러면 `publish_event`(=`lib_py.delegation`)가 **형제 패키지를 임포트**하게 되어 freeze 하에서 트리가 섞인다. Phase 1이 명문화하고 Phase 2a에서 엔진 배치를 정정하게 만들었던 바로 그 규칙이며, 기존 가드 테스트(`test_delegation_package_imports_no_sibling_lib_py_modules`)가 전체 스위트에서 이를 검출했다. + +**매니페스트는 job의 리포트 디렉터리에 쓰이고 에이전트가 호출하는 CLI는 delegation 진입점이므로, `finalize`는 `lib_py/delegation/`에 속한다.** `lib_py.loop`가 `lib_py.delegation`을 임포트하는 방향은 허용된다. 같은 규칙에 두 번 걸린 셈이며, 가드가 두 번 다 잡았다. + ## 10. Phase 3 인계 1. `preexec_fn`: 2b도 데몬을 스폰하지 않아 미노출 유지. 알림 flush는 기존 구독자 프로세스 안에서 수행한다. diff --git a/tests/fixtures/issue_5/phase1/help-publish_event.txt b/tests/fixtures/issue_5/phase1/help-publish_event.txt index 7af324a..add7bd2 100644 --- a/tests/fixtures/issue_5/phase1/help-publish_event.txt +++ b/tests/fixtures/issue_5/phase1/help-publish_event.txt @@ -3,7 +3,9 @@ usage: publish_event.py [-h] (--job JOB | --pick-pending) [--event {started,permission_required,progress,completed,error,cancelled}] [--detail DETAIL] [--data DATA] [--retained] [--registry-dir REGISTRY_DIR] [--attempts ATTEMPTS] - [-v] + [--finalize-report PATH] + [--side-effect KIND=REFERENCE] + [--attempt-id ATTEMPT_ID] [-v] Publish a Job event to MQTT @@ -19,4 +21,13 @@ optional arguments: --retained force retain=True (auto for completed/error) --registry-dir REGISTRY_DIR --attempts ATTEMPTS + --finalize-report PATH + atomically finalize this report and declare its + completion manifest before publishing + --side-effect KIND=REFERENCE + evidence for an external side effect this job + performed + --attempt-id ATTEMPT_ID + attempt this finalization belongs to (default: the + job's current iteration) -v, --verbose diff --git a/tests/test_issue5_phase1_delegation.py b/tests/test_issue5_phase1_delegation.py index 7a73580..c94ccd2 100644 --- a/tests/test_issue5_phase1_delegation.py +++ b/tests/test_issue5_phase1_delegation.py @@ -488,7 +488,13 @@ def test_importing_delegation_does_not_mutate_environment(mam_sandbox): def test_shim_help_output_matches_pre_move_capture(): - """The shim must be indistinguishable from the pre-move script at the CLI.""" + """The shim must be indistinguishable from the implementation at the CLI. + + The captures were taken before the phase 1 move to prove the move changed + nothing. They are refreshed only when the CLI is *intentionally* extended + (phase 2b added the finalization options to publish_event); the guard's job + is to catch drift nobody meant, which it still does. + """ for mod in ("publish_event", "registry", "job_subscriber"): golden = (FIXTURES / "help-{}.txt".format(mod)).read_text(encoding="utf-8") res = subprocess.run( diff --git a/tests/test_issue5_phase2b_autocomplete.py b/tests/test_issue5_phase2b_autocomplete.py index 1dbede7..d92419e 100644 --- a/tests/test_issue5_phase2b_autocomplete.py +++ b/tests/test_issue5_phase2b_autocomplete.py @@ -154,7 +154,7 @@ def _report(tmp_path, body="done\n", staged=False): def test_finalize_promotes_a_staged_report_atomically(tmp_path): """A reader must never see a half-written report as finalized.""" - from lib_py.loop import finalize + from lib_py.delegation import finalize path = _report(tmp_path, "final body\n", staged=True) assert not path.exists() and path.with_name(path.name + ".tmp").exists() @@ -165,7 +165,7 @@ def test_finalize_promotes_a_staged_report_atomically(tmp_path): def test_finalize_verifies_against_the_file_not_the_declaration(tmp_path): """The hash is recomputed, so a forged manifest cannot certify itself.""" - from lib_py.loop import finalize + from lib_py.delegation import finalize path = _report(tmp_path) finalize.finalize_report(path, "job1", 1) @@ -181,7 +181,7 @@ def test_finalize_verifies_against_the_file_not_the_declaration(tmp_path): def test_finalize_is_invalidated_by_a_rewritten_report(tmp_path): """A new body needs a new finalization; the old hash is void immediately.""" - from lib_py.loop import finalize + from lib_py.delegation import finalize path = _report(tmp_path, "first\n") finalize.finalize_report(path, "job1", 1) @@ -196,7 +196,7 @@ def test_finalize_is_invalidated_by_a_rewritten_report(tmp_path): ({"expected_attempt": 9}, "belongs to attempt"), ]) def test_finalize_rejects_a_manifest_for_a_different_job_or_attempt(tmp_path, kwargs, fragment): - from lib_py.loop import finalize + from lib_py.delegation import finalize path = _report(tmp_path) finalize.finalize_report(path, "job1", 1) args = {"job_id": "job1"} @@ -207,7 +207,7 @@ def test_finalize_rejects_a_manifest_for_a_different_job_or_attempt(tmp_path, kw def test_finalize_requires_evidence_for_every_declared_side_effect(tmp_path): """Partial evidence is no evidence: the work is not known to be complete.""" - from lib_py.loop import finalize + from lib_py.delegation import finalize path = _report(tmp_path) finalize.finalize_report(path, "job1", 1, @@ -220,7 +220,7 @@ def test_finalize_requires_evidence_for_every_declared_side_effect(tmp_path): def test_finalize_rejects_a_malformed_side_effect_pair(): - from lib_py.loop import finalize + from lib_py.delegation import finalize with pytest.raises(ValueError): finalize.parse_side_effects(["nokey"]) with pytest.raises(ValueError): @@ -228,7 +228,7 @@ def test_finalize_rejects_a_malformed_side_effect_pair(): def test_finalize_missing_manifest_is_a_refusal_not_a_crash(tmp_path): - from lib_py.loop import finalize + from lib_py.delegation import finalize d = tmp_path / "empty" d.mkdir() verdict = finalize.verify_manifest(d, "job1") @@ -239,7 +239,7 @@ def test_publish_event_finalizes_before_it_publishes(mam_sandbox, tmp_path, monk """The CLI surface an agent actually calls.""" monkeypatch.chdir(mam_sandbox) from lib_py.delegation import publish_event - from lib_py.loop import finalize + from lib_py.delegation import finalize reg = _reg(tmp_path) job_id = _make_job(reg) @@ -280,7 +280,8 @@ def test_publish_event_reports_a_bad_side_effect_instead_of_publishing(mam_sandb def _ready_job(tmp_path, mam_sandbox, *, backend="claude-code", opt_in=True, artifacts=None, body="report\n"): """A job in exactly the state that should auto-complete, plus its evidence.""" - from lib_py.loop import finalize, store + from lib_py.delegation import finalize + from lib_py.loop import store reg = _reg(tmp_path) job_id = _make_job(reg, agent=backend, expected_artifacts=artifacts or []) @@ -398,7 +399,8 @@ def test_g5_a_rewritten_report_stops_the_gate(mam_sandbox, tmp_path, monkeypatch def test_g5_missing_side_effect_evidence_stops_the_gate(mam_sandbox, tmp_path, monkeypatch): monkeypatch.chdir(mam_sandbox) monkeypatch.setenv("MAM_AUTO_COMPLETE", "1") - from lib_py.loop import autocomplete, finalize + from lib_py.loop import autocomplete + from lib_py.delegation import finalize job_id, reg, scenario, report_dir = _ready_job(tmp_path, mam_sandbox, artifacts=["git_push"])