0095: task-level 병렬 bounded 루프 (X) + omc multi-worker (Y) default-on
0095: task-level 병렬 bounded 루프 (X) + omc multi-worker (Y) default-on
- Status: accepted
- Date: 2026-06-02
- Deciders: User, Claude Code
- Related: ADR 0094 (동시성 안전 substrate — 본 ADR 의 전제), ADR 0087 (opt-in omc 병렬 runner / single-worker pin), ADR 0085 (무한 모드 + 안전 가드 SEMANTICS), ADR 0083 (
make 시작local-gate completion / EXECUTE_SHIP=0), ADR 0001 (baseline byte-identical 보존), ADR 0005 (private 데이터 경계), ADR 0061 (외부/유료 API opt-in 3조건) - Issue: #1762
Context
maintainer 는 make 시작(active-auto-loop, scripts/agent_loop.py)
의 직렬(serialize-only) 처리를 X×Y×Z 3-level 병렬화로 확장하기로 결정했다 — X = task-level
pool(서로 다른 queue task N개 동시), Y = per-task omc multi-worker, Z = intra-task role
parallelism(이미 존재하는 spawn_and_wait). 본 ADR 은 ADR 0094
의 동시성 안전 substrate(atomic-write + LeaseManager.claim_disjoint + 전역 BoundedSemaphore)에
의존한다 — substrate 가 먼저 착륙해야 X 를 안전하게 켤 수 있다(3자 YELLOW 합의의 핵심).
바뀌는 두 가지: (1) _resolve_omc_worker_mix 의 single-worker 핀(total_workers = 1 +
assert ... == 1, ADR 0087)과 (2) 직렬 루프 body
(write_active_auto_loop 가 task 를 한 개씩 처리하는 순차 wave). Z 는 이미 존재하므로 throttle
만 ADR 0094 의 전역 M 으로 받는다.
Decision
XYZ 병렬화를 도입한다. X 는 PR-E2~E3c 에서 default-dark(기본 X=1)로 dark 착륙한 뒤 PR-F(#1948)에서
기본을 X=2 로 flip 했다 — 현재 기본 = X=2 병렬(X=1 직렬·ADR 0001 byte-identical 경로는
ACTIVE_TASK_POOL=1 / BIDMATE_AGENT_LOOP_TASK_POOL=1 / kill-switch override 로 유지). Y 는
default-on(omc path 한정). 단일 전역 budget M 은 ADR 0094 에서
온다. EXECUTE_SHIP=0(ADR 0083) human-gated ship 은 불변이다.
- Y (omc multi-worker) default-on:
_resolve_omc_worker_mix의total_workers=1핀 +assert ... == 1을 제거한다(PR-D, #1804). worker 수는 agent_mix 정책에서 도출하되OMC_MAX_WORKERS(기본 ≤3) ∧ M 으로 clamp 한다. explicit--read-agent claude/codexoverride 는 single lane 유지(auto만 fan-out). ADR 0087 이 미룬 multi-worker per-worker diff 캡처 를 빌드하되 NO auto-merge 를 유지한다(캡처된 diff 는 privacy 재감사 + scope 재부과 + 기존 active-apply / Conservative Gate / human-gated ship 으로만 라우팅 — main 미머지).- per-worker 캡처 + fail-closed 집계: 각 worker 의 worktree 에서 merge-base→
git add -A→git diff --cached(ADR 0087 round-6/8/10 fix 시퀀스)로 diff 를 캡처해omc_runs/omc-team/worker-{idx}/patch_artifact.jsonnamespace 에 privacy+scope 재감사 후 기록한다. 어느 worker 라도 (a) merge-base 실패, (b)add -A실패, (c) diff 실패, (d) privacy 위반, (e) scope 위반 시 전체 run blocked(부분 성공 허용 금지; blocker 에 worker idx 기록). - 정본(canonical) 정책: N==1 + 전 검사 통과 는 표준 active-apply 경로
(
patch_runs/implementer/patch_artifact.json)에 proposed 기록 — ADR 0087 single-worker 동작과 byte-identical(worker-N namespace 미생성). N>1 + 전 검사 통과 는 표준 경로를 “needs human selection” blocked artifact 로 라우팅(active-apply 자동 소비 차단)하고 per-worker namespace 에 각 proposed 를 보존한다 — human 이 수동으로 하나를 표준 경로로 승격한다. 자동 승격(auto-promotion)은 PR-D non-goal(캡처 + 안전 라우팅까지만). - OMC_MAX_WORKERS ∧ M 은 best-effort cap:
omc team은 단일 out-of-process subprocess 라 전역 semaphore(M)는 그 launch 에 1 permit 만 부과한다 — out-of-process omc worker 수를 in-process semaphore 가 hard-enforce 하지 못한다.OMC_MAX_WORKERS는 runner 가 요청하는 mix_spec worker 총수의 best-effort cap 이며, 잔여 N-fold egress 는 ack(ACTIVE_OMC_RUNNER_ACK=1)가 수용한다. 이미 ack-gated 된runner=omc경로에 한정 되므로 기본make 시작(codex runner)은 영향받지 않는다. omc 의 단일 기존 ack(ACTIVE_OMC_RUNNER_ACK=1)은 maintainer 결정에 따라 N-fold egress 에 대한 consent 로 수용한다. 전역 kill-switchBIDMATE_AGENT_LOOP_PARALLELISM_KILL=1는 PR-D 에서 omc-scope 로 도입되어(_resolve_omc_worker_mix를 single worker 로 강등) 켜질 때 multi-worker 를 즉시 직렬 강등한다; X-task-pool 강등은 PR-E.
- per-worker 캡처 + fail-closed 집계: 각 worker 의 worktree 에서 merge-base→
- X (task pool) DEFAULT X=2 (go-live): 루프 body 를
run_one_task로 refactor 하고ThreadPoolExecutor+ lockedclaim_next_task(다음 task 선택을 atomic 하게)로 묶는다. race-free completed-count, convergent stop(#1719 teardown 재사용), per-task artifact namespacing(동시 task 의patch_artifact.json충돌 방지)을 구현한다. 기본 X=1 으로 dark 착륙(PR-E2~E3c) 후 substrate + 테스트가 안정화되어, PR-F(#1948)에서 기본을 X=2 로 flip 했다(현재 기본 = X=2 병렬). X=1(직렬, ADR 0001 byte-identical 경로)은ACTIVE_TASK_POOL=1/BIDMATE_AGENT_LOOP_TASK_POOL=1또는 kill-switch(BIDMATE_AGENT_LOOP_PARALLELISM_KILL=1)로 여전히 사용 가능하다.- X>1 시작 가드 — HEAD≠origin/main demote(PR-F, codex round-3 Option B + round-4 exact parity):
X>1 cycle worktree 는
origin/main에서 fork 하고 parent 의 DIRTY(uncommitted) 파일만 seed 하므로, cycle tree 가 parent checkout 과 일치하는 건 HEAD == origin/main(정확히 같은 commit) 일 때 뿐이다. 어느 방향으로든 어긋나면 cycle 이 X=1(parent repo 직접 실행)과 다른 tree 에서 돈다: HEAD 가 앞서면 committed-but-unpushed 작업이 cycle 에서 안 보이고(round-3, data-loss 방향), origin/main 이 앞서면 cycle 이 operator 가 checkout 한 것보다 새 코드에서 fork 돼 stale HEAD 가 origin/main tip 으로 fan-out 된다(round-4, stale-base 방향). 따라서 driver 시작 시_head_matches_origin_main(repo_root)(=rev-parse HEAD와rev-parse origin/maincommit id 비교)로 1회 확인하고, 같지 않으면 X 를 1 로 강등(demote) 하고 경고를 emit 한다 — cycle 코드 가시성을 항상 X=1 과 일치시킨다. ancestor 검사로는 round-4 를 못 잡는다(HEAD 가 더 새 origin/main 의 ancestor 이지만 tree 는 다름) → commit id 등치 비교. fcntl clamp 와 같은 correctness 가드라 명시적 knob 값에도 적용된다(선호가 아님). FAIL-SAFE: 어느 ref 든 미해결이면_git_ref가 None → False → 강등(검증 불가한 base 로 cycle 실행 금지). within-run task commit 은 X>1 에서도 안전(lease 가 claimed-file disjoint 강제) — PRE-RUN checkout 상태만 위험하므로 시작 시점 1회 검사로 충분. HEAD 를 origin/main 으로 sync(push/merge 또는 pull) 하면 X>1 재활성화.
- X>1 시작 가드 — HEAD≠origin/main demote(PR-F, codex round-3 Option B + round-4 exact parity):
X>1 cycle worktree 는
- 단일 전역 M: ADR 0094 의
BoundedSemaphore(M)(기본 8)를 모든 CLI spawn 이 acquire — X·Y·Z 곱셈 폭증 방지.
ADR 0087 의 single-worker pin 을 부분 supersede 한다:
0087 의 거버넌스/ack 기계(데이터-경계 ack fail-closed, no-auto-merge, privacy 재감사, scope
재부과, gate 라우팅)는 그대로 유지 되고, 오직 worker-count 결정(total_workers=1 강제)만
번복된다.
ADR 0085 의 직렬 루프가 암묵적으로 의존하던 단일 ledger writer 안전성(0085 가 명시한 결정이 아니라 직렬 설계의 ambient 불변식)을 ADR 0094 의 명시적 locking 계약으로 대체한다: 0085 의 가드 SEMANTICS(연속-blocker, wall-clock, exit-code)는 보존 되며 concurrency 용으로 재표현된다 — 특히 consecutive-blocker 를 “blockers since last completion(마지막 완료 이후 누적된 blocker)” 으로 재정의한다.
scripts/agent_loop.py 는 본 단계에서도 LOAD_BEARING_PATHS 에 올리지 않는다(ADR 0080/0085/0087
계승) — task-level 병렬은 retrieval/verifier/answer/eval 런타임을 건드리지 않는다.
Drivers
- X dark-first — Plan + codex 가 “X 는 dark 로 착륙, 안정화 후 flip” 을 권고했다(폭주 리스크를 default-off 로 격리). substrate 가 검증되기 전에 X 를 기본 켜지 않았고, 검증·테스트 안정화 후 PR-F(#1948)에서 기본을 X=2 로 flip 했다.
- Y default-on, omc-only — maintainer 가 omc multi-worker 를 기본 동작으로 원한다. 이미 ack-gated 된 omc 경로에 한정하므로 기본 codex 경로 byte-identical 이 보존된다(ADR 0001).
- 재사용 / SSoT — ADR 0094 substrate + issue #1719 confinement primitive + 기존 active-apply/gate 라우팅(ADR 0087) 재사용.
Alternatives considered
- X 를 처음부터 default-on. 기각: Plan + codex 가 dark-first 를 권고. substrate + 동시성 테스트가 안정화되기 전 X 기본 활성은 폭주(runaway) 리스크. 안정화 후 PR-F(#1948)에서 X=2 로 flip 했다.
- Y 도 ack 뒤 opt-in 유지(default-off). 기각: maintainer 가 명시적으로 default-on 을 결정.
단 omc 경로 자체가 이미
ACTIVE_OMC_RUNNER_ACK=1로 gated 이므로 기본 codex 경로는 불변. - per-task runner 선택(task 별 codex/omc/claude 라우팅). 기각/연기: post-v1 deferred. v1 은 단일 runner 정책 + Y(omc) 다중화에 집중.
- 독립 cap(X·Y·Z 각각). 기각: ADR 0094 의 단일 전역 M 으로 곱셈 폭증을 막는다.
Consequences
- omc multi-worker 의 외부 egress 증폭은 best-effort 로
OMC_MAX_WORKERS(기본 ≤3) ∧ M 에 의해 bounded 된다 — out-of-process worker 라 hard enforce 는 불가하며, 그 한계는 기존 ADR 0087 ack gate 와 ADR 0005/0061 데이터-경계가 보완한다(N-fold egress 는 ack consent 로 수용). - per-worker 캡처 + fail-closed(PR-D, #1804): 각 worker diff 가 자체 worktree 에서 캡처되어 worker 별 privacy+scope 재감사를 통과해야만 per-worker artifact 로 이어진다. 어느 worker 라도 실패 시 전체 run 이 blocked 되어(부분 성공 없음) 한 worker 의 누출/범위이탈이 다른 worker 의 proposed 와 함께 active-apply 로 새지 않는다.
- 정본 라우팅(PR-D): N==1 + 전 검사 통과는 ADR 0087 single-worker 경로와 byte-identical 하게 표준 active-apply 경로에 proposed 를 기록한다(worker-N namespace 미생성). N>1 + 전 검사 통과는 표준 경로가 “needs human selection” blocked artifact 가 되어 active-apply 자동 소비가 차단되고, per-worker proposed 는 보존된다. 자동 승격은 의도적 non-goal — human 이 정확히 하나를 표준 경로로 승격한다(NO auto-merge 불변).
- kill-switch omc-scope(PR-D):
BIDMATE_AGENT_LOOP_PARALLELISM_KILL=1가_resolve_omc_worker_mix를 single worker(auto→majority lane 1; explicit override 존중)로 강등한다 — 코드 revert 없이 multi-worker 를 즉시 끈다. X-task-pool 강등은 PR-E 에서 같은 env 로 확장된다. - stale worker-* 증거 격리(PR-D, codex round-2/3 fix): stale
worker-{idx}/patch_artifact.jsoneviction 은_run_omc_team_runner함수 초입(execute=True가드)에서 모든 pre-launch early-return 앞에 실행된다(이전에는 team-launch 직전에 위치해 no-ack / task-scope / assignment / privacy pre-launch blocked early-return 이 eviction 을 우회했다).execute=False(dry-run)는 round-9 fix #2 read-only 불변에 따라 제외. eviction 실패는 warning-only 가 아니라 fail-closed:rmtree실패 시 stale artifact 를 in-place blocked 로 overwrite(blocked 는 apply-ineligible); overwrite 도 실패하면 run 을 blocked early-return(blocker 에 “fail-closed” 기록). - 가드-semantics 변경이 문서화된다 — consecutive-blocker 가 “since last completion” 으로 재정의 되고, wall-clock 은 per-task budget 으로 재표현된다. exit-code 가드 SEMANTICS 는 보존.
- redaction-scan per-task scoping 이 요구된다 —
_redact_active_*glob 이 동시 task 를 교차 스캔하지 않도록 PR-E 전에 trace + scoping 필요(미해결 시 PR-E 보류). PR-E1(#1817)에서 착륙:_redact_active_codex_runs/_redact_active_patch_runs에task_slug인자를 추가해 scan 을<active>/codex_runs|patch_runs/<slug>subtree 로 좁히고,expected가드를 “표준 경로 OR 그 직계 task-scoped 자식” 으로 확장한다(pre-E1 의 bareruns != expected동치 검사는 task-scoped 경로를 조용히 스킵 → private 데이터 누출의 가장 미묘한 함정이었다).task_slug은 영숫자+-_만 허용한다(_sanitize_task_slug) —.·/를 포함한 그 외 문자는 모두 제거되므로./../traversal 토큰이 애초에 살아남을 수 없고(charset 자체가 방어), 남는 글자가 없으면None을 반환한다. 미지정(task_slug=None) 시 표준 경로로 fallback 하되, task-scoped intent(task_slug is not None)인데 sanitize 결과가None(unsafe)이면 표준 경로를 스캔하지 않고0을 반환(fail-closed) — task-scoped 모드에서 표준 스캔으로 fallback 하면 실제 per-task subtree 를 건너뛰어 누출이 되기 때문(E1 기본 = byte-identical, E2 가 slug 주입해 활성). - guard trip 하에서 completed-set 이 비결정적(nondeterministic)일 수 있다 → count 기반 정확 일치 대신 invariant-based 테스트로 검증한다.
EXECUTE_SHIP=0(ADR 0083) 불변, X=1/M=8 byte-identical(ADR 0001),agent_loop.pyLOAD_BEARING_PATHS비승격 유지.- pre-existing:
agent_loopprivacy redaction 이reports/real100/만 매치하고real100_v2*를 포함하지 않는다. PR-D diff 밖(pre-existing) — 별도 follow-up issue 로 추적 권장. - PR-D 한정 알려진 미결: artifact race + teardown-중 permit 조기 release(X=1 dark라 무해).
_finalize_omc_runner_result(artifact write + heartbeat invalidation)와 teardown(shutdown)이global_concurrency_limiter().slot()밖에서 실행된다. 모든 omc run 이 공유하는 단일 표준 경로(patch_runs/implementer/patch_artifact.json)가 있기 때문에 X>1 동시 omc run에서 artifact last-writer-wins race 가 발생하고, teardown 진입 시점에 이미 permit 이 반환된 상태다. PR-D 는 X=1 dark 이므로 동시 omc run 이 없어 무해하다. 이 race 는 slot/fence 가 아니라 PR-E2 의 per-task disjointstandard_path로 닫힌다 — semaphore 는 capacity throttle 이지 publication mutex 가 아니다(아래 PR-E1 항목). PR-E1 은 그standard_path파라미터화 substrate 만 깐다. - PR-E1(#1817)에서
standard_pathsubstrate 착륙 — HIGH-4 는 slot/fence 로 닫지 않는다: PR-E 가 2-PR 로 분할된다(E1=path/privacy substrate, E2=#1816 X task pool). E1 은 오직 표준 active-apply 경로를_finalize_omc_runner_result의standard_path인자로 parametrize 하고(4개의 하드코딩 재계산 제거 — late-blocker overwrite 포함), per-task run-root helper(_omc_task_run_root)와 task-scoped redaction scoping(task_slug)을 도입한다. HIGH-4 의 X>1 publication race 를 slot/semaphore fence 로 닫지 않는다:global_concurrency_limiter()는BoundedSemaphore(M)capacity throttle 일 뿐 publication mutex 가 아니다 — M=1 이어도 teardown gap 이 launch/capture 순서와 publication 순서를 분리하고, M>1 이면 두 sibling run 이 둘 다 permit 을 쥔 채 같은standard_path를 last-writer-wins clobber 한다(slot 은 exclusion 을 제공하지 못한다). legacy 공유 경로에 flock 을 거는 것도 같은 이유로 last-writer-wins data loss 를 못 막고 partial-write tearing 만 막으며, E2 가 path 를 disjoint 로 만들면 redundant 가 된다. 따라서 E1 의 finalize 는 어떤 slot 으로도 감싸지 않는다(launch slot 은 ADR 0094 의 정당한 spawn throttle 로 유지). E1 기본값 (standard_path 미지정, task_slug=None)은 기존 경로 계산식과 textually identical → X=1 byte-identical(ADR 0001); X=1 은 dark 라 동시 publication 자체가 없다. HIGH-4 의 실제 fix 는 PR-E2(#1816) 의 worktree-per-task isolation 으로 한다 — E1 의 per-filestandard_path/task_slugfence 가 아니라 cycle 의repo_root경계에서 닫는다(아래 PR-E2 항목). E1 의 redactiontask_slugsubstrate 는 merged 인 채로 유지하되(다른 concern), E2 의 worktree 전략은 그것을 wiring 하지 않는다(unused-but-intact). - PR-E2(#1816) 착륙 — SHRUNK scope: X=1 byte-identical driver + 추출 primitives + worktree lifecycle (DARK):
codex round-1 BLOCK(slug-scope → repo_root 전략 재설계) + round-2 SHRINK(two-root split 은 write만
절반 wiring — read/acquire/release/overlap 미완 → 깨끗한 E3 경계가 낫다) 2라운드 검토 후 확정된 최소
범위. E2 delivers:
- (a) X=1 byte-identical driver:
ThreadPoolExecutor(1)+ claim→submit→future.result()→next claim = 정확한 pre-E2 직렬 순서. effective pool 은_e2_task_pool_dark_clamp_enabled()로 1 로 clamp (PR-E3 에서 이 함수+호출 제거);_resolve_task_pool_size/--task-pool/ Makefile 브리지 / fcntl gating / kill-switch 함께 착륙. per-task wall-clock budget 은effective_task_pool_size > 1일 때만 resolve — X==1 에서 env 무시 → byte-identical (Finding 5). - (b) cycle body extraction:
claim_next_task(leafthreading.Lock, select+append 감싸고write_active_start/semaphore 전에 release) +run_one_task(cycle body verbatim move;break→stop_event.set()+return,continue→return) +run_task_in_worktree(X==1→직접run_one_task; X>1→E3-deferredRuntimeError— 코드에 E3 work-list 문서화). - (c) stop_event fail-closed (Finding 1, general correctness — X==1 에서도 bounded-blocker 시
작동):
complete_if_not_stopped(task_id)헬퍼가 ALL 3 terminal completion site (local-gate / ship / repair-applied)를 guard. X==1 에서는 event 가 mid-cycle set 되지 않아 항상 True → byte-identical. - (d) worktree lifecycle primitive (Finding 4 seed-failure teardown 포함):
_task_cycle_worktree_ _paths/create_task_cycle_worktree/teardown_task_cycle_worktree/_run_cycle_in_task_worktree— 모듈레벨, injected git runner 로 모든 exit path(seed failure/blocker/exception/stop/budget) teardown 단위 테스트 완료. PR-E3 가 이 primitive 를run_task_in_worktreeX>1 분기에 wire.try/finally를create직후(seed 전)로 이동해 seed 예외도 teardown 보장 (Finding 4). X>1 는 PR-E2 에서 명시적으로 deferred —run_task_in_worktreeX>1 분기는RuntimeError로 E3 work-list 를 문서화(코드 경계 가시화); E2-dark clamp 로 runtime unreachable. - PR-E3 work-list (codex round-2 HIGH findings): leases coordination_root —
write_active_loop/write_active_start의 write 뿐 아니라_load_active_leases(read) +acquire_active_agent/release_active_agent(acquire/release) +build_overlap_preflight(overlap-preflight) 전부 coordination_root(parent) 를 사용해야 cross-task lease overlap 이 가시적. 추가: cycle worktree 의 parent branch/issue inheritance(origin/main 기반 생성, 현재 branch tied issue 미전파),run_one_task/run_repair_apply에cycle_repo_root+coordination_roottwo explicit root threading,claim_disjointfirst-writer-wins REJECT(현재 REPORT-only)._e2_task_pool_dark_clamp_enabled제거. - Open question:
claim_disjointREPORT-only — E2-dark 에서는 disjointness 가 task selection (select_next_task이attempted_this_run제외)에서 나와 문제없음. E3 가 first-writer-wins 결정.
- (a) X=1 byte-identical driver:
Resolution
- PR-F(#1948) go-live:
DEFAULT_ACTIVE_TASK_POOL을 1 → 2 로 flip 해 X-병렬 bounded-loop 실행을 기본값으로 켰다 — 이로써 X=1 기본 경로의 byte-identity(dark-brick 불변)는 의도적으로 종료된다 (MakefileACTIVE_TASK_POOL기본값도 2 로 동반 flip). X=1(직렬, ADR 0001 byte-identical)은ACTIVE_TASK_POOL=1/ kill-switch 로 여전히 사용 가능하고, 그 byte-identity 회귀는test_active_auto_loop_x1_byte_identical(양 arm 모두task_pool=1고정)이 계속 지킨다. - End-to-end X>1 증거: 이전 X>1 driver 테스트는 전부
_run_cycle_in_task_worktree를 stub 했기에 REAL worktree create→seed→confine→mirror→teardown 체인이 2-task fan-out 하에서 한 번도 실행되지 않았다. PR-F 의 신규 테스트test_x_gt_1_full_driver_real_worktree_e2e가 그 공백을 닫는다 — REAL git tmp repo +task_pool=2로 실제agent/<id>/cycleworktree 2개를 만들고(codex/gate + GitHub-side read 만 fake, 실제 PR side effect 없음), (a) 두 task 의 convergent 완료, (b) 각 task artifact 의 parent 미러링 + teardown 생존, (c) disjoint cycle branch 생성→정리, (d) ledger 무충돌 reconcile, (e) mirror-failure warning 부재(silent partial-mirror 가 성공으로 통과 못함)를 증거로 단언한다. ADR 0094 동시성 substrate 는 이미 accepted 다. - Go-live 정합성 수정(codex+architect 2026-06-04 adversarial 리뷰): flip 시점의 pre-commit
codex 8-pass 리뷰가 X=2 기본화로 새로 노출되는 2건을 제기했고, architect tie-breaker 가 코드로
판정했다. (1) REACHABLE —
_active_task_context_files가 모든 task claim set 에tasks/queue.md를 prepend 하는데assert_claimed_files_disjoint의 context-only 제외가 whole-set 단위라, 서로 다른 real 파일을 만지는 두 task 가 queue.md 로 false-overlap → reject → X>1 이 조용히 serial 로 강등됐다. per-file 제외(_is_context_only_path)로 수정 (test_disjoint_mixed_claim_compares_real_files_only+_still_blocks_real_file_conflict). (2) NOT REACHABLE — cross-process cycle-worktree 삭제는 teardown 의worktree remove가 호출자repo_root로 path-scoped + git 이 타 worktree 에 checked-out 된 branch 의-D를 거부하므로 불가하며, 암묵적이던 이 불변식을test_teardown_cannot_delete_branch_checked_out_elsewhere(real git 2-worktree)로 고정했다. - Go-live 정합성 수정 2차(codex+architect 2026-06-04, 2~3라운드): flip 을 staging 한 뒤
pre-commit codex 리뷰가 X=2 기본화 고유의 회복·격리 공백 2건(둘 다 REACHABLE) 을 더 표면화했고,
주목할 점은 그 1차 수정 자체가 다음 라운드에서 불완전으로 판정되어 2번 다듬은 것이다(“각 라운드가
새 X>1 gap 을 드러낸다”의 자기예시). (A) create-failure teardown 이 preserved/sibling cycle
worktree 를 삭제 — 직전 X>1 run 이 artifact 미러링에 실패하면
.claude/worktrees/<task>-cycle을 의도적으로 보존하는데, 같은 root 의 다음 run 이 기존 경로에서 create 실패 후 그 worktree 를 teardown → fail-closed 회복 상태가 데이터 손실로 전환. 1차 수정(create 직전cycle_path_preexisted캡처 후 pre-existing 일 때만 보존)은 probe→add TOCTOU race 가 남았다(probe 시 부재여도 sibling 이 add 직전 생성 가능 → 여전히 sibling worktree 삭제). 그래서 create 실패 시 무조건 teardown 하지 않고 보존 + recovery blocker 로 정정(git worktree prune로 out-of-band GC; 소유권 증명 불가하므로 unconditional).test_worktree_lifecycle_create_failure_fails_closed_and_preserves(path 부재) +test_worktree_lifecycle_preexisting_cycle_path_is_preserved_not_torn_down(보존 artifact 생존)으로 핀. r1-(2) 의 cross-process 삭제(NOT REACHABLE)와 달리 이건 same-root 재시도 경로라 도달 가능. (B) cycle seed 가 unscoped — X>1 cycle 이seed_scratch_worktree_from_parent를include_paths없이 호출해 parent 의 모든 dirty 파일을 각 cycle 로 복사 → X=2 에서 무관한 두 task(+로컬/staged 편집)가 서로의 미커밋 상태 상속, claimed-file disjointness 무의미화. 1차 수정(seed 를_active_task_context_files(task)로만 한정)은 claim footprint 의requested_files(=changed_files)를 누락해 task 가 자기 in-flight 작업을 못 보는 과도 축소였다. 그래서 claim 과 동일한 footprint (requested_files+ context files)를 쓰는_cycle_seed_include_paths헬퍼를 추출해run_one_task의 claim 계산(context_files)을 미러링하도록 정정(None은 legacy copy-all 보존). 회귀:test_cycle_seed_include_paths_includes_changed_files(changed_files-only 경로 포함) +test_worktree_lifecycle_seed_is_scoped_to_task_include_paths(forwarding) + 필터 자체는 기존test_seed_scratch_worktree_from_parent_can_limit_to_claimed_files. go-live flip 의 blast radius 가 dark-brick 들이 X=1 에서 잠재워 둔 latent X>1 isolation/recovery gap 들을 활성화함을 보여준다. (2차에서 informational·freq 1/8 로 남겼던 “cycle 이origin/mainbase 라 committed-but-unpushed parent-branch state 미상속”은 3차에서 blocking 으로 escalate → Option B 로 해소, 아래.) - Go-live 정합성 수정 3·4차(codex 2026-06-04, Option B + exact parity): 2차에서 informational
(freq 1/8)로 남겼던 origin/main-base 미상속이 X=2 기본 flip 의 blast-radius 하에서 3차에 freq
4/8·2/8 high 로 escalate(go-live 시 cycle 이 stale tree 에서 실행될 위험). codex 권고는 (a) cycle 을
parent HEAD/branch 기준으로 생성, 또는 (b) branch-local commit 감지 시 X=1 로 강등 + 경고였고, 사용자가
(b) Option B 를 선택했다(E3b 격리 설계 유지 + 최소 변경). 3차 1차 구현은
_git_is_ancestor("HEAD", "origin/main")즉 “HEAD 가 origin/main 을 벗어나는가”(HEAD-ahead) 만 검사했는데, 4차 리뷰가 그 ancestor 검사의 반대 방향 공백을 freq 3/8 high 로 표면화: origin/main 이 HEAD 보다 앞서면(로컬 stale) HEAD 는 새 origin/main 의 ancestor 라 검사를 통과하지만 cycle 은 더 새 코드에서 fork → stale HEAD fan-out. 최종 구현:_head_matches_origin_main(repo_root)(=rev-parse HEAD와rev-parse origin/maincommit id 등치; 기존_git_ref재사용)로 정확한 parity 검사 → 양방향(ahead/behind/ diverged) 모두 강등.write_active_auto_loop시작 가드(fcntl clamp 와 동격 correctness 가드 — 명시적 knob 값에도 적용; FAIL-SAFE: 어느 ref 든 미해결 → None → 강등). 회귀 4종:test_head_matches_origin_main_true_only_when_exactly_equal(real git: equal→True / HEAD-ahead→False [round-3 sentinel] / origin/main-ahead→False [round-4; ancestor 검사는 통과함을 같은 테스트에서 대조]),test_head_matches_origin_main_false_when_origin_main_unresolved(fail-safe),test_x_gt_1_demotes_to_serial_when_head_ahead_of_origin_main(real git: HEAD ahead + task_pool=2 → 강등 경고 + cycle 0개 + 각 task 가 committed sentinel parent tree 에서 실행),test_x_gt_1_demotes_to_serial_when_origin_main_ahead_of_head(real git: origin/main ahead + task_pool=2 → 강등 경고 + cycle 0개; behind checkout 은 기존 stale-base overlap-preflight 가 runner 전에 추가로 block = defense-in-depth). dispatch 테스트의 X>1 precondition 은_patch_active_loop_clear가_head_matches_origin_main→True로 명시 greenlight 한다(이전엔 faked subprocess 에 암묵 의존). round-3 freq-1/8 MEDIUM(“Accepted ADR 가 여전히 X=dark serial 이라 기술”)은 Decision lead 를 X=2 기본· X=1 serial override 로 재작성해 해소.
Verification
python3 -m pytest -q tests/test_agent_loop.py -k 'task_pool or omc_multi or global_concurrency or convergent or e2e or lifecycle or origin_main'
python3 -m pytest -q tests/test_agent_loop_worktree_confinement.py -k 'disjoint or teardown'
python3 scripts/_governance.py --check-adr-readme-parity docs/adr/0095-task-parallel-bounded-loop.md
git diff --check