Skip to content

Commit f718da1

Browse files
author
reconciler-bot
committed
Merge origin/main into reconcile/rct_20260912-0001-mcp-tool-roadmap
충돌은 docs/comment-policy/ledger.md 의 합계 마커 한 줄뿐이었고, check_comment_policy.py 실측값(3014 = 2989 + 이 PR 4 + #131 21)으로 재계산했다.
2 parents 9ee289f + 57a7936 commit f718da1

6 files changed

Lines changed: 134 additions & 110 deletions

File tree

control-plane/test/e2e_approval_gated_3_approval_path_test.go

Lines changed: 17 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,9 @@
33
// 검증 시나리오: approval-gated-workload.md#시나리오 3
44
//
55
// AC-F3 (docs/prd/approval-gated-workload.md), asserted on the deployed SUT.
6-
//
7-
// 이 파일이 사는 것: write 가 승인을 기다리지 않고 반환한다 · 대기 표시가 세션 출력에
8-
// in-band 로 투영되고 SSE 와 **같은 커서의** read 가 같은 바이트를 본다 · 승인 요청의 외부
9-
// 식별자가 `{세션ID}:{요청ID}` 다 · 승인 뒤 결정 마커가 같은 바이트열에 이어 붙는다 · 그
10-
// 마커를 실어 나르는 커서 계약(event id = `nextOffset`, decoded 길이 = 커서 이동량, 커서가
11-
// UTF-8 code-point 경계).
12-
//
13-
// What this file deliberately does NOT assert, and why:
14-
//
15-
// - 승인 후 **실제 아웃바운드 GET 이 정확히 1회** 일어나고 그 응답이 같은 바이트열에 이어
16-
// 붙는 것. 시나리오의 사전 조건이 요구하는 「호출을 관찰할 수 있는 외부 테스트 upstream」
17-
// 이 이 SUT 에 없다 — 도구는 https 만 받는데(`validateFetchTarget`) 그 호출을 내는 MCP
18-
// 컨테이너의 fetch 는 시스템 루트만 신뢰하는 기본 클라이언트라(`http.DefaultClient`)
19-
// 인클러스터 origin 의 사설 인증서를 검증할 수 없고, 그 컨테이너엔 CA 를 넣을 자리도
20-
// 없다. docs/test/e2e.md 의 차단 요인 `FETCH-ORIGIN` 이 그 판정을 갖는다.
21-
// - 같은 이유로 「결정 전 테스트 upstream 무도달」의 **음성** 단언도 없다. 닿을 수 있는
22-
// upstream 이 애초에 없으므로 그 단언은 결정 전이든 후든 공허하다. 「아직 실행되지
23-
// 않았다」를 이 SUT 에서 공허하지 않게 사는 형태는 따로 있고 그것이 아래 두 관측이다 —
24-
// 게이트웨이 대역이 그 요청을 **PENDING 으로 들고 있는 것**(대역에 물어서 확인한다)과
25-
// 출력에 결정 마커가 **아직 없는 것**. 둘 다 우리가 결정을 내리는 순간 바뀌므로 공허하지
26-
// 않다.
6+
// What this file buys — and the two branches it deliberately leaves unbought,
7+
// both held by the blocker `FETCH-ORIGIN` — is docs/test/e2e.md: the mapping row
8+
// the line above names, and its §「남은 미검증 분기」.
279
package e2e_test
2810

2911
import (
@@ -39,34 +21,17 @@ import (
3921
)
4022

4123
const (
42-
// The marker texts are data-plane/cmd/agent/session_mcp_notice_tail.go's
43-
// renderApprovalNotice, written out rather than imported for the reason
44-
// e2e_f1 gives. Only the prefix is a constant: the tool and the external
45-
// identifier that follow are what the assertions below read out of it.
24+
// The marker texts are session_mcp_notice_tail.go's renderApprovalNotice,
25+
// written out rather than imported for the reason e2e_f1 gives.
4626
f3AwaitingPrefix = "[session-platform: awaiting approval — "
4727
f3ApprovedPrefix = "[session-platform: approval approved — "
48-
// The provider stand-in turns this directive into a tool_use block
49-
// (deploy/e2e-anthropic-fake.yaml); providerToolName is how the CLI
50-
// namespaces the session MCP's one tool. The URL is never fetched here —
51-
// see the FETCH-ORIGIN note in the header — but it still has to be one the
52-
// gate will accept, so it is https.
53-
f3Prompt = "e2e-tool:" + providerToolName + `:{"url":"https://example.test/doc"}`
54-
// The marker names the tool the *session MCP* registers, not the namespaced
55-
// name the CLI hands the model: renderApprovalNotice prints the gate's own
56-
// webFetchGetTool (data-plane/cmd/agent/session_mcp_tools.go). The two names
57-
// differ on purpose, and that is what makes this assertion worth making —
58-
// the stand-in is instructed with providerToolName, so a marker carrying the
59-
// bare name is proof the CLI *resolved* the namespaced name onto this
60-
// server's registered tool. Asserting providerToolName here instead would
61-
// assert the CLI's own spelling back at itself; asserting this one buys the
62-
// cross-check the sibling ledger's CLAUDE-PROVIDER entry says is missing
63-
// ("대역이 내는 도구 이름이 세션 MCP 가 실제로 등재한 것과 같은지").
28+
// The directive grammar is deploy/e2e-anthropic-fake.yaml's.
29+
f3Prompt = "e2e-tool:" + providerToolName + `:{"url":"https://example.test/doc"}`
6430
f3MarkerToolName = "web_fetch_get"
6531
// data-plane/cmd/agent/session_mcp_tools.go requestIDPrefix, same reason.
6632
f3RequestIDPrefix = "req-"
67-
// The CLI gives an MCP tool call 60s before it cancels it, so the decision
68-
// has to land inside that window; everything this file waits for is sized
69-
// well under it.
33+
// 60s is the CLI's ceiling on one MCP tool call — it cancels there rather
34+
// than waiting longer, and that number lives only in its behaviour.
7035
f3MarkerBudget = 90 * time.Second
7136
)
7237

@@ -97,12 +62,6 @@ func newF3Session(t *testing.T) f3Session {
9762
return f3Session{typedSession: s, cs: cs, cfg: cfg, ns: sessionNamespace()}
9863
}
9964

100-
// The stand-in's operator surface stands in for the human at the real approval
101-
// UI. It is a ClusterIP Service, so it is driven from the one container that is
102-
// meant to reach the gateway at all — the helper pod's session MCP, the
103-
// gateway's own client — rather than from the test process. The container
104-
// already holds the address as APPROVAL_GATEWAY_URL, so the route is the only
105-
// thing this has to know.
10665
func (f f3Session) operator(t *testing.T, method, route, body string) string {
10766
t.Helper()
10867
helpers := helperPodsFor(t, f.cs, f.ns, f.ID)
@@ -114,10 +73,8 @@ func (f f3Session) operator(t *testing.T, method, route, body string) string {
11473
return f4Sh(t, f.cs, f.cfg, f.ns, helpers[0].Name, sessionMCPContainer, script, method, route, body)
11574
}
11675

117-
// f3GatewayStatus is what the stand-in says it is holding for this external
118-
// identifier. Absent is reported as "" rather than as a failure: before the CLI
119-
// has reached the tool there is legitimately no record yet, and a caller that
120-
// polls needs to tell "not yet" from "decided".
76+
// Absent is "" rather than a failure: until the CLI reaches the tool there is
77+
// legitimately no record, and that is not the same answer as "decided".
12178
func (f f3Session) gatewayStatus(t *testing.T, externalID string) string {
12279
t.Helper()
12380
raw := f.operator(t, http.MethodGet, "/operator/requests", "")
@@ -136,10 +93,6 @@ func (f f3Session) gatewayStatus(t *testing.T, externalID string) string {
13693
return ""
13794
}
13895

139-
// f3Marker reads one rendered notice out of the session buffer and returns its
140-
// two variable halves. Parsing rather than matching a whole literal is what
141-
// lets the assertions below say *which* tool and *which* request the marker
142-
// named, which is the half of AC-F3 the text alone does not carry.
14396
func f3Marker(t *testing.T, buf, prefix string) (tool, externalID string) {
14497
t.Helper()
14598
i := strings.Index(buf, prefix)
@@ -158,27 +111,21 @@ func f3Marker(t *testing.T, buf, prefix string) (tool, externalID string) {
158111
return tool, externalID
159112
}
160113

161-
// The scenario names the approval path as one round trip, so it is bought as
162-
// one: a second session would double the SUT cost and buy nothing, since every
163-
// clause below is about the ordering *within* a single wait.
164114
func TestApprovalGatedApprovalPath_WriteReturnsBeforeTheGateAndTheWaitIsAnnounced(t *testing.T) {
165115
f := newF3Session(t)
166116

167-
// The stand-in decides nothing on its own — that is what makes the wait
168-
// below real. PENDING is already its default; setting it makes this test
169-
// independent of whatever ran before it in the same cluster.
117+
// One stand-in serves the whole cluster and defaultStatus is mutable state
118+
// on it, so this is what makes the wait below independent of what ran before.
170119
f.operator(t, http.MethodPost, "/operator/policy", `{"defaultStatus":"PENDING"}`)
171120

172-
// Opened before the write so the stream carries the whole round trip from
173-
// offset 0, which is what lets the SSE and read halves be compared at the
174-
// same cursor further down.
121+
// Opened before the write, not after: the stream has to be attached before
122+
// the round trip starts or the bytes it misses are the ones compared below.
175123
stream := s6Open(t, f.ID, "?offset=0", "", f3MarkerBudget+30*time.Second)
176124

177125
started := time.Now()
178126
writeShell(t, f.ID, f3Prompt)
179127
writeTook := time.Since(started)
180128

181-
// ---------------------------------------------------------------- 대기 표시
182129
waiting := f3EventuallyMarker(t, f.ID, f3AwaitingPrefix)
183130
tool, externalID := f3Marker(t, waiting.Payload, f3AwaitingPrefix)
184131
if tool != f3MarkerToolName {
@@ -188,7 +135,6 @@ func TestApprovalGatedApprovalPath_WriteReturnsBeforeTheGateAndTheWaitIsAnnounce
188135
tool, f3MarkerToolName, providerToolName)
189136
}
190137

191-
// ------------------------------------------------------- 외부 식별자의 모양
192138
sessionID, requestID, ok := strings.Cut(externalID, ":")
193139
if !ok {
194140
t.Fatalf("external id %q is not `{세션ID}:{요청ID}`", externalID)
@@ -201,15 +147,6 @@ func TestApprovalGatedApprovalPath_WriteReturnsBeforeTheGateAndTheWaitIsAnnounce
201147
requestID, f3RequestIDPrefix)
202148
}
203149

204-
// ------------------------------------------------- write did not wait here
205-
//
206-
// The marker is published when the gate *enters* the wait and nothing can
207-
// leave that wait until the decision below, which has not happened yet — so
208-
// observing it now proves the invocation was still in flight after write
209-
// had already returned. That ordering, not the duration, is the assertion;
210-
// the duration only says how far from the wall it landed, and it is checked
211-
// against a ceiling well under the CLI's own 60s tool budget because a
212-
// write that really did block on the gate could not come in under it.
213150
if decided := strings.Contains(waiting.Payload, f3ApprovedPrefix); decided {
214151
t.Fatalf("the buffer already carries an approval decision before anyone decided: %q", waiting.Payload)
215152
}
@@ -218,21 +155,10 @@ func TestApprovalGatedApprovalPath_WriteReturnsBeforeTheGateAndTheWaitIsAnnounce
218155
}
219156
t.Logf("write returned in %s; the gate was still waiting at %s", writeTook, time.Since(started))
220157

221-
// ------------------------------------------------- the wait is not a story
222-
//
223-
// The marker says a wait began; this says the gateway is the one holding
224-
// it. Without this the marker could be printed into the void and every
225-
// assertion above would still pass.
226158
if got := f.gatewayStatus(t, externalID); got != "PENDING" {
227159
t.Fatalf("the gateway holds request %s as %q, want PENDING before anyone decides", externalID, got)
228160
}
229161

230-
// ------------------------------------ SSE 와 같은 커서의 read 가 같은 바이트
231-
//
232-
// s6Output carries the per-event half of the cursor contract (event id =
233-
// nextOffset, decoded length = cursor movement); what is added here is that
234-
// the stream and the read surface agree on the bytes *and* that the wait
235-
// marker is among them.
236162
streamed, bounds := f3DrainUntil(t, stream, f3AwaitingPrefix)
237163
if !strings.Contains(streamed, f3AwaitingPrefix) {
238164
t.Fatalf("the stream never carried the wait marker; it ended with %q", streamed)
@@ -243,11 +169,6 @@ func TestApprovalGatedApprovalPath_WriteReturnsBeforeTheGateAndTheWaitIsAnnounce
243169
atCursor.Payload, streamed)
244170
}
245171

246-
// ------------------------------------------- 커서는 UTF-8 code-point 경계다
247-
//
248-
// Non-vacuous because of the marker itself: renderApprovalNotice spells it
249-
// with an em dash and a middle dot, so the bytes the cursors move across
250-
// genuinely contain multi-byte runes for a naive chunker to split.
251172
if !utf8.ValidString(streamed) {
252173
t.Fatal("the streamed bytes are not valid UTF-8 — a cursor landed inside a rune")
253174
}
@@ -266,7 +187,6 @@ func TestApprovalGatedApprovalPath_WriteReturnsBeforeTheGateAndTheWaitIsAnnounce
266187
}
267188
}
268189

269-
// --------------------------------------------------- 승인 후에야 결정이 온다
270190
f.operator(t, http.MethodPost, "/operator/decide",
271191
`{"externalId":"`+externalID+`","status":"APPROVED"}`)
272192

@@ -276,8 +196,6 @@ func TestApprovalGatedApprovalPath_WriteReturnsBeforeTheGateAndTheWaitIsAnnounce
276196
t.Fatalf("the decision marker names %s · %s, want the waiting request %s · %s",
277197
decidedTool, decidedID, tool, externalID)
278198
}
279-
// 같은 바이트열에 이어 붙는다: the decision did not open a second buffer, and
280-
// it did not rewrite the wait that preceded it.
281199
if !strings.HasPrefix(approved.Payload, waiting.Payload) {
282200
t.Fatalf("the buffer at the decision is not an extension of the buffer at the wait:\nwait=%q\nafter=%q",
283201
waiting.Payload, approved.Payload)
@@ -311,10 +229,8 @@ func f3EventuallyMarker(t *testing.T, id, prefix string) readResp {
311229
}
312230
}
313231

314-
// f3DrainUntil accumulates output events until the marker has arrived, and
315-
// returns the bytes together with every cursor the stream stopped at. Reading
316-
// only as far as the marker keeps the stream from outliving the assertion it
317-
// serves — the session goes on producing bytes after the tool result.
232+
// Reading only as far as the marker keeps the stream from outliving the
233+
// assertion it serves — the session goes on producing bytes after the tool result.
318234
func f3DrainUntil(t *testing.T, s *s6Stream, prefix string) (string, []int64) {
319235
t.Helper()
320236
var buf strings.Builder

docs/comment-policy/ledger.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@
3232
| 2026-09-10 | `control-plane/test/e2e_state_api_6_stream_contract_test.go` · `control-plane/test/harness_shared_test.go` | 48 | `6d39ddcbad4e` | 12차 판정 패스 — **130줄 판정, 제거 82 · 유지 48**. 자매 축이 방금 들인 신규 파일 하나와, **10·11차가 유예해 둔 공유 하네스**다. 이 행으로 `control-plane/test/`의 미판정은 **`shared_volume_test.go` 하나**로 줄고, 그것만이 살아 있는 소유 선언(열린 PR)을 갖는다. 지배 형태는 9·10·11차와 같은 `docs/test/e2e.md` 매핑 행의 사본이지만, 하네스 쪽은 **다른 형태**였다 — 등재 표·매핑 규칙·실행 절차를 파일 헤더로 옮겨 적은 것과 **좌표가 이미 썩은 주석 1건**. [상세](./passes/2026-09-10-new-e2e-and-shared-harness.md) |
3333
| 2026-09-10 | `control-plane/test/e2e_claude_code_6_archive_freeze_test.go` | 14 | `157959ce7d5f` | 13차 판정 패스 — **80줄 판정, 제거 66 · 유지 14**. 자매 축이 방금 들인 신규 e2e 1파일로, 12차가 「자기 모델의 열린 PR 이 처분되면」을 조건으로 유예했다가 **그 조건이 예고된 그대로 소멸한** 잔여다(#109 머지 시점에 이 모델의 비-터미널 task 0 · 소유 열린 PR 0 ⇒ 소유자 0). **제거율 83%로 이 원장 최고**이고, 80줄 중 35줄이 파일 헤더 하나였다 — 9~12차와 같은 `docs/test/e2e.md` 사본이지만 여기서는 **매핑 행과 예외 등재 쪽이 주석보다 더 자세하다**(주석이 「`manager_test.go` 가 순서를 소유한다」로 끝낸 자리에서 그 행은 테스트 함수 세 개의 이름까지 적는다). 이 행으로 `control-plane/test/` 의 미판정은 **`shared_volume_test.go` 하나**만 남고 그것은 남의 살아 있는 소유 선언 아래에 있다. [상세](./passes/2026-09-10-claude-code-6-archive-freeze.md) |
3434
| 2026-09-12 | `control-plane/test/e2e_claude_code_3_serial_queue_test.go` · `control-plane/test/e2e_claude_code_4_stream_reconnect_test.go` | 33 | `a5f2efd9374a` | 14차 판정 패스 — **175줄 판정, 제거 142 · 유지 33**. 자매 축이 09-11 에 들인 신규 e2e 2파일로, 13차가 같은 날 들어온 **다른** 파일을 집고 닫히면서 **이 모델에서 처음으로 소유자가 0이 된** 잔여다(비-터미널 task 0 · 소유 열린 PR 0). 11차처럼 **유예 사유가 존재한 적이 없다** — 앞 슬라이스들이 매번 다른 파일을 집었을 뿐이다. 지배 형태는 9~13차와 같은 `docs/test/e2e.md` 매핑 행의 사본이고, 두 헤더 합계 66줄 중 **59줄**이 그 행·저작 슬라이스 노트·PR 본문 중 하나 이상에 문장 단위로 남아 있었다. 새 형태 하나: **헤더가 자기 PR 을 현재형으로 가리켜**(「until this PR」) 머지되는 순간 지시어가 가리킬 곳을 잃는다. 유지 33줄의 절반은 **경쟁·순서 계약**(정지 대기 루프가 왜 땜질이 아닌가 · 프로브가 write 보다 먼저 붙어야 하는 이유 · 한계 상수를 import 하지 않고 베낀 이유)이라 지우면 조용히 플레이크·거짓 음성이 된다. [상세](./passes/2026-09-12-claude-code-3-and-4.md) |
35+
| 2026-09-12 | `control-plane/test/e2e_approval_gated_3_approval_path_test.go` | 21 | `77fec16993f5` | 15차 판정 패스 — **105줄 판정, 제거 84 · 유지 21**. 자매 축이 **같은 날** #126 으로 들인 신규 e2e 1파일로, 14차가 닫은 자리에 하루도 안 돼 같은 형태가 다시 선 것이다(미등재 잔여 48 → 153). 지배 형태는 9~14차와 같은 `docs/test/e2e.md` 매핑 행의 사본이지만, 여기서는 **#126 본문의 「이 파일이 사는 것」 절**이 같은 목록을 거의 축자로 들고 있어 ②·③ 이중이었다 — 헤더 23줄 중 18줄이 그렇다. **새 형태 하나: 사본이 저작 다음 날 낡았다** — 헤더와 상수 doc 이 「도구는 https 만 받는다(`validateFetchTarget`)」를 전제로 적혔는데 #129 가 그 함수를 평문 http 까지 받도록 넓혔다. 유지 21줄의 절반은 **순서·공유상태 계약**(스트림이 write 앞에 붙어야 하는 이유 · 대역이 싱글턴이라 `defaultStatus` 가 가변 전역이라는 것 · 마커까지만 드레인하지 않으면 루프가 끝나지 않는다는 것)이라 지우면 조용히 플레이크·거짓 통과가 된다. [상세](./passes/2026-09-12-approval-gated-3.md) |
3536
<!-- /판정-원장 -->
3637

37-
판정 완료 합계 **<!-- 판정-합계 -->2993<!-- /판정-합계 -->**(등재 범위의 현재 줄 수 합).
38+
판정 완료 합계 **<!-- 판정-합계 -->3014<!-- /판정-합계 -->**(등재 범위의 현재 줄 수 합).
3839
전체 대비 비율과 미판정 잔량은 **게이트가 출력한다** — 프로즈에 적으면 낡는다.

0 commit comments

Comments
 (0)