fix(agents): completed 를 확인한 쪽이 세션 ID 를 등록하고, agy 는 다른 작업 공간의 대화를 받지 않는다 (Issue #40) #67
No files matched your search
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20261020T133532Z-a1684a",
|
||||||
|
"base_sha": "ff8b381520ae04e80c73a9e18bfad73d47e853c9",
|
||||||
|
"head_sha": "feccfd5e0356c8acf0438bc68bce7ea36ce2588e",
|
||||||
|
"tree_sha": "0f0f33cc22f5e492a0ddfccb6bac5be888c10d1a",
|
||||||
|
"review_commit": "feccfd5e0356c8acf0438bc68bce7ea36ce2588e",
|
||||||
|
"reviewer": "planner-reviewer-claude-01",
|
||||||
|
"job_id": "5bdb3a81",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-10-20T15:51:16Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Issue #40 리뷰 — Class A 세션 ID 자동 확정, agy 다른 작업 공간 ID 오등록 방지 (job 5bdb3a81)
|
||||||
|
|
||||||
|
- 리뷰어: planner-reviewer-claude-01
|
||||||
|
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261020T133532Z-a1684a/1`
|
||||||
|
- identity: HEAD `feccfd5e0356c8acf0438bc68bce7ea36ce2588e`, tree `0f0f33cc22f5e492a0ddfccb6bac5be888c10d1a` 가 일치한다. 작업 트리는 clean 이고 `.mam` 은 없다.
|
||||||
|
- 범위: base `ff8b381`..head 1커밋, 19개 파일. 판정은 base..head 전체에 대한 것이다.
|
||||||
|
- 기준 문서: 이슈 본문과 댓글 전체(`issue40.txt`)를 끝까지 읽고 합의 사항과 대조했다.
|
||||||
|
- 환경: macOS, Python 3.9.6.
|
||||||
|
- 실제 에이전트 홈은 `ls` 와 `lsof -p <agy pid>`(프로세스 테이블 조회)로만 봤다. 파일 자체를 연 적은 없다.
|
||||||
|
- 재현과 테스트는 모두 scratchpad 의 임시 HOME, mock herdr, git archive 사본에서 했다.
|
||||||
|
|
||||||
|
## 결론
|
||||||
|
|
||||||
|
합의된 설계(PR #41 리뷰·이슈 댓글)대로 구현됐고, 완료 조건 1-4 가 테스트로 고정됐다. 각 조건의 테스트는 base 에서 실패하고 head 에서 통과한다. 이 변경이 만든 회귀는 찾지 못했다. 남은 것은 테스트 보강 권고와 운영 위험 기록이다.
|
||||||
|
|
||||||
|
## 설계 확인 (합의 대비)
|
||||||
|
|
||||||
|
**어댑터는 사실과 판정만 제공한다.**
|
||||||
|
- `id_lifecycle`: 기본값은 `POST_DISCOVERED`. claude·grok 만 `PRE_ASSIGNED` 이다(`base.py:378-381`, `claude.py:188`, `grok.py:18`).
|
||||||
|
- `capture_active_session_id`(`base.py:383-401`): `discover()` 후보 가운데 `verify_session_uuid` 를 통과하고 다른 행이 기록하지 않은 것이 정확히 하나일 때만 `captured` 를 돌려준다. 그 밖에는 이유가 담긴 결과(`capture_result`)를 돌려준다. 읽기 전용이다.
|
||||||
|
- 설정 추가나 리팩터링은 없다.
|
||||||
|
|
||||||
|
**agy 경로 (`agy.py`)**
|
||||||
|
- `verify_artifact` discover 모드(`:97-106`): `last_conversations.json` 이 그 id 를 다른 cwd 에 매핑하면 거절한다(workspace_key 로 비교). 다른 행이 기록한 id 는 캐시가 일치해도 항상 거절한다. 이제 캐시 일치가 이 검사를 건너뛰지 못한다.
|
||||||
|
- `capture_active_session_id`(`:129-152`): 후보는 `last_conversations.json[cwd]` 하나뿐이다. 그 대화의 presence lock 이나 DB 를 그 행의 pane 프로세스 또는 그 자손이 열고 있어야 확정한다(`_owner_check`, `:154-183`, lsof/ps). 증명하지 못하면 확정하지 않는다.
|
||||||
|
- 이 근거를 실제로 확인했다. 이 Mac 에서 실행 중인 agy 3개(`--conversation <id>`)는 모두 자기 `presence/<id>.lock` 을 열고 있다(`lsof -p`).
|
||||||
|
- `_holder_pids`·`_process_parents` 를 macOS 에서 직접 확인했다. 아무도 안 연 파일은 `[]`, 연 파일은 보유 pid 를 돌려준다(0.13s). ps 표에는 자기 pid 가 들어 있다.
|
||||||
|
|
||||||
|
**reconcile drift C (agy·hermes·opencode·codex) 가 하나로 합쳐졌다** (`reconcile.sh:778-811`).
|
||||||
|
- 네 에이전트가 모두 `autopin.capture` 를 거쳐 같은 어댑터 판정을 쓴다. agy 의 `*.db` 전체 glob 은 사라졌다.
|
||||||
|
- 기존 안전장치는 그대로다: 후보가 둘 이상이면 C-ambiguous, 뷰포트가 다르면(rc 1) C-warn 으로 pin 안 함, 뷰포트 확인이 불가하면(그 밖의 rc) degraded pin.
|
||||||
|
- 다른 행이 가진 id 배제는 더 엄격해졌다. 예전에는 같은 cwd 의, 멈추지 않은, 같은 에이전트 행만 봤다. 지금은 다른 모든 행의 모든 own key 를 본다(`autopin.claimed_elsewhere`).
|
||||||
|
- 지운 hermes·opencode·codex SQL 은 각 어댑터의 `discover()` 와 같은 범위(cwd·epoch·`parent_id IS NULL`)다. codex 는 `CODEX_HOME` 을 따른다(`codex.py:65`).
|
||||||
|
|
||||||
|
**수신 측 처리기 (`lib_py/agents/autopin.py`)**
|
||||||
|
- 호출 지점:
|
||||||
|
- 래퍼: `finalize_unfinished` 뒤 `completed` 일 때(`multi-agent-mux-delegate-job:454-458`). stderr 로만 출력하므로 마지막 stdout 줄(audit-log 경로) 계약은 그대로다.
|
||||||
|
- `create --onboard` 의 completed 분기(`create_session.sh:754-756`)
|
||||||
|
- 루프 `wait_for_job` 직후의 서브프로세스(`runner.py:679, 690-705`, G9 경계를 지킴)
|
||||||
|
- 공통 헬퍼 `delegate_autopin_completed`(`lib.sh:2470-2480`)는 `|| true` 라 잡 결과와 종료 코드를 바꾸지 않는다. `set -u` 에서 쓰는 변수(`AGENT_SESSIONS_YAML`, `_disp_tok`, `DELEGATE_JOB_TOKEN`, `task_reg_dir`)는 모두 정의돼 있다.
|
||||||
|
- 중복 방지:
|
||||||
|
- 키는 `(job_id, dispatch_token, attempt_id)` 이다. 잡 디렉터리의 `autopin.lock`(flock)과 `autopin.json` 마커를 쓴다. 마커는 원자 교체로 쓴다.
|
||||||
|
- 쓰기는 `atomic_dump_yaml` 의 mutation 으로 한다. 이 모듈의 `_MUTATION` 을 `sys.modules[__name__]` 로 찾아 `python -m` 에서도 같은 객체를 쓴다. 쓸 것이 없으면 `SystemExit(0)` 으로 쓰지 않고 끝나며, `atomic_yaml.py:195` 의 finally 가 정리한다.
|
||||||
|
- 건너뛰는 잡: remote, human, completed 가 아닌 잡, 오래된 token. pre-assigned 와 이미 id 가 있는 행은 쓰기 없이 끝난다.
|
||||||
|
- 메타데이터: pin 은 `session_id_source=discovered`, `session_id_verified=true` 를 함께 기록한다. reconcile 의 `_pin_and_verify_resume` 도 같은 `pin_row` 를 쓴다. 이슈 댓글이 짚은 불일치(선택 항목)가 해결됐다.
|
||||||
|
- publish_event.py 는 바뀌지 않았다. 에이전트는 제어 평면에 쓰지 않는다(`test_call_sites_and_publisher_boundary`).
|
||||||
|
- 잠금 안에서 뷰포트 확인과 resume `--dry-run` 을 서브프로세스로 실행한다. 예전 reconcile 도 mutation 안에서 똑같이 했다. `load_state_json` 은 YAML flock 없이 SQLite 를 읽으므로 교착은 없다.
|
||||||
|
|
||||||
|
## 완료 조건별 테스트 (base 실패 / head 통과)
|
||||||
|
|
||||||
|
`tests/test_issue40_auto_pin.py`(27개)를 base 사본에 넣어 실행하면 **16 failed, 13 errors**(errors 는 base 에 없는 `agy._holder_pids` 를 패치하는 fixture 때문), **41 passed**(issue64 포함) 이다. head 에서는 issue40+issue64 **70 passed**.
|
||||||
|
|
||||||
|
| 조건 | 테스트 | base 결과 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `test_agy_verify_rejects_id_cached_for_another_workspace` | `True is False` |
|
||||||
|
| 1 | `test_reconcile_does_not_pin_another_workspaces_agy_conversation` | `'b4000002…' is None` — 실제로 B 의 id 를 A 행에 기록함 |
|
||||||
|
| 1 | `test_autopin_does_not_pin_…` | — |
|
||||||
|
| 1 | issue64 `:407` 단언 | `is False` 로 바뀜 |
|
||||||
|
| 2 | `test_completed_onboarding_pins_agy_without_daemon`(MAM_NO_SUBSCRIBER_DAEMON=1, B 가 더 늦게 갱신) | — |
|
||||||
|
| 2 | `test_completed_first_job_pins_opencode_codex_hermes`[3] | 실패 |
|
||||||
|
| 2 | `test_reconcile_pins_agy_owned_by_the_pane`, `test_lib_sh_helper_pins_through_the_cli`, `test_create_onboard_pins_without_daemon` | 실패 |
|
||||||
|
| 3 | `test_wrapper_and_loop_recovery_pin_once`(capture 1회, YAML mtime 불변), `test_loop_recovery_pins_when_the_wrapper_did_not` | 실패 |
|
||||||
|
| 3 | `test_ambiguous_candidates_leave_reason_without_pin`(행에 `autopin ambiguous (job amb): 2 candidates`) | 실패 |
|
||||||
|
| 4 | `test_preassigned_and_existing_ids_are_untouched`, `test_ineligible_jobs_pin_nothing`, `test_stale_dispatch_token_pins_nothing` | — |
|
||||||
|
|
||||||
|
- reconcile 테스트는 세션을 mock herdr state 에 running 으로 등록한다(`_mock_agent`). 그래서 drift A 에서 멈추지 않고 실제로 drift C 까지 간다. base 에서 B 의 id 가 기록되는 것이 그 증거다.
|
||||||
|
|
||||||
|
뮤테이션: git archive 사본에서 issue40 모듈만 돌렸다.
|
||||||
|
|
||||||
|
| 뮤테이션 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| 다른 cwd 거절 제거 | 1 failed |
|
||||||
|
| 중복 마커 무시 | 2 failed |
|
||||||
|
| 모호 판정 제거 | 1 failed |
|
||||||
|
| agy 소유 확인 제거 | 4 failed(같은 cwd 형제 교차 pin 포함) |
|
||||||
|
| 이미 id 가 있는 행 보호 제거 | 1 failed |
|
||||||
|
| remote·human 제외 제거 | 2 failed |
|
||||||
|
| 래퍼 호출 제거 | 1 failed(정적 검사만 잡음) |
|
||||||
|
| **pre-assigned 보호 제거** | **실패 0** (아래 T1) |
|
||||||
|
|
||||||
|
## 관련 테스트 묶음
|
||||||
|
|
||||||
|
- 조건: `env -i`, 임시 HOME, `XDG_DATA_HOME=<scratchpad>`, basetemp 는 scratchpad.
|
||||||
|
- 대상 18개 모듈: issue40, issue64, a4, issue10_13, issue31, issue50_dispatch, issue55, issue57, issue60_linux_fixes, issue60_opencode_v2, tier1, tier2, uuid_target, orc_onboard, issue21_phase2_loop_a, issue5_phase3_loop, issue21_worktree_prereqs, issue17_workspace_root_isolation.
|
||||||
|
- 결과: **577 passed, 1 skipped, 3 failed**, 18분.
|
||||||
|
- 실패 3개(`test_issue21_phase2_loop_a::test_w10_…[install.sh|install_mam.sh]`, `test_w11_…`)는 환경 탓이다.
|
||||||
|
- 설치 스크립트가 시스템 python3 의 PyYAML 을 찾는데, 이 Mac 의 PyYAML 은 사용자 site(`~/Library/Python/3.9/...`)에만 있다. 내가 HOME 을 임시로 바꿔서 보이지 않았다.
|
||||||
|
- base 사본에서도 같은 3개가 같은 이유로 실패하고, 설치 스크립트는 이 diff 에 없다. 판정 근거로 쓰지 않았다.
|
||||||
|
- skip 1개: PATH 에 opencode 가 없는 실바이너리 계약 테스트.
|
||||||
|
- 부작용 확인:
|
||||||
|
- 실제 `~/.gemini/antigravity-cli/conversations`·`~/.hermes`·`~/.local/share/opencode` 목록은 전후가 같았다.
|
||||||
|
- scratchpad XDG 디렉터리는 실행 뒤에도 비어 있었다.
|
||||||
|
- #55 가드 오탐은 없었다.
|
||||||
|
- 린트:
|
||||||
|
- `git diff --check` 는 깨끗하다.
|
||||||
|
- 바뀐 셸 4개(lib.sh, create_session.sh, 래퍼, reconcile.sh)가 `/bin/bash -n`(3.2)을 통과한다.
|
||||||
|
- 바뀐 Python 은 3.9 로 파싱된다. `/usr/bin/python3` 로 `lib_py.agents.autopin`·`lib_py.loop.runner` 를 import 할 수 있다.
|
||||||
|
|
||||||
|
## 비차단
|
||||||
|
|
||||||
|
- **T1. pre-assigned 보호(`autopin.capture` 의 `id_lifecycle != POST_DISCOVERED`)는 테스트로 고정되지 않았다.**
|
||||||
|
- 테스트의 claude·grok 행은 이미 id 가 있어서 `already-set` 에서 먼저 끝난다.
|
||||||
|
- id 가 비어 있는 claude 행이 생기면(예: 행을 손으로 고침), 이 보호 없이는 기본 capture 가 claude `discover()` 로 남의 transcript 를 pin 할 수 있다.
|
||||||
|
- 권고: `claude_session_id_own: None` 인 claude 행에서 `pre-assigned` 가 나오는지 확인하는 한 줄을 추가한다.
|
||||||
|
- **T2.** 래퍼 진입점은 정적 검사(`test_call_sites_…`)로만 고정됐다. 헬퍼(`lib.sh`)와 CLI, create, 루프는 실행으로 검증됐다.
|
||||||
|
- **R1. agy 의 pin 은 이제 세 가지에 기댄다**(reconcile drift C 도 같다):
|
||||||
|
- (a) lsof·ps 가 있어야 한다
|
||||||
|
- (b) 행에 pane pid 가 있어야 한다
|
||||||
|
- (c) agy 가 presence lock 을 열고 있어야 한다
|
||||||
|
- (c) 는 resume 된 agy 3개에서 확인했다. 새로 띄운 agy 의 첫 턴 직후는 실제 agy 를 띄울 수 없어 확인하지 못했다.
|
||||||
|
- lsof 가 없는 Linux 에서는 agy 가 영영 pin 되지 않는다. 예전 glob 경로는 pin 했다(오등록 위험을 감수하고). autopin 경로는 행에 `autopin unverifiable-owner … lsof is unavailable` 을 남기지만, reconcile 은 아무것도 남기지 않는다.
|
||||||
|
- 설계상 의도된 안전 쪽 선택이다. 그래도 Linux 의존성으로 문서에 한 줄 적어 두기를 권한다.
|
||||||
|
- **R2.** 중복 마커는 실패 결과도 기록한다. 래퍼 시점에 `no-candidate` 였던 잡은 루프 복구 경로에서 다시 시도되지 않는다. 다음 잡의 completed 나 reconcile 이 다시 시도한다. 합의("job/token/attempt 단위로 중복 금지")와 맞다.
|
||||||
|
- **범위 밖(보고만).** stop 의 캡처 경로는 정리하지 않았다(이슈가 "크면 하지 말고 보고만"이라고 함).
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Issue #40 구현 계획 정교화 — agy 동일 cwd 오등록 방지
|
||||||
|
|
||||||
|
## 반영한 이의제기와 결론
|
||||||
|
|
||||||
|
Creator의 이의는 타당합니다. `last_conversations.json`은 cwd를 키로 UUID 하나만 저장하므로 그 값을 후보로 삼는 것만으로는 agy 후보가 최대 하나가 됩니다. 따라서 일반적인 `candidate_count > 1` 검사는 이 경우 작동하지 않습니다. 이 캐시는 대화를 연 시점에 갱신되며 현재 대화의 소유자를 증명하지도 않습니다. 특히 `agy.verify_artifact()`의 형제 UUID 검사가 `cache_match`가 아닐 때만 실행된다면, cache hit만 사용하는 자동 pin 경로에서 형제 배제는 무력화됩니다. 같은 cwd의 동시 세션은 실제 지원 시나리오이므로 epoch와 viewport 확인도 대화 소유권을 대신할 수 없습니다.
|
||||||
|
|
||||||
|
이에 따라 agy는 **캐시 일치만으로 자동 pin하지 않습니다.** 캐시는 검사할 UUID를 제안하는 데만 쓰고, 해당 UUID의 열린 presence lock을 보유한 프로세스가 현재 행의 pane 프로세스이거나 그 자손인지 확인해야 합니다. 확인할 수 없거나 소유자가 불일치하면 fail closed로 미확정 처리합니다. 같은 cwd에 다른 non-stopped agy 세션이 있으면 이 소유권 증명이 필수이며, 증명 없이 pin하지 않습니다. 형제 UUID 배제는 cache hit 여부와 무관하게 항상 적용합니다.
|
||||||
|
|
||||||
|
## 현재 기준선과 범위
|
||||||
|
|
||||||
|
대상 워크트리 `/Users/godopu16/PuKi/laa/canary_projects/mam-wt-issue40`, 브랜치 `fix/issue-40-auto-pin`은 base/head 모두 `ff8b381520ae04e80c73a9e18bfad73d47e853c9`이며 변경 사항이 없습니다. 본 작업은 계획서 갱신이며 코드·브랜치를 수정하지 않습니다. 구현은 이 워크트리에서 진행합니다.
|
||||||
|
|
||||||
|
목표는 Class A 어댑터의 첫 로컬 `completed` 작업을 통해 미등록 세션 ID를 안전하게 확정하는 것입니다. 어댑터는 수명주기와 읽기 전용 capture 사실을 제공하고, completed를 검증한 로컬 수신 측만 YAML/SQLite를 갱신합니다. `publish_event.py`는 계속 이벤트 발행만 맡습니다. wrapper subscriber 종결 처리와 `loop.runner.wait_for_job()` 복구 경로는 같은 handler를 호출하고 `(job_id, dispatch_token, attempt_id)`로 중복을 제거합니다. remote/human job 및 pre-assigned ID는 제외합니다.
|
||||||
|
|
||||||
|
## 정교화한 설계
|
||||||
|
|
||||||
|
1. **Capture 결과와 lifecycle 계약**
|
||||||
|
|
||||||
|
`BaseAgentAdapter`에 `id_lifecycle` 및 `capture_active_session_id(ctx)` 계약을 추가합니다. 결과는 ID와 구체적 상태/사유를 포함해 `captured`, `no-candidate`, `ambiguous(shared-cwd)`, `unverifiable-owner`, `owner-mismatch`, `sibling-claimed`, `stale`, `wrong-workspace` 등을 구분합니다. Claude/Grok은 `pre_assigned`로 선언하고 자동 capture하지 않습니다. agy·opencode·codex·hermes는 `post_discovered` 대상입니다. 추가/기존 어댑터가 결과를 명확히 전달하도록 하며, `None` 하나로 모든 실패 원인을 뭉개지 않습니다.
|
||||||
|
|
||||||
|
2. **공용 handler와 저장 경계**
|
||||||
|
|
||||||
|
`lib_py/agents/autopin.py`와 같은 공용 경로에 판정·pin handler를 둡니다. 검증된 local `completed` job, 세션 row, transport, agent, dispatch token 및 attempt를 확인하고, 기존 ID/lifecycle을 존중한 뒤 adapter capture, workspace/epoch 검증, sibling 배제, viewport 확인을 수행합니다. 판정 코드는 저장소를 직접 우회하지 않고 기존 `atomic_dump_yaml` 경로에서 row와 관련 SQLite 상태를 갱신합니다. 성공 때 own ID, `session_id_source='discovered'`, `session_id_verified=True`, 표시 상태를 일관되게 기록하고, 실패 시 기존 own ID를 변경하지 않은 채 구체적 사유를 보존합니다. capture 실패는 이미 성공한 작업의 terminal 상태를 실패로 뒤집지 않습니다.
|
||||||
|
|
||||||
|
3. **agy는 캐시 후보와 실행 중 소유권을 모두 증명**
|
||||||
|
|
||||||
|
- `last_conversations.json[normalized_cwd]`는 후보 UUID 검색의 시작점일 뿐, 단독 pin 근거로 사용하지 않습니다. 해당 map이 다른 cwd를 가리키는 UUID는 즉시 거절합니다.
|
||||||
|
- 후보 UUID의 `presence/<uuid>.lock` (필요하면 기존 artifact 경로 계약에 따른 DB/sidecar)을 열고 있는 PID를 `lsof`로 조회합니다. 소유 PID는 해당 행의 `pane.pid` 또는 그 자손 프로세스여야 합니다. PID/조상 관계를 확인하는 동안 프로세스 종료·PID 재사용 가능성을 고려해 실제 프로세스 존재와 계보 확인에 실패하면 성공으로 추정하지 않습니다.
|
||||||
|
- lsof 부재/오류, lock 미점유, pane PID 누락, 계보 불일치 등의 경우 pin하지 않고 각각 재현 가능한 fail-closed 사유를 기록합니다. 실제 CLI 실행 상태를 확인할 수 없는 환경에서 cache-only fallback은 두지 않습니다.
|
||||||
|
- 동일 normalized cwd를 가진 다른 non-stopped agy row가 있으면 owner 검사가 통과해야만 그 소유 행에 pin할 수 있습니다. 서로 다른 행이 같은 캐시 UUID를 갖는 동시 처리에서도 pane 소유권을 통과한 행만 후보가 됩니다. owner가 불확실하면 둘 다 미확정으로 남을 수 있으며 이는 안전한 결과입니다.
|
||||||
|
- `verify_artifact()`의 sibling claim 검사는 cache match와 무관하게 먼저 적용합니다. 다른 활성/등록 행이 이미 보유한 UUID를 거절합니다. own row의 재검증은 자기 claim을 sibling으로 오인하지 않도록 제외합니다.
|
||||||
|
- reconcile drift C와 completed capture는 같은 agy 판정기를 호출합니다. 대화 폴더 `*.db` 전체를 훑어 얻은 단독 후보를 자동 pin하지 않습니다. 다른 workspace의 DB를 읽지 않도록 막고, DB 조회는 `base._ro_connect`를 사용합니다.
|
||||||
|
|
||||||
|
4. **나머지 Class A 어댑터**
|
||||||
|
|
||||||
|
OpenCode는 세션 DB의 directory/time, Codex는 state DB의 cwd와 rollout metadata, Hermes는 sessions의 cwd/started_at을 사용합니다. 각 후보는 workspace와 세션 epoch(`herdr_session_epoch`) 조건을 모두 만족해야 하며 검증된 후보가 정확히 하나일 때만 확정합니다. 다수 후보는 `ambiguous`로 남깁니다. agy의 presence owner 규칙은 다른 어댑터에 억지로 일반화하지 않습니다.
|
||||||
|
|
||||||
|
5. **동시 completed 처리와 재수신**
|
||||||
|
|
||||||
|
job/token/attempt 키 아래 직렬화하고 처리 결과를 남깁니다. wrapper와 loop 복구 경로가 연속 또는 동시 호출되어도 하나의 capture만 수행합니다. handler가 상태 저장 전에 중단된 경우 재시도는 기존 own ID와 artifact를 재검증해 멱등이어야 합니다. agy의 owner 판별은 각 처리 시점에 수행하며, 캐시가 바뀌어도 UUID 소유 pane을 잘못 선택하지 않습니다.
|
||||||
|
|
||||||
|
6. **호출 지점**
|
||||||
|
|
||||||
|
subscriber가 검증한 completed, subscriber timeout 뒤 disk fallback에서 확정한 completed, loop `wait_for_job()`의 completed 반환 직전 모두 동일 handler로 이어집니다. 기존 wrapper가 순수 상태 매핑을 맡고 있다면 얇은 Python 호출 경계를 추가합니다. subscriber와 `publish_event.py`에 registry/YAML/SQLite 쓰기를 추가하지 않습니다.
|
||||||
|
|
||||||
|
## 단계별 실행 및 검증 기준
|
||||||
|
|
||||||
|
### 1단계 — 실패 재현과 회귀 테스트
|
||||||
|
|
||||||
|
- Creator의 같은 cwd 재현을 테스트 fixture로 옮깁니다. A와 B가 동일 cwd이고 cache는 UUID U 하나를 가리키며, U의 presence lock 소유자는 A의 pane PID인 상태에서 B는 discover/capture/verify 모두 U를 얻지 못해야 합니다.
|
||||||
|
- 동일 cwd 동시 onboard 완료 두 건을 모의합니다. cache UUID는 하나여도 결과는 소유 pane 행만 pin할 수 있고, PID 확인이 불가능하거나 불일치하면 어느 행도 pin하지 않습니다.
|
||||||
|
- `test_agy_capture_requires_presence_owned_by_pane_pid`: lsof 결과를 모의해 일치 PID/자손, 다른 PID, lock 미점유, lsof 오류를 검증합니다.
|
||||||
|
- `test_agy_capture_rejects_cache_uuid_owned_by_sibling_in_same_cwd` 및 `test_agy_concurrent_onboard_same_cwd_no_cross_pin`을 추가합니다. sibling 배제 검사는 cache-hit에서도 실행되어야 합니다.
|
||||||
|
- Issue #64의 `verify_session_uuid(...uuid_b...) is True` 기대를 `False`로 바꾸고, 외부 DB에 sidecar가 생기지 않는 확인은 유지합니다.
|
||||||
|
|
||||||
|
### 2단계 — adapter 및 agy 판정
|
||||||
|
|
||||||
|
- lifecycle 선언, capture 결과 타입, epoch/workspace 단일 후보 규칙을 테스트합니다.
|
||||||
|
- A/B 캐시 전환, 타 workspace mapping, 같은 cwd sibling claim, cache match여도 sibling UUID 거부, presence 소유자가 pane 또는 자손인지 확인합니다.
|
||||||
|
- lsof를 쓸 수 없는 상황에서는 `unverifiable-owner` 등 사유와 함께 미확정임을 확인합니다. 어떤 예외 경로도 cache-only 자동 pin으로 되돌아가면 안 됩니다.
|
||||||
|
|
||||||
|
### 3단계 — 공용 completed handler
|
||||||
|
|
||||||
|
- daemon 없는 onboard 완료, OpenCode/Codex/Hermes의 첫 completed pin, 기존 ID/pre-assigned skip, remote/human/stale token/non-completed 제외를 확인합니다.
|
||||||
|
- wrapper completed와 loop 복구 완료를 같은 키로 호출해 capture 및 저장이 한 번뿐인지 검증합니다.
|
||||||
|
- 모호성·검증 실패의 사유가 보존되고 자체 ID는 그대로인지 확인합니다.
|
||||||
|
|
||||||
|
### 4단계 — reconcile 통합 및 회귀
|
||||||
|
|
||||||
|
- drift C가 공용 판정기를 사용하고 agy의 전체 DB glob 후보를 자동 pin에 쓰지 않는지 테스트합니다.
|
||||||
|
- same-cwd A/B에서 B의 최신 cache 값과 A의 더 최근 epoch가 있어도 lock 소유 pane이 A라면 A만 pin될 수 있고, B는 pin되지 않는지 검증합니다.
|
||||||
|
- subscriber, retained/disk fallback, daemon 없는 실행, loop 복구 경로에서 동일 dedupe key를 확인합니다. publisher가 제어-plane 상태를 쓰지 않는 것도 확인합니다.
|
||||||
|
|
||||||
|
### 5단계 — 검증과 범위 마무리
|
||||||
|
|
||||||
|
- 지정 Python 환경에서 신규 Issue #40 테스트, 갱신된 Issue #64, UUID/tier2/dispatch 및 관련 adapter/reconcile 테스트를 실행합니다. 전체 테스트는 상위 오케스트레이션에 맡깁니다.
|
||||||
|
- 테스트는 임시 HOME/DB와 source 바깥 basetemp를 쓰고 실제 agent home, `.mam.env`, monitor/herdr 서비스에 접근하지 않습니다. 변경한 shell은 `bash -n`, diff는 `git diff --check`로 확인합니다.
|
||||||
|
- 작업 전후 branch/status를 확인하고 요청 범위 파일만 stage합니다. 버전 파일 및 `.venv`는 추가하지 않습니다.
|
||||||
|
|
||||||
|
## 위험과 명시적 한계
|
||||||
|
|
||||||
|
가장 큰 위험은 잘못 pin된 ID가 잘못된 resume 또는 purge로 이어지는 것입니다. 따라서 agy에서 lock 소유 프로세스와 pane의 관계를 확인할 수 없으면 자동 pin을 포기합니다. 이는 일부 환경에서 정상 대화도 자동 등록되지 않는 false negative를 허용하는 fail-closed 선택입니다. 추후 에이전트가 presence lock을 열지 않는 지원 모드가 확인되면 별도의 식별 가능한 프로세스 귀속 신호를 설계해야 하며, 기존 cwd 캐시를 대체 증거로 승격하지 않습니다.
|
||||||
|
|
||||||
|
같은 cwd의 활성 agy 세션 수가 많을수록 cache-only 판정은 신뢰할 수 없습니다. viewport 검사, epoch, 최신 mtime은 owner 증명이 아닙니다. stop의 기존 capture와 source/verified 메타데이터 정합성은 별도 후속 범위로 남기되, completed pin이 이를 덮어써 기존 ID를 변경하지 않도록 회귀 테스트합니다.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20261020T133532Z-a1684a",
|
||||||
|
"base_sha": "ff8b381520ae04e80c73a9e18bfad73d47e853c9",
|
||||||
|
"head_sha": "feccfd5e0356c8acf0438bc68bce7ea36ce2588e",
|
||||||
|
"tree_sha": "0f0f33cc22f5e492a0ddfccb6bac5be888c10d1a",
|
||||||
|
"review_commit": "feccfd5e0356c8acf0438bc68bce7ea36ce2588e",
|
||||||
|
"reviewer": "planner-reviewer-codex-01",
|
||||||
|
"job_id": "75b29d78",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-10-20T15:51:16Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Issue #40 교차 코드 리뷰
|
||||||
|
|
||||||
|
## 검토 범위와 기준선
|
||||||
|
|
||||||
|
리뷰는 브리프가 지정한 immutable 검증 경로 `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261020T133532Z-a1684a/1`에서만 했습니다. HEAD는 `feccfd5e0356c8acf0438bc68bce7ea36ce2588e`, tree는 브리프의 `0f0f33cc22f5e492a0ddfccb6bac5be888c10d1a`와 일치하고, `git status`는 깨끗합니다. diff는 기준선 `ff8b381520ae04e80c73a9e18bfad73d47e853c9` 대비 19개 파일, 1128 insertions / 338 deletions입니다.
|
||||||
|
|
||||||
|
검토한 경계는 `autopin.py`와 adapter capture/verify, agy presence-lock 소유 PID 확인, wrapper/create/loop 호출 경로, reconcile drift C 통합, atomic YAML/SQLite 기록, Issue #40/64 회귀 및 기존 tier 테스트입니다. 소스 작업 트리에서는 실행하거나 수정하지 않았습니다. 기준선 checkout으로 테스트를 재실행하지 않았습니다. 리뷰 지침상 지정된 immutable head 검증 경로에서만 실행할 수 있었고 별도 baseline worktree를 만들지 않았기 때문입니다.
|
||||||
|
|
||||||
|
## 코드 리뷰 결과
|
||||||
|
|
||||||
|
- **agy cross-workspace 및 same-cwd 격리:** cache는 workspace 키에 해당하는 UUID 후보만 제공하며, 후보가 다른 cwd에도 매핑되면 `verify_artifact()`가 거절합니다. sibling UUID 검사는 cache hit와 무관하게 적용됩니다. `capture_active_session_id()`는 pane PID 또는 그 자손이 presence lock/DB 경로를 점유함을 확인하기 전에는 ID를 반환하지 않습니다. lsof/ps, pane PID, 점유자 확인 불능은 fail-closed이고, 다른 행이 이미 UUID를 가졌으면 거절합니다. 이 경로가 `autopin.capture()`와 reconcile drift C에서 공유됩니다.
|
||||||
|
- **completed 수신과 중복 처리:** wrapper/create 및 loop 복구가 동일 handler를 호출합니다. handler는 terminal status, local transport, session row와 dispatch token을 검사하고 job-scoped flock와 `(job_id, dispatch_token, attempt_id)` marker로 중복 호출을 억제합니다. publisher는 제어 평면에 쓰지 않습니다. pin 성공은 atomic mutation 안에서 ID와 `session_id_source`/`session_id_verified`를 함께 기록합니다.
|
||||||
|
- **기존 안전장치:** epoch/workspace 검증과 sibling exclusion 이후 단일 후보만 선택하고, viewport mismatch에서는 pin을 막습니다. 모호성은 pin하지 않고 행에 사유를 남깁니다. 실패한 pin은 job의 성공 terminal 상태를 덮어쓰지 않습니다. Claude/Grok 및 이미 ID가 있는 행은 capture 경로에서 보존됩니다.
|
||||||
|
- **변경 범위:** reconcile의 agent별 후보 검색을 공용 Python capture로 줄이고, create/delegate/loop 호출과 문서·테스트를 더했습니다. stop/purge 구현과 publisher 소유권 경계를 건드리지 않았습니다. 기존 reconcile의 비-0/1 viewport 결과를 degraded pin으로 처리하는 동작은 유지했습니다.
|
||||||
|
|
||||||
|
차단성 결함은 발견하지 못했습니다. 비차단 관찰로, reconcile drift C는 `ambiguous`일 때만 구체적 capture 사유를 `last_visible_status`/drifts에 남기고 `unverifiable-owner`, `owner-mismatch`, `rejected`는 조용히 건너뜁니다. completed handler 경로는 이 사유를 기록하므로 안전성 문제는 아니지만, completed 처리 뒤 재시도/운영 진단을 돕기 위해 reconcile에서도 이 세 상태를 warning drift로 노출하면 추적성이 좋아집니다.
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- `tests/test_issue40_auto_pin.py tests/test_issue64_ro_connect.py`: **70 passed** (42.64s). same-cwd A/B 소유 pane test, presence 소유자 일치/자손/불일치/lsof 불가, 다른 workspace 거절, completed dedupe, ambiguous 및 publisher 경계 포함.
|
||||||
|
- `tests/test_issue60_linux_fixes.py tests/test_issue60_opencode_v2.py tests/test_tier1_unit.py tests/test_tier2_component.py`: **176 passed** (390.99s).
|
||||||
|
- 변경 shell 파일(`lib.sh`, create script, delegate wrapper, reconcile): `bash -n` 통과.
|
||||||
|
- `git diff --check ff8b381..HEAD`: 통과.
|
||||||
|
|
||||||
|
기준선에서 새 테스트가 실패하고 HEAD에서 통과한다는 A/B 재실행은 이 리뷰의 격리 경로 제한상 수행하지 않았습니다. HEAD 테스트와 코드 경로 검토만으로 verdict를 냈습니다. 전체 테스트 스위트는 브리프대로 상위 오케스트레이터 검증에 남깁니다.
|
||||||
|
|
||||||
|
## 남은 위험
|
||||||
|
|
||||||
|
agy 자동 pin은 `lsof`와 `ps`의 소유자 계보 확인에 의존하므로 제한된 권한 환경에서는 정상 대화도 자동 등록되지 않을 수 있습니다. 현재 동작은 오등록보다 false negative를 택해 안전하게 실패합니다. process snapshot과 completed event 처리 사이의 race는 이미 종료된 후보를 보수적으로 거절할 수 있지만, 다른 pane의 ID를 승인하게 하지는 않습니다. 위 reconcile 진단 개선 외에 설계 재계획이 필요한 문제는 발견되지 않았습니다.
|
||||||
|
|
||||||
|
리뷰 가능한 변경분에서 자동 pin의 핵심 안전 경로를 확인했고, 관련 회귀 묶음이 모두 통과했습니다. 다른 workspace 또는 동일 cwd sibling의 agy 대화를 잘못 pin할 차단성 우회는 발견하지 못했습니다.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20261020T133532Z-a1684a",
|
||||||
|
"base_sha": "ff8b381520ae04e80c73a9e18bfad73d47e853c9",
|
||||||
|
"head_sha": "feccfd5e0356c8acf0438bc68bce7ea36ce2588e",
|
||||||
|
"tree_sha": "0f0f33cc22f5e492a0ddfccb6bac5be888c10d1a",
|
||||||
|
"review_commit": "feccfd5e0356c8acf0438bc68bce7ea36ce2588e",
|
||||||
|
"reviewer": "reviewer-agy-01",
|
||||||
|
"job_id": "99090752",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-10-20T15:51:16Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Code Review Report — Job `99090752` (Issue #40: Auto-Pin of Post-Discovered Session IDs on `completed`)
|
||||||
|
|
||||||
|
- **Reviewer**: `herdr:reviewer-agy-01` (`agy`)
|
||||||
|
- **Iteration**: 1
|
||||||
|
- **Verification Worktree**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261020T133532Z-a1684a/1`
|
||||||
|
- **Branch / Commit Range**: `fix/issue-40-auto-pin` (`ff8b381520ae04e80c73a9e18bfad73d47e853c9..feccfd5e0356c8acf0438bc68bce7ea36ce2588e`, 1 commit)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Summary of Changes Reviewed
|
||||||
|
|
||||||
|
The implementation resolves Issue #40 by unifying post-discovered session ID capture and pinning across the `completed`-receiving control-plane paths and `reconcile.sh` drift C, while eliminating `agy`'s cross-workspace / cross-session conversation ID contamination:
|
||||||
|
|
||||||
|
1. **Adapter Contract & Lifecycle Classification (`.agents/skills/lib_py/agents/base.py`, `claude.py`, `grok.py`, `agy.py`)**:
|
||||||
|
- Introduces `PRE_ASSIGNED` (`claude`, `grok`) and `POST_DISCOVERED` (`agy`, `opencode`, `codex`, `hermes` via `BaseAgentAdapter.id_lifecycle` default).
|
||||||
|
- Adds `BaseAgentAdapter.capture_active_session_id(ctx)` (read-only), returning `capture_result(status, session_id=..., reason=..., candidates=...)`. For `opencode`, `codex`, and `hermes`, a session ID is captured only when `discover(ctx)` yields **exactly one** candidate accepted by `verify_session_uuid(..., mode="discover")` and not claimed in `ctx.row["_sibling_claimed_uuids"]`.
|
||||||
|
- `AgyAgentAdapter.verify_artifact` (`mode="discover"`) now:
|
||||||
|
- Rejects any UUID mapped by `cache/last_conversations.json` to another workspace (`workspace_key(k) != workspace_key(ctx.cwd)`).
|
||||||
|
- Rejects any UUID in `ctx.row.get("_sibling_claimed_uuids")` unconditionally (eliminating the previous `if not cache_match:` bypass that allowed two `agy` sessions in the same `cwd` to cross-pin the same cached conversation ID).
|
||||||
|
- `AgyAgentAdapter.capture_active_session_id(ctx)` treats `last_conversations.json[cwd]` strictly as a proposal and requires `_owner_check(uuid, ctx)` (`lsof -t` on `presence/<uuid>.lock`, `<uuid>.db`, `-wal`, `-shm` held by `pane.pid` or a descendant in `ps -eo pid=,ppid=`).
|
||||||
|
2. **Control-Plane Auto-Pin Handler (`.agents/skills/lib_py/agents/autopin.py`, `.agents/skills/lib.sh`)**:
|
||||||
|
- Implements `claimed_elsewhere`, `capture`, `pin_row`, `resume_dry_run`, `tui_viewport`, `handle_completed`, and CLI `python -m lib_py.agents.autopin`.
|
||||||
|
- Enforces security and trust boundaries (#15/#20/#21): only the receiving/verifying side mutates `.mam/agent-sessions.yaml` via `atomic_dump_yaml`; `publish_event.py` / `publisher.py` never touch the session registry.
|
||||||
|
- Deduplicates per `(job_id, dispatch_token, attempt_id)` under `fcntl.flock` (`<job_dir>/autopin.lock`) + atomic `os.replace` marker (`<job_dir>/autopin.json`).
|
||||||
|
- Skips remote (`transport == "remote"` / `remote:*`), `human`, non-`completed`, or stale-dispatch-token records; leaves `PRE_ASSIGNED` and already-pinned rows untouched; records failure/ambiguity reasons in `last_visible_status` without failing the completed job.
|
||||||
|
3. **Call Sites & Reconcile Unification (`create_session.sh`, `multi-agent-mux-delegate-job`, `runner.py`, `reconcile.sh`)**:
|
||||||
|
- `create_session.sh`: invokes `delegate_autopin_completed "$DELEGATE_JOB_ID" "$DELEGATE_JOB_TOKEN" "$task_reg_dir"` upon onboarding `completed`.
|
||||||
|
- `multi-agent-mux-delegate-job`: invokes `delegate_autopin_completed "$JOB_ID" "$_disp_tok" "$REGISTRY_DIR"` when direct job finishes `completed`.
|
||||||
|
- `lib_py/loop/runner.py`: invokes `self._autopin(job_id)` in `wait_for_job` when `status == "completed"` as an idempotent recovery path (preserving `G9` import isolation via subprocess invocation).
|
||||||
|
- `reconcile.sh`: replaces the separate drift C blocks for `agy` (which previously globbed `conversations/*.db` across all workspaces), `hermes`, `opencode`, and `codex` with a single loop calling `autopin.capture(s, agent, d, home_dir=home)` and `autopin.pin_row` / `autopin.resume_dry_run`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Verification & Test Evidence
|
||||||
|
|
||||||
|
All verification was executed against the isolated worktree `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261020T133532Z-a1684a/1` with temporary `HOME` and `--basetemp` directories (no real user/agent state databases opened):
|
||||||
|
|
||||||
|
1. **Issue #40 Completion Conditions (`tests/test_issue40_auto_pin.py`)**:
|
||||||
|
- **Condition 1 (No cross-workspace `agy` pinning)**: `test_agy_verify_rejects_id_cached_for_another_workspace`, `test_reconcile_does_not_pin_another_workspaces_agy_conversation`, `test_autopin_does_not_pin_another_workspaces_agy_conversation` — **PASSED**.
|
||||||
|
- **Condition 2 (First completed job pins without daemon)**: `test_completed_onboarding_pins_agy_without_daemon`, `test_completed_first_job_pins_opencode_codex_hermes[opencode|codex|hermes]`, `test_reconcile_pins_agy_owned_by_the_pane`, `test_lib_sh_helper_pins_through_the_cli`, `test_create_onboard_pins_without_daemon` — **PASSED**.
|
||||||
|
- **Condition 3 (Idempotency per job/token/attempt, ambiguity & ownership handling)**: `test_agy_same_cwd_sibling_never_cross_pins`, `test_agy_capture_requires_presence_owned_by_pane` (4 parametrized cases), `test_agy_holder_pids_reads_lsof`, `test_wrapper_and_loop_recovery_pin_once`, `test_loop_recovery_pins_when_the_wrapper_did_not`, `test_ambiguous_candidates_leave_reason_without_pin` — **PASSED**.
|
||||||
|
- **Condition 4 (Pre-assigned & existing IDs untouched, ineligible/stale skipped, publisher boundary)**: `test_preassigned_and_existing_ids_are_untouched`, `test_ineligible_jobs_pin_nothing` (4 parametrized cases), `test_stale_dispatch_token_pins_nothing`, `test_call_sites_and_publisher_boundary` — **PASSED**.
|
||||||
|
2. **Regression & Component Suites**:
|
||||||
|
- `pytest tests/test_issue40_auto_pin.py tests/test_issue64_ro_connect.py tests/test_issue60_linux_fixes.py tests/test_issue60_opencode_v2.py tests/test_a4_adapter_contract.py tests/test_tier1_unit.py -v` → all 27 tests in `tests/test_issue40_auto_pin.py` and all unit/adapter/ro_connect tests **PASSED** (`237 passed, 0 failed`).
|
||||||
|
- `pytest tests/test_issue40_auto_pin.py tests/test_issue64_ro_connect.py tests/test_issue60_linux_fixes.py tests/test_issue60_opencode_v2.py tests/test_a4_adapter_contract.py tests/test_tier1_unit.py tests/test_tier2_component.py -k "opencode or codex or agy or hermes or autopin or capture or lifecycle or ro_connect or issue40"` → **131 passed** (`0` failed).
|
||||||
|
3. **Negative Regression Verification Against Base (`ff8b381520ae04e80c73a9e18bfad73d47e853c9`)**:
|
||||||
|
- Exported `ff8b381520ae04e80c73a9e18bfad73d47e853c9` via `git archive` to a temporary directory and ran `tests/test_issue40_auto_pin.py` tests (`test_adapter_id_lifecycle_classification`, `test_agy_verify_rejects_id_cached_for_another_workspace`, `test_reconcile_does_not_pin_another_workspaces_agy_conversation`, `test_call_sites_and_publisher_boundary`) → **4 failed (`BASE_RC=1`)**, confirming the tests directly catch the pre-fix behavior (including `reconcile.sh` pinning `proj-B`'s `UB` onto `proj-A`'s row).
|
||||||
|
4. **Static & Hygiene Checks**:
|
||||||
|
- `git diff --check ff8b381520ae04e80c73a9e18bfad73d47e853c9..feccfd5e0356c8acf0438bc68bce7ea36ce2588e` → clean (exit `0`).
|
||||||
|
- `bash -n` on `.agents/skills/lib.sh`, `create_session.sh`, `multi-agent-mux-delegate-job`, and `reconcile.sh` → clean (exit `0`).
|
||||||
|
- `git status --short` in verification worktree → clean.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Findings
|
||||||
|
|
||||||
|
No blocking issues, regressions, or defects found.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"loop_id": "loop-20261020T133532Z-a1684a",
|
||||||
|
"base_sha": "ff8b381520ae04e80c73a9e18bfad73d47e853c9",
|
||||||
|
"head_sha": "feccfd5e0356c8acf0438bc68bce7ea36ce2588e",
|
||||||
|
"tree_sha": "0f0f33cc22f5e492a0ddfccb6bac5be888c10d1a",
|
||||||
|
"review_commit": "feccfd5e0356c8acf0438bc68bce7ea36ce2588e",
|
||||||
|
"reviewer": "reviewer-grok-01",
|
||||||
|
"job_id": "f02af611",
|
||||||
|
"verdict": "PASS",
|
||||||
|
"transport": "herdr",
|
||||||
|
"evidence": "review-tree",
|
||||||
|
"isolation": "worktree",
|
||||||
|
"verified_at": "2026-10-20T15:51:16Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Review f02af611 — Issue #40
|
||||||
|
|
||||||
|
PASS. Commit `feccfd5` pins a post-discovered session id when the receiving side has verified `completed`, and agy no longer records another workspace's conversation. The four completion conditions fail on base `ff8b381` and pass on this head. Related tests: 76 passed.
|
||||||
|
|
||||||
|
Reviewed only the snapshot `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261020T133532Z-a1684a/1` (`loop-20261020T133532Z-a1684a`). Head `feccfd5e0356c8acf0438bc68bce7ea36ce2588e`, tree `0f0f33cc22f5e492a0ddfccb6bac5be888c10d1a`, base `ff8b381520ae04e80c73a9e18bfad73d47e853c9` is an ancestor and is the control-plane HEAD. One commit, 19 files, +1128/−338. `git diff --check` is clean. Snapshot porcelain is empty and the snapshot has no `.mam`. Control-plane porcelain is empty. No product file was edited. The source worktree was not run.
|
||||||
|
|
||||||
|
The commit is `feccfd5` `fix(agents): pin post-discovered session ids on a verified completed; agy never takes another workspace's or session's conversation (Issue #40)`.
|
||||||
|
|
||||||
|
Host tools: control-plane venv Python 3.9.6, sqlite 3.51.0, bash 3.2.57(1)-release. `bash -n` accepts `lib.sh`, `create_session.sh`, `reconcile.sh`, and `multi-agent-mux-delegate-job`. Linux was not executed here. The real agent databases were not opened.
|
||||||
|
|
||||||
|
## When a pin happens
|
||||||
|
|
||||||
|
Class A adapters (`agy`, `opencode`, `codex`, `hermes`) keep `id_lifecycle` `post_discovered`. Class B (`claude`, `grok`) return `pre_assigned`. The default on `BaseAgentAdapter` is `post_discovered`.
|
||||||
|
|
||||||
|
`capture_active_session_id` is read-only. The default (`base.py:384-400`) keeps a `discover()` candidate only when `verify_session_uuid` accepts it and no other row already records that id. Zero verified candidates is `no-candidate`. Two or more is `ambiguous`. Agy replaces that method (`agy.py:129`): `last_conversations.json` may propose the one id mapped to this cwd, `verify_session_uuid` must accept it, and a holder of that conversation's presence lock or database must be the row's pane process or a descendant (`agy.py:202-231`). A cache entry alone is not enough, because one cwd has one cache slot.
|
||||||
|
|
||||||
|
The write lives in `lib_py.agents.autopin`, which the agent process does not run. `handle_completed` runs only for a local job record whose status is `completed`. Remote transport, a `remote:` session, and a human job return `skipped`. A dispatch token passed by the caller must match the record. The pin key is `job_id:dispatch_token:attempt_id`, stored in `<registry>/<job>/autopin.json` under a flock on `autopin.lock`. A second call with the same key returns the stored result and does not capture again.
|
||||||
|
|
||||||
|
Callers:
|
||||||
|
|
||||||
|
- `delegate_autopin_completed` in `lib.sh` (the shell only launches `python -m lib_py.agents.autopin`).
|
||||||
|
- The delegate-job wrapper, after `finalize_unfinished`, when `job_status` is `completed` (`multi-agent-mux-delegate-job` beside the audit-log echo).
|
||||||
|
- `create_session.sh`, in the onboarding `completed` branch.
|
||||||
|
- `LoopRunner.wait_for_job`, immediately after it observes `completed` (`runner.py:676-679`, `_autopin` at `:690`). This call does not pass `--expect-dispatch-token`; the marker key still uses the token stored on the record.
|
||||||
|
|
||||||
|
`pin_row` (`autopin.py:73-78`) sets the agent's own key, `session_id_source` `discovered`, `session_id_verified` true, and `last_visible_status` `pinned`, then the caller replaces that status with the resume dry-run result. A captured id is pinned when `verify_tui_viewport` returns 0, and also when it returns something other than 1 (the old degraded pin). Return 1 leaves the id empty and records `viewport-mismatch`. Any other capture status except `pre-assigned`, `already-set`, and `skipped` writes `last_visible_status` `autopin <status> (job <id>): <reason>` and still stores the marker, so that job is not tried again. The function's exit status stays 0 (`|| true` in the shell helper; `main` returns 0).
|
||||||
|
|
||||||
|
Reconcile drift C for agy, hermes, opencode, and codex calls the same `autopin.capture` (`reconcile.sh:795`) and the same `pin_row` (`:728`). Ambiguous capture sets `ambiguous: N candidates` and does not pin. A single `captured` id still goes through `verify_tui_viewport`. Claude drift C is unchanged and still scans its own jsonl directory.
|
||||||
|
|
||||||
|
## What changed, and what stayed
|
||||||
|
|
||||||
|
| File | Why |
|
||||||
|
| --- | --- |
|
||||||
|
| `lib_py/agents/base.py` | `PRE_ASSIGNED`, `POST_DISCOVERED`, `capture_result`, default `id_lifecycle`, default `capture_active_session_id` |
|
||||||
|
| `lib_py/agents/autopin.py` | Receiving-side capture, pin, dedup marker, CLI |
|
||||||
|
| `adapters/agy.py` | Discover mode rejects an id the cache maps to another cwd, and rejects a sibling id even when the cache names this cwd. Capture adds the pane-owner check |
|
||||||
|
| `adapters/claude.py`, `adapters/grok.py` | `id_lifecycle` is `pre_assigned` |
|
||||||
|
| `lib.sh`, delegate-job wrapper, `create_session.sh` | Call the handler after a verified `completed` |
|
||||||
|
| `loop/runner.py` | Same handler after `wait_for_job` sees `completed` |
|
||||||
|
| `reconcile.sh` | Class A drift C is the shared capture. The agy `*.db` glob and the hermes, opencode, and codex SQL in the shell are gone |
|
||||||
|
| create and monitor `SKILL.md`, `docs/NEW_AGENT_INTEGRATION_GUIDE.md` | The new adapter fields and who writes the registry |
|
||||||
|
| `tests/test_issue40_auto_pin.py` | The four completion conditions |
|
||||||
|
| `test_issue64_ro_connect.py`, `test_issue60_linux_fixes.py`, `test_issue60_opencode_v2.py`, `test_tier1_unit.py`, `test_tier2_component.py` | Assertions that drift C now goes through `autopin.capture`, and the Issue #64 cross-workspace assert is now false |
|
||||||
|
|
||||||
|
`publish_event.py` and `publisher.py` have no autopin and no session-registry write. `test_call_sites_and_publisher_boundary` checks that and passed. Purge deletes stay read-write. Status still opens hermes, opencode, and codex with `_ro_connect`. Adapter queries still use `_ro_connect`. `reconcile.sh` contains neither `_ro_connect` nor `sqlite3.connect`; the adapters open the databases. `VERSIONS.md` is unchanged.
|
||||||
|
|
||||||
|
Stop's own capture path is unchanged. A new pin from this handler records `session_id_source: discovered` and `session_id_verified: true`. An id that stop recorded earlier can still disagree with those two fields. The issue comment asked for that stop cleanup only if it stayed small, and it is outside this diff.
|
||||||
|
|
||||||
|
`_pin_and_verify_resume` now records source and verified for claude too, because it calls `pin_row`. Claude's candidate scan is the same as before.
|
||||||
|
|
||||||
|
## Completion conditions
|
||||||
|
|
||||||
|
Pytest used the control-plane venv, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and `--basetemp` `/tmp/mamf02-f02af611-pybase` (head) and `/tmp/mamf02-f02af611-pybase-base` (base). Neither path contains `mam-review` or `shim`. Inherited `HERDR_*`, `MAM_*`, `MQTT_*`, `SKS_*`, `DELEGATE_JOB_LOGS_DIR`, `HOME_DIR`, `CLAUDE_PROJECT_DIR`, `CLAUDE_CONFIG_DIR`, `CLAUDE_CODE_TMPDIR`, `CODEX_HOME`, `PYTHONPATH`, `WORKSPACE_ROOT`, `MAM_ENV_FILE`, `MAM_JOBS_DIR`, `AGENT_SESSIONS_YAML`, and the four `XDG_*` homes were dropped. Sandbox tests point `HOME` and `HOME_DIR` at the temporary tree.
|
||||||
|
|
||||||
|
Head, cwd the snapshot: `tests/test_issue40_auto_pin.py`, `tests/test_issue64_ro_connect.py`, `test_status_and_reconcile_open_the_database_read_only`, `test_status_and_reconcile_read_session_v2`, `test_opencode_shell_and_scripts_integration`, `test_comp_opencode_create_and_reconcile_and_stop`, and `test_comp_codex_create_and_reconcile_and_stop`. 76 passed in 130.63 s. Exit 0. No skip and no error.
|
||||||
|
|
||||||
|
Base proof is a throwaway `git clone --shared` checked out at `ff8b381`, with `tests/test_issue40_auto_pin.py` and `tests/test_issue64_ro_connect.py` taken from `feccfd5`. The snapshot was not checked out. Result: 15 failed, 13 errors in 19.80 s. Exit 1.
|
||||||
|
|
||||||
|
1. Another workspace's agy id is not recorded. On base, `test_agy_verify_rejects_id_cached_for_another_workspace` got `True` from `verify_session_uuid`. `test_reconcile_does_not_pin_another_workspaces_agy_conversation` found workspace B's id written on A's row. `test_unpinned_agy_reconcile_leaves_other_workspace_clean_db_without_sidecars` still asserted the old `True`. On head all three pass, and the directory stays the one database file.
|
||||||
|
2. The first completed job pins the session's own id with `MAM_NO_SUBSCRIBER_DAEMON=1`. On base, `test_completed_onboarding_pins_agy_without_daemon` errors because `_holder_pids` is absent, the three `test_completed_first_job_pins_opencode_codex_hermes` cases cannot import `autopin`, `test_reconcile_pins_agy_owned_by_the_pane` leaves the id empty, `delegate_autopin_completed` is not a command, and `test_create_onboard_pins_without_daemon` finds no `autopin.json`. On head, agy pins the id the cache maps to A's cwd when A's pane holds it, and opencode, codex, and hermes pin the session in their own workspace. The lib.sh helper, the loop recovery, and `create --onboard` do the same.
|
||||||
|
3. One pin per job, and ambiguity pins nothing. On base, `test_wrapper_and_loop_recovery_pin_once` and `test_ambiguous_candidates_leave_reason_without_pin` cannot import `autopin`, and `test_loop_recovery_pins_when_the_wrapper_did_not` leaves the id empty. On head the wrapper call and the loop call share one marker, the yaml mtime does not move the second time, and two opencode sessions in one cwd leave `autopin ambiguous (job amb): 2 candidates` with the own id empty.
|
||||||
|
4. Pre-assigned rows and rows that already have an id stay as they were. On base, `test_preassigned_and_existing_ids_are_untouched` errors in the owner fixture, and `test_call_sites_and_publisher_boundary` does not find the wrapper call. On head the claude, grok, already-pinned agy, and already-set opencode rows compare equal before and after, and remote, human, error, and still-running jobs return `skipped` without a marker. A stale `--expect-dispatch-token` also writes no marker.
|
||||||
|
|
||||||
|
## Remaining risk
|
||||||
|
|
||||||
|
Agy will not pin from the cache unless `lsof` shows the presence lock or the database held by the pane process or a descendant. No holder, a missing `lsof`, or a pane pid of 0 or 1 is `unverifiable-owner`. That result is stored on the job marker, so the loop recovery does not try the same job again. Reconcile does not read the marker and will try on a later cycle. A worktree creator has no monitor, so a completed turn whose files are already closed stays unpinned until a later completed job. The tests stub `lsof` and `ps`; a live agy process was not used.
|
||||||
|
|
||||||
|
`claimed_elsewhere` treats every own id on every other row, including a stopped row, as taken. The old drift C list was the live same-cwd siblings. An id that still sits on a stopped row is not given to a new row.
|
||||||
|
|
||||||
|
Codex drift C now uses `discover()` plus `verify_artifact`, so the epoch check is the rollout file mtime. The removed SQL compared the state-db timestamp. Hermes and opencode still filter cwd and epoch in `discover()`, and opencode still requires `parent_id IS NULL`.
|
||||||
|
|
||||||
|
The resume dry-run and the viewport check run while `atomic_dump_yaml` holds the registry lock, which is the same arrangement the old `_pin_and_verify_resume` used. `resume_session.sh --dry-run` returns before `update_yaml_resumed.sh` when herdr already has the session. `verify_tui_viewport` reads the pane and does not take that lock.
|
||||||
|
|
||||||
|
A `mode=ro` open that already has both sidecars can still rewrite `-shm` bytes, and the immutable open can miss a wal created after the exists check. Those are the Issue #64 residuals. This commit removes the reconcile path that opened every agy database.
|
||||||
|
|
||||||
|
The user's background service stayed up. Its pid is 23630 and the command is `/Users/godopu16/.opencode/bin/opencode serve --service`. The real database mtime is 1792420196 and its size is 36712448. `~/.config/opencode/service.json` mtime is 1792246838. `~/.local/state/opencode/service.json` mtime is 1792365227. The real `opencode.log` mtime is 1792420391 and its size is 2699345. The herdr config mtime is 1784456394.
|
||||||
|
|
||||||
|
After the report, snapshot porcelain is still empty, the snapshot still has no `.mam`, and control-plane porcelain is still empty at `ff8b381520ae04e80c73a9e18bfad73d47e853c9`. The herdr config mtime is still 1784456394. The opencode database mtime is still 1792420196 and its size is still 36712448. The opencode log mtime is still 1792420391 and its size is still 2699345. Service pid 23630 is still `opencode serve --service`.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
+13
-1
@@ -29,7 +29,7 @@ export WORKSPACE_ROOT
|
|||||||
# Framework semantic version. Single runtime source of truth; kept in lockstep
|
# Framework semantic version. Single runtime source of truth; kept in lockstep
|
||||||
# with VERSIONS.md and the 8 SKILL.md frontmatters by tests/test_version_consistency.py.
|
# with VERSIONS.md and the 8 SKILL.md frontmatters by tests/test_version_consistency.py.
|
||||||
# NOTE: unlike other MAM_* variables this one is intentionally NOT env-overridable.
|
# NOTE: unlike other MAM_* variables this one is intentionally NOT env-overridable.
|
||||||
MAM_VERSION="4.9.8"
|
MAM_VERSION="4.9.9"
|
||||||
export MAM_VERSION
|
export MAM_VERSION
|
||||||
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
|
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
|
||||||
|
|
||||||
@@ -2467,6 +2467,18 @@ delegate_wait_terminal() {
|
|||||||
--job "$job_id" --registry-dir "$reg_dir" --timeout "$timeout_s" --watch-pid "$$"
|
--job "$job_id" --registry-dir "$reg_dir" --timeout "$timeout_s" --watch-pid "$$"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# delegate_autopin_completed <job_id> [dispatch_token] [registry_dir]
|
||||||
|
# Issue #40: once this side has seen the job `completed`, pin the session's
|
||||||
|
# post-discovered id (lib_py.agents.autopin decides and writes; remote, human
|
||||||
|
# and pre-assigned sessions are left alone, one job/token/attempt is handled
|
||||||
|
# once). Reports on stderr; never changes the job's result or this exit code.
|
||||||
|
delegate_autopin_completed() {
|
||||||
|
local job_id="$1" token="${2:-}" reg_dir="${3:-${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}}"
|
||||||
|
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" HOME_DIR="${HOME_DIR:-$HOME}" LIB_SH="$SKILL_DIR/lib.sh" \
|
||||||
|
"$(_delegate_py_bin)" -m lib_py.agents.autopin --job "$job_id" --registry-dir "$reg_dir" \
|
||||||
|
--yaml "$AGENT_SESSIONS_YAML" ${token:+--expect-dispatch-token "$token"} >&2 || true
|
||||||
|
}
|
||||||
|
|
||||||
# delegate_onboard_exit <status>
|
# delegate_onboard_exit <status>
|
||||||
# Issue #50: create's exit code for an onboarding status (0, 44 or 45).
|
# Issue #50: create's exit code for an onboarding status (0, 44 or 45).
|
||||||
delegate_onboard_exit() {
|
delegate_onboard_exit() {
|
||||||
|
|||||||
@@ -1,6 +1,43 @@
|
|||||||
import os, sys, json, shutil
|
import os, sys, json, shutil, subprocess
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any, Dict, List
|
||||||
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, _ro_connect, conversation_in_use
|
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, _ro_connect, capture_result, conversation_in_use
|
||||||
|
from lib_py.verify_session import verify_session_uuid, workspace_key
|
||||||
|
|
||||||
|
|
||||||
|
def _holder_pids(paths: List[str]) -> Optional[List[int]]:
|
||||||
|
"""PIDs holding any of `paths` open (lsof -t), [] when none does, None
|
||||||
|
when lsof cannot answer (missing, failed, timed out)."""
|
||||||
|
existing = [p for p in paths if p and os.path.exists(p)]
|
||||||
|
if not existing:
|
||||||
|
return []
|
||||||
|
lsof = shutil.which("lsof")
|
||||||
|
if not lsof:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
r = subprocess.run([lsof, "-t"] + existing, capture_output=True, text=True, timeout=10)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
pids = sorted({int(t) for t in r.stdout.split() if t.isdigit()})
|
||||||
|
# lsof exits 1 both when nothing holds the files and on errors.
|
||||||
|
if r.returncode not in (0, 1) or (r.returncode == 1 and not pids and r.stderr.strip()):
|
||||||
|
return None
|
||||||
|
return pids
|
||||||
|
|
||||||
|
|
||||||
|
def _process_parents() -> Optional[Dict[int, int]]:
|
||||||
|
"""pid -> ppid of every running process (ps), None when ps fails."""
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["ps", "-eo", "pid=,ppid="], capture_output=True, text=True, timeout=10)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if out.returncode != 0:
|
||||||
|
return None
|
||||||
|
parents: Dict[int, int] = {}
|
||||||
|
for line in out.stdout.splitlines():
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
|
||||||
|
parents[int(parts[0])] = int(parts[1])
|
||||||
|
return parents or None
|
||||||
|
|
||||||
class AgyAgentAdapter(BaseAgentAdapter):
|
class AgyAgentAdapter(BaseAgentAdapter):
|
||||||
@property
|
@property
|
||||||
@@ -58,18 +95,15 @@ class AgyAgentAdapter(BaseAgentAdapter):
|
|||||||
if ctx.epoch and os.path.getmtime(path) < ctx.epoch:
|
if ctx.epoch and os.path.getmtime(path) < ctx.epoch:
|
||||||
return False
|
return False
|
||||||
if ctx.mode == "discover":
|
if ctx.mode == "discover":
|
||||||
lc = f"{ctx.home_dir}/.gemini/antigravity-cli/cache/last_conversations.json"
|
# Issue #40: the cache maps each workspace to the conversation last
|
||||||
cache_match = False
|
# opened there, so an id it maps to another workspace is that
|
||||||
if os.path.exists(lc):
|
# workspace's; and an id another row records is never this row's,
|
||||||
try:
|
# whether or not the cache names it for this cwd.
|
||||||
with open(lc) as f:
|
here = workspace_key(ctx.cwd)
|
||||||
lc_data = json.load(f)
|
if any(v == uuid and workspace_key(k) != here for k, v in self._last_conversations(ctx).items()):
|
||||||
cache_match = (lc_data.get(ctx.cwd) == uuid)
|
return False
|
||||||
except Exception:
|
if uuid in (ctx.row.get("_sibling_claimed_uuids") or []):
|
||||||
cache_match = False
|
return False
|
||||||
if not cache_match:
|
|
||||||
if uuid in (ctx.row.get("_sibling_claimed_uuids") or []):
|
|
||||||
return False
|
|
||||||
try:
|
try:
|
||||||
conn = _ro_connect(path)
|
conn = _ro_connect(path)
|
||||||
r = conn.execute("SELECT count(*) FROM steps").fetchone()
|
r = conn.execute("SELECT count(*) FROM steps").fetchone()
|
||||||
@@ -83,6 +117,71 @@ class AgyAgentAdapter(BaseAgentAdapter):
|
|||||||
def _cli_root(self, ctx: DiscoveryContext) -> str:
|
def _cli_root(self, ctx: DiscoveryContext) -> str:
|
||||||
return f"{ctx.home_dir}/.gemini/antigravity-cli"
|
return f"{ctx.home_dir}/.gemini/antigravity-cli"
|
||||||
|
|
||||||
|
def _last_conversations(self, ctx: DiscoveryContext) -> dict:
|
||||||
|
"""cache/last_conversations.json: {cwd: id of the conversation last opened there}."""
|
||||||
|
try:
|
||||||
|
with open(f"{self._cli_root(ctx)}/cache/last_conversations.json") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def capture_active_session_id(self, ctx: DiscoveryContext) -> dict:
|
||||||
|
"""Issue #40: the cache only proposes an id - it holds one id per cwd,
|
||||||
|
rewritten whenever any agy opens a conversation there, so it cannot tell
|
||||||
|
two sessions in one cwd apart. The id is this session's only when its
|
||||||
|
presence lock (or DB) is held open by the row's pane process or one of
|
||||||
|
its descendants. Anything unprovable is not pinned (no cache-only fallback)."""
|
||||||
|
here = workspace_key(ctx.cwd)
|
||||||
|
proposed = sorted({v for k, v in self._last_conversations(ctx).items()
|
||||||
|
if v and workspace_key(k) == here})
|
||||||
|
if not proposed:
|
||||||
|
return capture_result("no-candidate", reason="last_conversations.json names no conversation for this cwd")
|
||||||
|
if len(proposed) > 1:
|
||||||
|
return capture_result("ambiguous", reason="last_conversations.json maps this cwd to several conversations",
|
||||||
|
candidates=proposed)
|
||||||
|
uuid = proposed[0]
|
||||||
|
if uuid in (ctx.row.get("_sibling_claimed_uuids") or []):
|
||||||
|
return capture_result("sibling-claimed", reason=f"{uuid} is recorded by another session", candidates=[uuid])
|
||||||
|
if not verify_session_uuid(ctx.cwd, self.name, uuid, row=ctx.row, home_dir=ctx.home_dir, mode="discover"):
|
||||||
|
return capture_result("rejected", reason=f"{uuid} failed verification (missing, older than the session, or empty)",
|
||||||
|
candidates=[uuid])
|
||||||
|
status, reason = self._owner_check(uuid, ctx)
|
||||||
|
if status != "ok":
|
||||||
|
return capture_result(status, reason=reason, candidates=[uuid])
|
||||||
|
return capture_result("captured", session_id=uuid, candidates=[uuid])
|
||||||
|
|
||||||
|
def _owner_check(self, uuid: str, ctx: DiscoveryContext):
|
||||||
|
"""('ok', '') when a holder of the conversation's presence lock or DB is
|
||||||
|
the row's pane process or its descendant; otherwise
|
||||||
|
('unverifiable-owner' | 'owner-mismatch', reason)."""
|
||||||
|
try:
|
||||||
|
pane_pid = int((ctx.row.get("pane") or {}).get("pid") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pane_pid = 0
|
||||||
|
if pane_pid <= 1:
|
||||||
|
return "unverifiable-owner", "the row records no pane pid"
|
||||||
|
db = self.artifact_path(uuid, ctx)
|
||||||
|
paths = [f"{self._cli_root(ctx)}/presence/{uuid}.lock", db, f"{db}-wal", f"{db}-shm"]
|
||||||
|
holders = _holder_pids(paths)
|
||||||
|
if holders is None:
|
||||||
|
return "unverifiable-owner", "lsof is unavailable or failed"
|
||||||
|
if not holders:
|
||||||
|
return "unverifiable-owner", f"no process holds {uuid}'s presence lock or DB"
|
||||||
|
if pane_pid in holders:
|
||||||
|
return "ok", ""
|
||||||
|
parents = _process_parents()
|
||||||
|
if parents is None or pane_pid not in parents:
|
||||||
|
return "unverifiable-owner", f"pane process {pane_pid} cannot be found"
|
||||||
|
for pid in holders:
|
||||||
|
seen = set()
|
||||||
|
while pid > 1 and pid not in seen:
|
||||||
|
if pid == pane_pid:
|
||||||
|
return "ok", ""
|
||||||
|
seen.add(pid)
|
||||||
|
pid = parents.get(pid, 0)
|
||||||
|
return "owner-mismatch", f"{uuid} is held by pid(s) {holders}, none in pane process {pane_pid}'s tree"
|
||||||
|
|
||||||
def _artifact_paths(self, uuid: str, ctx: DiscoveryContext) -> list:
|
def _artifact_paths(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||||
"""Every on-disk piece of one conversation, DB first: the SQLite
|
"""Every on-disk piece of one conversation, DB first: the SQLite
|
||||||
sidecars, the brain dir, the title annotation and the presence lock."""
|
sidecars, the brain dir, the title annotation and the presence lock."""
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import os, re, json, glob, math, stat, time, shutil, calendar, subprocess
|
import os, re, json, glob, math, stat, time, shutil, calendar, subprocess
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any
|
||||||
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, conversation_in_use, scan_names
|
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, PRE_ASSIGNED, conversation_in_use, scan_names
|
||||||
from lib_py.verify_session import workspace_key
|
from lib_py.verify_session import workspace_key
|
||||||
|
|
||||||
|
|
||||||
@@ -184,6 +184,11 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
|
|||||||
def own_key(self) -> str:
|
def own_key(self) -> str:
|
||||||
return 'claude_session_id_own'
|
return 'claude_session_id_own'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def id_lifecycle(self) -> str:
|
||||||
|
# create hands the CLI its id (--session-id); nothing to capture (#40)
|
||||||
|
return PRE_ASSIGNED
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ready_tokens(self) -> str:
|
def ready_tokens(self) -> str:
|
||||||
return 'Anthropic|Assistant|Chat|Welcome|Claude Code|Opus|Sonnet|Haiku'
|
return 'Anthropic|Assistant|Chat|Welcome|Claude Code|Opus|Sonnet|Haiku'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import os, json, glob, shutil
|
import os, json, glob, shutil
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, scan_names
|
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, PRE_ASSIGNED, scan_names
|
||||||
from lib_py.verify_session import workspace_key
|
from lib_py.verify_session import workspace_key
|
||||||
|
|
||||||
|
|
||||||
@@ -14,6 +14,11 @@ class GrokAgentAdapter(BaseAgentAdapter):
|
|||||||
def own_key(self) -> str:
|
def own_key(self) -> str:
|
||||||
return 'grok_session_id_own'
|
return 'grok_session_id_own'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def id_lifecycle(self) -> str:
|
||||||
|
# create hands the CLI its id (--session-id); nothing to capture (#40)
|
||||||
|
return PRE_ASSIGNED
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ready_tokens(self) -> str:
|
def ready_tokens(self) -> str:
|
||||||
return 'Grok|xAI|Assistant|❯|>>>'
|
return 'Grok|xAI|Assistant|❯|>>>'
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
"""Auto-pin of a post-discovered session id on a verified local `completed` (Issue #40).
|
||||||
|
|
||||||
|
Class A agents (agy, opencode, codex, hermes) write their conversation id to
|
||||||
|
disk only after their first turn, so create records the row without one. The
|
||||||
|
side that verified a job's `completed` - the delegate-job wrapper, create
|
||||||
|
--onboard, and the loop's wait_for_job as a recovery path - calls
|
||||||
|
handle_completed(). It captures the id through the adapter
|
||||||
|
(capture_active_session_id) and pins it with pin_row() under atomic_dump_yaml.
|
||||||
|
reconcile's drift C calls the same capture() and pin_row(), so the two paths
|
||||||
|
reach one decision.
|
||||||
|
|
||||||
|
One (job_id, dispatch_token, attempt_id) is handled once: a job-scoped flock
|
||||||
|
and a marker in the job's directory make a second call (wrapper + loop
|
||||||
|
recovery) a no-op. Remote and human jobs have no local disk or pane and are
|
||||||
|
skipped; the agent's own publish_event.py never writes the control plane
|
||||||
|
(#15/#20/#21). A failed pin never changes the job's result: the reason is
|
||||||
|
written on the row and returned.
|
||||||
|
|
||||||
|
CLI: python -m lib_py.agents.autopin --registry-dir DIR --job ID
|
||||||
|
[--yaml AGENT_SESSIONS_YAML] [--expect-dispatch-token TOKEN]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import fcntl
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
from lib_py.agents.base import DiscoveryContext, POST_DISCOVERED, capture_result
|
||||||
|
from lib_py.agents.registry import _ADAPTERS, agent_of_row, get_adapter
|
||||||
|
|
||||||
|
OWN_KEYS = tuple(a.own_key for a in _ADAPTERS.values())
|
||||||
|
MARKER = "autopin.json"
|
||||||
|
|
||||||
|
|
||||||
|
def claimed_elsewhere(row: Dict[str, Any], state: Dict[str, Any]) -> List[str]:
|
||||||
|
"""Every id another row records: an id already some session's is never this one's."""
|
||||||
|
ids: List[str] = []
|
||||||
|
for other in state.get("herdr_sessions") or []:
|
||||||
|
if other is row or other.get("name") == row.get("name"):
|
||||||
|
continue
|
||||||
|
for key in OWN_KEYS:
|
||||||
|
v = other.get(key)
|
||||||
|
if v and v not in ids:
|
||||||
|
ids.append(v)
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def capture(row: Dict[str, Any], agent: str, state: Dict[str, Any], home_dir: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
"""The adapter's decision for this row (read-only). Besides the capture
|
||||||
|
statuses: 'pre-assigned' and 'already-set' (nothing to capture), 'skipped'."""
|
||||||
|
adapter = get_adapter(agent)
|
||||||
|
if adapter is None:
|
||||||
|
return capture_result("skipped", reason=f"unknown agent {agent!r}")
|
||||||
|
if adapter.id_lifecycle != POST_DISCOVERED:
|
||||||
|
return capture_result("pre-assigned")
|
||||||
|
if row.get(adapter.own_key):
|
||||||
|
return capture_result("already-set", session_id=row[adapter.own_key])
|
||||||
|
cwd = (row.get("pane") or {}).get("cwd") or ""
|
||||||
|
if not cwd:
|
||||||
|
return capture_result("no-candidate", reason="the row has no pane cwd")
|
||||||
|
row_eval = dict(row)
|
||||||
|
row_eval["_sibling_claimed_uuids"] = claimed_elsewhere(row, state)
|
||||||
|
ctx = DiscoveryContext(workspace=cwd, agent_name=agent, home_dir=home_dir,
|
||||||
|
epoch=row.get("herdr_session_epoch") or 0, row=row_eval, mode="discover")
|
||||||
|
return adapter.capture_active_session_id(ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def pin_row(row: Dict[str, Any], agent: str, uuid: str) -> None:
|
||||||
|
"""Record a verified id: own key, source and verified flag, in one change."""
|
||||||
|
row[get_adapter(agent).own_key] = uuid
|
||||||
|
row["session_id_source"] = "discovered"
|
||||||
|
row["session_id_verified"] = True
|
||||||
|
row["last_visible_status"] = "pinned"
|
||||||
|
|
||||||
|
|
||||||
|
def _skills_dir() -> str:
|
||||||
|
return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
|
||||||
|
def resume_dry_run(skills_dir: str, cwd: str, agent: str, name: str, env: Optional[Dict[str, str]] = None) -> str:
|
||||||
|
"""last_visible_status after a pin: 'resume_verified' or why the dry-run failed."""
|
||||||
|
cmd = ["bash", os.path.join(skills_dir, "multi-agent-mux-resume", "scripts", "resume_session.sh"),
|
||||||
|
"--workspace", cwd, "--agent", agent, "--session", name, "--dry-run"]
|
||||||
|
res = subprocess.run(cmd, capture_output=True, text=True, env=env)
|
||||||
|
if res.returncode == 0:
|
||||||
|
return "resume_verified"
|
||||||
|
return f"resume dry-run failed: {res.stderr.strip() or res.stdout.strip()}"
|
||||||
|
|
||||||
|
|
||||||
|
def tui_viewport(lib_sh: str, name: str, agent: str, cwd: str, env: Optional[Dict[str, str]] = None) -> int:
|
||||||
|
"""lib.sh verify_tui_viewport: 0 match, 1 mismatch, other = no evidence."""
|
||||||
|
script = 'source "$1" && verify_tui_viewport "$2" "$3" "$4"'
|
||||||
|
return subprocess.run(["bash", "-c", script, "_", lib_sh, name, agent, cwd], env=env).returncode
|
||||||
|
|
||||||
|
|
||||||
|
def _load_record(registry_dir: str, job_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
with open(os.path.join(registry_dir, f"{job_id}.json"), encoding="utf-8") as f:
|
||||||
|
rec = json.load(f)
|
||||||
|
return rec if isinstance(rec, dict) else None
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_MUTATION: Optional[Callable[[Dict[str, Any]], bool]] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_update(yaml_path: str, mutate: Callable[[Dict[str, Any]], bool]) -> None:
|
||||||
|
"""Run `mutate(d)` as an atomic_dump_yaml mutation in this process; it
|
||||||
|
returns False to leave the registry untouched."""
|
||||||
|
from lib_py import atomic_yaml
|
||||||
|
global _MUTATION
|
||||||
|
saved = {k: os.environ.get(k) for k in ("YAML_PATH", "AGENT_SESSIONS_MUTATION")}
|
||||||
|
_MUTATION = mutate
|
||||||
|
os.environ["YAML_PATH"] = yaml_path
|
||||||
|
# this very module (it is __main__ under `python -m`), not a second copy
|
||||||
|
os.environ["AGENT_SESSIONS_MUTATION"] = (
|
||||||
|
"import sys as _sys\n"
|
||||||
|
f"if not _sys.modules[{__name__!r}]._MUTATION(d):\n"
|
||||||
|
" raise SystemExit(0)\n")
|
||||||
|
try:
|
||||||
|
atomic_yaml.atomic_dump_yaml_main()
|
||||||
|
except SystemExit as exc:
|
||||||
|
if exc.code not in (0, None):
|
||||||
|
raise RuntimeError(str(exc.code))
|
||||||
|
finally:
|
||||||
|
_MUTATION = None
|
||||||
|
for k, v in saved.items():
|
||||||
|
if v is None:
|
||||||
|
os.environ.pop(k, None)
|
||||||
|
else:
|
||||||
|
os.environ[k] = v
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _subprocess_env(yaml_path: str, registry_dir: str) -> Dict[str, str]:
|
||||||
|
"""The control plane the lib.sh checks run against: the registry's own."""
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.setdefault("AGENT_SESSIONS_YAML", yaml_path)
|
||||||
|
env.setdefault("MAM_JOBS_DIR", registry_dir)
|
||||||
|
mam = os.path.dirname(yaml_path)
|
||||||
|
if os.path.basename(mam) == ".mam":
|
||||||
|
env.setdefault("WORKSPACE_ROOT", os.path.dirname(mam))
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def _pin_session(name: str, job_id: str, yaml_path: str, home_dir: Optional[str],
|
||||||
|
viewport: Callable[[str, str, str], int], resume: Callable[[str, str, str], str]) -> Dict[str, Any]:
|
||||||
|
out: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
def mutate(d: Dict[str, Any]) -> bool:
|
||||||
|
row = next((s for s in d.get("herdr_sessions") or [] if s.get("name") == name), None)
|
||||||
|
if row is None:
|
||||||
|
out.update(capture_result("skipped", reason=f"no session row {name!r}"))
|
||||||
|
return False
|
||||||
|
if row.get("status") != "running":
|
||||||
|
out.update(capture_result("skipped", reason=f"session {name!r} is {row.get('status')}"))
|
||||||
|
return False
|
||||||
|
agent = agent_of_row(row)
|
||||||
|
res = capture(row, agent or "", d, home_dir=home_dir)
|
||||||
|
cwd = (row.get("pane") or {}).get("cwd") or ""
|
||||||
|
if res["status"] == "captured":
|
||||||
|
rc = viewport(name, agent, cwd)
|
||||||
|
if rc == 1:
|
||||||
|
res = capture_result("viewport-mismatch", reason="the pane does not show this workspace",
|
||||||
|
candidates=res["candidates"])
|
||||||
|
else:
|
||||||
|
pin_row(row, agent, res["session_id"])
|
||||||
|
row["last_visible_status"] = resume(name, agent, cwd)
|
||||||
|
res = dict(res, status="pinned", degraded=(rc != 0))
|
||||||
|
out.update(res)
|
||||||
|
return True
|
||||||
|
out.update(res)
|
||||||
|
if res["status"] in ("pre-assigned", "already-set", "skipped"):
|
||||||
|
return False
|
||||||
|
row["last_visible_status"] = f"autopin {res['status']} (job {job_id}): {res['reason']}"
|
||||||
|
return True
|
||||||
|
|
||||||
|
_atomic_update(yaml_path, mutate)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def handle_completed(registry_dir: str, job_id: str, yaml_path: Optional[str] = None,
|
||||||
|
expect_token: Optional[str] = None, home_dir: Optional[str] = None,
|
||||||
|
viewport: Optional[Callable[[str, str, str], int]] = None,
|
||||||
|
resume: Optional[Callable[[str, str, str], str]] = None) -> Dict[str, Any]:
|
||||||
|
"""Pin the job's session id if the job completed locally and the row still
|
||||||
|
lacks a post-discovered id. Returns the result (also kept in the marker)."""
|
||||||
|
rec = _load_record(registry_dir, job_id)
|
||||||
|
if rec is None:
|
||||||
|
return capture_result("skipped", reason=f"no job record {job_id}")
|
||||||
|
if rec.get("status") != "completed":
|
||||||
|
return capture_result("skipped", reason=f"job {job_id} is {rec.get('status')}")
|
||||||
|
token = rec.get("dispatch_token") or ""
|
||||||
|
if expect_token and token != expect_token:
|
||||||
|
return capture_result("skipped", reason="stale dispatch token")
|
||||||
|
session = str(rec.get("agent_session") or "")
|
||||||
|
if (rec.get("transport") == "remote" or session.startswith("remote:")
|
||||||
|
or rec.get("agent") == "human" or session in ("", "human", "herdr:human")):
|
||||||
|
return capture_result("skipped", reason="not a local agent session")
|
||||||
|
name = session[len("herdr:"):] if session.startswith("herdr:") else session
|
||||||
|
yaml_path = yaml_path or os.environ.get("AGENT_SESSIONS_YAML") or os.path.join(
|
||||||
|
os.path.dirname(os.path.abspath(registry_dir)), "agent-sessions.yaml")
|
||||||
|
if not (os.path.exists(yaml_path) or os.path.exists(os.path.splitext(yaml_path)[0] + ".db")):
|
||||||
|
return capture_result("skipped", reason=f"no session registry at {yaml_path}")
|
||||||
|
|
||||||
|
key = f"{job_id}:{token}:{rec.get('attempt_id')}"
|
||||||
|
job_dir = os.path.join(registry_dir, job_id)
|
||||||
|
os.makedirs(job_dir, exist_ok=True)
|
||||||
|
env = _subprocess_env(yaml_path, registry_dir)
|
||||||
|
if viewport is None:
|
||||||
|
lib_sh = os.environ.get("LIB_SH") or os.path.join(_skills_dir(), "lib.sh")
|
||||||
|
viewport = lambda n, a, c: tui_viewport(lib_sh, n, a, c, env=env) # noqa: E731
|
||||||
|
if resume is None:
|
||||||
|
resume = lambda n, a, c: resume_dry_run(_skills_dir(), c, a, n, env=env) # noqa: E731
|
||||||
|
|
||||||
|
with open(os.path.join(job_dir, "autopin.lock"), "w") as lock:
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||||
|
marker = os.path.join(job_dir, MARKER)
|
||||||
|
try:
|
||||||
|
with open(marker, encoding="utf-8") as f:
|
||||||
|
done = json.load(f)
|
||||||
|
if done.get("key") == key:
|
||||||
|
return dict(done.get("result") or {}, deduplicated=True)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
result = _pin_session(name, job_id, yaml_path, home_dir, viewport, resume)
|
||||||
|
except Exception as exc:
|
||||||
|
result = capture_result("error", reason=f"{type(exc).__name__}: {exc}")
|
||||||
|
result["session"] = name
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=job_dir, prefix=".autopin.", suffix=".tmp")
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"key": key, "at": int(time.time()), "result": result}, f)
|
||||||
|
os.replace(tmp, marker)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Optional[List[str]] = None) -> int:
|
||||||
|
p = argparse.ArgumentParser(prog="python -m lib_py.agents.autopin")
|
||||||
|
p.add_argument("--registry-dir", required=True)
|
||||||
|
p.add_argument("--job", required=True)
|
||||||
|
p.add_argument("--yaml", default=None, help="agent-sessions.yaml (default: beside the registry)")
|
||||||
|
p.add_argument("--expect-dispatch-token", default=None)
|
||||||
|
a = p.parse_args(argv)
|
||||||
|
res = handle_completed(a.registry_dir, a.job, yaml_path=a.yaml, expect_token=a.expect_dispatch_token)
|
||||||
|
if res.get("status") == "pinned":
|
||||||
|
print(f"autopin: {res['session']} pinned {res['session_id']} (job {a.job})", file=sys.stderr)
|
||||||
|
elif res.get("status") not in ("skipped", "pre-assigned", "already-set") and not res.get("deduplicated"):
|
||||||
|
print(f"autopin: {res.get('session')} not pinned - {res['status']}: {res.get('reason')} (job {a.job})",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -169,6 +169,22 @@ def db_rows_naming(db: str, uuid: str, columns=(), id_tables=(), connect=None) -
|
|||||||
conn.close()
|
conn.close()
|
||||||
return found
|
return found
|
||||||
|
|
||||||
|
# Session-id lifecycles (Issue #40): the id is handed to the CLI before it
|
||||||
|
# starts, or the CLI writes it to disk after its first turn.
|
||||||
|
PRE_ASSIGNED = 'pre_assigned'
|
||||||
|
POST_DISCOVERED = 'post_discovered'
|
||||||
|
|
||||||
|
|
||||||
|
def capture_result(status: str, session_id: Optional[str] = None, reason: str = "",
|
||||||
|
candidates: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||||
|
"""What capture_active_session_id found (Issue #40). `status` is
|
||||||
|
'captured' (session_id set) or why nothing may be pinned: 'no-candidate',
|
||||||
|
'ambiguous', 'sibling-claimed', 'rejected', 'unverifiable-owner',
|
||||||
|
'owner-mismatch'."""
|
||||||
|
return {"status": status, "session_id": session_id, "reason": reason,
|
||||||
|
"candidates": list(candidates or [])}
|
||||||
|
|
||||||
|
|
||||||
class SpawnSpec:
|
class SpawnSpec:
|
||||||
def __init__(self, agent_name: str, session_name: str, command: str, env: Optional[Dict[str, str]] = None):
|
def __init__(self, agent_name: str, session_name: str, command: str, env: Optional[Dict[str, str]] = None):
|
||||||
self.agent_name = agent_name
|
self.agent_name = agent_name
|
||||||
@@ -358,3 +374,28 @@ class BaseAgentAdapter:
|
|||||||
|
|
||||||
def discover(self, ctx: DiscoveryContext) -> list:
|
def discover(self, ctx: DiscoveryContext) -> list:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@property
|
||||||
|
def id_lifecycle(self) -> str:
|
||||||
|
"""PRE_ASSIGNED: the id is given to the CLI at spawn (nothing to capture).
|
||||||
|
POST_DISCOVERED: the CLI writes it after its first turn (Issue #40)."""
|
||||||
|
return POST_DISCOVERED
|
||||||
|
|
||||||
|
def capture_active_session_id(self, ctx: DiscoveryContext) -> Dict[str, Any]:
|
||||||
|
"""This session's id, decided only when it is unambiguous (Issue #40):
|
||||||
|
exactly one discover() candidate that verify_session_uuid accepts and
|
||||||
|
no other row records (ctx.row['_sibling_claimed_uuids']). Read-only."""
|
||||||
|
claimed = set(ctx.row.get("_sibling_claimed_uuids") or [])
|
||||||
|
found: List[str] = []
|
||||||
|
for uuid in self.discover(ctx):
|
||||||
|
if not uuid or uuid in claimed or uuid in found:
|
||||||
|
continue
|
||||||
|
if verify_session_uuid(ctx.cwd, self.name, uuid, row=ctx.row, home_dir=ctx.home_dir,
|
||||||
|
claude_dir=ctx.claude_dir, mode="discover"):
|
||||||
|
found.append(uuid)
|
||||||
|
if not found:
|
||||||
|
return capture_result("no-candidate", reason="no verified candidate newer than the session")
|
||||||
|
if len(found) > 1:
|
||||||
|
return capture_result("ambiguous", reason=f"{len(found)} candidates newer than the session",
|
||||||
|
candidates=found)
|
||||||
|
return capture_result("captured", session_id=found[0], candidates=found)
|
||||||
@@ -13,7 +13,7 @@ import sys
|
|||||||
from typing import List, Sequence
|
from typing import List, Sequence
|
||||||
|
|
||||||
# Framework version locked with framework constants
|
# Framework version locked with framework constants
|
||||||
FRAMEWORK_VERSION = "4.9.8"
|
FRAMEWORK_VERSION = "4.9.9"
|
||||||
|
|
||||||
CORE_MODULES: Sequence[str] = (
|
CORE_MODULES: Sequence[str] = (
|
||||||
# Standard library modules
|
# Standard library modules
|
||||||
|
|||||||
@@ -676,6 +676,7 @@ class LoopRunner:
|
|||||||
if status == "completed":
|
if status == "completed":
|
||||||
if self.verbose:
|
if self.verbose:
|
||||||
log_success(f"Job '{job_id}' completed successfully.")
|
log_success(f"Job '{job_id}' completed successfully.")
|
||||||
|
self._autopin(job_id)
|
||||||
return True
|
return True
|
||||||
elif status in ("error", "cancelled"):
|
elif status in ("error", "cancelled"):
|
||||||
log_error(f"Job '{job_id}' finished with status '{status}'.")
|
log_error(f"Job '{job_id}' finished with status '{status}'.")
|
||||||
@@ -686,6 +687,23 @@ class LoopRunner:
|
|||||||
log_error(f"Job '{job_id}' timed out after {max_wait}s.")
|
log_error(f"Job '{job_id}' timed out after {max_wait}s.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _autopin(self, job_id: str) -> None:
|
||||||
|
"""Issue #40 recovery path: the handler the wrapper already ran for this
|
||||||
|
job (one job/token/attempt is handled once), run like the wrapper from
|
||||||
|
the control root's skills - lib_py.loop imports no sibling but
|
||||||
|
delegation (G9). Never fails the wait; the handler reports on stderr."""
|
||||||
|
skills = self.workspace_root / ".agents" / "skills"
|
||||||
|
if not (skills / "lib_py" / "agents" / "autopin.py").exists():
|
||||||
|
skills = Path(__file__).resolve().parents[2]
|
||||||
|
yaml_path = os.environ.get("AGENT_SESSIONS_YAML") or str(self.mam_dir / "agent-sessions.yaml")
|
||||||
|
env = dict(os.environ)
|
||||||
|
env["PYTHONPATH"] = str(skills) + (os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "")
|
||||||
|
try:
|
||||||
|
subprocess.run([sys.executable, "-m", "lib_py.agents.autopin", "--registry-dir", str(self.jobs_dir),
|
||||||
|
"--job", job_id, "--yaml", yaml_path], env=env, timeout=300)
|
||||||
|
except Exception as exc:
|
||||||
|
log_warn(f"autopin for job '{job_id}' failed: {exc}")
|
||||||
|
|
||||||
def _relay_while_submitting(self, label: str, since: str, stop: threading.Event) -> None:
|
def _relay_while_submitting(self, label: str, since: str, stop: threading.Event) -> None:
|
||||||
"""Issue #30 (review R2): the wrapper runs until its job ends and its
|
"""Issue #30 (review R2): the wrapper runs until its job ends and its
|
||||||
output is captured, so a blocked agent would only reach the loop log
|
output is captured, so a blocked agent would only reach the loop log
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: multi-agent-mux-create
|
name: multi-agent-mux-create
|
||||||
description: "Create a new agent session (claude, antigravity/agy) in a dedicated herdr session for context-preserving long-running work. Always creates a herdr session — never backgrounds with nohup/disown. Writes the new session to .mam/agent-sessions.yaml. Use when you want to start a fresh agent (no prior UUID) for a new project workspace."
|
description: "Create a new agent session (claude, antigravity/agy) in a dedicated herdr session for context-preserving long-running work. Always creates a herdr session — never backgrounds with nohup/disown. Writes the new session to .mam/agent-sessions.yaml. Use when you want to start a fresh agent (no prior UUID) for a new project workspace."
|
||||||
version: 4.9.8
|
version: 4.9.9
|
||||||
author: godopu
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
platforms: [linux, macos]
|
||||||
@@ -220,6 +220,7 @@ bash .agents/skills/multi-agent-mux-create/scripts/worktree_prepare.sh \
|
|||||||
- **Don't use `nohup`/`disown`/`setsid` for the agent itself** — those background the agent outside herdr. The whole point of this skill is *the herdr session is the supervisor*. `nohup` is OK only for *launching the wrapper* (which itself creates the herdr session via `herdr new-session -d`).
|
- **Don't use `nohup`/`disown`/`setsid` for the agent itself** — those background the agent outside herdr. The whole point of this skill is *the herdr session is the supervisor*. `nohup` is OK only for *launching the wrapper* (which itself creates the herdr session via `herdr new-session -d`).
|
||||||
- **Claude Session ID Auto-Assignment**: Fresh `claude` sessions automatically generate a new UUID (`mam_gen_uuid`) passed via `claude --session-id <uuid>`. `claude_session_id_own` is stored in `.mam/agent-sessions.yaml` with `session_id_source: assigned` and `session_id_verified: false`.
|
- **Claude Session ID Auto-Assignment**: Fresh `claude` sessions automatically generate a new UUID (`mam_gen_uuid`) passed via `claude --session-id <uuid>`. `claude_session_id_own` is stored in `.mam/agent-sessions.yaml` with `session_id_source: assigned` and `session_id_verified: false`.
|
||||||
- **First Message Materialization**: The transcript file `.jsonl` is created on disk when the first user prompt is sent. The monitor loop (`reconcile.sh`) verifies the transcript and promotes `session_id_verified: true` and `last_visible_status: pinned`.
|
- **First Message Materialization**: The transcript file `.jsonl` is created on disk when the first user prompt is sent. The monitor loop (`reconcile.sh`) verifies the transcript and promotes `session_id_verified: true` and `last_visible_status: pinned`.
|
||||||
|
- **Post-discovered ids (agy, hermes, opencode, codex)**: these rows start with no id (`session_id_source: pending-discovery`). With `--onboard`, create pins the id itself once the onboarding job completes (Issue #40, `lib_py.agents.autopin`), monitor or not; later, every completed delegate job does the same until the id is set.
|
||||||
- **Wrapper script MUST NOT be created via `hermes profile alias`** — that command writes a `hermes -p <profile>` wrapper that destroys the herdr behavior. Create wrappers manually (see `lab-landing-page-creator-claude` template).
|
- **Wrapper script MUST NOT be created via `hermes profile alias`** — that command writes a `hermes -p <profile>` wrapper that destroys the herdr behavior. Create wrappers manually (see `lab-landing-page-creator-claude` template).
|
||||||
- **Always use the workspace-relative path** in herdr `cwd` — relative paths break when herdr respawns in a different shell context.
|
- **Always use the workspace-relative path** in herdr `cwd` — relative paths break when herdr respawns in a different shell context.
|
||||||
|
|
||||||
|
|||||||
@@ -751,6 +751,9 @@ If you delegate any part of it with multi-agent-mux-delegate-job submit, add --p
|
|||||||
case "$ONBOARD_STATUS" in
|
case "$ONBOARD_STATUS" in
|
||||||
completed)
|
completed)
|
||||||
echo "onboarding completed: job $DELEGATE_JOB_ID ($(( $(date +%s) - ONBOARD_T0 ))s)"
|
echo "onboarding completed: job $DELEGATE_JOB_ID ($(( $(date +%s) - ONBOARD_T0 ))s)"
|
||||||
|
# Issue #40: the first turn is done, so a post-discovered id is on disk -
|
||||||
|
# pin it now instead of waiting for a monitor (worktree creators run none).
|
||||||
|
delegate_autopin_completed "$DELEGATE_JOB_ID" "$DELEGATE_JOB_TOKEN" "$task_reg_dir"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
# The session is kept, as for a missing handshake above: the row says
|
# The session is kept, as for a missing handshake above: the row says
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: multi-agent-mux-delegate-job
|
name: multi-agent-mux-delegate-job
|
||||||
description: "Delegate a unit of work to any autonomous agent (claude-code, hermes, agy, grok-build, codex, or a human) and observe it asynchronously over an MQTT event channel. Supported roles include orchestrator, worker, and reviewer."
|
description: "Delegate a unit of work to any autonomous agent (claude-code, hermes, agy, grok-build, codex, or a human) and observe it asynchronously over an MQTT event channel. Supported roles include orchestrator, worker, and reviewer."
|
||||||
version: 4.9.8
|
version: 4.9.9
|
||||||
author: godopu
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos, windows]
|
platforms: [linux, macos, windows]
|
||||||
|
|||||||
@@ -451,6 +451,11 @@ EOF
|
|||||||
|
|
||||||
finalize_unfinished "$JOB_ID" "$job_status" "$sub_rc" "direct"
|
finalize_unfinished "$JOB_ID" "$job_status" "$sub_rc" "direct"
|
||||||
job_status="${FINAL_JOB_STATUS:-$job_status}"
|
job_status="${FINAL_JOB_STATUS:-$job_status}"
|
||||||
|
# Issue #40: this subscriber verified the `completed` (or the disk did) -
|
||||||
|
# pin the session's post-discovered id now, daemon or not.
|
||||||
|
if [ "$job_status" = "completed" ]; then
|
||||||
|
delegate_autopin_completed "$JOB_ID" "$_disp_tok" "$REGISTRY_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
# Last stdout line: the persistent audit-log dir for this job (see SKILL.md
|
# Last stdout line: the persistent audit-log dir for this job (see SKILL.md
|
||||||
# "Audit Logs"). Callers can scrape `tail -n1` to find it.
|
# "Audit Logs"). Callers can scrape `tail -n1` to find it.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: multi-agent-mux-loop
|
name: multi-agent-mux-loop
|
||||||
description: "Run an autonomous planning-execution-review loop using multiple agents (Planner, Creator, Reviewers) in the workspace. Automatically orchestrates plan discussion, code changes, and peer reviews until a unanimous PASS is achieved or the maximum iteration limit is reached."
|
description: "Run an autonomous planning-execution-review loop using multiple agents (Planner, Creator, Reviewers) in the workspace. Automatically orchestrates plan discussion, code changes, and peer reviews until a unanimous PASS is achieved or the maximum iteration limit is reached."
|
||||||
version: 4.9.8
|
version: 4.9.9
|
||||||
author: godopu
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
platforms: [linux, macos]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: multi-agent-mux-monitor
|
name: multi-agent-mux-monitor
|
||||||
description: "Run a long-lived reconciler that watches .mam/agent-sessions.yaml against the actual herdr/agent runtime state and reconciles them. Use when you want live visibility into which agent sessions are running, which are dead, which have stale YAML entries, and which have new session ids that haven't been recorded yet. Runs as a persistent loop (`reconcile.sh --subscribe`) that keeps going until it times out, idles out, or is interrupted."
|
description: "Run a long-lived reconciler that watches .mam/agent-sessions.yaml against the actual herdr/agent runtime state and reconciles them. Use when you want live visibility into which agent sessions are running, which are dead, which have stale YAML entries, and which have new session ids that haven't been recorded yet. Runs as a persistent loop (`reconcile.sh --subscribe`) that keeps going until it times out, idles out, or is interrupted."
|
||||||
version: 4.9.8
|
version: 4.9.9
|
||||||
author: godopu
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
platforms: [linux, macos]
|
||||||
@@ -161,6 +161,8 @@ YAML: no such session
|
|||||||
→ report: "lab-landing-page-creator-claude: session id materialized b3a7...c2f"
|
→ report: "lab-landing-page-creator-claude: session id materialized b3a7...c2f"
|
||||||
```
|
```
|
||||||
- **C-ambiguous. Multiple candidates detected**: If multiple candidate transcripts match an unassigned session, the monitor avoids random pinning, reports `C-ambiguous`, and sets `last_visible_status: "ambiguous: N candidates"`.
|
- **C-ambiguous. Multiple candidates detected**: If multiple candidate transcripts match an unassigned session, the monitor avoids random pinning, reports `C-ambiguous`, and sets `last_visible_status: "ambiguous: N candidates"`.
|
||||||
|
- **Post-discovered ids (agy, hermes, opencode, codex; Issue #40)**: drift C asks the adapter's `capture_active_session_id`, the decision the completed handler (`lib_py.agents.autopin`) also uses. A candidate must be newer than the session, pass verification and be recorded by no other row. For agy, `last_conversations.json[cwd]` only proposes the id: the conversation's presence lock (or DB) must be held by the row's pane process or a descendant, and an id the cache maps to another workspace is rejected. The conversations folder is no longer scanned. A pin records `session_id_source: discovered` and `session_id_verified: true`.
|
||||||
|
- **Pinned without a monitor**: the side that verified a job's `completed` (the delegate-job wrapper, `create --onboard`, the loop's job wait) pins the session's post-discovered id at once, so worktree creators (`MAM_NO_SUBSCRIBER_DAEMON=1`) need no reconcile. One job/token/attempt is handled once; a failed or ambiguous capture leaves `last_visible_status: "autopin <status> (job <id>): <reason>"` and the job's result unchanged.
|
||||||
|
|
||||||
## Pitfalls
|
## Pitfalls
|
||||||
|
|
||||||
|
|||||||
@@ -420,7 +420,7 @@ import yaml
|
|||||||
|
|
||||||
from lib_py.verify_session import verify_session_uuid, workspace_key
|
from lib_py.verify_session import verify_session_uuid, workspace_key
|
||||||
from lib_py.agents.registry import get_adapter
|
from lib_py.agents.registry import get_adapter
|
||||||
from lib_py.agents.base import DiscoveryContext, _ro_connect
|
from lib_py.agents import autopin
|
||||||
|
|
||||||
def _slug(path):
|
def _slug(path):
|
||||||
if not path:
|
if not path:
|
||||||
@@ -554,17 +554,9 @@ def pane_meta(session, srv):
|
|||||||
from lib_py.agents.registry import get_adapter, own_key as _get_own_key, agent_of_row
|
from lib_py.agents.registry import get_adapter, own_key as _get_own_key, agent_of_row
|
||||||
|
|
||||||
def _pin_and_verify_resume(s, agent, cwd, uuid, degraded=False):
|
def _pin_and_verify_resume(s, agent, cwd, uuid, degraded=False):
|
||||||
own_key_field = _get_own_key(agent)
|
# the completed handler's pin (Issue #40): own id, source and verified together
|
||||||
if own_key_field:
|
autopin.pin_row(s, agent, uuid)
|
||||||
s[own_key_field] = uuid
|
s['last_visible_status'] = autopin.resume_dry_run(skills_dir, cwd, agent, s['name'])
|
||||||
s['last_visible_status'] = 'pinned'
|
|
||||||
resume_cmd = ['bash', os.path.join(skills_dir, 'multi-agent-mux-resume', 'scripts', 'resume_session.sh'),
|
|
||||||
'--workspace', cwd, '--agent', agent, '--session', s['name'], '--dry-run']
|
|
||||||
res = subprocess.run(resume_cmd, capture_output=True, text=True)
|
|
||||||
if res.returncode == 0:
|
|
||||||
s['last_visible_status'] = 'resume_verified'
|
|
||||||
else:
|
|
||||||
s['last_visible_status'] = f"resume dry-run failed: {res.stderr.strip() or res.stdout.strip()}"
|
|
||||||
|
|
||||||
id_name = 'session' if agent == 'claude' else 'conversation'
|
id_name = 'session' if agent == 'claude' else 'conversation'
|
||||||
if not degraded:
|
if not degraded:
|
||||||
@@ -783,314 +775,40 @@ for s in d.get('herdr_sessions', []):
|
|||||||
else:
|
else:
|
||||||
_pin_and_verify_resume(s, 'claude', cwd, uuid, degraded=True)
|
_pin_and_verify_resume(s, 'claude', cwd, uuid, degraded=True)
|
||||||
|
|
||||||
# === drift C (agy): agy 새 session id materialize (per-row own id) ===
|
# === drift C (agy), drift C (hermes), drift C (opencode), drift C (codex) ===
|
||||||
|
# Post-discovered ids (Issue #40): the adapter's capture_active_session_id
|
||||||
|
# decides - the same decision the completed handler (lib_py.agents.autopin)
|
||||||
|
# makes - so the two paths cannot drift apart. Exactly one verified candidate
|
||||||
|
# newer than the session that no other row records; agy additionally needs its
|
||||||
|
# presence lock held by this row's pane (its cwd cache alone proves nothing).
|
||||||
for s in d.get('herdr_sessions', []):
|
for s in d.get('herdr_sessions', []):
|
||||||
if row_agent(s) != 'agy':
|
agent = row_agent(s)
|
||||||
|
if agent not in ('agy', 'hermes', 'opencode', 'codex'):
|
||||||
continue
|
continue
|
||||||
if s.get('status') != 'running':
|
if s.get('status') != 'running':
|
||||||
continue
|
continue
|
||||||
if s.get('agy_conversation_id_own'):
|
if s.get(OWN_KEY_BY_AGENT[agent]):
|
||||||
continue
|
continue
|
||||||
cwd = (s.get('pane') or {}).get('cwd', '')
|
cwd = (s.get('pane') or {}).get('cwd', '')
|
||||||
if not cwd:
|
if not cwd:
|
||||||
continue
|
continue
|
||||||
conv_dir = f"{home}/.gemini/antigravity-cli/conversations"
|
res = autopin.capture(s, agent, d, home_dir=home)
|
||||||
if not os.path.isdir(conv_dir):
|
if res['status'] == 'ambiguous':
|
||||||
continue
|
n = len(res['candidates'])
|
||||||
dbs = sorted(glob.glob(f"{conv_dir}/*.db"), key=os.path.getmtime, reverse=True)
|
|
||||||
|
|
||||||
sibling_claimed = [
|
|
||||||
other.get('agy_conversation_id_own')
|
|
||||||
for other in d.get('herdr_sessions', [])
|
|
||||||
if other is not s
|
|
||||||
and (other.get('pane') or {}).get('cwd') == cwd
|
|
||||||
and other.get('status') not in ('stopped', 'terminated')
|
|
||||||
and other.get('agy_conversation_id_own')
|
|
||||||
]
|
|
||||||
s_eval = dict(s)
|
|
||||||
s_eval['_sibling_claimed_uuids'] = sibling_claimed
|
|
||||||
|
|
||||||
valid_candidates = []
|
|
||||||
for db in dbs:
|
|
||||||
uuid = os.path.basename(db)[:-3]
|
|
||||||
if verify_session_uuid(cwd, 'agy', uuid, s_eval, mode="discover"):
|
|
||||||
valid_candidates.append(uuid)
|
|
||||||
|
|
||||||
if len(valid_candidates) > 1:
|
|
||||||
drifts.append({'class': 'C-ambiguous', 'name': s['name'],
|
drifts.append({'class': 'C-ambiguous', 'name': s['name'],
|
||||||
'msg': f"{s['name']}: {len(valid_candidates)} candidate transcripts newer than session epoch; not pinning"})
|
'msg': f"{s['name']}: {n} candidate transcripts newer than session epoch; not pinning"})
|
||||||
s['last_visible_status'] = f"ambiguous: {len(valid_candidates)} candidates"
|
s['last_visible_status'] = f"ambiguous: {n} candidates"
|
||||||
actions.append(f"ambiguous candidates: {s['name']}")
|
actions.append(f"ambiguous candidates: {s['name']}")
|
||||||
if len(valid_candidates) == 1:
|
elif res['status'] == 'captured':
|
||||||
uuid = valid_candidates[0]
|
uuid = res['session_id']
|
||||||
cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "agy" "{cwd}"']
|
cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "{agent}" "{cwd}"']
|
||||||
rc = subprocess.run(cmd).returncode
|
rc = subprocess.run(cmd).returncode
|
||||||
if rc == 0:
|
if rc == 0:
|
||||||
_pin_and_verify_resume(s, 'agy', cwd, uuid, degraded=False)
|
_pin_and_verify_resume(s, agent, cwd, uuid, degraded=False)
|
||||||
elif rc == 1:
|
elif rc == 1:
|
||||||
drifts.append({'class': 'C-warn', 'name': s['name'], 'msg': f"{s['name']}: TUI viewport mismatch for candidate {uuid} — not pinned, will retry next cycle"})
|
drifts.append({'class': 'C-warn', 'name': s['name'], 'msg': f"{s['name']}: TUI viewport mismatch for candidate {uuid} — not pinned, will retry next cycle"})
|
||||||
else:
|
else:
|
||||||
_pin_and_verify_resume(s, 'agy', cwd, uuid, degraded=True)
|
_pin_and_verify_resume(s, agent, cwd, uuid, degraded=True)
|
||||||
|
|
||||||
# === drift C (hermes): hermes 새 session id materialize (per-row own id) ===
|
|
||||||
for s in d.get('herdr_sessions', []):
|
|
||||||
if row_agent(s) != 'hermes':
|
|
||||||
continue
|
|
||||||
if s.get('status') != 'running':
|
|
||||||
continue
|
|
||||||
if s.get('hermes_conversation_id_own'):
|
|
||||||
continue
|
|
||||||
cwd = (s.get('pane') or {}).get('cwd', '')
|
|
||||||
if not cwd:
|
|
||||||
continue
|
|
||||||
hdb = f"{home}/.hermes/state.db"
|
|
||||||
if not os.path.exists(hdb):
|
|
||||||
continue
|
|
||||||
|
|
||||||
epoch_threshold = s.get('herdr_session_epoch', 0)
|
|
||||||
|
|
||||||
sibling_claimed = [
|
|
||||||
other.get('hermes_conversation_id_own')
|
|
||||||
for other in d.get('herdr_sessions', [])
|
|
||||||
if other is not s
|
|
||||||
and (other.get('pane') or {}).get('cwd') == cwd
|
|
||||||
and other.get('status') not in ('stopped', 'terminated')
|
|
||||||
and other.get('hermes_conversation_id_own')
|
|
||||||
]
|
|
||||||
s_eval = dict(s)
|
|
||||||
s_eval['_sibling_claimed_uuids'] = sibling_claimed
|
|
||||||
|
|
||||||
valid_candidates = []
|
|
||||||
try:
|
|
||||||
conn = _ro_connect(hdb)
|
|
||||||
rows = conn.execute(
|
|
||||||
"SELECT id FROM sessions WHERE cwd=? AND started_at >= ? ORDER BY started_at DESC LIMIT 20",
|
|
||||||
(cwd, epoch_threshold)
|
|
||||||
).fetchall()
|
|
||||||
conn.close()
|
|
||||||
for (uuid,) in rows:
|
|
||||||
if uuid in (sibling_claimed or []):
|
|
||||||
continue
|
|
||||||
if verify_session_uuid(cwd, 'hermes', uuid, s_eval, mode="discover"):
|
|
||||||
valid_candidates.append(uuid)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if len(valid_candidates) > 1:
|
|
||||||
drifts.append({'class': 'C-ambiguous', 'name': s['name'],
|
|
||||||
'msg': f"{s['name']}: {len(valid_candidates)} candidate transcripts newer than session epoch; not pinning"})
|
|
||||||
s['last_visible_status'] = f"ambiguous: {len(valid_candidates)} candidates"
|
|
||||||
actions.append(f"ambiguous candidates: {s['name']}")
|
|
||||||
if len(valid_candidates) == 1:
|
|
||||||
uuid = valid_candidates[0]
|
|
||||||
cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "hermes" "{cwd}"']
|
|
||||||
rc = subprocess.run(cmd).returncode
|
|
||||||
if rc == 0:
|
|
||||||
_pin_and_verify_resume(s, 'hermes', cwd, uuid, degraded=False)
|
|
||||||
elif rc == 1:
|
|
||||||
drifts.append({'class': 'C-warn', 'name': s['name'], 'msg': f"{s['name']}: TUI viewport mismatch for candidate {uuid} — not pinned, will retry next cycle"})
|
|
||||||
else:
|
|
||||||
_pin_and_verify_resume(s, 'hermes', cwd, uuid, degraded=True)
|
|
||||||
|
|
||||||
# === drift C (opencode): opencode 새 session id materialize (per-row own id) ===
|
|
||||||
for s in d.get('herdr_sessions', []):
|
|
||||||
if row_agent(s) != 'opencode':
|
|
||||||
continue
|
|
||||||
if s.get('status') != 'running':
|
|
||||||
continue
|
|
||||||
if s.get('opencode_session_id_own'):
|
|
||||||
continue
|
|
||||||
cwd = (s.get('pane') or {}).get('cwd', '')
|
|
||||||
if not cwd:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Resolve DB path via shared OpenCodeAgentAdapter helper with fallbacks
|
|
||||||
odb = None
|
|
||||||
try:
|
|
||||||
op_adapter = get_adapter('opencode')
|
|
||||||
if op_adapter:
|
|
||||||
odb = op_adapter._resolve_db(DiscoveryContext(workspace=cwd, agent_name='opencode', home_dir=home))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not odb:
|
|
||||||
opencode_base = f"{home}/.local/share/opencode"
|
|
||||||
candidates = [
|
|
||||||
f"{opencode_base}/opencode.db",
|
|
||||||
f"{opencode_base}/storage.db",
|
|
||||||
]
|
|
||||||
for cand in candidates:
|
|
||||||
if os.path.exists(cand):
|
|
||||||
odb = cand
|
|
||||||
break
|
|
||||||
if not odb and os.path.isdir(f"{opencode_base}/project"):
|
|
||||||
for root, _, files in os.walk(f"{opencode_base}/project"):
|
|
||||||
for f in files:
|
|
||||||
if f.endswith(".db"):
|
|
||||||
odb = os.path.join(root, f)
|
|
||||||
break
|
|
||||||
if odb and os.path.exists(odb):
|
|
||||||
break
|
|
||||||
|
|
||||||
if not odb or not os.path.exists(odb):
|
|
||||||
continue
|
|
||||||
|
|
||||||
epoch_threshold = s.get('herdr_session_epoch', 0)
|
|
||||||
|
|
||||||
sibling_claimed = [
|
|
||||||
other.get('opencode_session_id_own')
|
|
||||||
for other in d.get('herdr_sessions', [])
|
|
||||||
if other is not s
|
|
||||||
and (other.get('pane') or {}).get('cwd') == cwd
|
|
||||||
and other.get('status') not in ('stopped', 'terminated')
|
|
||||||
and other.get('opencode_session_id_own')
|
|
||||||
]
|
|
||||||
s_eval = dict(s)
|
|
||||||
s_eval['_sibling_claimed_uuids'] = sibling_claimed
|
|
||||||
|
|
||||||
valid_candidates = []
|
|
||||||
try:
|
|
||||||
# read-only: nothing is created or removed beside the agent's db (Issue #60)
|
|
||||||
conn = _ro_connect(odb)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
# opencode 2.x keeps sessions in session_v2 (a migrated 1.x `session`
|
|
||||||
# table is no longer written).
|
|
||||||
cursor.execute("PRAGMA table_info(session_v2)")
|
|
||||||
cols = [col[1] for col in cursor.fetchall()]
|
|
||||||
if cols:
|
|
||||||
cwd_col = "directory" if "directory" in cols else ("cwd" if "cwd" in cols else None)
|
|
||||||
time_col = "time_created" if "time_created" in cols else ("created_at" if "created_at" in cols else ("started_at" if "started_at" in cols else None))
|
|
||||||
|
|
||||||
query = "SELECT id FROM session_v2"
|
|
||||||
params = []
|
|
||||||
where_clauses = []
|
|
||||||
if "parent_id" in cols:
|
|
||||||
# a subagent's session shares the directory and is newer
|
|
||||||
where_clauses.append("parent_id IS NULL")
|
|
||||||
if cwd_col and cwd:
|
|
||||||
where_clauses.append(f"{cwd_col} = ?")
|
|
||||||
params.append(cwd)
|
|
||||||
if time_col and epoch_threshold:
|
|
||||||
if time_col == "time_created":
|
|
||||||
where_clauses.append(f"{time_col} >= ?")
|
|
||||||
params.append(int(float(epoch_threshold) * 1000))
|
|
||||||
else:
|
|
||||||
where_clauses.append(f"{time_col} >= ?")
|
|
||||||
params.append(epoch_threshold)
|
|
||||||
if where_clauses:
|
|
||||||
query += " WHERE " + " AND ".join(where_clauses)
|
|
||||||
if time_col:
|
|
||||||
query += f" ORDER BY {time_col} DESC"
|
|
||||||
query += " LIMIT 20"
|
|
||||||
rows = cursor.execute(query, params).fetchall()
|
|
||||||
conn.close()
|
|
||||||
for (uuid,) in rows:
|
|
||||||
if uuid in (sibling_claimed or []):
|
|
||||||
continue
|
|
||||||
if verify_session_uuid(cwd, 'opencode', uuid, s_eval, mode="discover"):
|
|
||||||
valid_candidates.append(uuid)
|
|
||||||
else:
|
|
||||||
conn.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if len(valid_candidates) > 1:
|
|
||||||
drifts.append({'class': 'C-ambiguous', 'name': s['name'],
|
|
||||||
'msg': f"{s['name']}: {len(valid_candidates)} candidate transcripts newer than session epoch; not pinning"})
|
|
||||||
s['last_visible_status'] = f"ambiguous: {len(valid_candidates)} candidates"
|
|
||||||
actions.append(f"ambiguous candidates: {s['name']}")
|
|
||||||
if len(valid_candidates) == 1:
|
|
||||||
uuid = valid_candidates[0]
|
|
||||||
cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "opencode" "{cwd}"']
|
|
||||||
rc = subprocess.run(cmd).returncode
|
|
||||||
if rc == 0:
|
|
||||||
_pin_and_verify_resume(s, 'opencode', cwd, uuid, degraded=False)
|
|
||||||
elif rc == 1:
|
|
||||||
drifts.append({'class': 'C-warn', 'name': s['name'], 'msg': f"{s['name']}: TUI viewport mismatch for candidate {uuid} — not pinned, will retry next cycle"})
|
|
||||||
else:
|
|
||||||
_pin_and_verify_resume(s, 'opencode', cwd, uuid, degraded=True)
|
|
||||||
|
|
||||||
# === drift C (codex): codex 새 session id materialize (per-row own id) ===
|
|
||||||
for s in d.get('herdr_sessions', []):
|
|
||||||
if row_agent(s) != 'codex':
|
|
||||||
continue
|
|
||||||
if s.get('status') != 'running':
|
|
||||||
continue
|
|
||||||
if s.get('codex_session_id_own'):
|
|
||||||
continue
|
|
||||||
cwd = (s.get('pane') or {}).get('cwd', '')
|
|
||||||
if not cwd:
|
|
||||||
continue
|
|
||||||
|
|
||||||
codex_home = os.environ.get('CODEX_HOME') or f"{home}/.codex"
|
|
||||||
epoch_threshold = s.get('herdr_session_epoch', 0)
|
|
||||||
|
|
||||||
sibling_claimed = [
|
|
||||||
other.get('codex_session_id_own')
|
|
||||||
for other in d.get('herdr_sessions', [])
|
|
||||||
if other is not s
|
|
||||||
and (other.get('pane') or {}).get('cwd') == cwd
|
|
||||||
and other.get('status') not in ('stopped', 'terminated')
|
|
||||||
and other.get('codex_session_id_own')
|
|
||||||
]
|
|
||||||
s_eval = dict(s)
|
|
||||||
s_eval['_sibling_claimed_uuids'] = sibling_claimed
|
|
||||||
|
|
||||||
valid_candidates = []
|
|
||||||
try:
|
|
||||||
# Rev.2: state DB first (indexed, single query) — dynamic filename,
|
|
||||||
# no hardcoded suffix (see §1.5 / §2 _state_db()).
|
|
||||||
state_matches = glob.glob(f"{codex_home}/state_*.sqlite")
|
|
||||||
state_db = max(state_matches, key=os.path.getmtime) if state_matches else None
|
|
||||||
rows = []
|
|
||||||
if state_db and os.path.exists(state_db):
|
|
||||||
try:
|
|
||||||
conn = _ro_connect(state_db)
|
|
||||||
cols = [c[1] for c in conn.execute("PRAGMA table_info(threads)").fetchall()]
|
|
||||||
order_col = "updated_at" if "updated_at" in cols else ("created_at" if "created_at" in cols else None)
|
|
||||||
if order_col:
|
|
||||||
rows = conn.execute(
|
|
||||||
f"SELECT id, {order_col} FROM threads WHERE cwd=? ORDER BY {order_col} DESC LIMIT 20",
|
|
||||||
(cwd,)
|
|
||||||
).fetchall()
|
|
||||||
else:
|
|
||||||
rows = [(r[0], int(time.time())) for r in conn.execute("SELECT id FROM threads WHERE cwd=? LIMIT 20", (cwd,)).fetchall()]
|
|
||||||
conn.close()
|
|
||||||
except Exception:
|
|
||||||
rows = []
|
|
||||||
if not rows:
|
|
||||||
# Fallback: bounded glob over the date-nested tree
|
|
||||||
paths = sorted(
|
|
||||||
glob.glob(f"{codex_home}/sessions/*/*/*/rollout-*.jsonl"),
|
|
||||||
key=os.path.getmtime, reverse=True
|
|
||||||
)[:50]
|
|
||||||
rows = [(os.path.basename(p)[:-6][-36:], os.path.getmtime(p)) for p in paths]
|
|
||||||
|
|
||||||
for uuid, ts in rows:
|
|
||||||
if epoch_threshold and ts and ts < epoch_threshold:
|
|
||||||
continue
|
|
||||||
if uuid in (sibling_claimed or []):
|
|
||||||
continue
|
|
||||||
if verify_session_uuid(cwd, 'codex', uuid, s_eval, mode="discover"):
|
|
||||||
valid_candidates.append(uuid)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if len(valid_candidates) > 1:
|
|
||||||
drifts.append({'class': 'C-ambiguous', 'name': s['name'],
|
|
||||||
'msg': f"{s['name']}: {len(valid_candidates)} candidate transcripts newer than session epoch; not pinning"})
|
|
||||||
s['last_visible_status'] = f"ambiguous: {len(valid_candidates)} candidates"
|
|
||||||
actions.append(f"ambiguous candidates: {s['name']}")
|
|
||||||
elif len(valid_candidates) == 1:
|
|
||||||
uuid = valid_candidates[0]
|
|
||||||
cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "codex" "{cwd}"']
|
|
||||||
rc = subprocess.run(cmd).returncode
|
|
||||||
if rc == 0:
|
|
||||||
_pin_and_verify_resume(s, 'codex', cwd, uuid, degraded=False)
|
|
||||||
elif rc == 1:
|
|
||||||
drifts.append({'class': 'C-warn', 'name': s['name'], 'msg': f"{s['name']}: TUI viewport mismatch for candidate {uuid} — not pinned, will retry next cycle"})
|
|
||||||
else:
|
|
||||||
_pin_and_verify_resume(s, 'codex', cwd, uuid, degraded=True)
|
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
'timestamp': now_iso,
|
'timestamp': now_iso,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: multi-agent-mux-orc-onboard
|
name: multi-agent-mux-orc-onboard
|
||||||
description: "Register current or specified orchestrator session UUID into agent-sessions.yaml orchestrator_uuids list to prevent sub-agent discovery capture."
|
description: "Register current or specified orchestrator session UUID into agent-sessions.yaml orchestrator_uuids list to prevent sub-agent discovery capture."
|
||||||
version: 4.9.8
|
version: 4.9.9
|
||||||
author: godopu
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
platforms: [linux, macos]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: multi-agent-mux-resume
|
name: multi-agent-mux-resume
|
||||||
description: "Resume an existing agent (claude, antigravity/agy) conversation by UUID into a herdr session. Reads .mam/agent-sessions.yaml for the saved session/conversation id, spawns (or reuses) a herdr session of the matching name, and runs `claude -r <id>` or `agy --conversation <id>` inside. Use when you want to reattach to a previous session's context, or revive a session whose herdr died but the agent's conversation is still on disk."
|
description: "Resume an existing agent (claude, antigravity/agy) conversation by UUID into a herdr session. Reads .mam/agent-sessions.yaml for the saved session/conversation id, spawns (or reuses) a herdr session of the matching name, and runs `claude -r <id>` or `agy --conversation <id>` inside. Use when you want to reattach to a previous session's context, or revive a session whose herdr died but the agent's conversation is still on disk."
|
||||||
version: 4.9.8
|
version: 4.9.9
|
||||||
author: godopu
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
platforms: [linux, macos]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: multi-agent-mux-status
|
name: multi-agent-mux-status
|
||||||
description: "Read-only instant snapshot of all agent herdr sessions — name, YAML status, herdr alive, pane cmd/cwd, resume UUID on disk, and any drift. No mutation. Reuses reconcile.sh --dry-run for the diff logic. Use when you want to know 'what's running RIGHT NOW' without spinning up the monitor loop."
|
description: "Read-only instant snapshot of all agent herdr sessions — name, YAML status, herdr alive, pane cmd/cwd, resume UUID on disk, and any drift. No mutation. Reuses reconcile.sh --dry-run for the diff logic. Use when you want to know 'what's running RIGHT NOW' without spinning up the monitor loop."
|
||||||
version: 4.9.8
|
version: 4.9.9
|
||||||
author: godopu
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
platforms: [linux, macos]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: multi-agent-mux-stop
|
name: multi-agent-mux-stop
|
||||||
description: "Stop an agent herdr session (claude, antigravity/agy) and update .mam/agent-sessions.yaml. Default stops gracefully and marks status=stopped with conversation preserved for resume. Does NOT delete on-disk conversation artifacts (jsonl/db) — those are preserved unless --purge-conversation is passed. Use when ending a work session, switching to a different one, or cleaning up before a fresh start."
|
description: "Stop an agent herdr session (claude, antigravity/agy) and update .mam/agent-sessions.yaml. Default stops gracefully and marks status=stopped with conversation preserved for resume. Does NOT delete on-disk conversation artifacts (jsonl/db) — those are preserved unless --purge-conversation is passed. Use when ending a work session, switching to a different one, or cleaning up before a fresh start."
|
||||||
version: 4.9.8
|
version: 4.9.9
|
||||||
author: godopu
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
platforms: [linux, macos]
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
본 문서는 `herdr_agent_orchestration` 오케스트레이션 및 메시징 백플레인 워크플로우를 새로운 프로젝트에 도입하여 이식하고, 새로운 개발자/에이전트가 초기 가동을 시작할 때 수행해야 하는 환경 설정 및 구축 절차를 안내합니다.
|
본 문서는 `herdr_agent_orchestration` 오케스트레이션 및 메시징 백플레인 워크플로우를 새로운 프로젝트에 도입하여 이식하고, 새로운 개발자/에이전트가 초기 가동을 시작할 때 수행해야 하는 환경 설정 및 구축 절차를 안내합니다.
|
||||||
|
|
||||||
[](VERSIONS.md)
|
[](VERSIONS.md)
|
||||||
|
|
||||||
새로운 에이전트는 이 안내서에 기술된 절차를 순차적으로 실행하여 초기 환경을 안정적으로 설정할 수 있습니다.
|
새로운 에이전트는 이 안내서에 기술된 절차를 순차적으로 실행하여 초기 환경을 안정적으로 설정할 수 있습니다.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
This document guides you through the setup and initialization procedures required to adopt the `herdr_agent_orchestration` orchestration and messaging backplane workflow in a new project, enabling a new developer or agent to get up and running quickly.
|
This document guides you through the setup and initialization procedures required to adopt the `herdr_agent_orchestration` orchestration and messaging backplane workflow in a new project, enabling a new developer or agent to get up and running quickly.
|
||||||
|
|
||||||
[](VERSIONS.md)
|
[](VERSIONS.md)
|
||||||
|
|
||||||
A new agent can follow the steps in this guide sequentially to establish a stable and reliable initial environment.
|
A new agent can follow the steps in this guide sequentially to establish a stable and reliable initial environment.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Messaging System Technical Analysis & Architecture Report
|
# Messaging System Technical Analysis & Architecture Report
|
||||||
|
|
||||||
Applies to: MAM v4.9.8
|
Applies to: MAM v4.9.9
|
||||||
|
|
||||||
This report provides a comprehensive, deep-dive analysis of the messaging system implemented in the `multi-agent-mux-delegate-job` skill. It covers the MQTT broker architecture, event protocols, job lifecycles, codebase internals, cross-system integration, and a list of known limitations along with production recommendations.
|
This report provides a comprehensive, deep-dive analysis of the messaging system implemented in the `multi-agent-mux-delegate-job` skill. It covers the MQTT broker architecture, event protocols, job lifecycles, codebase internals, cross-system integration, and a list of known limitations along with production recommendations.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
[herdr](https://herdr.dev)와 MQTT 위에 만든 **다중 에이전트 오케스트레이션·메시징 백플레인**입니다. 장시간 실행되는 코딩 에이전트(`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`)를 격리된 herdr pane 에서 돌리고, 작업을 추적되는 잡으로 위임하며, 모든 리뷰어가 통과시킬 때까지 planner / creator / reviewer 루프를 돌립니다.
|
[herdr](https://herdr.dev)와 MQTT 위에 만든 **다중 에이전트 오케스트레이션·메시징 백플레인**입니다. 장시간 실행되는 코딩 에이전트(`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`)를 격리된 herdr pane 에서 돌리고, 작업을 추적되는 잡으로 위임하며, 모든 리뷰어가 통과시킬 때까지 planner / creator / reviewer 루프를 돌립니다.
|
||||||
|
|
||||||
[](VERSIONS.md)
|
[](VERSIONS.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A **Multi-Agent Orchestration & Messaging Backplane** built on [herdr](https://herdr.dev) and MQTT. It runs long-lived coding agents (`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`) in isolated herdr panes, delegates work to them as tracked jobs, and drives planner / creator / reviewer loops until every reviewer passes the change.
|
A **Multi-Agent Orchestration & Messaging Backplane** built on [herdr](https://herdr.dev) and MQTT. It runs long-lived coding agents (`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`) in isolated herdr panes, delegates work to them as tracked jobs, and drives planner / creator / reviewer loops until every reviewer passes the change.
|
||||||
|
|
||||||
[](VERSIONS.md)
|
[](VERSIONS.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+49
-11
@@ -6,8 +6,8 @@
|
|||||||
|
|
||||||
## 📌 현재 버전 개요 (Current Release)
|
## 📌 현재 버전 개요 (Current Release)
|
||||||
|
|
||||||
- **프레임워크 버전**: `v4.9.8`
|
- **프레임워크 버전**: `v4.9.9`
|
||||||
- **최신 릴리스 일시**: 2026-10-20 (KST)
|
- **최신 릴리스 일시**: 2026-10-21 (KST)
|
||||||
- **기준 브랜치**: `main`
|
- **기준 브랜치**: `main`
|
||||||
- **핵심 아키텍처**:
|
- **핵심 아키텍처**:
|
||||||
- **Terminal 상태 단방향 불변성 (`engine.py`, Issue #6 Phase 1)**: `completed`, `error`, `cancelled` 터미널 상태 진입 후 후속 이벤트나 업데이트에 의한 덮어쓰기를 엄격히 차단(Terminal Immutability). `registry.py close` 서브커맨드 및 fail-closed 조기 종료 도입.
|
- **Terminal 상태 단방향 불변성 (`engine.py`, Issue #6 Phase 1)**: `completed`, `error`, `cancelled` 터미널 상태 진입 후 후속 이벤트나 업데이트에 의한 덮어쓰기를 엄격히 차단(Terminal Immutability). `registry.py close` 서브커맨드 및 fail-closed 조기 종료 도입.
|
||||||
@@ -24,23 +24,61 @@
|
|||||||
|
|
||||||
## 🧭 스킬 패키지 버전 매트릭스 (Skills Version Matrix)
|
## 🧭 스킬 패키지 버전 매트릭스 (Skills Version Matrix)
|
||||||
|
|
||||||
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.9.8`로 동기화되어 배포됩니다.
|
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.9.9`로 동기화되어 배포됩니다.
|
||||||
|
|
||||||
| 스킬명 | 버전 | 역할 및 주요 책임 | 상태 |
|
| 스킬명 | 버전 | 역할 및 주요 책임 | 상태 |
|
||||||
| :--- | :---: | :--- | :---: |
|
| :--- | :---: | :--- | :---: |
|
||||||
| **`multi-agent-mux-create`** | `4.9.8` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
|
| **`multi-agent-mux-create`** | `4.9.9` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
|
||||||
| **`multi-agent-mux-stop`** | `4.9.8` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
|
| **`multi-agent-mux-stop`** | `4.9.9` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
|
||||||
| **`multi-agent-mux-resume`** | `4.9.8` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
|
| **`multi-agent-mux-resume`** | `4.9.9` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
|
||||||
| **`multi-agent-mux-status`** | `4.9.8` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
|
| **`multi-agent-mux-status`** | `4.9.9` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
|
||||||
| **`multi-agent-mux-monitor`** | `4.9.8` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
|
| **`multi-agent-mux-monitor`** | `4.9.9` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
|
||||||
| **`multi-agent-mux-delegate-job`** | `4.9.8` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
|
| **`multi-agent-mux-delegate-job`** | `4.9.9` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
|
||||||
| **`multi-agent-mux-loop`** | `4.9.8` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
|
| **`multi-agent-mux-loop`** | `4.9.9` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
|
||||||
| **`multi-agent-mux-orc-onboard`** | `4.9.8` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
|
| **`multi-agent-mux-orc-onboard`** | `4.9.9` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 버전별 상세 변경 내역 (Changelog)
|
## 📋 버전별 상세 변경 내역 (Changelog)
|
||||||
|
|
||||||
|
### 🚀 `v4.9.9` — completed 를 확인한 쪽이 세션 ID 를 등록하고, agy 는 다른 작업 공간의 대화를 받지 않는다 (Issue #40) (2026-10-21)
|
||||||
|
|
||||||
|
> **패치 (PATCH Release)**: 세션 ID 가 생성 뒤에야 생기는 에이전트(agy·opencode·codex·hermes)는 지금까지 모니터 데몬의 reconcile 이 찾아 등록했다. 데몬이 없는 워크트리 creator 는 등록되지 않았고, agy 의 drift C 는 호스트 공용 대화 폴더의 모든 `*.db` 를 후보로 보아 다른 작업 공간의 대화 ID 를 등록할 수 있었다. 이제 잡의 completed 를 확인한 쪽이 같은 판정으로 한 번 등록하고, reconcile 도 그 판정을 쓴다.
|
||||||
|
|
||||||
|
#### 🌟 주요 개선 사항
|
||||||
|
1. **어댑터 규격**: `BaseAgentAdapter.id_lifecycle`(`pre_assigned`: claude·grok / `post_discovered`: 나머지)과 `capture_active_session_id`. 세션 시작 뒤 갱신되고, 검증을 통과하고, 다른 행이 기록하지 않은 후보가 정확히 하나일 때만 ID 를 돌려준다. 아니면 이유(`no-candidate`, `ambiguous`, `unverifiable-owner` 등)를 돌려준다.
|
||||||
|
2. **agy**:
|
||||||
|
- `verify_artifact` 의 discover 모드는 `last_conversations.json` 이 다른 작업 공간에 매핑한 ID 를 거절한다. 다른 행이 기록한 ID 도 항상 제외한다.
|
||||||
|
- capture 는 캐시를 제안으로만 쓴다. 그 대화의 presence lock 이나 DB 를 행의 pane 프로세스(또는 그 자손)가 열고 있음을 `lsof`·`ps` 로 확인해야 등록한다. 확인할 수 없으면 등록하지 않는다.
|
||||||
|
3. **`lib_py/agents/autopin.py`**: `handle_completed` 를 completed 를 확인한 쪽이 부른다.
|
||||||
|
- 위임 래퍼(direct), `create --onboard`, 루프 `wait_for_job`(복구 경로).
|
||||||
|
- 로컬 herdr 잡만 처리한다(remote·human 제외).
|
||||||
|
- (잡, dispatch token, attempt) 하나는 한 번만 처리한다(잡 단위 flock 과 잡 폴더의 마커).
|
||||||
|
- 등록은 `atomic_dump_yaml` 로 쓰고 `session_id_source=discovered`, `session_id_verified=true` 를 남긴다. 실패하면 이유를 행에 남기고, 잡의 결과는 바꾸지 않는다.
|
||||||
|
- 에이전트가 실행하는 `publish_event.py` 에는 쓰기를 넣지 않았다.
|
||||||
|
4. **reconcile**: drift C 의 agy·hermes·opencode·codex 가 같은 capture 를 부른다. agy 의 `*.db` glob 은 없어졌다. `_pin_and_verify_resume` 은 `pin_row` 를 쓴다.
|
||||||
|
|
||||||
|
#### ✅ 품질 검증
|
||||||
|
- **회귀**: `tests/test_issue40_auto_pin.py`(27개). 이슈의 완료 조건 네 가지를 고정한다.
|
||||||
|
1. 다른 작업 공간의 ID 를 등록하지 않는다.
|
||||||
|
2. 데몬 없이(`MAM_NO_SUBSCRIBER_DAEMON=1`) 첫 completed 뒤 agy·opencode·codex·hermes 가 등록된다.
|
||||||
|
3. 래퍼와 루프 복구가 같은 잡을 처리해도 등록은 한 번이고, 후보가 모호하면 등록하지 않고 이유를 남긴다.
|
||||||
|
4. claude·grok 행과 이미 ID 가 있는 행은 바뀌지 않는다.
|
||||||
|
|
||||||
|
base(v4.9.8)에서 실패한다. `tests/test_issue64_ro_connect.py` 의 다른 작업 공간 agy 단언은 이제 거절(False)을 기대한다.
|
||||||
|
- **리뷰 (`--all-reviewer`, `--plan`, 구현은 claude creator)**: 1회차(@feccfd5) **4/4 PASS**. 리포트는 `.agents/reports/<session>/` 에 있다.
|
||||||
|
- **전체 스위트 (macOS, Python 3.9.6, @feccfd5)**: 2068 passed, 56 skipped, 3 failed.
|
||||||
|
- 실패 3개는 모두 `tests/test_issue42_skill_blocks.py` 였다. 이 변경이 옮긴 SKILL.md 블록과 `reconcile.sh` 근거 줄 번호가 목록에 남아 있었다.
|
||||||
|
- `tests/data/skill_code_blocks.json` 을 갱신한 뒤(@84af709) 이 파일은 37 passed, 1 skipped.
|
||||||
|
- 실제 `~/.claude`·`/private/tmp/claude-<uid>`·herdr 서버 pane 목록·opencode 의 data·state·config 에 이 실행이 남긴 것은 없었다.
|
||||||
|
- **알려진 비차단**:
|
||||||
|
- agy 등록은 `lsof`·`ps`, 행의 pane pid, agy 가 presence lock 이나 DB 를 열고 있음에 기댄다. `lsof` 가 없는 환경에서는 agy 가 자동 등록되지 않는다(오등록보다 미등록을 택함). completed 경로는 이유를 행에 남긴다.
|
||||||
|
- reconcile 은 `ambiguous` 만 이유를 남기고 `unverifiable-owner`·`owner-mismatch`·`rejected` 는 조용히 건너뛴다.
|
||||||
|
- id 가 빈 claude 행에서 `pre-assigned` 보호가 걸리는지 확인하는 테스트는 없다.
|
||||||
|
- 중복 마커는 실패 결과도 기록하므로, 래퍼 시점에 `no-candidate` 였던 잡은 루프 복구에서 다시 시도하지 않는다. 다음 잡의 completed 나 reconcile 이 다시 시도한다.
|
||||||
|
- 다른 행이 가진 ID 는 멈춘(stopped) 행의 것이어도 제외한다.
|
||||||
|
- stop 의 캡처 경로 정리는 범위 밖이다.
|
||||||
|
|
||||||
### 🚀 `v4.9.8` — agy·codex·hermes DB 조회도 읽기 전용 (Issue #64) (2026-10-20)
|
### 🚀 `v4.9.8` — agy·codex·hermes DB 조회도 읽기 전용 (Issue #64) (2026-10-20)
|
||||||
|
|
||||||
> **패치 (PATCH Release)**: v4.9.7 이 opencode 에 적용한 읽기 전용 열기(`_ro_connect`)를 agy·codex·hermes 의 조회에도 적용한다. macOS 의 시스템 SQLite 는 쓰기 가능으로 열었다 닫기만 해도 `-wal`·`-shm` 을 남긴다(persistent WAL). 그래서 MAM 의 조회가 다른 작업 공간의 agy 대화 DB 옆에 사이드카를 만들 수 있었다. Linux 는 닫을 때 지우므로 Linux 확인은 이슈에서 따로 한다.
|
> **패치 (PATCH Release)**: v4.9.7 이 opencode 에 적용한 읽기 전용 열기(`_ro_connect`)를 agy·codex·hermes 의 조회에도 적용한다. macOS 의 시스템 SQLite 는 쓰기 가능으로 열었다 닫기만 해도 `-wal`·`-shm` 을 남긴다(persistent WAL). 그래서 MAM 의 조회가 다른 작업 공간의 agy 대화 DB 옆에 사이드카를 만들 수 있었다. Linux 는 닫을 때 지우므로 Linux 확인은 이슈에서 따로 한다.
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ MAM은 에이전트별 동작 특성(TUI 프롬프트 패턴, 세션 복원 인
|
|||||||
│ │ • input_prompt, input_placeholder, input_rule_pattern │ │
|
│ │ • input_prompt, input_placeholder, input_rule_pattern │ │
|
||||||
│ │ • artifact_path(), verify_artifact(), purge_artifacts() │ │
|
│ │ • artifact_path(), verify_artifact(), purge_artifacts() │ │
|
||||||
│ │ • spawn_spec(), resume_spec(), auth_ok(), discover() │ │
|
│ │ • spawn_spec(), resume_spec(), auth_ok(), discover() │ │
|
||||||
|
│ │ • id_lifecycle, capture_active_session_id() (Issue #40) │ │
|
||||||
│ └────────────────────────────────────────────────────────────────┬─────────────────────────────────────────────────────────────────┘ │
|
│ └────────────────────────────────────────────────────────────────┬─────────────────────────────────────────────────────────────────┘ │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ ┌────────────────────┬───────────────────────────────┬───────────────────────────────┬─────────────────────┐ │
|
│ ┌────────────────────┬───────────────────────────────┬───────────────────────────────┬─────────────────────┐ │
|
||||||
@@ -70,7 +71,7 @@ MAM은 에이전트별 동작 특성(TUI 프롬프트 패턴, 세션 복원 인
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
from typing import Any, List, Optional
|
from typing import Any, List, Optional
|
||||||
from ..base import BaseAgentAdapter, DiscoveryContext
|
from ..base import BaseAgentAdapter, DiscoveryContext, PRE_ASSIGNED
|
||||||
|
|
||||||
|
|
||||||
class GrokAgentAdapter(BaseAgentAdapter):
|
class GrokAgentAdapter(BaseAgentAdapter):
|
||||||
@@ -169,8 +170,19 @@ class GrokAgentAdapter(BaseAgentAdapter):
|
|||||||
def discover(self, ctx: DiscoveryContext) -> List[str]:
|
def discover(self, ctx: DiscoveryContext) -> List[str]:
|
||||||
"""디스크의 세션 저장소에서 워크스페이스와 일치하는 세션 UUID 목록 탐색"""
|
"""디스크의 세션 저장소에서 워크스페이스와 일치하는 세션 UUID 목록 탐색"""
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 세션 ID 라이프사이클 (Issue #40)
|
||||||
|
# =========================================================================
|
||||||
|
@property
|
||||||
|
def id_lifecycle(self) -> str:
|
||||||
|
"""PRE_ASSIGNED: 기동 시 --session-id 로 ID 를 넘긴다 (claude, grok).
|
||||||
|
POST_DISCOVERED(기본값): 첫 턴 뒤 디스크에 생긴 ID 를 찾는다 (agy, opencode, codex, hermes)."""
|
||||||
|
return PRE_ASSIGNED
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**세션 ID 자동 확정 (Issue #40)**: `POST_DISCOVERED` 어댑터는 `capture_active_session_id(ctx)` 를 제공합니다. 기본 구현은 `discover()` 후보 중 `verify_session_uuid` 를 통과하고 다른 행이 기록하지 않은 것이 **정확히 하나**일 때만 `capture_result('captured', session_id=...)` 를 돌려주고, 아니면 `no-candidate` / `ambiguous` 등 이유를 돌려줍니다(읽기 전용). 후보 출처가 하나의 값만 주는 경우(agy 의 cwd 캐시처럼)에는 이 유일성이 아무것도 증명하지 못하므로, 그 어댑터는 이 메서드를 재정의해 소유를 따로 증명해야 합니다(agy: presence lock 을 쥔 프로세스가 그 행의 pane 프로세스 또는 자손). 실제 기록은 `completed` 를 검증한 수신 측(`lib_py.agents.autopin`: 위임 래퍼, `create --onboard`, 루프의 잡 대기)과 reconcile drift C 가 같은 판정으로 하며, 어댑터는 YAML/SQLite 에 쓰지 않습니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Step 2: 어댑터 레지스트리 등록 (`.agents/skills/lib_py/agents/registry.py`)
|
### Step 2: 어댑터 레지스트리 등록 (`.agents/skills/lib_py/agents/registry.py`)
|
||||||
|
|||||||
@@ -103,8 +103,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"skill": "multi-agent-mux-create",
|
"skill": "multi-agent-mux-create",
|
||||||
"start": 230,
|
"start": 231,
|
||||||
"end": 252,
|
"end": 253,
|
||||||
"lang": "bash",
|
"lang": "bash",
|
||||||
"sha": "d90cfd2555fd",
|
"sha": "d90cfd2555fd",
|
||||||
"class": "b",
|
"class": "b",
|
||||||
@@ -231,7 +231,7 @@
|
|||||||
"class": "c",
|
"class": "c",
|
||||||
"kind": "example",
|
"kind": "example",
|
||||||
"evidence": [
|
"evidence": [
|
||||||
"multi-agent-mux-monitor/scripts/reconcile.sh:601"
|
"multi-agent-mux-monitor/scripts/reconcile.sh:593"
|
||||||
],
|
],
|
||||||
"mismatch": null,
|
"mismatch": null,
|
||||||
"note": "drift A example"
|
"note": "drift A example"
|
||||||
@@ -266,8 +266,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"skill": "multi-agent-mux-monitor",
|
"skill": "multi-agent-mux-monitor",
|
||||||
"start": 182,
|
"start": 184,
|
||||||
"end": 210,
|
"end": 212,
|
||||||
"lang": "markdown",
|
"lang": "markdown",
|
||||||
"sha": "3f5d82c08fd9",
|
"sha": "3f5d82c08fd9",
|
||||||
"class": "b",
|
"class": "b",
|
||||||
@@ -282,8 +282,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"skill": "multi-agent-mux-monitor",
|
"skill": "multi-agent-mux-monitor",
|
||||||
"start": 229,
|
"start": 231,
|
||||||
"end": 233,
|
"end": 235,
|
||||||
"lang": "bash",
|
"lang": "bash",
|
||||||
"sha": "b3588563316e",
|
"sha": "b3588563316e",
|
||||||
"class": "c",
|
"class": "c",
|
||||||
@@ -401,7 +401,7 @@
|
|||||||
"evidence": [
|
"evidence": [
|
||||||
"multi-agent-mux-status/scripts/status.sh:31-36",
|
"multi-agent-mux-status/scripts/status.sh:31-36",
|
||||||
"multi-agent-mux-status/scripts/status.sh:209-232",
|
"multi-agent-mux-status/scripts/status.sh:209-232",
|
||||||
"multi-agent-mux-monitor/scripts/reconcile.sh:1084"
|
"multi-agent-mux-monitor/scripts/reconcile.sh:816"
|
||||||
],
|
],
|
||||||
"mismatch": [
|
"mismatch": [
|
||||||
"S1 (fixed): the example used rows/yaml_entries/unregistered; the real keys are reconcile.sh's drift object plus sessions_detail"
|
"S1 (fixed): the example used rows/yaml_entries/unregistered; the real keys are reconcile.sh's drift object plus sessions_detail"
|
||||||
|
|||||||
@@ -0,0 +1,599 @@
|
|||||||
|
"""Issue #40: a post-discovered session id (agy, opencode, codex, hermes) is
|
||||||
|
pinned once a local job's `completed` is verified, by the receiving side
|
||||||
|
(lib_py.agents.autopin), and agy never records another workspace's - or
|
||||||
|
another session's - conversation.
|
||||||
|
|
||||||
|
Completion conditions:
|
||||||
|
1) an unpinned agy row never records the id of a conversation that only
|
||||||
|
another workspace updated: test_agy_verify_rejects_id_cached_for_another_workspace,
|
||||||
|
test_reconcile_does_not_pin_another_workspaces_agy_conversation,
|
||||||
|
test_autopin_does_not_pin_another_workspaces_agy_conversation
|
||||||
|
2) the first completed job pins the session's own id, no daemon:
|
||||||
|
test_completed_onboarding_pins_agy_without_daemon (+ reconcile, lib.sh and
|
||||||
|
create --onboard variants), test_completed_first_job_pins_opencode_codex_hermes
|
||||||
|
3) one pin per job/token/attempt; ambiguity pins nothing and leaves a reason:
|
||||||
|
test_wrapper_and_loop_recovery_pin_once, test_ambiguous_candidates_leave_reason_without_pin
|
||||||
|
4) pre-assigned rows and rows with an id are untouched:
|
||||||
|
test_preassigned_and_existing_ids_are_untouched
|
||||||
|
|
||||||
|
Everything runs against temporary homes, registries and a mock herdr.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[1]
|
||||||
|
SKILLS = REPO / ".agents" / "skills"
|
||||||
|
|
||||||
|
UA = "a4000001-0000-4000-8000-000000000001"
|
||||||
|
UB = "b4000002-0000-4000-8000-000000000002"
|
||||||
|
UC = "c4000003-0000-4000-8000-000000000003"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- helpers
|
||||||
|
|
||||||
|
def _agy_conv(home, uuid, mtime=None):
|
||||||
|
db = Path(home) / ".gemini" / "antigravity-cli" / "conversations" / f"{uuid}.db"
|
||||||
|
db.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
conn.execute("CREATE TABLE steps (id INTEGER)")
|
||||||
|
conn.execute("INSERT INTO steps VALUES (1)")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
if mtime is not None:
|
||||||
|
os.utime(db, (mtime, mtime))
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def _agy_cache(home, mapping):
|
||||||
|
lc = Path(home) / ".gemini" / "antigravity-cli" / "cache" / "last_conversations.json"
|
||||||
|
lc.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
lc.write_text(json.dumps({str(k): v for k, v in mapping.items()}))
|
||||||
|
|
||||||
|
|
||||||
|
def _row(name, agent, cwd, epoch, pid=4242, **extra):
|
||||||
|
own = {"agy": "agy_conversation_id_own", "opencode": "opencode_session_id_own",
|
||||||
|
"codex": "codex_session_id_own", "hermes": "hermes_conversation_id_own",
|
||||||
|
"claude": "claude_session_id_own", "grok": "grok_session_id_own"}[agent]
|
||||||
|
row = {"name": name, "status": "running", "role": "creator", "agent": agent,
|
||||||
|
"herdr_session_epoch": epoch, own: None,
|
||||||
|
"session_id_source": "pending-discovery", "session_id_verified": False,
|
||||||
|
"last_visible_status": "unverified",
|
||||||
|
"pane": {"cwd": str(cwd), "pid": pid, "cmd": agent}}
|
||||||
|
row.update(extra)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _control_plane(root, rows):
|
||||||
|
mam = Path(root) / ".mam"
|
||||||
|
(mam / "jobs").mkdir(parents=True, exist_ok=True)
|
||||||
|
yaml_path = mam / "agent-sessions.yaml"
|
||||||
|
yaml_path.write_text(yaml.safe_dump({"herdr_sessions": rows}, sort_keys=False))
|
||||||
|
return yaml_path, mam / "jobs"
|
||||||
|
|
||||||
|
|
||||||
|
def _job(jobs_dir, job_id, session, status="completed", token="tok-1", attempt=1,
|
||||||
|
transport="herdr", agent="agy"):
|
||||||
|
rec = {"job_id": job_id, "status": status, "agent": agent, "agent_session": session,
|
||||||
|
"dispatch_token": token, "attempt_id": attempt, "transport": transport}
|
||||||
|
(Path(jobs_dir) / f"{job_id}.json").write_text(json.dumps(rec))
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(yaml_path):
|
||||||
|
return {r["name"]: r for r in yaml.safe_load(Path(yaml_path).read_text())["herdr_sessions"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _handle(jobs_dir, job_id, yaml_path, home, **kw):
|
||||||
|
from lib_py.agents import autopin
|
||||||
|
kw.setdefault("viewport", lambda name, agent, cwd: 0)
|
||||||
|
kw.setdefault("resume", lambda name, agent, cwd: "resume_verified")
|
||||||
|
return autopin.handle_completed(str(jobs_dir), job_id, yaml_path=str(yaml_path), home_dir=str(home), **kw)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def owners(monkeypatch):
|
||||||
|
"""agy presence ownership without lsof/ps: {uuid: [holder pids]} and a
|
||||||
|
pid -> ppid table the test fills in."""
|
||||||
|
from lib_py.agents.adapters import agy
|
||||||
|
held, parents = {}, {}
|
||||||
|
|
||||||
|
def holders(paths):
|
||||||
|
if held.get("__lsof__") is None and "__lsof__" in held:
|
||||||
|
return None
|
||||||
|
return sorted({p for path in paths for u, pids in held.items() if u in path for p in pids})
|
||||||
|
|
||||||
|
monkeypatch.setattr(agy, "_holder_pids", holders)
|
||||||
|
monkeypatch.setattr(agy, "_process_parents", lambda: dict(parents) or None)
|
||||||
|
return held, parents
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_opencode(tmp_path, monkeypatch):
|
||||||
|
"""No real opencode is ever asked for its paths: the adapter falls back
|
||||||
|
to <home>/.local/share/opencode/opencode.db."""
|
||||||
|
bindir = tmp_path / "fakebin"
|
||||||
|
bindir.mkdir(exist_ok=True)
|
||||||
|
exe = bindir / "opencode"
|
||||||
|
exe.write_text("#!/bin/sh\nexit 1\n")
|
||||||
|
exe.chmod(0o755)
|
||||||
|
monkeypatch.setenv("PATH", f"{bindir}:{os.environ.get('PATH', '')}")
|
||||||
|
monkeypatch.delenv("XDG_DATA_HOME", raising=False)
|
||||||
|
return exe
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_class_a(agent, home, ws, ws_other, sid, other_sid, started):
|
||||||
|
"""One session of `agent` in ws (sid) and one in another workspace (other_sid), both new."""
|
||||||
|
if agent == "opencode":
|
||||||
|
db = Path(home) / ".local" / "share" / "opencode" / "opencode.db"
|
||||||
|
db.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
conn.execute("CREATE TABLE IF NOT EXISTS session_v2 (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT, time_created INTEGER)")
|
||||||
|
for u, w, t in ((sid, ws, started), (other_sid, ws_other, started + 5)):
|
||||||
|
conn.execute("INSERT OR REPLACE INTO session_v2 VALUES (?, NULL, ?, ?)", (u, str(w), int(t * 1000)))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
elif agent == "hermes":
|
||||||
|
db = Path(home) / ".hermes" / "state.db"
|
||||||
|
db.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
conn.execute("CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, cwd TEXT, started_at REAL)")
|
||||||
|
for u, w, t in ((sid, ws, started), (other_sid, ws_other, started + 5)):
|
||||||
|
conn.execute("INSERT OR REPLACE INTO sessions VALUES (?, ?, ?)", (u, str(w), t))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
elif agent == "codex":
|
||||||
|
codex = Path(home) / ".codex"
|
||||||
|
sess = codex / "sessions" / "2026" / "10" / "20"
|
||||||
|
sess.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(str(codex / "state_5.sqlite"))
|
||||||
|
conn.execute("CREATE TABLE IF NOT EXISTS threads (id TEXT PRIMARY KEY, cwd TEXT, rollout_path TEXT, created_at INTEGER, updated_at INTEGER)")
|
||||||
|
for u, w, t in ((sid, ws, started), (other_sid, ws_other, started + 5)):
|
||||||
|
rollout = sess / f"rollout-2026-10-20T00-00-00-{u}.jsonl"
|
||||||
|
rollout.write_text(json.dumps({"payload": {"id": u, "cwd": str(w)}}) + "\n")
|
||||||
|
conn.execute("INSERT OR REPLACE INTO threads VALUES (?, ?, ?, ?, ?)", (u, str(w), str(rollout), int(t), int(t)))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- adapter contract
|
||||||
|
|
||||||
|
def test_adapter_id_lifecycle_classification():
|
||||||
|
from lib_py.agents.base import POST_DISCOVERED, PRE_ASSIGNED
|
||||||
|
from lib_py.agents.registry import get_adapter
|
||||||
|
assert {a: get_adapter(a).id_lifecycle for a in ("agy", "opencode", "codex", "hermes", "claude", "grok")} == {
|
||||||
|
"agy": POST_DISCOVERED, "opencode": POST_DISCOVERED, "codex": POST_DISCOVERED,
|
||||||
|
"hermes": POST_DISCOVERED, "claude": PRE_ASSIGNED, "grok": PRE_ASSIGNED}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- 1) another workspace's agy id
|
||||||
|
|
||||||
|
def test_agy_verify_rejects_id_cached_for_another_workspace(tmp_path):
|
||||||
|
from lib_py.verify_session import verify_session_uuid
|
||||||
|
home, ws_a, ws_b = tmp_path / "home", tmp_path / "ws_a", tmp_path / "ws_b"
|
||||||
|
ws_a.mkdir()
|
||||||
|
ws_b.mkdir()
|
||||||
|
epoch = int(time.time()) - 100
|
||||||
|
_agy_conv(home, UB)
|
||||||
|
_agy_cache(home, {ws_b: UB})
|
||||||
|
row_a = _row("ws-a-creator-agy", "agy", ws_a, epoch)
|
||||||
|
assert verify_session_uuid(str(ws_a), "agy", UB, row=row_a, home_dir=str(home), mode="discover") is False
|
||||||
|
# the same id is fine for the workspace the cache names
|
||||||
|
row_b = _row("ws-b-creator-agy", "agy", ws_b, epoch)
|
||||||
|
assert verify_session_uuid(str(ws_b), "agy", UB, row=row_b, home_dir=str(home), mode="discover") is True
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_agent(state_file, name, cwd, pid, epoch):
|
||||||
|
state = json.loads(Path(state_file).read_text())
|
||||||
|
state.setdefault("agents", {})[name] = {"pid": pid, "cwd": str(cwd), "command": "agy", "agent": "agy",
|
||||||
|
"created": epoch, "buffer": f"Antigravity {cwd}\n>"}
|
||||||
|
Path(state_file).write_text(json.dumps(state))
|
||||||
|
|
||||||
|
|
||||||
|
def _reconcile(sandbox):
|
||||||
|
script = sandbox / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
|
||||||
|
res = subprocess.run(["bash", str(script), "--once", "--emit-diff"], capture_output=True, text=True,
|
||||||
|
cwd=str(sandbox), env={**os.environ, "WORKSPACE_ROOT": str(sandbox)}, timeout=120)
|
||||||
|
assert res.returncode == 0, res.stderr
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_does_not_pin_another_workspaces_agy_conversation(mam_sandbox, mock_herdr):
|
||||||
|
"""The issue's example: proj-A's agy session is unpinned, only proj-B's
|
||||||
|
conversation changed after A started. reconcile used to glob every *.db
|
||||||
|
and record B's id on A."""
|
||||||
|
ws_a, ws_b = mam_sandbox / "proj-a", mam_sandbox / "proj-b"
|
||||||
|
ws_a.mkdir()
|
||||||
|
ws_b.mkdir()
|
||||||
|
epoch = int(time.time()) - 100
|
||||||
|
_agy_conv(mam_sandbox, UB)
|
||||||
|
_agy_cache(mam_sandbox, {ws_b: UB})
|
||||||
|
name = "proj-a-creator-agy"
|
||||||
|
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
||||||
|
yaml_path.write_text(yaml.safe_dump({"herdr_sessions": [_row(name, "agy", ws_a, epoch, pid=os.getpid())]}))
|
||||||
|
_mock_agent(mock_herdr, name, ws_a, os.getpid(), epoch)
|
||||||
|
|
||||||
|
_reconcile(mam_sandbox)
|
||||||
|
row = _rows(yaml_path)[name]
|
||||||
|
assert row["status"] == "running"
|
||||||
|
assert row["agy_conversation_id_own"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_autopin_does_not_pin_another_workspaces_agy_conversation(tmp_path, owners):
|
||||||
|
home, ws_a, ws_b = tmp_path / "home", tmp_path / "ws_a", tmp_path / "ws_b"
|
||||||
|
ws_a.mkdir()
|
||||||
|
ws_b.mkdir()
|
||||||
|
epoch = int(time.time()) - 100
|
||||||
|
_agy_conv(home, UB)
|
||||||
|
_agy_cache(home, {ws_b: UB})
|
||||||
|
held, _ = owners
|
||||||
|
held[UB] = [4242] # even a holder that looks like A's pane does not make B's id A's
|
||||||
|
yaml_path, jobs = _control_plane(tmp_path / "ctl", [_row("a-agy", "agy", ws_a, epoch, pid=4242)])
|
||||||
|
_job(jobs, "j1", "herdr:a-agy")
|
||||||
|
res = _handle(jobs, "j1", yaml_path, home)
|
||||||
|
assert res["status"] == "no-candidate"
|
||||||
|
row = _rows(yaml_path)["a-agy"]
|
||||||
|
assert row["agy_conversation_id_own"] is None
|
||||||
|
assert "autopin no-candidate (job j1)" in row["last_visible_status"]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- 2) first completed job pins
|
||||||
|
|
||||||
|
def test_completed_onboarding_pins_agy_without_daemon(tmp_path, owners, monkeypatch):
|
||||||
|
"""A's onboarding completed; B's conversation was updated meanwhile (and
|
||||||
|
later than A's). A gets A's id, owned by A's pane."""
|
||||||
|
monkeypatch.setenv("MAM_NO_SUBSCRIBER_DAEMON", "1")
|
||||||
|
home, ws_a, ws_b = tmp_path / "home", tmp_path / "ws_a", tmp_path / "ws_b"
|
||||||
|
ws_a.mkdir()
|
||||||
|
ws_b.mkdir()
|
||||||
|
now = time.time()
|
||||||
|
epoch = int(now) - 100
|
||||||
|
_agy_conv(home, UA, mtime=now - 10)
|
||||||
|
_agy_conv(home, UB, mtime=now)
|
||||||
|
_agy_cache(home, {ws_a: UA, ws_b: UB})
|
||||||
|
held, _ = owners
|
||||||
|
held[UA], held[UB] = [5001], [6001]
|
||||||
|
yaml_path, jobs = _control_plane(tmp_path / "ctl", [_row("a-agy", "agy", ws_a, epoch, pid=5001)])
|
||||||
|
_job(jobs, "onb1", "herdr:a-agy")
|
||||||
|
|
||||||
|
res = _handle(jobs, "onb1", yaml_path, home)
|
||||||
|
assert res["status"] == "pinned", res
|
||||||
|
row = _rows(yaml_path)["a-agy"]
|
||||||
|
assert row["agy_conversation_id_own"] == UA
|
||||||
|
assert row["session_id_source"] == "discovered"
|
||||||
|
assert row["session_id_verified"] is True
|
||||||
|
assert row["last_visible_status"] == "resume_verified"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("agent,sid,other", [("opencode", "ses_own_a", "ses_other_b"),
|
||||||
|
("codex", UA, UB), ("hermes", "h-own-a", "h-other-b")])
|
||||||
|
def test_completed_first_job_pins_opencode_codex_hermes(tmp_path, monkeypatch, fake_opencode, agent, sid, other):
|
||||||
|
home, ws, ws_other = tmp_path / "home", tmp_path / "ws", tmp_path / "ws_other"
|
||||||
|
ws.mkdir()
|
||||||
|
ws_other.mkdir()
|
||||||
|
monkeypatch.setenv("HOME", str(home))
|
||||||
|
epoch = int(time.time()) - 100
|
||||||
|
_seed_class_a(agent, home, ws, ws_other, sid, other, started=epoch + 10)
|
||||||
|
yaml_path, jobs = _control_plane(tmp_path / "ctl", [_row(f"w-creator-{agent}", agent, ws, epoch)])
|
||||||
|
_job(jobs, "first", f"herdr:w-creator-{agent}", agent=agent)
|
||||||
|
|
||||||
|
res = _handle(jobs, "first", yaml_path, home)
|
||||||
|
assert res["status"] == "pinned", res
|
||||||
|
row = _rows(yaml_path)[f"w-creator-{agent}"]
|
||||||
|
from lib_py.agents.registry import own_key
|
||||||
|
assert row[own_key(agent)] == sid
|
||||||
|
assert (row["session_id_source"], row["session_id_verified"]) == ("discovered", True)
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_lsof(bindir, uuid, pid):
|
||||||
|
exe = Path(bindir) / "lsof"
|
||||||
|
exe.write_text(f'#!/bin/sh\nfor a in "$@"; do case "$a" in *{uuid}*) echo {pid}; exit 0;; esac; done\nexit 1\n')
|
||||||
|
exe.chmod(0o755)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_pins_agy_owned_by_the_pane(mam_sandbox, mock_herdr):
|
||||||
|
"""drift C uses the same capture: A's cached id, held by A's pane, is pinned;
|
||||||
|
B's newer conversation in another workspace is not considered."""
|
||||||
|
ws_a, ws_b = mam_sandbox / "proj-a", mam_sandbox / "proj-b"
|
||||||
|
ws_a.mkdir()
|
||||||
|
ws_b.mkdir()
|
||||||
|
now = time.time()
|
||||||
|
epoch = int(now) - 100
|
||||||
|
_agy_conv(mam_sandbox, UA, mtime=now - 10)
|
||||||
|
_agy_conv(mam_sandbox, UB, mtime=now)
|
||||||
|
_agy_cache(mam_sandbox, {ws_a: UA, ws_b: UB})
|
||||||
|
_fake_lsof(mam_sandbox / "bin", UA, os.getpid())
|
||||||
|
name = "proj-a-creator-agy"
|
||||||
|
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
||||||
|
yaml_path.write_text(yaml.safe_dump({"herdr_sessions": [_row(name, "agy", ws_a, epoch, pid=os.getpid())]}))
|
||||||
|
_mock_agent(mock_herdr, name, ws_a, os.getpid(), epoch)
|
||||||
|
|
||||||
|
_reconcile(mam_sandbox)
|
||||||
|
row = _rows(yaml_path)[name]
|
||||||
|
assert row["agy_conversation_id_own"] == UA
|
||||||
|
assert (row["session_id_source"], row["session_id_verified"]) == ("discovered", True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lib_sh_helper_pins_through_the_cli(mam_sandbox, mock_herdr, fake_opencode):
|
||||||
|
"""The wrapper's and create's entry point: lib.sh delegate_autopin_completed
|
||||||
|
runs `python -m lib_py.agents.autopin` against the sandbox control plane."""
|
||||||
|
ws, ws_other = mam_sandbox / "proj", mam_sandbox / "other"
|
||||||
|
ws.mkdir()
|
||||||
|
ws_other.mkdir()
|
||||||
|
epoch = int(time.time()) - 100
|
||||||
|
_seed_class_a("opencode", mam_sandbox, ws, ws_other, "ses_own", "ses_other", started=epoch + 10)
|
||||||
|
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
||||||
|
name = "proj-creator-opencode"
|
||||||
|
yaml_path.write_text(yaml.safe_dump({"herdr_sessions": [_row(name, "opencode", ws, epoch)]}))
|
||||||
|
jobs = mam_sandbox / ".mam" / "jobs"
|
||||||
|
jobs.mkdir(parents=True, exist_ok=True)
|
||||||
|
_job(jobs, "jcli", f"herdr:{name}", agent="opencode")
|
||||||
|
|
||||||
|
lib_sh = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||||
|
res = subprocess.run(["bash", "-c", f'source "{lib_sh}" && delegate_autopin_completed jcli tok-1 "{jobs}"'],
|
||||||
|
capture_output=True, text=True, env=dict(os.environ), timeout=120)
|
||||||
|
assert res.returncode == 0, res.stderr
|
||||||
|
row = _rows(yaml_path)[name]
|
||||||
|
assert row["opencode_session_id_own"] == "ses_own", res.stderr
|
||||||
|
marker = json.loads((jobs / "jcli" / "autopin.json").read_text())
|
||||||
|
assert marker["key"] == "jcli:tok-1:1" and marker["result"]["status"] == "pinned"
|
||||||
|
# a stale dispatch token is not this attempt's completion
|
||||||
|
_job(jobs, "jstale", f"herdr:{name}", agent="opencode", token="tok-new")
|
||||||
|
subprocess.run(["bash", "-c", f'source "{lib_sh}" && delegate_autopin_completed jstale tok-old "{jobs}"'],
|
||||||
|
capture_output=True, text=True, env=dict(os.environ), timeout=120)
|
||||||
|
assert not (jobs / "jstale" / "autopin.json").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_onboard_pins_without_daemon(mam_sandbox, mock_herdr, mock_agents):
|
||||||
|
"""create --onboard with MAM_NO_SUBSCRIBER_DAEMON=1 (mam_sandbox): the
|
||||||
|
mock agent completes the onboarding and create runs the handler itself."""
|
||||||
|
create = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||||
|
res = subprocess.run(["bash", str(create), "--workspace", str(mam_sandbox), "--agent", "opencode",
|
||||||
|
"--role", "creator", "--onboard"],
|
||||||
|
capture_output=True, text=True, cwd=str(mam_sandbox), timeout=300)
|
||||||
|
assert res.returncode == 0, res.stderr
|
||||||
|
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
||||||
|
(row,) = _rows(yaml_path).values()
|
||||||
|
job = row["delegate_job_id"]
|
||||||
|
marker = json.loads((mam_sandbox / ".mam" / "jobs" / job / "autopin.json").read_text())
|
||||||
|
# create's async reconcile may get there first; either way the handler ran
|
||||||
|
assert marker["result"]["status"] in ("pinned", "already-set"), marker
|
||||||
|
assert row["opencode_session_id_own"]
|
||||||
|
assert (row["session_id_source"], row["session_id_verified"]) == ("discovered", True)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- agy: the cwd cache proves nothing
|
||||||
|
|
||||||
|
def test_agy_same_cwd_sibling_never_cross_pins(tmp_path, owners):
|
||||||
|
"""Two agy sessions in one cwd; the cache names U, whose presence lock A's
|
||||||
|
pane holds. Only A may get U - before and after A is pinned."""
|
||||||
|
from lib_py.verify_session import verify_session_uuid
|
||||||
|
home, ws = tmp_path / "home", tmp_path / "ws"
|
||||||
|
ws.mkdir()
|
||||||
|
epoch = int(time.time()) - 100
|
||||||
|
_agy_conv(home, UA)
|
||||||
|
_agy_cache(home, {ws: UA})
|
||||||
|
held, parents = owners
|
||||||
|
held[UA] = [5001]
|
||||||
|
parents.update({5001: 1, 6001: 1})
|
||||||
|
yaml_path, jobs = _control_plane(tmp_path / "ctl", [_row("a-agy", "agy", ws, epoch, pid=5001),
|
||||||
|
_row("b-agy", "agy", ws, epoch, pid=6001)])
|
||||||
|
_job(jobs, "jb1", "herdr:b-agy")
|
||||||
|
assert _handle(jobs, "jb1", yaml_path, home)["status"] == "owner-mismatch"
|
||||||
|
_job(jobs, "ja1", "herdr:a-agy")
|
||||||
|
assert _handle(jobs, "ja1", yaml_path, home)["status"] == "pinned"
|
||||||
|
_job(jobs, "jb2", "herdr:b-agy")
|
||||||
|
assert _handle(jobs, "jb2", yaml_path, home)["status"] == "sibling-claimed"
|
||||||
|
rows = _rows(yaml_path)
|
||||||
|
assert rows["a-agy"]["agy_conversation_id_own"] == UA
|
||||||
|
assert rows["b-agy"]["agy_conversation_id_own"] is None
|
||||||
|
# a cache hit no longer bypasses the sibling exclusion
|
||||||
|
row_b = dict(rows["b-agy"], _sibling_claimed_uuids=[UA])
|
||||||
|
assert verify_session_uuid(str(ws), "agy", UA, row=row_b, home_dir=str(home), mode="discover") is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("holders,parents,expected", [
|
||||||
|
(None, {}, "unverifiable-owner"), # lsof missing or failed
|
||||||
|
([], {}, "unverifiable-owner"), # nobody holds the lock
|
||||||
|
([7001], {7001: 1, 5001: 1}, "owner-mismatch"), # held outside the pane's tree
|
||||||
|
([7001], {7001: 7000, 7000: 5001, 5001: 1}, "captured"), # a descendant of the pane
|
||||||
|
])
|
||||||
|
def test_agy_capture_requires_presence_owned_by_pane(tmp_path, owners, holders, parents, expected):
|
||||||
|
from lib_py.agents.autopin import capture
|
||||||
|
home, ws = tmp_path / "home", tmp_path / "ws"
|
||||||
|
ws.mkdir()
|
||||||
|
epoch = int(time.time()) - 100
|
||||||
|
_agy_conv(home, UA)
|
||||||
|
_agy_cache(home, {ws: UA})
|
||||||
|
held, ppid = owners
|
||||||
|
if holders is None:
|
||||||
|
held["__lsof__"] = None
|
||||||
|
else:
|
||||||
|
held[UA] = holders
|
||||||
|
ppid.update(parents)
|
||||||
|
row = _row("a-agy", "agy", ws, epoch, pid=5001)
|
||||||
|
res = capture(row, "agy", {"herdr_sessions": [row]}, home_dir=str(home))
|
||||||
|
assert res["status"] == expected, res
|
||||||
|
assert (res["session_id"] == UA) == (expected == "captured")
|
||||||
|
|
||||||
|
|
||||||
|
def test_agy_holder_pids_reads_lsof(tmp_path, monkeypatch):
|
||||||
|
"""The real helper: lsof's pids, [] when it finds no holder, None without lsof."""
|
||||||
|
from lib_py.agents.adapters import agy
|
||||||
|
f = tmp_path / "x.lock"
|
||||||
|
f.write_text("")
|
||||||
|
bindir = tmp_path / "bin"
|
||||||
|
bindir.mkdir()
|
||||||
|
monkeypatch.setenv("PATH", str(bindir))
|
||||||
|
assert agy._holder_pids([str(f)]) is None
|
||||||
|
_fake_lsof(bindir, "x.lock", 4321)
|
||||||
|
assert agy._holder_pids([str(f)]) == [4321]
|
||||||
|
assert agy._holder_pids([str(tmp_path / "missing.lock")]) == []
|
||||||
|
(bindir / "lsof").write_text("#!/bin/sh\nexit 1\n")
|
||||||
|
assert agy._holder_pids([str(f)]) == []
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- 3) once per job, ambiguity
|
||||||
|
|
||||||
|
def _count_captures(monkeypatch):
|
||||||
|
from lib_py.agents import autopin
|
||||||
|
calls = []
|
||||||
|
real = autopin.capture
|
||||||
|
|
||||||
|
def counting(*a, **kw):
|
||||||
|
calls.append(a[0].get("name"))
|
||||||
|
return real(*a, **kw)
|
||||||
|
|
||||||
|
monkeypatch.setattr(autopin, "capture", counting)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
def _loop_runner(mam_dir, workspace_root=REPO):
|
||||||
|
from lib_py.loop.runner import LoopRunner
|
||||||
|
r = LoopRunner.__new__(LoopRunner)
|
||||||
|
r.workspace_root = Path(workspace_root)
|
||||||
|
r.mam_dir, r.jobs_dir = Path(mam_dir), Path(mam_dir) / "jobs"
|
||||||
|
r.failed_submit_jobs, r._stall_seen = set(), {}
|
||||||
|
r.verbose, r.interrupted = False, False
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrapper_and_loop_recovery_pin_once(tmp_path, monkeypatch, fake_opencode):
|
||||||
|
home, ws, ws_other = tmp_path / "home", tmp_path / "ws", tmp_path / "ws_other"
|
||||||
|
ws.mkdir()
|
||||||
|
ws_other.mkdir()
|
||||||
|
epoch = int(time.time()) - 100
|
||||||
|
_seed_class_a("opencode", home, ws, ws_other, "ses_own", "ses_other", started=epoch + 10)
|
||||||
|
yaml_path, jobs = _control_plane(tmp_path / "ctl", [_row("w-opencode", "opencode", ws, epoch)])
|
||||||
|
monkeypatch.setenv("AGENT_SESSIONS_YAML", str(yaml_path))
|
||||||
|
monkeypatch.setenv("HOME_DIR", str(home))
|
||||||
|
_job(jobs, "dup", "herdr:w-opencode", agent="opencode")
|
||||||
|
calls = _count_captures(monkeypatch)
|
||||||
|
|
||||||
|
first = _handle(jobs, "dup", yaml_path, home) # the wrapper's subscriber terminal
|
||||||
|
assert first["status"] == "pinned"
|
||||||
|
written = yaml_path.stat().st_mtime_ns
|
||||||
|
marker = (jobs / "dup" / "autopin.json").read_text()
|
||||||
|
# loop recovery (a subprocess of the same handler): deduplicated, nothing rewritten
|
||||||
|
assert _loop_runner(tmp_path / "ctl" / ".mam").wait_for_job("dup", max_wait=5) is True
|
||||||
|
assert (jobs / "dup" / "autopin.json").read_text() == marker
|
||||||
|
again = _handle(jobs, "dup", yaml_path, home)
|
||||||
|
assert again.get("deduplicated") is True and again["status"] == "pinned"
|
||||||
|
assert calls == ["w-opencode"]
|
||||||
|
assert yaml_path.stat().st_mtime_ns == written
|
||||||
|
assert _rows(yaml_path)["w-opencode"]["opencode_session_id_own"] == "ses_own"
|
||||||
|
|
||||||
|
|
||||||
|
def test_loop_recovery_pins_when_the_wrapper_did_not(mam_sandbox, mock_herdr, fake_opencode):
|
||||||
|
"""wait_for_job's recovery call alone pins the id (the wrapper's call was missed)."""
|
||||||
|
ws, ws_other = mam_sandbox / "proj", mam_sandbox / "other"
|
||||||
|
ws.mkdir()
|
||||||
|
ws_other.mkdir()
|
||||||
|
epoch = int(time.time()) - 100
|
||||||
|
_seed_class_a("opencode", mam_sandbox, ws, ws_other, "ses_own", "ses_other", started=epoch + 10)
|
||||||
|
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
||||||
|
name = "proj-creator-opencode"
|
||||||
|
yaml_path.write_text(yaml.safe_dump({"herdr_sessions": [_row(name, "opencode", ws, epoch)]}))
|
||||||
|
jobs = mam_sandbox / ".mam" / "jobs"
|
||||||
|
jobs.mkdir(parents=True, exist_ok=True)
|
||||||
|
_job(jobs, "jloop", f"herdr:{name}", agent="opencode")
|
||||||
|
|
||||||
|
assert _loop_runner(mam_sandbox / ".mam", workspace_root=mam_sandbox).wait_for_job("jloop", max_wait=5) is True
|
||||||
|
row = _rows(yaml_path)[name]
|
||||||
|
assert row["opencode_session_id_own"] == "ses_own"
|
||||||
|
assert json.loads((jobs / "jloop" / "autopin.json").read_text())["result"]["status"] == "pinned"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ambiguous_candidates_leave_reason_without_pin(tmp_path, monkeypatch, fake_opencode):
|
||||||
|
home, ws = tmp_path / "home", tmp_path / "ws"
|
||||||
|
ws.mkdir()
|
||||||
|
epoch = int(time.time()) - 100
|
||||||
|
_seed_class_a("opencode", home, ws, ws, "ses_one", "ses_two", started=epoch + 10) # both in ws
|
||||||
|
yaml_path, jobs = _control_plane(tmp_path / "ctl", [_row("w-opencode", "opencode", ws, epoch)])
|
||||||
|
_job(jobs, "amb", "herdr:w-opencode", agent="opencode")
|
||||||
|
calls = _count_captures(monkeypatch)
|
||||||
|
|
||||||
|
res = _handle(jobs, "amb", yaml_path, home)
|
||||||
|
assert res["status"] == "ambiguous" and sorted(res["candidates"]) == ["ses_one", "ses_two"]
|
||||||
|
row = _rows(yaml_path)["w-opencode"]
|
||||||
|
assert row["opencode_session_id_own"] is None
|
||||||
|
assert row["last_visible_status"].startswith("autopin ambiguous (job amb): 2 candidates")
|
||||||
|
assert _handle(jobs, "amb", yaml_path, home).get("deduplicated") is True
|
||||||
|
assert calls == ["w-opencode"]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- 4) untouched rows, skipped jobs
|
||||||
|
|
||||||
|
def test_preassigned_and_existing_ids_are_untouched(tmp_path, owners, fake_opencode):
|
||||||
|
home, ws = tmp_path / "home", tmp_path / "ws"
|
||||||
|
ws.mkdir()
|
||||||
|
epoch = int(time.time()) - 100
|
||||||
|
_agy_conv(home, UB)
|
||||||
|
_agy_cache(home, {ws: UB})
|
||||||
|
held, _ = owners
|
||||||
|
held[UB] = [5003]
|
||||||
|
_seed_class_a("opencode", home, ws, ws, "ses_x", "ses_y", started=epoch + 10)
|
||||||
|
rows = [
|
||||||
|
_row("c-claude", "claude", ws, epoch, claude_session_id_own=UA, session_id_source="assigned"),
|
||||||
|
_row("g-grok", "grok", ws, epoch, grok_session_id_own=UC, session_id_source="assigned"),
|
||||||
|
_row("p-agy", "agy", ws, epoch, pid=5003, agy_conversation_id_own="agy-pinned-id",
|
||||||
|
session_id_source="discovered", session_id_verified=True, last_visible_status="resume_verified"),
|
||||||
|
_row("o-opencode", "opencode", ws, epoch, opencode_session_id_own="ses_kept"),
|
||||||
|
]
|
||||||
|
yaml_path, jobs = _control_plane(tmp_path / "ctl", rows)
|
||||||
|
before = _rows(yaml_path)
|
||||||
|
for i, r in enumerate(rows):
|
||||||
|
_job(jobs, f"k{i}", f"herdr:{r['name']}", agent=r["agent"])
|
||||||
|
assert _handle(jobs, f"k{i}", yaml_path, home)["status"] in ("pre-assigned", "already-set")
|
||||||
|
assert _rows(yaml_path) == before
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("change,reason", [
|
||||||
|
({"transport": "remote", "session": "remote:box"}, "not a local agent session"),
|
||||||
|
({"agent": "human", "session": "human"}, "not a local agent session"),
|
||||||
|
({"status": "error"}, "job j is error"),
|
||||||
|
({"status": "running"}, "job j is running"),
|
||||||
|
])
|
||||||
|
def test_ineligible_jobs_pin_nothing(tmp_path, owners, change, reason):
|
||||||
|
home, ws = tmp_path / "home", tmp_path / "ws"
|
||||||
|
ws.mkdir()
|
||||||
|
epoch = int(time.time()) - 100
|
||||||
|
_agy_conv(home, UA)
|
||||||
|
_agy_cache(home, {ws: UA})
|
||||||
|
owners[0][UA] = [5001]
|
||||||
|
yaml_path, jobs = _control_plane(tmp_path / "ctl", [_row("a-agy", "agy", ws, epoch, pid=5001)])
|
||||||
|
_job(jobs, "j", change.get("session", "herdr:a-agy"), status=change.get("status", "completed"),
|
||||||
|
transport=change.get("transport", "herdr"), agent=change.get("agent", "agy"))
|
||||||
|
res = _handle(jobs, "j", yaml_path, home)
|
||||||
|
assert (res["status"], res["reason"]) == ("skipped", reason)
|
||||||
|
assert _rows(yaml_path)["a-agy"]["agy_conversation_id_own"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_dispatch_token_pins_nothing(tmp_path, owners):
|
||||||
|
home, ws = tmp_path / "home", tmp_path / "ws"
|
||||||
|
ws.mkdir()
|
||||||
|
_agy_conv(home, UA)
|
||||||
|
_agy_cache(home, {ws: UA})
|
||||||
|
owners[0][UA] = [5001]
|
||||||
|
yaml_path, jobs = _control_plane(tmp_path / "ctl", [_row("a-agy", "agy", ws, int(time.time()) - 100, pid=5001)])
|
||||||
|
_job(jobs, "j", "herdr:a-agy", token="tok-2")
|
||||||
|
res = _handle(jobs, "j", yaml_path, home, expect_token="tok-1")
|
||||||
|
assert (res["status"], res["reason"]) == ("skipped", "stale dispatch token")
|
||||||
|
assert not (jobs / "j" / "autopin.json").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_call_sites_and_publisher_boundary():
|
||||||
|
"""The wrapper's direct path and create --onboard call the handler on
|
||||||
|
`completed`; the agent-run publisher never touches the session registry."""
|
||||||
|
wrapper = (SKILLS / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job").read_text()
|
||||||
|
assert 'delegate_autopin_completed "$JOB_ID" "$_disp_tok" "$REGISTRY_DIR"' in wrapper
|
||||||
|
create = (SKILLS / "multi-agent-mux-create" / "scripts" / "create_session.sh").read_text()
|
||||||
|
assert 'delegate_autopin_completed "$DELEGATE_JOB_ID" "$DELEGATE_JOB_TOKEN"' in create
|
||||||
|
for f in ("publish_event.py", "publisher.py"):
|
||||||
|
src = (SKILLS / "lib_py" / "delegation" / f).read_text()
|
||||||
|
assert "atomic_dump_yaml" not in src and "agent-sessions" not in src and "autopin" not in src
|
||||||
@@ -193,7 +193,7 @@ def test_a_query_reads_right_and_leaves_the_sidecars_alone(wal_home, state, list
|
|||||||
|
|
||||||
@pytest.mark.parametrize("script,count", [
|
@pytest.mark.parametrize("script,count", [
|
||||||
("multi-agent-mux-status/scripts/status.sh", 2),
|
("multi-agent-mux-status/scripts/status.sh", 2),
|
||||||
("multi-agent-mux-monitor/scripts/reconcile.sh", 1),
|
("multi-agent-mux-monitor/scripts/reconcile.sh", 0), # Issue #40: through the adapter only
|
||||||
])
|
])
|
||||||
def test_status_and_reconcile_open_the_database_read_only(script, count):
|
def test_status_and_reconcile_open_the_database_read_only(script, count):
|
||||||
text = (SKILLS / script).read_text()
|
text = (SKILLS / script).read_text()
|
||||||
|
|||||||
@@ -281,7 +281,8 @@ def test_status_and_reconcile_read_session_v2():
|
|||||||
for rel in ("multi-agent-mux-status/scripts/status.sh", "multi-agent-mux-monitor/scripts/reconcile.sh"):
|
for rel in ("multi-agent-mux-status/scripts/status.sh", "multi-agent-mux-monitor/scripts/reconcile.sh"):
|
||||||
text = (SKILLS / rel).read_text()
|
text = (SKILLS / rel).read_text()
|
||||||
assert not re.search(r"FROM session\b(?!_)|table_info\(session\)", text), rel
|
assert not re.search(r"FROM session\b(?!_)|table_info\(session\)", text), rel
|
||||||
assert "session_v2" in text, rel
|
# Issue #40: reconcile's drift C reads session_v2 through the adapter
|
||||||
|
assert ("session_v2" in text) if "status" in rel else ("autopin.capture(" in text), rel
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -230,10 +230,11 @@ def test_status_and_reconcile_static_ro_connect_usage():
|
|||||||
assert status_text.count("_ro_connect(db)") == 2
|
assert status_text.count("_ro_connect(db)") == 2
|
||||||
assert "sqlite3.connect" not in status_text
|
assert "sqlite3.connect" not in status_text
|
||||||
|
|
||||||
|
# Issue #40: drift C reads the agents' databases only through the adapters
|
||||||
|
# (lib_py.agents.autopin.capture), which use _ro_connect.
|
||||||
reconcile_text = (SKILLS / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh").read_text()
|
reconcile_text = (SKILLS / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh").read_text()
|
||||||
assert reconcile_text.count("_ro_connect(hdb)") == 1
|
assert "autopin.capture(s, agent, d, home_dir=home)" in reconcile_text
|
||||||
assert reconcile_text.count("_ro_connect(odb)") == 1
|
assert "_ro_connect" not in reconcile_text
|
||||||
assert reconcile_text.count("_ro_connect(state_db)") == 1
|
|
||||||
assert "sqlite3.connect" not in reconcile_text
|
assert "sqlite3.connect" not in reconcile_text
|
||||||
|
|
||||||
|
|
||||||
@@ -370,12 +371,11 @@ d['herdr_sessions'] = [
|
|||||||
# 4. Cross-workspace agy DB sidecar regression test (Task 1 root-cause)
|
# 4. Cross-workspace agy DB sidecar regression test (Task 1 root-cause)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
def test_unpinned_agy_reconcile_leaves_other_workspace_clean_db_without_sidecars(tmp_path):
|
def test_unpinned_agy_reconcile_leaves_other_workspace_clean_db_without_sidecars(tmp_path):
|
||||||
"""When workspace A has an unpinned running agy session, reconcile.sh's
|
"""`verify_session_uuid(ws_a, 'agy', uuid_b, ..., mode='discover')` for an
|
||||||
drift-C scan iterates every `*.db` in `~/.gemini/antigravity-cli/conversations/`
|
unpinned running agy session in workspace A leaves no `-wal` or `-shm`
|
||||||
and calls `verify_session_uuid(ws_a, 'agy', uuid, ..., mode='discover')`.
|
sidecar beside another workspace's `uuid_b.db` (reconcile.sh's drift C
|
||||||
Even when `last_conversations.json` maps `uuid_b` to another workspace `ws_b`,
|
used to call it for every `*.db` in the conversations folder). Since Issue
|
||||||
`verify_artifact` opens `uuid_b.db` to query `SELECT count(*) FROM steps`;
|
#40 an id `last_conversations.json` maps to another workspace is rejected."""
|
||||||
with `_ro_connect`, no `-wal` or `-shm` sidecar is created beside `uuid_b.db`."""
|
|
||||||
home = tmp_path / "home"
|
home = tmp_path / "home"
|
||||||
ws_a = tmp_path / "ws_a"
|
ws_a = tmp_path / "ws_a"
|
||||||
ws_b = tmp_path / "ws_b"
|
ws_b = tmp_path / "ws_b"
|
||||||
@@ -404,5 +404,6 @@ def test_unpinned_agy_reconcile_leaves_other_workspace_clean_db_without_sidecars
|
|||||||
"pane": {"cwd": str(ws_a), "cmd": "agy"},
|
"pane": {"cwd": str(ws_a), "cmd": "agy"},
|
||||||
"_sibling_claimed_uuids": [],
|
"_sibling_claimed_uuids": [],
|
||||||
}
|
}
|
||||||
assert verify_session_uuid(str(ws_a), "agy", uuid_b, row=row_a, home_dir=str(home), mode="discover") is True
|
# Issue #40: an id the cache maps to another workspace is rejected
|
||||||
|
assert verify_session_uuid(str(ws_a), "agy", uuid_b, row=row_a, home_dir=str(home), mode="discover") is False
|
||||||
assert sorted(os.listdir(db_b.parent)) == [f"{uuid_b}.db"]
|
assert sorted(os.listdir(db_b.parent)) == [f"{uuid_b}.db"]
|
||||||
@@ -1054,7 +1054,8 @@ def test_opencode_shell_and_scripts_integration(mam_sandbox):
|
|||||||
recon_path = mam_sandbox / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
|
recon_path = mam_sandbox / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
|
||||||
recon_content = recon_path.read_text()
|
recon_content = recon_path.read_text()
|
||||||
assert 'import os, json, glob, subprocess, time, sqlite3, re, shutil' in recon_content
|
assert 'import os, json, glob, subprocess, time, sqlite3, re, shutil' in recon_content
|
||||||
assert 'op_adapter = get_adapter(\'opencode\')' in recon_content
|
# Issue #40: drift C (opencode) goes through the adapter's capture
|
||||||
|
assert 'autopin.capture(s, agent, d, home_dir=home)' in recon_content
|
||||||
|
|
||||||
|
|
||||||
def test_codex_shell_and_scripts_integration(mam_sandbox):
|
def test_codex_shell_and_scripts_integration(mam_sandbox):
|
||||||
|
|||||||
@@ -1233,7 +1233,10 @@ def test_comp_opencode_create_and_reconcile_and_stop(mam_sandbox, mock_herdr, mo
|
|||||||
"bash", str(create_script),
|
"bash", str(create_script),
|
||||||
"--workspace", str(mam_sandbox),
|
"--workspace", str(mam_sandbox),
|
||||||
"--agent", "opencode",
|
"--agent", "opencode",
|
||||||
"--role", "creator"
|
"--role", "creator",
|
||||||
|
# the completed onboarding would pin the id itself (Issue #40); this
|
||||||
|
# test is about reconcile's drift C
|
||||||
|
"--no-onboard"
|
||||||
], capture_output=True, text=True, cwd=str(mam_sandbox))
|
], capture_output=True, text=True, cwd=str(mam_sandbox))
|
||||||
assert res.returncode == 0, res.stderr
|
assert res.returncode == 0, res.stderr
|
||||||
|
|
||||||
@@ -1296,7 +1299,10 @@ def test_comp_codex_create_and_reconcile_and_stop(mam_sandbox, mock_herdr, mock_
|
|||||||
"bash", str(create_script),
|
"bash", str(create_script),
|
||||||
"--workspace", str(mam_sandbox),
|
"--workspace", str(mam_sandbox),
|
||||||
"--agent", "codex",
|
"--agent", "codex",
|
||||||
"--role", "creator"
|
"--role", "creator",
|
||||||
|
# the completed onboarding would pin the id itself (Issue #40); this
|
||||||
|
# test is about reconcile's drift C
|
||||||
|
"--no-onboard"
|
||||||
], capture_output=True, text=True, cwd=str(mam_sandbox))
|
], capture_output=True, text=True, cwd=str(mam_sandbox))
|
||||||
assert res.returncode == 0, res.stderr
|
assert res.returncode == 0, res.stderr
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user