Add shared managed lock broker

This commit is contained in:
2026-10-07 02:23:39 +00:00
parent 18676070b2
commit 470c7fb09b
16 changed files with 1583 additions and 120 deletions
@@ -1,7 +1,7 @@
--- ---
name: gitea-issue-implementation name: gitea-issue-implementation
description: 승인된 Gitea Issue를 구현·검증하고 Gitea PR로 전달한다. description: 승인된 Gitea Issue를 구현·검증하고 Gitea PR로 전달한다.
version: 0.4.5 version: 0.4.6
author: genichin, Hermes Agent author: genichin, Hermes Agent
license: MIT license: MIT
platforms: [linux] platforms: [linux]
@@ -15,6 +15,11 @@ metadata:
승인된 Gitea Discovery와 pinned repository revision을 근거로 Issue별 persistent worktree에서 요구 문서, source와 test를 구현하고 검증한 뒤 Issue 전용 branch와 Gitea PR로 전달한다. Gitea 인증과 readback은 [Gitea authenticated access 운영 지침](../../../../operations/gitea-access.md), repository cache와 worktree 규칙은 [Repository cache 운영 지침](../../../../operations/repository-cache.md), 문서 형식은 `docs/` 아래 각 문서 유형의 README와 TEMPLATE만 정본으로 사용한다. Exact Issue 구현 요청은 승인된 범위의 commit, 일반 push와 PR 생성을 승인하며, 열린 PR의 authoritative readback까지가 이 스킬의 기본 완료 경계다. PR 전달을 별도 승인 단계로 미루지 않는다. 다만 merge, force-push, Issue close, release 또는 deployment는 승인하지 않는다. 승인된 Gitea Discovery와 pinned repository revision을 근거로 Issue별 persistent worktree에서 요구 문서, source와 test를 구현하고 검증한 뒤 Issue 전용 branch와 Gitea PR로 전달한다. Gitea 인증과 readback은 [Gitea authenticated access 운영 지침](../../../../operations/gitea-access.md), repository cache와 worktree 규칙은 [Repository cache 운영 지침](../../../../operations/repository-cache.md), 문서 형식은 `docs/` 아래 각 문서 유형의 README와 TEMPLATE만 정본으로 사용한다. Exact Issue 구현 요청은 승인된 범위의 commit, 일반 push와 PR 생성을 승인하며, 열린 PR의 authoritative readback까지가 이 스킬의 기본 완료 경계다. PR 전달을 별도 승인 단계로 미루지 않는다. 다만 merge, force-push, Issue close, release 또는 deployment는 승인하지 않는다.
## Managed-lock broker gate
Issue workflow는 bootstrap 이후 반드시 [Repository cache 운영 지침](../../../../operations/repository-cache.md)의 하나의 role-neutral managed-lock broker를 사용한다. `serve`를 tracked background process로 시작하고 ready JSON을 기다리며, checkout 밖 role-owned mode `0700` runtime directory와 run-unique handle/socket을 미리 만든다. Client는 raw token을 inspect/log하지 않으며 public `request` API가 token을 캡슐화한다. Repository lock은 같은 handle의 `acquire_repository`/`release_repository` request로만 중첩하고, TTL 전에 `renew`한다. 정상·오류·cancel 모두 `release_repository`(보유 시) → `release` → broker wait/reap → `probe`의 `free=true` 확인 순서를 완료하기 전에는 완료·로그·보고하지 않는다. Raw `flock ... sleep`과 lock-file unlink는 금지한다.
## 사용 시점 ## 사용 시점
다음과 같이 exact repository와 Issue를 지정해 구현을 요청받았을 때 사용한다. 다음과 같이 exact repository와 Issue를 지정해 구현을 요청받았을 때 사용한다.
@@ -66,19 +71,19 @@ Issue body와 `updated_at`을 immutable approval snapshot으로 보존한다. bo
모든 cache, lock, path, credential, worktree 생성·재사용·제거 규칙은 작업할 때마다 `read_file`로 `operations/repository-cache.md`를 새로 읽어 적용한다. 이 스킬의 요약이 canonical 문서를 완화하거나 대체하지 않는다. 모든 cache, lock, path, credential, worktree 생성·재사용·제거 규칙은 작업할 때마다 `read_file`로 `operations/repository-cache.md`를 새로 읽어 적용한다. 이 스킬의 요약이 canonical 문서를 완화하거나 대체하지 않는다.
- Issue workflow 전체에서 exact `issue-<number>.lock`을 유지한다. - Issue workflow 전체에서 broker가 관리하는 exact Issue lease를 유지한다.
- Cache 초기화·fetch·ref 검증과 worktree 등록 변경에는 canonical 순서로 repository lock을 추가 획득한다. - Cache 초기화·fetch·ref 검증과 worktree 등록 변경에는 같은 handle로 `acquire_repository` request를 보내 repository lease를 추가한다.
- 일반 편집과 test에는 Issue lock만 유지한다. - 일반 편집과 test에는 broker Issue lease만 유지한다.
- 다른 Issue worktree나 다른 checkout을 읽기·수정·정리하지 않는다. - 다른 Issue worktree나 다른 checkout을 읽기·수정·정리하지 않는다.
- lock을 획득할 수 없거나 현재 실행이 lock 순서를 증명할 수 없으면 fail-closed한다. - broker request가 실패하거나 현재 실행이 broker lease 순서를 증명할 수 없으면 fail-closed한다.
- 종료, 오류와 중단 경로에서 현재 실행이 획득한 lock만 역순으로 해제한다. - 종료, 오류와 중단 경로에서 현재 실행이 보유한 repository lease를 `release_repository`, Issue lease를 `release` request로 해제한다.
## 절차 ## 절차
### 1. Target과 identity 고정 ### 1. Target과 identity 고정
1. 요청에서 exact `owner`, `repository`와 Issue 번호를 추출하고 각각 canonical identifier 규칙으로 검증한다. 1. 요청에서 exact `owner`, `repository`와 Issue 번호를 추출하고 각각 canonical identifier 규칙으로 검증한다.
2. Issue lock을 획득한다. 2. Broker `serve`가 관리하는 Issue lease를 public `request` API의 `status`/ready 응답으로 확인한다.
3. Canonical authenticated Gitea API adapter로 현재 impl role의 stable account identity를 확인한다. 3. Canonical authenticated Gitea API adapter로 현재 impl role의 stable account identity를 확인한다.
4. 같은 adapter로 exact repository metadata, default branch와 SHA, Issue 전체 필드와 전체 comment baseline을 읽는다. 4. 같은 adapter로 exact repository metadata, default branch와 SHA, Issue 전체 필드와 전체 comment baseline을 읽는다.
5. title, body, state, labels, assignees, milestone, `updated_at`, comment cardinality와 body digest를 immutable approval snapshot으로 기록한다. 5. title, body, state, labels, assignees, milestone, `updated_at`, comment cardinality와 body digest를 immutable approval snapshot으로 기록한다.
@@ -100,7 +105,7 @@ Issue body와 `updated_at`을 immutable approval snapshot으로 보존한다. bo
1. `operations/repository-cache.md`를 새로 읽는다. 1. `operations/repository-cache.md`를 새로 읽는다.
2. Canonical 절차로 cache를 초기화하거나 기존 cache를 검증한다. malformed cache를 삭제, reset, chmod, chown 또는 다른 repository로 교체하지 않는다. 2. Canonical 절차로 cache를 초기화하거나 기존 cache를 검증한다. malformed cache를 삭제, reset, chmod, chown 또는 다른 repository로 교체하지 않는다.
3. Issue lock 아래 repository lock을 획득해 exact origin에서 prune/fetch하고 Gitea default branch SHA와 local ref 일치를 검증한다. 3. Issue lease를 유지한 채 같은 broker handle로 `acquire_repository` request를 보내 exact origin에서 prune/fetch하고 Gitea default branch SHA와 local ref 일치를 검증한다.
4. Discovery pinned SHA가 cache에 존재하는 commit object이고 Issue가 주장한 revision과 exact-match하는지 확인한다. 4. Discovery pinned SHA가 cache에 존재하는 commit object이고 Issue가 주장한 revision과 exact-match하는지 확인한다.
5. Pinned SHA와 current default branch SHA가 같으면 이를 base로 확정한다. 5. Pinned SHA와 current default branch SHA가 같으면 이를 base로 확정한다.
6. 두 SHA가 다르면 pinned revision부터 current default branch까지의 commit과 변경 파일을 조사한다. 변경이 Discovery 근거, 요구 문서 ID·경로, 대상 API, test contract 또는 acceptance criteria를 무효화하지 않는다는 근거가 있을 때만 current default branch SHA를 base로 사용한다. 6. 두 SHA가 다르면 pinned revision부터 current default branch까지의 commit과 변경 파일을 조사한다. 변경이 Discovery 근거, 요구 문서 ID·경로, 대상 API, test contract 또는 acceptance criteria를 무효화하지 않는다는 근거가 있을 때만 current default branch SHA를 base로 사용한다.
@@ -116,7 +121,7 @@ Issue body와 `updated_at`을 immutable approval snapshot으로 보존한다. bo
4. `gitea-agent.worktree/v1` state를 canonical 방식으로 게시한다. 4. `gitea-agent.worktree/v1` state를 canonical 방식으로 게시한다.
5. 기존 worktree는 state identity, Git registration, common directory, detached·locked 상태, base ancestry, dirty 상태와 미완료 Git operation을 모두 검증한 뒤 재사용한다. 5. 기존 worktree는 state identity, Git registration, common directory, detached·locked 상태, base ancestry, dirty 상태와 미완료 Git operation을 모두 검증한 뒤 재사용한다.
6. 기존 worktree의 파일, untracked state 또는 commit을 reset, clean, rebase, checkout, switch 또는 삭제로 자동 복구하지 않는다. 6. 기존 worktree의 파일, untracked state 또는 commit을 reset, clean, rebase, checkout, switch 또는 삭제로 자동 복구하지 않는다.
7. Repository lock을 해제하고 Issue lock은 유지한다. 7. `release_repository` request를 보내고 Issue lease는 유지한다.
완료 조건: exact worktree path, base SHA, current HEAD, detached·locked 상태와 approval snapshot binding이 canonical 검증을 통과한다. 완료 조건: exact worktree path, base SHA, current HEAD, detached·locked 상태와 approval snapshot binding이 canonical 검증을 통과한다.
@@ -175,7 +180,7 @@ Discovery가 `신규 작성` 또는 `수정`으로 판정한 문서만 작성한
### 9. Drift 확인, commit과 PR 전달 — 필수 완료 gate ### 9. Drift 확인, commit과 PR 전달 — 필수 완료 gate
1. Gitea Issue와 comments를 다시 읽어 approval snapshot의 body, `updated_at`과 comment baseline drift를 확인한다. 1. Gitea Issue와 comments를 다시 읽어 approval snapshot의 body, `updated_at`과 comment baseline drift를 확인한다.
2. Repository lock 아래 origin을 다시 fetch하고 Gitea default branch SHA와 local ref를 확인한다. 2. Broker Issue lease를 유지한 채 같은 handle에 `acquire_repository`를 request하고, 그 repository lease 아래 origin을 다시 fetch해 Gitea default branch SHA와 local ref를 확인한 뒤 `release_repository`를 request한다.
3. Default branch가 base 이후 전진했으면 자동 merge, rebase 또는 history rewrite하지 않는다. 새 변경이 구현과 충돌하지 않는지 판정할 수 없으면 delivery-ready로 보고하지 않는다. 3. Default branch가 base 이후 전진했으면 자동 merge, rebase 또는 history rewrite하지 않는다. 새 변경이 구현과 충돌하지 않는지 판정할 수 없으면 delivery-ready로 보고하지 않는다.
4. Worktree의 base SHA, final HEAD, staged·unstaged·untracked 상태, 미완료 Git operation과 test 결과를 기록한다. 4. Worktree의 base SHA, final HEAD, staged·unstaged·untracked 상태, 미완료 Git operation과 test 결과를 기록한다.
5. Remote branch 이름은 repository convention이 있으면 이를 따르고, 없으면 `gitea-agent/issue-<number>-<short-slug>`처럼 deterministic Issue 전용 이름을 사용한다. Gitea와 remote에서 동일 이름의 branch와 열린 PR을 먼저 조회해 중복 생성하지 않는다. 이 이름으로 canonical mirror의 `refs/heads/*`에 local branch를 만들거나 Issue worktree를 attach하지 않는다. 5. Remote branch 이름은 repository convention이 있으면 이를 따르고, 없으면 `gitea-agent/issue-<number>-<short-slug>`처럼 deterministic Issue 전용 이름을 사용한다. Gitea와 remote에서 동일 이름의 branch와 열린 PR을 먼저 조회해 중복 생성하지 않는다. 이 이름으로 canonical mirror의 `refs/heads/*`에 local branch를 만들거나 Issue worktree를 attach하지 않는다.
@@ -190,7 +195,7 @@ Discovery가 `신규 작성` 또는 `수정`으로 판정한 문서만 작성한
14. Gitea에서 PR details, base branch, head repository·branch·SHA, state, changed files와 commit을 다시 읽는다. 최종 remote head SHA가 최종 local commit SHA와 일치할 때만 전달 완료로 판정한다. 14. Gitea에서 PR details, base branch, head repository·branch·SHA, state, changed files와 commit을 다시 읽는다. 최종 remote head SHA가 최종 local commit SHA와 일치할 때만 전달 완료로 판정한다.
15. Gitea Actions 또는 commit status가 제공되면 최종 head의 success, failed, pending, run 없음 또는 조회 불가 상태를 읽고 그대로 보고한다. 15. Gitea Actions 또는 commit status가 제공되면 최종 head의 success, failed, pending, run 없음 또는 조회 불가 상태를 읽고 그대로 보고한다.
16. PR 생성 또는 후속 push가 부분 실패하면 blind retry하거나 중복 PR을 만들지 않는다. 이미 생성된 fork·branch·PR과 local commit의 exact 상태 및 안전한 복구 지점을 보고한다. 16. PR 생성 또는 후속 push가 부분 실패하면 blind retry하거나 중복 PR을 만들지 않는다. 이미 생성된 fork·branch·PR과 local commit의 exact 상태 및 안전한 복구 지점을 보고한다.
17. 성공·실패와 관계없이 현재 실행이 만든 임시 server, fixture workspace와 credential helper를 정리하고 부재를 확인한 뒤 현재 실행이 획득한 lock을 해제한다. Persistent Issue worktree는 별도 제거 승인이 없으면 보존한다. 17. 성공·실패와 관계없이 현재 실행이 만든 임시 server, fixture workspace와 credential helper를 정리하고 부재를 확인한 뒤 보유한 repository lease에 `release_repository`, Issue lease에 `release` request를 보낸다. Persistent Issue worktree는 별도 제거 승인이 없으면 보존한다.
완료 조건: Issue와 source drift, final worktree 상태와 실행 결과가 fresh readback으로 확인되고, Issue worktree가 final commit의 detached HEAD로 남아 있으며, 열린 Gitea PR의 base·head와 remote head SHA가 검증된 최종 local commit에 exact-match하고, mirror fetch가 구현 branch checkout 충돌 없이 성공하며, 임시 resource와 lock 정리가 검증됐다. 열린 PR이 없으면 명시적인 local-only 제한이나 외부 permission blocker가 기록된 경우를 제외하고 스킬은 미완료다. 완료 조건: Issue와 source drift, final worktree 상태와 실행 결과가 fresh readback으로 확인되고, Issue worktree가 final commit의 detached HEAD로 남아 있으며, 열린 Gitea PR의 base·head와 remote head SHA가 검증된 최종 local commit에 exact-match하고, mirror fetch가 구현 branch checkout 충돌 없이 성공하며, 임시 resource와 lock 정리가 검증됐다. 열린 PR이 없으면 명시적인 local-only 제한이나 외부 permission blocker가 기록된 경우를 제외하고 스킬은 미완료다.
@@ -1,7 +1,7 @@
--- ---
name: gitea-issue-rework name: gitea-issue-rework
description: Gitea PR 검토 지적을 계약에 맞춰 재작업하고 검증한다. description: Gitea PR 검토 지적을 계약에 맞춰 재작업하고 검증한다.
version: 0.2.10 version: 0.2.11
author: genichin, Hermes Agent author: genichin, Hermes Agent
license: MIT license: MIT
platforms: [linux] platforms: [linux]
@@ -17,6 +17,11 @@ metadata:
Gitea 인증과 authoritative readback은 [Gitea authenticated access 운영 지침](../../../../operations/gitea-access.md), cache·lock·worktree 규칙은 [Repository cache 운영 지침](../../../../operations/repository-cache.md), 문서 형식은 `docs/` 아래 각 문서 유형의 README와 TEMPLATE만 정본으로 사용한다. Gitea 인증과 authoritative readback은 [Gitea authenticated access 운영 지침](../../../../operations/gitea-access.md), cache·lock·worktree 규칙은 [Repository cache 운영 지침](../../../../operations/repository-cache.md), 문서 형식은 `docs/` 아래 각 문서 유형의 README와 TEMPLATE만 정본으로 사용한다.
## Managed-lock broker gate
Bootstrap 이후 이 Issue 재작업도 canonical [Repository cache 운영 지침](../../../../operations/repository-cache.md)의 공통 managed-lock broker 하나만 사용한다. `serve`는 checkout 밖 pre-created mode `0700` role-owned runtime directory, unique handle/socket, tracked background process와 ready JSON을 요구한다. 같은 handle로만 public `request` API의 `acquire_repository`/`release_repository`를 요청하고 TTL 전에 `renew`한다. Client는 raw token을 inspect/log하지 않으며 broker request API가 token을 캡슐화한다. 정상·오류·cancel cleanup은 보유한 repository lease release, Issue `release`, broker wait/reap, `probe`의 `free=true` 확인까지 동일하며 그 전에는 completed/log/report를 주장하지 않는다. Raw `flock ... sleep`, lock-file unlink와 client-side lock holder는 금지한다.
## 사용 시점 ## 사용 시점
다음처럼 exact repository, source Issue, 기존 구현 PR과 재작업할 review를 식별할 수 있는 요청에 사용한다. 다음처럼 exact repository, source Issue, 기존 구현 PR과 재작업할 review를 식별할 수 있는 요청에 사용한다.
@@ -169,7 +174,7 @@ Reviewer가 rework head를 새 snapshot으로 다시 검토해야 하며 이 com
### 1. Target, identity와 review 고정 ### 1. Target, identity와 review 고정
1. Exact target identifier를 검증하고 Issue lock을 획득한다. 1. Exact target identifier를 검증하고 broker `serve`의 ready 응답과 public `request` API로 Issue lease를 확인한다.
2. Canonical authenticated Gitea adapter로 impl role identity, repository, Issue, PR, commits, changed files, statuses, workflow runs, 전체 reviews와 review comments를 읽는다. 2. Canonical authenticated Gitea adapter로 impl role identity, repository, Issue, PR, commits, changed files, statuses, workflow runs, 전체 reviews와 review comments를 읽는다.
3. 입력 승인 gate를 적용하고 Issue·PR·review snapshot을 저장한다. 3. 입력 승인 gate를 적용하고 Issue·PR·review snapshot을 저장한다.
4. Review marker에 head, contract digest, result digest 또는 correlation이 있으면 형식과 authoritative 값의 일치를 검증한다. Marker가 없는 code review는 review ID·author·commit binding과 finding body를 그대로 보존한다. 4. Review marker에 head, contract digest, result digest 또는 correlation이 있으면 형식과 authoritative 값의 일치를 검증한다. Marker가 없는 code review는 review ID·author·commit binding과 finding body를 그대로 보존한다.
@@ -180,11 +185,11 @@ Reviewer가 rework head를 새 snapshot으로 다시 검토해야 하며 이 com
### 2. Cache와 기존 worktree 검증 ### 2. Cache와 기존 worktree 검증
1. `read_file`로 `operations/repository-cache.md`를 새로 읽는다. 1. `read_file`로 `operations/repository-cache.md`를 새로 읽는다.
2. Issue lock 아래 canonical 순서로 repository lock을 획득하고 cache path·ownership·permission·bare·remote를 검증한 뒤 origin에서 prune/fetch한다. 2. Issue lease를 유지한 채 같은 broker handle로 `acquire_repository` request를 보내 cache path·ownership·permission·bare·remote를 검증한 뒤 origin에서 prune/fetch한다.
3. Gitea default branch와 local ref, PR base·head object 및 remote head branch SHA를 대조한다. Fork PR이면 target cache에 head object가 있다고 가정하지 않고 authoritative head repository와 branch를 별도로 검증한다. 3. Gitea default branch와 local ref, PR base·head object 및 remote head branch SHA를 대조한다. Fork PR이면 target cache에 head object가 있다고 가정하지 않고 authoritative head repository와 branch를 별도로 검증한다.
4. Issue lock과 repository lock을 모두 유지한 상태에서 기존 Issue worktree의 state identity, Git registration, common directory, detached·locked 상태, current HEAD, index, tracked·untracked state와 미완료 Git operation을 검증한다. 4. Broker Issue lease와 repository lease를 모두 유지한 상태에서 기존 Issue worktree의 state identity, Git registration, common directory, detached·locked 상태, current HEAD, index, tracked·untracked state와 미완료 Git operation을 검증한다.
5. Worktree HEAD와 PR head SHA가 다르거나 dirty state가 snapshot으로 설명되지 않거나 다른 실행의 lock·Git operation이 있으면 reset·checkout·clean으로 복구하지 않고 중단한다. 5. Worktree HEAD와 PR head SHA가 다르거나 dirty state가 snapshot으로 설명되지 않거나 다른 실행의 lock·Git operation이 있으면 reset·checkout·clean으로 복구하지 않고 중단한다.
6. Worktree admission 검증이 모두 끝난 뒤 repository lock을 해제하고 일반 편집·test·commit 동안 Issue lock만 유지한다. 6. Worktree admission 검증이 모두 끝난 뒤 `release_repository` request를 보내고 일반 편집·test·commit 동안 Issue lease만 유지한다.
7. 다른 checkout, 임시 clone 또는 경쟁 worktree를 만들지 않는다. 7. 다른 checkout, 임시 clone 또는 경쟁 worktree를 만들지 않는다.
완료 조건: 기존 worktree가 exact owner, repository, Issue, impl role, PR branch와 reviewed head에 결합되고 보존할 모든 기존 상태가 기록돼 있다. 완료 조건: 기존 worktree가 exact owner, repository, Issue, impl role, PR branch와 reviewed head에 결합되고 보존할 모든 기존 상태가 기록돼 있다.
@@ -259,7 +264,7 @@ Reviewer가 rework head를 새 snapshot으로 다시 검토해야 하며 이 com
### 8. 종료와 다음 review handoff ### 8. 종료와 다음 review handoff
1. 임시 검증 자원을 정리하기 전에 relevant output을 redaction하고 exact HEAD·command·exit code·환경과 결합해 reviewer-accessible evidence location에 저장한 뒤 그 내용을 readback한다. 그 후 임시 server, probe script·raw output, browser context, fixture workspace와 credential helper를 정리하고 부재를 확인한다. 1. 임시 검증 자원을 정리하기 전에 relevant output을 redaction하고 exact HEAD·command·exit code·환경과 결합해 reviewer-accessible evidence location에 저장한 뒤 그 내용을 readback한다. 그 후 임시 server, probe script·raw output, browser context, fixture workspace와 credential helper를 정리하고 부재를 확인한다.
2. 현재 실행이 시작한 lock holder는 lock FD를 상속한 자식 process까지 함께 종료한다. Shell PID 종료만으로 해제를 판정하지 않으며, 현재 실행이 획득한 lock을 역순으로 해제한 뒤 repository lock과 Issue lock을 각각 non-blocking으로 실제 재획득·해제한 경우에만 cleanup 완료로 판정한다. 2. 현재 실행이 시작한 tracked broker process와 그 자식 process를 cleanup 대상으로 관리한다. Shell PID 종료만으로 해제를 판정하지 않으며, 보유한 repository lease를 `release_repository`하고 Issue lease를 `release`한 뒤 broker를 wait/reap하고 `probe`에서 `free=true`를 확인한 경우에만 cleanup 완료로 판정한다.
3. Finding matrix, final diff, 검증 기록, remote head와 CI/status를 다시 대조한다. 3. Finding matrix, final diff, 검증 기록, remote head와 CI/status를 다시 대조한다.
4. 모든 item이 `resolved`여도 reviewer의 새 head 검토 전에는 구현 승인 완료가 아니라 `재작업 전달 및 comment handoff 완료, 재검토 대기`로 보고한다. 4. 모든 item이 `resolved`여도 reviewer의 새 head 검토 전에는 구현 승인 완료가 아니라 `재작업 전달 및 comment handoff 완료, 재검토 대기`로 보고한다.
5. 후속 review가 새 head에 `REQUEST_CHANGES`를 남기면 그 review를 새 immutable snapshot으로 하는 별도 재작업 iteration을 시작한다. 이전 review snapshot이나 matrix를 새 head에 그대로 재사용하지 않는다. 5. 후속 review가 새 head에 `REQUEST_CHANGES`를 남기면 그 review를 새 immutable snapshot으로 하는 별도 재작업 iteration을 시작한다. 이전 review snapshot이나 matrix를 새 head에 그대로 재사용하지 않는다.
@@ -1,7 +1,7 @@
--- ---
name: gitea-code-review name: gitea-code-review
description: Spec pass된 Gitea PR의 코드 품질과 안전성을 검토한다. description: Spec pass된 Gitea PR의 코드 품질과 안전성을 검토한다.
version: 0.1.1 version: 0.1.2
author: genichin, Hermes Agent author: genichin, Hermes Agent
license: MIT license: MIT
platforms: [linux] platforms: [linux]
@@ -15,6 +15,11 @@ metadata:
`구현 → spec pass → code review`의 독립 2차 리뷰를 수행한다. 승인된 요구사항을 바꾸거나 직접 구현하지 않고, 코드 결함과 검증 부족을 근거로 판정한다. `구현 → spec pass → code review`의 독립 2차 리뷰를 수행한다. 승인된 요구사항을 바꾸거나 직접 구현하지 않고, 코드 결함과 검증 부족을 근거로 판정한다.
## Managed-lock broker gate
Bootstrap 이후 code review는 canonical [Repository cache 운영 지침](../../../../operations/repository-cache.md)의 공통 managed-lock broker 하나를 사용한다. Checkout 밖 pre-created mode `0700` role-owned runtime directory와 unique handle/socket으로 tracked `serve`를 시작하고 ready JSON을 기다린다. Client는 raw token을 inspect/log하지 않으며 public `request` API가 token을 캡슐화한다. Nested repository lease는 같은 handle의 `acquire_repository`/`release_repository` request로만 관리하고 TTL 전에 `renew`한다. 정상·오류·cancel에서 repository release(보유 시) → Issue `release` → broker wait/reap → `probe` `free=true` 확인을 검증하기 전에는 완료·로그·보고하지 않는다. Raw `flock ... sleep`, lock-file unlink와 client-side lock holder는 금지한다. Native publication은 이 broker lease를 사용하며 adapter의 compatibility bridge는 canonical protocol을 우회하지 않는다.
## 사용 시점과 정본 ## 사용 시점과 정본
- Exact `owner/repository`, source Issue와 PR을 지정해 코드 리뷰 또는 재리뷰를 요청받았을 때 사용한다. - Exact `owner/repository`, source Issue와 PR을 지정해 코드 리뷰 또는 재리뷰를 요청받았을 때 사용한다.
@@ -47,11 +52,11 @@ metadata:
별도 워크트리는 만들지 않는다. 기존 impl Issue 워크트리는 다음 조건을 모두 만족할 때 **검증 전용으로 대여**한다. 구현 재개가 아니므로 `state.json`의 `created_by_role`은 impl role로 확인·보존하고 review role로 바꾸지 않는다. 별도 워크트리는 만들지 않는다. 기존 impl Issue 워크트리는 다음 조건을 모두 만족할 때 **검증 전용으로 대여**한다. 구현 재개가 아니므로 `state.json`의 `created_by_role`은 impl role로 확인·보존하고 review role로 바꾸지 않는다.
1. Workflow 동안 exact Issue lock을 유지한다. Cache·registration 검증에는 `issue lock → repository lock` 순서를 사용하고, 테스트 전에 repository lock만 해제한다. 1. Workflow 동안 broker가 관리하는 exact Issue lease를 유지한다. Cache·registration 검증에는 같은 handle의 `acquire_repository` request를 사용하고, 테스트 전에 `release_repository` request를 보낸다.
2. Canonical path·권한·state identity·common Git directory·detached/locked registration을 검증한다. HEAD는 현재 PR head와 같고 미완료 Git operation과 tracked 변경이 없어야 한다. 2. Canonical path·권한·state identity·common Git directory·detached/locked registration을 검증한다. HEAD는 현재 PR head와 같고 미완료 Git operation과 tracked 변경이 없어야 한다.
3. Untracked 파일, dependency 환경과 설정이 검증을 오염시키지 않는지 확인한다. 설명할 수 없는 상태는 reset·clean·stash·checkout으로 복구하지 않고 중단한다. 3. Untracked 파일, dependency 환경과 설정이 검증을 오염시키지 않는지 확인한다. 설명할 수 없는 상태는 reset·clean·stash·checkout으로 복구하지 않고 중단한다.
4. Source·test·Git refs·state는 수정하지 않는다. 테스트 출력·fixture·DB는 격리된 임시 경로를 사용하고 운영 데이터·credential을 사용하지 않는다. 파일 수정이나 위험한 외부 쓰기가 필요한 명령은 실행하지 않는다. 4. Source·test·Git refs·state는 수정하지 않는다. 테스트 출력·fixture·DB는 격리된 임시 경로를 사용하고 운영 데이터·credential을 사용하지 않는다. 파일 수정이나 위험한 외부 쓰기가 필요한 명령은 실행하지 않는다.
5. 실행 전후 HEAD와 tracked/untracked 상태를 비교한다. 현재 실행이 만든 자원만 정리하고, 모든 종료 경로에서 lock을 역순 해제한 뒤 non-blocking 재획득·해제로 해제를 확인한다. 5. 실행 전후 HEAD와 tracked/untracked 상태를 비교한다. 현재 실행이 만든 자원만 정리하고, 모든 종료 경로에서 canonical broker cleanup(보유한 `release_repository` → `release` → tracked broker wait/reap → `probe` `free=true`)을 검증한다.
안전한 대여가 불가능하면 CI·기존 근거를 사용하고 직접 실행 불가 사유를 보고한다. 필수 검증 근거가 부족하면 승인하지 않는다. 독립성은 reviewer의 판단과 직접 확인에 있으며 별도 checkout 자체가 필수는 아니다. 안전한 대여가 불가능하면 CI·기존 근거를 사용하고 직접 실행 불가 사유를 보고한다. 필수 검증 근거가 부족하면 승인하지 않는다. 독립성은 reviewer의 판단과 직접 확인에 있으며 별도 checkout 자체가 필수는 아니다.
@@ -8,7 +8,7 @@ This adapter is a publication guard, not a substitute for the main reviewer's co
- Python API: `build_payload(plan)`, `validate_snapshot(plan, snapshot)`, `verify_publication(plan, before, after, exact)`, and `publish(plan, baseline, api, lease, journal_directory)`. - Python API: `build_payload(plan)`, `validate_snapshot(plan, snapshot)`, `verify_publication(plan, before, after, exact)`, and `publish(plan, baseline, api, lease, journal_directory)`.
- REST bridge: `ApiAdapter(authenticated_request, origin=..., credential_source=...)`. - REST bridge: `ApiAdapter(authenticated_request, origin=..., credential_source=...)`.
- Existing lease bridge: `HeldIssueLease(issue_fd, absolute_cache_policy, deadline_monotonic)`. - Primary lease bridge: `BrokerIssueLease(broker_handle, absolute_cache_policy, deadline_monotonic)` backed by `managed_lock_broker.py`; `HeldIssueLease(issue_fd, absolute_cache_policy, deadline_monotonic)` remains compatibility-only for already-admitted legacy callers. Broker requests are limited to `status`, `renew`, `acquire_repository`, `release_repository` and `release`; renew before expiry and use canonical release/wait/reap/probe cleanup.
- Read-only CLI: `preflight --plan PLAN --baseline SNAPSHOT` and `verify --plan PLAN --before BEFORE --after AFTER --exact EXACT`. - Read-only CLI: `preflight --plan PLAN --baseline SNAPSHOT` and `verify --plan PLAN --before BEFORE --after AFTER --exact EXACT`.
- There is intentionally **no live `publish` CLI**. Live publication uses the Python API with the workflow's existing authenticated transport and still-held Issue lock. Never invent a `capture`, `build`, `publish`, or `submit` command or guess a legacy helper schema. - There is intentionally **no live `publish` CLI**. Live publication uses the Python API with the workflow's existing authenticated transport and still-held Issue lock. Never invent a `capture`, `build`, `publish`, or `submit` command or guess a legacy helper schema.
@@ -39,10 +39,10 @@ Keep `gitea-access.md` and `repository-cache.md` authoritative. Before calling `
1. Admit the canonical, existing role-owned repository cache and impl-created, registered detached/locked worktree. Complete the independent code review and collect evidence on the exact source head. 1. Admit the canonical, existing role-owned repository cache and impl-created, registered detached/locked worktree. Complete the independent code review and collect evidence on the exact source head.
2. Authenticate **one** transport from the current role's `$HERMES_HOME/.env` `GITEA_TOKEN`, or an explicitly authorized role-scoped external secret source. No ambient-token fallback, cross-profile credentials, credential-bearing Git URLs or anonymous downgrade is allowed. 2. Authenticate **one** transport from the current role's `$HERMES_HOME/.env` `GITEA_TOKEN`, or an explicitly authorized role-scoped external secret source. No ambient-token fallback, cross-profile credentials, credential-bearing Git URLs or anonymous downgrade is allowed.
3. Validate the exact fixed origin `https://gitea.jsfamily.quest/api/v1` and `GET /user` account ID/login. The transport must verify TLS, reject redirects, bound network timeouts and **not retry mutation requests**. The bridge's provenance labels are assertions supplied by this already-validated caller, not a mechanism to load or certify an arbitrary client. 3. Validate the exact fixed origin `https://gitea.jsfamily.quest/api/v1` and `GET /user` account ID/login. The transport must verify TLS, reject redirects, bound network timeouts and **not retry mutation requests**. The bridge's provenance labels are assertions supplied by this already-validated caller, not a mechanism to load or certify an arbitrary client.
4. Keep the Issue `fcntl.flock` exclusive descriptor owned by this process open throughout publication/readback. Pass a live monotonic deadline, not an old PID/`ready.json` flag. `HeldIssueLease` checks exact canonical Issue lock inode, owner/mode, worktree state/registration, clean HEAD and deadline. It never releases or closes the caller's descriptor. The caller must do so in its normal `finally` cleanup. A custom supervisor lease must implement the same invariants: `check(plan)` returns `None` only on success and otherwise raises; boolean/dict success flags are unsupported. 4. Use the common broker Issue lease throughout publication/readback. Pass a live monotonic deadline, not an old PID/`ready.json` flag. `BrokerIssueLease` checks broker status and canonical worktree invariants; renew before expiry. `HeldIssueLease` is compatibility-only and may be used only by an already-admitted legacy caller, never to create a second lock protocol. The caller must run canonical release/wait/reap/probe cleanup. A custom supervisor lease must implement the same invariants: `check(plan)` returns `None` only on success and otherwise raises; boolean/dict success flags are unsupported.
5. Put the journal outside every source checkout in a trusted artifact parent. Use the **same journal for every retry/resume of this plan**, not a new directory after a timeout. The adapter creates mode 0700 directories and 0600 atomic/fsynced artifacts, rejects symlinks/hardlinks/foreign or unsafe modes, and holds a separate nonblocking journal mutex. Redact secrets before constructing plans/artifacts; do not log request headers, raw transport exceptions or token values. Journals contain complete source snapshots and must remain private. 5. Put the journal outside every source checkout in a trusted artifact parent. Use the **same journal for every retry/resume of this plan**, not a new directory after a timeout. The adapter creates mode 0700 directories and 0600 atomic/fsynced artifacts, rejects symlinks/hardlinks/foreign or unsafe modes, and holds a separate nonblocking journal mutex. Redact secrets before constructing plans/artifacts; do not log request headers, raw transport exceptions or token values. Journals contain complete source snapshots and must remain private.
The current helper is strict about worktree child directories: group/world-writable checkout paths are rejected. Do not change a real worktree's permissions to force publication; stop and reconcile an admission/publisher compatibility conflict. The built-in lease bridge is Linux-only: it requires an existing whole-file exclusive `FLOCK` owned by this process in `/proc/self/fdinfo`, and never acquires or reacquires that lock. It uses a root-owned, non-group/world-writable `/usr/bin/git` with a fixed minimal environment, not the caller's `PATH`. The current helper is strict about worktree child directories: group/world-writable checkout paths are rejected. Do not change a real worktree's permissions to force publication; stop and reconcile an admission/publisher compatibility conflict. The legacy compatibility bridge is Linux-only: it validates an already-admitted broker-held whole-file lease represented by an exclusive `FLOCK` owned by this process in `/proc/self/fdinfo`, and never acquires or reacquires that resource. New callers use `BrokerIssueLease` and the broker public request API. It uses a root-owned, non-group/world-writable `/usr/bin/git` with a fixed minimal environment, not the caller's `PATH`.
These guards assume a cooperative, trusted role-local filesystem and transport. They do not isolate a malicious process with the same UID or pin every filesystem ancestor across subprocess path resolution. At-most-once creation is scoped to the retained journal and held canonical Issue lease, **not** arbitrary alternate journal paths, unrelated publishers, or multiple hosts. Do not share one lease across simultaneous publication callers or change the journal after an uncertain attempt. These guards assume a cooperative, trusted role-local filesystem and transport. They do not isolate a malicious process with the same UID or pin every filesystem ancestor across subprocess path resolution. At-most-once creation is scoped to the retained journal and held canonical Issue lease, **not** arbitrary alternate journal paths, unrelated publishers, or multiple hosts. Do not share one lease across simultaneous publication callers or change the journal after an uncertain attempt.
@@ -60,7 +60,7 @@ api = native_review.ApiAdapter(
origin="https://gitea.jsfamily.quest/api/v1", origin="https://gitea.jsfamily.quest/api/v1",
credential_source="$HERMES_HOME/.env:GITEA_TOKEN", credential_source="$HERMES_HOME/.env:GITEA_TOKEN",
) )
lease = native_review.HeldIssueLease(issue_fd, absolute_cache_policy, deadline_monotonic) lease = native_review.BrokerIssueLease(broker_handle, absolute_cache_policy, deadline_monotonic)
baseline = api.snapshot(plan["target"]) baseline = api.snapshot(plan["target"])
native_review.validate_snapshot(plan, baseline) native_review.validate_snapshot(plan, baseline)
result = native_review.publish(plan, baseline, api, lease, journal_directory) result = native_review.publish(plan, baseline, api, lease, journal_directory)
@@ -14,6 +14,14 @@ _SPEC_PATH = (Path(__file__).resolve().parents[4] /
_spec = importlib.util.spec_from_file_location('gitea_native_spec_support', _SPEC_PATH) _spec = importlib.util.spec_from_file_location('gitea_native_spec_support', _SPEC_PATH)
_support = importlib.util.module_from_spec(_spec) _support = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_support) _spec.loader.exec_module(_support)
_LOCK_PATH = Path(__file__).resolve().parents[5] / 'lib/managed_lock.py'
_lock_spec = importlib.util.spec_from_file_location('gitea_managed_lock', _LOCK_PATH)
_locks = importlib.util.module_from_spec(_lock_spec)
_lock_spec.loader.exec_module(_locks)
_BROKER_PATH = Path(__file__).resolve().parents[5] / 'lib/managed_lock_broker.py'
_broker_spec = importlib.util.spec_from_file_location('gitea_managed_lock_broker', _BROKER_PATH)
_broker = importlib.util.module_from_spec(_broker_spec)
_broker_spec.loader.exec_module(_broker)
ValidationError = _support.ValidationError ValidationError = _support.ValidationError
loads_json = _support.loads_json loads_json = _support.loads_json
collect_pages = _support.collect_pages collect_pages = _support.collect_pages
@@ -646,83 +654,158 @@ class ApiAdapter:
return data return data
def _validate_registered_source(plan, policy_path, deadline_monotonic):
import math, os, stat, subprocess, time
if type(deadline_monotonic) not in [int, float] or not math.isfinite(deadline_monotonic):
fail('invalid or expired Issue lease')
t = plan['target']
info = _support.validate_cache_paths(policy_path, t['owner'], t['repository'], t['issue'])
wt = Path(info['repository']) / 'worktrees' / f'issue-{t["issue"]}' / 'checkout'
opened = []
def close_opened():
while opened:
try: os.close(opened.pop())
except OSError: pass
def reject(message):
close_opened()
fail(message)
def open_dir(path):
parts = path.parts
fd = os.open(parts[0], os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC)
opened.append(fd)
try:
for part in parts[1:]:
nxt = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC,
dir_fd=fd)
opened.append(nxt); os.close(fd); opened.remove(fd); fd = nxt
return fd
except BaseException:
close_opened(); raise
try:
worktrees_fd = open_dir(wt.parent.parent)
issue_fd = os.open(wt.parent.name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC,
dir_fd=worktrees_fd)
opened.append(issue_fd)
checkout_fd = os.open(wt.name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC,
dir_fd=issue_fd)
opened.append(checkout_fd)
for directory_fd in [worktrees_fd, issue_fd, checkout_fd]:
st = os.fstat(directory_fd)
if (not stat.S_ISDIR(st.st_mode) or st.st_uid != os.getuid()
or st.st_mode & (stat.S_IWGRP | stat.S_IWOTH)):
reject('unsafe existing worktree path')
state_fd = os.open('state.json', os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=issue_fd)
opened.append(state_fd)
git_pointer_fd = os.open('.git', os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=checkout_fd)
opened.append(git_pointer_fd)
state_st = os.fstat(state_fd); git_st = os.fstat(git_pointer_fd)
if not stat.S_ISREG(state_st.st_mode) or state_st.st_uid != os.getuid() or stat.S_IMODE(state_st.st_mode) != 0o600:
reject('unsafe worktree state')
if not stat.S_ISREG(git_st.st_mode) or git_st.st_uid != os.getuid():
reject('invalid worktree Git pointer')
chunks = []
while chunk := os.read(state_fd, 65536):
chunks.append(chunk)
try:
state = loads_json(b''.join(chunks).decode('utf-8'))
except Exception:
close_opened()
raise
os.lseek(git_pointer_fd, 0, os.SEEK_SET)
pointer = os.read(git_pointer_fd, 4096).decode('utf-8').strip()
if not pointer.startswith('gitdir: '):
reject('invalid worktree Git pointer')
worktree_git_fd = open_dir(Path(pointer[8:]))
except OSError:
close_opened()
raise ValidationError('unsafe existing worktree path') from None
if (type(state) is not dict or state.get('schema') != 'gitea-agent.worktree/v1' or state.get('owner') != t['owner']
or state.get('repository') != t['repository'] or type(state.get('issue')) is not int
or state['issue'] != t['issue'] or state.get('created_by_role') != 'gitea-impl-agent'
or state.get('mode') != 'detached'):
reject('worktree registration identity mismatch')
git_binary = '/usr/bin/git'; binary_stat = Path(git_binary).lstat()
if not stat.S_ISREG(binary_stat.st_mode) or binary_stat.st_uid != 0 or binary_stat.st_mode & 0o022:
reject('untrusted system Git binary')
env = {'PATH': '/usr/bin:/bin', 'GIT_CONFIG_GLOBAL': '/dev/null', 'GIT_CONFIG_NOSYSTEM': '1', 'GIT_OPTIONAL_LOCKS': '0'}
git_dir_fd = open_dir(Path(info['repository_git']))
def git(*args):
try:
process = subprocess.run([git_binary, '-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null',
'-c', 'safe.directory=/proc/self/fd/' + str(checkout_fd),
'--git-dir=/proc/self/fd/' + str(worktree_git_fd),
'--work-tree=/proc/self/fd/' + str(checkout_fd), *args], capture_output=True, text=True, env=env, timeout=15,
pass_fds=(checkout_fd, worktree_git_fd, git_dir_fd))
except Exception:
close_opened()
raise ValidationError('read-only Git check unavailable') from None
if process.returncode or len(process.stdout) > 1000000:
reject('read-only Git check failed')
return process.stdout
if git('rev-parse', 'HEAD').strip() != t['head']:
reject('worktree HEAD mismatch')
if git('status', '--porcelain=v1', '--untracked-files=all').strip():
reject('existing worktree is not clean')
if git('rev-parse', '--path-format=absolute', '--git-common-dir').strip() != info['repository_git']:
reject('worktree common Git directory mismatch')
records = git('--git-dir=/proc/self/fd/' + str(git_dir_fd), 'worktree', 'list', '--porcelain', '-z').split('\0\0')
matches = [r.split('\0') for r in records if r.startswith('worktree ' + str(wt) + '\0')]
if len(matches) != 1 or 'detached' not in matches[0] or not any(x == 'locked' or x.startswith('locked ') for x in matches[0]):
reject('worktree detached/locked registration mismatch')
close_opened()
if time.monotonic() >= deadline_monotonic:
fail('Issue lease expired during source checks')
class HeldIssueLease: class HeldIssueLease:
"""Validate a caller-owned Issue lock FD; never close/release the caller's FD.""" """Validate a caller-owned Issue lock FD; never close/release the caller's FD."""
def __init__(self, issue_fd, policy_path, deadline_monotonic): def __init__(self, issue_fd, policy_path, deadline_monotonic):
self.issue_fd = issue_fd self.issue_fd = issue_fd; self.policy_path = policy_path; self.deadline_monotonic = deadline_monotonic
self.policy_path = policy_path
self.deadline_monotonic = deadline_monotonic
def check(self, plan): def check(self, plan):
import math, os, stat, subprocess, time import math, time
validate_plan(plan) validate_plan(plan)
if (type(self.issue_fd) is not int or type(self.deadline_monotonic) not in [int, float] if (type(self.issue_fd) is not int or type(self.deadline_monotonic) not in [int, float]
or not math.isfinite(self.deadline_monotonic) or time.monotonic() >= self.deadline_monotonic): or not math.isfinite(self.deadline_monotonic) or time.monotonic() >= self.deadline_monotonic):
fail('invalid or expired Issue lease') fail('invalid or expired Issue lease')
t = plan['target'] t = plan['target']; info = _support.validate_cache_paths(self.policy_path, t['owner'], t['repository'], t['issue'])
info = _support.validate_cache_paths(self.policy_path, t['owner'], t['repository'], t['issue'])
lock = Path(info['locks']) / f'issue-{t["issue"]}.lock' lock = Path(info['locks']) / f'issue-{t["issue"]}.lock'
try: try:
current = os.fstat(self.issue_fd); disk = lock.lstat() if not _locks.validate_held_flock(self.issue_fd, lock):
if (not stat.S_ISREG(current.st_mode) or not stat.S_ISREG(disk.st_mode)
or current.st_uid != os.getuid() or stat.S_IMODE(current.st_mode) != 0o600
or current.st_nlink != 1 or (current.st_dev, current.st_ino) != (disk.st_dev, disk.st_ino)):
fail('invalid Issue lock descriptor')
# Linux fdinfo reports locks on this open file description. Do not
# acquire/reacquire here: an opened but unheld FD is not a lease.
records = [line.split() for line in Path(f'/proc/self/fdinfo/{self.issue_fd}').read_text().splitlines()]
device_inode = f'{os.major(current.st_dev):02x}:{os.minor(current.st_dev):02x}:{current.st_ino}'
if not any(len(row) == 9 and row[0] == 'lock:'
and row[2:6] == ['FLOCK', 'ADVISORY', 'WRITE', str(os.getpid())]
and row[6:] == [device_inode, '0', 'EOF'] for row in records):
fail('Issue lock is not already held by this process') fail('Issue lock is not already held by this process')
except OSError: except (OSError, _locks.LockValidationError):
raise ValidationError('Issue lock unavailable') from None raise ValidationError('Issue lock unavailable') from None
wt = Path(info['repository']) / 'worktrees' / f'issue-{t["issue"]}' / 'checkout' _validate_registered_source(plan, self.policy_path, self.deadline_monotonic)
for directory in [wt.parent.parent, wt.parent, wt]:
st = directory.lstat()
if not stat.S_ISDIR(st.st_mode) or st.st_uid != os.getuid() or st.st_mode & (stat.S_IWGRP | stat.S_IWOTH): class BrokerIssueLease:
fail('unsafe existing worktree path') """Validate an authenticated shared managed-lock broker Issue lease."""
state_file = wt.parent / 'state.json'; st = state_file.lstat() def __init__(self, handle_path, policy_path, deadline_monotonic):
if not stat.S_ISREG(st.st_mode) or st.st_uid != os.getuid() or stat.S_IMODE(st.st_mode) != 0o600: self.handle_path = handle_path; self.policy_path = policy_path; self.deadline_monotonic = deadline_monotonic
fail('unsafe worktree state')
state = loads_json(state_file.read_text(encoding='utf-8')) def check(self, plan):
if (type(state) is not dict or state.get('schema') != 'gitea-agent.worktree/v1' or state.get('owner') != t['owner'] import math, time
or state.get('repository') != t['repository'] or type(state.get('issue')) is not int validate_plan(plan)
or state['issue'] != t['issue'] or state.get('created_by_role') != 'gitea-impl-agent' if (type(self.deadline_monotonic) not in [int, float] or not math.isfinite(self.deadline_monotonic)
or state.get('mode') != 'detached'): or time.monotonic() >= self.deadline_monotonic):
fail('worktree registration identity mismatch') fail('invalid or expired Issue lease')
st = (wt / '.git').lstat() t = plan['target']
if not stat.S_ISREG(st.st_mode) or st.st_uid != os.getuid(): try:
fail('invalid worktree Git pointer') handle = _broker.read_handle_metadata(self.handle_path)
git_binary = '/usr/bin/git' status = _broker.request_handle(self.handle_path, 'status')
binary_stat = Path(git_binary).lstat() except Exception:
if not stat.S_ISREG(binary_stat.st_mode) or binary_stat.st_uid != 0 or binary_stat.st_mode & 0o022: raise ValidationError('broker Issue lease unavailable') from None
fail('untrusted system Git binary') if (type(status) is not dict or status.get('status') not in ('held', 'repository-held')
env = {'PATH': '/usr/bin:/bin', 'GIT_CONFIG_GLOBAL': '/dev/null', or status.get('owner') != t['owner'] or status.get('repository') != t['repository']
'GIT_CONFIG_NOSYSTEM': '1', 'GIT_OPTIONAL_LOCKS': '0'} or status.get('issue') != t['issue'] or status.get('role') != 'gitea-review-agent'
def git(*args): or type(status.get('pid')) is not int or status.get('pid') != handle.get('pid')
try: or type(handle.get('pid')) is not int or status.get('deadline') != handle.get('deadline')
process = subprocess.run([git_binary, '-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null', or type(status.get('deadline')) not in [int, float]
'-c', 'safe.directory=' + str(wt), *args], or status.get('deadline') > self.deadline_monotonic or time.monotonic() >= status.get('deadline')):
capture_output=True, text=True, env=env, timeout=15) fail('broker Issue lease identity mismatch')
except Exception: _validate_registered_source(plan, self.policy_path, self.deadline_monotonic)
raise ValidationError('read-only Git check unavailable') from None
if process.returncode or len(process.stdout) > 1000000:
fail('read-only Git check failed')
return process.stdout
if git('-C', str(wt), 'rev-parse', 'HEAD').strip() != t['head']:
fail('worktree HEAD mismatch')
if git('-C', str(wt), 'status', '--porcelain=v1', '--untracked-files=all').strip():
fail('existing worktree is not clean')
if git('-C', str(wt), 'rev-parse', '--path-format=absolute', '--git-common-dir').strip() != info['repository_git']:
fail('worktree common Git directory mismatch')
records = git('--git-dir=' + info['repository_git'], 'worktree', 'list', '--porcelain', '-z').split('\0\0')
matches = [r.split('\0') for r in records if r.startswith('worktree ' + str(wt) + '\0')]
if len(matches) != 1 or 'detached' not in matches[0] or not any(x == 'locked' or x.startswith('locked ') for x in matches[0]):
fail('worktree detached/locked registration mismatch')
if time.monotonic() >= self.deadline_monotonic:
fail('Issue lease expired during source checks')
def main(argv=None): def main(argv=None):
@@ -1,7 +1,7 @@
--- ---
name: gitea-discovery-authoring name: gitea-discovery-authoring
description: Gitea Issue의 Discovery 초안을 작성하고 게시한다. description: Gitea Issue의 Discovery 초안을 작성하고 게시한다.
version: 0.4.0 version: 0.4.1
author: genichin, Hermes Agent author: genichin, Hermes Agent
license: MIT license: MIT
platforms: [linux] platforms: [linux]
@@ -15,6 +15,11 @@ metadata:
Gitea Issue 원본과 저장소 근거를 조사해 Discovery 초안을 작성하고 Issue 본문에 게시한다. Gitea 인증과 readback은 [Gitea authenticated access 운영 지침](../../../../operations/gitea-access.md), 작성 규칙은 [Discovery 작성 방법](../../../../docs/discovery/README.md), 문서 구조는 [Discovery 템플릿](../../../../docs/discovery/TEMPLATE.md)만 정본으로 사용하며 이 스킬에 복제하거나 임의로 확장하지 않는다. Gitea Issue 원본과 저장소 근거를 조사해 Discovery 초안을 작성하고 Issue 본문에 게시한다. Gitea 인증과 readback은 [Gitea authenticated access 운영 지침](../../../../operations/gitea-access.md), 작성 규칙은 [Discovery 작성 방법](../../../../docs/discovery/README.md), 문서 구조는 [Discovery 템플릿](../../../../docs/discovery/TEMPLATE.md)만 정본으로 사용하며 이 스킬에 복제하거나 임의로 확장하지 않는다.
## Managed-lock broker gate
Bootstrap 이후 Issue workflow는 canonical [Repository cache 운영 지침](../../../../operations/repository-cache.md)의 공통 managed-lock broker 하나를 사용한다. Checkout 밖 pre-created mode `0700` role-owned runtime directory와 unique handle/socket을 사용해 tracked `serve`를 시작하고 ready JSON을 기다린다. Client는 raw token을 inspect/log하지 않으며 public `request` API가 token을 캡슐화한다. 같은 handle의 `acquire_repository`/`release_repository`로만 nested repository lock을 관리하고 TTL 전에 `renew`한다. 정상·오류·cancel 모두 repository release(보유 시), Issue release, broker wait/reap, `probe` `free=true` readback 순서가 검증돼야 완료·로그·보고할 수 있다. Raw `flock ... sleep`, lock-file unlink와 client-side lock holder는 금지한다.
## 사용 시점 ## 사용 시점
다음과 같이 정확한 저장소와 Issue를 지정해 Discovery 초안 작성을 요청할 때 사용한다. 다음과 같이 정확한 저장소와 Issue를 지정해 Discovery 초안 작성을 요청할 때 사용한다.
@@ -31,24 +36,24 @@ Gitea Issue 원본과 저장소 근거를 조사해 Discovery 초안을 작성
Repository cache의 구조와 운영 규칙은 [Repository cache 운영 지침](../../../../operations/repository-cache.md)만 정본으로 사용한다. 작업할 때마다 이 문서를 새로 읽고 상세 규칙을 이 스킬에 복제하거나 완화하지 않는다. Repository cache의 구조와 운영 규칙은 [Repository cache 운영 지침](../../../../operations/repository-cache.md)만 정본으로 사용한다. 작업할 때마다 이 문서를 새로 읽고 상세 규칙을 이 스킬에 복제하거나 완화하지 않는다.
Discovery 초안을 작성하기 전에 repository cache admission gate를 반드시 통과한다. 대상 cache가 없으면 Issue lock 획득 전에 `gitea-repository-cache-bootstrap` 스킬로 초기화하며, 기존 cache가 유효하지 않으면 자동 초기화하지 않고 중단한다. 초기화 여부와 관계없이 Issue lock 아래에서 canonical origin의 cache를 갱신하고, 갱신 후 다시 읽은 Gitea authoritative default-branch SHA와 local ref SHA가 정확히 일치해야 한다. 일치한 SHA를 pinned source revision으로 고정하며, gate가 실패하면 초안을 작성하지 않는다. Discovery 초안을 작성하기 전에 repository cache admission gate를 반드시 통과한다. 대상 cache가 없으면 broker Issue lease 확인 전에 `gitea-repository-cache-bootstrap` 스킬로 초기화하며, 기존 cache가 유효하지 않으면 자동 초기화하지 않고 중단한다. 초기화 여부와 관계없이 broker Issue lease 아래에서 같은 handle의 `acquire_repository` request로 canonical origin의 cache를 갱신하고, 갱신 후 다시 읽은 Gitea authoritative default-branch SHA와 local ref SHA가 정확히 일치해야 한다. 일치한 SHA를 pinned source revision으로 고정하며, gate가 실패하면 초안을 작성하지 않는다.
## 절차 ## 절차
1. 요청에서 exact `owner`, `repository`, Issue 번호를 추출하고 형식을 검증한다. `read_file`로 `operations/repository-cache.md`를 새로 읽고 canonical cache 경로를 계산·검증한다. 1. 요청에서 exact `owner`, `repository`, Issue 번호를 추출하고 형식을 검증한다. `read_file`로 `operations/repository-cache.md`를 새로 읽고 canonical cache 경로를 계산·검증한다.
2. Canonical authenticated Gitea API adapter로 spec-agent의 stable account identity와 exact repository metadata를 확인한다. 2. Canonical authenticated Gitea API adapter로 spec-agent의 stable account identity와 exact repository metadata를 확인한다.
3. 대상 cache가 없으면 `gitea-repository-cache-bootstrap` 스킬로 초기화하고 관련 lock이 해제됐는지 확인한다. 기존 cache가 있으면 자동 초기화하지 않고 canonical 검증 절차를 따른다. 3. 대상 cache가 없으면 `gitea-repository-cache-bootstrap` 스킬로 초기화하고 관련 lock이 해제됐는지 확인한다. 기존 cache가 있으면 자동 초기화하지 않고 canonical 검증 절차를 따른다.
4. Cache와 lock 경로의 안전성을 검증한 뒤 canonical Issue lock을 획득한다. 같은 adapter로 Issue 전체 내용과 전체 comment baseline을 새로 읽고, Issue title, body, state, labels, assignees, milestone과 `updated_at`을 immutable pre-write snapshot으로 보존한다. 4. Cache와 lock 경로의 안전성을 검증한 뒤 broker `serve`의 ready 응답과 public `request` API로 canonical Issue lease를 확인한다. 같은 adapter로 Issue 전체 내용과 전체 comment baseline을 새로 읽고, Issue title, body, state, labels, assignees, milestone과 `updated_at`을 immutable pre-write snapshot으로 보존한다.
5. Issue lock을 유지한 채 canonical 순서에 따라 repository lock을 획득한다. path, bare 상태와 remote identity를 검증한 뒤 authoritative origin에서 prune/fetch한다. fetch 후 Gitea default branch와 SHA를 다시 읽고 local ref가 exact SHA와 일치하는지 확인하여 pinned source revision으로 기록한다. 어느 단계든 실패하면 획득한 lock을 canonical 규칙에 따라 해제하고 외부 write와 Discovery 작성을 수행하지 않는다. 5. Issue lease를 유지한 채 같은 broker handle로 `acquire_repository` request를 보낸다. path, bare 상태와 remote identity를 검증한 뒤 authoritative origin에서 prune/fetch한다. fetch 후 Gitea default branch와 SHA를 다시 읽고 local ref가 exact SHA와 일치하는지 확인하여 pinned source revision으로 기록한다. 어느 단계든 실패하면 보유 lease에 `release_repository`/`release` request를 보내고 외부 write와 Discovery 작성을 수행하지 않는다.
6. `read_file`로 `docs/discovery/README.md`와 `docs/discovery/TEMPLATE.md`를 새로 읽는다. 6. `read_file`로 `docs/discovery/README.md`와 `docs/discovery/TEMPLATE.md`를 새로 읽는다.
7. repository lock을 유지한 채 Issue 원본과 pinned repository SHA의 tree 및 파일을 조사한다. 움직이는 branch 이름 대신 exact pinned SHA를 사용하고, `git --git-dir=<path>/repository.git`의 read-only 명령 또는 이에 상응하는 안전한 reader만 사용하며 조사한 경로와 SHA를 기록한다. README가 정한 후속 문서의 exact 경로 존재 여부와 기존 요구사항명세서의 ID·파일명을 함께 조사한다. 7. repository lease를 유지한 채 Issue 원본과 pinned repository SHA의 tree 및 파일을 조사한다. 움직이는 branch 이름 대신 exact pinned SHA를 사용하고, `git --git-dir=<path>/repository.git`의 read-only 명령 또는 이에 상응하는 안전한 reader만 사용하며 조사한 경로와 SHA를 기록한다. README가 정한 후속 문서의 exact 경로 존재 여부와 기존 요구사항명세서의 ID·파일명을 함께 조사한다.
8. 조사 종료 시 Gitea default-branch SHA와 local ref를 다시 읽어 pinned SHA와 모두 일치하는지 확인하고 repository lock을 해제한다. Issue lock은 전체 Issue workflow가 끝날 때까지 유지한다. 8. 조사 종료 시 Gitea default-branch SHA와 local ref를 다시 읽어 pinned SHA와 모두 일치하는지 확인하고 `release_repository` request를 보낸다. Issue lease는 전체 Issue workflow가 끝날 때까지 유지한다.
9. README의 작성 규칙과 TEMPLATE의 visible 구조를 그대로 적용해 Discovery body artifact를 작성한다. `문서 영향 및 산출물`은 README가 정한 모든 문서 유형을 빠짐없이 판정하고 문서별 필수 내용을 구현 에이전트가 추가 설계 없이 작성할 수 있을 정도로 구체화한다. 요구사항명세서는 pinned SHA에서 충돌 여부를 확인한 exact 문서 ID·경로·제목·포함할 내용·제외할 내용을 기록한다. 모든 `${...}` placeholder를 교체하고 TEMPLATE에 없는 hidden state, marker 또는 metadata를 추가하지 않는다. 9. README의 작성 규칙과 TEMPLATE의 visible 구조를 그대로 적용해 Discovery body artifact를 작성한다. `문서 영향 및 산출물`은 README가 정한 모든 문서 유형을 빠짐없이 판정하고 문서별 필수 내용을 구현 에이전트가 추가 설계 없이 작성할 수 있을 정도로 구체화한다. 요구사항명세서는 pinned SHA에서 충돌 여부를 확인한 exact 문서 ID·경로·제목·포함할 내용·제외할 내용을 기록한다. 모든 `${...}` placeholder를 교체하고 TEMPLATE에 없는 hidden state, marker 또는 metadata를 추가하지 않는다.
10. README의 작성 후 검증에 따라 형식 검증과 내용 검토를 각각 수행한다. body artifact의 첫 marker, 섹션 순서·제목, placeholder 부재, `문서 영향 및 산출물`의 문서 유형·순서·조치·경로·변경 내용·근거와 문서별 필수 내용 및 모든 저장소 근거가 pinned SHA에서 확인됐는지 검증한다. 신규 요구사항명세서의 경로에 `NNN`, `<slug>` 또는 다른 미확정 값이 남아 있거나 ID 충돌·형식 오류를 해소할 수 없으면 외부 write 없이 중단한다. 10. README의 작성 후 검증에 따라 형식 검증과 내용 검토를 각각 수행한다. body artifact의 첫 marker, 섹션 순서·제목, placeholder 부재, `문서 영향 및 산출물`의 문서 유형·순서·조치·경로·변경 내용·근거와 문서별 필수 내용 및 모든 저장소 근거가 pinned SHA에서 확인됐는지 검증한다. 신규 요구사항명세서의 경로에 `NNN`, `<slug>` 또는 다른 미확정 값이 남아 있거나 ID 충돌·형식 오류를 해소할 수 없으면 외부 write 없이 중단한다.
11. write 직전에 Issue와 comments를 다시 읽는다. pre-write snapshot의 body와 `updated_at`이 달라졌거나 comment baseline이 drift했으면 중단한다. 11. write 직전에 Issue와 comments를 다시 읽는다. pre-write snapshot의 body와 `updated_at`이 달라졌거나 comment baseline이 drift했으면 중단한다.
12. 아래의 단순 원본 보존 comment 형식으로 exact comment artifact를 만들고 한 번 작성한 뒤, 곧바로 comment 목록에서 authoritative readback한다. 12. 아래의 단순 원본 보존 comment 형식으로 exact comment artifact를 만들고 한 번 작성한 뒤, 곧바로 comment 목록에서 authoritative readback한다.
13. comment readback 직후 Issue와 comments를 다시 읽어 post-comment snapshot을 만든다. body와 비본문 필드가 pre-write snapshot과 같고 comments가 baseline과 exact 신규 comment 하나로만 구성돼야 한다. 다르면 body를 변경하지 않고 partial 상태로 중단한다. 13. comment readback 직후 Issue와 comments를 다시 읽어 post-comment snapshot을 만든다. body와 비본문 필드가 pre-write snapshot과 같고 comments가 baseline과 exact 신규 comment 하나로만 구성돼야 한다. 다르면 body를 변경하지 않고 partial 상태로 중단한다.
14. 보존 comment가 정확히 확인된 경우에만 Issue body 하나만 Discovery 초안으로 교체한다. provider가 조건부 update를 지원하면 post-comment `updated_at` 또는 ETag를 precondition으로 사용한다. 지원하지 않으면 Issue lock 아래에서 13번 read 직후 write하며 title, state, labels, assignees, milestone 등 다른 필드는 write payload에 포함하지 않는다. 14. 보존 comment가 정확히 확인된 경우에만 Issue body 하나만 Discovery 초안으로 교체한다. provider가 조건부 update를 지원하면 post-comment `updated_at` 또는 ETag를 precondition으로 사용한다. 지원하지 않으면 broker-managed Issue lease 아래에서 13번 read 직후 write하며 title, state, labels, assignees, milestone 등 다른 필드는 write payload에 포함하지 않는다.
15. 동일 Issue를 다시 읽어 body가 초안과 정확히 일치하고 다른 snapshot 필드가 변경되지 않았는지 확인한다. comments도 다시 읽어 보존 comment가 정확히 하나인지 확인한다. 15. 동일 Issue를 다시 읽어 body가 초안과 정확히 일치하고 다른 snapshot 필드가 변경되지 않았는지 확인한다. comments도 다시 읽어 보존 comment가 정확히 하나인지 확인한다.
## 원본 보존 comment ## 원본 보존 comment
@@ -1,7 +1,7 @@
--- ---
name: gitea-discovery-review name: gitea-discovery-review
description: 수정된 Gitea Discovery를 검토하고 정규화한다. description: 수정된 Gitea Discovery를 검토하고 정규화한다.
version: 0.3.1 version: 0.3.2
author: genichin, Hermes Agent author: genichin, Hermes Agent
license: MIT license: MIT
platforms: [linux] platforms: [linux]
@@ -17,6 +17,11 @@ metadata:
Gitea 인증과 readback은 [Gitea authenticated access 운영 지침](../../../../operations/gitea-access.md), 작성 규칙은 [Discovery 작성 방법](../../../../docs/discovery/README.md), visible 구조는 [Discovery 템플릿](../../../../docs/discovery/TEMPLATE.md), cache와 lock 규칙은 [Repository cache 운영 지침](../../../../operations/repository-cache.md)만 정본으로 사용한다. Gitea 인증과 readback은 [Gitea authenticated access 운영 지침](../../../../operations/gitea-access.md), 작성 규칙은 [Discovery 작성 방법](../../../../docs/discovery/README.md), visible 구조는 [Discovery 템플릿](../../../../docs/discovery/TEMPLATE.md), cache와 lock 규칙은 [Repository cache 운영 지침](../../../../operations/repository-cache.md)만 정본으로 사용한다.
## Managed-lock broker gate
Bootstrap 이후 Discovery review는 canonical [Repository cache 운영 지침](../../../../operations/repository-cache.md)의 단일 role-neutral managed-lock broker를 사용한다. `serve`를 tracked background process로 시작하고 ready JSON을 기다리며 checkout 밖 mode `0700` role-owned runtime directory와 unique handle/socket을 pre-create한다. Client는 raw token을 inspect/log하지 않으며 public `request` API가 token을 캡슐화한다. 같은 handle로 `acquire_repository`/`release_repository`를 요청하고 TTL 전에 `renew`한다. 정상·오류·cancel cleanup은 repository release(보유 시), Issue `release`, broker wait/reap, `probe` `free=true` 확인이며, 이 검증 전에는 completed/log/report를 주장하지 않는다. Raw `flock ... sleep`, lock-file unlink와 client-side lock holder는 금지한다.
## 사용 시점 ## 사용 시점
다음과 같이 exact repository와 Issue의 수정된 Discovery 검토를 요청받았을 때 사용한다. 다음과 같이 exact repository와 Issue의 수정된 Discovery 검토를 요청받았을 때 사용한다.
@@ -64,21 +69,21 @@ Gitea 인증과 readback은 [Gitea authenticated access 운영 지침](../../../
## 절차 ## 절차
1. 요청에서 exact `owner`, `repository`, Issue 번호를 추출하고 단일 안전 경로 요소인지 검증한다. `operations/repository-cache.md`를 새로 읽고 canonical cache 경로를 계산·검증한다. 1. 요청에서 exact `owner`, `repository`, Issue 번호를 추출하고 단일 안전 경로 요소인지 검증한다. `operations/repository-cache.md`를 새로 읽고 canonical cache 경로를 계산·검증한다.
2. Authenticated Gitea API adapter로 review role의 stable account identity를 확인한다. 대상 cache가 없으면 `gitea-repository-cache-bootstrap`을 완료하고 bootstrap·repository lock이 모두 해제됐는지 확인한다. 기존 cache는 자동 초기화하지 않는다. Cache와 lock 경로의 안전성을 검증한 뒤 canonical Issue lock을 획득한다. 2. Authenticated Gitea API adapter로 review role의 stable account identity를 확인한다. 대상 cache가 없으면 `gitea-repository-cache-bootstrap`을 완료하고 bootstrap·repository lock이 모두 해제됐는지 확인한다. 기존 cache는 자동 초기화하지 않는다. Cache와 lock 경로의 안전성을 검증한 뒤 broker `serve`의 ready 응답과 public `request` API로 canonical Issue lease를 확인한다.
3. exact Issue와 전체 comments를 읽어 body, `updated_at`, title, state, labels, assignees, milestone과 comment baseline을 immutable snapshot으로 보존한다. 3. exact Issue와 전체 comments를 읽어 body, `updated_at`, title, state, labels, assignees, milestone과 comment baseline을 immutable snapshot으로 보존한다.
4. 이 스킬의 입력 승인 gate로 current body와 최초 authoring의 원본 provenance comment를 검증한다. 실패하면 comment와 body를 쓰지 않는다. 4. 이 스킬의 입력 승인 gate로 current body와 최초 authoring의 원본 provenance comment를 검증한다. 실패하면 comment와 body를 쓰지 않는다.
5. Issue lock을 유지한 채 canonical 지침에 따라 기존 cache를 검증한다. 검증에 실패하면 자동 초기화하거나 복구하지 않고 중단한다. 5. Broker Issue lease를 유지한 채 canonical 지침에 따라 기존 cache를 검증한다. 검증에 실패하면 자동 초기화하거나 복구하지 않고 중단한다.
6. repository lock을 canonical 순서로 획득해 origin을 fetch하고 Gitea default-branch SHA와 local ref가 정확히 일치하는 revision을 pin한다. 6. Issue lease를 유지한 채 같은 broker handle로 `acquire_repository` request를 보내 origin을 fetch하고 Gitea default-branch SHA와 local ref가 정확히 일치하는 revision을 pin한다.
7. pinned SHA에서 current Discovery의 모든 저장소 근거, 다섯 후속 문서 경로, 요구사항 ID·파일명과 사용자 prose 수정의 사실성을 조사한다. 움직이는 branch나 다른 workspace를 근거로 사용하지 않는다. 7. pinned SHA에서 current Discovery의 모든 저장소 근거, 다섯 후속 문서 경로, 요구사항 ID·파일명과 사용자 prose 수정의 사실성을 조사한다. 움직이는 branch나 다른 workspace를 근거로 사용하지 않는다.
8. 범위 분류·blocking 판단과 기술적 결론의 경계를 먼저 적용하고 각 OQ의 선택 수를 판정한다. 선택된 결정이 특정 API·dependency·runtime·배포 기능에 의존하면 pinned 저장소와 적용 버전의 공식 문서·소스를 조사해 지원 여부와 구현 경로를 확인한다. 조사만으로 판단할 수 없고 실행 실험이 필요하면 기술적 전제 검증을 미완료로 남긴다. 선택된 결정을 관련 모든 섹션에 전파하고, 새 모순·누락으로 새 사용자 결정이 필요할 때만 OQ 검토 규칙에 따라 새 OQ를 추가한다. 8. 범위 분류·blocking 판단과 기술적 결론의 경계를 먼저 적용하고 각 OQ의 선택 수를 판정한다. 선택된 결정이 특정 API·dependency·runtime·배포 기능에 의존하면 pinned 저장소와 적용 버전의 공식 문서·소스를 조사해 지원 여부와 구현 경로를 확인한다. 조사만으로 판단할 수 없고 실행 실험이 필요하면 기술적 전제 검증을 미완료로 남긴다. 선택된 결정을 관련 모든 섹션에 전파하고, 새 모순·누락으로 새 사용자 결정이 필요할 때만 OQ 검토 규칙에 따라 새 OQ를 추가한다.
9. `문서 영향 및 산출물`을 README 정책에 따라 전부 다시 판정한다. 요구사항명세서의 exact ID·경로·제목·포함할 내용·제외할 내용과 다른 문서의 구체적 변경 계약을 현재 결정과 pinned SHA에 맞춘다. 9. `문서 영향 및 산출물`을 README 정책에 따라 전부 다시 판정한다. 요구사항명세서의 exact ID·경로·제목·포함할 내용·제외할 내용과 다른 문서의 구체적 변경 계약을 현재 결정과 pinned SHA에 맞춘다.
10. 조사 종료 시 Gitea default-branch SHA와 local ref가 pinned SHA와 여전히 일치하는지 확인하고 repository lock을 해제한다. Issue lock은 유지한다. 10. 조사 종료 시 Gitea default-branch SHA와 local ref가 pinned SHA와 여전히 일치하는지 확인하고 `release_repository` request를 보낸다. Issue lease는 유지한다.
11. trusted workflow directory에 revised body artifact를 만들고 marker, H2 순서, placeholder 부재, OQ ID·checkbox, 결정 전파, 문서 영향, repository evidence와 credential 부재를 검증한다. 11. trusted workflow directory에 revised body artifact를 만들고 marker, H2 순서, placeholder 부재, OQ ID·checkbox, 결정 전파, 문서 영향, repository evidence와 credential 부재를 검증한다.
12. revised body가 current body와 exact-match하면 comment와 body write 없이 no-op으로 종료한다. 12. revised body가 current body와 exact-match하면 comment와 body write 없이 no-op으로 종료한다.
13. write 직전에 Issue와 comments를 다시 읽는다. snapshot의 body·`updated_at` 또는 comment baseline이 drift했으면 외부 write 없이 중단한다. 13. write 직전에 Issue와 comments를 다시 읽는다. snapshot의 body·`updated_at` 또는 comment baseline이 drift했으면 외부 write 없이 중단한다.
14. snapshot의 body·`updated_at`·comment baseline과 비본문 필드가 불변일 때 revised body artifact의 exact bytes로 Issue body 하나만 교체한다. title, state, labels, assignees, milestone은 write payload에 포함하지 않는다. 14. snapshot의 body·`updated_at`·comment baseline과 비본문 필드가 불변일 때 revised body artifact의 exact bytes로 Issue body 하나만 교체한다. title, state, labels, assignees, milestone은 write payload에 포함하지 않는다.
15. Issue와 comments를 다시 읽어 body exact-match, 비본문 필드 불변성과 comment baseline 완전 불변을 검증한다. review는 어떤 comment도 추가·수정·삭제해서는 안 된다. 15. Issue와 comments를 다시 읽어 body exact-match, 비본문 필드 불변성과 comment baseline 완전 불변을 검증한다. review는 어떤 comment도 추가·수정·삭제해서는 안 된다.
16. 동일 pinned source revision과 readback한 현재 본문을 기준으로 내용 검토·정규화를 다시 적용해 결과가 현재 본문과 exact-match인지 확인한다. Replay에서는 외부 write를 수행하지 않으며, 불일치하면 replay 검증 실패로 보고한다. 확인 후 Issue lock을 해제한다. 16. 동일 pinned source revision과 readback한 현재 본문을 기준으로 내용 검토·정규화를 다시 적용해 결과가 현재 본문과 exact-match인지 확인한다. Replay에서는 외부 write를 수행하지 않으며, 불일치하면 replay 검증 실패로 보고한다. 확인 후 Issue lease에 `release` request를 보내고 broker wait/reap 및 `probe --resource issue`의 `free=true`를 확인한다.
## 금지 사항 ## 금지 사항
@@ -1,7 +1,7 @@
--- ---
name: gitea-implementation-spec-review name: gitea-implementation-spec-review
description: 승인된 계약에 대해 Gitea 구현 PR 적합성을 검토한다. description: 승인된 계약에 대해 Gitea 구현 PR 적합성을 검토한다.
version: 0.2.2 version: 0.2.3
author: genichin, Hermes Agent author: genichin, Hermes Agent
license: MIT license: MIT
platforms: [linux] platforms: [linux]
@@ -15,6 +15,11 @@ metadata:
`gitea-impl-agent`가 만든 Gitea PR을 승인된 Discovery 계약과 대조해 요구사항 충족, 범위 누락·초과, acceptance criteria와 test evidence의 대응을 판정한다. 검토 결과는 구현 branch의 Git commit이나 Issue comment가 아니라 검토한 immutable PR head commit에 바인딩된 native Gitea review로 기록한다. Gitea 인증과 readback은 [Gitea authenticated access 운영 지침](../../../../operations/gitea-access.md)을 정본으로 사용한다. 일반 코드 품질·보안 검토, source 수정, Discovery 변경과 merge는 수행하지 않는다. `gitea-impl-agent`가 만든 Gitea PR을 승인된 Discovery 계약과 대조해 요구사항 충족, 범위 누락·초과, acceptance criteria와 test evidence의 대응을 판정한다. 검토 결과는 구현 branch의 Git commit이나 Issue comment가 아니라 검토한 immutable PR head commit에 바인딩된 native Gitea review로 기록한다. Gitea 인증과 readback은 [Gitea authenticated access 운영 지침](../../../../operations/gitea-access.md)을 정본으로 사용한다. 일반 코드 품질·보안 검토, source 수정, Discovery 변경과 merge는 수행하지 않는다.
## Managed-lock broker gate
Cache와 Issue workflow의 lock은 canonical [Repository cache 운영 지침](../../../../operations/repository-cache.md)을 따른다. Bootstrap 이후에는 role-neutral broker 하나를 `serve`로 tracked background process로 시작하고 ready JSON을 기다린다. Checkout 밖 role-owned mode `0700` runtime directory와 unique handle/socket을 pre-create하며 client는 raw token을 inspect/log하지 않고 public `request` API가 이를 캡슐화한다. Nested repository lock은 같은 handle의 `acquire_repository`/`release_repository` request만 사용하고 TTL 전에 `renew`한다. 정상·no-op·검증 실패·API 오류·cancel을 포함한 모든 종료 경로는 `release_repository`(보유 시) → `release` → wait/reap → `probe`에서 `free=true` 확인을 거쳐야 하며, 그 전에는 completed/log/report를 주장하지 않는다. Raw `flock ... sleep`과 lock-file unlink는 금지한다.
## 사용 시점 ## 사용 시점
다음과 같이 exact repository, source Issue와 PR을 지정해 구현 적합성 검토를 요청받았을 때 사용한다. 다음과 같이 exact repository, source Issue와 PR을 지정해 구현 적합성 검토를 요청받았을 때 사용한다.
@@ -211,15 +216,15 @@ terminal(command="python3 harness/agents/spec/skills/gitea-implementation-spec-r
## 절차 ## 절차
1. Exact target identifiers를 검증하고 `operations/repository-cache.md`를 새로 읽는다. 1. Exact target identifiers를 검증하고 `operations/repository-cache.md`를 새로 읽는다.
2. Cache가 없으면 Issue lock 획득 전에 `gitea-repository-cache-bootstrap`으로 초기화한다. Bootstrap의 `bootstrap.lock → repository.lock`을 모두 역순으로 해제한 뒤 canonical path와 mode를 검증한 source Issue lock을 획득한다. 기존 cache의 path·ownership·permission이 invalid하면 자동 복구하지 않고 fail-closed한다. 2. Cache가 없으면 broker Issue lease를 확인하기 전에 `gitea-repository-cache-bootstrap`으로 초기화한다. Bootstrap의 `bootstrap.lock → repository.lock`을 모두 역순으로 해제한 뒤 canonical path와 mode를 검증하고 broker `serve`의 ready 응답 및 public `request` API로 source Issue lease를 확인한다. 기존 cache의 path·ownership·permission이 invalid하면 자동 복구하지 않고 fail-closed한다.
3. Canonical authenticated Gitea API adapter로 spec-agent stable account ID를 확인하고 PR author와 분리됐는지 검증한다. 3. Canonical authenticated Gitea API adapter로 spec-agent stable account ID를 확인하고 PR author와 분리됐는지 검증한다.
4. Exact repository metadata, default branch, source Issue 전체 필드·comments, PR details·head commit·status·changed files·diff와 전체 review baseline을 읽는다. Fork PR이면 exact head repository metadata와 head branch SHA도 읽는다. 4. Exact repository metadata, default branch, source Issue 전체 필드·comments, PR details·head commit·status·changed files·diff와 전체 review baseline을 읽는다. Fork PR이면 exact head repository metadata와 head branch SHA도 읽는다.
5. 입력 승인 gate를 적용하고 Issue contract snapshot, PR snapshot, `contract-sha256`, initial head SHA를 고정한다. 5. 입력 승인 gate를 적용하고 Issue contract snapshot, PR snapshot, `contract-sha256`, initial head SHA를 고정한다.
실행 helper는 directory 종류별 canonical permission 계약과 authenticated repository metadata의 exact `clone_url`을 사용하며 bare directory에 임의 `0700` 조건을 추가하거나 `.git`을 제거하지 않는다. Close binding은 행 단위로 파싱하고 whitespace가 다음 행까지 매칭되지 않게 한다. Commit metadata의 실제 nested 필드와 collection의 declared count를 검증하며 `null` collection은 해당 wire 계약과 count가 empty를 증명할 때만 빈 배열로 처리한다. Hunk는 immutable base/head blob의 위치·내용으로 대조한다. 검증된 helper가 있으면 재사용하고, 임의 wire shape·문자열 재구성·diff 직렬화 일치로 gate를 대체하지 않는다. 실행 helper는 directory 종류별 canonical permission 계약과 authenticated repository metadata의 exact `clone_url`을 사용하며 bare directory에 임의 `0700` 조건을 추가하거나 `.git`을 제거하지 않는다. Close binding은 행 단위로 파싱하고 whitespace가 다음 행까지 매칭되지 않게 한다. Commit metadata의 실제 nested 필드와 collection의 declared count를 검증하며 `null` collection은 해당 wire 계약과 count가 empty를 증명할 때만 빈 배열로 처리한다. Hunk는 immutable base/head blob의 위치·내용으로 대조한다. 검증된 helper가 있으면 재사용하고, 임의 wire shape·문자열 재구성·diff 직렬화 일치로 gate를 대체하지 않는다.
6. 검증된 기존 cache에 대해 `issue lock → repository lock` 순서로 repository lock을 획득한다. Issue lock 획득 후 cache가 없어졌거나 invalid해졌으면 bootstrap 또는 자동 복구하지 않고 중단한다. 6. 검증된 기존 cache에 대해 Issue lease를 유지한 채 같은 broker handle로 `acquire_repository` request를 보낸다. Issue lease 확인 후 cache가 없어졌거나 invalid해졌으면 bootstrap 또는 자동 복구하지 않고 중단한다.
7. Cache path·ownership·permission·bare·remote를 검증하고 canonical base repository origin에서 prune/fetch한다. Gitea default branch SHA와 local ref를 일치시키고 PR base commit object가 cache에 존재하는지 검증한다. 7. Cache path·ownership·permission·bare·remote를 검증하고 canonical base repository origin에서 prune/fetch한다. Gitea default branch SHA와 local ref를 일치시키고 PR base commit object가 cache에 존재하는지 검증한다.
8. Same-repository PR이면 head object도 cache에서 검증하고 exact base-to-head tree를 Gitea changed-file set·diff와 대조한다. Fork PR이면 target cache에 head object가 있다고 가정하지 않고 authoritative head repository·branch SHA와 Gitea changed-file set·diff를 대조한다. Diff 직렬화의 byte 차이만으로 실패시키지 않고 변경 경로와 exact base/head blob 및 hunk가 동일 변경을 나타내는지 검증한다. 임의 ref, checkout, clone 또는 worktree를 만들지 않는다. 8. Same-repository PR이면 head object도 cache에서 검증하고 exact base-to-head tree를 Gitea changed-file set·diff와 대조한다. Fork PR이면 target cache에 head object가 있다고 가정하지 않고 authoritative head repository·branch SHA와 Gitea changed-file set·diff를 대조한다. Diff 직렬화의 byte 차이만으로 실패시키지 않고 변경 경로와 exact base/head blob 및 hunk가 동일 변경을 나타내는지 검증한다. 임의 ref, checkout, clone 또는 worktree를 만들지 않는다.
9. Repository lock을 해제하되 Issue lock은 유지한다. 9. `release_repository` request를 보내되 Issue lease는 유지한다.
10. Acceptance matrix 작성 전에 구현자 실행 보고와 상세 실행 기록의 출처 및 확인 범위를 구분한다. Revision이 식별된 기존 evidence는 source revision과 reviewed head 사이 전체 diff·실행 입력 변경을 확인하고, 필수 검증별 재사용 가능 범위와 현재 head에 새로 결합해야 할 근거를 먼저 요약한다. 상세 기록이나 revision 정보의 부재만으로 새 검증 gate를 만들지 않는다. 재사용 여부는 위 Acceptance matrix의 기존 evidence binding 기준으로 판단하며 새 gate나 일괄 재실행 요구를 추가하지 않는다. 재검토에서는 이전 finding별 승인 계약 근거와 최소 보완 요청을 새 구현·evidence의 확인 위치에 대응하고, 해소 또는 잔여 사유를 기존 criterion의 `evidence`와 `reason`에 기록한다. 이미 충족된 검증을 source revision 차이만으로 다시 요구하지 않되, 변경 영향으로 새로 판정할 수 없어진 필수 조건은 그 근거와 최소 보완 범위를 특정한다. 이 초기 요약만으로 검토를 끝내지 않고 Approved Discovery에서 acceptance matrix를 만들고 모든 changed file, 문서, source, test와 exact-head-bound status evidence를 대응시킨다. 10. Acceptance matrix 작성 전에 구현자 실행 보고와 상세 실행 기록의 출처 및 확인 범위를 구분한다. Revision이 식별된 기존 evidence는 source revision과 reviewed head 사이 전체 diff·실행 입력 변경을 확인하고, 필수 검증별 재사용 가능 범위와 현재 head에 새로 결합해야 할 근거를 먼저 요약한다. 상세 기록이나 revision 정보의 부재만으로 새 검증 gate를 만들지 않는다. 재사용 여부는 위 Acceptance matrix의 기존 evidence binding 기준으로 판단하며 새 gate나 일괄 재실행 요구를 추가하지 않는다. 재검토에서는 이전 finding별 승인 계약 근거와 최소 보완 요청을 새 구현·evidence의 확인 위치에 대응하고, 해소 또는 잔여 사유를 기존 criterion의 `evidence`와 `reason`에 기록한다. 이미 충족된 검증을 source revision 차이만으로 다시 요구하지 않되, 변경 영향으로 새로 판정할 수 없어진 필수 조건은 그 근거와 최소 보완 범위를 특정한다. 이 초기 요약만으로 검토를 끝내지 않고 Approved Discovery에서 acceptance matrix를 만들고 모든 changed file, 문서, source, test와 exact-head-bound status evidence를 대응시킨다.
11. 문서 검토 입력과 구조·의미 분리 절차로 trusted 계약·산출물별 의무표를 준비하고 모든 문서 의무를 대조한다. 위임 시 해당 입력 전체를 제공한다. 독립 code-review worker 결과가 있으면 exact head에 바인딩됐고 authoritative하게 읽을 수 있을 때만 보조 evidence로 사용한다. 그 결과를 spec verdict로 그대로 복사하지 않는다. 11. 문서 검토 입력과 구조·의미 분리 절차로 trusted 계약·산출물별 의무표를 준비하고 모든 문서 의무를 대조한다. 위임 시 해당 입력 전체를 제공한다. 독립 code-review worker 결과가 있으면 exact head에 바인딩됐고 authoritative하게 읽을 수 있을 때만 보조 evidence로 사용한다. 그 결과를 spec verdict로 그대로 복사하지 않는다.
12. Verdict 규칙으로 하나의 strict result artifact와 canonical review body를 trusted workflow directory에 생성한다. 12. Verdict 규칙으로 하나의 strict result artifact와 canonical review body를 trusted workflow directory에 생성한다.
@@ -236,9 +241,9 @@ terminal(command="python3 harness/agents/spec/skills/gitea-implementation-spec-r
기존 review 대체 projection은 write 전에 authoritative provider 계약으로 대체 조건·대상·필드를 확인해 기록한 경우에만 허용한다. 동일 PR·동일 stable author의 baseline상 `official=true`, `dismissed=false`인 이전 review에서 `official: true→false`, `dismissed: false→true`, 해당 native create/submit event 시각으로의 `updated_at` 변경만 허용하며, 영향받은 모든 ID를 읽어 기존 ID·body·commit·state·author·stale와 나머지 필드 및 record 보존을 검증한다. 신규 review/event cardinality와 해당 write의 인과도 별도로 확인한다. Timestamp 일치나 과거 관측만으로 대체를 추정하지 않으며 다른 작성자·다른 필드 변경, 계약 미확인 또는 인과 불명은 `partial`/fail-closed로 기록하고 추가 write를 중단한다. 기존 flag를 직접 복원·dismiss하거나 과거 실행 결과를 소급 성공으로 바꾸지 않는다. 기존 review 대체 projection은 write 전에 authoritative provider 계약으로 대체 조건·대상·필드를 확인해 기록한 경우에만 허용한다. 동일 PR·동일 stable author의 baseline상 `official=true`, `dismissed=false`인 이전 review에서 `official: true→false`, `dismissed: false→true`, 해당 native create/submit event 시각으로의 `updated_at` 변경만 허용하며, 영향받은 모든 ID를 읽어 기존 ID·body·commit·state·author·stale와 나머지 필드 및 record 보존을 검증한다. 신규 review/event cardinality와 해당 write의 인과도 별도로 확인한다. Timestamp 일치나 과거 관측만으로 대체를 추정하지 않으며 다른 작성자·다른 필드 변경, 계약 미확인 또는 인과 불명은 `partial`/fail-closed로 기록하고 추가 write를 중단한다. 기존 flag를 직접 복원·dismiss하거나 과거 실행 결과를 소급 성공으로 바꾸지 않는다.
별도로, 동일 PR·동일 stable author의 baseline상 이미 `dismissed=true`인 이전 approve/reject review는 서버 버전에 결합된 authoritative provider 계약으로 bulk-update 대상과 timestamp 동작을 write 전에 확인한 경우에만 해당 native create/submit event에 인과가 증명된 `updated_at` 갱신만 허용한다. 기존 `official`·`dismissed` 값과 ID·body·commit·state·author·stale 및 모든 나머지 필드·record는 그대로 보존하고 영향받은 모든 ID와 신규 review/event cardinality를 readback한다. Timestamp 일치만으로 인과를 추정하지 않으며 계약·인과가 미확인되면 write 전 차단하거나 write 후 `partial`/fail-closed로 중단한다. 별도로, 동일 PR·동일 stable author의 baseline상 이미 `dismissed=true`인 이전 approve/reject review는 서버 버전에 결합된 authoritative provider 계약으로 bulk-update 대상과 timestamp 동작을 write 전에 확인한 경우에만 해당 native create/submit event에 인과가 증명된 `updated_at` 갱신만 허용한다. 기존 `official`·`dismissed` 값과 ID·body·commit·state·author·stale 및 모든 나머지 필드·record는 그대로 보존하고 영향받은 모든 ID와 신규 review/event cardinality를 readback한다. Timestamp 일치만으로 인과를 추정하지 않으며 계약·인과가 미확인되면 write 전 차단하거나 write 후 `partial`/fail-closed로 중단한다.
21. 동일 state를 read-only replay해 stable deduplication identity의 canonical review를 재사용하며 `writes=[]`인지 확인한다. 21. 동일 state를 read-only replay해 stable deduplication identity의 canonical review를 재사용하며 `writes=[]`인지 확인한다.
22. Issue lock을 해제한 뒤 `repository.lock`과 `issue-<number>.lock`을 각각 non-blocking으로 획득·해제할 수 있는지 확인한다. 22. Issue lease에 대해 `release_repository`(보유 시) → `release` request를 보내고 tracked broker process를 wait/reap한 뒤 `probe --resource issue` 응답의 `free=true`를 확인한다. Raw lock 파일이나 token을 직접 재획득·검사하지 않는다.
모든 종료 경로(정상·no-op·검증 실패·API 오류·예외)는 획득한 lock을 `finally`에서 역순 해제하고 non-blocking 재획득·해제를 확인한다. Write 후 응답과 authoritative readback snapshot은 판정 assertion 전에 credential 없이 먼저 보존하고, 검증 실패 사유는 별도로 기록한다. 불확실한 POST는 절차 19만 적용하고 후속 write를 중단한다. 종료 시 final report를 authoritative 결과로 확정하며 native review 제출 상태와 절차 전체 상태를 분리한다. Progress artifact를 사용했다면 최종 상태·review ID·잔여 오류를 갱신하거나 stage·timestamp·final report 참조를 갖춘 중간 snapshot임을 명시한다. Cleanup 실패도 final report에 남기고 완료로 보고하지 않는다. 모든 종료 경로(정상·no-op·검증 실패·API 오류·예외·cancel)는 canonical broker cleanup을 `finally`에서 수행한다: 보유한 `release_repository`, `release`, tracked broker wait/reap, `probe`의 `free=true` readback 순서를 확인한다. Write 후 응답과 authoritative readback snapshot은 판정 assertion 전에 credential 없이 먼저 보존하고, 검증 실패 사유는 별도로 기록한다. 불확실한 POST는 절차 19만 적용하고 후속 write를 중단한다. 종료 시 final report를 authoritative 결과로 확정하며 native review 제출 상태와 절차 전체 상태를 분리한다. Progress artifact를 사용했다면 최종 상태·review ID·잔여 오류를 갱신하거나 stage·timestamp·final report 참조를 갖춘 중간 snapshot임을 명시한다. Cleanup 실패도 final report에 남기고 완료로 보고하지 않는다.
## 문서 검토 입력과 구조·의미 분리 ## 문서 검토 입력과 구조·의미 분리
+21
View File
@@ -0,0 +1,21 @@
from .managed_lock import (
LockError,
LockOrderError,
LockTimeoutError,
LockValidationError,
ManagedLock,
ManagedLockSet,
probe_free,
validate_held_flock,
)
__all__ = [
"LockError",
"LockOrderError",
"LockTimeoutError",
"LockValidationError",
"ManagedLock",
"ManagedLockSet",
"probe_free",
"validate_held_flock",
]
+283
View File
@@ -0,0 +1,283 @@
"""Small, strict, process-owned advisory locks for harness coordination."""
from __future__ import annotations
import errno
import fcntl
import json
import os
import stat
import time
from contextlib import contextmanager
from pathlib import Path
from typing import Callable
class LockError(RuntimeError):
"""Base class for managed lock failures."""
class LockTimeoutError(LockError, TimeoutError):
"""The lock could not be acquired before the configured deadline."""
class LockValidationError(LockError):
"""The persistent lock path or held lock failed a safety check."""
class LockOrderError(LockError):
"""A lock operation would violate the canonical acquisition order."""
def _canonical(path: os.PathLike[str] | str) -> Path:
raw_value = os.fspath(path)
raw = Path(raw_value)
if any(part in {".", ".."} for part in str(raw_value).split("/")):
raise LockValidationError(f"lock path must be absolute and traversal-free: {raw_value}")
supplied = Path(os.path.abspath(os.fspath(path)))
if not supplied.is_absolute():
raise LockValidationError(f"lock path must be absolute and traversal-free: {supplied}")
# Resolve ancestors through directory FDs. Path.is_symlink() followed by
# a later open is a check-then-use race.
try:
parent_fd, name = _open_parent(supplied, create_parent=False)
except FileNotFoundError:
return supplied
except OSError as exc:
raise LockValidationError(f"cannot inspect lock path {supplied}") from exc
try:
try:
st = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
if stat.S_ISLNK(st.st_mode):
raise LockValidationError(f"lock path must not traverse a symlink: {supplied}")
except FileNotFoundError:
pass
finally:
os.close(parent_fd)
return supplied
def _open_parent(path: Path, *, create_parent: bool) -> tuple[int, str]:
"""Open *path*'s parent one component at a time, without symlinks."""
parts = path.parts
fd = os.open(parts[0], os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC)
try:
for part in parts[1:-1]:
try:
nxt = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC,
dir_fd=fd)
except FileNotFoundError:
if not create_parent:
raise
os.mkdir(part, 0o700, dir_fd=fd)
nxt = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC,
dir_fd=fd)
os.close(fd)
fd = nxt
return fd, parts[-1]
except BaseException:
os.close(fd)
raise
def _validate_file(fd: int, path: Path) -> None:
try:
fd_stat = os.fstat(fd)
except OSError as exc:
raise LockValidationError(f"cannot inspect lock file {path}: {exc}") from exc
if not stat.S_ISREG(fd_stat.st_mode):
raise LockValidationError(f"lock is not a regular file: {path}")
if fd_stat.st_uid != os.getuid():
raise LockValidationError(f"lock is not owned by the current uid: {path}")
if stat.S_IMODE(fd_stat.st_mode) != 0o600:
raise LockValidationError(f"lock mode must be exactly 0600: {path}")
if fd_stat.st_nlink != 1:
raise LockValidationError(f"lock must have exactly one link: {path}")
def _open_lock(path: Path) -> int:
flags = os.O_RDWR | os.O_NOFOLLOW | os.O_CLOEXEC
parent_fd, name = _open_parent(path, create_parent=True)
try:
fd = os.open(name, flags | os.O_CREAT, 0o600, dir_fd=parent_fd)
except OSError as exc:
if exc.errno in (errno.ELOOP, errno.ENOTDIR):
raise LockValidationError(f"lock path is not a safe regular file: {path}") from exc
raise
try:
_validate_file(fd, path)
return fd
except BaseException:
os.close(fd)
raise
finally:
os.close(parent_fd)
def validate_held_flock(fd: int, path: os.PathLike[str] | str) -> bool:
"""Verify *fd* has this process's exclusive whole-file kernel FLOCK."""
canonical = _canonical(path)
_validate_file(fd, canonical)
try:
with open(f"/proc/self/fdinfo/{fd}", encoding="ascii") as info:
records = info.read().splitlines()
except (OSError, ValueError) as exc:
raise LockValidationError(f"cannot inspect fdinfo for fd {fd}") from exc
st = os.fstat(fd)
device = f"{os.major(st.st_dev):02x}:{os.minor(st.st_dev):02x}:{st.st_ino}"
for line in records:
fields = line.split()
if len(fields) == 9 and fields[0].startswith("lock:"):
if (fields[2:7] == ["FLOCK", "ADVISORY", "WRITE", str(os.getpid()), device]
and fields[7:] == ["0", "EOF"]):
return True
return False
class ManagedLock:
"""A persistent lock file whose exclusive FLOCK lasts until release."""
def __init__(self, path: os.PathLike[str] | str, *, timeout: float = 0.0,
poll_interval: float = 0.01) -> None:
if timeout < 0 or poll_interval <= 0:
raise ValueError("timeout must be non-negative and poll_interval positive")
self.path = _canonical(path)
self.timeout = timeout
self.poll_interval = poll_interval
self.fd: int | None = None
@property
def held(self) -> bool:
return self.fd is not None
def acquire(self) -> "ManagedLock":
if self.fd is not None:
raise LockError("lock is already acquired")
fd = _open_lock(self.path)
deadline = time.monotonic() + self.timeout
try:
while True:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
if not validate_held_flock(fd, self.path):
raise LockValidationError("kernel FLOCK was not recorded as expected")
self.fd = fd
return self
except BlockingIOError:
if time.monotonic() >= deadline:
raise LockTimeoutError(f"timed out acquiring {self.path}")
time.sleep(min(self.poll_interval, max(0.0, deadline - time.monotonic())))
except BaseException:
os.close(fd)
raise
def release(self) -> None:
if self.fd is None:
return
fd, self.fd = self.fd, None
try:
fcntl.flock(fd, fcntl.LOCK_UN)
finally:
os.close(fd)
def write_metadata(self, metadata: object) -> None:
if self.fd is None or not validate_held_flock(self.fd, self.path):
raise RuntimeError("metadata can only be written while this lock is held")
payload = (json.dumps(metadata, sort_keys=True, separators=(",", ":")) + "\n").encode()
os.ftruncate(self.fd, 0)
os.lseek(self.fd, 0, os.SEEK_SET)
written = 0
while written < len(payload):
written += os.write(self.fd, payload[written:])
os.fsync(self.fd)
def __enter__(self) -> "ManagedLock":
return self.acquire()
def __exit__(self, exc_type, exc_value, traceback) -> None:
self.release()
def probe_free(path: os.PathLike[str] | str, *, poll_interval: float = 0.01) -> bool:
"""Probe a lock without removing or repairing its persistent file."""
try:
lock = ManagedLock(path, timeout=0.0, poll_interval=poll_interval).acquire()
except LockTimeoutError:
return False
lock.release()
return True
class ManagedLockSet:
"""Manage an Issue lock with a short-lived nested repository scope."""
@staticmethod
def _component(value: object, name: str) -> str:
if not isinstance(value, str) or not value or value in {".", ".."} or "\x00" in value:
raise ValueError(f"{name} must be one safe path component")
if "/" in value or "\\" in value:
raise ValueError(f"{name} must be one safe path component")
return value
@classmethod
def paths_for_issue(cls, harness_root: os.PathLike[str] | str,
owner: str, repository: str, issue: int) -> tuple[Path, Path]:
root = Path(os.fspath(harness_root))
if not root.is_absolute() or "." in root.parts or ".." in root.parts:
raise ValueError("harness_root must be an absolute, traversal-free path")
owner = cls._component(owner, "owner")
repository = cls._component(repository, "repository")
if isinstance(issue, bool) or not isinstance(issue, int) or issue <= 0:
raise ValueError("issue must be a positive integer")
locks = root / "repositories" / owner / repository / "locks"
return locks / f"issue-{issue}.lock", locks / "repository.lock"
@classmethod
def for_issue(cls, harness_root: os.PathLike[str] | str,
owner: str, repository: str, issue: int, **kwargs) -> "ManagedLockSet":
issue_path, repository_path = cls.paths_for_issue(harness_root, owner, repository, issue)
return cls(issue_path, repository_path, **kwargs)
def __init__(self, issue: os.PathLike[str] | str, repository: os.PathLike[str] | str,
*, timeout: float = 0.0, poll_interval: float = 0.01,
event_sink: Callable[[str], None] | None = None) -> None:
self.issue = ManagedLock(issue, timeout=timeout, poll_interval=poll_interval)
self.repository = ManagedLock(repository, timeout=timeout, poll_interval=poll_interval)
self._event_sink = event_sink or (lambda event: None)
def __enter__(self) -> "ManagedLockSet":
try:
self.issue.acquire()
self._event_sink("acquire:issue")
return self
except BaseException:
if self.issue.held:
self.issue.release()
self._event_sink("release:issue")
raise
def __exit__(self, exc_type, exc_value, traceback) -> None:
if self.repository.held:
self.repository.release()
self._event_sink("release:repository")
self.release_issue()
@contextmanager
def repository_scope(self):
if not self.issue.held:
raise LockOrderError("repository lock requires the Issue lock")
self.repository.acquire()
try:
self._event_sink("acquire:repository")
yield self
finally:
if self.repository.held:
self.repository.release()
self._event_sink("release:repository")
def release_issue(self) -> None:
if self.repository.held:
raise LockOrderError("release the repository lock before the Issue lock")
if self.issue.held:
self.issue.release()
self._event_sink("release:issue")
+431
View File
@@ -0,0 +1,431 @@
"""Cross-turn broker for ManagedLockSet issue/repository FLOCKs."""
from __future__ import annotations
import argparse
import errno
import json
import os
import secrets
import signal
import socket
import stat
import sys
import time
from pathlib import Path
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from harness.lib.managed_lock import LockTimeoutError, ManagedLockSet, probe_free
ROLES = {"gitea-impl-agent", "gitea-spec-agent", "gitea-review-agent"}
MAX_TTL = 3600
def _abs(value: str, name: str) -> Path:
p = Path(value)
if not p.is_absolute() or "\x00" in value:
raise ValueError(f"{name} must be absolute")
return p
def _safe_dir(path: Path, name: str) -> None:
try:
fd = _open_directory(path)
except OSError as exc:
raise ValueError(f"unsafe {name} parent") from exc
try:
st = os.fstat(fd)
if (not stat.S_ISDIR(st.st_mode) or st.st_uid != os.getuid()
or stat.S_IMODE(st.st_mode) != 0o700):
raise ValueError(f"unsafe {name} parent")
finally:
os.close(fd)
def _open_directory(path: Path) -> int:
parts = path.parts
fd = os.open(parts[0], os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC)
try:
for part in parts[1:]:
nxt = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC,
dir_fd=fd)
os.close(fd)
fd = nxt
return fd
except BaseException:
os.close(fd)
raise
def _open_parent(path: Path) -> tuple[int, str]:
return _open_directory(path.parent), path.name
def _validate_handle_fd(fd: int, path: Path) -> os.stat_result:
fd_st = os.fstat(fd)
if (not stat.S_ISREG(fd_st.st_mode)
or fd_st.st_uid != os.getuid()
or stat.S_IMODE(fd_st.st_mode) != 0o600
or fd_st.st_nlink != 1):
raise ValueError("unsafe handle file")
return fd_st
def _open_handle(path: Path, flags: int) -> int:
parent_fd, name = _open_parent(path)
try:
fd = os.open(name, flags | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=parent_fd)
except OSError as exc:
if exc.errno in (errno.ELOOP, errno.ENOTDIR):
raise ValueError("unsafe handle file") from exc
raise
try:
_validate_handle_fd(fd, path)
return fd
except BaseException:
os.close(fd)
raise
finally:
os.close(parent_fd)
def _safe_existing_regular(path: Path) -> None:
fd = _open_handle(path, os.O_RDONLY)
os.close(fd)
def _write_all(fd: int, data: bytes) -> None:
written = 0
while written < len(data):
written += os.write(fd, data[written:])
def _write_handle(path: Path, state: dict, *, exclusive: bool = False, on_created=None) -> None:
flags = os.O_WRONLY | os.O_CREAT | (os.O_EXCL if exclusive else 0)
if not exclusive:
fd = _open_handle(path, flags)
parent_fd = None
else:
parent_fd, name = _open_parent(path)
fd = None
try:
fd = os.open(name, flags | os.O_NOFOLLOW | os.O_CLOEXEC, 0o600, dir_fd=parent_fd)
created = os.fstat(fd)
if on_created is not None:
on_created((created.st_dev, created.st_ino))
except BaseException:
if fd is not None:
os.close(fd)
raise
finally:
os.close(parent_fd)
try:
if exclusive:
os.fchmod(fd, 0o600)
_validate_handle_fd(fd, path)
os.lseek(fd, 0, os.SEEK_SET)
os.ftruncate(fd, 0)
_write_all(fd, (json.dumps(state, sort_keys=True, separators=(",", ":")) + "\n").encode())
os.fsync(fd)
finally:
os.close(fd)
def _update_handle(path: Path, state: dict) -> None:
fd = _open_handle(path, os.O_WRONLY)
try:
os.lseek(fd, 0, os.SEEK_SET)
os.ftruncate(fd, 0)
_write_all(fd, (json.dumps(state, sort_keys=True, separators=(",", ":")) + "\n").encode())
os.fsync(fd)
finally:
os.close(fd)
def _read_handle(path: Path) -> dict:
fd = _open_handle(path, os.O_RDONLY)
try:
chunks = []
while True:
chunk = os.read(fd, 65536)
if not chunk:
break
chunks.append(chunk)
data = json.loads(b"".join(chunks).decode())
finally:
os.close(fd)
if not isinstance(data, dict) or not isinstance(data.get("token"), str) or not isinstance(data.get("socket"), str):
raise ValueError("invalid handle")
return data
def read_handle_metadata(path: os.PathLike[str] | str) -> dict:
"""Return non-secret, authenticated-handle identity metadata only."""
data = _read_handle(_abs(str(path), "handle"))
return {key: value for key, value in data.items() if key != "token"}
class Broker:
"""Broker locks are trusted by OS UID; random token authenticates the holder.
Same-UID role strings are metadata, not authorization: the same OS UID is the
security boundary and an unpredictable token rejects non-holder requests.
"""
def __init__(self, args: argparse.Namespace):
if args.role not in ROLES:
raise ValueError("invalid role")
if not isinstance(args.run_id, str) or not args.run_id or "\x00" in args.run_id or "/" in args.run_id or "\\" in args.run_id:
raise ValueError("invalid run_id")
if not isinstance(args.issue, int) or args.issue <= 0:
raise ValueError("issue must be positive")
if not 1 <= args.ttl_seconds <= MAX_TTL:
raise ValueError("ttl_seconds out of bounds")
self.root = _abs(args.root, "root")
self.handle = _abs(args.handle, "handle")
self.sock_path = _abs(args.socket, "socket")
locks = self.root / "repositories" / args.owner / args.repository / "locks"
_safe_dir(locks, "repository locks")
_safe_dir(self.handle.parent, "handle")
_safe_dir(self.sock_path.parent, "socket")
self.token = secrets.token_hex(32)
self.deadline = time.monotonic() + args.ttl_seconds
self.set = ManagedLockSet.for_issue(self.root, args.owner, args.repository, args.issue, timeout=args.acquire_timeout)
self.meta = {"owner": args.owner, "repository": args.repository, "issue": args.issue, "role": args.role, "run_id": args.run_id, "pid": os.getpid(), "deadline": self.deadline}
self.state = {**self.meta, "socket": str(self.sock_path), "status": "held", "token": self.token}
self.server: socket.socket | None = None
self.socket_identity: tuple[int, int] | None = None
self.socket_parent_fd: int | None = None
self.stopping = False
self.handle_created = False
self.handle_identity: tuple[int, int] | None = None
self.started = False
def _record_handle_identity(self, identity: tuple[int, int]) -> None:
self.handle_identity = identity
self.handle_created = True
def persist(self, status: str, *, retain_token: bool = True) -> None:
self.state["status"] = status
self.state["deadline"] = self.deadline
persisted = dict(self.state)
if not retain_token:
persisted.pop("token", None)
_update_handle(self.handle, persisted)
def cleanup(self, status: str) -> bool:
released = False
try:
if self.set.repository.held:
self.set.repository.release()
self.set.release_issue()
released = True
except Exception:
released = False
if released and self.handle_created:
try:
self.persist(status, retain_token=False)
except Exception:
released = False
if self.server:
self.server.close()
self.server = None
if self.socket_identity is not None:
try:
if self.socket_parent_fd is not None:
st = os.stat(self.sock_path.name, dir_fd=self.socket_parent_fd, follow_symlinks=False)
if (st.st_dev, st.st_ino) == self.socket_identity:
os.unlink(self.sock_path.name, dir_fd=self.socket_parent_fd)
except (FileNotFoundError, OSError):
pass
if self.handle_created and not self.started:
try:
parent_fd, name = _open_parent(self.handle)
try:
st = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
if self.handle_identity == (st.st_dev, st.st_ino):
try:
self.persist(status, retain_token=False)
except Exception:
os.unlink(name, dir_fd=parent_fd)
finally:
os.close(parent_fd)
except OSError:
pass
if self.socket_parent_fd is not None:
try:
os.close(self.socket_parent_fd)
except OSError:
pass
self.socket_parent_fd = None
return released
def authenticate(self, req: dict) -> bool:
supplied = req.get("token")
return isinstance(supplied, str) and secrets.compare_digest(supplied, self.token)
def handle_request(self, req: dict) -> dict:
if not self.authenticate(req):
return {"error": "unauthorized"}
if time.monotonic() >= self.deadline:
self.cleanup("expired")
self.stopping = True
return {"error": "expired"}
action = req.get("action")
if action == "status":
return {"status": self.state["status"], "deadline": self.deadline,
"repository_held": self.set.repository.held,
"owner": self.state["owner"], "repository": self.state["repository"],
"issue": self.state["issue"], "role": self.state["role"],
"pid": self.state["pid"]}
if action == "renew":
ttl = req.get("ttl_seconds")
if isinstance(ttl, bool) or not isinstance(ttl, (int, float)) or not 1 <= ttl <= MAX_TTL:
return {"error": "invalid ttl_seconds"}
self.deadline = time.monotonic() + ttl
self.persist("repository-held" if self.set.repository.held else "held")
return {"status": self.state["status"], "deadline": self.deadline}
if action == "acquire_repository":
try:
self.set.repository.acquire()
except LockTimeoutError:
return {"error": "repository-busy"}
self.persist("repository-held")
return {"status": "repository-held"}
if action == "release_repository":
if self.set.repository.held:
self.set.repository.release()
self.persist("held")
return {"status": "held"}
if action == "release":
if not self.cleanup("released"):
return {"error": "release-failed"}
self.stopping = True
return {"status": "released"}
return {"error": "invalid action"}
def run(self) -> None:
try:
self.set.issue.acquire()
signal.signal(signal.SIGTERM, lambda *_: setattr(self, "stopping", True))
signal.signal(signal.SIGINT, lambda *_: setattr(self, "stopping", True))
self.set.issue.write_metadata(self.meta)
try:
_write_handle(self.handle, self.state, exclusive=True,
on_created=self._record_handle_identity)
except BaseException:
raise
st = self.handle.lstat()
self.handle_identity = (st.st_dev, st.st_ino)
self.handle_created = True
self.socket_parent_fd, socket_name = _open_parent(self.sock_path)
try:
os.stat(socket_name, dir_fd=self.socket_parent_fd, follow_symlinks=False)
raise ValueError("socket path already exists")
except FileNotFoundError:
pass
self.server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.server.bind(f"/proc/self/fd/{self.socket_parent_fd}/{socket_name}")
sock_st = os.fstat(self.server.fileno())
self.socket_identity = (sock_st.st_dev, sock_st.st_ino)
os.fchmod(self.server.fileno(), 0o600)
self.server.listen(8); self.server.settimeout(0.2)
self.started = True
print(json.dumps({"status": "held", "handle": str(self.handle), "socket": str(self.sock_path)}, separators=(",", ":")), flush=True)
while not self.stopping:
if time.monotonic() >= self.deadline:
self.stopping = True
self.cleanup("expired"); break
try:
conn, _ = self.server.accept()
except socket.timeout:
continue
with conn:
conn.settimeout(1)
line = conn.makefile("rb").readline(65536)
try:
req = json.loads(line.decode())
response = self.handle_request(req) if isinstance(req, dict) else {"error": "invalid request"}
except Exception:
response = {"error": "invalid request"}
conn.sendall((json.dumps(response, separators=(",", ":")) + "\n").encode())
finally:
if self.set.issue.held or self.handle_created or self.server:
self.cleanup("released" if not self.stopping else "expired")
def serve(args: argparse.Namespace) -> int:
broker = Broker(args)
broker.run()
return 0
def request_handle(handle_path: str | Path, action: str, ttl: float | None = None) -> dict:
"""Send an authenticated request using a validated handle, without printing."""
handle = _abs(str(handle_path), "handle")
data = _read_handle(handle)
sock_path = _abs(data["socket"], "socket")
req = {"action": action, "token": data["token"]}
if ttl is not None:
req["ttl_seconds"] = ttl
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.settimeout(3); sock.connect(str(sock_path)); sock.sendall((json.dumps(req) + "\n").encode())
with sock.makefile("rb") as response_file:
response = json.loads(response_file.readline())
if not isinstance(response, dict):
raise ValueError("invalid broker response")
return response
def request(args: argparse.Namespace) -> int:
response = request_handle(args.handle, args.action, args.ttl_seconds)
print(json.dumps(response, separators=(",", ":")))
return 0 if "error" not in response else 1
def probe(args: argparse.Namespace) -> int:
root = _abs(args.root, "root")
issue_path, repository_path = ManagedLockSet.paths_for_issue(
root, args.owner, args.repository, args.issue)
_safe_dir(issue_path.parent, "repository locks")
issue_free = probe_free(issue_path)
repository_free = probe_free(repository_path)
if args.resource == "issue":
free = issue_free
elif args.resource == "repository":
free = repository_free
else:
free = issue_free and repository_free
print(json.dumps({"free": free, "issue_free": issue_free,
"repository_free": repository_free}, separators=(",", ":")))
return 0 if free else 1
def parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser()
sub = p.add_subparsers(dest="command", required=True)
s = sub.add_parser("serve")
s.add_argument("--root", required=True); s.add_argument("--owner", required=True); s.add_argument("--repository", required=True); s.add_argument("--issue", required=True, type=int); s.add_argument("--role", required=True); s.add_argument("--run-id", required=True); s.add_argument("--handle", required=True); s.add_argument("--socket", required=True); s.add_argument("--ttl-seconds", required=True, type=float); s.add_argument("--acquire-timeout", type=float, default=0.0)
r = sub.add_parser("request")
r.add_argument("--handle", required=True); r.add_argument("--action", required=True, choices=["status", "renew", "acquire_repository", "release_repository", "release"]); r.add_argument("--ttl-seconds", type=float)
check = sub.add_parser("probe")
check.add_argument("--root", required=True); check.add_argument("--owner", required=True); check.add_argument("--repository", required=True); check.add_argument("--issue", required=True, type=int)
check.add_argument("--resource", choices=["issue", "repository", "all"], default="all")
return p
def main() -> int:
try:
args = parser().parse_args()
if args.command == "serve":
return serve(args)
if args.command == "request":
return request(args)
return probe(args)
except (ValueError, OSError, LockTimeoutError, json.JSONDecodeError, KeyError, TypeError) as exc:
print(f"error: {type(exc).__name__}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+57 -13
View File
@@ -57,16 +57,60 @@ Repository cache 경로는 고정된 machine-local 절대경로나 현재 작업
8. `worktrees`, `issue-<issue-number>`와 `checkout`은 symlink가 아닌 directory이고 실행 role 소유여야 한다. `worktrees`와 Issue directory는 mode `0700`, `state.json`은 mode `0600`이어야 하며 checkout과 그 상위 runtime 경로는 group 또는 world-writable이면 안 된다. 8. `worktrees`, `issue-<issue-number>`와 `checkout`은 symlink가 아닌 directory이고 실행 role 소유여야 한다. `worktrees`와 Issue directory는 mode `0700`, `state.json`은 mode `0600`이어야 하며 checkout과 그 상위 runtime 경로는 group 또는 world-writable이면 안 된다.
9. 기존 path가 malformed 상태면 삭제, chmod, chown, reset 또는 덮어쓰기로 자동 복구하지 않고 fail-closed한다. 권한 변경은 운영자가 exact path와 mode를 명시적으로 승인한 경우에만 수행하고 즉시 재검증한다. 9. 기존 path가 malformed 상태면 삭제, chmod, chown, reset 또는 덮어쓰기로 자동 복구하지 않고 fail-closed한다. 권한 변경은 운영자가 exact path와 mode를 명시적으로 승인한 경우에만 수행하고 즉시 재검증한다.
## Managed-lock broker (Issue workflow 필수)
Bootstrap 이후 모든 Issue workflow는 role-neutral shared managed-lock broker 하나를 사용한다. 개별 impl/spec/review skill이 직접 `flock`을 획득하거나 broker 규칙을 재정의하지 않는다. Broker executable은 `harness/lib/managed_lock_broker.py`이며 authenticated request action은 `status`, `renew`, `acquire_repository`, `release_repository`, `release`이고 최대 TTL은 `3600`초다.
- Bootstrap lock을 해제한 뒤 broker를 시작한다. checkout 밖 trusted parent 아래 role-owned mode `0700` runtime directory를 미리 만들고, run-unique handle과 socket path를 사용한다. Handle file은 mode `0600`이고 socket은 mode `0600`이며 둘 다 symlink가 아니어야 한다.
- `serve`는 tracked background process로 시작하고 ready JSON을 기다린 뒤에만 Issue 작업을 시작한다. Client는 raw token을 직접 read/log하지 않으며, broker public `request` API가 handle을 인증하고 token을 캡슐화한다.
이 broker의 보안 경계에서 filesystem owner UID는 trusted principal이다. mode `0700`/`0600`과 random bearer token은 다른 UID 또는 foreign token을 사용한 작업을 막지만, 같은 UID를 공유하는 상호 비신뢰 프로세스를 서로 격리하지는 못한다. 그런 격리가 필요한 배포는 각 role을 distinct UID로 실행해야 한다.
- Issue lock은 broker의 `serve`가 관리한다. Repository lock이 필요하면 같은 handle로 `acquire_repository`를 request하고, 끝나면 `release_repository`를 request한다. 중첩 repository lock에 raw `flock ... sleep` 또는 별도 lock holder를 사용하지 않는다.
- TTL 만료 전에 `renew`를 request한다. Broker crash 뒤 kernel이 advisory lock을 해제해도 stale handle은 진단 정보일 뿐이며 `probe`가 authoritative하다.
- 정상, 오류와 cancel 경로는 동일한 cleanup을 수행한다: 보유 중이면 `release_repository`, `release`, broker process wait/reap, 그리고 Issue `probe`를 실행해 `free=true`를 요구한다. 다른 Issue workflow가 합법적으로 repository lock을 먼저 획득할 수 있으므로 완료 gate는 Issue resource를 검사하고, 종료한 broker process가 자신의 repository FD를 더 이상 보유하지 않음을 wait/reap으로 증명한다. 이 순서를 검증하기 전에는 completed/log/report를 주장하지 않는다. Lock 파일은 절대 unlink하지 않는다.
### Canonical command templates
```sh
# <runtime-dir> is pre-created mode 0700 outside every checkout.
python3 <harness-root>/harness/lib/managed_lock_broker.py serve \
--root <harness-root> \
--owner <owner> --repository <repository> --issue <issue-number> \
--role <role> --run-id <run-id> \
--handle <runtime-dir>/<run-id>.handle \
--socket <runtime-dir>/<run-id>.sock \
--ttl-seconds <ttl-seconds>
# After ready JSON; never print/read the token value.
python3 <harness-root>/harness/lib/managed_lock_broker.py request \
--handle <runtime-dir>/<run-id>.handle --action status
python3 <harness-root>/harness/lib/managed_lock_broker.py request \
--handle <runtime-dir>/<run-id>.handle --action acquire_repository
python3 <harness-root>/harness/lib/managed_lock_broker.py request \
--handle <runtime-dir>/<run-id>.handle --action release_repository
python3 <harness-root>/harness/lib/managed_lock_broker.py request \
--handle <runtime-dir>/<run-id>.handle --action renew --ttl-seconds <ttl-seconds>
python3 <harness-root>/harness/lib/managed_lock_broker.py request \
--handle <runtime-dir>/<run-id>.handle --action release
# After wait/reap of the tracked broker process; free=true is mandatory for the Issue resource.
python3 <harness-root>/harness/lib/managed_lock_broker.py probe \
--root <harness-root> \
--owner <owner> --repository <repository> --issue <issue-number> \
--resource issue
```
## Lock 규칙 ## Lock 규칙
- Linux OS advisory lock을 사용한다. lock file descriptor는 symlink를 따라가지 않는 방식으로 열고 원자적으로 lock을 획득한다. - Linux OS advisory lock을 사용한다. lock file descriptor는 symlink를 따라가지 않는 방식으로 열고 원자적으로 lock을 획득한다.
- 독립적인 신규 cache 초기화 순서는 `bootstrap.lock → repository.lock`이다. - 독립적인 신규 cache 초기화 순서는 `bootstrap.lock → repository.lock`이다.
- Issue workflow가 기존 cache를 사용할 때의 순서는 `issue-<issue-number>.lock → repository.lock`이다. - Issue workflow가 기존 cache를 사용할 때 broker가 Issue lease를 보유하고, 같은 broker handle의 `acquire_repository` request로 repository lease를 중첩한다. 개념적 순서는 `Issue(broker) → repository(broker)`이며 client는 raw descriptor lock을 사용하지 않는다.
- Issue 작업에 사용할 cache가 없으면 Issue lock 획득 전에 신규 cache 초기화 절차를 완료한다. 초기화 중에는 Issue를 변경하지 않는다. Bootstrap의 repository lock과 bootstrap lock을 모두 해제한 뒤 Issue lock을 획득하며, 이후에는 기존 cache 사용 순서인 `issue-<issue-number>.lock → repository.lock`을 따른다. Issue 작업의 기준 snapshot은 Issue lock 획득 후 읽는다. - Issue 작업에 사용할 cache가 없으면 Issue broker lease 획득 전에 신규 cache 초기화 절차를 완료한다. 초기화 중에는 Issue를 변경하지 않는다. Bootstrap의 repository lock과 bootstrap lock을 모두 해제한 뒤 broker를 시작하고 Issue lease를 획득하며, 이후에는 broker의 `Issue → acquire_repository` 순서를 따른다. Issue 작업의 기준 snapshot은 broker Issue lease 획득 후 읽는다.
- Worktree 생성, 등록, lock, 이동, 제거 또는 repair는 Issue lock을 유지한 채 repository lock을 추가로 획득해서 수행한다. 일반 파일 편집, 테스트와 commit에는 Issue lock만 유지하고 repository lock은 보유하지 않는다. - Worktree 생성, 등록, lock, 이동, 제거 또는 repair는 broker Issue lease를 유지한 채 같은 broker handle의 `acquire_repository` request로 repository lease를 추가 획득해서 수행한다. 일반 파일 편집, 테스트와 commit에는 broker Issue lease만 유지하고 repository lease는 보유하지 않는다.
- 같은 repository의 다른 Issue worktree를 검사, 변경 또는 제거하지 않는다. 한 Issue workflow는 자신의 exact Issue worktree만 다룬다. - 같은 repository의 다른 Issue worktree를 검사, 변경 또는 제거하지 않는다. 한 Issue workflow는 자신의 exact Issue worktree만 다룬다.
- 반대 순서로 lock을 획득하거나 이미 잡은 상위 lock을 해제한 뒤 하위 작업을 계속하지 않는다. - 반대 순서로 lock을 획득하거나 이미 잡은 상위 lock을 해제한 뒤 하위 작업을 계속하지 않는다.
- 정상 종료, 오류와 중단 경로 모두에서 descriptor를 닫아 lock을 해제한다. - 정상 종료, 오류와 중단 경로 모두에서 broker cleanup protocol을 수행한다: repository lease release, Issue lease release, broker wait/reap, Issue resource `probe --resource issue`의 `free=true` readback. 다른 Issue가 그 사이 repository lock을 획득할 수 있으므로 repository의 순간적 busy 상태만으로 현재 broker cleanup 실패로 판정하지 않는다.
- lock 파일은 unlink하지 않는다. 프로세스 종료 시 kernel이 advisory lock을 해제하므로 남아 있는 파일을 stale active lock으로 간주하거나 PID 기준으로 삭제하지 않는다. - lock 파일은 unlink하지 않는다. 프로세스 종료 시 kernel이 advisory lock을 해제하므로 남아 있는 파일을 stale active lock으로 간주하거나 PID 기준으로 삭제하지 않는다.
## 신규 cache 초기화 ## 신규 cache 초기화
@@ -87,13 +131,13 @@ Repository cache 경로는 고정된 machine-local 절대경로나 현재 작업
## 기존 cache 갱신 ## 기존 cache 갱신
1. 필요한 상위 lock을 유지한 채 repository lock을 획득한다. 1. Issue workflow는 broker Issue lease를 유지한 채 같은 handle에 `acquire_repository`를 request한다. 별도 승인된 non-Issue workflow는 해당 workflow에 명시된 상위 lock과 repository lock 순서를 따른다.
2. target과 `repository.git`의 path, ownership, permissions와 bare 상태를 다시 검증한다. 2. target과 `repository.git`의 path, ownership, permissions와 bare 상태를 다시 검증한다.
3. `origin`의 canonical owner/repository identity가 요청과 일치하는지 확인한다. URL 문자열의 credential이나 transport hostname만으로 identity를 판정하지 않는다. 3. `origin`의 canonical owner/repository identity가 요청과 일치하는지 확인한다. URL 문자열의 credential이나 transport hostname만으로 identity를 판정하지 않는다.
4. 명시적으로 검증한 origin에서 mirror refs를 prune/fetch한다. 임의 remote 교체, force reset 또는 다른 repository 재사용은 금지한다. 4. 명시적으로 검증한 origin에서 mirror refs를 prune/fetch한다. 임의 remote 교체, force reset 또는 다른 repository 재사용은 금지한다.
5. Gitea에서 default branch와 SHA를 다시 읽고 local ref와 일치하는지 확인한다. 5. Gitea에서 default branch와 SHA를 다시 읽고 local ref와 일치하는지 확인한다.
6. 일치한 exact SHA를 이번 작업의 pinned source revision으로 기록한다. 6. 일치한 exact SHA를 이번 작업의 pinned source revision으로 기록한다.
7. repository lock을 해제한다. 7. Issue workflow는 같은 handle에 `release_repository`를 request한다. 별도 non-Issue workflow는 자신의 repository lock을 명시된 순서로 해제한다.
## Issue worktree 모델 ## Issue worktree 모델
@@ -131,7 +175,7 @@ Repository cache 경로는 고정된 machine-local 절대경로나 현재 작업
Worktree를 신규 생성하거나 기존 worktree에서 구현을 재개하기 전에 다음을 모두 확인한다. Worktree를 신규 생성하거나 기존 worktree에서 구현을 재개하기 전에 다음을 모두 확인한다.
1. exact `owner`, `repository`와 Issue 번호가 검증됐고 Issue lock을 획득했다. 1. exact `owner`, `repository`와 Issue 번호가 검증됐고 broker `serve`의 ready response로 Issue lease 보유를 확인했다.
2. Authenticated Gitea API adapter로 role identity, exact repository metadata, Issue body, `updated_at`과 구현 승인 상태를 읽었다. 2. Authenticated Gitea API adapter로 role identity, exact repository metadata, Issue body, `updated_at`과 구현 승인 상태를 읽었다.
3. repository cache가 이 문서의 path, ownership, permission, bare, remote identity와 authoritative ref 검증을 통과했다. 3. repository cache가 이 문서의 path, ownership, permission, bare, remote identity와 authoritative ref 검증을 통과했다.
4. 구현에 사용할 exact base SHA와 그 출처를 결정했다. Discovery pinned revision과 현재 default branch SHA가 다르면 어느 한쪽을 자동 선택하지 않고 변경을 조사해 요구사항과 충돌하지 않는 base를 확정하거나 중단한다. 4. 구현에 사용할 exact base SHA와 그 출처를 결정했다. Discovery pinned revision과 현재 default branch SHA가 다르면 어느 한쪽을 자동 선택하지 않고 변경을 조사해 요구사항과 충돌하지 않는 base를 확정하거나 중단한다.
@@ -143,7 +187,7 @@ Gate가 끝난 뒤 write 직전에 Issue body, `updated_at`과 구현 승인 상
## Worktree 신규 생성 ## Worktree 신규 생성
1. Issue lock을 유지한 채 repository lock을 획득한다. 1. Broker Issue lease를 유지한 채 같은 handle에 `acquire_repository`를 request해 repository lease를 중첩한다.
2. cache와 base commit을 다시 검증하고 `worktrees`와 Issue directory를 canonical mode로 생성한 뒤 즉시 `lstat`한다. 2. cache와 base commit을 다시 검증하고 `worktrees`와 Issue directory를 canonical mode로 생성한 뒤 즉시 `lstat`한다.
3. final `checkout`, `state.json`과 동일 Issue의 Git worktree registration이 모두 없는지 다시 확인한다. 3. final `checkout`, `state.json`과 동일 Issue의 Git worktree registration이 모두 없는지 다시 확인한다.
4. `git --git-dir=<repository.git> worktree add --detach <checkout> <full-base-sha>`와 동등한 호출로 exact final checkout을 생성한다. branch 생성 option을 사용하지 않는다. 4. `git --git-dir=<repository.git> worktree add --detach <checkout> <full-base-sha>`와 동등한 호출로 exact final checkout을 생성한다. branch 생성 option을 사용하지 않는다.
@@ -156,13 +200,13 @@ Gate가 끝난 뒤 write 직전에 Issue body, `updated_at`과 구현 승인 상
- `git rev-parse --git-common-dir`가 canonical `repository.git`과 동일하다. - `git rev-parse --git-common-dir`가 canonical `repository.git`과 동일하다.
- `git worktree list --porcelain`에 exact checkout path가 한 번만 있고 locked 상태다. - `git worktree list --porcelain`에 exact checkout path가 한 번만 있고 locked 상태다.
- checkout path와 `state.json`의 identity, ownership과 permission이 canonical 계약을 충족한다. - checkout path와 `state.json`의 identity, ownership과 permission이 canonical 계약을 충족한다.
8. 모든 검증 후 repository lock을 해제한다. Issue lock은 해당 Issue workflow가 끝날 때까지 유지한다. 8. 모든 검증 후 같은 handle에 `release_repository`를 request한다. Broker Issue lease는 해당 Issue workflow가 끝날 때까지 유지한다.
Git 명령의 성공 exit만으로 생성 완료를 판정하지 않는다. Git registration, checkout, state와 Issue snapshot을 모두 검증해야 한다. Git 명령의 성공 exit만으로 생성 완료를 판정하지 않는다. Git registration, checkout, state와 Issue snapshot을 모두 검증해야 한다.
## 기존 worktree 재사용 ## 기존 worktree 재사용
기존 directory 또는 Git registration 중 하나라도 발견되면 신규 생성으로 덮어쓰지 않는다. Issue lock을 유지하고 repository lock 아래에서 다음을 모두 확인한다. 기존 directory 또는 Git registration 중 하나라도 발견되면 신규 생성으로 덮어쓰지 않는다. Broker Issue lease를 유지하고 같은 handle의 `acquire_repository` request로 얻은 repository lease 아래에서 다음을 모두 확인한다.
- `state.json`이 유효한 `gitea-agent.worktree/v1`이고 owner, repository, Issue와 role이 현재 workflow와 일치한다. - `state.json`이 유효한 `gitea-agent.worktree/v1`이고 owner, repository, Issue와 role이 현재 workflow와 일치한다.
- exact checkout이 canonical `repository.git`에 한 번만 등록돼 있고 locked 상태다. - exact checkout이 canonical `repository.git`에 한 번만 등록돼 있고 locked 상태다.
@@ -176,7 +220,7 @@ Dirty worktree, untracked 파일 또는 base 이후 commit은 진행 중 구현
## Worktree 작업과 delivery ## Worktree 작업과 delivery
- 일반 편집, 테스트와 commit은 exact Issue lock 아래에서만 수행한다. 현재 worktree 외의 source, 다른 checkout 또는 installed package를 수정하거나 구현 근거로 사용하지 않는다. - 일반 편집, 테스트와 commit은 exact broker Issue lease 아래에서만 수행한다. 현재 worktree 외의 source, 다른 checkout 또는 installed package를 수정하거나 구현 근거로 사용하지 않는다.
- commit 전후 base SHA, HEAD SHA, staged·unstaged·untracked 상태를 기록한다. detached 상태를 local branch로 자동 전환하지 않는다. - commit 전후 base SHA, HEAD SHA, staged·unstaged·untracked 상태를 기록한다. detached 상태를 local branch로 자동 전환하지 않는다.
- 명시적으로 승인된 push는 source와 destination을 모두 지정해 `HEAD:refs/heads/<approved-branch>` 형태와 동등하게 수행한다. Canonical cache remote에 `remote.<name>.mirror=true`가 설정돼 있으면 credential을 포함하지 않은 explicit repository URL을 사용하거나 `git -c remote.<name>.mirror=false push <name> HEAD:refs/heads/<approved-branch>`와 동등하게 해당 호출에서만 mirror 동작을 끈다. Persistent remote config는 변경하지 않는다. destination 없는 push, force push와 mirror push는 금지한다. - 명시적으로 승인된 push는 source와 destination을 모두 지정해 `HEAD:refs/heads/<approved-branch>` 형태와 동등하게 수행한다. Canonical cache remote에 `remote.<name>.mirror=true`가 설정돼 있으면 credential을 포함하지 않은 explicit repository URL을 사용하거나 `git -c remote.<name>.mirror=false push <name> HEAD:refs/heads/<approved-branch>`와 동등하게 해당 호출에서만 mirror 동작을 끈다. Persistent remote config는 변경하지 않는다. destination 없는 push, force push와 mirror push는 금지한다.
- Push 후 Gitea에서 exact remote branch SHA를 다시 읽어 local HEAD와 일치하는 경우에만 전달 완료로 판정한다. PR 생성, merge, Issue comment·label·state 변경은 각각 별도 승인이 필요하다. - Push 후 Gitea에서 exact remote branch SHA를 다시 읽어 local HEAD와 일치하는 경우에만 전달 완료로 판정한다. PR 생성, merge, Issue comment·label·state 변경은 각각 별도 승인이 필요하다.
@@ -184,7 +228,7 @@ Dirty worktree, untracked 파일 또는 base 이후 commit은 진행 중 구현
## Worktree 실패 처리와 제거 ## Worktree 실패 처리와 제거
현재 run이 worktree를 생성했고 편집이나 commit이 시작되지 않았으며 worktree가 clean하고 HEAD가 base SHA와 같다는 것을 증명할 때만 생성 실패를 자동 cleanup할 수 있다. 이때 Issue lock과 repository lock을 모두 유지하고 worktree lock을 해제한 뒤 `git worktree remove`와 동등한 Git operation으로 exact registration과 checkout을 제거한다. filesystem 삭제를 먼저 수행하지 않는다. 현재 run이 worktree를 생성했고 편집이나 commit이 시작되지 않았으며 worktree가 clean하고 HEAD가 base SHA와 같다는 것을 증명할 때만 생성 실패를 자동 cleanup할 수 있다. 이때 broker Issue lease를 유지하고 같은 handle의 `acquire_repository` request로 repository lease를 중첩한 뒤 worktree lock을 해제하고 `git worktree remove`와 동등한 Git operation으로 exact registration과 checkout을 제거한다. filesystem 삭제를 먼저 수행하지 않는다.
다음 상태는 자동 cleanup하지 않고 보존한 채 partial 또는 fail-closed로 보고한다. 다음 상태는 자동 cleanup하지 않고 보존한 채 partial 또는 fail-closed로 보고한다.
@@ -198,7 +242,7 @@ Dirty worktree, untracked 파일 또는 base 이후 commit은 진행 중 구현
정상 worktree 제거는 사용자가 exact worktree 폐기를 승인했거나, 승인된 delivery가 완료되어 remote branch SHA가 local HEAD와 일치함을 authoritative readback한 경우에만 수행한다. 제거 전 base SHA, final HEAD SHA, dirty·untracked 상태, 미전달 commit과 remote 보존 여부를 확인하고 보고한다. Dirty 상태, 미전달 commit 또는 판정 불가능한 파일이 있으면 `--force`, filesystem 재귀 삭제, reset 또는 clean으로 우회하지 않는다. 정상 worktree 제거는 사용자가 exact worktree 폐기를 승인했거나, 승인된 delivery가 완료되어 remote branch SHA가 local HEAD와 일치함을 authoritative readback한 경우에만 수행한다. 제거 전 base SHA, final HEAD SHA, dirty·untracked 상태, 미전달 commit과 remote 보존 여부를 확인하고 보고한다. Dirty 상태, 미전달 commit 또는 판정 불가능한 파일이 있으면 `--force`, filesystem 재귀 삭제, reset 또는 clean으로 우회하지 않는다.
제거할 때는 Issue lock과 repository lock 아래에서 worktree lock을 해제하고 Git operation으로 checkout과 registration을 먼저 제거한다. 두 대상의 부재를 확인한 뒤 exact `state.json`과 비어 있는 Issue worktree directory만 제거하며, 현재 run이 만든 state temporary file이 있으면 identity를 확인한 뒤 함께 제거한다. 다른 파일이나 하위 directory가 남아 있으면 재귀 삭제하지 않고 중단한다. 마지막으로 exact checkout, Git registration, `state.json`과 Issue worktree directory가 모두 없는지 다시 검증한다. 제거할 때는 broker Issue lease를 유지하고 같은 handle의 `acquire_repository` request로 repository lease를 중첩한 상태에서 worktree lock을 해제하고 Git operation으로 checkout과 registration을 먼저 제거한다. 두 대상의 부재를 확인한 뒤 exact `state.json`과 비어 있는 Issue worktree directory만 제거하며, 현재 run이 만든 state temporary file이 있으면 identity를 확인한 뒤 함께 제거한다. 다른 파일이나 하위 directory가 남아 있으면 재귀 삭제하지 않고 중단한다. 마지막으로 exact checkout, Git registration, `state.json`과 Issue worktree directory가 모두 없는지 다시 검증하고 `release_repository`를 request한다.
## Credential 안전 ## Credential 안전
View File
+263
View File
@@ -0,0 +1,263 @@
import fcntl
import os
import stat
import subprocess
import sys
import tempfile
import time
import unittest
from pathlib import Path
from unittest.mock import patch
from harness.lib.managed_lock import (
LockOrderError,
LockTimeoutError,
LockValidationError,
ManagedLock,
ManagedLockSet,
probe_free,
validate_held_flock,
)
class ManagedLockTests(unittest.TestCase):
def test_lock_is_created_securely_and_held_for_context(self):
with tempfile.TemporaryDirectory() as d:
path = Path(d) / "repository.lock"
with ManagedLock(path) as lock:
self.assertIsNotNone(lock.fd)
st = path.stat()
self.assertEqual(stat.S_IMODE(st.st_mode), 0o600)
self.assertEqual(st.st_nlink, 1)
self.assertEqual(st.st_uid, os.getuid())
self.assertTrue(validate_held_flock(lock.fd, path))
self.assertTrue(probe_free(path))
def test_lock_timeout_is_bounded_and_does_not_steal_lock(self):
with tempfile.TemporaryDirectory() as d:
path = Path(d) / "issue.lock"
with ManagedLock(path):
start = time.monotonic()
with self.assertRaises(LockTimeoutError):
with ManagedLock(path, timeout=0.05, poll_interval=0.005):
pass
self.assertLess(time.monotonic() - start, 1)
self.assertTrue(probe_free(path))
def test_existing_unsafe_lock_file_is_rejected_without_repair(self):
with tempfile.TemporaryDirectory() as d:
path = Path(d) / "bad.lock"
path.write_text("x")
os.chmod(path, 0o644)
with self.assertRaises(LockValidationError):
ManagedLock(path).acquire()
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o644)
self.assertEqual(path.read_text(), "x")
def test_literal_dotdot_component_is_rejected_before_normalization(self):
with tempfile.TemporaryDirectory() as d:
path = Path(d) / "a" / ".." / "b.lock"
with self.assertRaises(LockValidationError):
ManagedLock(path)
def test_symlink_lock_is_rejected(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
target = root / "target"
target.write_text("do not touch")
path = root / "link.lock"
path.symlink_to(target)
with self.assertRaises(LockValidationError):
ManagedLock(path).acquire()
self.assertEqual(target.read_text(), "do not touch")
def test_symlink_to_safe_lock_is_rejected(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
target = root / "safe-target.lock"
target.touch(mode=0o600)
path = root / "safe-link.lock"
path.symlink_to(target)
with self.assertRaises(LockValidationError):
ManagedLock(path)
def test_lock_path_through_symlinked_parent_is_rejected(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
target = root / "real"
target.mkdir()
parent = root / "linked"
parent.symlink_to(target, target_is_directory=True)
with self.assertRaises(LockValidationError):
ManagedLock(parent / "issue.lock").acquire()
self.assertFalse((target / "issue.lock").exists())
def test_ancestor_swap_after_parent_open_cannot_redirect_lock(self):
"""The final open stays relative to the already pinned parent inode."""
with tempfile.TemporaryDirectory() as d:
root = Path(d)
parent = root / "locks"
parent.mkdir()
diverted = root / "diverted"
diverted.mkdir()
module = __import__("harness.lib.managed_lock", fromlist=["_open_parent"])
original_open_parent = module._open_parent
def swap_after_open(path, *, create_parent):
fd, name = original_open_parent(path, create_parent=create_parent)
parent.rename(root / "original-locks")
parent.symlink_to(diverted, target_is_directory=True)
return fd, name
lock = ManagedLock(parent / "issue.lock")
with patch("harness.lib.managed_lock._open_parent", side_effect=swap_after_open):
with self.assertRaises(LockValidationError):
lock.acquire()
self.assertTrue((root / "original-locks" / "issue.lock").exists())
self.assertFalse((diverted / "issue.lock").exists())
self.assertFalse(lock.held)
def test_hardlink_lock_is_rejected(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
path = root / "lock"
alias = root / "alias"
path.touch(mode=0o600)
os.link(path, alias)
with self.assertRaises(LockValidationError):
ManagedLock(path).acquire()
def test_for_issue_builds_canonical_paths(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d).absolute()
issue, repository = ManagedLockSet.paths_for_issue(root, "alice", "ledger", 7)
self.assertEqual(issue, root / "repositories/alice/ledger/locks/issue-7.lock")
self.assertEqual(repository, root / "repositories/alice/ledger/locks/repository.lock")
locks = ManagedLockSet.for_issue(root, "alice", "ledger", 7)
self.assertEqual(locks.issue.path, issue)
self.assertEqual(locks.repository.path, repository)
def test_for_issue_rejects_unsafe_identity_issue_and_root(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d).absolute()
for owner, repo in [("../alice", "ledger"), ("alice/x", "ledger"), ("alice", ".."), ("alice", "")]:
with self.subTest(owner=owner, repo=repo), self.assertRaises(ValueError):
ManagedLockSet.for_issue(root, owner, repo, 1)
for issue in (0, -1, True, "1"):
with self.subTest(issue=issue), self.assertRaises(ValueError):
ManagedLockSet.for_issue(root, "alice", "ledger", issue)
with self.assertRaises(ValueError):
ManagedLockSet.for_issue(Path("relative"), "alice", "ledger", 1)
with self.assertRaises(ValueError):
ManagedLockSet.for_issue(root / ".." / root.name, "alice", "ledger", 1)
def test_managed_lock_set_holds_issue_then_repository_scope_only(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
issue, repo = root / "issue", root / "repo"
events = []
locks = ManagedLockSet(issue, repo, event_sink=events.append)
with locks:
self.assertTrue(validate_held_flock(locks.issue.fd, issue))
self.assertFalse(locks.repository.held)
with locks.repository_scope():
self.assertTrue(validate_held_flock(locks.repository.fd, repo))
self.assertFalse(locks.repository.held)
self.assertEqual(events, ["acquire:issue", "acquire:repository", "release:repository", "release:issue"])
def test_release_issue_rejects_while_repository_held(self):
with tempfile.TemporaryDirectory() as d:
locks = ManagedLockSet(Path(d) / "issue", Path(d) / "repo")
with locks:
with locks.repository_scope():
with self.assertRaises(LockOrderError):
locks.release_issue()
locks.release_issue()
self.assertFalse(locks.issue.held)
def test_context_exception_cleans_up_issue_and_repository(self):
with tempfile.TemporaryDirectory() as d:
locks = ManagedLockSet(Path(d) / "issue", Path(d) / "repo")
with self.assertRaises(RuntimeError):
with locks:
with locks.repository_scope():
raise RuntimeError("boom")
self.assertFalse(locks.issue.held)
self.assertFalse(locks.repository.held)
self.assertTrue(probe_free(locks.issue.path))
self.assertTrue(probe_free(locks.repository.path))
def test_repository_scope_requires_issue_and_cleans_up_acquire_exception(self):
with tempfile.TemporaryDirectory() as d:
locks = ManagedLockSet(Path(d) / "issue", Path(d) / "repo")
with self.assertRaises(LockOrderError):
with locks.repository_scope():
pass
with ManagedLock(locks.repository.path):
with locks:
with self.assertRaises(LockTimeoutError):
with locks.repository_scope():
pass
self.assertTrue(locks.issue.held)
locks.release_issue()
def test_repository_scope_event_exception_releases_repository_and_issue_context(self):
with tempfile.TemporaryDirectory() as d:
def fail_on_repository(event):
if event == "acquire:repository":
raise RuntimeError("event failure")
locks = ManagedLockSet(Path(d) / "issue", Path(d) / "repo", event_sink=fail_on_repository)
locks.issue.acquire()
with self.assertRaises(RuntimeError):
with locks.repository_scope():
pass
self.assertTrue(locks.issue.held)
self.assertFalse(locks.repository.held)
locks.release_issue()
self.assertTrue(probe_free(locks.issue.path))
self.assertTrue(probe_free(locks.repository.path))
def test_write_metadata_replaces_bytes_exactly_on_repeated_writes(self):
with tempfile.TemporaryDirectory() as d:
path = Path(d) / "metadata.lock"
with ManagedLock(path) as lock:
lock.write_metadata({"long": "x" * 100})
lock.write_metadata({"x": 1})
self.assertEqual(path.read_bytes(), b'{"x":1}\n')
def test_metadata_requires_held_lock(self):
with tempfile.TemporaryDirectory() as d:
lock = ManagedLock(Path(d) / "metadata.lock")
with self.assertRaises(RuntimeError):
lock.write_metadata({"state": "held"})
def test_probe_free_does_not_delete_or_modify_lock(self):
with tempfile.TemporaryDirectory() as d:
path = Path(d) / "probe.lock"
with ManagedLock(path):
pass
before = path.stat()
self.assertTrue(probe_free(path))
after = path.stat()
self.assertEqual((before.st_ino, before.st_size, before.st_mode), (after.st_ino, after.st_size, after.st_mode))
def test_other_process_cannot_acquire_while_context_alive(self):
with tempfile.TemporaryDirectory() as d:
path = Path(d) / "process.lock"
script = """
import sys
sys.path.insert(0, sys.argv[2])
from harness.lib.managed_lock import ManagedLock, LockTimeoutError
try:
with ManagedLock(sys.argv[1], timeout=0.03, poll_interval=0.005):
print('acquired')
except LockTimeoutError:
print('blocked')
"""
with ManagedLock(path):
result = subprocess.run([sys.executable, "-c", script, str(path), str(Path.cwd())], text=True, capture_output=True, check=True)
self.assertEqual(result.stdout.strip(), "blocked")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,261 @@
import json
import os
import secrets
import stat
import subprocess
import sys
import tempfile
import time
import unittest
from argparse import Namespace
from pathlib import Path
from unittest.mock import patch
from harness.lib.managed_lock import ManagedLock, probe_free
from harness.lib import managed_lock_broker as broker
BROKER = Path(__file__).parents[2] / "lib" / "managed_lock_broker.py"
def wait_ready(proc, timeout=3):
line = proc.stdout.readline().decode()
if not line:
raise AssertionError(proc.stderr.read().decode())
return json.loads(line)
class ManagedLockBrokerTests(unittest.TestCase):
def start(self, root, owner="alice", repo="ledger", issue=7, ttl=5, role="gitea-impl-agent"):
locks = root / "repositories" / owner / repo / "locks"
locks.mkdir(parents=True)
os.chmod(locks, 0o700)
(locks / "repository.lock").touch(mode=0o600)
runtime = root / "runtime"
runtime.mkdir()
os.chmod(runtime, 0o700)
handle = runtime / "handle.json"
socket = runtime / "broker.sock"
cmd = [sys.executable, str(BROKER), "serve", "--root", str(root), "--owner", owner,
"--repository", repo, "--issue", str(issue), "--role", role, "--run-id", "run-1",
"--handle", str(handle), "--socket", str(socket), "--ttl-seconds", str(ttl),
"--acquire-timeout", "0.3"]
proc = subprocess.Popen(cmd, cwd=Path.cwd(), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
ready = wait_ready(proc)
self.assertEqual(ready["status"], "held")
self.assertNotIn("token", json.dumps(ready))
return proc, handle, socket
def request(self, handle, action, ttl=None, token=None):
data = json.loads(handle.read_text())
if token is not None:
data["token"] = token
import socket
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
s.connect(data["socket"])
payload = {"action": action, "token": data["token"] if token is None else token}
if ttl is not None:
payload["ttl_seconds"] = ttl
s.sendall((json.dumps(payload) + "\n").encode())
with s.makefile("rb") as response_file:
return json.loads(response_file.readline())
def test_cross_process_contention_and_authenticated_release(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
proc, handle, _ = self.start(root)
try:
self.assertFalse(probe_free(root / "repositories/alice/ledger/locks/issue-7.lock"))
self.assertEqual(self.request(handle, "release")["status"], "released")
proc.wait(timeout=2)
self.assertTrue(probe_free(root / "repositories/alice/ledger/locks/issue-7.lock"))
self.assertNotIn("token", json.loads(handle.read_text()))
finally:
if proc.poll() is None:
proc.kill(); proc.wait()
proc.stdout.close(); proc.stderr.close()
def test_probe_cli_fails_while_held_and_passes_after_release(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
proc, handle, _ = self.start(root)
command = [sys.executable, str(BROKER), "probe", "--root", str(root),
"--owner", "alice", "--repository", "ledger", "--issue", "7"]
try:
held = subprocess.run(command, capture_output=True, text=True)
self.assertEqual(held.returncode, 1)
self.assertFalse(json.loads(held.stdout)["free"])
self.assertEqual(self.request(handle, "release")["status"], "released")
proc.wait(timeout=2)
free = subprocess.run(command, capture_output=True, text=True)
self.assertEqual(free.returncode, 0)
self.assertTrue(json.loads(free.stdout)["free"])
finally:
if proc.poll() is None:
proc.kill(); proc.wait()
proc.stdout.close(); proc.stderr.close()
def test_issue_probe_passes_when_another_workflow_holds_repository(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
proc, handle, _ = self.start(root)
base = [sys.executable, str(BROKER), "probe", "--root", str(root),
"--owner", "alice", "--repository", "ledger", "--issue", "7"]
repository = root / "repositories/alice/ledger/locks/repository.lock"
try:
self.assertEqual(self.request(handle, "release")["status"], "released")
proc.wait(timeout=2)
with ManagedLock(repository):
issue = subprocess.run([*base, "--resource", "issue"], capture_output=True, text=True)
all_resources = subprocess.run([*base, "--resource", "all"], capture_output=True, text=True)
self.assertEqual(issue.returncode, 0)
self.assertTrue(json.loads(issue.stdout)["issue_free"])
self.assertEqual(all_resources.returncode, 1)
self.assertFalse(json.loads(all_resources.stdout)["repository_free"])
finally:
if proc.poll() is None:
proc.kill(); proc.wait()
proc.stdout.close(); proc.stderr.close()
def test_new_handle_mode_is_exact_under_restrictive_umask(self):
with tempfile.TemporaryDirectory() as d:
path = Path(d) / "handle.json"
old = os.umask(0o777)
try:
broker._write_handle(path, {"token": "a" * 64, "socket": "/tmp/x"}, exclusive=True)
finally:
os.umask(old)
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600)
def test_repository_scope_release_keeps_issue(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d); proc, handle, _ = self.start(root)
try:
self.assertEqual(self.request(handle, "acquire_repository")["status"], "repository-held")
self.assertFalse(probe_free(root / "repositories/alice/ledger/locks/repository.lock"))
self.assertEqual(self.request(handle, "release_repository")["status"], "held")
self.assertTrue(probe_free(root / "repositories/alice/ledger/locks/repository.lock"))
self.assertFalse(probe_free(root / "repositories/alice/ledger/locks/issue-7.lock"))
finally:
self.request(handle, "release"); proc.wait(timeout=2); proc.stdout.close(); proc.stderr.close()
def test_ttl_expiry_updates_handle_and_releases(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d); proc, handle, _ = self.start(root, ttl=1)
proc.wait(timeout=4)
state = json.loads(handle.read_text())
self.assertEqual(state["status"], "expired")
self.assertNotIn("token", state)
self.assertTrue(probe_free(root / "repositories/alice/ledger/locks/issue-7.lock"))
proc.stdout.close(); proc.stderr.close()
def test_expired_lease_rejects_renew_and_release_before_action(self):
for action in ("renew", "release"):
with self.subTest(action=action), tempfile.TemporaryDirectory() as d:
root = Path(d)
locks = root / "repositories/alice/ledger/locks"
locks.mkdir(parents=True); os.chmod(locks, 0o700)
(locks / "repository.lock").touch(mode=0o600)
runtime = root / "runtime"; runtime.mkdir(mode=0o700)
args = Namespace(root=str(root), owner="alice", repository="ledger", issue=7,
role="gitea-impl-agent", run_id="expiry-test",
handle=str(runtime / "handle.json"), socket=str(runtime / "broker.sock"),
ttl_seconds=5.0, acquire_timeout=0.0)
instance = broker.Broker(args)
instance.set.issue.acquire()
broker._write_handle(instance.handle, instance.state, exclusive=True)
instance.handle_created = True; instance.started = True
instance.deadline = time.monotonic() - 1
request: dict[str, object] = {"action": action, "token": instance.token}
if action == "renew": request["ttl_seconds"] = 30
self.assertEqual(instance.handle_request(request), {"error": "expired"})
self.assertTrue(probe_free(locks / "issue-7.lock"))
terminal = json.loads(instance.handle.read_text())
self.assertEqual(terminal["status"], "expired")
self.assertNotIn("token", terminal)
def test_startup_failure_releases_issue_and_terminalizes_partial_handle(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
locks = root / "repositories/alice/ledger/locks"
locks.mkdir(parents=True); os.chmod(locks, 0o700)
(locks / "repository.lock").touch(mode=0o600)
runtime = root / "runtime"; runtime.mkdir(mode=0o700)
handle = runtime / "handle.json"
args = Namespace(root=str(root), owner="alice", repository="ledger", issue=7,
role="gitea-impl-agent", run_id="startup-failure",
handle=str(handle), socket=str(runtime / "broker.sock"),
ttl_seconds=5.0, acquire_timeout=0.0)
instance = broker.Broker(args)
real_write = broker._write_handle
def partial_then_fail(path, state, *, exclusive=False, on_created=None):
real_write(path, state, exclusive=exclusive, on_created=on_created)
raise OSError("synthetic startup failure")
with patch.object(broker, "_write_handle", side_effect=partial_then_fail):
with self.assertRaises(OSError):
instance.run()
self.assertTrue(probe_free(locks / "issue-7.lock"))
terminal = json.loads(handle.read_text())
self.assertIn(terminal["status"], ("released", "expired"))
self.assertNotIn("token", terminal)
def test_startup_existing_handle_is_never_claimed_or_modified(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
locks = root / "repositories/alice/ledger/locks"
locks.mkdir(parents=True); os.chmod(locks, 0o700)
(locks / "repository.lock").touch(mode=0o600)
runtime = root / "runtime"; runtime.mkdir(mode=0o700)
handle = runtime / "handle.json"
original = b'{"foreign":"do-not-touch"}\n'
handle.write_bytes(original); handle.chmod(0o600)
args = Namespace(root=str(root), owner="alice", repository="ledger", issue=7,
role="gitea-impl-agent", run_id="existing-handle",
handle=str(handle), socket=str(runtime / "broker.sock"),
ttl_seconds=5.0, acquire_timeout=0.0)
instance = broker.Broker(args)
with self.assertRaises(FileExistsError):
instance.run()
self.assertEqual(handle.read_bytes(), original)
self.assertTrue(probe_free(locks / "issue-7.lock"))
def test_cli_client_compatibility_and_nonprinting_function(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d); proc, handle, _ = self.start(root)
try:
result = broker.request_handle(handle, "status")
self.assertEqual(result["status"], "held")
self.assertNotIn("token", json.dumps(result))
cli = subprocess.run([sys.executable, str(BROKER), "request", "--handle", str(handle), "--action", "status"],
capture_output=True, text=True)
self.assertEqual(cli.returncode, 0)
self.assertEqual(json.loads(cli.stdout)["status"], "held")
finally:
broker.request_handle(handle, "release"); proc.wait(timeout=2); proc.stdout.close(); proc.stderr.close()
def test_token_not_in_ready_or_errors_and_bad_handle_rejected(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d); proc, handle, _ = self.start(root)
try:
raw = handle.read_text()
self.assertIn("token", raw)
bad = root / "bad"; bad.symlink_to(handle)
result = subprocess.run([sys.executable, str(BROKER), "request", "--handle", str(bad), "--action", "status"], capture_output=True, text=True)
self.assertNotEqual(result.returncode, 0)
self.assertNotIn(json.loads(raw)["token"], result.stderr)
self.assertEqual(self.request(handle, "status", token=secrets.token_hex(32))["error"], "unauthorized")
finally:
self.request(handle, "release"); proc.wait(timeout=2); proc.stdout.close(); proc.stderr.close()
def test_unsafe_handle_and_identifiers_rejected(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
for role in ("bad", "gitea-impl-agent/evil"):
p = subprocess.run([sys.executable, str(BROKER), "serve", "--root", str(root), "--owner", "a", "--repository", "r", "--issue", "1", "--role", role, "--run-id", "x", "--handle", str(root/"h"), "--socket", str(root/"s"), "--ttl-seconds", "1"], capture_output=True, text=True)
self.assertNotEqual(p.returncode, 0)
self.assertNotIn("token", p.stderr.lower())
if __name__ == "__main__":
unittest.main()
@@ -257,6 +257,18 @@ class NativeReviewTests(unittest.TestCase):
lease.deadline_monotonic = time.monotonic() + 30 lease.deadline_monotonic = time.monotonic() + 30
(wt / 'a.txt').write_text('dirty') (wt / 'a.txt').write_text('dirty')
with self.assertRaises(m.ValidationError): lease.check(p) with self.assertRaises(m.ValidationError): lease.check(p)
# Every failed source validation must close all transient FDs.
before_fds = len(list((Path('/proc/self/fd')).iterdir()))
for _ in range(20):
with self.assertRaises(m.ValidationError): lease.check(p)
after_fds = len(list((Path('/proc/self/fd')).iterdir()))
self.assertLessEqual(after_fds, before_fds + 1)
(wt / 'a.txt').unlink()
for directory in [wt.parent.parent, wt.parent, wt]:
directory.chmod(0o770)
with self.subTest(directory=directory), self.assertRaises(m.ValidationError):
lease.check(p)
directory.chmod(0o700)
finally: finally:
os.close(fd) os.close(fd)
def test_cli_preflight_and_saved_wire_verification_are_read_only(self): def test_cli_preflight_and_saved_wire_verification_are_read_only(self):
@@ -538,6 +550,41 @@ class NativeReviewTests(unittest.TestCase):
with self.assertRaises(m.ValidationError): m.publish(p, before, FakeAPI(p, before, after, exact), lease, directory) with self.assertRaises(m.ValidationError): m.publish(p, before, FakeAPI(p, before, after, exact), lease, directory)
self.assertFalse(directory.exists()) self.assertFalse(directory.exists())
def test_broker_issue_lease_accepts_authenticated_matching_status(self):
m = self.module(); p, before, after, exact = publication_fixture()
now = time.monotonic()
lease = m.BrokerIssueLease('/trusted/handle.json', '/trusted/policy.md', now + 30)
handle = {'owner': 'acme', 'repository': 'repo', 'issue': 3, 'role': 'gitea-review-agent',
'pid': 1234, 'deadline': now + 20, 'token': 'not-returned', 'socket': '/trusted/broker.sock'}
status = {'status': 'held', 'owner': 'acme', 'repository': 'repo', 'issue': 3,
'role': 'gitea-review-agent', 'pid': 1234, 'deadline': handle['deadline']}
from unittest.mock import patch
with patch.object(m._broker, 'read_handle_metadata', return_value={k: v for k, v in handle.items() if k != 'token'}), \
patch.object(m._broker, 'request_handle', return_value=status), \
patch.object(m, '_validate_registered_source') as source:
self.assertIsNone(lease.check(p))
source.assert_called_once_with(p, '/trusted/policy.md', lease.deadline_monotonic)
def test_broker_issue_lease_rejects_wrong_identity_status_or_deadline(self):
m = self.module(); p, before, after, exact = publication_fixture(); now = time.monotonic()
lease = m.BrokerIssueLease('/trusted/handle.json', '/trusted/policy.md', now + 30)
handle = {'pid': 1234, 'deadline': now + 20, 'token': 'hidden', 'socket': '/trusted/broker.sock'}
cases = [
{'status': 'held', 'owner': 'acme', 'repository': 'repo', 'issue': 3, 'role': 'gitea-spec-agent', 'pid': 1234, 'deadline': handle['deadline']},
{'status': 'held', 'owner': 'other', 'repository': 'repo', 'issue': 3, 'role': 'gitea-review-agent', 'pid': 1234, 'deadline': handle['deadline']},
{'status': 'released', 'owner': 'acme', 'repository': 'repo', 'issue': 3, 'role': 'gitea-review-agent', 'pid': 1234, 'deadline': handle['deadline']},
{'status': 'held', 'owner': 'acme', 'repository': 'repo', 'issue': 3, 'role': 'gitea-review-agent', 'pid': 9999, 'deadline': handle['deadline']},
{'status': 'held', 'owner': 'acme', 'repository': 'repo', 'issue': 3, 'role': 'gitea-review-agent', 'pid': 1234, 'deadline': now + 40},
]
from unittest.mock import patch
for status in cases:
with self.subTest(status=status):
with patch.object(m._broker, 'read_handle_metadata', return_value={k: v for k, v in handle.items() if k != 'token'}), \
patch.object(m._broker, 'request_handle', return_value=status), \
patch.object(m, '_validate_registered_source') as source:
with self.assertRaises(m.ValidationError): lease.check(p)
source.assert_not_called()
def test_journal_intent_fields_are_validated_on_resume(self): def test_journal_intent_fields_are_validated_on_resume(self):
m = self.module() m = self.module()
for field in ['schema', 'head', 'event', 'body_sha256']: for field in ['schema', 'head', 'event', 'body_sha256']: