docs(stage2): S2_10 v5/v6 analyses, strategy v.5 and backups from 2026-10-05 session
Add the S2_10 failure analyses (v4 gate stop, Gitea AuthGate 401, v6 slot delivery and localdocs cache poisoning), the SKILL.md/gpt compatibility report, the provider budget and reasoning check, revision strategy v.5, the v4 and v5.0.0 YAML backups, and the Stage 2 MEMORY entries. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+117
@@ -0,0 +1,117 @@
|
||||
# Stage_2_S2_10 v4 실행 실패 분석 (SLOT_PREFLIGHT_RUNTIME_UNVERIFIED)
|
||||
|
||||
## 1. 결론
|
||||
|
||||
**이번 실패는 런타임 오류가 아니다. YAML이 일부러 넣어 둔 실행 차단 장치(fail-closed gate)가 설계대로 작동한 결과다.**
|
||||
|
||||
`Task_S2_10_prepare_provisional_bundles`(stage `S2_10_prepare`)는 upstream 검증부터 묶음·slot 준비까지 정상 수행했다. 그러나 마지막 admission 단계에서 하드코딩 상수 `BACKEND_SLOT_PREFLIGHT_VERIFIED = False`를 만났다. 그래서 모델 작업 목록을 비우고 `TECHNICAL_INCOMPLETE` 상태를 발행한 뒤 `ok:false`를 반환했다. `receipt_main`은 `ok:false`를 exit code 2로 바꾸고, backend는 이를 `run_code` 도구 실패로 기록했다. 따라서 다음 stage `S2_10`(LLM map `Task_S2_10_assess_bundle`와 reducer `Task_S2_10_validate_publish_bundles`)은 실행되지 않았다.
|
||||
|
||||
현행 YAML을 그대로 다시 실행해도 같은 결과가 나온다. 입력·workspace·인증 상태와 무관하게, 코드가 이 경로만 허용하도록 고정되어 있기 때문이다.
|
||||
|
||||
| 구분 | 판정 |
|
||||
|---|---|
|
||||
| 실패 위치 | stage 0 `S2_10_prepare` → `Task_S2_10_prepare_provisional_bundles` → `prepare()` 말미 admission 분기 ([Stage_2_S2_10.yml:856-859](Stage_2_S2_10.yml#L856-L859)) |
|
||||
| 직접 원인 | `BACKEND_SLOT_PREFLIGHT_VERIFIED = False` ([Stage_2_S2_10.yml:132](Stage_2_S2_10.yml#L132)) |
|
||||
| exit code 2의 출처 | `receipt_main`의 `return 0 if value.get('ok') else 2` ([Stage_2_S2_10.yml:293-306](Stage_2_S2_10.yml#L293-L306)) |
|
||||
| 실패 성격 | 의도된 기술 미완료(TECHNICAL_INCOMPLETE). 예외·crash·LLM 오류 아님 |
|
||||
| 실행되지 않은 것 | LLM map, reducer, `assessments/batch-NNNN.json` |
|
||||
| 대상 YAML | 145,115 bytes, SHA-256 `e4148f6d5d4c285c4d0e7523b87d9b1f689b12717be681a060e59a8d3a2d04fa`. 직전 분석 [Fail_Analysis_Stage_2_S2_10.md](Fail_Analysis_Stage_2_S2_10.md)의 대상과 같은 bytes |
|
||||
|
||||
## 2. 로그 해석: 어느 코드 경로에서 나온 출력인가
|
||||
|
||||
관측 로그:
|
||||
|
||||
```
|
||||
[FAILED] MCP tool 'run_code' 실행 실패 (exit_code=2): {"error":{"code":"SLOT_PREFLIGHT_RUNTIME_UNVERIFIED"},"map_items":0,"ok":false,"output_root":"stage2_runs/from-stage1/s2_10/v4","plan_sha256":"936c5c254725ba22fa920e5b921c11f776dfd29b7df5b72f2b907f8e6ed59851","status":"TECHNICAL_INCOMPLETE"}
|
||||
```
|
||||
|
||||
prepare 코드에서 `SLOT_PREFLIGHT_RUNTIME_UNVERIFIED`가 나올 수 있는 경로는 두 곳이다. 출력 JSON의 모양으로 둘 중 어느 쪽인지 구별된다.
|
||||
|
||||
| 경로 | 위치 | 출력 모양 | 이번 로그와 일치 여부 |
|
||||
|---|---|---|---|
|
||||
| A. 중단된 batch 재생(inflight replay) 중 `require` 실패 | [L810](Stage_2_S2_10.yml#L810) → `Failure` 예외 → `receipt_main` except 분기 | `{"ok":false,"status":"TECHNICAL_INCOMPLETE","error":{"code":…,"detail":""}}`. `output_root`, `plan_sha256`, `map_items` 없음. `error.detail` 있음 | **불일치** |
|
||||
| B. 정상 준비 후 admission gate | [L855-859](Stage_2_S2_10.yml#L855-L859) → 정상 `return` | `ok`, `status`, `output_root`, `map_items:0`, `plan_sha256`, `error:{code}`. `detail` 없음 | **일치** |
|
||||
|
||||
따라서 이번 실패는 **경로 B**에서 나왔다. 이 판정에서 아래 사실이 따라 나온다(코드 순서에 근거한 추론이며, workspace 파일을 직접 열어 확인한 것은 아니다).
|
||||
|
||||
1. **backend 인증·workspace 해석은 통과했다.** 직전 run 1412의 `workspaces/lookup HTTP 401`은 이번에는 재발하지 않았다. `Localdocs()` 초기화(`__user_hash__`/`__workspace_hash__` 64자리 hex 확인, MCP initialize)도 성공했다.
|
||||
2. **upstream S2_00 v6 검증을 통과했다.** `read_upstream`의 `READY/READY_WITH_ISSUES`, 버전, `WORKSPACE_EXECUTION_TEST`, 4개 artifact 집합과 hash·header 일치 검사가 모두 성공했다.
|
||||
3. **Stage 1 sealed source, profile digest, catalog·membership·분할 계산이 성공했다.**
|
||||
4. **대기 중 묶음은 slot 파일로 기록되었을 수 있다.** gate 판정([L855](Stage_2_S2_10.yml#L855))이 slot 기록 루프([L826-850](Stage_2_S2_10.yml#L826-L850)) **뒤에** 있다. 대기 묶음이 하나 이상이었다면 `work/llm_input/slot-NN.json`이 이미 써졌다. 이후 `plan['items']=[]`로 비워졌으므로 이 파일들은 참조되지 않는 잔여물이다.
|
||||
5. **`publish_state`가 세 파일을 기록하고 read-back까지 성공했다.** `claims/claim_records.json`, `work/bundle_plan.json`(items 빈 상태), `s2_10_status.json`(`status=TECHNICAL_INCOMPLETE`, `technical_reason=SLOT_PREFLIGHT_RUNTIME_UNVERIFIED`)이다. 로그의 `plan_sha256 936c5c25…`는 이 빈 items 계획 파일의 hash다.
|
||||
|
||||
## 3. 왜 이 gate가 존재하는가
|
||||
|
||||
이 상수는 실수로 남은 값이 아니다. v4-1 전략에서 명시적으로 요구한 안전장치다.
|
||||
|
||||
- YAML metadata `runtime_admission.slot_preflight`: "False until actual dynamic-map preflight verification; default TECHNICAL_INCOMPLETE without model items" ([L36](Stage_2_S2_10.yml#L36))
|
||||
- 코드 주석: "Change only after actual AgentBackend map-slot/zero-map tests, not after local fixture success." ([L131](Stage_2_S2_10.yml#L131))
|
||||
- [S2_10_revision_strategy_v.4-1.md:158](S2_10_revision_strategy_v.4-1.md#L158): slot 전달 경로를 실제 backend에서 확인하고, "이 경로가 검증되지 않으면 기술 미완료로 보고"
|
||||
- [Analysis_Stage_2_S2_10_v.4.md:9](Analysis_Stage_2_S2_10_v.4.md#L9), [L246](Analysis_Stage_2_S2_10_v.4.md#L246): "upstream과 I/O 검증이 성공하더라도 prepare는 모델 작업을 비우고 `TECHNICAL_INCOMPLETE`를 발행한다." 이번 실행은 이 예고와 정확히 같다.
|
||||
|
||||
검증 대상은 S2_10 map task의 입력 전달 방식이다. 모델에게는 user 메시지로 작은 descriptor(`bundle_ref`, `input_path`)만 주고, 실제 자료 본문은 `preflight: true` + `preflight_files: ['{{item.input_path}}']`로 backend가 slot 파일을 읽어 넣어 주기를 기대한다. [SKILL.md:288-307](../../../SKILL.md#L288-L307)은 동적 `{{item.*}}` preflight를 문서화하지만 예시와 cache 설명은 Gemini 경로 위주다. 이 map은 `openai / gpt-6.1-sol / responses` 경로다. 아래 사항이 실제 backend에서 확인되지 않았다.
|
||||
|
||||
- `{{item.input_path}}`가 fan-out 인스턴스별로 올바르게 치환되는가
|
||||
- 해당 slot이 OpenAI Responses 요청에 실제로 삽입되는가, 크기 제한·잘림은 없는가
|
||||
- `preflight_files` 화이트리스트 밖의 파일명(prompt 본문 regex 스캔)이 추가로 삽입되지 않는가
|
||||
- 같은 본문이 중복 삽입되지 않는가
|
||||
|
||||
이 경로가 틀리면 모델은 본문 없이 descriptor만 보고 판단한다. 그러면 schema는 통과하지만 근거 없는 법률 판단이 생성될 수 있다. gate는 이 위험을 막으려고 모델 호출 전에 실행을 멈춘다.
|
||||
|
||||
## 4. gate는 하나가 아니라 연쇄다
|
||||
|
||||
`BACKEND_SLOT_PREFLIGHT_VERIFIED` 하나만 True로 바꾸면 다음 gate에서 다시 멈춘다.
|
||||
|
||||
| 순서 | 조건 | 위치 | 실패 코드 |
|
||||
|---|---|---|---|
|
||||
| 1 | `BACKEND_SLOT_PREFLIGHT_VERIFIED` | prepare [L132](Stage_2_S2_10.yml#L132), [L856](Stage_2_S2_10.yml#L856) | `SLOT_PREFLIGHT_RUNTIME_UNVERIFIED` ← **이번 실패** |
|
||||
| 2 | `MODEL_BUDGET.provider_capacity_verified` and `reasoning_application_verified` | prepare [L130](Stage_2_S2_10.yml#L130), [L854-858](Stage_2_S2_10.yml#L854-L858) | `MODEL_BUDGET_RUNTIME_UNVERIFIED` |
|
||||
| 3 | items가 0개일 때 `BACKEND_EMPTY_MAP_REDUCER_VERIFIED` | prepare [L133](Stage_2_S2_10.yml#L133), [L856](Stage_2_S2_10.yml#L856) | `EMPTY_MAP_REDUCER_RUNTIME_UNVERIFIED` |
|
||||
| 4 | reducer에서 1·2 재검사 | reducer [L967](Stage_2_S2_10.yml#L967), [L1367-1368](Stage_2_S2_10.yml#L1367-L1368) | 위와 동일 |
|
||||
| 5 | 빈 map reducer 재검사 | reducer [L968](Stage_2_S2_10.yml#L968), [L1376](Stage_2_S2_10.yml#L1376) | `EMPTY_MAP_RUNTIME_OR_RECEIPT_INVALID` |
|
||||
|
||||
상수는 prepare 코드 블록([L130-133](Stage_2_S2_10.yml#L130-L133))과 reducer 코드 블록([L965-968](Stage_2_S2_10.yml#L965-L968))에 **각각 따로** 정의되어 있다. 한쪽만 고치면 prepare는 통과하지만, 모델 호출 비용을 쓴 뒤 reducer에서 실패한다.
|
||||
|
||||
## 5. 재실행 전에 알아야 할 부수 문제
|
||||
|
||||
### 5.1 MODEL_BUDGET 변경 시 `FIXED_PLAN_OR_SOURCE_CHANGED`로 막힘 (중요)
|
||||
|
||||
`MODEL_BUDGET` 전체(검증 플래그 포함)가 계약 `contract.model_budget`에 들어가고([L790](Stage_2_S2_10.yml#L790)), 이 계약이 `input_fingerprint`를 결정한다. 이번 실행으로 `s2_10/v4/work/bundle_plan.json`이 이미 존재한다. 그 상태에서 플래그를 True로 바꿔 재실행하면 fingerprint가 달라진다. 그러면 [L806](Stage_2_S2_10.yml#L806)의 `require(old_plan['input_fingerprint']==fingerprint …,'FIXED_PLAN_OR_SOURCE_CHANGED')`에서 실패한다. 그 검사를 지나더라도 `read_results`의 `EXISTING_OUTPUT_BINDING_CONFLICT`([L311](Stage_2_S2_10.yml#L311))가 기존 `s2_10_status.json`과 충돌한다.
|
||||
|
||||
`BACKEND_SLOT_PREFLIGHT_VERIFIED`는 계약에 들어가지 않으므로 이 문제가 없다. 결국 gate 2를 해제하는 순간 같은 output root의 이전 TECHNICAL_INCOMPLETE 산출물이 재실행을 막는다. 다음 중 하나가 필요하다.
|
||||
|
||||
- 이번에 생성된 `stage2_runs/from-stage1/s2_10/v4/` 아래 `work/bundle_plan.json`, `s2_10_status.json`, `claims/claim_records.json`, `work/llm_input/slot-*.json`을 보관 위치로 옮기거나 정리
|
||||
- 또는 검증 플래그를 fingerprint 계약에서 빼고, 실제 예산 수치(context/output/reasoning)만 계약에 남기도록 YAML 수정 (설계 변경이므로 전략 문서 갱신 필요)
|
||||
|
||||
### 5.2 gate 위치가 slot 기록 뒤에 있음
|
||||
|
||||
admission 판정이 slot 기록 이후에 있어서, 차단되는 실행에서도 slot 파일 쓰기와 read-back I/O가 수행되고 잔여 파일이 남는다. 동작 오류는 아니다. 다음 실행은 slot을 같은 이름으로 다시 쓰고 `write_revision(work=True)`로 덮어쓰므로 무결성 문제도 없다. 다만 gate 조건이 입력과 무관한 상수이므로, 판정을 `prepare()` 앞부분(upstream 읽기 직후)으로 옮겨도 의미가 바뀌지 않는다. 옮기면 불필요한 I/O를 줄일 수 있다. 단, 현재 위치는 "준비 경로 전체는 실제로 돌아간다"는 것을 확인하는 효과도 있으므로, 옮길지는 선택 사항이다.
|
||||
|
||||
### 5.3 exit code 2는 의도된 중단 신호
|
||||
|
||||
`ok:false` → exit 2 → backend `[FAILED]`는 의도된 흐름이다. exit 0으로 바꾸면 backend는 `S2_10` stage로 진행한다. 그러면 빈 `items`로 map이 돌고, 미검증 상태인 빈 map reducer 동작(gate 3)에 의존하게 된다. 현행 설계에서 exit 2로 세션을 멈추는 것이 맞다. 다만 backend 로그만 보면 crash와 설계된 차단이 구별되지 않으므로, 운영자는 stdout JSON의 `error.code`와 `output_root` 유무(§2의 A/B 구분)로 판정해야 한다.
|
||||
|
||||
## 6. 이번 실패의 원인이 아닌 것
|
||||
|
||||
| 항목 | 근거 |
|
||||
|---|---|
|
||||
| backend 인증(직전 run 1412의 401) | Localdocs 초기화와 upstream 읽기가 성공해야 경로 B에 도달함 |
|
||||
| upstream S2_00 v6 미완료·hash 불일치 | 해당 `require`가 실패했다면 다른 코드(`UPSTREAM_*`, `SOURCE_HASH_MISMATCH` 등)가 나왔어야 함 |
|
||||
| LLM 오류·모델 품질·schema 위반 | LLM map stage에 도달하지 않음 |
|
||||
| `read_docs`/TaskGroup 오류(v2 시절) | map source 단계에 도달하지 않음 |
|
||||
| 입력 크기 초과 | 그 경우 `input_blocked`로 기록되고 다른 경로·코드가 됨 |
|
||||
|
||||
## 7. 해소 절차 (권장, 이번 작업에서는 수행하지 않음)
|
||||
|
||||
1. **동적 slot preflight 실측 probe.** S2_10과 같은 provider·endpoint·map 구조를 가진 최소 YAML로 확인한다. 예: descriptor 2~3개, `preflight_files: ['{{item.input_path}}']`, slot마다 서로 다른 canary 문자열. 모델이 자기 slot의 canary만 되돌려 주는지, backend 로그(`docker logs agent-backend`의 preflight/read_docs 기록)에서 인스턴스별 파일 1회 삽입, 화이트리스트 외 삽입 없음, 크기 잘림 없음을 확인한다.
|
||||
2. **빈 map reducer probe.** items가 0개인 map에서 reducer가 호출되는지, `map_results_b64`가 `[]`로 오는지 확인한다.
|
||||
3. **provider 예산·reasoning 적용 확인.** `gpt-6.1-sol` Responses 경로에서 `configured_context_budget_tokens=262144`가 실제 한도 안인지, `reasoning=xhigh`가 실제로 적용되는지(응답 usage의 reasoning token 등) 확인한다.
|
||||
4. **상수 갱신.** 확인된 항목만 prepare([L130-133](Stage_2_S2_10.yml#L130-L133))와 reducer([L965-968](Stage_2_S2_10.yml#L965-L968)) **양쪽 모두** 같은 값으로 바꾼다. metadata `runtime_admission`, `verification_record`도 실측 근거와 함께 갱신한다. 확인하지 않은 채 True로 바꾸는 것은 검증을 대신하지 못한다.
|
||||
5. **기존 산출물 처리.** §5.1에 따라 `s2_10/v4`의 이번 TECHNICAL_INCOMPLETE 산출물을 정리하거나, fingerprint 계약 구조를 조정한다.
|
||||
6. **재실행과 완료 판정.** prepare `BUNDLE_BATCH_PREPARED` → map → reducer `PUBLISHED_STATUS_LAST`를 확인한다. `NEXT_WAVE_PENDING`이면 같은 snapshot으로 순차 재호출한다. 최종 `s2_10_status.json`의 `COMPLETED` 또는 `COMPLETED_WITH_ISSUES`와 artifact hash read-back으로 완료를 판정한다. S2_20과의 handoff 호환은 별도 검증 대상이다.
|
||||
|
||||
## 8. 분석 근거와 한계
|
||||
|
||||
- 근거: 사용자가 제공한 backend 실행 로그 1줄, 현행 YAML 코드(위 행 번호), v4-1 전략·v4 분석 문서, [SKILL.md](../../../SKILL.md)의 preflight 설명.
|
||||
- Gitea Actions 최근 run 목록에는 이번 실행이 없다(최신은 2026-10-04 23:35 KST의 run 1412, 401 실패). 이번 실행은 Actions 경로가 아닌 다른 API 호출 경로로 수행된 것으로 보이며, 해당 session ID·전체 SSE 이벤트·workspace 파일은 직접 확인하지 않았다. §2의 "통과한 단계"와 "기록된 파일"은 출력 모양과 코드 순서에서 도출한 추론이다.
|
||||
- YAML·상수·workspace 산출물은 변경하지 않았다.
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
# Stage_2_S2_10 v4 실행 실패 분석 (SLOT_PREFLIGHT_RUNTIME_UNVERIFIED) — v.1
|
||||
|
||||
> v.1 정정(2026-10-05): [SKILL_compatibility_with_gpt.md](SKILL_compatibility_with_gpt.md)의 backend 코드 확인 결과를 반영했다. §3(slot 전달 실패 원인: provider 차이가 아니라 map_reduce 실행 모드), §7(해소 절차: 구조 변경을 선행 조건으로), §8(근거와 한계)을 고쳤다. §1·§2·§4·§5·§6은 초판과 같다. 초판은 [Analysis_failure_S2_10_fable.md](Analysis_failure_S2_10_fable.md)에 그대로 보존했다.
|
||||
|
||||
## 1. 결론
|
||||
|
||||
**이번 실패는 런타임 오류가 아니다. YAML이 일부러 넣어 둔 실행 차단 장치(fail-closed gate)가 설계대로 작동한 결과다.**
|
||||
|
||||
`Task_S2_10_prepare_provisional_bundles`(stage `S2_10_prepare`)는 upstream 검증부터 묶음·slot 준비까지 정상 수행했다. 그러나 마지막 admission 단계에서 하드코딩 상수 `BACKEND_SLOT_PREFLIGHT_VERIFIED = False`를 만났다. 그래서 모델 작업 목록을 비우고 `TECHNICAL_INCOMPLETE` 상태를 발행한 뒤 `ok:false`를 반환했다. `receipt_main`은 `ok:false`를 exit code 2로 바꾸고, backend는 이를 `run_code` 도구 실패로 기록했다. 따라서 다음 stage `S2_10`(LLM map `Task_S2_10_assess_bundle`와 reducer `Task_S2_10_validate_publish_bundles`)은 실행되지 않았다.
|
||||
|
||||
현행 YAML을 그대로 다시 실행해도 같은 결과가 나온다. 입력·workspace·인증 상태와 무관하게, 코드가 이 경로만 허용하도록 고정되어 있기 때문이다.
|
||||
|
||||
| 구분 | 판정 |
|
||||
|---|---|
|
||||
| 실패 위치 | stage 0 `S2_10_prepare` → `Task_S2_10_prepare_provisional_bundles` → `prepare()` 말미 admission 분기 ([Stage_2_S2_10.yml:856-859](Stage_2_S2_10.yml#L856-L859)) |
|
||||
| 직접 원인 | `BACKEND_SLOT_PREFLIGHT_VERIFIED = False` ([Stage_2_S2_10.yml:132](Stage_2_S2_10.yml#L132)) |
|
||||
| exit code 2의 출처 | `receipt_main`의 `return 0 if value.get('ok') else 2` ([Stage_2_S2_10.yml:293-306](Stage_2_S2_10.yml#L293-L306)) |
|
||||
| 실패 성격 | 의도된 기술 미완료(TECHNICAL_INCOMPLETE). 예외·crash·LLM 오류 아님 |
|
||||
| 실행되지 않은 것 | LLM map, reducer, `assessments/batch-NNNN.json` |
|
||||
| 대상 YAML | 145,115 bytes, SHA-256 `e4148f6d5d4c285c4d0e7523b87d9b1f689b12717be681a060e59a8d3a2d04fa`. 직전 분석 [Fail_Analysis_Stage_2_S2_10.md](Fail_Analysis_Stage_2_S2_10.md)의 대상과 같은 bytes |
|
||||
|
||||
## 2. 로그 해석: 어느 코드 경로에서 나온 출력인가
|
||||
|
||||
관측 로그:
|
||||
|
||||
```
|
||||
[FAILED] MCP tool 'run_code' 실행 실패 (exit_code=2): {"error":{"code":"SLOT_PREFLIGHT_RUNTIME_UNVERIFIED"},"map_items":0,"ok":false,"output_root":"stage2_runs/from-stage1/s2_10/v4","plan_sha256":"936c5c254725ba22fa920e5b921c11f776dfd29b7df5b72f2b907f8e6ed59851","status":"TECHNICAL_INCOMPLETE"}
|
||||
```
|
||||
|
||||
prepare 코드에서 `SLOT_PREFLIGHT_RUNTIME_UNVERIFIED`가 나올 수 있는 경로는 두 곳이다. 출력 JSON의 모양으로 둘 중 어느 쪽인지 구별된다.
|
||||
|
||||
| 경로 | 위치 | 출력 모양 | 이번 로그와 일치 여부 |
|
||||
|---|---|---|---|
|
||||
| A. 중단된 batch 재생(inflight replay) 중 `require` 실패 | [L810](Stage_2_S2_10.yml#L810) → `Failure` 예외 → `receipt_main` except 분기 | `{"ok":false,"status":"TECHNICAL_INCOMPLETE","error":{"code":…,"detail":""}}`. `output_root`, `plan_sha256`, `map_items` 없음. `error.detail` 있음 | **불일치** |
|
||||
| B. 정상 준비 후 admission gate | [L855-859](Stage_2_S2_10.yml#L855-L859) → 정상 `return` | `ok`, `status`, `output_root`, `map_items:0`, `plan_sha256`, `error:{code}`. `detail` 없음 | **일치** |
|
||||
|
||||
따라서 이번 실패는 **경로 B**에서 나왔다. 이 판정에서 아래 사실이 따라 나온다(코드 순서에 근거한 추론이며, workspace 파일을 직접 열어 확인한 것은 아니다).
|
||||
|
||||
1. **backend 인증·workspace 해석은 통과했다.** 직전 run 1412의 `workspaces/lookup HTTP 401`은 이번에는 재발하지 않았다. `Localdocs()` 초기화(`__user_hash__`/`__workspace_hash__` 64자리 hex 확인, MCP initialize)도 성공했다.
|
||||
2. **upstream S2_00 v6 검증을 통과했다.** `read_upstream`의 `READY/READY_WITH_ISSUES`, 버전, `WORKSPACE_EXECUTION_TEST`, 4개 artifact 집합과 hash·header 일치 검사가 모두 성공했다.
|
||||
3. **Stage 1 sealed source, profile digest, catalog·membership·분할 계산이 성공했다.**
|
||||
4. **대기 중 묶음은 slot 파일로 기록되었을 수 있다.** gate 판정([L855](Stage_2_S2_10.yml#L855))이 slot 기록 루프([L826-850](Stage_2_S2_10.yml#L826-L850)) **뒤에** 있다. 대기 묶음이 하나 이상이었다면 `work/llm_input/slot-NN.json`이 이미 써졌다. 이후 `plan['items']=[]`로 비워졌으므로 이 파일들은 참조되지 않는 잔여물이다.
|
||||
5. **`publish_state`가 세 파일을 기록하고 read-back까지 성공했다.** `claims/claim_records.json`, `work/bundle_plan.json`(items 빈 상태), `s2_10_status.json`(`status=TECHNICAL_INCOMPLETE`, `technical_reason=SLOT_PREFLIGHT_RUNTIME_UNVERIFIED`)이다. 로그의 `plan_sha256 936c5c25…`는 이 빈 items 계획 파일의 hash다.
|
||||
|
||||
## 3. 왜 이 gate가 존재하는가
|
||||
|
||||
이 상수는 실수로 남은 값이 아니다. v4-1 전략에서 명시적으로 요구한 안전장치다.
|
||||
|
||||
- YAML metadata `runtime_admission.slot_preflight`: "False until actual dynamic-map preflight verification; default TECHNICAL_INCOMPLETE without model items" ([L36](Stage_2_S2_10.yml#L36))
|
||||
- 코드 주석: "Change only after actual AgentBackend map-slot/zero-map tests, not after local fixture success." ([L131](Stage_2_S2_10.yml#L131))
|
||||
- [S2_10_revision_strategy_v.4-1.md:158](S2_10_revision_strategy_v.4-1.md#L158): slot 전달 경로를 실제 backend에서 확인하고, "이 경로가 검증되지 않으면 기술 미완료로 보고"
|
||||
- [Analysis_Stage_2_S2_10_v.4.md:9](Analysis_Stage_2_S2_10_v.4.md#L9), [L246](Analysis_Stage_2_S2_10_v.4.md#L246): "upstream과 I/O 검증이 성공하더라도 prepare는 모델 작업을 비우고 `TECHNICAL_INCOMPLETE`를 발행한다." 이번 실행은 이 예고와 정확히 같다.
|
||||
|
||||
검증 대상은 S2_10 map task의 입력 전달 방식이다. 모델에게는 user 메시지로 작은 descriptor(`bundle_ref`, `input_path`)만 주고, 실제 자료 본문은 `preflight: true` + `preflight_files: ['{{item.input_path}}']`로 backend가 slot 파일을 읽어 넣어 주기를 기대한다([Stage_2_S2_10.yml:891-900](Stage_2_S2_10.yml#L891-L900)).
|
||||
|
||||
**운영 backend 코드를 확인한 결과, 현재 구조에서는 이 전달이 일어나지 않는다.** 원인은 provider(Gemini/OpenAI)가 아니라 실행 모드다. 상세 근거는 [SKILL_compatibility_with_gpt.md](SKILL_compatibility_with_gpt.md)에 있다.
|
||||
|
||||
- SKILL.md의 동적 `{{item.*}}` preflight 예시([SKILL.md:245-261](../../../SKILL.md#L245-L261), 설명 [L307](../../../SKILL.md#L307))는 `task_procedure` wildcard fan-out(`Task_X_*`) 방식의 예시다. S2_10은 `map_reduce`를 쓴다.
|
||||
- 운영 backend(`agent-backend`의 `/app/src/agent.py`, 2026-10-01 배포본)에서 `preflight_files`를 Task에 넘기는 곳은 `task_procedure` 경로의 `_create_task_from_config`뿐이다. map_reduce 경로의 `MapReduceExecutor._create_task`는 이 필드를 넘기지 않는다. 따라서 S2_10의 `preflight_files`는 무시된다. `{{item.input_path}}` 치환 자체는 일어나지만 그 값을 쓰는 곳이 없다.
|
||||
- preflight는 Task에 MCP manager가 있을 때만 실행된다(agent.py:1261). map_reduce에서는 `use_tools`와 `tools`가 모두 없으면 manager가 `None`이 된다(agent.py:2990-2996). S2_10 map task에는 둘 다 없으므로 **preflight 자체가 건너뛰어진다.** 화이트리스트 밖 regex 스캔이나 중복 삽입 문제도 생기지 않는다. 애초에 preflight가 돌지 않기 때문이다.
|
||||
- 참고로 전달 경로가 생겼을 때 적용되는 상한은 파일당 50,000자, 합계 200,000자, 최대 30개다. 넘는 파일은 잘려 들어가는 것이 아니라 **통째로 빠지고 warning만 남는다.** slot 상한 47,125 bytes는 이 범위 안이다.
|
||||
|
||||
결과적으로 현행 구조에서 모델은 **반드시** 본문 없이 descriptor만 받는다. 그런데 system prompt는 "preflight로 제공된 slot JSON 전체가 이번 입력이다"라고 지시하므로, 모델은 존재하지 않는 입력을 전제로 schema만 맞춘 근거 없는 법률 판단을 생성하게 된다. gate는 가능성이 아니라 이미 확정된 이 결함을 모델 호출 전에 막고 있다.
|
||||
|
||||
## 4. gate는 하나가 아니라 연쇄다
|
||||
|
||||
`BACKEND_SLOT_PREFLIGHT_VERIFIED` 하나만 True로 바꾸면 다음 gate에서 다시 멈춘다.
|
||||
|
||||
| 순서 | 조건 | 위치 | 실패 코드 |
|
||||
|---|---|---|---|
|
||||
| 1 | `BACKEND_SLOT_PREFLIGHT_VERIFIED` | prepare [L132](Stage_2_S2_10.yml#L132), [L856](Stage_2_S2_10.yml#L856) | `SLOT_PREFLIGHT_RUNTIME_UNVERIFIED` ← **이번 실패** |
|
||||
| 2 | `MODEL_BUDGET.provider_capacity_verified` and `reasoning_application_verified` | prepare [L130](Stage_2_S2_10.yml#L130), [L854-858](Stage_2_S2_10.yml#L854-L858) | `MODEL_BUDGET_RUNTIME_UNVERIFIED` |
|
||||
| 3 | items가 0개일 때 `BACKEND_EMPTY_MAP_REDUCER_VERIFIED` | prepare [L133](Stage_2_S2_10.yml#L133), [L856](Stage_2_S2_10.yml#L856) | `EMPTY_MAP_REDUCER_RUNTIME_UNVERIFIED` |
|
||||
| 4 | reducer에서 1·2 재검사 | reducer [L967](Stage_2_S2_10.yml#L967), [L1367-1368](Stage_2_S2_10.yml#L1367-L1368) | 위와 동일 |
|
||||
| 5 | 빈 map reducer 재검사 | reducer [L968](Stage_2_S2_10.yml#L968), [L1376](Stage_2_S2_10.yml#L1376) | `EMPTY_MAP_RUNTIME_OR_RECEIPT_INVALID` |
|
||||
|
||||
상수는 prepare 코드 블록([L130-133](Stage_2_S2_10.yml#L130-L133))과 reducer 코드 블록([L965-968](Stage_2_S2_10.yml#L965-L968))에 **각각 따로** 정의되어 있다. 한쪽만 고치면 prepare는 통과하지만, 모델 호출 비용을 쓴 뒤 reducer에서 실패한다.
|
||||
|
||||
## 5. 재실행 전에 알아야 할 부수 문제
|
||||
|
||||
### 5.1 MODEL_BUDGET 변경 시 `FIXED_PLAN_OR_SOURCE_CHANGED`로 막힘 (중요)
|
||||
|
||||
`MODEL_BUDGET` 전체(검증 플래그 포함)가 계약 `contract.model_budget`에 들어가고([L790](Stage_2_S2_10.yml#L790)), 이 계약이 `input_fingerprint`를 결정한다. 이번 실행으로 `s2_10/v4/work/bundle_plan.json`이 이미 존재한다. 그 상태에서 플래그를 True로 바꿔 재실행하면 fingerprint가 달라진다. 그러면 [L806](Stage_2_S2_10.yml#L806)의 `require(old_plan['input_fingerprint']==fingerprint …,'FIXED_PLAN_OR_SOURCE_CHANGED')`에서 실패한다. 그 검사를 지나더라도 `read_results`의 `EXISTING_OUTPUT_BINDING_CONFLICT`([L311](Stage_2_S2_10.yml#L311))가 기존 `s2_10_status.json`과 충돌한다.
|
||||
|
||||
`BACKEND_SLOT_PREFLIGHT_VERIFIED`는 계약에 들어가지 않으므로 이 문제가 없다. 결국 gate 2를 해제하는 순간 같은 output root의 이전 TECHNICAL_INCOMPLETE 산출물이 재실행을 막는다. 다음 중 하나가 필요하다.
|
||||
|
||||
- 이번에 생성된 `stage2_runs/from-stage1/s2_10/v4/` 아래 `work/bundle_plan.json`, `s2_10_status.json`, `claims/claim_records.json`, `work/llm_input/slot-*.json`을 보관 위치로 옮기거나 정리
|
||||
- 또는 검증 플래그를 fingerprint 계약에서 빼고, 실제 예산 수치(context/output/reasoning)만 계약에 남기도록 YAML 수정 (설계 변경이므로 전략 문서 갱신 필요)
|
||||
|
||||
### 5.2 gate 위치가 slot 기록 뒤에 있음
|
||||
|
||||
admission 판정이 slot 기록 이후에 있어서, 차단되는 실행에서도 slot 파일 쓰기와 read-back I/O가 수행되고 잔여 파일이 남는다. 동작 오류는 아니다. 다음 실행은 slot을 같은 이름으로 다시 쓰고 `write_revision(work=True)`로 덮어쓰므로 무결성 문제도 없다. 다만 gate 조건이 입력과 무관한 상수이므로, 판정을 `prepare()` 앞부분(upstream 읽기 직후)으로 옮겨도 의미가 바뀌지 않는다. 옮기면 불필요한 I/O를 줄일 수 있다. 단, 현재 위치는 "준비 경로 전체는 실제로 돌아간다"는 것을 확인하는 효과도 있으므로, 옮길지는 선택 사항이다.
|
||||
|
||||
### 5.3 exit code 2는 의도된 중단 신호
|
||||
|
||||
`ok:false` → exit 2 → backend `[FAILED]`는 의도된 흐름이다. exit 0으로 바꾸면 backend는 `S2_10` stage로 진행한다. 그러면 빈 `items`로 map이 돌고, 미검증 상태인 빈 map reducer 동작(gate 3)에 의존하게 된다. 현행 설계에서 exit 2로 세션을 멈추는 것이 맞다. 다만 backend 로그만 보면 crash와 설계된 차단이 구별되지 않으므로, 운영자는 stdout JSON의 `error.code`와 `output_root` 유무(§2의 A/B 구분)로 판정해야 한다.
|
||||
|
||||
## 6. 이번 실패의 원인이 아닌 것
|
||||
|
||||
| 항목 | 근거 |
|
||||
|---|---|
|
||||
| backend 인증(직전 run 1412의 401) | Localdocs 초기화와 upstream 읽기가 성공해야 경로 B에 도달함 |
|
||||
| upstream S2_00 v6 미완료·hash 불일치 | 해당 `require`가 실패했다면 다른 코드(`UPSTREAM_*`, `SOURCE_HASH_MISMATCH` 등)가 나왔어야 함 |
|
||||
| LLM 오류·모델 품질·schema 위반 | LLM map stage에 도달하지 않음 |
|
||||
| `read_docs`/TaskGroup 오류(v2 시절) | map source 단계에 도달하지 않음 |
|
||||
| 입력 크기 초과 | 그 경우 `input_blocked`로 기록되고 다른 경로·코드가 됨 |
|
||||
|
||||
## 7. 해소 절차 (권장, 이번 작업에서는 수행하지 않음)
|
||||
|
||||
1. **slot 전달 구조 변경 (선행 조건).** 현행 map_reduce 구조로 probe를 돌리면 실패하는 것이 코드상 이미 정해져 있으므로, 먼저 아래 중 하나로 전달 구조를 바꾼다.
|
||||
- backend 수정: `MapReduceExecutor._create_task`가 `preflight_files`와 `preflight_files_raw`를 Task에 넘기게 하고, preflight용 MCP manager와 LLM에 노출하는 도구를 분리한다.
|
||||
- YAML 전환: LLM 단계를 `task_procedure` wildcard fan-out(`dynamic_fanout` + `Task_S2_10_assess_bundle_*`)으로 옮긴다. 이 경로에서 preflight를 쓰려면 `use_tools: ["localdocs"]`가 필요하므로 LLM에 도구가 노출된다. 도구 사용 금지는 prompt로만 강제된다.
|
||||
- 본문 직접 전달: map item 자체에 slot 본문을 넣어 `{{item_json}}`으로 전달한다. map source `read_docs`의 크기 제약과 `bundle_plan.json` 크기 증가를 확인해야 한다.
|
||||
2. **바꾼 구조에서 canary probe.** slot마다 서로 다른 canary 문자열을 넣고, 모델이 자기 slot의 canary만 되돌려 주는지 확인한다. backend 로그(`docker logs agent-backend`)에서 `Pre-read loaded`, `Injected 2-turn preflight history`가 찍히고 `Skipping preflight inject for huge file`(파일 통째 누락)이 없는지 확인한다.
|
||||
3. **빈 map reducer probe.** items가 0개인 map에서 reducer가 호출되는지, `map_results_b64`가 `[]`로 오는지 확인한다.
|
||||
4. **provider 예산·reasoning 수용 확인.** Bridge(`llm_bridge 0.3.0`)는 `reasoning={"effort":"xhigh"}`와 `text.verbosity`를 값 검증 없이 OpenAI Responses 요청에 그대로 넣는다. 따라서 남은 질문은 `gpt-6.1-sol`이 `xhigh`를 받아들이는지, `configured_context_budget_tokens=262144`가 실제 한도 안인지뿐이다. probe의 `[BRIDGE] responses.create completed` 로그와 usage의 reasoning token으로 확인한다. S2_10은 `llm_token_limit`을 지정하지 않아 출력 token 상한 없이 호출된다는 점도 함께 검토한다.
|
||||
5. **상수 갱신.** 1단계의 구조 변경과 2-4단계의 실측이 끝난 뒤에만, 확인된 항목을 prepare([L130-133](Stage_2_S2_10.yml#L130-L133))와 reducer([L965-968](Stage_2_S2_10.yml#L965-L968)) **양쪽 모두** 같은 값으로 바꾼다. 현행 map_reduce 구조에서는 slot preflight가 성공할 수 없으므로 `BACKEND_SLOT_PREFLIGHT_VERIFIED`를 True로 바꿀 정당한 경로가 없다. metadata `runtime_admission`, `verification_record`도 실측 근거와 함께 갱신한다.
|
||||
6. **기존 산출물 처리.** §5.1에 따라 `s2_10/v4`의 이번 TECHNICAL_INCOMPLETE 산출물을 정리하거나, fingerprint 계약 구조를 조정한다.
|
||||
7. **재실행과 완료 판정.** prepare `BUNDLE_BATCH_PREPARED` → map → reducer `PUBLISHED_STATUS_LAST`를 확인한다. `NEXT_WAVE_PENDING`이면 같은 snapshot으로 순차 재호출한다. 최종 `s2_10_status.json`의 `COMPLETED` 또는 `COMPLETED_WITH_ISSUES`와 artifact hash read-back으로 완료를 판정한다. S2_20과의 handoff 호환은 별도 검증 대상이다.
|
||||
|
||||
## 8. 분석 근거와 한계
|
||||
|
||||
- 근거: 사용자가 제공한 backend 실행 로그 1줄, 현행 YAML 코드(위 행 번호), v4-1 전략·v4 분석 문서, [SKILL.md](../../../SKILL.md)의 preflight 설명. v.1에서 운영 backend 코드 확인을 추가했다: `agent-backend` 컨테이너의 `/app/src/agent.py`(2026-10-01 배포본)와 `llm_bridge 0.3.0/bridge.py`를 읽기 전용으로 조회했다([SKILL_compatibility_with_gpt.md](SKILL_compatibility_with_gpt.md)).
|
||||
- 초판의 한계: 초판은 SKILL.md 설명만 근거로 삼고 backend 코드를 확인하지 않았다. 그 결과 slot 전달 문제를 provider 차이로 서술하고, 이미 결과가 정해진 probe를 해소 절차로 제안했다. v.1에서 §3과 §7을 정정했다.
|
||||
- 미확인: `gpt-6.1-sol`의 `xhigh` 수용 여부와 실제 호출 성공은 서버 로그에 호출 기록이 없어 확인하지 못했다.
|
||||
- Gitea Actions 최근 run 목록에는 이번 실행이 없다(최신은 2026-10-04 23:35 KST의 run 1412, 401 실패). 이번 실행은 Actions 경로가 아닌 다른 API 호출 경로로 수행된 것으로 보이며, 해당 session ID·전체 SSE 이벤트·workspace 파일은 직접 확인하지 않았다. §2의 "통과한 단계"와 "기록된 파일"은 출력 모양과 코드 순서에서 도출한 추론이다.
|
||||
- YAML·상수·workspace 산출물은 변경하지 않았다.
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# S2_10 provider 예산·reasoning 수용 확인
|
||||
|
||||
> 확인일 2026-10-05. 대상: [Analysis_failure_S2_10_fable_v.1.md](Analysis_failure_S2_10_fable_v.1.md) §7 4단계.
|
||||
> 근거: OpenAI 공식 문서([gpt-6.1-sol 모델 페이지](https://developers.openai.com/api/docs/models/gpt-6.1-sol), [Reasoning 가이드](https://developers.openai.com/api/docs/guides/reasoning)), 운영 `agent-backend`의 `/app/src/agent.py`와 `llm_bridge 0.3.0`(`bridge.py`, `base.py`)의 읽기 전용 조회, [Stage_2_S2_10.yml](Stage_2_S2_10.yml).
|
||||
> **실호출 probe는 실행하지 못했다.** backend 컨테이너에서 OpenAI를 1회 호출하는 최소 probe를 시도했으나, 원격 쓰기·외부 호출이라는 이유로 Claude Code auto mode 권한 검사에서 거부되었다. 아래 판정은 문서와 코드에 근거한다.
|
||||
|
||||
## 1. 결론
|
||||
|
||||
| 확인 항목 | 판정 |
|
||||
|---|---|
|
||||
| ① `gpt-6.1-sol`이 `xhigh`를 받는가 | ✅ **문서상 지원.** 허용값은 `low`, `medium`(기본), `high`, `xhigh`, `max`이고 `none`과 `minimal`은 지원하지 않는다. Responses API도 지원한다. 이 계정 키로 실제 수용되는지는 미실측이다 |
|
||||
| ① `configured_context_budget_tokens=262144`가 실제 한도 안인가 | ✅ **입력 측은 충분히 안이다.** 모델 context는 1,050,000 tokens다. ⚠ **출력 측 가정은 API와 맞지 않는다** (§2) |
|
||||
| ② `[BRIDGE] responses.create completed` 로그와 usage의 reasoning token으로 확인 | ⚠ **절반만 가능.** 로그로 호출 성공은 확인할 수 있지만 **backend는 reasoning token을 기록하지 않는다.** 확인 방법을 바꿔야 한다 (§3) |
|
||||
| ③ `llm_token_limit` 미지정 시 출력 상한 없이 호출되는가 | ✅ **맞다(코드 확인).** 요청에 `max_output_tokens`가 빠진다. 실제 상한은 모델 기본 최대 출력 128,000 tokens이며, 여기에는 reasoning token이 포함된다 |
|
||||
|
||||
## 2. 예산 계산 대조
|
||||
|
||||
YAML `input_fits`([L741-745](Stage_2_S2_10.yml#L741-L745))는 다음 식으로 입력을 받을지 결정한다. 입력 상한 65,536 + 출력 예비 131,072 + reasoning 예비 32,768 = **229,376 ≤ 262,144**.
|
||||
|
||||
- **입력 측:** 262,144는 모델 context 1,050,000의 약 25%다. UTF-8 bytes를 token 수 대신 쓰므로 보수적인 추정이다(한글은 token 수가 bytes보다 적다). backend의 요약 개입 임계값 `TOKEN_THRESHOLD=360,000`에도 걸리지 않는다.
|
||||
- **출력 측 불일치:** OpenAI에서 reasoning token은 출력 token으로 계산되고 context를 차지한다. 그런데 YAML은 출력 예비와 reasoning 예비를 따로 더해 163,840으로 잡는다. 이는 모델 최대 출력 128,000을 넘는 가정이다. 또 `output_reserve_tokens`에 bytes 상수(`MAX_OUTPUT_BYTES`)를 token 수처럼 넣고 있다. 판정 결과에는 영향이 없지만(입력 측 식만 통과하면 됨), "reasoning 예비 32,768"이 실제로 보장되는 것은 아니다. xhigh reasoning과 긴 JSON 출력을 합쳐 128,000을 넘으면 응답은 `status=incomplete`(`reason=max_output_tokens`)로 끝난다. 그 경우 reducer의 schema 검증 실패로 기술 미완료가 된다.
|
||||
|
||||
## 3. 로그·usage로 확인할 수 있는 범위
|
||||
|
||||
- `[BRIDGE] responses.create completed in {초}s, response_id=...`(bridge.py:748)는 호출 성공만 보여 준다. effort 값과 usage는 찍히지 않는다. 다만 허용되지 않는 effort를 보내면 API가 오류를 반환하고 `[BRIDGE] responses.create FAILED ...`가 찍히므로, **completed 로그가 있다는 것 자체는 `xhigh` 수용의 간접 증거**가 된다.
|
||||
- **reasoning token은 기록되지 않는다.** Bridge의 usage 집계(base.py:545-561)는 `input_tokens`와 `output_tokens`만 읽는다. DB `token_usage_logs`에도 `prompt/completion/total/cached`만 있다. `output_tokens_details.reasoning_tokens`는 버려진다. `completion_tokens`에 reasoning이 합산되어 있으므로 "보이는 출력보다 훨씬 크다"는 정도의 간접 신호만 얻을 수 있다.
|
||||
- **추가 발견:** cached token은 Anthropic식 필드(`cache_read_input_tokens`)만 읽는다. OpenAI의 `input_tokens_details.cached_tokens`는 읽지 않으므로 **OpenAI 호출의 `cached_tokens`는 항상 0으로 기록된다.** S2_10 cache 효과도 이 DB로는 측정할 수 없다.
|
||||
- **대체 확인 방법:** Bridge는 `store=false`를 보내지 않으므로 응답이 OpenAI 쪽에 저장된다. 로그의 `response_id`로 OpenAI `GET /v1/responses/{id}`를 조회하면 `reasoning.effort`, `usage.output_tokens_details.reasoning_tokens`, `input_tokens_details.cached_tokens`, `status`를 직접 확인할 수 있다.
|
||||
|
||||
## 4. ③ 코드 경로
|
||||
|
||||
YAML map task에는 `llm_token_limit`이 없다. `MapReduceExecutor._create_task`는 `llm_token_limit=config.get("llm_token_limit")`이고 stage 기본값이 없으므로 `None`이 된다(agent.py:3007). Bridge의 `token_limit`도 `None`이므로 `_apply_token_limit`이 아무것도 넣지 않고 끝난다(bridge.py:182-196, 739). 따라서 요청에 `max_output_tokens`가 없다.
|
||||
|
||||
## 5. §7 4단계 수정 제안
|
||||
|
||||
"usage의 reasoning token으로 확인한다"를 다음으로 바꾼다. `response_id`로 OpenAI Responses 조회 API에서 `reasoning.effort`, `reasoning_tokens`, `status`를 확인한다. 출력 측 예산은 `llm_token_limit`을 명시하는 방식(예: 128,000 이하에서 reasoning과 출력을 합친 상한)으로 다시 설계한다. 실호출 probe는 사용자가 권한을 허용한 뒤 수행한다.
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
# S2_10 작업명세서 개정 전략 v.5
|
||||
|
||||
작성일: 2026-10-05
|
||||
대상: 같은 폴더의 `Stage_2_S2_10.yml` (Agent `Stage_2_S2_10_v4` 4.1.0, SHA-256 `e4148f6d…2d04fa`)
|
||||
개정 원본: [v.4-1 전략서](S2_10_revision_strategy_v.4-1.md) — 원본 보존
|
||||
개정 근거: [실패 분석 v.1](Analysis_failure_S2_10_fable_v.1.md) §7의 선택, [SKILL 호환성 조사](SKILL_compatibility_with_gpt.md), [provider 예산·reasoning 수용 확인](S2_10_provider_예산_reasoning_수용확인.md), [SKILL.md](../../../SKILL.md), 운영 `agent-backend`의 `/app/src/agent.py`(2026-10-01 배포본) 읽기 전용 판독
|
||||
상태: 구현 전략. YAML 구현·probe·workspace 실행 전.
|
||||
|
||||
## 1. 개정 범위와 원칙
|
||||
|
||||
v.4-1의 **법률 판단·잠정 묶음·가역성·재검토·상태 규칙(§3–§5, §7, §8.3)은 바꾸지 않는다.** 잠정 묶음, 8개 동일성 기준, 단일 출력 Schema, 영향 범위 재검토 1회, 구조 보정 1회, status-last 발행은 그대로다.
|
||||
|
||||
v.5는 실패 분석 v.1 §7에서 선택한 세 가지만 반영한다.
|
||||
|
||||
| 선택 | v.5의 결정 |
|
||||
|---|---|
|
||||
| §7-1 slot 전달 구조 변경 → **YAML 전환** | LLM 단계를 `map_reduce`에서 `task_procedure` wildcard fan-out으로 옮긴다. backend 수정과 본문 직접 전달은 채택하지 않는다 (§2) |
|
||||
| §7-4 provider 예산·reasoning → **수용 확인 보고서 기준** | 출력 상한을 `llm_token_limit`으로 명시하고 예산 식을 API 계산 방식에 맞춘다. reasoning 확인 경로를 OpenAI 응답 조회로 바꾼다 (§3) |
|
||||
| §7-6 기존 산출물 → **v4 정리 + fingerprint 조정** | `s2_10/v4`의 TECHNICAL_INCOMPLETE 산출물을 보관·정리하고, 검증 플래그를 fingerprint 계약에서 뺀다 (§4) |
|
||||
|
||||
대원칙은 **단순한 해법 우선**이다. 문서화된 SKILL.md 패턴을 우선 쓰고, 문서에 없는 backend 동작에 기대는 부분은 probe 항목으로 분리한다. 실행 경로를 바꾸는 데 필요한 최소한의 task만 추가한다.
|
||||
|
||||
## 2. 새 실행 구조 (YAML 전환)
|
||||
|
||||
### 2.1 왜 구조를 바꾸는가
|
||||
|
||||
운영 backend에서 `preflight_files`를 Task에 넘기는 곳은 `task_procedure` 경로(`_create_task_from_config`)뿐이다. map_reduce 경로(`MapReduceExecutor._create_task`)는 넘기지 않고, 도구가 없으면 preflight 자체도 건너뛴다. 따라서 현행 map task는 slot 본문을 받지 못한다. SKILL.md의 동적 `{{item.*}}` preflight 예시(L245-261)는 `task_procedure` wildcard fan-out 패턴이므로, 그 패턴으로 옮긴다.
|
||||
|
||||
### 2.2 구조
|
||||
|
||||
```text
|
||||
Stage S2_10_prepare (task_procedure — 현행 유지)
|
||||
IN → Task_S2_10_prepare_provisional_bundles (code) → OUT
|
||||
실패 시 exit 2 → Stage 중단 (현행 fail-closed 그대로)
|
||||
|
||||
Stage S2_10 (task_procedure — map_reduce 대체)
|
||||
IN → Task_S2_10_load_fanout (code)
|
||||
→ Task_S2_10_assess_bundle_* (LLM; 인스턴스별 slot preflight)
|
||||
→ Task_S2_10_capture_bundle_{same_ordinal} (code; 결과 파일 저장)
|
||||
Task_S2_10_validate_publish_bundles (code)
|
||||
wait_until: [Task_S2_10_load_fanout, "all Task_S2_10_capture_bundle_*"]
|
||||
→ OUT
|
||||
```
|
||||
|
||||
이 형태는 SKILL.md L198-224의 "Planner → B1_* → B2_{same_ordinal} → quality gate(all B2_*)" 예시와 같은 모양이다.
|
||||
|
||||
### 2.3 task별 책임
|
||||
|
||||
| task | 책임 | 바뀌는 점 |
|
||||
|---|---|---|
|
||||
| `Task_S2_10_prepare_provisional_bundles` | 현행 그대로: upstream 검증, 잠정 묶음, 최대 8개 slot 기록, admission gate, plan 발행 | output root `v5`, 예산 식(§3), fingerprint(§4), 플래그 기록 위치(§4.2)만 바뀐다 |
|
||||
| `Task_S2_10_load_fanout` (신규) | `work/bundle_plan.json`을 읽고 prepare의 `plan_sha256`과 대조한 뒤, stdout에 `{"dynamic_fanout": plan.items, "plan_sha256": …, "ok": …}`를 출력한다 | 오류가 나도 **exit 0**으로 `dynamic_fanout: []`와 `error`를 출력한다 (아래 이유). reducer가 이를 보고 실패 처리한다 |
|
||||
| `Task_S2_10_assess_bundle_*` | 현행 LLM 평가 그대로. 한 인스턴스가 한 slot을 평가한다 | §2.4의 설정 변경 |
|
||||
| `Task_S2_10_capture_bundle_*` (신규) | `wait_until: [Task_S2_10_assess_bundle_{same_ordinal}]`. `{{prev \| py}}`로 해당 인스턴스 결과를 받아 `work/llm_output/slot-NN.json`에 `{item, task_result}`로 저장하고 read-back한다. assess가 실패해 결과가 없으면 `MODEL_TASK_RESULT_MISSING` 표시를 저장한다 | — |
|
||||
| `Task_S2_10_validate_publish_bundles` | 현행 검증·발행 그대로 | 입력을 `{{map_results_b64}}` 대신 plan items와 `work/llm_output/slot-NN.json`에서 읽는다. load_fanout의 `ok:false`이면 실패 처리한다 |
|
||||
|
||||
**두 stage로 두고 `load_fanout`을 추가하는 이유.** 이것은 backend 코드 판독에서 나온 추론이며 probe로 확인한다. fan-out 원천 task가 실패하면 wildcard가 확장되지 않는다(`_maybe_expand_wildcards`는 성공 시에만 호출). 그러면 `all Task_X_*`를 기다리는 reducer의 aggregate event가 executor 종료 직전까지 세워지지 않아 대기가 풀리지 않을 수 있다.
|
||||
|
||||
prepare를 바로 fan-out 원천으로 쓰려면 prepare가 실패해도 exit 0을 내야 한다. 그러면 현재 관측된 "prepare 실패 → Stage 중단"이라는 단순한 fail-closed 동작을 잃는다. 그래서 무거운 prepare는 별도 stage에 그대로 두고, 실패할 일이 거의 없는 작은 `load_fanout`만 원천으로 쓴다. 원천이 빈 목록을 내면 backend는 aggregate event를 즉시 세우므로 reducer가 실행된다(agent.py `_maybe_expand_wildcards`).
|
||||
|
||||
**capture task가 필요한 이유.** task_procedure에서 `{{prev.*}}`에 들어가는 값은 `wait_until`의 구체 task 이름뿐이다. `all Task_X_*` 집계 대기는 `_get_dependencies`에서 제외된다(agent.py:1974-1978). 따라서 reducer는 LLM 인스턴스 결과를 `prev`로 받을 수 없다. 같은 ordinal의 capture가 각 결과를 파일로 남기고, reducer는 파일을 읽는다.
|
||||
|
||||
**채택하지 않은 대안.** reducer의 `wait_until`에 `Task_S2_10_assess_bundle_0`부터 `_7`까지 구체 이름을 미리 나열하는 방식은 capture task를 없앨 수 있다. 하지만 존재하지 않는 task 이름을 대기 목록에 두는 동작은 문서화되지 않았으므로 채택하지 않는다.
|
||||
|
||||
### 2.4 assess_bundle_* 설정
|
||||
|
||||
```yaml
|
||||
- task_name: Task_S2_10_assess_bundle_*
|
||||
llm_provider: openai
|
||||
llm_model: gpt-6.1-sol
|
||||
llm_reasoning: xhigh
|
||||
llm_verbosity: medium
|
||||
llm_endpoint: responses
|
||||
llm_token_limit: 128000 # §3
|
||||
max_concurrency: 8
|
||||
max_iterations: 1 # 도구 호출 반복 차단 (probe 확인)
|
||||
use_tools: ["localdocs"] # preflight에 MCP manager가 필요
|
||||
preflight: true
|
||||
preflight_files:
|
||||
- "{{item.input_path}}"
|
||||
prompts: (현행 system/user prompt; 아래 한 문장만 수정)
|
||||
```
|
||||
|
||||
- **도구 노출.** preflight를 쓰려면 MCP manager가 필요하고, manager를 붙이면 LLM에 localdocs 도구가 노출된다. 이는 backend 구조상 피할 수 없다. system prompt의 "도구 호출·검색·파일 저장 권한이 없다"를 "도구 목록이 보이더라도 어떤 도구도 호출하지 않는다"로 고친다. `max_iterations: 1`로 반복 호출을 막는다. 모델이 그래도 도구를 호출할 때의 동작은 probe로 확인한다.
|
||||
- **slot 크기.** `MAX_INPUT_BYTES=65,536`과 고정 prompt 예약 18,411 bytes를 유지하므로 slot은 최대 47,125 bytes다. backend preflight 상한(파일당 50,000자, 넘으면 통째 누락)보다 작다.
|
||||
- **주입 방식.** `{{`가 들어간 동적 entry는 인스턴스별 "2-turn preflight history"로 주입된다. 공유 prefix inline 대상이 아니다. `prompt_cache_key`는 `{stage}:Task_S2_10_assess_bundle`로 자동 파생되어 인스턴스 간에 공유된다.
|
||||
|
||||
### 2.5 capture가 받는 값의 형식
|
||||
|
||||
`{{prev | py}}` modifier는 backend `_render_template` docstring에만 있고 SKILL.md에는 문서화되어 있지 않다. 그래서 probe 항목으로 둔다.
|
||||
|
||||
또 backend의 `_build_prev_value`는 모델 출력을 관대하게 파싱한다. JSON dict이면 `json_output` 키를 덧붙이고, 평문이면 문자열 그대로 둔다. reducer는 다음 순서로 처리한다.
|
||||
|
||||
1. dict이면 `json_output`을 꺼낸다.
|
||||
2. 문자열이면 strict JSON으로 다시 파싱한다.
|
||||
|
||||
dict 경로에서는 현행의 중복 키 검출(`JSON_DUPLICATE_KEY`)이 적용되지 않는다. 이 엄격성 약화는 알려진 제한으로 기록하며, 출력 Schema 검증은 그대로 적용한다.
|
||||
|
||||
## 3. provider 예산과 reasoning (수용 확인 보고서 기준)
|
||||
|
||||
| 항목 | 확인 결과 | v.5의 결정 |
|
||||
|---|---|---|
|
||||
| `xhigh` 수용 | 공식 문서상 지원(low~max). 이 계정 키로의 실호출은 미실측 | 설정 유지. probe에서 응답 조회로 확인 |
|
||||
| context 한도 | 모델 1,050,000 tokens. YAML 예산 262,144는 충분히 안쪽 | `configured_context_budget_tokens=262144` 유지 |
|
||||
| 출력 측 예산 | reasoning token은 출력 token이며 모델 최대 출력은 128,000이다. 현행은 출력 예비 131,072(bytes 상수를 token처럼 사용)와 reasoning 예비 32,768을 따로 더해 163,840으로 가정한다 | 두 예비를 **`max_output_tokens = 128000`(reasoning 포함) 하나로 합친다.** assess에 `llm_token_limit: 128000`을 명시하여 요청에 `max_output_tokens`가 실리게 한다 |
|
||||
| 입력 admission 식 | 현행: input_guard + 131,072 + 32,768 ≤ 262,144 | **input_guard + 128,000 ≤ 262,144** (최대 65,536 + 128,000 = 193,536) |
|
||||
| 출력 상한 도달 | API는 `status=incomplete`(`reason=max_output_tokens`)로 끝난다 | 별도 장치를 두지 않는다. 불완전 출력은 reducer의 Schema 검증 실패로 기술 미완료가 되고, 현행 규칙대로 구조 보정 1회 대상이다 |
|
||||
| reasoning·cache 측정 | backend는 `reasoning_tokens`를 버린다. OpenAI의 `cached_tokens`는 항상 0으로 기록된다 | 확인 경로를 **`[BRIDGE] responses.create completed … response_id=…` 로그 → OpenAI `GET /v1/responses/{id}`**로 바꾼다. `reasoning.effort`, `usage.output_tokens_details.reasoning_tokens`, `input_tokens_details.cached_tokens`, `status`, `max_output_tokens`를 확인한다. status 파일의 usage는 현행대로 `UNAVAILABLE`로 둔다 |
|
||||
|
||||
`MAX_OUTPUT_BYTES=131,072`는 보이는 출력 텍스트 크기의 사후 검증으로만 남기고, token 예산 식에서는 뺀다.
|
||||
|
||||
## 4. 기존 산출물 정리와 fingerprint 조정
|
||||
|
||||
### 4.1 버전과 출력 root
|
||||
|
||||
v.5는 실행 구조·예산 계약이 바뀌므로 새 출력 root를 쓴다. 이는 v.4-1 §8.2의 "새 출력 root" 원칙과 같다.
|
||||
|
||||
- Agent `Stage_2_S2_10_v5`, version `5.0.0`
|
||||
- `ALGORITHM = 's2_10_provisional_bundles/5.0.0'`, plan/batch/claims/status의 `schema_version` 접미사 `.v5`
|
||||
- `output_root()`가 `/s2_10/v5`를 반환. upstream은 `s2_00/v6` 그대로
|
||||
|
||||
### 4.2 fingerprint 계약 조정
|
||||
|
||||
| 구분 | 필드 |
|
||||
|---|---|
|
||||
| **계약에 포함 (fingerprint에 반영)** | algorithm, prompt SHA-256, model 설정(provider·model·reasoning·verbosity·endpoint·`llm_token_limit`), output Schema SHA-256, `max_input_bytes`, `fixed_prompt_bytes`, `max_output_tokens`, `configured_context_budget_tokens`, authority seals, 전달 방식 `task_procedure_fanout_preflight` |
|
||||
| **계약에서 제외** | 런타임 검증 플래그 전부: `slot_preflight`, `empty_fanout_reducer`, `reasoning_effort` |
|
||||
|
||||
검증 플래그는 `RUNTIME_VERIFIED = {...}` 한 dict로 모은다. prepare와 reducer 두 코드 블록에 같은 값으로 둔다. prepare는 이 값을 plan의 `runtime_verified`(fingerprint 밖)에 기록하고, reducer는 자기 값과 plan 기록이 같은지 확인한다.
|
||||
|
||||
결과적으로 **플래그만 바꾸면 fingerprint가 바뀌지 않아** 같은 root에서 재실행할 수 있다. 실제 예산·모델·prompt가 바뀌면 fingerprint가 바뀌어 기존 plan과 `FIXED_PLAN_OR_SOURCE_CHANGED`로 충돌한다. 이 경우는 새 root를 쓰거나 §4.3의 정리를 거친다. 이는 의도된 보호 장치다.
|
||||
|
||||
### 4.3 `s2_10/v4` TECHNICAL_INCOMPLETE 산출물 정리
|
||||
|
||||
v5 root를 쓰므로 v4 파일은 v5 실행에 영향을 주지 않는다. 그래도 실패 분석 v.1 §5.1에 따라 정리한다. 대상은 다음과 같다.
|
||||
|
||||
| 경로 (`stage2_runs/from-stage1/s2_10/v4/` 기준) | 내용 |
|
||||
|---|---|
|
||||
| `s2_10_status.json` | `TECHNICAL_INCOMPLETE`, `technical_reason=SLOT_PREFLIGHT_RUNTIME_UNVERIFIED` |
|
||||
| `claims/claim_records.json` | 빈 결과 조립본 |
|
||||
| `work/bundle_plan.json` | items가 빈 계획 (`plan_sha256 936c5c25…`) |
|
||||
| `work/llm_input/slot-*.json` | gate 판정 전에 기록된 잔여 slot (있는 경우) |
|
||||
|
||||
절차는 다음 순서로 한다. 이 작업은 S2_10 YAML에 넣지 않고, 실행 전 1회성 운영 작업으로 수행한다.
|
||||
|
||||
1. **목록 확인:** localdocs로 위 경로의 실재 여부, 크기, SHA-256을 읽어 목록을 만든다.
|
||||
2. **보관:** 각 파일을 `s2_10/v4/_archive/2026-10-04_TECHNICAL_INCOMPLETE/` 아래 같은 상대 경로로 복사한다. read-back hash가 일치하는지 확인하고 `archive_manifest.json`을 쓴다.
|
||||
3. **원본 제거:** 되돌릴 수 없는 작업이므로 사용자 승인 후에만 한다. localdocs에 삭제 도구가 없으면 원본은 그대로 두고 manifest에 `superseded_by: s2_10/v5`를 기록한다.
|
||||
|
||||
## 5. gate와 probe
|
||||
|
||||
현행 gate 구조(prepare가 플래그를 보고 items를 비워 TECHNICAL_INCOMPLETE 발행)는 유지한다. 다만 각 플래그의 의미를 새 경로 기준으로 다시 정의한다. **세 플래그 모두 probe 통과 전까지 False다.**
|
||||
|
||||
| 플래그 | 의미 | True로 바꾸는 근거 |
|
||||
|---|---|---|
|
||||
| `slot_preflight` | task_procedure 인스턴스가 자기 slot 본문을 실제로 받는다 | probe 1-4 |
|
||||
| `empty_fanout_reducer` | 빈 fan-out에서 reducer가 실행되고 완료 재사용을 처리한다 (backend 코드상 실행됨) | probe 5 |
|
||||
| `reasoning_effort` | `xhigh`가 수용·적용되고 출력 상한이 실린다 | probe 6 |
|
||||
|
||||
**probe.** S2_10과 같은 stage·task 구조, 같은 모델 설정을 쓰는 최소 YAML로 수행한다. slot 2-3개에 서로 다른 canary 문자열을 넣는다. 실호출이므로 사용자 승인 후 실행한다.
|
||||
|
||||
1. 각 인스턴스가 자기 slot의 canary만 되돌려 주는가
|
||||
2. backend 로그에 인스턴스별 `Pre-read loaded`와 `Injected 2-turn preflight history`가 있고, `Skipping preflight inject for huge file`이 없는가
|
||||
3. 모델이 도구를 호출하지 않는가. 호출했다면 `max_iterations: 1`에서 결과가 어떻게 되는가
|
||||
4. capture가 `{{prev | py}}`로 결과를 받아 파일에 저장하고, reducer가 그 파일을 읽는가
|
||||
5. `dynamic_fanout: []`에서 reducer가 실행되는가. load_fanout 오류 시 reducer가 실패 처리하는가
|
||||
6. 로그의 `response_id`로 `GET /v1/responses/{id}`를 조회했을 때 `reasoning.effort=xhigh`, `max_output_tokens=128000`, `status`, `reasoning_tokens`가 확인되는가
|
||||
|
||||
## 6. 파일과 YAML 변경 요약
|
||||
|
||||
| 파일 | v.5 |
|
||||
|---|---|
|
||||
| `work/bundle_plan.json` | 현행 + `runtime_verified`(fingerprint 밖) |
|
||||
| `work/llm_input/slot-NN.json` | 현행 그대로 |
|
||||
| `work/llm_output/slot-NN.json` | **신규.** capture가 저장하는 `{item, task_result}` |
|
||||
| `assessments/batch-NNNN.json`, `claims/claim_records.json`, `s2_10_status.json` | 현행 그대로 (`schema_version` `.v5`) |
|
||||
|
||||
YAML에서 바꾸는 곳은 다음과 같다.
|
||||
|
||||
- Stage `S2_10`의 `map_reduce` 블록을 `task_procedure`와 `tasks`로 교체한다
|
||||
- `MAX_MAP_ENVELOPE_BYTES` admission을 파일별 크기 검사(`MAX_FILE_BYTES`)로 교체한다
|
||||
- metadata의 `runtime_admission`, `implementation_status`, `strategy_implementation_map`, `output_contract`를 v.5 기준으로 갱신한다
|
||||
|
||||
## 7. 구현 순서
|
||||
|
||||
1. **v4 산출물 정리** (§4.3; 원본 제거는 승인 후)
|
||||
2. **YAML 개정:** 버전·root·fingerprint·예산(§3, §4), Stage `S2_10`의 task_procedure 전환(§2), reducer 입력 변경. 플래그는 모두 False로 둔다
|
||||
3. **오프라인 확인:** YAML/Python 구문, 출력 Schema, fingerprint가 플래그와 무관한지, `{{prev | py}}` 형태 fixture를 reducer가 unwrap하는지
|
||||
4. **probe** (§5; 사용자 승인 후)
|
||||
5. **플래그 갱신:** 통과한 항목만 두 코드 블록 모두에서 True. fingerprint는 바뀌지 않는다
|
||||
6. **workspace 실행과 완료 판정:** v.4-1 §8과 실패 분석 v.1 §7-7의 기준을 따른다. S2_20 handoff 호환은 별도 검증 대상이다
|
||||
|
||||
## 8. 검증 기록 (초안 1회 검증, main agent)
|
||||
|
||||
초안을 실패 분석 v.1 §7의 세 선택과 단순성 원칙에 대조하여 14개 개선 사항을 찾아 반영했다.
|
||||
|
||||
- **YAML 전환:** capture task가 필요한 근거(aggregate 대기는 prev에 들어가지 않음), 두 stage와 load_fanout을 둔 이유(원천 실패 시 대기 해제 불가 추정, 현행 fail-closed 보존), 채택하지 않은 대안, `{{prev | py}}`의 문서화 부재와 관대 파싱에 따른 엄격성 약화, slot 크기와 preflight 상한의 관계, 동적 주입 방식, 플래그 재정의, `max_iterations: 1` 동작 미확인을 추가했다.
|
||||
- **provider:** 문서 근거, 출력 측 예산 불일치 해소, bytes-as-tokens 제거, 측정 경로 변경, incomplete 처리, 실호출 승인 필요를 추가했다.
|
||||
- **정리·fingerprint:** 버전·root·ALGORITHM 갱신, 정리 대상 파일 목록과 절차(보관 → 승인 후 제거), fingerprint 포함·제외 필드와 플래그 교차확인을 구체화했다.
|
||||
- **기타:** 파일 표와 YAML 변경 목록 갱신, 빈 목록 경로, 추론과 확인 사항의 구분 표기를 추가했다.
|
||||
|
||||
YAML·backend·workspace는 변경하지 않았다. 이 전략의 backend 동작 서술 중 "추론"으로 표기한 부분과 §5의 probe 항목은 실행으로 확인되지 않았다.
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# SKILL.md preflight·cache 설명과 `openai / gpt-6.1-sol / responses` 호환성
|
||||
|
||||
> 조사일 2026-10-05. 근거: [SKILL.md](../../../SKILL.md) L245-307·L681-699, 운영 backend 컨테이너 `agent-backend`의 `/app/src/agent.py`(2026-10-01 배포본)와 `llm_bridge 0.3.0/bridge.py`(읽기 전용 조회), [Stage_2_S2_10.yml](Stage_2_S2_10.yml) L891-900.
|
||||
|
||||
## 1. 결론
|
||||
|
||||
**Gemini와 OpenAI 사이의 provider 차이는 핵심 문제가 아니다. 핵심은 실행 모드 차이다.** SKILL.md의 동적 `{{item.*}}` preflight 예시는 `task_procedure` wildcard fan-out(`Task_X_*`) 경로를 설명한다. S2_10은 `map_reduce`의 map task를 쓴다. 운영 backend의 map_reduce 경로는 이 기능을 구현하지 않는다. 따라서 현행 S2_10 map task에서는 **provider와 관계없이 slot 파일이 모델에 전달되지 않는다.** 이전 분석에서 "Gemini 경로 위주"라고 쓴 표현은 원인을 provider 쪽으로 잘못 짚은 것이다. 실제 비호환 지점은 아래 표의 1·2행이다.
|
||||
|
||||
## 2. 항목별 판정
|
||||
|
||||
| # | SKILL.md 설명 | backend 실제 동작 | S2_10 판정 |
|
||||
|---|---|---|---|
|
||||
| 1 | `preflight_files`에 `{{item.*}}`가 있으면 인스턴스별로 다른 파일을 로드 (L250-252, L307) | `task_procedure` 경로(`_create_task_from_config`, agent.py:2063-2069)만 `preflight_files`를 Task에 전달한다. **map_reduce 경로(`MapReduceExecutor._create_task`, agent.py:2963-3010)는 이 필드를 전달하지 않는다** | ❌ **무시됨.** `preflight_files: ['{{item.input_path}}']`는 효과 없음 |
|
||||
| 2 | `preflight: true`(기본값)이면 prompt에 나온 파일명을 미리 `read_docs` (L288) | preflight는 `self.mcp_client_manager`가 있을 때만 실행된다(agent.py:1261). map_reduce에서는 `use_tools`와 `tools`가 모두 없으면 manager가 `None`이 된다(agent.py:2990-2996) | ❌ **preflight 자체가 생략됨.** 모델은 user 메시지의 descriptor(`bundle_ref`, `input_path`)만 받는다 |
|
||||
| 3 | 파일 크기 상한 (SKILL.md에 언급 없음) | 파일당 50,000자, 합계 200,000자, 최대 30개. 초과한 파일은 warning만 남기고 빠진다(agent.py:23-35, 1316-1331) | ⚠ 전달 경로가 생기면 통과한다. slot 상한은 65,536 − 18,411 = 47,125 bytes이고, 문자 수는 byte 수보다 크지 않으므로 50,000자 아래다. 다만 상한을 넘었을 때 실행이 실패하지 않고 조용히 진행되는 점은 위험하다 |
|
||||
| 4 | `llm_reasoning`은 "Google native SDK 경로에서만 작동" (L305), 허용값 `low/medium/high` (L281) | **문서가 낡았다.** Bridge가 OpenAI Responses 요청에 `reasoning={"effort": "<값>"}`을 넣는다(bridge.py:161-169). 값 검증 없이 그대로 전달한다 | ⚠ 전달은 된다. `xhigh`를 받아들일지는 OpenAI 모델 쪽 판단이다. 서버 로그에 `gpt-6.1-sol` 호출 기록이 없어 실제 수용은 확인하지 못했다 |
|
||||
| 5 | `llm_verbosity` (L282) | OpenAI 요청에 `text={"verbosity": ...}`로 넣는다(bridge.py:209-220) | ✅ 호환 |
|
||||
| 6 | `cache_control` 및 Gemini cache·TTL 설명 (L285-287, L681-699) | `cache_control`은 anthropic·google 전용이다. openai에서는 버린다(agent.py:1102) | ✅ 해당 없음. S2_10은 `cache_control`을 쓰지 않으므로 충돌하지 않는다 |
|
||||
| 7 | 정적 preflight 파일을 user prefix에 inline하여 "Gemini cache 공유" (L307, L880) | 실제 inline은 provider와 무관한 문자열 삽입이다(agent.py:1532-1558). OpenAI에서는 자동 prefix caching 대상이 된다. `prompt_cache_key`는 자동으로 `{stage}:{task 기본명}`을 만든다(agent.py:1123-1131). map 경로는 stage_name을 넘기지 않아 키가 `stage:Task_S2_10_assess_bundle`이 된다 | ✅ 개념상 호환. 단 이 inline도 1행 때문에 map_reduce에서는 동작하지 않는다. 고정 system prompt 부분의 OpenAI prefix cache는 동작할 수 있다 |
|
||||
| 8 | 출력 token 상한 (SKILL.md에 없음) | `llm_token_limit`을 지정하면 Responses 요청의 `max_output_tokens`로 넣는다(bridge.py:194-196) | ⚠ S2_10은 지정하지 않아 상한 없이 호출된다. `MAX_OUTPUT_BYTES` 검사는 응답을 받은 뒤 reducer에서만 한다 |
|
||||
|
||||
## 3. S2_10에 대한 함의
|
||||
|
||||
- 현행 S2_10 v4의 입력 전달 설계(작은 descriptor + slot preflight)는 운영 backend에서 **성립하지 않는다.** `BACKEND_SLOT_PREFLIGHT_VERIFIED=False` gate를 그냥 True로 바꾸면, 모델은 자료 본문 없이 schema만 맞춘 법률 판단을 생성하게 된다. 현재 gate가 그 위험을 실제로 막고 있다.
|
||||
- 두 경로 모두 preflight에 MCP manager가 필요하고, manager를 붙이면 LLM에도 도구가 노출된다(`task.initialize(task_mcp_manager, ...)`). 따라서 "도구 사용 금지 + preflight 사용"은 현재 backend 구조에서 함께 성립하지 않는다.
|
||||
|
||||
## 4. 선택지 (모두 실측 확인 필요)
|
||||
|
||||
1. **backend 수정 (근본 해결).** `MapReduceExecutor._create_task`가 `preflight_files`와 `preflight_files_raw`를 Task에 전달하게 한다. 또 preflight용 MCP manager와 LLM에 노출하는 도구를 분리한다.
|
||||
2. **YAML 구조 변경.** LLM stage를 `task_procedure` wildcard fan-out(`dynamic_fanout` + `Task_S2_10_assess_bundle_*`)으로 옮긴다. 이때 `use_tools: ["localdocs"]`가 필요하므로 도구 사용 금지는 prompt로만 강제된다.
|
||||
3. **preflight 없이 본문 직접 전달.** map item 자체에 slot 본문을 넣어 `{{item_json}}`으로 전달한다. map source `read_docs`의 크기 제약과 `bundle_plan.json` 크기 증가를 확인해야 한다.
|
||||
|
||||
어떤 선택지든 slot마다 서로 다른 canary 문자열을 넣은 probe 실행으로 확인한다. 확인할 것은 모델이 자기 slot의 canary만 되돌려 주는지, backend 로그에 `Pre-read loaded`·`Injected 2-turn preflight history`가 찍히는지다. `xhigh` 수용 여부는 같은 probe의 `[BRIDGE] responses.create completed` 로그와 usage의 reasoning token으로 확인한다.
|
||||
+1414
File diff suppressed because one or more lines are too long
+1770
File diff suppressed because one or more lines are too long
@@ -4,6 +4,53 @@
|
||||
|
||||
## How to Write MEMORY.md
|
||||
|
||||
### 2026-10-05 — S2_10 YAML v5 구현: task_procedure fan-out 전환
|
||||
|
||||
한 줄 요약: `agent_scripts/Stage_2_S2_10.yml`을 v.5 전략대로 개정했다(Agent `Stage_2_S2_10_v5` 5.0.0, 166,341 bytes, SHA-256 `a2dd637cb4514ad2e32925ce41e09c741f2ff26f4310929450596772783186ed`). 원본 v4는 `Stage_2_S2_10_10_05_2pm.yml`(기존 hash `e4148f6d…2d04fa` 동일)로 보존하고, 같은 bytes를 Stage 2 루트 `Stage_2_S2_10_v.5.yml`로 복제했다.
|
||||
|
||||
- 구조: Stage `S2_10`의 `map_reduce`를 `task_procedure`로 바꿨다. `Task_S2_10_load_fanout`(code; plan을 읽어 `dynamic_fanout` 출력, 오류여도 exit 0과 빈 목록) → `Task_S2_10_assess_bundle_*`(LLM; `preflight_files: ['{{item.input_path}}']`, `use_tools: [localdocs]`, `max_iterations: 1`, `max_concurrency: 8`, `llm_token_limit: 128000`) → `Task_S2_10_capture_bundle_{same_ordinal}`(code; 결과를 `work/llm_output/slot-NN.json`에 저장) → reducer(`wait_until: [load_fanout, "all capture_*"]`, 파일을 읽어 검증·발행). prepare stage와 법률 판단·prompt 본문은 유지했다.
|
||||
- 전략과 다른 선택 1건: capture는 미문서 `{{prev | py}}` 대신 SKILL.md §4에 문서화된 `r"""{{prev}}"""`(JSON) 패턴을 쓴다. assess가 실패하면 backend가 prev를 렌더링하지 않아 placeholder가 그대로 남으므로, 그 경우 `MODEL_TASK_RESULT_MISSING`을 기록한다. prepare와 inflight 재생은 slot마다 `PENDING` marker를 먼저 쓰고, reducer는 marker가 모두 PENDING이면 `FANOUT_NOT_EXECUTED`(인프라 실패)로, slot hash·item 불일치는 `CAPTURE_SLOT_BINDING_MISMATCH`/`CAPTURE_ITEM_BINDING`으로 구별한다.
|
||||
- 예산·계약: `MODEL_BUDGET`을 `max_output_tokens=128000`(reasoning 포함) 하나로 합쳐 `input_guard + 128000 ≤ 262144`로 판정하고 `MAX_MAP_ENVELOPE_BYTES`를 없앴다. 검증 플래그 3개는 `RUNTIME_VERIFIED`(모두 False)로 모아 fingerprint 밖의 plan 필드에 기록하고 reducer가 교차 확인한다(`RUNTIME_VERIFIED_MISMATCH`). root `s2_10/v5`, ALGORITHM 5.0.0, schema `.v5`. prompt는 두 문장만 바꿨다(도구 목록이 보여도 호출 금지; 체크리스트·진행 상황·완료 표식 금지 — task_procedure의 backend 공통 system prompt가 체크리스트와 완료 표식을 요구하기 때문). `PROMPT_SHA256=8f09796a…dfa852`, `FIXED_PROMPT_BYTES=18659`(본문 합 + 2,048).
|
||||
- 검증: 결정적 build 스크립트로 생성하고 YAML parse, 4개 code block compile, prepare/reducer 공유 helper 362행 동일, prompt hash 재계산, v4 잔존 식별자 0, `{{ }}` placeholder 감사(의도한 7종만), 가짜 Localdocs로 capture·load_fanout·reducer gate 경로 fixture 실행을 통과했다. 1회 검토에서 5건(prev 패턴 기록, inflight 재생 시 PENDING 재설정, prompt의 "map 결과" 표현, clientInfo 버전, 검증 기록)을 고쳐 재생성했다. backend·workspace 실행과 canary probe는 미실시이며, 플래그가 False이므로 실행 시 prepare가 `SLOT_PREFLIGHT_RUNTIME_UNVERIFIED`로 멈추는 것이 의도된 동작이다.
|
||||
|
||||
### 2026-10-05 — S2_10 개정 전략 v.5: task_procedure fan-out 전환
|
||||
|
||||
한 줄 요약: `agent_scripts/S2_10_revision_strategy_v.5.md` 작성. 실패 분석 v.1 §7에서 선택한 세 가지(YAML 전환, provider 수용 확인 보고서 기준, v4 정리 + fingerprint 조정)만 v.4-1에 반영했다. 법률 판단·묶음·재검토·상태 규칙은 v.4-1 그대로다.
|
||||
|
||||
- 구조: prepare stage는 그대로 둔다(실패 시 exit 2로 중단). LLM stage는 `load_fanout`(code, 오류여도 exit 0과 빈 fan-out) → `assess_bundle_*`(LLM; `preflight_files: ['{{item.input_path}}']`, `use_tools: ["localdocs"]`, `max_iterations: 1`, `llm_token_limit: 128000`) → `capture_bundle_{same_ordinal}`(code; `{{prev | py}}`를 `work/llm_output/slot-NN.json`에 저장) → reducer(load_fanout과 `all capture_*`를 대기하고 파일을 읽음)로 구성한다.
|
||||
- 근거(backend 읽기 전용 판독): `all Task_X_*` 집계 대기는 `_get_dependencies`에서 제외되어 reducer의 prev에 인스턴스 결과가 들어가지 않는다. 그래서 capture task가 필요하다. 또 fan-out 원천이 실패하면 wildcard가 확장되지 않아 aggregate 대기가 풀리지 않을 수 있다(추론). 그래서 무거운 prepare를 원천으로 쓰지 않는다. 실패한 task도 event를 세우므로 하류 task는 계속 실행된다. `{{prev | py}}`는 SKILL.md에 문서화되어 있지 않고, `_build_prev_value`가 관대하게 파싱하여 중복 키 검출이 약해진다.
|
||||
- 예산은 출력 예비 131,072와 reasoning 예비 32,768을 `max_output_tokens=128000`(reasoning 포함) 하나로 합쳐 input + 128,000 ≤ 262,144로 계산한다. reasoning 측정은 `response_id` → `GET /v1/responses/{id}`로 한다. 검증 플래그 3개는 `RUNTIME_VERIFIED`로 모아 fingerprint에서 빼고 plan에 별도 기록한다. 새 root는 `s2_10/v5`(ALGORITHM 5.0.0)이고, v4 산출물은 보관 → 승인 후 제거한다. 플래그는 canary probe 6항목을 통과하기 전까지 False다.
|
||||
- 초안을 1회 검증(main agent, sub-agent 없음)하여 14개 개선 사항을 반영했고 로컬 링크 5개를 확인했다. YAML·backend·workspace는 변경하지 않았으며 probe는 미실행이다(실호출은 사용자 승인 필요).
|
||||
|
||||
### 2026-10-05 — S2_10 provider 예산·reasoning 수용 확인
|
||||
|
||||
한 줄 요약: `agent_scripts/S2_10_provider_예산_reasoning_수용확인.md` 작성. 공식 문서상 `gpt-6.1-sol`은 `xhigh`를 지원하고 context는 1,050,000, 최대 출력은 128,000(reasoning 포함)이다. 그런데 backend는 reasoning token을 기록하지 않아 "usage의 reasoning token으로 확인"하는 계획은 성립하지 않는다.
|
||||
|
||||
- `llm_token_limit`을 지정하지 않으면 요청에 `max_output_tokens`가 빠진다(agent.py:3007, bridge.py:182-196). YAML은 출력 예비와 reasoning 예비를 따로 더해(163,840) 모델 최대 출력보다 크게 가정했고, bytes 상수를 token 수처럼 썼다. Bridge의 usage 집계는 Anthropic식 `cache_read_input_tokens`만 읽어 OpenAI의 cached token은 항상 0으로 기록된다.
|
||||
- backend 컨테이너에서 OpenAI를 직접 호출하는 최소 probe는 auto mode 권한 검사에서 거부되어 실행하지 않았고 우회하지도 않았다. 판정은 OpenAI 공식 문서와 코드 판독에 근거한다.
|
||||
|
||||
### 2026-10-05 — S2_10 실패 분석 v.1 정정
|
||||
|
||||
한 줄 요약: `agent_scripts/Analysis_failure_S2_10_fable_v.1.md` 저장(초판 보존). 호환성 조사 결과를 반영하여 §3(원인은 provider가 아니라 map_reduce 실행 모드이며, slot 미전달이 확정), §7(구조 변경을 선행 조건으로 7단계로 재구성), §8(근거와 초판의 한계)을 고쳤다.
|
||||
|
||||
- 교훈: 문서 설명만으로 "미검증"이라고 쓴 항목은 backend 코드로 이미 결론이 날 수 있다. 결과가 이미 정해진 probe를 해소 절차로 제안하지 않도록, 실측 전에 코드부터 확인한다.
|
||||
|
||||
### 2026-10-05 — SKILL.md preflight·cache 설명의 gpt/responses 호환성 조사
|
||||
|
||||
한 줄 요약: `agent_scripts/SKILL_compatibility_with_gpt.md` 작성. 호환되지 않는 원인은 provider(Gemini/OpenAI) 차이가 아니라 실행 모드 차이다. 운영 backend의 `map_reduce` map task는 `preflight_files` 설정을 무시하고, `use_tools`/`tools`가 없으면 preflight 자체를 건너뛴다. 따라서 현행 S2_10 v4의 slot 전달은 성립하지 않는다.
|
||||
|
||||
- 근거: 운영 `agent-backend` 컨테이너의 `/app/src/agent.py`(2026-10-01 배포본)와 `llm_bridge 0.3.0/bridge.py`를 읽기 전용으로 조회했다. `preflight_files`를 Task에 넘기는 곳은 `task_procedure` 경로(`_create_task_from_config`)뿐이고, `MapReduceExecutor._create_task`는 넘기지 않는다. preflight에는 MCP manager가 필요한데, manager를 붙이면 LLM에도 도구가 노출된다. preflight 파일은 파일당 50,000자, 합계 200,000자, 최대 30개 상한이 있고, 넘는 파일은 warning만 남기고 조용히 빠진다. S2_10 slot 상한 47,125 bytes는 이 범위 안이다.
|
||||
- SKILL.md L305의 "reasoning은 Google 경로에서만 작동"은 낡은 설명이다. Bridge는 OpenAI Responses 요청에 `reasoning={"effort": 값}`(값 검증 없음)과 `text.verbosity`를 넣는다. `cache_control`은 openai에서 무시되고 `prompt_cache_key`는 자동으로 만들어진다. 다만 `gpt-6.1-sol` 실호출 기록이 서버 로그에 없어 `xhigh` 수용 여부는 확인하지 못했다. S2_10은 `llm_token_limit`을 지정하지 않아 출력 token 상한 없이 호출된다.
|
||||
- 교훈: 같은 YAML 키라도 실행 모드마다 Task 생성 함수가 다르므로, 문서 예시가 어느 모드의 것인지 backend 코드로 확인해야 한다. 오류 없이 무시되는 설정은 오프라인 fixture 검증으로 잡히지 않는다. 대안은 backend 수정, `task_procedure` wildcard 방식으로 전환, map item에 본문 직접 포함의 세 가지이며, 어느 쪽이든 canary probe로 실측해야 한다. YAML과 backend는 변경하지 않았다.
|
||||
|
||||
### 2026-10-04 — S2_10 v4 실행 실패(SLOT_PREFLIGHT_RUNTIME_UNVERIFIED) 분석
|
||||
|
||||
한 줄 요약: `agent_scripts/Analysis_failure_S2_10_fable.md` 작성. `run_code exit_code=2`는 crash가 아니라 설계된 중단이다. prepare가 하드코딩 상수 `BACKEND_SLOT_PREFLIGHT_VERIFIED=False` gate(L855-859)에서 모델 작업 목록을 비우고 `TECHNICAL_INCOMPLETE`를 발행한 뒤 `ok:false`를 반환했다.
|
||||
|
||||
- 출력 JSON에 `output_root`·`plan_sha256`·`map_items`가 있고 `error.detail`이 없으므로, 예외 경로(L810)가 아니라 정상 반환 경로로 판정했다. 여기서 인증(직전 run 1412의 401은 해소), S2_00 v6 upstream 검증, 묶음 준비, status-last 발행까지 통과했다고 추론했다. workspace 파일은 직접 확인하지 않았다.
|
||||
- gate는 slot preflight, MODEL_BUDGET, 빈 map reducer 순서의 연쇄이고, 상수는 prepare 블록과 reducer 블록에 각각 중복 정의되어 있다. `MODEL_BUDGET` 플래그는 `input_fingerprint` 계약에 포함되므로, 플래그만 바꿔 재실행하면 기존 `s2_10/v4` 산출물과 충돌해 `FIXED_PLAN_OR_SOURCE_CHANGED`로 실패한다. slot 기록이 gate 판정보다 앞에 있어 잔여 slot 파일이 남을 수 있다.
|
||||
- 보고서 §3의 "Gemini 경로 위주"라는 원인 표현은 다음 날 호환성 조사(위 항목)에서 실행 모드 문제로 정정되었다. 정정본은 `Analysis_failure_S2_10_fable_v.1.md`이다. YAML과 workspace는 변경하지 않았다.
|
||||
|
||||
### 2026-10-04 — 현행 S2_10 v4 작업명세서 분석
|
||||
|
||||
한 줄 요약: `agent_scripts/Analysis_Stage_2_S2_10_v.4.md`에 현행 S2_10 v4(Agent 4.1.0)의 요청된 7개 목차 분석을 작성했다. 실제 실행단위는 확정 청구권이나 원본 cluster가 아니라 가역적 잠정 bundle이며, reducer의 결과 조립을 전역 청구권 중복 제거와 구별해야 한다.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# S2_10 v.5 실행 실패 분석 (Gitea run 1414)
|
||||
|
||||
작성일: 2026-10-05
|
||||
대상: [Stage_2_S2_10_v.5.yml](Stage_2_S2_10_v.5.yml) (Agent `Stage_2_S2_10_v5` 5.0.0, SHA-256 `a2dd637cb4514ad2e32925ce41e09c741f2ff26f4310929450596772783186ed`)
|
||||
실행 경로: Gitea Actions `Run Agent YAML via API` → `scripts/run_agent_api.py` → `http://agent-backend:8000`
|
||||
workspace: `10월_1일_구성` (user_id `jhogyu`)
|
||||
|
||||
## 1. 결론
|
||||
|
||||
**실행은 backend에 도달하기 전 단계에서 실패했다. YAML은 업로드되지 않았고 backend 세션도 생성되지 않았다.** 직접 원인은 Gitea Actions job 안에서 환경변수 `ADMIN_API_TOKEN`이 비어 있었던 것이다. 그래서 driver가 `X-Admin-Token` header 없이 `GET /workspaces/lookup`을 호출했고, backend의 AuthGate가 HTTP 401 `{"detail":"authentication required"}`로 거절했다.
|
||||
|
||||
이것은 2026-10-04 run 1412와 **같은 실패**다. 그때 "secret이 없거나 전달되지 않았다"고 분석했는데, 이번 조사로 좁혀졌다. **repo secret `ADMIN_API_TOKEN`은 2026-08-20부터 존재하고 workflow도 그것을 올바르게 참조한다. 그런데도 runner가 job에 값을 넣지 않는다.** 즉 문제는 YAML이나 workflow 파일이 아니라 Gitea(1.27.3)·act_runner(v0.2.13)의 secret 주입이다.
|
||||
|
||||
v.5 YAML 자체는 이번 실행에서 아무것도 검증되지 않았다. 실행이 backend에 들어갔더라도 세 runtime 플래그가 False이므로 prepare가 `SLOT_PREFLIGHT_RUNTIME_UNVERIFIED`로 멈추는 것이 예정된 결과였다. 그 설계된 중단조차 이번에는 확인하지 못했다.
|
||||
|
||||
## 2. 실행 기록
|
||||
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| commit | `ab448fbf23e0a72ae5a935dae75317ed0bd2f51c` — `Stage_2_S2_10_v.5.yml`과 `agent_scripts/Stage_2_S2_10.yml` 2개 파일만 포함. push 뒤 Gitea raw 파일 SHA-256이 로컬과 일치함을 확인하고 dispatch |
|
||||
| dispatch | `POST …/actions/workflows/run-agent.yml/dispatches` HTTP 204, 2026-10-05 10:38:31Z. inputs: `yaml_path`=위 파일, `user_id=jhogyu`, `workspace_name=10월_1일_구성`, `start_stage_index=0`, `max_runtime_seconds=1800` |
|
||||
| run / job | run 1414 (run_number 14), job 3296, runner `runner-base-1` v0.2.13 |
|
||||
| 결과 | `completed` / **`failure`**, 10:39:55Z 시작 → 10:42:05Z 종료 (2분 10초; 대부분 checkout·pip 설치) |
|
||||
| artifact | 로그상 `agent-run-14` 업로드 완료(2 파일, 415 bytes). REST `GET /actions/runs/1414/artifacts`는 빈 목록 — 10-04와 같은 현상이며 내려받아 확인하지 않았다 |
|
||||
| backend 세션 | 없음. `upload-agent`, `sse/agent/*/start` 호출 없음 |
|
||||
|
||||
부수 run: **run 1413**(10:37:42Z)은 내 조작 실수다. 파일을 commit·push하기 전에 dispatch가 먼저 나가서 commit `f4174692`(v.5 파일이 없는 상태)로 실행됐다. 그 run도 같은 401에서 멈췄으므로 파일 부재까지는 가지 못했다. 백엔드 영향 없음.
|
||||
|
||||
## 3. 시각별 관측 (UTC)
|
||||
|
||||
| 시각 | 출처 | 관측 |
|
||||
|---|---|---|
|
||||
| 10:39:55 | Gitea | run 1414 시작, head_sha `ab448fbf` |
|
||||
| 10:39:58–10:41:53 | job log | checkout, Python 3.12, `httpx httpx-sse` 설치 |
|
||||
| 10:41:59.213 | job log | `WARNING: ADMIN_API_TOKEN 미설정 — 인증이 필요한 백엔드는 401 로 거부됩니다` |
|
||||
| 10:41:59.268 | job log | `Resolving workspace by name: '10월_1일_구성' (user_id=jhogyu)` |
|
||||
| 10:41:59 | backend `docker logs` | `192.168.48.15 - "GET /workspaces/lookup?user_id=jhogyu HTTP/1.1" 401 Unauthorized` — 요청은 내부 네트워크로 backend에 도달했다 |
|
||||
| 10:41:59.270 | job log | `ERROR: workspaces/lookup HTTP 401: {"detail":"authentication required"}` → `Summary written (error, 0s)` → `exitcode '1'` |
|
||||
| 10:42:05 | Gitea | `Job failed` |
|
||||
|
||||
backend 로그에는 이 401 한 줄 외에 S2_10 관련 기록이 없다(`[STAGE`, `[TASK`, `[FANOUT]`, `upload-agent` 없음).
|
||||
|
||||
## 4. 원인 분석
|
||||
|
||||
### 4.1 확인된 사실
|
||||
|
||||
- **workflow는 secret을 올바르게 참조한다.** `.gitea/workflows/run-agent.yml`의 `Run agent via API` step에 `env: ADMIN_API_TOKEN: ${{ secrets.ADMIN_API_TOKEN }}`가 있다.
|
||||
- **repo secret은 존재한다.** `GET /repos/jhogyu/Liti-agent-Development/actions/secrets` → `[{"name":"ADMIN_API_TOKEN","created_at":"2026-08-20T08:00:27Z"}]`. run 1412(10-04), 1413, 1414 모두 이 secret이 생긴 뒤의 실행이다.
|
||||
- **driver는 환경변수가 비어 있다고 보고했다.** `scripts/run_agent_api.py`는 `os.environ.get("ADMIN_API_TOKEN","")`이 비면 위 WARNING을 내고 header 없이 호출한다. 세 run 모두 같은 WARNING이 있다.
|
||||
- **backend는 토큰을 갖고 있고 비교 로직도 있다.** `server.py:102`가 `ADMIN_API_TOKEN` 환경변수를 읽고, `AuthGateMiddleware`(L345-370)가 `X-Admin-Token`을 `hmac.compare_digest`로 대조한다. 컨테이너 환경변수는 설정되어 있다(길이 64자; 값은 확인·기록하지 않음).
|
||||
|
||||
### 4.2 판정
|
||||
|
||||
실패 지점은 **Gitea 서버가 runner job에 secret 값을 넘기는 단계**다. workflow·driver·backend 세 쪽은 모두 secret이 있다는 전제로 올바르게 동작한다. 값이 비어 있는 이유는 이번 조사 범위(API·로그 읽기)로는 확정할 수 없다. 가능성은 다음과 같다. 모두 `UNVERIFIED`다.
|
||||
|
||||
1. secret 값이 빈 문자열로 저장되어 있다(2026-08-20 등록 당시 값 누락).
|
||||
2. runner가 secret을 받지 못하는 설정이다(runner 등록 범위·Gitea 버전 1.27.3의 secret 전달 문제·`workflow_dispatch` 이벤트 처리 차이).
|
||||
3. secret 이름의 보이지 않는 문자 차이(공백 등). API 응답상 이름은 정확히 `ADMIN_API_TOKEN`으로 보인다.
|
||||
|
||||
### 4.3 이 pipeline의 이력
|
||||
|
||||
성공한 마지막 run은 8번(2026-07-10)이다. 그 뒤 run 9·12·13·14는 실패, 10·11은 취소다. backend AuthGate와 secret이 2026-08-20에 도입된 이후 이 Gitea 경로로 backend 인증을 통과한 기록이 없다. 즉 **이 경로는 AuthGate 도입 후 한 번도 끝까지 동작하지 않았다.** 10-04의 S2_10 v4 실행 로그(`SLOT_PREFLIGHT_RUNTIME_UNVERIFIED`)는 다른 경로로 수행된 것이다.
|
||||
|
||||
## 5. 이번 실행에서 확인한 것과 못 한 것
|
||||
|
||||
| 확인함 | 확인 못 함 |
|
||||
|---|---|
|
||||
| v.5 YAML이 Gitea `main`에 로컬과 같은 bytes로 올라갔다 | YAML 등록(`upload-agent`)과 Agent 이름 `Stage_2_S2_10_v5` 인식 |
|
||||
| runner가 내부 네트워크로 backend에 닿는다(401은 backend가 보낸 응답) | prepare 실행, `s2_10/v5` root 생성, PENDING marker, fingerprint |
|
||||
| secret 존재·workflow 참조·backend 비교 로직 | 설계된 중단(`SLOT_PREFLIGHT_RUNTIME_UNVERIFIED`)의 실제 발생 |
|
||||
| — | task_procedure fan-out, preflight 주입, capture, reducer (플래그 False라 어차피 미실행 예정) |
|
||||
|
||||
## 6. 필요한 조치
|
||||
|
||||
이 작업에서는 secret·runner·backend 설정을 바꾸지 않았다. 아래는 사용자 또는 운영자가 해야 할 일이다.
|
||||
|
||||
1. **secret 재등록.** Gitea 저장소 설정 → Actions → Secrets에서 `ADMIN_API_TOKEN`을 삭제하고, backend 컨테이너의 `ADMIN_API_TOKEN` 값과 같은 값으로 다시 만든다. 빈 값 저장 가능성(§4.2-1)을 먼저 제거하는 가장 싼 조치다.
|
||||
2. **주입 확인.** 재등록 뒤 `run-agent.yml`을 dispatch하고 job log에서 `ADMIN_API_TOKEN 미설정` WARNING이 사라졌는지 본다. 값은 로그에 찍히지 않는다(driver가 header로만 쓴다).
|
||||
3. **그래도 비어 있으면** runner 쪽 문제(§4.2-2)다. act_runner 등록 범위와 Gitea 1.27.3 release note의 secret 관련 수정을 확인한다. 대안은 workflow를 거치지 않고 서버 내부에서 driver를 직접 실행하는 경로(`.env`로 토큰 제공)이지만, 이는 Gitea 경로의 산출물(artifact·run 기록)을 잃는다.
|
||||
4. **인증이 통과되면** 같은 commit `ab448fbf`를 다시 dispatch한다. 예상 결과는 prepare stage가 `TECHNICAL_INCOMPLETE` / `SLOT_PREFLIGHT_RUNTIME_UNVERIFIED`로 멈추는 것이다. 그 run에서 확인할 것은 `s2_10/v5/s2_10_status.json`의 `algorithm_version 5.0.0`, `runtime_verification.flags`, `work/bundle_plan.json`의 `runtime_verified` 필드, slot별 PENDING marker다. 그 다음 단계는 [S2_10_revision_strategy_v.5.md](Default_Agent/Stage_2_Clean/agent_scripts/S2_10_revision_strategy_v.5.md) §5의 canary probe다.
|
||||
|
||||
## 7. 근거 파일
|
||||
|
||||
- Gitea job log: run 1414 / job 3296 (23,614 bytes), run 1413 / job 3295 — 세션 scratchpad에 보존. 저장소 deliverable은 이 문서다.
|
||||
- backend: `docker logs agent-backend` 10:41:00–10:42:30Z 구간, `/app/src/server.py` L102·L164-187·L345-370 (읽기 전용).
|
||||
- Gitea API: `/actions/secrets`, `/actions/runs`, `/actions/runs/1414/jobs`, `/contents/.gitea/workflows/run-agent.yml`, `/version`.
|
||||
- Gitea token과 backend secret 값은 이 문서에 기록하지 않았다.
|
||||
@@ -0,0 +1,79 @@
|
||||
# S2_10 v.6 실행 실패 분석 (backend session a7b537e6)
|
||||
|
||||
작성일: 2026-10-05
|
||||
대상: [Stage_2_S2_10_v.6.yml](Stage_2_S2_10_v.6.yml) (Agent `Stage_2_S2_10_v5` 5.0.1, SHA-256 `d6d1f6d5…b94fab`, commit `7551764b`)
|
||||
실행 경로: 서버 `agent-network` 안의 일회용 컨테이너(`python:3.12-slim`)에서 `scripts/run_agent_api.py`를 `X-Admin-Token`으로 직접 실행. Gitea workflow는 secret 미주입 문제([S2_10_v.5_failure_analysis.md](S2_10_v.5_failure_analysis.md))로 사용하지 않았다.
|
||||
workspace: `10월_1일_구성` (`ccbd7e83-7700-48e8-bbea-b196935420ab`), user_id `jhogyu`
|
||||
|
||||
## 1. 결론
|
||||
|
||||
**인증·업로드·prepare·fan-out은 모두 통과했고, 실패는 LLM 인스턴스에 slot 본문이 전달되지 않은 데서 났다.** 8개 `Task_S2_10_assess_bundle_*` 인스턴스가 모두 `Skipping preflight inject for huge file … (59,412 chars > 50,000 limit)`로 slot을 받지 못한 채 모델을 호출했다(입력 5,277 tokens = prompt만). 자료 없이 평가가 진행되면 reducer의 coverage 검증이 실패해 묶음마다 1회뿐인 repair 예산을 소모하고, `xhigh` 호출 비용만 남으므로 세션을 취소했다(`POST /sse/agent/Stage_2_S2_10_v5/cancel/a7b537e6-…` HTTP 200, 시작 후 2분 23초).
|
||||
|
||||
원인은 YAML과 localdocs의 상호작용에 있다. **prepare가 slot을 `write_binary_file`로 쓰고 `read_binary_doc`으로 read-back하는데, localdocs의 `read_binary_doc`은 그 결과(base64 봉투)를 `read_docs`와 공유하는 파일 캐시에 넣는다.** 이후 preflight가 `read_docs`로 같은 파일을 읽으면 캐시된 봉투 문자열(`{"type":"binary","filename":…,"content_base64":…}`, 59,412 chars)을 받는다. 원문은 44,426 bytes / 37,895 chars로 제한 안이지만, base64 봉투는 1.33배로 불어 50,000 chars 상한을 넘는다. 상한을 넘지 않았더라도 모델에게는 JSON 본문이 아니라 base64 봉투가 주입되었을 것이다. 따라서 v.5/v.6의 slot 전달 설계는 **현행 prepare의 저장 방식과 양립하지 않는다.**
|
||||
|
||||
두 번째 결함도 관측됐다. 모델은 prompt의 금지에도 도구를 호출했고(`tools: 13` 노출), backend는 같은 iteration 안에서 도구 결과를 붙여 두 번째 `responses.create`를 보냈다. `max_iterations: 1`은 바깥 반복만 제한하고 도구 왕복은 막지 못한다.
|
||||
|
||||
## 2. 실행 기록
|
||||
|
||||
| 시각 (UTC) | 출처 | 관측 |
|
||||
|---|---|---|
|
||||
| 11:11:02 | driver | `Resolving workspace by name` → `Resolved … -> ccbd7e83…`; `Agent registered: Stage_2_S2_10_v5 (stages=2)`; `Session started: a7b537e6-88fa-499f-8ccf-693733146541` |
|
||||
| 11:11:02–11:11:11 | driver | `S2_10_prepare` 실행, `Task_S2_10_prepare_provisional_bundles` 완료(9초), stage 자동 confirm |
|
||||
| 11:11:11–11:11:14 | driver / backend | `S2_10` 시작, `Task_S2_10_load_fanout` 완료, `dag_update`로 `assess_bundle_0..7`, `capture_bundle_0..7` 확장. fan-out·`{same_ordinal}` 연결은 설계대로 동작 |
|
||||
| 11:11:14 | backend | 8개 인스턴스 모두 `Pre-read (whitelist, expanded): {…slot-0N.json}` → `Skipping preflight inject for huge file … (58,276~60,892 chars > 50,000 limit)` → `Current token count: 5,277 / 360,000` |
|
||||
| 11:11:1x | backend | `[BRIDGE] submit_messages … model: gpt-6.1-sol, endpoint: responses, messages: 3, tools: 13`; 8건 `responses.create completed in 6.7~8.2s`; 이어 각 인스턴스가 `previous_response_id=…, input_items=1/5`로 **두 번째 호출**(도구 결과 전송) |
|
||||
| 11:13:2x | API | 세션 취소 HTTP 200; backend `[CANCEL_API] session=a7b537e6`, `Cancellation requested during stage S2_10` |
|
||||
| 11:13:25 | driver | `Execution STOPPED: Execution cancelled due to disconnection`, `Summary written (stopped, 143s)` |
|
||||
|
||||
driver 산출물(`out/events.jsonl` 16,724 bytes, `out/summary.md`)은 세션 scratchpad에 보존했다. summary: 최종 상태 `stopped`, task_start 29, task_complete 2(prepare, load_fanout).
|
||||
|
||||
history API(`/history/sessions/{hash}/task_runs`)에는 assess 8건이 `status None, iteration_count 0`으로 남아 있다(취소로 미완). usage는 기록되지 않았다.
|
||||
|
||||
## 3. 원인 분석
|
||||
|
||||
### 3.1 slot 미전달 — 캐시 오염
|
||||
|
||||
| 단계 | 코드 | 동작 |
|
||||
|---|---|---|
|
||||
| prepare slot 저장 | YAML `Localdocs.write_verified` → `write_binary_file` + `read_binary_doc` read-back | localdocs `read_binary_doc`(mcp_localdocs_server.py:332-357)은 `_read_as_binary` 결과를 `_file_cache.put(file_path, mtime, result)`로 캐시한다 |
|
||||
| preflight | backend `_mcp_read_docs_single` → MCP `read_docs` | `read_docs`(L368-393)는 `_file_cache.get(file_path)`를 먼저 보고 **캐시가 있으면 그대로 반환**한다. 캐시는 mtime·size만 검사하고 어느 도구가 넣었는지 구별하지 않는다 |
|
||||
| 크기 판정 | backend `MAX_PREFLIGHT_FILE_SIZE_CHARS=50,000` | 봉투 59,412 chars > 50,000 → 주입 생략, 경고만 남김 |
|
||||
|
||||
직접 측정으로 확인했다. 같은 clientInfo(user·workspace hash)로 `read_docs(slot-01.json)`를 호출하면 `results[0].content`가 59,412 chars의 봉투 문자열이고, 그 안의 `size: 44426`, `content_base64` 59,236 chars다. backend `/files/{path}/content` API로 읽은 원문은 44,426 bytes / 37,895 chars, `\u` escape·indent 없음. `.json`은 localdocs `BINARY_EXTENSIONS`에 없고, 암호화 저장은 text/binary가 동일(`encrypted_write_text` = `encrypted_write(text.encode())`)이므로 봉투의 유일한 출처는 캐시다.
|
||||
|
||||
v.5 전략·구현에서 "slot ≤ 47,125 bytes < 50,000자 상한"이라고 판단한 것은 **원문 기준으로는 맞았지만, read-back이 캐시를 봉투로 채운다는 점을 놓쳤다.** 캐시는 mtime이 같으면 유지되므로, 코드만 고치고 같은 slot 파일을 다시 쓰지 않는 재생(`SAME_INPUT_BATCH_REUSED`) 경로에서는 오염이 그대로 남는다.
|
||||
|
||||
### 3.2 도구 호출 — `max_iterations: 1`의 범위
|
||||
|
||||
backend Task 루프는 `for i in range(self.max_iterations)`(agent.py:1574) 안에서 한 번의 "LLM 호출"을 수행하는데, Bridge가 도구 호출을 받으면 도구를 실행하고 결과를 붙여 같은 iteration 안에서 모델을 다시 부른다. 로그의 두 번째 `responses.create`(`previous_response_id` 설정, `input_items=1/5`)가 그 증거다. 즉 `max_iterations: 1`은 도구 왕복을 막지 못한다. 모델이 도구를 부른 직접 동기는 두 가지다. 자료가 없었고, task_procedure의 backend 공통 system prompt가 "MCP tools를 활용하는 autonomous agent"로 역할을 규정한다. 자료가 주입되면 동기는 줄지만, 구조적으로 막히지는 않는다. 취소로 중단됐으므로 최종 출력이 JSON 하나였을지는 확인하지 못했다.
|
||||
|
||||
### 3.3 이번 실행에서 확인된 것
|
||||
|
||||
- 인증: `X-Admin-Token`으로 `workspaces/lookup`·`upload-agent`·SSE start 모두 통과.
|
||||
- v5 prepare: `s2_10/v5` root에 `work/bundle_plan.json`(`algorithm_version 5.0.0`, `runtime_verified` 3개 True, `inflight: True`, items 8, bundles 1,836, `input_blocked` 2), slot 8개, `work/llm_output/slot-NN.json` PENDING marker 생성. `s2_10_status.json`은 없음(정상: 발행 전 취소).
|
||||
- task_procedure: `load_fanout` stdout이 `dynamic_fanout`으로 인식되어 8개 인스턴스 확장, `capture_bundle_{same_ordinal}` 연결까지 DAG에 반영. (`{{stages.S2_10_prepare.plan_sha256}}` 렌더링은 load_fanout이 성공했으므로 성립.)
|
||||
- `preflight_files: ['{{item.input_path}}']`는 task_procedure 경로에서 인스턴스별로 올바른 slot 경로로 확장되어 read 시도까지 갔다(`Pre-read (whitelist, expanded)`).
|
||||
- OpenAI: `gpt-6.1-sol` + `xhigh` + Responses 호출 성공(16건 completed/시작). `max_output_tokens`·reasoning 수용은 응답 조회로 확인하지 않았다.
|
||||
|
||||
## 4. 현재 workspace 상태와 주의
|
||||
|
||||
- `s2_10/v5/work/bundle_plan.json`이 `inflight: True`다. 같은 코드로 다시 실행하면 재생 경로(`SAME_INPUT_BATCH_REUSED`)를 타고 PENDING marker만 다시 쓰고 slot은 다시 쓰지 않는다. **localdocs 캐시의 봉투가 그대로 남아 같은 실패가 반복된다.**
|
||||
- 플래그 3개가 True인 v.6은 gate가 막지 않으므로, 전달 결함을 고치기 전에는 다시 실행하지 않아야 한다. 실행하면 묶음당 1회의 repair 예산이 자료 없는 출력으로 소모된다.
|
||||
- 서버의 임시 디렉토리(`~/s210_v6`, 토큰 파일 포함)와 driver 컨테이너는 삭제했다. 토큰 값은 어디에도 기록하지 않았다(로컬 `.env`에만 보관).
|
||||
|
||||
## 5. 필요한 개정
|
||||
|
||||
최소 변경 순서다. 모두 YAML 쪽이며 backend 수정은 선택이다.
|
||||
|
||||
1. **slot·marker를 텍스트 도구로 쓴다.** `write_verified`에서 slot(`work/llm_input/slot-NN.json`)과 capture marker는 `write_file`(text)로 쓰고 read-back도 `read_docs`로 한다. 그러면 캐시에 텍스트가 들어가고 preflight가 원문(37~39K chars)을 받는다. 다른 산출물(plan·batch·claims·status)은 현행 binary 경로를 유지해도 된다. 캐시 오염을 피하려면 slot을 `read_binary_doc`으로 읽는 경로가 어디에도 없어야 한다(reducer의 `SLOT_CHANGED_DURING_BATCH` 검사도 `read_docs`로 바꾸거나 hash 계산을 텍스트 기준으로 통일).
|
||||
2. **새 root로 간다.** 캐시는 mtime이 바뀌어야 무효화되므로 기존 `s2_10/v5` slot을 그대로 재사용할 수 없다. `ALGORITHM 5.1.0`, root `s2_10/v6`로 올리고 fingerprint 계약에 `delivery: write_file_text_preflight`를 반영한다. v5 산출물은 v4와 같은 방식으로 보관한다.
|
||||
3. **크기 상한을 chars 기준으로 다시 잡는다.** backend 상한은 50,000 **chars**다. UTF-8 bytes 상한 65,536(−18,659 reserve)은 한글 비중이 높을 때만 안전하다. `input_fits`에 `len(canonical(payload).decode())`(문자 수) ≤ 50,000 − 여유를 추가하는 것이 정확하다.
|
||||
4. **도구 왕복 차단.** 가장 단순한 길은 prompt 강화("입력은 이미 본문에 있다. read_docs를 포함한 어떤 도구도 호출하지 않는다")이지만 구조적 보장은 아니다. 보장이 필요하면 backend에 preflight용 manager와 LLM 노출 도구를 분리하는 옵션(예: `preflight_tools` 또는 `use_tools: []`에서도 preflight 허용)을 추가해야 한다. 이는 [SKILL_compatibility_with_gpt.md](Default_Agent/Stage_2_Clean/agent_scripts/SKILL_compatibility_with_gpt.md) §3에서 지적한 구조적 제약이다.
|
||||
5. **플래그는 다시 False로 두고 canary probe를 먼저 돈다.** 이번 실행이 보여 준 것처럼, 7개 전제 중 "slot 본문이 모델에 들어간다"와 "도구 호출이 억제된다"는 가정이 틀렸다. v.5 전략서 §5의 probe(slot 2~3개, canary 문자열)로 1·3·4를 확인한 뒤에만 True로 바꾼다.
|
||||
|
||||
## 6. 근거
|
||||
|
||||
- driver 로그·`out/summary.md`·`out/events.jsonl`(세션 scratchpad `s210_v6/`), backend `docker logs agent-backend` 11:11–11:14Z.
|
||||
- localdocs 컨테이너 `/app/mcp_localdocs_server.py` L170-199(캐시), L248-259(`BINARY_EXTENSIONS`, `is_binary_file`), L297-330(`_read_file_content`, `_read_as_binary`), L332-357(`read_binary_doc`), L368-393(`read_docs`), L467-554(`write_file`, `write_binary_file`); `/app/localdocs_crypto.py` L30-44. 모두 읽기 전용.
|
||||
- backend `/app/src/agent.py` L23-35(preflight 상한), L1261-1335(preflight), L1574(iteration 루프); `llm_bridge/bridge.py` 로그.
|
||||
- 측정: MCP `read_docs(slot-01.json)` 봉투 59,412 chars(base64 59,236, size 44,426); backend `/files/…/content` 원문 44,426 bytes·37,895 chars; `work/bundle_plan.json`·`llm_output/slot-01.json`(PENDING) 내용.
|
||||
Reference in New Issue
Block a user