diff --git a/.gitignore b/.gitignore index febbc85c4..5b20d782e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ __pycache__/ .coverage .pytest_cache/ .codegraph/ +.claude/ strix_runs/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 39c61c142..38cacf1a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ this file. The format follows Keep a Changelog, and versioned releases follow Semantic Versioning where the repository publishes a release. ## [Unreleased] +- Fix a false-positive in `scripts/ci/test_strix_quick_gate.sh`'s + `assert_opencode_review_uses_codegraph_and_contextual_orchestrator`: its + `awk '/^ required-workflow-bootstrap:$/,/^[^ ]/'` range never actually + terminated (no line in `opencode-review.yml`'s `jobs:` block dedents to + column 0 before EOF), so the assertion scanned the entire rest of the file + instead of just the `required-workflow-bootstrap` job -- flagging the + unrelated `opencode-review-target` job's own `if: + github.event.action != 'closed'` (a legitimate closed-PR skip) as if it + violated required-workflow-bootstrap's payload-independence rule. + Confirmed via two independent full runs of the script (this branch's + merged worktree, and a fresh clone of `main` alone) that both fail this + exact assertion identically -- a pre-existing defect on `main`, not + introduced by this branch's merge. Replaced the range with a flag-based + scan that stops at the next 2-space-indented job key. Verified the fix + still catches a genuine violation (a synthetic `if:` injected inside + `required-workflow-bootstrap` itself) and that the full script now passes + with zero failures. - Harden the review sidecar's per-account catalog cap against silent drift: `contextual_orchestrator_review_launcher.py`'s two `build_zdr_prioritized_catalog` call sites now source their @@ -19,6 +36,52 @@ Semantic Versioning where the repository publishes a release. then rejected via 429/404/timeout). New regression tests pin the default to the policy module's canonical value and forbid the total-routes constant from reappearing as the account-cap fallback. +- Fix `scripts/ci/contextual_orchestrator_review_sidecar.sh`'s gateway-preflight + retry loop: when every configured attempt exhausted with no usable HTTP + response, the script always recorded generic "transport exhausted" evidence + even when the sidecar process itself had already exited after passing + readiness -- discarding the single most useful diagnostic (the process's own + exit status and stderr). Now checks `kill -0 "$sidecar_pid"` first (safe for + a bash-owned background job: a failing `kill -0` means bash's own job table + has already reaped it, so the following `wait` retrieves the real cached + exit status, not a fresh `waitpid()` on an already-gone zombie -- the same + pattern the pre-existing healthz branch already used) and, when the sidecar + has exited, records distinct evidence (`error_type: + "sidecar_process_exited"`, exit status, attempts) and fails with a distinct + message including the exit status and stderr tail, instead of the generic + transport-exhausted path. Exact-head evidence: Strix run/job + `33341290448`/`99337282309` for `ContextualWisdomLab/.github#1460` target + `2cc819a9` (reported via a cross-agent coordination comment, verified + against issue #1399 and PR #1460 before acting on it). New tests: + `tests/test_contextual_orchestrator_review_sidecar_contract.py::test_gateway_preflight_distinguishes_a_dead_sidecar_from_an_unreachable_one` + and + `tests/test_contextual_orchestrator_review_runtime_preflight.py::test_gateway_retry_loop_diagnoses_a_sidecar_that_died_after_readiness`. + See the 2026-08-30 gap-baseline entry for the full diagnosis and the two + pairs of pre-existing-test regressions this surfaced and fixed along the + way. +- Fix `scripts/ci/pingora_edge_policy.py`'s `_load_changed_files`: the + post-loop pagination-exhaustion `PolicyError` was unreachable dead code + (proven: 31 full 100-item pages always trip the in-loop `len(files) > + 3_000` raise during page 31's own iteration before the loop can exhaust + its range), and it was silently failing this repo's org-wide + `coverage-evidence` gate (`fail_under=100`) for every PR reviewed through + the central OpenCode/Noema/Strix dispatcher, blocking opencode-agent from + ever posting an APPROVED verdict anywhere in the org. Marked + `# pragma: no cover` with justification, matching this repo's existing + convention. See the 2026-08-30 "OpenCode Agent 자체 문제" gap-baseline + entry for the full three-PR diagnosis this came out of. (Independently + found and fixed by the org owner in the same window, commit `34c88356`, + with a stronger regression test pinning the arithmetic invariant itself — + adopted the owner's version when reconciling the resulting merge conflict.) +- Document a structural CodeRabbit gap in `docs/product-technical-gap-baseline.md` + (2026-08-30 entry): every ContextualWisdomLab repo is below CodeRabbit's + 10-GitHub-star automatic-review threshold, so CodeRabbit never reviews a new + commit without an explicit `@coderabbitai review` trigger comment — this was + surfacing as a `naruon` PR-governance metadata-gate block that looked like a + blocking finding but was actually CodeRabbit's own "not reviewed yet" state. + No code change; a central auto-trigger workflow is a candidate follow-up, + deliberately deferred pending a review of its required-workflow-ruleset + blast radius. - Fix a dangling reference #1468 left in `docs/product-goal-directive.md` (flagged by Devin Review on that PR): the standing operating directive still named the removed `free_family_diversity` evidence field instead of @@ -583,6 +646,27 @@ Semantic Versioning where the repository publishes a release. `zero_data_retention` stays `False` as it already was; only the citation and note change. See the 2026-08-30 ZDR/NIM-routing gap-baseline entry for the full architecture review this citation was part of. +- Correct `docs/product-technical-gap-baseline.md`'s "2026-08-30 post-#1486/#1438 wake" entry: Bytez + can never populate `orchestrator/free` regardless of its HTTP status (`_parse_bytez` never sets + `is_free`), and the `request_failed status=413` line is the sidecar's own unconditional self-test + rather than a live ZDR-prefetch fallback. The incident's actual terminating message + (`"review sidecar preflight failed"`, a live warm-up-probe rejection) is the same one the + `ORCHESTRATOR_CATALOG_FAMILY_CAP` fix above mitigates (hosted confirmation remains pending) — the + two corrections converge on the same real root cause rather than describing two different bugs. + Also adds a bounded, one-retry + resilience improvement to `contextual-orchestrator`'s provider *discovery* fetch (a different, + non-overlapping call site from the family-cap/preflight fix above) in + `ContextualWisdomLab/contextual-orchestrator#923`. +- Refresh `docs/product-technical-gap-baseline.md`'s §5.1 next-increment list, + which had gone stale: #1297 was already merged, and #1345/#1326 were closed + unmerged, yet all three were still listed as pending candidates. Replaced + with the current state (#1347 still open/dirty; `ContextualWisdomLab/naruon#1486` + added as this pass's naruon increment) and recorded the naruon-side Noema + role clarification: naruon's `noema-general-agent` is a separate, correctly + BYO-LLM-scoped agent from this repo's central review-bot Noema — they + intentionally share only a name — and it now has a `check_calendar_conflict` + tool (PRD-02) that reuses naruon's existing deterministic conflict policy + instead of inventing a second one. - Bump the vendored `contextual-orchestrator` review-sidecar pin from `5f2753a` (the #1422 pin) to current `main` `30c6d716`, picking up `ContextualWisdomLab/contextual-orchestrator#919`: generalizes the diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 758ef2961..b9eaa7b70 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1292,6 +1292,523 @@ conflicting** PRs address pieces of this: currently blocked by the sidecar-preflight outage above, so neither could be re-reviewed to a genuine pass yet regardless of which approach wins. +## 2026-08-30 hourly pass: §5.1 next-increment list was stale; naruon Noema role clarified + +- Re-verified the three §5.1 items from the previous pass against their current state rather than + assuming they still apply (per this document's own "병합 판단에는 재사용하지 않는다" rule): + - **#1297 is already merged** (`merged_at` 2026-08-26T21:47:16Z, into `main` at + `31e5f5337d8a8d844c456fe03f123c51b62416c9`+). No action remains; the item should not have still + been listed as pending. + - **#1345 is closed, unmerged** (`mergeable_state: dirty`, closed 2026-08-28T17:03:18Z without + merging). Its normalizer-linear-scan fix duplicates what #1417 already landed on `main` per the + 2026-08-30 entry above ("Bolt: label_section 탐색 로직 최적화"); treat as superseded, not a live + candidate. + - **#1326 is closed, unmerged** (`mergeable_state: behind`, closed 2026-08-27T11:59:18Z). The + appguardrail/macos_utility_packs hourly-caller onboarding it proposed was not carried forward by + this pass; if still wanted, it needs a fresh PR rebased on current `main`, not a reopen of #1326. + - **#1347 remains open** (SSRF/isolation hardening for `sandboxed_web_e2e.py`), `mergeable_state: + dirty` against current `main`. Its only review signal is CodeRabbit/Devin bot commentary (one + nitpick, several addressed rounds) — no human or required-check-independent `APPROVED` verdict + yet. Not touched this pass (time budget went to the naruon increment below instead); next pass + should merge current `main` into its head as an ordinary merge commit (never rebase) and re-check. +- **naruon-side Noema role clarified and widened (this pass's concrete increment, not just an + audit).** The user's directive for this loop specifically flagged that Noema is the central + `.github` review/CI agent, but naruon needs its own suited role rather than a copy of that one. + Investigation found naruon already has a *separate*, correctly-scoped agent identity — + `noema-general-agent` in `ContextualWisdomLab/naruon` `backend/services/noema_agent.py` — that + reasons over mail/content-graph/tasks on the **tenant's own configured LLM provider** (never routed + through the org's shared `contextual-orchestrator` review gateway; doing so would mix customer + prompts into shared org infrastructure and defeat the ZDR/cost-isolation boundary + `docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md` establishes for CI review). This is the + correct design, not a gap — the note in `ContextualWisdomLab/noema`'s `CLAUDE.md` that "every LLM + path ... naruon judgments — calls contextual-orchestrator" is imprecise about this and should be + read as covering a distinct, not-yet-built internal-governance use of Noema, not the customer-facing + workspace assistant. + - The actual, concrete gap: naruon's Noema was scoped to mail/tasks/calendar-*writeback-only* with + no scheduling-conflict judgment, even though naruon already has a stateless, deterministic, + fully-tested conflict policy (`services/calendar_conflict_policy.py::evaluate_calendar_conflicts`, + status-weighted confirmed > tentative > desired, RFC 5545 `STATUS:CANCELLED`-aware) behind + `POST /api/calendar/conflicts/evaluate` — directly serving PRD-02 ("일정 이동과 RSVP/commitment + 충돌을 놓치지 않는다") but not reachable from the agent. + - Fix: `ContextualWisdomLab/naruon#1486` adds a `check_calendar_conflict` tool to + `noema_agent.py` that calls the *same* `evaluate_calendar_conflicts` function the REST endpoint + uses (no second conflict policy invented), so the agent's judgment and the customer-facing API can + never diverge. Naruon does not persist provider calendar events server-side, so the tool evaluates + only commitments the caller already supplies (e.g. ones the LLM read from mail/tasks earlier in + the same run) rather than fetching a provider calendar itself; malformed `existing` rows are + skipped rather than raised. `registered_agents.json`/`task_agent_mapping.json` updated to list the + new `calendar.conflict_check` capability and to state explicitly that naruon's Noema and the + central `.github` review-bot Noema are two separate agents that intentionally share only a name. + - Validation (naruon repo): `PYTHONPATH=. python -m pytest backend/tests/test_noema_agent.py -q` → + 21 passed (6 new, including the pre-existing full-agent-run `TestModel` test that now also + exercises this tool end-to-end); full backend suite `python -m pytest -q` → 1808 passed, 32 + skipped; `ruff check` clean on both changed files. + - Acceptance open until `#1486` clears naruon's own required Checks (OpenCode/Strix/merge-scheduler, + central-workflow-sourced same as every other consumer repo) and merges; this snapshot is + implementation + local-evidence only, not merge authorization. + +## 2026-08-30 post-#1486/#1438 wake: pin `30c6d716…` confirmed live, still fails closed (new signature) + +- `ContextualWisdomLab/naruon#1486`'s `noema-review` run (`run 33305922007`, PR-target on trusted base + `dc2ed58f…`) confirms the vendored sidecar now provisions the current pin + (`30c6d71680e659f25a0a433d4726ad0d437f9757`, the #919 User-Agent/403 fix bumped in earlier this same + pass) — this is the first hosted confirmation that pin actually reaches a PR-target run, not just + static contract tests. It still fails closed, but with a **new** failure shape distinct from the + prior HTTP 403/413-on-Models.dev signature: + 1. A ZDR-catalog prefetch got `request_failed status=413 code=request_too_large` and fell back to + "using live OpenRouter ZDR endpoint feed" — handled non-fatally, logged only, sidecar continued. + 2. During sidecar startup, `bytez` model discovery returned `provider_discovery_failed + provider=bytez code=http_status_500` — this line was fully visible in the CI log (not folded + into `omitted_unstructured_lines`), confirming the `_log_discovery_errors()` visibility fix + recorded earlier this pass is working as intended. 4 other stderr lines were still folded into + `omitted_unstructured_lines=4` (not inspected further this pass — could be additional detail, not + assumed secret-bearing given the sanitizer's existing allowlist-based design). + 3. `[contextual-orchestrator-sidecar] error: sidecar exited before healthz (status 1)` — the process + exits non-zero, matching the already-tracked "review sidecar discovered no eligible models; + orchestrator/free would fail closed" `SystemExit` path once `bytez` (a candidate free-pool + contributor per the five-secret design) also fails to enumerate. +- This is consistent with, not contradictory to, the already-open "orchestrator/free pool exhausted" + gap above: OpenRouter's genuinely-free models remain intentionally `evidence_only` (correct ZDR + hardening, must not be reverted), and nvidia_nim/nvidia_nim_sub/openai were already found to + contribute ~0 routable free models even after the #919 Models.dev fix. `bytez` returning HTTP 500 in + this run removes what may have been the last remaining candidate source for that run, though whether + this specific 500 is a transient Bytez-side fault or a persistent one was not re-tested this pass + (would need a second hosted run to distinguish; not attempted given time budget). Either way, the + underlying decision this gap needs — accept real spend via `orchestrator/auto`, or wire a genuine + zero-cost source (`opencode_zen`, per the 2026-08-30 entry above) — remains open and still requires a + budget-owner call, not a unilaterally-applied code change. +- Also confirmed this pass: `opencode-review`'s required-check gate (a separate mechanism from + `noema-review`) correctly fails closed on a fresh head with no `opencode-agent` review yet, on both + `naruon#1486` and `.github#1438` — this is the documented "asynchronous model dispatch... had not + completed for any of the refreshed PRs by the time this pass ended" pattern from the entry above, not + a new defect. Commented on both PRs distinguishing the two failure classes; no code change made in + either PR for either failure, since neither is caused by their own diffs. + +**Correction (recorded below, superseded further still by "2026-08-30 sidecar-preflight outage: +consolidated evidence" further above, landed concurrently on `main` while this entry's own follow-up +investigation was in progress): the "removes what may have been the last remaining candidate" framing +above is wrong** — see the correction entry immediately below for why, and the consolidated-evidence +entry above this one for the actual, evidence-confirmed root cause and its fix. + +## 2026-08-30 correction: Bytez was never free-eligible; the real root cause landed concurrently on `main` + +Triggered directly by explicit user feedback that a single external provider erroring should never be +able to fail-close org-wide PR review CI. A 7-agent investigation (3 independent code readers, one +synthesis, 3 adversarial skeptics who each independently re-read the cited source rather than trusting +the synthesis) found the entry immediately above, and two earlier ones, misdiagnosed the mechanism. + +**What was wrong:** + +1. **Bytez can never populate `orchestrator/free`, regardless of its HTTP status.** + `contextual_orchestrator/model_discovery.py`'s `_parse_bytez` never sets `is_free` on any row it + builds, and `DiscoveredModel.is_free` defaults to `False`; `free_discovered_models()` is a flat + `is_free` filter. So the entry immediately above's claim that Bytez's HTTP 500 "removes what may + have been the last remaining candidate free-pool contributor" is false — Bytez was never a + candidate in the first place, whether its discovery call succeeds or fails. This is not new + information: `docs/planning/adrs/0041-generalize-models-dev-cost-classification.md` in + `contextual-orchestrator` already documents that Bytez has zero Models.dev coverage; the two + documents had simply never been cross-checked against each other on this point. +2. **The `request_failed status=413 code=request_too_large` line is the sidecar's own offline + self-test, not a live ZDR-catalog prefetch that "fell back" to anything.** + `scripts/ci/contextual_orchestrator_review_sidecar.sh`'s embedded self-test heredoc deliberately + spins up a throwaway local server and asserts `response.status == 413` on every single sidecar + boot, unconditionally, before any discovery or ZDR fetch starts. The immediately-following "using + live OpenRouter ZDR endpoint feed" line is printed only on that *unrelated* step's curl success, never + as a fallback from the 413. This misattribution — reading two adjacent, unrelated, always-emitted + log lines as a causal pair — appears not just in the entry immediately above but also in "2026-08-30 + sidecar pin staleness recurrence" (`gateway preflight returned HTTP 502 (and, on a differently-shaped + request, request_failed status=413...) before the model pool can run`) and in "2026-08-30 + post-#1413/#1422 backlog refresh cycle"'s #1420 bullet (`failed with request_failed status=413 ... + during model discovery, fell back to the OpenRouter ZDR feed`). Those two entries are left as + historical record rather than hand-edited (the first is explicitly marked superseded/kept-unedited + already; both predate this correction) — this note is the authoritative correction for all three. +3. **The naruon#1486/.github#1438 incident's actual terminating message was `"review sidecar preflight + failed"`, a distinct, separately-sanitized string from `"review sidecar discovered no eligible + models"`** (confirmed in `contextual_orchestrator_review_launcher.py`'s two separate `SystemExit` + sites and `sanitize_contextual_orchestrator_sidecar_stream.py`'s two separate allowlist entries). + That proves discovery *did* find at least one genuinely free-eligible candidate (necessarily from + `nvidia_nim`/`nvidia_nim_sub`/`openai` — the only sources with real Models.dev-sourced zero pricing) + and every one of those candidates then failed the live chat-completion warm-up probe in + `_preflight_review_agents`. The entry immediately above's claim that this "match[es]" the + no-eligible-models path is wrong. + +**My own working theory at the time (partially wrong, corrected here):** this investigation initially +concluded the mechanism was purely generic — two call sites (`discover_provider_models`'s model-list +fetch, and `_preflight_review_agents`'s live `client.proxy_send_once(...)` warm-up probe) each making +exactly one HTTP attempt with zero retry, so *any* transient failure at either one would be +architecturally sufficient to fail the sidecar closed. That mechanism is real (confirmed by code +reading, not superseded), but it is not what actually happened in this specific incident. **The +"2026-08-30 sidecar-preflight outage: consolidated evidence" entry above — landed on `main` from a +parallel, independent, far more thorough investigation with actual hosted-run artifacts this session +never had access to — found the real, precise, evidence-confirmed cause**: `orchestrator/free`'s +candidate selection (`contextual_orchestrator_review_policy.py`'s `family_cap`) is alphabetical with no +reliability signal, and since `nvidia_nim`/`nvidia_nim_sub` was the *only* family populating the free +pool, the same 4 alphabetically-first candidates were selected on every single run — 2 of which are +NVIDIA-retired model ids returning HTTP 404 **forever**, not transiently, plus 2 that timed out. That is +a deterministic selection defect, not a generic "one random transient failure" story, and it is +mitigated (`ORCHESTRATOR_CATALOG_FAMILY_CAP` raised 4→8; hosted confirmation remains pending) +alongside two further, genuinely independent bugs +in the same call path (the gateway smoke-test's own `curl --max-time` was too tight for a real +reasoning-model completion, and its `max_tokens` was desynchronized from the launcher's own probe +budget) — see that entry for the full evidence trail and reasoning. None of this needed, and this +correction does not propose, retrying the completion warm-up probe itself; the consolidated-evidence +entry's fix operates one layer earlier, on *which* candidates are ever offered to that probe. + +**What still stands, independent of the correction above:** + +- Points 1 and 2 above (Bytez's structural non-eligibility; the 413 self-test misattribution) are + unaffected by which of the two root-cause theories is right, and remain the correction to make to + this document. +- `ContextualWisdomLab/contextual-orchestrator#923` — a genuinely independent, still-valid resilience + improvement: one bounded retry (short delay, shortened timeout, gated on the existing + `is_transient_error` classifier reused as-is) on the provider *discovery* model-list fetch, a call + site the consolidated-evidence fix above does not touch. This does not fix, and was never confirmed + to fix, the naruon#1486/.github#1438 incident specifically — it is a defense-in-depth improvement for + a real but different failure mode (a transient 5xx during discovery itself, which would otherwise + zero out a provider's entire contribution for that pass). Full suite: 2765 passed, 1 skipped. +- `ContextualWisdomLab/.github#1438` — originally also added a preflight-rejection console-visibility + fix (`_log_preflight_rejections`); **dropped** on discovering the consolidated-evidence fix above + already ships an equivalent, simpler mechanism (`log "sidecar preflight route evidence: ..."` dumping + the already-bounded-safe `preflight_report` JSON directly) — keeping both would have been duplicate, + redundant code solving the same problem two ways. What remains in #1438: the corrected framing in + this document, and the `SIDECAR_STDERR_TAIL_LINES` failure-path log-tail widening (20→60 lines, + complementary to, not overlapping with, the consolidated-evidence fix's own new log line). +- §5.1 below is updated to reflect this. + +## 2026-08-30 wakeup: reconciled with concurrent owner fixes; three PRs merged-current and marked ready + +- Confirmed all three open PRs from the previous pass (`contextual-orchestrator#923`, `.github#1438`, + `naruon#1486`) had fallen behind their base branches while this session was investigating the + bytez/preflight incident — `.github`'s `main` in particular had advanced by 6 commits (owner + bypass-merges) covering the exact same incident with much deeper, hosted-run-artifact-backed + evidence than this session's own investigation had. Rather than merge blind, read every one of + those commits' diffs and the doc's own new "sidecar-preflight outage: consolidated evidence" entry + before touching anything — see that entry and the "correction" entry directly below it for the full + reconciliation. Net effect: this session's own `_log_preflight_rejections` visibility fix was + dropped as redundant with what had already shipped; this session's discovery-retry fix + (`contextual-orchestrator#923`) and the doc corrections were kept as independently valid. +- Merged current `main`/`develop` into all three branches as ordinary merge commits (never rebase); + `contextual-orchestrator#923` needed no merge (its `main` had not moved). Full suites re-verified + clean after each merge: `.github` 1897 passed/1 skipped/21 subtests (100% coverage on touched files, + 100% interrogate), `naruon` 1812 passed/32 skipped, `contextual-orchestrator` 2765 passed/1 skipped + (unchanged, no merge needed). +- All three marked ready for review (undrafted) — implementation and local validation are complete; + keeping them in draft only paused the central review pipeline (CodeRabbit skips drafts entirely; + merge automation is explicitly gated off for drafts) with nothing left to gain from staying in that + state. +- `.github#1347` (SSRF/isolation for `sandboxed_web_e2e.py`) deliberately **not** touched this pass: + it is 8+ days stale against a `main` that has independently grown substantial SSRF hardening in the + exact same file (already flagged in an earlier entry above as a same-file, overlapping-logic case + that needs actual semantic reconciliation, not a mechanical merge) — attempting that at the tail end + of an already long pass risked a rushed, wrong resolution more than it risked leaving it one more + cycle. Left for a dedicated next pass. + +## 2026-08-30 CodeRabbit이 조용히 자동 리뷰를 하지 않는 근본 원인: repo star 임계값 + +- `ContextualWisdomLab/naruon#1486`에서 `github-actions[bot]`의 `pr-governance:metadata-gate` 코멘트가 새 head + (`6a5365ee`)에서 "Current-head CodeRabbit issue comment has blocking warning/failure evidence"로 + 다시 막혔다. 처음엔 CodeRabbit이 실제 finding을 낸 것으로 의심했으나, 코멘트 원문을 다시 읽으니 + CodeRabbit 자신의 "Approval pending — has not reviewed the latest commit yet, check the box to + trigger review" 상태였다 — `docs/development/merge-gate-policy.md`(naruon) 정책상 "current-head + CodeRabbit issue comment has blocking warning/failure evidence"는 정확히 이 미해결 상태(clean + approval도 아니고 rebuttal도 없음)를 fail-closed로 잡아낸 것이었다. 즉 gate는 올바르게 동작했다. +- `ContextualWisdomLab/contextual-orchestrator#923`의 동일 유형 코멘트(재발행 이벤트로 이 세션에 + 도착)가 근본 원인을 명시했다: "This repository does not receive automatic reviews because it has + fewer than 10 stars." CodeRabbit의 OSS 무료 자동 리뷰 기능은 public repo의 GitHub star 수가 10 + 미만이면 자동으로 트리거되지 않고, PR 코멘트의 체크박스(`🔍 Trigger review`) 또는 + `@coderabbitai review` 커맨드로 수동 트리거해야만 그 커밋에 대한 리뷰가 실행된다. 이 세션이 직접 + 확인한 4개 저장소(`ContextualWisdomLab/.github`, `naruon`, `contextual-orchestrator`, `noema`) + 모두 이 임계값 아래다 — 다른 org 저장소까지 전수 확인한 것은 아니므로, 결론은 이 4개 저장소로 + 한정한다. 확인된 4개 저장소 안에서는 새 커밋마다 동일하게 재발하는, 특정 PR의 결함이 아닌 구조적 + gap이다. +- 즉시 조치: `ContextualWisdomLab/naruon#1486`과 `ContextualWisdomLab/contextual-orchestrator#923` + 양쪽에 `@coderabbitai review` 코멘트를 게시해 현재 head의 리뷰를 명시적으로 트리거했다. + `ContextualWisdomLab/.github#1438`도 이어서 같은 코멘트로 트리거했다. +- 근본 해결책 후보(아직 미착수, 다음 pass에서 검토): `.github`의 central 필수 workflow에 PR + `opened`/`synchronize`/`ready_for_review` 이벤트마다 `@coderabbitai review` 코멘트를 자동 + 게시하는 얇은 단계를 추가하면 이 수동 트리거가 사라진다. 다만 이는 org-wide required-workflow + ruleset(`CWL Central required workflows`, id `18156473`)에 새 workflow를 등록하는 작업이라 + blast radius가 크다 — 이번 pass에서는 구현하지 않고, 매 PR마다 수동으로 트리거하는 현재 관행을 + 유지하며 후속 pass의 별도 증분으로 남긴다. 대안으로 CodeRabbit 자체의 organization 설정에서 이 + 10-star 게이트를 우회하는 옵션이 있는지 확인하는 것도 병행 검토 대상이다. +- **추가 발견 (트리거 직후)**: 두 트리거 코멘트 모두 CodeRabbit이 커맨드 자체는 수락했으나 + ("I will review pull request..."), 곧이어 별도의 "Review limit reached — next included review + available in ~7–31 minutes" 코멘트로 rate-limit에 걸렸다. 즉 이 조직에는 두 개의 독립적인 + CodeRabbit 제약이 겹쳐 있다: (1) 10-star 미만 repo는 애초에 자동 리뷰가 트리거되지 않는 gate, + (2) OSS 무료 티어의 리뷰 횟수 자체가 org 전체(또는 계정 전체)에서 공유되는 rate limit. 10-star + 게이트를 자동화로 우회해도 (2)가 여전히 남아 즉시 리뷰가 실행되지 않을 수 있으므로, 위 "근본 + 해결책 후보"는 재시도/backoff까지 함께 고려해야 완전하다. 이번 pass에서는 재트리거하지 않고 + rate-limit 창(가장 늦은 것 기준 naruon#1486 쪽 31분)이 지나기를 기다린다. + +## 2026-08-30 "OpenCode Agent 자체 문제" 진단: 세 PR이 서로 다른 3가지 원인으로 막혀 있었다 + +- 운영자 직접 질의("OpenCode Agent 자체에 문제가 있는 듯")에 대응해 4갈래 병렬 조사(디스패치 + 메커니즘 코드 분석, GitHub Actions 실행 이력, 조직 전체 리뷰 증거, 공유 게이트웨이 상태) + + 종합진단 5-agent Workflow를 실행했다. 결론: **단일 공통 장애가 아니라, 세 PR이 각기 다른 + 이유로 opencode-agent의 dispatch 단계에 도달하지 못하거나(2건) 도달은 했지만 근본 원인이 + 다른 버그로 막혀 있었다(1건)**. "async dispatch를 기다리는 중"이라는 이전 프레이밍은 두 PR에 + 대해서는 틀렸다 — dispatch 자체가 시도된 적이 없었다. + - `ContextualWisdomLab/naruon#1486`: 스케줄러(`scan-pr-queue`, 11:41Z 실행)가 + `{"action":"block","reason":"2 unresolved review thread(s)"}`로 dispatch를 보류했다. + 실제로는 이 세션이 이미 그 시점 이전에 모든 review thread를 resolve했으므로 stale한 + 스냅샷이었을 가능성이 높다 — 다음 스케줄러 tick(이벤트 기반 `scan-pr-queue` 또는 15분 + 주기 `org-queue-sweep`)에서 자동 해소되어야 한다. + - `ContextualWisdomLab/contextual-orchestrator#923`: `pr_review_merge_scheduler.py`의 + `inspect_pr()`가 OpenCode dispatch를 Strix evidence 뒤에 순서화하는데(Strix가 + `"completed"`가 아니면 OpenCode를 아예 호출하지 않음), 이 PR의 스케줄러 실행(11:49Z)이 + 동시에 `"this scheduler run has no cross-repository repository-dispatch credential"`을 + 로그에 남겼다 — **원인을 정확히 특정했다**: `pr-review-merge-scheduler.yml`(501/798행)은 + `SCHEDULER_ALLOW_CROSS_REPO_REPOSITORY_DISPATCH`를 + `(secrets.PR_REVIEW_MERGE_TOKEN != '' || secrets.OPENCODE_APPROVE_TOKEN != '')`로만 `true`로 + 설정한다. 요구된 워크플로우 ruleset이 이 스케줄러를 "각 대상 저장소의 컨텍스트에서" 실행하므로 + (`GITHUB_REPOSITORY`가 대상 저장소가 됨), 이 두 secret 중 하나가 **`contextual-orchestrator` + 저장소(또는 조직) 레벨에 설정되어 있지 않으면** 이 플래그는 항상 `false`가 되고 + `repository_dispatch_wait_reason()`(`pr_review_merge_scheduler.py:680-701`)은 + `execution_repo == dispatch_repo`(스케줄러가 `.github` 자신의 컨텍스트에서 실행될 때만 + 참)도 만족하지 못하므로, **이벤트 기반 `scan-pr-queue`(대상 저장소 컨텍스트에서 실행)만 놓고 + 보면** 이 두 secret 중 하나가 `contextual-orchestrator`에 없는 한 항상 wait를 반환한다. + **정정(Devin review 지적)**: "어떤 PR도 영원히 통과하지 못한다"는 표현은 과장이었다 — + 같은 파일의 `org-queue-sweep` job(574행)은 `if: github.repository == + 'ContextualWisdomLab/.github'`로 게이트되어 15분 주기 cron으로 **`.github` 자신의 + 컨텍스트에서** 조직 전체 저장소를 훑는 fallback이다. 이 경로에서는 + `execution_repo == dispatch_repo`가 항상 참(둘 다 `.github`)이므로 + `repository_dispatch_wait_reason()`의 두 번째 escape hatch를 만족해 `contextual-orchestrator` + 자체의 secret 유무와 무관하게 dispatch를 시도할 수 있다. 즉 이벤트 기반 경로는 확실히 + 막혀 있지만, 15분 주기 org-wide sweep은 별도 경로다. **추가 정정(Devin review 재지적)**: + "아마도 정상 동작"이라는 표현도 과도했다 — `pr-review-merge-scheduler.yml:820-825`를 직접 + 읽으면 `org-queue-sweep` 자신도 `SCHEDULER_MUTATION_TOKEN_SOURCE == "github-token"`(즉 + `PR_REVIEW_MERGE_TOKEN`/`OPENCODE_APPROVE_TOKEN` secret도, OpenCode app 토큰 교환도 전혀 + 없을 때)이면 `exit 1`로 즉시 전체 실패한다 — "credential availability와 무관한 독립 경로"가 + 아니라, **같은 종류의 secret을 `contextual-orchestrator` 저장소가 아니라 `.github` + 저장소(또는 조직) 레벨에서 요구하는 것으로 요구 위치만 옮겨진 것**이다. 따라서 + `contextual-orchestrator#923`이 이 fallback으로 실제 구제되는지는 `.github`/조직 레벨에 + `PR_REVIEW_MERGE_TOKEN`/`OPENCODE_APPROVE_TOKEN`(또는 유효한 OpenCode app 토큰 교환)이 + 설정되어 있는지에 전적으로 달려 있다 — 이 세션은 secret 값을 읽을 권한이 없어 이를 검증할 + 수 없다. "likely-working"이 아니라 "미검증, 조건부"로 정정한다. 사람이 organization 또는 + `.github`/`contextual-orchestrator` repository 설정에서 확인해야 한다. (PR #939는 제목만 + 비슷할 뿐 실제로는 Strix/Inkspan scanner 오탐·uv materialization에 관한 무관한 작업이므로, + 겹치는 범위가 아님을 확인했다.) + - `ContextualWisdomLab/.github#1438`: dispatch는 실제로 실행되었다(run `33310753001`, + 12:09:57Z 트리거). 하지만 이 저장소 자신의 `coverage-evidence` job이 + `scripts/ci/pingora_edge_policy.py`의 `_load_changed_files` 함수 끝의 방어적 post-loop + `raise`(당시 345번째 줄)에서 커버리지 미달로 실패했다 — `Coverage failure: total of 99 is + less than fail-under=100`. 이 job의 실패는 `.github`를 통해 리뷰되는 **모든** 대상 저장소의 + approval을 막는다(`opencode-review-dispatch.yml`이 "Coverage evidence did not pass; + approval is blocked"라고 명시). 동일한 정확한 실패가 완전히 무관한 다른 PR(`.github#1161`, + 11:31Z run)에서도 재현되어, PR별 결함이 아니라 `main`에 이미 존재하는 구조적 결함임을 + 확인했다. +- **근본 원인 분석과 수정**: `_load_changed_files`는 `for page in range(1, 32)`(최대 31페이지, + page당 최대 100개)로 변경 파일을 페이지네이션하며, 루프 안에서 `len(files) > 3_000`이면 즉시 + raise한다. 30페이지 모두 정확히 100개씩 반환되면 정확히 3,000개(초과 아님, raise 없음)가 + 누적되고, 이어지는 31번째(마지막) 페이지가 100개 미만이면 조기 `return`, 정확히 100개이면 그 + 첫 항목을 추가하는 순간 3,001 > 3,000이 되어 루프 **안**의 raise가 먼저 발동한다. 즉 루프가 + break/return/raise 없이 정상적으로 31회를 모두 소진해 루프 **밖**의 post-loop raise에 도달할 + 수 있는 입력은 수학적으로 존재하지 않는다 — 순수한 방어적 죽은 코드였다. 이 저장소는 + `# pragma: no cover`를 이미 다른 곳에서(`scripts/ci/*.py`의 `if __name__ == "__main__":`, + `contextual_orchestrator_review_launcher.py`의 provider 예외 등) 근거 주석과 함께 쓰는 + 확립된 관례가 있으므로, 동일한 관례로 이 줄에 이유를 설명하는 pragma를 추가했다(코드 삭제가 + 아니라 유지 — 향후 page 개수·per_page 값이 바뀔 경우를 대비한 방어적 불변식이므로). + 검증: `coverage run -m pytest tests` → 1897 passed, 1 skipped, 21 subtests; `coverage report` + → TOTAL 9966/9966 statements, 3926/3926 branches, **100%**; `interrogate` → 100.0%. +- **hosted 확인**: 별도로, 이전 "family_cap 4→8 완화, hosted confirmation 보류" 항목의 hosted + 확인도 이번 조사에서 확보되었다 — family_cap 수정(`e36a1f71`, 2026-08-30T10:46:01Z 병합) 이후 + 베이스를 사용한 3건의 실제 hosted run(`.github#1161`/`#1438`/`#1448`) 모두 sidecar가 healthz+ + provider-route preflight를 통과했고, 그 이전 베이스를 쓴 1건은 정확히 문서화된 pre-fix 서명 + 그대로 실패했다. **family_cap 결정론적 결함은 해결된 것으로 확인**(표본 3건, load-sensitive + provider timeout/429/502 가설은 아직 미검증). 단, 이 확인과는 별개로, 같은 커밋(`c11b68c2`)에서 + noema-review와 strix가 "healthz 통과 후 실제 completion 요청이 120초 타임아웃으로 0바이트 + 응답"이라는 다른 실패 시그니처를 보였다 — family_cap과는 다른, 아직 미해결인 별도 문제로 다음 + pass에서 추적한다(위 "완화, hosted confirmation 보류" 항목의 새 하위 이슈로 취급). +- **다음 행동**: (1) 이 커밋 병합 후 `.github`를 통해 리뷰되는 모든 PR의 `coverage-evidence`가 + 회복되는지 재확인, (2) naruon#1486은 스케줄러의 다음 tick을 기다리거나 필요시 + `repository_dispatch`로 수동 재트리거, (3) contextual-orchestrator#923의 cross-repo + dispatch 자격 증명 배선을 직접 확인, (4) org-wide 15분 주기 cron이 07:03Z 이후 ~5시간 + 공백이 있었다는 조사 결과(별도의 신뢰성 회귀)도 다음 pass에서 조사한다. +- **정정 (Devin review 지적, 순환 의존 주장은 틀렸음)**: 처음에는 "이 수정이 `main`에 병합되기 + 전까지는 `#1438` 자신도 구제받지 못하는 순환 의존"이라고 썼으나, 틀렸다. + `opencode-review-dispatch.yml:303-355`("Materialize pull request merge tree for coverage + measurement")를 직접 읽으면 `coverage-evidence`는 `PR_BASE_SHA`(=`main`)를 checkout한 뒤 + **PR의 현재 `PR_HEAD_SHA`를 그 위에 merge**해서 커버리지를 측정한다 — `PR_HEAD_SHA`는 + dispatch 시점의 PR 실제 head이므로, 이 pragma 수정이 이미 `#1438`의 head에 포함되어 있는 한 + 다음 dispatch부터 `#1438` 자신의 `coverage-evidence`는 (아직 `main`에 병합되기 전이라도) + 회복되어야 한다. 순환 의존은 없다 — `main`에 병합해야만 효과가 생기는 것은 **다른** PR들 + (naruon#1486, contextual-orchestrator#923 등, 이들 자신의 diff는 pingora_edge_policy.py를 + 건드리지 않으므로)의 coverage-evidence뿐이다. 사람의 개입(관리자 병합)이 필요하다는 주장도 + 철회한다 — `#1438`은 다음 dispatch에서 스스로 통과할 가능성이 높다. +- **추가 발견 (사용자 직접 지적)**: `contextual-orchestrator`의 Strix 실행에서도 별도의, 진짜 + 내부 로직 버그를 발견해 수정했다 — `ContextualWisdomLab/contextual-orchestrator`의 + `server.py`가 `/v1/chat/completions`에서 `tools`가 있을 때 `stream_options.include_usage=true` + 조합을 무조건 400으로 거부하고 있었는데, 실제로는 하위의 `_chat_response_sse_chunks`가 이미 + tool_calls delta와 정직하게 라벨링된(reported/estimated) usage chunk를 완전히 지원하는 + 코드였다 — 즉 존재하지 않는 제약을 이유로 이미 동작하는 조합을 막고 있던, 순수한 자체 + 버그였다. Strix의 `openai-agents` SDK가 tools와 함께 이 조합을 항상 보내므로, 이 저장소를 + 경유하는 모든 Strix 실행이 (sidecar preflight 통과 여부와 무관하게) 이 지점에서 결정론적으로 + 실패하고 있었다. `response_format`만 있는 multi-agent "conduct" 경로는 (aggregate usage + 추적이 아직 구현되지 않아) 여전히 fail-closed 상태로 남겨두었다. 수정·테스트 갱신·전체 스위트 + 검증 후 `contextual-orchestrator#923`에 병합했다. + +## 2026-08-30 시간별 재개: main과의 conflict 해소 + Strix streaming workaround와 stream_options 버그의 연결 확인 + +- 시간별 loop 재개 시점에 세 PR의 현재 상태를 다시 확인했다: `naruon#1486`(`blocked`), + `ContextualWisdomLab/contextual-orchestrator#923`(`blocked`), `ContextualWisdomLab/.github#1438` + (**`dirty`** — 새로 발생한 merge conflict). `.github`의 `main`이 이 세션이 마지막으로 동기화한 + 이후 3개 커밋(`34c88356`, `702392a2`, 병합 커밋 `1d8e8724`) 앞서 있었다. +- **`34c88356`**: 오너의 (다른) Claude 세션이 **이 세션이 이번 pass에서 고친 것과 정확히 동일한 + `scripts/ci/pingora_edge_policy.py`의 죽은 post-loop raise 버그**를 완전히 독립적으로 + 발견·수정했다 — 근거(31×100=3,100 산술), 결론(`# pragma: no cover`), 커밋 메시지의 논증까지 + 거의 동일하다. 다만 오너 쪽 수정이 한 걸음 더 나아갔다: `test_changed_file_pagination_bound_is_provably_unreachable` + 테스트를 추가해 `inspect.getsource`로 소스의 페이지 수·per_page·3,000 cap 상수를 직접 파싱하고 + 그 부등식을 assert함으로써, 향후 이 세 상수 중 하나라도 바뀌어 불변식이 깨지면 (죽은 코드가 + 더 이상 죽은 코드가 아니게 되면) 테스트가 요란하게 실패하도록 만들었다 — 이 세션의 수정에는 + 없던, 더 견고한 안전장치다. Merge conflict를 ordinary merge commit으로 해소하며 **오너 쪽 + 버전을 채택**하고 이 세션의 동등하지만 덜 완전한 버전은 버렸다(앞선 `_log_preflight_rejections` + 중복 사례와 동일한 패턴). +- **`702392a2`(★ 중요, 이 세션 자신의 발견과 직접 연결됨)**: 오너가 Strix 자신의 코드에 **정확히 + 이 세션이 `contextual-orchestrator#923`에서 발견·수정한 바로 그 `stream_options.include_usage=true` + + `tools` 거부 버그**를 우회하는 workaround를 커밋했다. 커밋 메시지: "contextual-orchestrator의 + 게이트웨이가 stream_options.include_usage=true와 tools 조합을 의도적으로 거부한다(사용량 + 집계가 조용히 불완전해지는 것을 막는 정합성 보장; 여기서 바꾸는 것은 범위 밖)" — 즉 오너 + (또는 오너의 세션)는 이 거부를 **의도된, 고칠 수 없는 제약**으로 받아들이고 호출자 + (Strix)측에서 `LLM_DISABLE_STREAMING=true`로 스트리밍 자체를 꺼서 문제의 조합을 아예 + 보내지 않는 방식으로 우회했다. 그런데 이 세션은 `_chat_response_sse_chunks`가 이미 + tool_calls delta와 정직하게 라벨링된 usage chunk를 완전히 지원한다는 것을 직접 코드로 + 증명했고, 그 거부는 "고칠 수 없는 제약"이 아니라 **불필요한 자체 버그**였다 — 이미 + `contextual-orchestrator#923`에 서버 쪽 근본 수정을 넣었다(아직 `contextual-orchestrator`의 + `main`에는 병합되지 않음). 두 수정은 **모순되지 않는다**: `702392a2`는 이미 병합되어 지금 + 당장 Strix의 org-wide 필수 게이트를 복구하고 있는(직접 인용: "이것이 모든 PR의 + opencode-review 체크를 교착시키고 있었다 — 그 체크는 dispatch 전에 완료된 Strix evidence를 + 요구하는데 Strix가 스캔을 완료할 수 없었다") 실사용 중인 fix이고, 이 세션의 fix는 근본 + 원인(게이트웨이 자신의 불필요한 거부)을 없애 향후 이 workaround 자체를 불필요하게 만들 + 후속 정리 대상이다. **지금 당장 `702392a2`를 되돌리거나 건드리지 않는다** — 아직 서버 쪽 + 수정이 병합·중앙 vendoring pin에 반영되지 않았으므로, 지금 워크어라운드를 제거하면 Strix가 + 다시 죽는다. `contextual-orchestrator#923` 병합 + `.github`의 `ORCHESTRATOR_PIN_SHA` 갱신 + 이후 별도 pass에서 이 workaround의 제거 가능 여부를 재검토한다(새 Gap 항목으로 기록). +- **이 발견이 바꾸는 것**: `702392a2`가 이미 `main`에 있으므로, Strix의 org-wide 필수 게이트가 + 이미 복구되어 있을 가능성이 높다 — 이전 조사에서 확인한 "`inspect_pr()`가 OpenCode dispatch를 + Strix evidence 뒤에 순서화하며 Strix가 완료되지 않으면 dispatch 자체가 발생하지 않는다"는 + 구조가, naruon#1486·contextual-orchestrator#923가 dispatch조차 받지 못했던 이유의 상당 부분을 + 설명했을 수 있다. naruon과 contextual-orchestrator는 자기 브랜치가 아니라 `.github`의 `main`에서 + 중앙 워크플로우를 매 dispatch 시점에 새로 가져오므로(trusted source ref), 이 두 PR은 **자기 + 브랜치를 건드리지 않고도** 다음 dispatch부터 이 fix의 혜택을 받을 수 있다. +- 병합 커밋(`c55620fc`) 검증: 전체 스위트 1898 passed/1 skipped/21 subtests, coverage TOTAL + 9966/9966 statements·3926/3926 branches **100%**, interrogate **100%**. 푸시 완료 — + `.github#1438`의 `mergeable_state`가 `dirty`(conflict)에서 `blocked`(required Checks/리뷰 + 대기, 정상)로 돌아왔다. +- **관찰**: 이 병합 직후 `contextual-orchestrator#923`의 `noema-review`가 `success`로 전환되었고 + (이전에 봤던 "healthz 통과 후 completion이 120초간 행" 시그니처가 이번에는 재현되지 않음), + `strix`도 (이전처럼 즉시 provider-unavailable로 실패하는 대신) 실제로 스캔을 진행 중이다 — + `702392a2`(Strix SDK streaming 비활성화 workaround)가 실제로 유효하게 작동하고 있다는 + 직접 증거다. `opencode-review`는 여전히 실패 상태이지만 이는 Strix가 아직 완료 전이라 + scheduler가 dispatch를 순서화하며 기다리는, 이미 알려진 정상 대기 상태다. +- `.github#1347`(SSRF/isolation)은 이번 pass에서도 손대지 않았다 — 별도 브랜치 + (`fix/sandboxed-web-e2e-isolation-clean`, `main` 대비 8월 26일 이후로 stale, `mergeable_state: + dirty`)이며 실제 로직 대조가 필요한 전용 pass 대상이므로, 이미 상당한 시간을 투입한 이번 + pass에 무리해서 끼워넣지 않고 명시적으로 다음 pass로 넘긴다. naruon G-06/G-15도 동일한 + 이유로 이번 pass에서는 착수하지 못했다 — 다음 pass의 최우선 항목으로 남긴다. + +## 2026-08-30 시간별 재개: 세 PR 재확인 + G-06/G-15/#1347 병행 조사 착수 + +세 PR(`naruon#1486`, `.github#1438`, `contextual-orchestrator#923`)의 required Checks를 +재확인했다. 공통 결론: 세 PR 모두 `opencode-review`가 실패 중이지만, 이는 코드 결함이 아니라 +현재 head에서 opencode-agent의 APPROVED/CHANGES_REQUESTED verdict가 아직 게시되지 않은, +이미 알려진 정상 비동기 대기 상태다(`.github#1438`은 구 head `c11b68c2`에서 받은 +`COMMENTED`(coverage gate가 그 시점에 실패해 opencode-agent 스스로 승인을 보류한 상태)만 +있고, 새 head `73459977`에 대한 verdict는 아직 없다; 나머지 두 PR은 아직 어떤 verdict도 없다). +`naruon#1486`의 `metadata-only gate evaluation` 실패도 동일하게 `opencode-review` 실패의 +하위 파생 결과일 뿐이다. + +`.github#1438`의 `noema-review`에서 이전에 문서화된 "healthz는 통과하지만 실제 completion +요청이 120초간 0바이트로 행"하는 시그니처가 재현됐다(`request_failed +status=413`/`provider_discovery_failed provider=bytez` 이후 healthz+preflight는 31초에 +확인됐으나, 이어진 `orchestrator/free` 전체 gateway preflight 요청이 `curl --max-time 120` +한계에서 0바이트로 타임아웃). 이 PR의 diff와 무관한 공유 리뷰 인프라(무료 티어 NVIDIA NIM +provider의 지연/부하 변동)로 판단해, 근거 없는 재작업 대신 governance 규칙에 따라 실패한 +job을 1회만 재실행했다(`rerun_failed_jobs`, run `33312587048`) — 재실행 결과는 다음 tick에서 +확인한다. + +병행해서 다음 3개 조사를 백그라운드 에이전트로 착수했다(결과는 다음 항목에서 반영): +1. naruon G-06 다음 증분 — thread/sender ontology 및 human-correction 슬라이스 중 어느 쪽이 + naruon의 기존 코드 관례(opaque `*_uid`, 구조화 Alembic, deny-first RBAC/ABAC) 위에서 + 가장 작고 실질적인 다음 조각인지 정찰. +2. naruon G-15 다음 증분 — 현재 첨부파일 1MB 상한의 실제 위치, 기존 parser/registry 유무, + streaming upload 여부, quarantine/zip-bomb 방어 유무를 정찰해 가장 작은 실질적 슬라이스를 + 특정. +3. `.github#1347`(SSRF/isolation) — PR 브랜치와 `main`이 독립적으로 각각 추가한 + `scripts/ci/sandboxed_web_e2e.py`의 SSRF 방어 로직을 정확히 대조하고, ordinary merge + commit(no rebase)으로 결합할 정확한 hunk별 해소안을 정찰. + +## 2026-08-30 시간별 재개: G-06 증분 배포 + `.github#1347` conflict 해소(동시 작업 병합 포함) + +세 배경 조사(위 §5.1)가 모두 완료되어 다음을 실행했다. + +- **naruon G-06 증분 배포**: 조사 결론(사람 정정 슬라이스가 sender ontology보다 작고 실질적인 + 다음 조각)에 따라, `evaluate_calendar_conflicts`의 결정을 `calendar_conflict_judgments` + 테이블에 판단(judgment)으로 영속화하고 `project_graph_corrections`와 동일한 before/after + 감사 흔적 패턴으로 사람이 그 판단을 정정할 수 있는 API 3개 + (`POST /judgments`, `GET /judgments`, `POST /judgments/{uid}/corrections`)를 + `naruon`에 추가했다(Alembic `0018_calendar_conflict_judgments`, 구조화 op). `/evaluate` + 자체의 무상태 계약은 바꾸지 않았다. 검증: 신규 테스트 8 passed, 전체 백엔드 스위트 1821 + passed/32 skipped(신규 skip 없음), ruff clean, `alembic heads`가 단일 head로 수렴. `naruon#1486`에 + 같은 브랜치로 push했다(#1486은 이미 이 세션이 연 PR이라 새 커밋이 자동으로 같은 PR에 반영됨). +- **`.github#1347`(SSRF/isolation) conflict 해소**: PR 브랜치를 로컬에 체크아웃해 `origin/main`을 + merge하니 사전 조사대로 정확히 3개 파일(`CHANGELOG.md`, `scripts/ci/sandboxed_web_e2e.py`, + `tests/test_sandboxed_web_e2e.py`)에서 충돌했다. `main`의 `require_loopback_readiness_url` + 계열(DNS-rebind 방지, userinfo 거부, IPv4-mapped IPv6 unwrap)을 정본으로 채택하고 PR + 브랜치의 bubblewrap isolation 코드와 "서비스 시작 전에 조기 실패"하는 `main()` 흐름은 그대로 + 유지했다. 이 과정에서 실제 회귀를 하나 발견해 직접 고쳤다: `main()`의 조기 검증 호출부는 + `wait_for_url`과 달리 빈 문자열 URL을 건너뛰는 가드가 없어, `--backend-ready-url`/ + `--frontend-ready-url`(기본값 `""`, "readiness 체크 없음"을 의미하는 흔한 경우)을 그대로 + 넘기면 `require_loopback_readiness_url("")`이 항상 실패하는 회귀가 생길 뻔했다 — 호출부에 + `if args.backend_ready_url:` 가드를 추가해 막았다. + **동시 작업 충돌**: 이 merge를 push하려는 순간, 같은 PR 브랜치에 이미 다른 세션이 정확히 + 동일한 `origin/main` merge를 독립적으로 수행해 먼저 push했음을 발견했다(동일한 3개 파일 + conflict, 동일 시각대). 지시문의 "동시 remote-agent 커밋을 경쟁으로 취급해 force-push하지 + 않는다"에 따라, 그 원격 커밋을 로컬에 merge해 재조정했다: `scripts/ci/sandboxed_web_e2e.py`는 + 두 세션의 해소가 완전히 동일해 자동 merge됐고(빈 문자열 가드 fix 포함, 서로 다른 세션이 같은 + 회귀를 각자 발견해 같은 방식으로 고쳤음을 확인) — `CHANGELOG.md`는 상대 세션이 쓴 더 완결된 + 단일 문단을 채택했으며, `tests/test_sandboxed_web_e2e.py`의 사소한 중복 assertion 2줄은 + 상대 세션 쪽(중복 없는 버전)을 채택했다. 재검증: PR 자체 명시 테스트 113 passed, 전체 스위트 + 1912 passed/1 skipped/21 subtests, coverage 100%, interrogate 100%. Push 완료 — + `mergeable_state`가 `dirty`에서 `blocked`(required Checks/리뷰 대기, 정상)로 전환됨을 확인했다. +- **naruon G-15**: 이번 pass에서는 정찰만 완료(현재 1MB/20MB/64MB로 흩어진 상한 위치, 이미 + 존재하는 MIME 키 parser registry(`_PARSER_MANIFEST`), zip-bomb 방어가 첨부 경로에는 전혀 + 없음을 확인). 가장 작은 실질적 슬라이스로 "MIME sniffing + 불일치 시 명시적 + quarantine 상태 + `attachment_uid` 부여 + reparse-intent API"를 특정했으나, 아직 구현하지 + 않았다 — 다음 pass 최우선. +- **추가**: `naruon#1486`에 push한 직후 Devin Review가 새 `calendar_conflict_judgment_service.py`에 + 대해 5건, github-code-quality가 1건을 지적했다(6개 unresolved review thread, PR governance + metadata gate 차단). 모두 검증 후 실제로 고쳤다: (1) `apply_correction`이 대상 judgment 행을 + `SELECT ... FOR UPDATE`로 잠가 동시 정정 경쟁을 막음, (2) `decision_code`를 바꾸는 정정은 + `reason_code`/`recommended_action`도 함께 교체해(`corrected_by_human_review` + rationale) + 서로 다른 결정의 필드가 섞인 응답을 방지(원본은 `before_json`에 보존), (3) `list_judgments`에 + 200건 상한 추가, (4) `MAX_EXISTING_COMMITMENTS`를 `services/calendar_conflict_policy.py`의 + 공유 상수로 통합해 `api/calendar_conflicts.py`/`noema_agent.py`가 서로 어긋날 수 없게 함, (5) + 테스트 파일의 이중 import 스타일 정리. 유일하게 고치지 않은 지적("PostgreSQL persistence + remains unverified")은 이 세션에 Postgres 접근이 없어 `test_project_graph_api.py`의 기존 + Postgres-스킵 스모크 테스트와 동일한 한계임을 코멘트로 남기고 resolve했다. 6개 thread 모두 + 코멘트+resolve 완료. 검증: 신규 테스트 4개 추가, 전체 백엔드 스위트 1825 passed/32 skipped, + ruff clean. +- **추가(2차 Devin Review, 보안 finding 포함)**: 위 fix가 push되자 Devin이 같은 head에 6건을 + 더 지적했다. 가장 중요한 것은 **[보안, 최우선]** "workspace 경계를 넘어 판단을 열람·정정할 + 수 있다"는 finding이었다 — `calendar_conflict_judgments`/`corrections`가 `user_id`+ + `organization_id`만으로 범위를 제한하고 `workspace_id`를 빠뜨렸는데, `AuthContext.workspace_id`는 + 세션 토큰의 독립 claim(`api/auth.py`의 `_required_string_claim(payload, "workspace")`)이라 + 테스트 스텁만 편의상 user_id/org에서 파생할 뿐, 실제로는 동일 user_id+organization_id가 + 서로 다른 workspace를 오갈 수 있어 실제 인가 우회였다. 검증 후 `naruon`의 기존 + `project_graph` 모듈이 이미 확립한 workspace_id 스코핑 관례를 그대로 따라 두 테이블·모든 + scoped 쿼리·API 4개 경로에 `workspace_id`를 추가했다(Alembic `0018`은 아직 어떤 DB에도 + 적용되지 않은 이번 PR 자체 마이그레이션이라 새 마이그레이션 대신 직접 수정). 나머지 5건도 + 모두 고쳤다: `list_judgments`의 200건 상한 이후 접근 불가 문제는 전체 페이지네이션 대신 + `GET /judgments/{judgment_uid}` 단건 조회로, correction rationale이 recommended_action으로 + 둔갑하는 문제는 `calendar_conflict_policy.py`에 새로 추가한 + `default_recommended_action()`(정책 자체의 단일 소스, `evaluate_calendar_conflicts`도 재사용)로, + status_code/decision_code 모순 조합은 API 모델 validator + 서비스 계층 이중 검증으로, + ICS 파서의 별도 500건 하드코딩은 공유 상수로, Noema 도구의 스킵된 행 개수 미공개는 + `skipped_existing_count` 필드 추가로 해소했다. 6개 thread 모두 코멘트+resolve 완료. 검증: + 전체 백엔드 스위트 1835 passed/32 skipped(무관한 process-group 타이밍 테스트 1건이 전체 + 스위트 동시 실행에서만 간헐적으로 실패, 단독 실행 시 통과 확인 — 이번 변경과 무관), ruff + clean, `alembic heads` 단일 head 유지. push 완료(86f4bd9b). + ## 2026-08-30 PR #1347 Devin Review 6건 검증: 4건 실재 결함 수정, 2건 확인 후 해소 `ContextualWisdomLab/.github#1347` (`fix/sandboxed-web-e2e-isolation-clean`, @@ -1530,6 +2047,33 @@ accidental redundancy (`docs/adr/0002-product-technical-gap-baseline.md`: this d operational snapshot" and "live PR metadata inventory," a distinct role from the ADR's settled design record and the CHANGELOG's terse pointer entries, not a duplicate of either). +**추가 (2026-08-30, 같은 시간별 재개 안):** `main`이 위 sidecar-preflight ADR-0005(#1449)를 병합해 +`6ffd8f8a`로 전진하면서 `.github#1347`(`fix/sandboxed-web-e2e-isolation-clean`)과 +`.github#1438`(공유 task 브랜치) 둘 다 `mergeable_state`가 `dirty`로 전환됐다. 두 곳 모두 충돌은 +`CHANGELOG.md`/`docs/product-technical-gap-baseline.md`에 각자 독립적으로 추가한 인접 항목뿐이었다 +(같은 줄을 편집한 실제 충돌 아님) — 양쪽 다 유지하는 통상적 merge commit으로 해소했다 +(`.github#1347`: 커밋 `583af50b`, 전체 스위트 1930 passed 재확인; task 브랜치/`.github#1438`: 커밋 +`c76e5a24`, 전체 스위트 1898 passed 재확인). Force-push 없음. + +**추가 2**: 다른 세션이 `.github#1347`에 symlink-escape 가드를 두 라운드 더 강화했다(Devin이 +`resolve(strict=True)`가 `DEFAULT_IGNORE`로 제외된 멀쩡한 dangling symlink까지 오탐한다고 지적 → +`os.readlink`+`os.path.normpath` 기반 hop-by-hop lexical walk로 교체한 `be77d299`, 그 walk의 +off-by-one을 고친 `fe237c4f` — 정확히 N-hop인 정상 체인이 잘못 거부되던 문제, 1934 passed로 검증). +그 직후 `main`이 다시 전진해(`1ff82682`, ADR-0005의 실제 diagnostic/bounded-retry preflight 구현) +`.github#1347`과 task 브랜치/`.github#1438` 둘 다 재차 dirty가 됐다 — 이번에도 CHANGELOG/gap-baseline의 +인접 항목 추가일 뿐이라 양쪽 유지하는 통상 merge로 해소(`.github#1347`: `46fdc2d7`, 1967 passed; +task 브랜치/`.github#1438`: `6a74c672`, 1931 passed). Force-push 없음. 같은 패스에서 naruon#1486에 +새로 도착한 Devin 6건 + CodeRabbit 2건도 검증했다: 실재 결함 3건을 고쳤다 — (1) DOCX/XLSX/PPTX 등 +ZIP 기반 컨테이너 형식이 ZIP 매직 바이트와 일치한다는 이유만으로 quarantine되던 오탐(ZIP 컨테이너 +계열 MIME 부분 문자열 판정으로 제외), (2) 상한 초과로 바이트를 보존 못한 mismatch가 여전히 +reparse-intent가 수락하는 quarantine 상태를 받던 문제(다른 초과-크기 첨부와 동일하게 +parse_size_limit_exceeded로 전환), (3) `apply_correction`의 status_code/decision_code 검증이 +텍스트 전용 ValueError였던 것을 `error_code` 속성을 가진 타입으로 교체(현재 REST 경로는 Literal +타입으로 이미 막혀 있어 방어적 일관성 확보 목적). 🟥 보안 지적(`_get_scoped_attachment`가 +workspace_id를 검증하지 않음)은 실재하지만 `Email` 모델 자체가 애초에 workspace_id가 없다는 +저장소 전반의 기존 gap임을 확인해 조용히 임시방편을 넣는 대신 ADR에 후속 작업으로 명시했다. +review thread 25/25 코멘트+resolve 완료(커밋 `dcc9fcd0`, 전체 백엔드 스위트 1845 passed). + - **Implemented** (`scripts/ci/contextual_orchestrator_review_launcher.py`, `scripts/ci/contextual_orchestrator_review_sidecar.sh`): Layer 1's `_preflight_review_agents` now probes each candidate at a new `REVIEW_PREFLIGHT_BASE_TOKENS = 16`, escalating that same candidate @@ -1733,11 +2277,555 @@ signature as the original round-4 bug) before passing after the fix. 1930 tests ### 5.1 이번 루프의 다음 개발 increment -1. ContextualWisdomLab/.github#1297 — current-head Strix serialization과 scoped close cleanup의 hosted Checks·독립 승인을 재확인한 뒤 보호된 auto-merge를 기다린다. -2. ContextualWisdomLab/.github#1345/#1347 — 각각 normalizer 선형 스캔과 web-E2E isolation/SSRF 수정의 terminal Checks·Strix·Noema 증거를 같은 HEAD에서 재확인한다. -3. ContextualWisdomLab/.github#1326 — Appguardrail/macOS hourly caller를 current CodeRabbit finding 및 APA citation evidence와 함께 재검토한다. -4. G-01/G-02는 중앙 control-plane merge evidence의 current-head 품질 문제, G-05/G-06는 naruon ecosystem 소비 증거, G-15는 대용량·미지원 첨부파일 parser registry의 소유 저장소 PR로 연결한다. -5. `scripts/ci/select_nvidia_nim_model.py`(호출자 없음, 위 §5의 여러 항목이 이미 문서화)를 별도의 작은 PR(`fix/remove-orphaned-nim-model-resolver`)로 분리 제거했다 — `#1437` 리뷰 스레드가 명시적으로 요청한 대로 direct-NIM cleanup을 pool-flip 논의와 분리했다. `contextual_orchestrator_review_sidecar.sh`의 참조 주석은 git history를 가리키도록 갱신했다. +1. ContextualWisdomLab/contextual-orchestrator#923 — main과 이미 동기화됨, ready-for-review로 전환 + 완료. discovery-side transient-retry 수정(진짜 root cause였던 family_cap/gateway-timeout 문제와는 + 별개의, 독립적인 resilience 개선)의 required Checks·독립 승인을 재확인하고, 조건 충족 시 merge한다. + 병합 후 `.github`의 `ORCHESTRATOR_PIN_SHA`를 해당 커밋으로 갱신하는 후속 PR이 필요하다(#1422/#1426이 + 이미 확립한 패턴과 동일). +2. ContextualWisdomLab/.github#1438 — main과 이미 동기화됨(6개 owner bypass-merge 반영), ready-for-review로 + 전환 완료. stderr tail 확장(`SIDECAR_STDERR_TAIL_LINES`) + gap-baseline correction(Bytez/413 오귀속 + 정정, family_cap 수정이 진짜 root cause임을 반영)의 required Checks·독립 승인을 재확인하고, 조건 충족 + 시 merge한다. +3. ContextualWisdomLab/naruon#1486 — develop과 이미 동기화됨, ready-for-review로 전환 완료. `check_calendar_conflict` + 도구 위에 G-06의 human-correction 슬라이스(judgment 영속화 + 정정 API 4개 — 단건 조회 `GET + /judgments/{judgment_uid}` 포함, Alembic `0018`)를 추가 배포했고, 이후 여러 라운드에 걸쳐 도착한 Devin + Review 전건(1차 6건 + 2차 6건 — workspace_id 인가 우회 보안 수정 포함 + 3차 no-op override rationale + 보존 수정 + 4차 doctoring 문서 최신화/자기모순 정정) + github-code-quality 1건을 모두 실제로 고치고 + review thread 17/17을 전부 코멘트+resolve했다(row lock, decision/reason/action 일관성, workspace_id + 스코핑, list 상한 + 단건 조회 우회, 공유 상수 통합, import 정리, doctoring 문서 동기화). naruon 자체 + required Checks(OpenCode/Strix/merge-scheduler)를 current head(`a5cebe53`)에서 재확인하고, 조건 충족 + 시 merge한다. +4. ContextualWisdomLab/.github#1347 — **conflict 해소 완료** (더 이상 "아직 손대지 않음"이 아니다). web-E2E + isolation/SSRF 수정을 `main`과 merge해 정확히 예상된 3개 파일 충돌을 해소했고(main의 DNS-rebind 방지 + validator 채택 + PR의 bubblewrap isolation 유지), 그 과정에서 실제 회귀(빈 readiness URL 처리 누락)도 + 고쳤다. push 직전 다른 세션이 이미 동일한 merge를 독립적으로 push한 것을 발견해 force-push 없이 + 재조정했다(위 2026-08-30 "G-06 증분 배포 + `.github#1347` conflict 해소" 항목 참조). 현재 + `mergeable_state`는 `dirty`가 아니라 `blocked`(required Checks/리뷰 대기, 나머지 세 PR과 동일한 정상 + 상태)다. required Checks·독립 승인을 재확인하고 조건 충족 시 merge한다. +5. G-01/G-02는 중앙 control-plane merge evidence의 current-head 품질 문제다. G-06은 `#1486`이 이제 + `check_calendar_conflict`(temporal commitment/conflict) + judgment/correction API(human correction) + 두 다리를 모두 갖춘 실질적 증분을 배포했다 — 남은 것은 thread/sender ontology 다리뿐이다. G-15(대용량· + 미지원 첨부파일 parser registry)는 정찰만 끝났고(MIME sniffing + quarantine status + `attachment_uid` + + reparse-intent API로 슬라이스 특정) 아직 구현하지 않았다 — 다음 pass 최우선 구현 대상이다. + completion warm-up probe(`proxy_send_once`) 자체의 재시도 여부는 이미 merge된 family_cap/gateway-timeout + 수정의 실제 hosted-run 결과와, `main`에 이미 병합된 `log "sidecar preflight route evidence: ..."` + 가시성 라인이 향후 축적할 실제 transience 증거가 나오기 전까지 보류한다 — 지금 다시 시도하는 것은 + 추측에 기반한 재작업일 뿐이다. + +## 2026-08-30 시간별 재개: 4개 PR 재확인 + `.github#1347` 실제 수정 착수 + G-15 착수 + +네 PR(`naruon#1486`, `.github#1438`, `contextual-orchestrator#923`, `.github#1347`)의 현재 head에서 +`get_check_runs`/`get_reviews`/`get_review_comments`를 전부 다시 읽었다. 결과: + +- `naruon#1486`(head `a5cebe53`): review thread 17/17 resolved. `opencode-review`와 + `metadata-only gate evaluation` 둘 다 `failure`이지만, 이 head에 대한 opencode-agent verdict가 + 아직 게시되지 않은 것뿐(같은 head에 대한 Devin/CodeRabbit/OpenCode 코멘트가 전무) — 이미 문서화된 + 비동기 대기 패턴이지 코드 결함이 아니다. Merge 조건 미충족, 다음 pass에서 재확인. +- `.github#1438`(head `a1c2ba50`): review thread 1건만 unresolved — Devin이 `86f4bd9b` push 이후 + §5.1 item 3이 여전히 `naruon#1486`의 stale head `7c20155f`를 가리킨다고 지적(정확한 지적). 그 사이 + `naruon#1486`은 두 라운드(no-op override 수정, doctoring 문서 자기모순 정정)를 더 거쳐 `a5cebe53`까지 + 진행한 상태였다. §5.1 item 3을 현재 head(`a5cebe53`)와 전체 review 이력(1차 6건 + 2차 6건[workspace_id + 보안 수정 포함] + 3차 no-op override 수정 + 4차 문서 정정, thread 17/17 resolved)으로 재작성해 커밋 + `c2013d02`로 push. `PYTHONPATH=. pytest tests/test_product_technical_gap_baseline.py` 5 passed로 + contract 유지 확인. +- `contextual-orchestrator#923`(head `eb453448`): review thread 5/5 resolved, 남은 건 없음. + `opencode-review`만 동일한 비동기 대기 패턴으로 `failure`. +- `.github#1347`(head `6ed44666`): review thread 25개 중 **6개가 unresolved** — Devin의 최신 라운드 + (commit `7ac8298b`)가 남긴 findings로, 그중 하나는 🟥 최고 심각도(**workspace 내 symlink가 파일시스템 + isolation을 우회할 수 있음** — 호스트 경로를 가리키는 repo 내 symlink가 sandbox로 복사되는 워킹 카피에 + 살아있는 채로 남아, 샌드박스 명령이 그 symlink를 따라가 sandbox 밖 호스트 파일을 읽거나 쓸 수 있다는 + 주장), 나머지는 🟡🟡🟨(malformed readiness port가 검증을 우회, bwrap이 PATH에는 있지만 실제 namespace + 생성 권한이 없는 host를 오분류, `isolated_command`가 `shutil.which`로 못 찾은 실행 파일을 검증 없이 + 통과시킴) + 📝 info 2건. 이전 pass에서 "conflict 해소 완료"로 기록했던 것은 main과의 3파일 merge + conflict였을 뿐, 이번 6건은 그 이후 새 Devin 라운드가 실제 코드에 대해 제기한 별개의 주장들이다 — 아직 + 검증도 수정도 하지 않은 상태였다. 이번 pass에서 이 6건을 현재 코드 기준으로 직접 검증하고 실제 결함만 + 최소 범위로 고치는 백그라운드 에이전트를 별도로 기동했다(worktree 격리, `fix/sandboxed-web-e2e-isolation-clean` + 브랜치, 6개 thread 각각에 회신+resolve, 기존 SSRF/isolation 테스트 재실행 후 push). + **완료 및 검증 결과**: 6건 중 4건 실재(malformed port, capability probe 부재, `isolated_command`의 + unresolved-executable 우회, **workspace symlink escape — 단 `--isolation required` 경로가 아니라 + `sandboxed_verify.py`/`--isolation disabled` 경로에서 실재. bwrap 필수 경로에 대한 최초 "재현 불가" + 판단은 정확했지만 그 경로 하나만 봤다는 게 놓친 부분이었다**), 2건은 확인 후 변경 불필요. 동시에 진행 중이던 + 다른 세션의 겹치는 수정(`c01c1aa2`)을 발견해 강제 push 없이 `git merge`로 재조정(`4088430a`). 그 직후 + 같은 파일에 대한 새 Devin 라운드가 3건을 추가로 남겼다(probe가 실제 `isolated_command`보다 적은 연산만 + 검증, 🟥 "sandbox가 로그·자격증명을 노출" — 후자는 로그/scrubbed home의 쓰기 가능 mount 자체는 의도된 + 설계이지만 repo checkout이 우연히 갖고 있을 수 있는 자격증명 파일이 그대로 복사되는 것은 실재 결함이었음). + 이 3건도 검증 후 실제로 고쳤다(probe가 `--new-session`/`/tmp`/실제 mount point로의 bind+chdir까지 진짜 + 임시 디렉터리로 재현하도록 확장, `copy_workspace` 기본 제외 목록에 `.env*`/`.netrc`/`.npmrc`/`.ssh`/`.aws` + 등 자격증명 경로 추가, 커밋 `bde444d4`). Push 직전 또 다른 동시 세션의 겹치는 수정(symlink 순환 탐지를 + `resolve(strict=True)`로 강화)을 발견해 다시 `git merge`로 재조정(`cb25974c`). review thread 28/28 + 전부 코멘트+resolve 완료. 검증: 전체 스위트 1930 passed/1 skipped/21 subtests, coverage 100% + (`sandboxed_verify.py` 120/120, `sandboxed_web_e2e.py` 282/282), interrogate 100%, ruff clean. +- **naruon G-15 첫 슬라이스를 실제로 배포했다** (`naruon#1486`의 같은 브랜치에 push, 커밋 `ee83effd`). + 정찰 에이전트가 확인한 사실(`Attachment`에 opaque id 부재, `_PARSER_MANIFEST`가 튜플 기반 정적 + 디스크립터, 상한이 1MB/20MB/64MB 세 곳에 흩어져 있으나 각각 다른 게이트, quarantine 개념 전무, + Alembic 최신 head `0018`, `docs/adr/`가 0001-0004까지 존재)를 바탕으로 구현: (1) + `services/attachment_parser.py`가 첨부파일의 실제 바이트를 알려진 매직 바이트(PDF/PNG/JPEG/GIF/ZIP)로 + 스니핑해 선언된/추론된 content_type과 다르면 파싱·보류·unsupported 분류 대신 + `parse_status=parse_error_code="content_type_mismatch_quarantined"`으로 격리(원본 바이트는 기존 + 20MB 상한 재사용해 base64 보존, 새 컬럼 없이 기존 `content_type`/`parse_content_type` 두 컬럼 + 비교만으로 declared-vs-actual을 드러냄), (2) `Attachment.attachment_uid` 오파크 id 추가(Alembic + `0019_attachment_uid`, 기존 행 백필하는 구조적 마이그레이션), (3) `POST + /api/data/attachments/{attachment_uid}/reparse-intent`가 quarantine된 첨부파일을 + `reparse_pending`으로 전환하는 intent만 기록(기존 hwp-conversion-intent/pdf-dom-recognition-intent와 + 동일 패턴 — 실제 재파싱 워커는 별도 후속 슬라이스로 명시적으로 미룸), (4) + `docs/adr/0005-attachment-content-type-quarantine.md` 신설 + README 색인 갱신. 검증: 신규 테스트 + 8개(파서 5 + API 3) 추가, 전체 백엔드 스위트 1842 passed/33 skipped(회귀 없음), ruff clean, + `alembic heads`가 `0019_attachment_uid` 단일 head로 수렴. 다음 슬라이스 후보: `reparse_pending`을 + 실제로 소비하는 워커, HWP/HWPX 지원, 단일 첨부파일 upload-accept 상한(현재 부재). + +**추가**: 이후 `naruon#1486`에 Devin 6건 + CodeRabbit 2건이 더 도착해 실재 결함 3건을 고쳐 커밋 +`dcc9fcd0`로 push했다(전체 스위트 1845 passed/33 skipped, review thread 25/25 코멘트+resolve) — (1) +DOCX/XLSX/PPTX 등 ZIP 기반 컨테이너 형식이 ZIP 매직 바이트와 일치한다는 이유만으로 quarantine되던 +오탐(ZIP 컨테이너 계열 MIME 부분 문자열 판정으로 제외), (2) 상한 초과로 바이트를 보존 못한 mismatch가 +여전히 reparse-intent가 수락하는 quarantine 상태를 받던 문제(다른 초과-크기 첨부와 동일하게 +`parse_size_limit_exceeded`로 전환), (3) `apply_correction`의 status_code/decision_code 검증이 +텍스트 전용 `ValueError`였던 것을 `error_code` 속성을 가진 타입(`CalendarConflictUnsupportedValueError`) +으로 교체. CodeRabbit의 `CHANGES_REQUESTED`(이전 head `ee83effd` 대상)가 지적한 2건 중 후자는 이렇게 +고쳤고, 전자(quarantine된 첨부파일을 실제로 재처리하는 worker 부재)는 위 "다음 슬라이스 후보"에 이미 +있던 바로 그 gap — ADR-0005에 알려진 후속 작업으로 명시된 채로 유지, 임시방편 없이 그대로 둔다. +CodeRabbit이 rate limit 이후 `dcc9fcd0`에 새 pass를 돌리면 review decision이 자동 갱신될 것으로 예상. +네 PR 모두 이번 pass에서 `get_check_runs`/job 로그로 재확인: `mergeable_state`는 전부 `blocked`이고, +원인은 전부 동일한 이미 문서화된 패턴(`opencode-review` job이 "No APPROVED or CHANGES_REQUESTED from +opencode-agent on the current head"로 exit 1 — 그 head에 대한 authenticated dispatch verdict이 아직 +게시되지 않은 것뿐, merge conflict나 코드 결함이 아님). `.github#1438`에는 Devin이 새로 3건(테스트가 +텍스트만 검사한다는 지적 — 이 계약 테스트 파일 전체가 원래 텍스트 기반 검증이라 이 PR이 새로 도입한 +격차가 아님, 순수 정보성 확인 2건)을 남겨 전부 검증 후 코멘트+resolve했다. CodeRabbit도 diff 밖(outside +diff range) finding 1건을 남겼다: `REVIEW_PREFLIGHT_GATEWAY_MAX_ATTEMPTS` 검증의 zero 거부가 정확히 +한 글자인 `"0"`만 매치해 `"00"`/`"0000"`처럼 전부 숫자이면서 0인 값이 통과했고, `[ -ge ]`는 이를 10진수 +0으로 파싱해 재시도 1회 만에 실패(설정한 재시도 횟수를 무시하는, 원래 malformed-value 버그와는 반대 +방향의 결함)하는 실재 버그였다 — case 패턴에 `00`/`0000`을 추가하고 기존 parametrized 회귀 테스트에도 +포함시켜 커밋 `17974388`으로 고쳤다(전체 스위트 1933 passed/1 skipped/21 subtests, coverage 100%, +interrogate 100%). `.github#1347`에는 또 새 Devin 라운드가 도착해 실제 코드 대조 검증을 전담 백그라운드 +에이전트에 위임했다 — **완료**: 위임 중 다른 세션이 독립적으로 동일한 9건 중 4건을 이미 고쳐(`fe68c2fa` +— repo launcher 경로 해석, `.env.example` 등 env 템플릿이 `DEFAULT_IGNORE`에 잘못 걸리던 문제, 공백만 +있는 명령이 코드화된 실패 대신 처리되지 않은 예외로 새던 문제, `shutil.which("sh")`가 호출자 PATH를 써 +bwrap mount 안에 없는 셸을 잘못 통과시키던 문제) 별도 hardening(`bd0697aa`, symlink 순환 탐지를 재귀 +active-set 방식으로 교체)까지 push했음을 발견 — 에이전트는 자신의 중복 draft를 버리고 `git merge +--ff-only`로만 재조정했다(새 커밋 없음, force-push 없음). 남은 것 중 (a) "rejected copy 유지" — +`copy_workspace`의 symlink-escape `ValueError`를 `sandboxed_verify.py`/`sandboxed_web_e2e.py` 양쪽 +`main()` 모두 잡지 않아 코드화된 실패 대신 raw traceback이 새던 실재 버그를 확인·수정(exit 125, 기존 +다른 ValueError 거부와 동일 관례; `--keep-sandbox`가 rejected copy도 유지하는 것은 "디버깅용 보존"이라는 +플래그의 명시된 목적과 일치해 정책 문제가 아니라고 판단, 변경하지 않음) — 커밋 `528a1eae`로 push. + +이후 Devin이 새 라운드에서 finding 2건을 더 남겨(총 unresolved 7건으로 증가) — "명시적 `--ignore +.env.example`가 env-template allowlist에 의해 무시당함"과 "PATH의 relative entry가 wrapper 자신의 +cwd 기준으로 해석돼 valid한 workspace-local 도구가 격리에서 거부됨" — 둘 다 직접 검증해 고치고 push +직전 재확인했더니, 이번엔 다른 세션이 **동일한 두 버그를 독립적으로, 동등하거나 더 방어적인 방식으로** +이미 고쳐 push해 있었다(`5b96f849`/`3c32d3ca` — PATH 수정은 relative entry가 sandbox_root 밖으로 +나가는 경우 파일시스템을 건드리기도 전에 걸러내는 추가 방어까지 포함). 내 uncommitted 중복 edit을 +버리고 `git merge --ff-only`로 재조정(새 커밋 없음). 나머지 2건은 정보성 확인(이미 정확, 조치 불필요), +1건(readiness probe가 bwrap의 `--unshare-net` 부재로 같은 runner의 다른 서비스를 오탐할 수 있음)은 +다른 세션이 PR 코멘트(`issuecomment-5469492897`)로 3가지 옵션(잔여 위험 수용 / `/proc` 기반 포트-소유권 +검증 / 전체 network-namespace 재설계)을 제시한 진짜 아키텍처 결정 사항이라 thread를 열어둔 채 유지. +review thread 7건 중 6건 코멘트+resolve, 1건(아키텍처 결정)은 의도적으로 open 유지. 최종 push된 tip +(`3c32d3ca`) 검증: 전체 스위트 1984 passed/1 skipped/21 subtests, coverage 100%, interrogate 100%, +ruff clean. + +**추가**: `ContextualWisdomLab/naruon#1486`(head `dcc9fcd0`)의 required `strix` check가 실패해 job +로그를 직접 확인 — "Strix run failed for model 'orchestrator/free' after 5404s (exit code 124)", +즉 스캐너 자체가 ~90분 만에 timeout한 것이지 이 PR의 diff에 대한 스캔 결과가 아니었다. 이 job 로그 +자체는 일반적인 timeout(exit 124)만 보여줄 뿐 request-level 증거는 없어, `.github#1438` PR 설명에 +이미 추적 중인 "healthz는 통과하지만 실제 completion 요청이 0바이트로 120초 행"과 정확히 같은 +메커니즘이라고 확인한 것은 아니다 — 같은 범주(contextual-orchestrator 게이트웨이 backend 가용성 +문제)로 잠정 분류했을 뿐이다. PR에 이 근거와 함께 standing-down 코멘트를 남기고 `rerun_failed_jobs`로 +1회 재실행 — **재실행 성공**(`conclusion: success`), 일회성 timeout이었음을 확인. +`ContextualWisdomLab/naruon#1486`에 남은 유일한 required-check 실패는 여전히 opencode-review의 +비동기 verdict 대기뿐. + +**참고(main 자체의 별개 증거, 이 PR의 required check 아님)**: `.github#1438`에 붙은 "Default-branch +repository_dispatch Strix evidence" 상태(main 자신, head `1ff82682` — 정확히 ADR-0005 sidecar fix를 +구현한 그 커밋 — 를 스캔)가 실패. job 로그 확인: sidecar 자체는 정상 기동해 "healthz and +provider-route preflight confirmed after 36s"까지 성공했으나(Bytez 500은 기존에 알려진 non-fatal +경고), 그 이후 워크플로 자신의 "gateway preflight" 단계가 3회 재시도(각 2분 간격, 총 6분) 모두 +"did not reach the sidecar cleanly (status=unreachable)"로 실패 — 이전에 문서화된 "healthz는 통과하되 +completion 요청이 0바이트로 hang"과는 다른 새 증상(요청이 hang하는 게 아니라 sidecar 자체가 preflight +성공 이후 어느 시점에 완전히 응답 불가 상태가 된 것으로 보임, 프로세스 조기 종료 또는 포트 미수신 +가능성). main 자신에 대한 평가라 이 PR의 required check를 막지 않고(diff와 무관), 별도 PR로 원인 +조사가 필요한 새 데이터 포인트로만 기록한다. + +**naruon G-15 두 번째 슬라이스 배포**: 4개 PR이 전부 async 대기 상태로 머물러 있는 동안, PR 소진 +여부와 무관하게 다음 제품 Gap 증분을 진행한다는 원칙에 따라 `reparse_pending`을 실제로 소비하는 +`AttachmentReparseWorker`를 구현·배포했다(`naruon#1486`의 같은 브랜치에 push, 커밋 `ba9a01be`) — +ADR-0005가 명시적으로 미뤄뒀던 바로 그 후속 워커. `NewsdomRecognitionWorker`와 동일한 +jittered-loop + PostgreSQL advisory-lock lease + starvation-free cursor 구조를 그대로 따라 +`main.py` lifespan에 배선했고, 매 스윕마다 보존된 원본 바이트 + 원래 선언된 `content_type`으로 +`parse_email_attachment`를 재호출해 재평가한다(sniff된 타입을 신뢰하는 별도 로직 없이 동일 +분류 파이프라인에 같은 질문을 다시 던지는 방식 — 향후 그 파이프라인에 생기는 어떤 수정도 +자동으로 이 워커에 반영됨). 보존 payload가 base64로 유효하지 않은 경우만 새 terminal 상태 +`reparse_payload_invalid`로 분류. PDF 전용이 아닌 범용 base64 디코더 +`decode_quarantined_attachment_payload`도 `attachment_parser.py`에 추가했고, ADR-0005의 +"reparse_pending has no consumer worker yet" 서술과 그 README 색인 행을 갱신했다. 검증: 신규 +테스트 19개(worker 15 + parser 디코더 4), 전체 백엔드 스위트 1864 passed/33 skipped(기존 1845), +ruff clean. 남은 G-15 후보: HWP/HWPX 지원, 단일 첨부파일 upload-accept 상한. G-06(thread/sender +ontology 다리)이 다음 pass의 우선순위 후보로 남는다. + +**naruon#1486 `AttachmentReparseWorker` CodeRabbit/Devin review 라운드(같은 브랜치, 커밋 +`ef49fc96`/`da816566`)**: 두 실제 정합성 결함을 근본 수정했다 — (1) `_sweep_attachments`가 배치 +처리 *전에* 커서를 `rows[-1].id`로 미리 전진시켜, 처리 중 예외로 `reparse_pending` 상태 그대로 +남은 행이 커서 아래로 떨어져 전방 큐가 완전히 비워질 때까지(지속 트래픽 하에서는 무한정) 다시 +선택되지 못하던 starvation 버그 — 첫 실패 행 바로 앞까지만 커서를 전진시키도록 수정. (2) +PostgreSQL advisory lease를 매 항목 `commit()`이 커넥션을 풀로 반환하는 동일한 `AsyncSession`으로 +획득·해제해, 해제가 lock을 잡았던 것과 다른 물리 커넥션에서 실행되어 lease가 영구히 묶일 수 +있던 문제 — 스윕 전체 동안 여는 전용 `AsyncConnection` 하나로만 획득·해제하도록 재설계. 이 +두 번째 결함과 완전히 동일한 acquire/release-through-the-per-item-session 구조가 +`services/newsdom_worker.py`에도 그대로 있어 같은 잠재 위험을 가진 것으로 추정되나, 이 PR이 +건드리지 않은 기존 코드라 수정 범위 밖으로 남겨둔다 — **후속 후보**: newsdom_worker도 동일한 +전용-커넥션 lease 패턴으로 맞추는 별도 PR. 추가로 Alembic `0019_attachment_uid`의 downgrade가 +`Base.metadata.create_all()`로 부트스트랩된 DB(제약 형태의 동일 이름 인덱스)에서 `DROP INDEX`가 +거부되는 경로를 고쳤고, `list_judgments` 정렬에 `calendar_conflict_judgment_id` 2차 키를 +추가해 동일 타임스탬프 경합을 제거했다. 검증: 전체 백엔드 스위트 1875 passed/33 skipped, ruff +clean. ADR-0005 Revisions/Decision 두 문서 불일치와 `Email.workspace_id`(이미 추적된 gap의 +재발견) 스레드도 각각 문서 수정과 회신으로 정리했다. + +**위 "후속 후보"를 실제로 배포했다 — `services/newsdom_worker.py`도 동일한 두 결함을 그대로 +갖고 있어 근본 수정** (naruon#1486의 같은 브랜치에 push): `AttachmentReparseWorker`에 적용한 +것과 완전히 동일한 두 수정을 `NewsdomRecognitionWorker`의 첨부파일/문서 두 스윕 모두에 +적용했다 — (1) advisory lease를 스윕 전체 동안 여는 전용 `AsyncConnection` 하나로만 +획득·해제(`_engine_uses_postgresql()`/`_try_acquire_sweep_lease`/`_release_sweep_lease`가 +이제 세션이 아니라 커넥션을 받음), (2) 커서를 첫 실패 행 바로 앞까지만 전진. 문서 커서는 +`Document.document_id`가 문자열 기본키라 "실패 id - 1" 산술이 불가능해, 실패 이전에 실제로 +커밋된 마지막 행의 id를 추적하는 방식으로 일반화했다(연속 정수 키에서는 기존 방식과 동일한 +결과, 비연속/문자열 키에서도 올바름). 구현 중 재확인한 사실: `AsyncSessionLocal`이 +`expire_on_commit=False`로 구성돼 있어(`db/session.py`) 매 항목 `commit()`은 후속 행의 +속성 읽기를 깨지 않지만, `rollback()`은 여전히(설정과 무관하게) 세션에 이미 로드된 모든 +객체를 expire시킨다 — 이것이 실제로 재현되는지 aiosqlite 없는 이 환경에서 직접 실행 +검증하지는 못했지만(추측이 아니라 이미 `AttachmentReparseWorker`의 동일 코드베이스에서 +검증·적용된 전례를 따른 것), 두 스윕 모두 각 행을 처리 직전 id로 다시 가져오도록 맞춰 +일관성을 확보했다. 검증: 신규 테스트 6개, 전체 백엔드 스위트 1879 passed/33 skipped(기존 +1875), ruff clean. ADR-0005 Revisions에 기록. + +**naruon#1486 `strix` 재발 (head `da816566`, run `33326526050`)**: 같은 PR의 앞선 `dcc9fcd0` +발생과 동일한 `STRIX_PROVIDER_UNAVAILABLE` 클래스가 새 head에서 다시 발생했으나 메커니즘은 +달랐다 — 이번에는 sidecar 기동과 preflight(healthz 25s, gateway chat/completions preflight +1차 시도 성공, `orchestrator/free` 8개 모델 선정)가 전부 정상 완료된 후, 실제 스캔 실행 자체가 +`orchestrator/free`를 대상으로 5400초(90분) 동안 응답 없이 멈췄다("Strix run timed out after +5400s" → exit 124 → `STRIX_PROVIDER_UNAVAILABLE: ...orchestrator/free exhausted`). 워크플로 +자체의 bounded-retry gate가 남은 job 예산(589초)이 재시도에 부족해 fail-closed됐다. 이는 앞서 +이 PR의 `noema-review` 코멘트(10:18)에서 이미 추적된 "`orchestrator/free` pool exhaustion" +클래스의 새 증거이지만, "healthz 이후 완전 무응답" 서브 증상과는 다른 "discovery/preflight는 +통과하지만 실제 스캔 완료 요청이 stall"하는 별도 서브 증상이다 — 두 서브 증상을 하나로 +단정하지 않고 구분해서 기록한다. `contextual-orchestrator#923`/`.github#1438`이 다루는 discovery +쪽 bounded retry가 이 런타임-스톨 서브 증상까지 커버하는지는 아직 확인되지 않았다. 진단 +코멘트를 남기고 실패한 job을 1회 재실행(`rerun_failed_jobs`, run `33326526050`)했다 — PR +자체의 diff와는 무관. + +**동일 서브 증상이 `.github#1438`(head `50febfe7`, run `33331290092`)에서도 재발**: 약 1시간 +뒤 다른 저장소의 다른 PR에서 완전히 동일한 로그 시그니처가 재현됐다 — sidecar healthz(48s)와 +gateway chat/completions preflight(1차 시도)는 정상 통과했지만, 실제 스캔이 5400초 동안 +멈췄다("Strix run timed out after 5400s" → exit 124 → `STRIX_PROVIDER_UNAVAILABLE: +...orchestrator/free exhausted`, 남은 job 예산 595초로 재시도 불가). 같은 시간대에 서로 다른 +두 저장소에서 동일 서브 증상이 나타난 것은 단발성 flake가 아니라 `orchestrator/free` pool이 +현재 실시간으로 저하된 상태임을 시사한다. 진단 코멘트를 남기고 실패한 job을 1회 재실행 +(`rerun_failed_jobs`, run `33331290092`)했다 — 이 PR의 diff와도 무관. + +**같은 PR의 바로 다음 head(`8a843c40`, run `33335906496`)에서 세 번째 서로 다른 서브 증상 발견 +— 이번엔 flake가 아니라 결정론적 버그**: 워크플로 자체의 내부 bounded-retry 루프가 3회 시도했고 +(135초/65초/73초 — 모두 빠름, hang 아님), 세 번 전부 완전히 동일한 모델/agent에서 실패했다 — +`agent_id: nvidia_nim_meta_llama_3_2_90b_vision_instruct`, `model: +meta/llama-3.2-90b-vision-instruct`, 매번 동일한 `Error code: 400 - invalid_request_error` +(`provider_status: 400, retryable: False`). **확인된 사실**: 3/3 완전 동일 모델/동일 에러 +반복은 flake가 아니라 이 모델에 대한 결정론적 실패를 증명한다 — 그 이상은 아니다. Devin +review가 정확히 지적했듯, 이 반복성 자체는 "왜" 400이 나는지(모달리티 불일치인지, 다른 요청 +파라미터 문제인지, 이 모델이 이 gateway 경로에서 아예 지원되지 않는지)를 증명하지 않으며, +`family_cap` 4→8 확대가 원인이라는 연결도 검증되지 않았다(생성기가 리턴한 일반적 wrapper +메시지("provider rejected the request with HTTP 400. Adjust the request parameters and +retry.")만 확인했을 뿐, NVIDIA NIM 쪽의 실제 원본 에러 바디는 읽지 못했고, family_cap=4일 때 +이 모델이 선택되지 않았을 것이라는 점도 직접 확인하지 못했다). **미검증 가설(다음 조사가 +필요, 단정하지 말 것)**: `meta/llama-3.2-90b-vision-instruct`는 이름으로 보아 공개적으로 +알려진 비전-멀티모달 모델이므로, 순수 텍스트 요청과의 모달리티 불일치가 그럴듯한 후보이긴 +하지만 확정된 근거는 아니다. 이 발견은 5400초 hang 클래스와는 별개의, 재현 가능한 증상이다 — +재실행하지 않았다(결정론적이라 재실행해도 같은 모델에 다시 걸릴 가능성이 높아 CI 시간만 +낭비). 이 PR의 diff와도 무관(family_cap/model-discovery 로직은 `contextual-orchestrator`에 +있음) — free-pool 모델 카탈로그 소유자가 이 모델의 실제 원본 provider 에러 바디를 확인해 +정확한 원인을 규명해야 하며, 그 전까지는 모달리티 불일치나 family_cap 연관을 확정된 root +cause로 취급하지 않는다. + +**4번째 서로 다른 sidecar 실패 서브 증상 발견 및 근본 수정 — "readiness 통과 후 sidecar +프로세스 자체가 종료"**: 다른 동시 실행 AI 에이전트(issue #1399, PR #1460 — PR #1460 본문 +자체가 "PR created automatically by Jules for task ... started by @seonghobae"라고 밝혀 +Google Jules가 이 계정으로 동작 중임을 스스로 증명함)가 남긴 교차-에이전트 협업 코멘트를 +issue #1399/PR #1460 원문 대조로 먼저 진위를 확인한 뒤 착수했다. 근거(exact-head): Strix +run/job `33341290448`/`99337282309`, `ContextualWisdomLab/.github#1460` target `2cc819a9` +— healthz/provider-route readiness가 23초 만에 통과했으나, 6분 뒤 gateway-preflight 3회 +시도가 전부 sidecar에 도달하지 못했다. 기존 코드는 이 실패를 "gateway_transport_exhausted"로 +뭉뚱그려, sidecar가 여전히 살아있는 네트워크 문제인지 이미 완전히 종료했는지를 구분할 유일한 +단서인 프로세스 종료 상태(exit status)를 그냥 버리고 있었다. + +수정(`scripts/ci/contextual_orchestrator_review_sidecar.sh`): gateway-preflight 재시도 +루프의 `[ -z "$gateway_http_status" ]` 분기 맨 앞에, 기존 healthz 분기가 이미 쓰던 것과 +동일한 `kill -0 "$sidecar_pid"` 판정을 추가했다 — 이 프로세스는 이 bash 스크립트 자신이 +백그라운드로 띄운 job이므로, `kill -0`이 실패하면 bash 자신의 SIGCHLD 기반 job-table이 +이미 비동기로 reap을 완료했다는 뜻이고, 그 뒤에 부르는 `wait "$sidecar_pid"`는 새로 +`waitpid()`를 시도하는 게 아니라 job-table에 캐시된 진짜 종료 상태를 그대로 돌려주는 +것이 보장된다(healthz 분기가 이미 의존해온 것과 같은 안전한 패턴). 종료가 확인됐으면 +`wait_for_sidecar_sanitizers`로 stderr를 온전히 비우고, 기존 "transport exhausted" +증거 대신 별도의 `{"endpoint": "chat/completions", "error_type": +"sidecar_process_exited", "attempts": N, "sidecar_exit_status": , "status": +"rejected"}`를 기록하며, exit status와 stderr tail을 포함한 별도 fail 메시지로 +실패한다 — sidecar가 여전히 살아있는 경우에만 기존 "transport exhausted" 경로로 +진행한다. + +TDD: `tests/test_contextual_orchestrator_review_sidecar_contract.py`에 구조 계약 테스트 +`test_gateway_preflight_distinguishes_a_dead_sidecar_from_an_unreachable_one` 추가(첫 +시도에서 `kill -0` 탐색 경계를 잘못 잡아 무관한 healthz 분기의 기존 occurrence에 우연히 +매치해버리는 false positive를 스스로 발견·수정한 뒤에야 진짜 RED를 확인). 실제 스크립트 +슬라이스를 bash 서브프로세스로 실행하는 +`tests/test_contextual_orchestrator_review_runtime_preflight.py`에는 +`_run_gateway_retry_loop`에 `sidecar_alive` 파라미터를 추가한 새 테스트 +`test_gateway_retry_loop_diagnoses_a_sidecar_that_died_after_readiness`를 추가했다 +— `sidecar_alive=False`일 때 `(exit 7) &`로 진짜로 죽는 자식 프로세스를 만들고 +`wait`를 절대 먼저 부르지 않은 채 `kill -0`만으로 폴링해, 코드 아래의 `wait`가 진짜 +종료 상태(7)를 그대로 받아오도록 설계했다. 이 변경으로 두 파일에서 각각 회귀 2건씩 +발견돼 함께 고쳤다 — contract 파일 쪽은 두 기존 테스트의 검색 범위를 좁혀 실제 불변식은 +그대로 두면서 새 코드로 인해 넓어진 매치 범위만 바로잡았고, runtime-preflight 파일 쪽은 +이 스크립트 슬라이스의 최소 하네스가 애초에 `sidecar_pid`/`wait_for_sidecar_sanitizers`/ +`sidecar_stderr`/`SIDECAR_STDERR_TAIL_LINES`를 정의한 적이 없어(이전까지는 그 슬라이스 +안에서 아무도 참조하지 않았으므로) `set -u` 아래 "unbound variable"로 깨졌던 것을 +`sidecar_alive` 기본값(`True` → 회귀 없이 기존 동작 보존)으로 고쳤다. 검증: 전체 +스위트 1935 passed/1 skipped/21 subtests, coverage 100%, interrogate 100%, `bash -n` +OK. + +**참고(main에서 독립적으로 완료된 별개 정리 작업)**: `scripts/ci/select_nvidia_nim_model.py`(호출자 +없음, 위 §5의 여러 항목이 이미 문서화)가 별도의 작은 PR(`fix/remove-orphaned-nim-model-resolver`)로 +분리 제거되어 `main`에 이미 반영되어 있다 — `#1437` 리뷰 스레드가 명시적으로 요청한 대로 direct-NIM +cleanup을 pool-flip 논의와 분리한 것이다. `contextual_orchestrator_review_sidecar.sh`의 참조 주석은 +git history를 가리키도록 갱신되었다. 이 branch에는 코드 변경이 필요 없다(이미 `main`을 merge해 반영됨). + +## 2026-08-31 시간별 재개: `.github#1438`의 `main` merge conflict 해소 + +`naruon#1486`의 required Checks 진행 상황을 확인하던 중 `.github#1438`의 `mergeable_state`가 +`blocked`에서 **`dirty`**로 바뀐 것을 발견했다 — `main`이 이 세션의 마지막 동기화 이후 추가로 +전진해 있었다. `origin/main`을 이 branch에 ordinary merge commit으로 병합해(rebase/force-push +없음) 정확히 예상대로 세 파일에서 충돌했다: + +- `.gitignore`: 양쪽이 서로 다른 무관한 항목(`.claude/` vs `strix_runs/`)을 추가한 것뿐이라 둘 다 + 유지. +- `CHANGELOG.md`: `## [Unreleased]` 섹션에 양쪽이 각자 무관한 새 항목을 추가한 것뿐이라, 두 + 블록을 그대로 이어붙였다(HEAD 항목 다음에 `main` 항목). +- `docs/product-technical-gap-baseline.md`: 이 문서 자체에 두 지점에서 충돌 — (1) HEAD의 훨씬 + 최신·상세한 시간별 pass 서술(naruon#1486/.github#1438/contextual-orchestrator#923/.github#1347 + 네 PR 재확인 이력 전체)과 `main`이 독립적으로 추가한 "PR #1347 Devin Review 6건 검증" 항목이 + 같은 위치에서 충돌 — 두 서술 모두 유효한 시간순 기록이라 HEAD 다음에 `main`의 항목을 이어붙였다. + (2) `### 5.1` "다음 개발 increment" 목록 — HEAD의 목록(네 PR의 현재 상태를 정확히 반영, 이미 + §5.1 자체가 두 차례 갱신된 상태)이 `main`의 훨씬 오래되고 이미 stale한 목록(#1297/#1345/#1326 — + 이 문서 앞부분의 "§5.1 next-increment list was stale" 항목이 이미 지적한 바로 그 항목들)과 + 충돌 — HEAD 목록을 유지하고, `main` 쪽에만 있던 새 정보 하나(`select_nvidia_nim_model.py` 고아 + 스크립트 제거, `fix/remove-orphaned-nim-model-resolver` PR로 이미 `main`에 반영됨)만 별도 + 참고 항목으로 보존했다. + +병합 커밋(`ca4c5ad4`) 검증: `PYTHONPATH=. python -m coverage run -m pytest tests` → 2097 +passed, 1 skipped, 21 subtests; `coverage report` → TOTAL 100% (statements 10454/10454, branches +4164/4164); `interrogate` → 100.0%; `bash -n scripts/ci/contextual_orchestrator_review_sidecar.sh` +→ syntax OK; `tests/test_product_technical_gap_baseline.py` → 5 passed (문서 계약 유지 확인). 푸시 +직전 재-fetch로 원격이 그대로임을 확인(경합 없음), 푸시 완료. `mergeable_state`가 `dirty`에서 +`blocked`(required Checks/리뷰 대기, 정상)로 복귀함을 확인했다. + +`naruon#1486`(head `ffed35e5`)은 이 시점 기준 `opencode-review`만 이미 문서화된 비동기 대기 +패턴으로 실패 중이고(`get_job_logs`로 "No APPROVED or CHANGES_REQUESTED from opencode-agent on +the current head" 재확인), 나머지 대부분(backend/frontend/CodeQL/Semgrep/Trivy/osv-scan/ +scorecard/dependency-review/coverage-evidence/noema-review)은 이미 success — `strix`와 세 이미지 +validate job, `metadata-only gate evaluation`만 아직 in_progress. 다음 tick에서 이들이 완료되면 +bypass-merge 조건 충족 여부를 재확인한다. + +## 2026-08-31 시간별 재개: naruon#1486 Devin 추가 배치 3건 수정 + `.github#1438` exact-head-path-policy false positive 근본원인 수정 + +이전 항목 이후 `naruon#1486`(head `399c1e5f`→`1b85703c`)에 대해 Devin이 head를 다시 분석하며 +새로 지적한 진짜 결함 3건을 각각 실제 RED 확인 후 고쳤다. + +1. **🔴 critical: POP3 동기화가 매 실행마다 조용히 메일을 0건 임포트.** `Pop3SyncWorker._import_messages`가 + `TenantConfig`에 존재하지 않는 `workspace_id`를 `getattr(config, "workspace_id", "")`로 읽어 + 항상 빈 문자열을 얻고, 바로 다음 가드에서 무조건 0건을 반환했다. `ImapSyncWorker`가 이미 쓰던 + `resolve_unambiguous_workspace_id()`(소유자의 기존 임포트 메일에서 workspace 역산, 0건/모호하면 + fail-closed)를 공용 헬퍼로 추출해 POP3에도 재사용(커밋 `c3e2856a`). +2. **🟡 `import_fixtures.py`가 커스텀 `NARUON_IMPORT_WORKSPACE_ID`를 무시.** 스레드 배정에는 반영하면서 + 실제 저장하는 `Email.workspace_id`는 그 자리에서 재계산해 env var를 무시했다(커밋 `399c1e5f`). +3. **🟡 owner당 이메일 임포트 할당량이 workspace마다 곱절로 증가.** `MAX_IMPORT_EMAILS_PER_OWNER`/advisory + lock은 `(user_id, organization_id)` 단위인데 카운트 쿼리가 `Email.owner_filters()`를 그대로 + 재사용해 `workspace_id`까지 필터링했다 — 같은 owner가 여러 workspace로 임포트하면 매 workspace가 + 독립적인 1000건 한도를 받았다(커밋 `c6085ef5`). +4. **🔍 analysis: calendar conflict judgment 영속화 경로에 실제 PostgreSQL 커버리지 부재.** + `apply_correction`의 `with_for_update()` row lock을 포함해 전부 mock 세션으로만 테스트되고 있었다 + — 새 real-Postgres smoke 테스트 추가, 로컬 PostgreSQL 16으로 실제 통과 확인(커밋 `c6085ef5`). +5. **🔍 analysis: `calendar_conflict_corrections.rationale`가 2단어 컬럼명 컨벤션 위반.** + `correction_rationale`로 리네임(모델/마이그레이션/서비스 계층만; API 응답 필드명은 유지). 동일 + 패턴의 기존 `project_graph_object_corrections.rationale`(이 PR 이전부터 존재)은 범위 밖으로 남김 + (커밋 `1b85703c`). + +각 수정 모두 전체 백엔드 스위트(최종 1897 passed / 36 skipped)와 ruff clean을 확인했고, 관련 +Devin 스레드 전부(POP3, fixture workspace 무시, fixture registry 우회, PostgreSQL 증거 부족, +legacy mail 도달 불가 재확인, IMAP 사전 workspace 증거 확인, owner quota, naming policy 확인, +rationale 리네임 등 총 9개)에 답글을 달고 resolve했다. + +**`.github#1438`: `exact-head-path-policy` 체크가 병합(`d753f38b`) 직후 FAIL로 나타난 원인을 +근본까지 추적.** `scripts/ci/test_strix_quick_gate.sh`의 +`assert_opencode_review_uses_codegraph_and_contextual_orchestrator`가 `required-workflow-bootstrap` +job 하나만 검사하려는 의도로 `awk '/^ required-workflow-bootstrap:$/,/^[^ ]/'` 범위를 썼는데, +GitHub Actions의 `jobs:` 아래 어떤 job 정의도 실제로는 column 0로 dedent되지 않는다(EOF까지 계속 +2-space indent) — 즉 `/^[^ ]/`는 사실상 파일 끝까지 절대 매치하지 않아, 이 assertion이 +`required-workflow-bootstrap` 하나가 아니라 **파일의 나머지 전체(다른 job들 포함)** 를 검사하고 +있었다. 그 결과 훨씬 뒤에 있는 무관한 `opencode-review-target` job 자신의 `if: +github.event.action != 'closed'`(closed-PR skip 목적, 정상)까지 "required-workflow-bootstrap이 +event payload에 의존한다"는 오탐으로 잡았다. + +**main 자체도 동일하게 실패함을 실증으로 확인**(이 PR이 만든 결함이 아님): (1) `.github#1438`이 +병합해 들어온 워크트리에서 `STRIX_TEST_PROCESS_TIMEOUT_SECONDS=3 STRIX_TEST_FAKE_SLEEP_SECONDS=5 +bash scripts/ci/test_strix_quick_gate.sh` 전체 실행 → 정확히 이 assertion 1건만 FAIL. (2) +`origin/main`(commit `1cbb6aa`) 단독 fresh clone에서 동일 스크립트를 독립적으로 실행 → **동일하게 +정확히 이 assertion 1건만 FAIL** — 병합이 가져온 게 아니라 `main` 자체의 선행 결함임을 확정했다. + +**수정**: `awk` 범위를 column-0 종료 조건 대신, 다음 2-space indent job 키(`^ [A-Za-z0-9_-]+:$`)에서 +멈추는 flag 기반 스크립트로 교체(`scripts/ci/test_strix_quick_gate.sh`). 검증: (a) 실제 +`required-workflow-bootstrap` job 안에 가짜 `if:`를 주입한 사본에서는 여전히 정확히 감지됨(진짜 +위반은 계속 잡음). (b) 수정된 스크립트를 `.github#1438` 워크트리 전체에 대해 재실행 → +`test_strix_quick_gate: PASS`로 회귀 없이 통과. + +## 2026-08-31 시간별 재개: naruon#1486 CodeRabbit 배치 3건 수정(project-graph workspace mismatch, 중복검사 workspace 누락, bootstrap_db.py legacy constraint 2번째 이름) + +이전 항목(head `1b85703c`) 이후 CodeRabbit이 head `a1027af6`를 재분석하며 새로 지적한 결함 4건 +중 3건을 실제 RED 확인 후 고쳤다(head `786d1544`). 4번째는 이미 조사·해소된 gap의 반복. + +1. **🟠 major: `_persist_project_graph_projection`이 호출자의 이미 해석된 workspace를 무시하고 + 자체 재계산.** `email_import_service.py`의 이 함수가 여전히 + `workspace_id = f"workspace-{organization_id}" if organization_id else f"workspace-{user_id}"`를 + 내부에서 재계산하고 있어, non-default import workspace를 쓰면 이메일 본체와 그 이메일에서 + 파생된 project-graph 객체가 서로 다른 workspace에 저장될 수 있었다. `workspace_id`를 필수 + keyword-only 파라미터로 바꾸고 `_import_single_eml`의 `resolved_workspace_id`를 그대로 전달하도록 + 수정(커밋 `786d1544`). 새 테스트로 진짜 RED 확인(`TypeError: unexpected keyword argument + 'workspace_id'`) 후 GREEN. 이 수정으로 이 함수를 구 시그니처로 호출하며 구 fallback 동작 자체를 + 단언하던 `test_project_graph_import_wiring.py`의 기존 테스트 5개가 깨졌다 — 변경 대상 파일만이 + 아니라 전체 스위트를 돌려야 잡히는 회귀였고, 5개 전부 새 계약(명시적 `workspace_id` 인자)에 + 맞게 갱신했다. +2. **🟡 minor: `import_fixtures.py`의 중복 임포트 검사가 workspace로 스코프되지 않음.** 기존 + 메일 존재 확인 쿼리가 `message_id`/`user_id`/`organization_id`만 필터링해, 같은 owner가 서로 다른 + workspace로 같은 메일을 임포트하면 두 번째 워크스페이스에서 잘못 "이미 존재"로 스킵될 수 있었다. + `Email.workspace_id == IMPORT_WORKSPACE_ID` 조건 추가(커밋 `786d1544`). **주의**: 이 수정을 + 증명하는 첫 테스트 시도(`assert "email_records.workspace_id" in query_text`)는 수정 없이도 + PASS하는 false negative였다 — `select(Email)`은 WHERE 절과 무관하게 항상 모든 ORM 컬럼을 SELECT + 목록에 포함하기 때문. `query_text.partition("where ")[2]`로 WHERE 절만 분리해 정확한 + `email_records.workspace_id = :bind_name` 프래그먼트를 확인하도록 재작성한 뒤에야 진짜 증명이 + 됐다. +3. **🟡 minor: `bootstrap_db.py`가 legacy unique constraint 이름을 하나만 drop.** 기존 코드는 + 자신이 직접 만들었던 이름(`uq_email_records_owner_message_id`)만 drop했는데, workspace scoping + 이전 `Base.metadata.create_all()`로 부트스트랩된 DB는 Alembic ORM 메타데이터가 실제로 쓰던 또 + 다른 이름(`uq_emails_owner_message_id`, `0020_email_workspace_scope.py`의 + `_OLD_EMAIL_IDENTITY`)을 갖고 있어 그 경로만 영구히 예전 3-column identity에 갇혔다. 두 이름 + 모두(constraint + index 형태) drop하도록 추가(커밋 `786d1544`). +4. **🔍 repeat: `IMPORT_WORKSPACE_ID`를 registry-backed config로 옮기라는 지적** — 이 코드베이스에는 + 동작하는 KV/credential registry가 없다는, 앞선 Devin 지적과 동일한 gap. `import_fixtures.py`는 + 런타임 요청 경로가 아닌 dev-only fixture 스크립트이므로 새 아키텍처 의존성을 추가하지 않고 기존 + `IMPORT_USER_ID`/`IMPORT_ORGANIZATION_ID`와 같은 env-var-with-default 패턴을 유지하기로 결정. + +전체 백엔드 스위트 1900 passed / 36 skipped, ruff clean(head `786d1544`). CodeRabbit 스레드 2개 +(registry 지적, bootstrap_db.py 지적)에 답글·resolve했고, 별도 discussion thread가 없는 "outside +diff range" 지적 2건(project-graph mismatch, 중복검사 workspace 누락)은 일반 PR issue comment로 +답변했다. + +## 2026-08-31 시간별 재개: naruon#1486 Devin 재분석 3건(quarantine 검색 노출 수정, TicketTask/reparse 두 건은 근거 있는 반려) + +이전 항목(head `786d1544`) 이후 Devin이 head를 재분석하며 새로 지적한 🔍 analysis 3건을 각각 +실제 코드로 검증했다. + +1. **✅ 수정: quarantine된 첨부파일의 base64 원본 payload가 hybrid 검색에 그대로 노출됨.** + `Attachment.parse_status`가 `"parsed"`가 아닌 모든 상태(이 PR이 새로 추가한 + `content_type_mismatch_quarantined` 포함, 기존 `pdf_dom_recognition_pending` 등)는 + `content`에 실제 파싱된 텍스트 대신 base64 인코딩된 원본 바이트나 빈 문자열을 저장하는데, + `build_lexical_attachment_statement`/`build_dense_attachment_statement`(둘 다 이 PR에서는 + 손대지 않은 기존 코드)가 이를 필터링 없이 그대로 검색 대상으로 삼고 있었다. 같은 파일의 + `project_graph_object` 채널이 이미 `_EXCLUDED_PROJECT_OBJECT_STATUS_CODES`로 유사한 상태 + 필터링을 하고 있어 그 패턴을 따라 두 statement 모두 + `.where(Attachment.parse_status == "parsed")`를 추가(커밋 `968b21fc`). 새 테스트 2개로 진짜 + RED(`assert "email_attachments.parse_status" in sql` 실패) 확인 후 GREEN. 전체 백엔드 스위트 + 1902 passed / 36 skipped, ruff clean. +2. **반려(범위 밖으로 결정, 후속 과제로 기록): `TicketTask`에 workspace 스코프가 없음.** + `TicketTask`는 이 PR 이전부터 `(user_id, organization_id)`로만 스코프되어 있었고 + `workspace_id` 컬럼 자체가 없다 — `_build_task_query`의 outer join은 소스 이메일을 + workspace로 필터링해 숨길 뿐, `TicketTask` 행 자신과 WHERE절에는 workspace 조건이 없어 + 같은 owner의 여러 workspace에 걸친 task가 서로 조회·수정 가능하다. 실재하는 아키텍처 격차이나, + 이미 63파일/6800줄 이상인 이 PR에 새 마이그레이션 + 모델/쿼리 변경 + 테스트가 필요한 별도 + 증분을 얹기보다 후속 과제로 분리하기로 결정. **다음 증분에서 필요**: `TicketTask`에 + `workspace_id` 컬럼 추가(마이그레이션 + 백필), `_build_task_query`/`update_ticket_task` 등 + 모든 task 엔드포인트에 workspace 필터 적용, 회귀 테스트. +3. **반려(근거 확인 후 불필요로 결론): reparse 경로에 실제 PostgreSQL 커버리지가 없다는 지적.** + `calendar_conflict_judgment_service.apply_correction`은 `with_for_update()` row lock을 쓰기 + 때문에 이 PR에서 실제 real-Postgres smoke 테스트를 추가했다(mock 세션은 실제 lock 동작을 + 재현할 수 없으므로). 반면 reparse 경로(`api/data.py`의 reparse 라우트, + `attachment_reparse_worker.py`)는 lock이나 제약조건 의존 동작이 전혀 없는 단순 단일 행 + 읽기→필드변경→commit이라, 기존 mock 세션 unit test가 이미 실제 로직(상태 전이, retained + payload 보존)을 충실히 검증하고 있음을 확인 — 추가할 real-Postgres 테스트가 검증할 새로운 + 내용이 없어 추가하지 않기로 결정. + +세 건 모두 스레드에 답글·resolve 완료. + +## 2026-09-01 시간별 재개: naruon CI에 Postgres 서비스가 전혀 없음을 발견 — real-Postgres 테스트 전량이 CI에서 한 번도 실행된 적 없었다 + +naruon#1486의 Devin 지적("Fresh migrations bypass legacy-table guard")을 검증하려 로컬 +PostgreSQL 16 + pgvector를 직접 설치·기동해 `@pytest.mark.postgres` 테스트를 처음으로 실제 +실행해 본 결과, `naruon/.github/workflows/app-ci.yml`의 backend job에 Postgres 서비스 컨테이너가 +**전혀 구성되어 있지 않음**을 확인했다. `tests/conftest.py`가 `DATABASE_URL`을 +`postgresql+asyncpg://test:test@localhost:5432/test_db`로 기본값 설정하지만 CI 러너에는 그 +주소로 연결 가능한 Postgres가 없어, 이 마커가 붙은 모든 테스트는 CI에서 매번 연결 실패로 +조용히 skip되어 왔다 — 이 저장소가 "real-PostgreSQL smoke test"라고 부르는 테스트 클래스 +전체가 사실상 CI에서 한 번도 실행된 적이 없었다는 뜻이다. + +이번 발견으로 실제 실행해 밝혀진 실재 결함(모두 naruon#1486에서 커밋 `b9b02dd0`으로 수정·검증 +완료): + +1. **🔴 critical: `0001_initial_control_plane.py::upgrade()`가 guard를 우회해, 신선한 DB에 + 대한 `alembic upgrade head`가 항상 실패.** `Base.metadata.create_all()`가 만들지 않는 legacy + `emails` 테이블에 인덱스를 만들려다 `relation "emails" does not exist`로 크래시. 실제 신선한 + DB에 대해 마이그레이션을 실행해 크래시를 직접 재현(진짜 RED)한 뒤 수정. +2. **이 PR이 `email_records.workspace_id`를 NOT NULL로 만든 뒤, 이를 반영하지 못한 이 PR과 + 무관한 기존 파일 4개의 real-Postgres 테스트 19건이 하드 실패.** `test_project_graph_api.py`, + `test_project_graph_projection.py`, `test_search_postgres.py`, `test_tasks_api.py`의 공유 + `Email(...)` 시딩 헬퍼가 `workspace_id`를 넘기지 않고 있었다. +3. **`test_data_api.py`의 raw SQL INSERT 3건이 `workspace_id`뿐 아니라 `is_read`/`attachment_uid`도 + 빠뜨림** — 둘 다 ORM 쪽 Python-side default(서버측 default 없음)라 raw SQL이 이를 우회했다. + +**남겨둔 후속 과제 (이번 커밋 범위 밖)**: naruon CI에 실제 Postgres(+ pgvector) 서비스 +컨테이너를 구성해, `@pytest.mark.postgres` 테스트가 매 PR마다 실제로 실행되도록 만드는 것. +현재 구조에서는 이 테스트 클래스 전체가 로컬에 우연히 Postgres를 설치해 둔 개발자가 수동으로 +실행하지 않는 한 영원히 검증되지 않는 죽은 코드나 다름없다 — 이번처럼 이 문서를 갱신하는 +세션이 우연히 로컬 Postgres를 기동하지 않았다면 이 20건의 결함(1번 critical 포함)은 계속 +발견되지 않았을 것이다. + +## 2026-09-01 시간별 재개: naruon#1486 Devin 재분석 2건 실재 결함(NewsDOM pending 커서 굶주림, reparse-intent 락 없는 경쟁) 수정 + CodeRabbit 2건 검증(1건 반려, 1건 오탐 확인) + +이전 항목(head b9b02dd0) 이후 Devin이 head `c1f02e24`를 재분석하며 지적한 2건과, 이후 +CodeRabbit이 지적한 2건을 모두 검증했다. + +1. **✅ 수정(Devin, 🟡): NewsDOM 재인식 sweep의 커서가 `RESULT_PENDING`(provider 미설정) 행도 + 해결된 것처럼 취급해 커서를 그 너머로 진행시킴.** `_sweep_attachments`/`_sweep_documents`는 + 이미 "예외 발생 행은 커서를 그 앞에서 멈춘다"는 불변식을 갖고 있었지만 + `RESULT_PENDING`(예외 없이 정상 반환되지만 상태는 그대로 pending)은 같은 취급을 받지 못해, + provider가 나중에 설정되어도 그 뒤로 새 업로드가 계속 들어오는 한 해당 행이 무기한 굶주릴 수 + 있었다. 두 sweep 모두 `RESULT_PENDING`을 예외와 동일하게 취급하도록 수정. 새 테스트 2개로 + 진짜 RED(커서가 배치의 마지막 행까지 진행) 확인 후 GREEN. 기존 + `test_document_sweep_advances_and_wraps_without_starvation`이 버그 이전 동작을 전제로 + 작성되어 있어 수정된 계약에 맞게 시나리오 재작성. +2. **✅ 수정(Devin, 🟨): 첨부파일 reparse-intent 엔드포인트가 락 없는 read-then-write로 상태를 + 전이해 TOCTOU 경쟁이 있었음.** `create_attachment_reparse_intent`가 quarantined 상태를 + 확인한 뒤 락 없이 reparse_pending으로 갱신·커밋 — 오래된 읽기를 든 지연된 중복 요청이 그 + 사이 워커가 이미 처리한 최신 상태를 되돌려 덮어쓸 수 있었다. + `calendar_conflict_judgment_service.apply_correction`이 이미 쓰는 `with_for_update()` 패턴을 + `_get_scoped_attachment`에 `lock` 키워드 인자로 추가해 이 엔드포인트에서만 적용. 새 테스트로 + 컴파일된 쿼리에 FOR UPDATE 포함 확인(같은 컨벤션의 기존 테스트와 동일한 검증 수준), 실제 + PostgreSQL로 JOIN + FOR UPDATE OF 조합이 유효한 SQL임을 별도 확인. + 두 수정 모두 커밋 `c1f02e24`. 전체 백엔드 스위트: Postgres 기동 시 1942 passed / 3 skipped, + 중지 시 1905 passed / 40 skipped, ruff clean. +3. **반려(사전 존재, 범위 밖, CodeRabbit 자신도 "Heavy lift"로 표시): `0001_initial_control_plane.py`가 + raw SQL(`execute_schema_backfill`) 대신 구조화된 Alembic 연산(`op.add_column` 등)을 써야 + 한다는 지적.** 확인 결과 `0018`/`0020` 등 다른 마이그레이션은 이미 구조화된 연산을 쓰고 있고, + `0001`만 이 PR 이전부터 raw SQL을 써온 유일한 예외(baseline 마이그레이션이라 성격이 다름) — + 이번 fresh-install 크래시 수정은 이 raw-SQL 특성 자체를 바꾸지 않았으므로 별도의 큰 리팩터로 + 남겨둠. +4. **오탐 확인(CodeRabbit, 🟡): `test_search_postgres.py`의 `_seed_segment_and_project_object`가 + `ProjectGraphObjectRecord.workspace_id="workspace-primary"`를 하드코딩해 `_make_email`의 + workspace(`workspace-org-acme`)와 불일치한다는 지적.** 실제 쿼리 로직을 추적한 결과, + `build_lexical_project_object_statement`의 owner_filters는 `Email.owner_filters(...)`(classmethod, + `cls`=Email)를 그대로 `.where()`에 넣고 `.join(Email, ...)`으로 조인하므로, workspace 필터는 + 전적으로 조인된 Email 행의 workspace_id에만 적용되고 `ProjectGraphObjectRecord.workspace_id`는 + 이 쿼리에서 전혀 참조되지 않음을 확인 — 즉 이 불일치는 검색 정확성에 영향을 주지 않는(하지만 + 지저분한) 테스트 픽스처 값일 뿐. 코드 수정 없이 근거와 함께 반려. ## 6. Compliance and data boundary diff --git a/scripts/ci/contextual_orchestrator_review_sidecar.sh b/scripts/ci/contextual_orchestrator_review_sidecar.sh index e4984f643..85a8bde36 100755 --- a/scripts/ci/contextual_orchestrator_review_sidecar.sh +++ b/scripts/ci/contextual_orchestrator_review_sidecar.sh @@ -35,6 +35,12 @@ SIDECAR_LOG_SANITIZER="$ORG_REPO_ROOT/scripts/ci/sanitize_contextual_orchestrato # finishes, letting the shell script wait for a deterministic marker instead # of guessing whether the async sanitizer has caught up. SIDECAR_DISCOVERY_DIAGNOSTICS_SENTINEL="discovery_diagnostics_complete" +# Bounded tail shown in job-log failure messages: discovery errors (up to one +# per credentialed provider) plus preflight-rejection diagnostics (up to +# REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES routes) plus a couple of summary lines can +# together exceed the old 20-line cap, silently truncating exactly the +# evidence a fail-closed incident needs. +SIDECAR_STDERR_TAIL_LINES=60 CATALOG_LIMIT="${ORCHESTRATOR_CATALOG_LIMIT:-12}" # Each KV credential is an independent account, including two credentials for # the same vendor or endpoint. The account cap prevents one credential from @@ -352,7 +358,7 @@ until curl -fsSL --max-time 2 "http://${ORCHESTRATOR_HOST}:${ORCHESTRATOR_PORT}/ if [ -s "$preflight_report" ]; then log "sidecar preflight route evidence: $(sed -n '1,80p' "$preflight_report" | tr '\n' ' ')" fi - fail "sidecar exited before healthz (status ${sidecar_status}); stderr: $(sed -n '1,20p' "$sidecar_stderr")" + fail "sidecar exited before healthz (status ${sidecar_status}); stderr: $(sed -n "1,${SIDECAR_STDERR_TAIL_LINES}p" "$sidecar_stderr")" fi i=$((i + 1)) # KNOWN GAP, tracked as ContextualWisdomLab/.github#1455 (not yet fixed): @@ -365,7 +371,7 @@ until curl -fsSL --max-time 2 "http://${ORCHESTRATOR_HOST}:${ORCHESTRATOR_PORT}/ # the vendored contextual_orchestrator.model_discovery source: ~7 # sequential HTTP calls at up to 15s each). if [ "$i" -ge 180 ]; then - fail "sidecar did not become healthy; stderr: $(sed -n '1,20p' "$sidecar_stderr")" + fail "sidecar did not become healthy; stderr: $(sed -n "1,${SIDECAR_STDERR_TAIL_LINES}p" "$sidecar_stderr")" fi sleep 1 done @@ -396,7 +402,7 @@ until grep -qx "$SIDECAR_DISCOVERY_DIAGNOSTICS_SENTINEL" "$sidecar_stderr" 2>/de fi sleep 0.2 done -sidecar_startup_warnings="$(grep -vx "$SIDECAR_DISCOVERY_DIAGNOSTICS_SENTINEL" "$sidecar_stderr" 2>/dev/null | sed -n '1,20p' || true)" +sidecar_startup_warnings="$(grep -vx "$SIDECAR_DISCOVERY_DIAGNOSTICS_SENTINEL" "$sidecar_stderr" 2>/dev/null | sed -n "1,${SIDECAR_STDERR_TAIL_LINES}p" || true)" if [ -n "$sidecar_startup_warnings" ]; then log "sidecar startup warnings (non-fatal): $sidecar_startup_warnings" fi @@ -470,9 +476,13 @@ REVIEW_PREFLIGHT_GATEWAY_MAX_ATTEMPTS="${REVIEW_PREFLIGHT_GATEWAY_MAX_ATTEMPTS:- # expected", identical to a non-numeric one) -- so the bound below also caps # digit COUNT, not just digit-ness. Four digits (up to 9999) is already far # beyond any realistic attempt count and stays safely representable on every -# platform this runs on. +# platform this runs on. An all-digit value can still be numerically zero +# with leading zeros ("00", "0000"): `[ -ge ]` parses those as decimal 0, so +# the loop would fail after exactly one attempt instead of respecting the +# configured retry count -- listed explicitly alongside the bare `0` case +# rather than folded into the digit-count cap below. case "$REVIEW_PREFLIGHT_GATEWAY_MAX_ATTEMPTS" in - ''|*[!0-9]*|0) + ''|*[!0-9]*|0|00|000|0000) fail "REVIEW_PREFLIGHT_GATEWAY_MAX_ATTEMPTS must be a positive integer" ;; ?????*) fail "REVIEW_PREFLIGHT_GATEWAY_MAX_ATTEMPTS must be at most 9999" ;; @@ -499,11 +509,57 @@ while :; do fi if [ "$gateway_attempt" -ge "$REVIEW_PREFLIGHT_GATEWAY_MAX_ATTEMPTS" ]; then if [ -z "$gateway_http_status" ]; then - # Every configured attempt exhausted with no usable HTTP response at - # all (Trigger A never resolved) -- record that before failing closed, - # using the same sanitize-then-atomic-replace pattern as the non-2xx - # and invalid-content paths below, so this exact failure case (the one - # telemetry matters most for) does not leave zero evidence trail. + # A transport failure on every attempt has two structurally different + # causes that "could not reach the sidecar" alone cannot distinguish: + # the sidecar process is still running (a real network/gateway issue), + # or it has already exited (its own bug/crash/OOM, unrelated to the + # network at all). Check which one this is before recording generic + # transport-exhausted evidence, so a died sidecar is never + # misclassified as merely unreachable -- exact-head evidence: Strix + # run/job 33341290448/99337282309 for ContextualWisdomLab/.github#1460 + # target 2cc819a9 passed healthz/provider-route readiness after 23s, + # then six minutes later all 3 gateway-preflight attempts failed to + # reach the sidecar at all, with no evidence of whether it had died. + if ! kill -0 "$sidecar_pid" 2>/dev/null; then + sidecar_exit_status=0 + wait "$sidecar_pid" 2>/dev/null || sidecar_exit_status=$? + # The sidecar has fully exited (confirmed above), so draining here + # cannot hang, and it guarantees $sidecar_stderr holds everything the + # sidecar wrote before we read it -- the same discipline the healthz + # branch above uses for the same reason. + wait_for_sidecar_sanitizers + "$sidecar_python" - "$preflight_report" "$gateway_attempt" "$sidecar_exit_status" <<'PY' +import json +from pathlib import Path +import sys + +report_path = Path(sys.argv[1]) +attempts = int(sys.argv[2]) if sys.argv[2].isdecimal() else 0 +exit_status_arg = sys.argv[3] +exit_status = int(exit_status_arg) if exit_status_arg.lstrip("-").isdecimal() else None +try: + report = json.loads(report_path.read_text(encoding="utf-8")) +except (OSError, json.JSONDecodeError): + report = {} +report["gateway"] = { + "endpoint": "chat/completions", + "error_type": "sidecar_process_exited", + "attempts": attempts, + "sidecar_exit_status": exit_status, + "status": "rejected", +} +temporary = report_path.with_suffix(".tmp") +temporary.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") +temporary.replace(report_path) +PY + fail "sidecar process exited after readiness, before gateway preflight completed (status ${sidecar_exit_status}); stderr: $(sed -n "1,${SIDECAR_STDERR_TAIL_LINES}p" "$sidecar_stderr")" + fi + # Sidecar still running -- every configured attempt exhausted with no + # usable HTTP response at all (Trigger A never resolved) -- record that + # before failing closed, using the same sanitize-then-atomic-replace + # pattern as the non-2xx and invalid-content paths below, so this exact + # failure case (the one telemetry matters most for) does not leave zero + # evidence trail. "$sidecar_python" - "$preflight_report" "$gateway_attempt" <<'PY' import json from pathlib import Path diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 4053f4fd5..4e14ed066 100644 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -522,7 +522,17 @@ assert_opencode_review_uses_codegraph_and_contextual_orchestrator() { assert_file_not_contains "$workflow_file" "Wait for trusted OpenCode approval review" "opencode pull_request bridge was removed to avoid duplicate required-check resource use" assert_file_not_contains "$workflow_file" "Trusted OpenCode requested changes for head" "opencode pull_request bridge no longer reconsumes stale trusted review state" assert_file_not_contains "$workflow_file" "github.event.pull_request.number == 240" "opencode review workflow must not hard-code repository-specific PR bypasses" - if awk '/^ required-workflow-bootstrap:$/,/^[^ ]/' "$bootstrap_file" | grep -q '^[[:space:]]*if:'; then + # The range must end at the *next job key* (2-space indent), not the next + # column-0 line: a GitHub Actions job list under `jobs:` never dedents to + # column 0 until EOF, so `/^[^ ]/` as an end pattern silently captured + # every line through the end of the file -- including unrelated later + # jobs' own `if:` conditions (e.g. opencode-review-target's own + # closed-PR skip) -- as if they belonged to required-workflow-bootstrap. + if awk ' + /^ required-workflow-bootstrap:$/ { in_job = 1; next } + in_job && /^ [A-Za-z0-9_-]+:$/ { in_job = 0 } + in_job + ' "$bootstrap_file" | grep -q '^[[:space:]]*if:'; then record_failure "opencode required workflow bootstrap must not depend on required-workflow event payload fields" fi assert_file_contains "$workflow_file" 'github.event.client_payload.target_repository || github.repository' "opencode review scopes concurrency by target repository" diff --git a/tests/test_contextual_orchestrator_review_runtime_preflight.py b/tests/test_contextual_orchestrator_review_runtime_preflight.py index 7093e8a3d..99dc55738 100644 --- a/tests/test_contextual_orchestrator_review_runtime_preflight.py +++ b/tests/test_contextual_orchestrator_review_runtime_preflight.py @@ -556,6 +556,7 @@ def _run_gateway_retry_loop( *, max_attempts: int | str, plan: list[str], + sidecar_alive: bool = True, ) -> tuple[subprocess.CompletedProcess[str], dict[str, object]]: """Execute the sidecar's real gateway curl retry loop against a fake curl. @@ -572,6 +573,16 @@ def _run_gateway_retry_loop( regression test. plan: One entry per expected curl call, each either ``"FAIL"`` (a transport failure) or ``"\\n"``. + sidecar_alive: When True (default), ``$sidecar_pid`` names a process + that is genuinely running for the harness's whole lifetime (the + harness script itself, via ``$$``) -- the common case, and what + every pre-existing test in this module implicitly assumed before + the dead-sidecar branch existed. When False, a short-lived child + is spawned and the harness deterministically polls (via `kill + -0`, never `wait`, so the real exit status stays retrievable) + until it has actually exited before the retry loop starts, + simulating a sidecar that crashed between readiness and gateway + preflight. Returns: The completed harness process and the resulting preflight report @@ -600,12 +611,37 @@ def _run_gateway_retry_loop( gateway_preflight_response = work_dir / "gateway-preflight.json" preflight_report = work_dir / "preflight.json" preflight_report.write_text("{}", encoding="utf-8") + sidecar_stderr = work_dir / "sidecar.stderr.log" + sidecar_stderr.write_text("synthetic sidecar stderr tail\n", encoding="utf-8") + + if sidecar_alive: + sidecar_pid_setup = 'sidecar_pid="$$"\n' + else: + # Spawn a child that exits almost immediately, then poll `kill -0` + # (never `wait`) until it is confirmed gone -- this only asserts, + # never consumes, so the retry block's own later `wait "$sidecar_pid"` + # still retrieves this child's real exit status, exactly like it + # would for a genuinely crashed sidecar. + sidecar_pid_setup = ( + "(exit 7) &\n" + "sidecar_pid=$!\n" + "sidecar_dead_wait=0\n" + 'while kill -0 "$sidecar_pid" 2>/dev/null; do\n' + " sidecar_dead_wait=$((sidecar_dead_wait + 1))\n" + ' if [ "$sidecar_dead_wait" -ge 500 ]; then\n' + " echo 'test setup: child never exited' >&2\n" + " exit 99\n" + " fi\n" + " sleep 0.01\n" + "done\n" + ) harness = tmp_path / "harness.sh" harness.write_text( "set -euo pipefail\n" "log() { printf '[test-sidecar] %s\\n' \"$*\"; }\n" 'fail() { log "error: $*" >&2; exit 1; }\n' + "wait_for_sidecar_sanitizers() { :; }\n" 'orchestrator_pool="free"\n' 'ORCHESTRATOR_TOKEN="synthetic-test-bearer"\n' 'ORCHESTRATOR_HOST="127.0.0.1"\n' @@ -614,6 +650,9 @@ def _run_gateway_retry_loop( f'gateway_preflight_request="{gateway_preflight_request}"\n' f'gateway_preflight_response="{gateway_preflight_response}"\n' f'preflight_report="{preflight_report}"\n' + f'sidecar_stderr="{sidecar_stderr}"\n' + 'SIDECAR_STDERR_TAIL_LINES=60\n' + + sidecar_pid_setup + retry_block + "\n", encoding="utf-8", @@ -639,7 +678,9 @@ def _run_gateway_retry_loop( return result, report -@pytest.mark.parametrize("malformed_value", ["not-a-number", "0", "-1", "3.5"]) +@pytest.mark.parametrize( + "malformed_value", ["not-a-number", "0", "-1", "3.5", "00", "0000"] +) def test_gateway_retry_loop_rejects_a_malformed_attempt_limit_before_any_curl_call( tmp_path: Path, malformed_value: str ) -> None: @@ -653,6 +694,16 @@ def test_gateway_retry_loop_rejects_a_malformed_attempt_limit_before_any_curl_ca the guard's own ``''`` pattern is defense in depth for a future change to that assignment, not a reachable case today.) + ``"00"``/``"0000"`` are a follow-up CodeRabbit finding on top of the + original fix: the digit-only guard's ``0`` branch only matched the exact + one-character string, so an all-digit-but-zero-valued override (leading + zeros) passed the guard and then made ``gateway_attempt -ge + $REVIEW_PREFLIGHT_GATEWAY_MAX_ATTEMPTS`` evaluate true on the very first + attempt (`test` parses a leading-zero numeral as decimal, so ``"00"`` is + ``0``) -- the opposite failure mode from the original bug (fails after + exactly one attempt instead of respecting the configured retry count), + but still config that must be rejected before any curl call. + The plan is deliberately empty: if the fix regresses and the loop reaches curl at all, the fake curl exits 2 with a distinct "no plan queued" message, which the assertions below would not match -- proving this @@ -810,6 +861,41 @@ def test_gateway_retry_loop_records_transport_exhaustion_evidence_before_failing } +def test_gateway_retry_loop_diagnoses_a_sidecar_that_died_after_readiness( + tmp_path: Path, +) -> None: + """Exact-head evidence (Strix run/job 33341290448/99337282309 for + ContextualWisdomLab/.github#1460 target 2cc819a9): the sidecar passed + healthz/provider-route readiness after 23s, then six minutes later all 3 + gateway-preflight attempts failed to reach it at all -- with the + pre-existing code, that is indistinguishable from a sidecar that was + still running the whole time but merely unreachable over the network. + + When every attempt fails transport-wise AND the sidecar process itself + has already exited, the failure must be reported and recorded distinctly + from `gateway_transport_exhausted`, carrying the process's own exit + status -- the one piece of evidence that tells an operator the sidecar + crashed rather than the network being flaky. + """ + result, report = _run_gateway_retry_loop( + tmp_path, max_attempts=1, plan=["FAIL"], sidecar_alive=False + ) + + assert result.returncode == 1 + assert ( + "sidecar process exited after readiness, before gateway preflight " + "completed (status 7); stderr: synthetic sidecar stderr tail" + in result.stderr + ) + assert report["gateway"] == { + "endpoint": "chat/completions", + "error_type": "sidecar_process_exited", + "attempts": 1, + "sidecar_exit_status": 7, + "status": "rejected", + } + + def test_gateway_retry_loop_classifies_a_transport_then_http_exhaustion_by_the_final_attempt( tmp_path: Path, ) -> None: diff --git a/tests/test_contextual_orchestrator_review_sidecar_contract.py b/tests/test_contextual_orchestrator_review_sidecar_contract.py index 0a63356da..4ecdc92e7 100644 --- a/tests/test_contextual_orchestrator_review_sidecar_contract.py +++ b/tests/test_contextual_orchestrator_review_sidecar_contract.py @@ -459,15 +459,44 @@ def test_sidecar_surfaces_nonfatal_discovery_warnings_on_a_successful_startup() # `grep -v` exits 1 when every line was filtered out (the common, healthy # case with zero warnings); under `set -o pipefail` that would abort the # whole script unless explicitly tolerated. - assert "sed -n '1,20p' || true)\"" in text + assert 'sed -n "1,${SIDECAR_STDERR_TAIL_LINES}p" || true)"' in text assert 'log "sidecar startup warnings (non-fatal): $sidecar_startup_warnings"' in text - # Must not `wait_for_sidecar_sanitizers` here: the sidecar keeps serving - # after a successful healthz, so its sanitizer never sees EOF and doing - # so would hang the workflow forever. + # Must not `wait_for_sidecar_sanitizers` in this immediate post-healthz + # warning-surfacing block: the sidecar keeps serving after a successful + # healthz, so its sanitizer never sees EOF and doing so would hang the + # workflow forever. Scoped to end at the gateway-preflight section (not + # end of file): that later section has its own, differently-gated + # `wait_for_sidecar_sanitizers` call for a sidecar already confirmed dead + # (a legitimate, distinct case -- see + # test_gateway_preflight_distinguishes_a_dead_sidecar_from_an_unreachable_one), + # which this assertion must not flag. healthz_confirmed = text.index("healthz and provider-route preflight confirmed") warnings_line = text.index("sidecar startup warnings (non-fatal)") - assert healthz_confirmed < warnings_line - assert "wait_for_sidecar_sanitizers" not in text[healthz_confirmed:] + gateway_preflight_start = text.index("gateway_virtual_model=") + assert healthz_confirmed < warnings_line < gateway_preflight_start + assert ( + "wait_for_sidecar_sanitizers" + not in text[healthz_confirmed:gateway_preflight_start] + ) + + +def test_sidecar_stderr_tail_covers_discovery_and_preflight_diagnostics() -> None: + """The failure-path log tail must be wide enough for the new diagnostics. + + Discovery errors (one per credentialed provider) plus preflight-rejection + diagnostics (up to ``REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES`` routes) plus a + couple of summary lines can together exceed the old fixed 20-line cap, + silently truncating exactly the evidence a fail-closed incident needs. + + Four uses total: the two healthz-branch failures, the post-healthz + non-fatal-warnings summary, and the dead-sidecar-during-gateway-preflight + failure (see + test_gateway_preflight_distinguishes_a_dead_sidecar_from_an_unreachable_one). + """ + text = _read(SIDECAR) + assert "SIDECAR_STDERR_TAIL_LINES=60" in text + assert text.count('sed -n "1,${SIDECAR_STDERR_TAIL_LINES}p"') == 4 + assert "sed -n '1,20p'" not in text def test_noema_review_workflow_provisions_sidecar_with_all_five_secrets() -> None: @@ -536,3 +565,54 @@ def test_required_strix_uses_the_gateway_and_zdr_visibility_contract() -> None: "Provision contextual-orchestrator Strix sidecar" ) assert "STRIX_FALLBACK_MODELS: \"\"" in workflow + + +def test_gateway_preflight_distinguishes_a_dead_sidecar_from_an_unreachable_one() -> None: + """A sidecar that dies between readiness and gateway preflight gets its own + diagnosis (exit status + stderr tail), not the generic transport-exhausted + message a merely-unreachable-but-still-running sidecar gets. + + Exact-head evidence (Strix run/job 33341290448/99337282309 for + ContextualWisdomLab/.github#1460 target 2cc819a9): the sidecar passed + healthz/provider-route readiness after 23s, then six minutes later all 3 + gateway-preflight attempts failed to reach it at all. The existing + ``gateway_transport_exhausted`` branch cannot tell that case apart from a + sidecar that is still running but merely unreachable over the network -- + it reports the same generic message either way, discarding the one piece + of evidence (the process's own exit status) that would tell an operator + whether the sidecar crashed. + """ + text = _read(SIDECAR) + exhausted_branch = text.index("gateway_transport_exhausted") + # Scope strictly to this branch's own opening condition so the search + # cannot accidentally match the unrelated, textually-earlier "sidecar + # exited before healthz" branch's own `kill -0 "$sidecar_pid"` check. + unreachable_branch_start = text.rindex( + 'if [ -z "$gateway_http_status" ]; then', 0, exhausted_branch + ) + dead_sidecar_check = text.index( + 'kill -0 "$sidecar_pid"', unreachable_branch_start, exhausted_branch + ) + # The dead-sidecar diagnosis must run BEFORE the generic + # transport-exhausted evidence is recorded, so a died sidecar is never + # misclassified as merely unreachable. + assert dead_sidecar_check < exhausted_branch + dead_sidecar_message = text.index( + "sidecar process exited after readiness, before gateway preflight completed" + ) + assert dead_sidecar_check < dead_sidecar_message < exhausted_branch + # Must reuse the same drain-then-read discipline as the healthz branch: + # wait() for the confirmed-dead child before reading its stderr tail, and + # drain the sanitizer first so the tail is not read mid-flight. + wait_call = text.index('wait "$sidecar_pid"', dead_sidecar_check) + assert wait_call < dead_sidecar_message + drain_call = text.index("wait_for_sidecar_sanitizers", wait_call) + assert wait_call < drain_call < dead_sidecar_message + assert f'sed -n "1,${{SIDECAR_STDERR_TAIL_LINES}}p" "$sidecar_stderr"' in text[ + dead_sidecar_message : dead_sidecar_message + 200 + ] + # The preflight evidence JSON must carry a distinct error_type so a reader + # of contextual-orchestrator-preflight.json can tell the two cases apart + # without parsing job-log prose. + assert '"error_type": "sidecar_process_exited"' in text + assert "sidecar_exit_status" in text