fix(openai): 로컬 Work 인계를 단순화한다

로컬 모델이 내부 REVIEW 프로토콜에 결합되지 않고 PLAN만 읽어 작업하도록 책임 경계를 Edge로 옮긴다.
This commit is contained in:
toki 2026-08-15 07:53:31 +09:00
parent bfaa9e8684
commit c1a402fe50
14 changed files with 204 additions and 146 deletions

View file

@ -95,9 +95,13 @@ An unmarked `light` preset with `workspace_tools` uses only the tool schemas
admitted by the caller request. Edge never receives or configures a raw caller
workspace path. Before issuing caller writes, Edge validates the selector's
default lightweight PLAN and pending REVIEW seed and retains the PLAN `P1..Pn`
inventory request-locally. Work must read PLAN, perform and verify caller-workspace
work, and use the Edge-owned canonical `iop_write_worker_review(content)` tool once
to replace REVIEW with a completed handoff covering every PLAN item. Review
inventory request-locally. Selector instructions require every PLAN field to be
English ASCII, and Edge rejects a non-ASCII PLAN before issuing caller writes.
Work receives only the relative PLAN path followed by the fixed English sentence
`Read it and complete the task.` and sees only ordinary caller tools. It must read
PLAN and perform and verify caller-workspace work. After a successful PLAN read and
a non-empty terminal worker report, Edge renders and validates the completed REVIEW
handoff and projects its single caller write; Work does not own that protocol. Review
must read both artifacts and successfully inspect an ordinary caller result before
it can return a non-empty caller-visible terminal. It never rewrites REVIEW; repair
and re-verification remain in the same Review binding. Cleanup targets only the

View file

@ -267,7 +267,7 @@ Provider-pool raw passthrough route는 위 목록을 provider request allowlist
Chat Completions의 실행 경로는 caller가 보낸 `model`의 route/provider capability로 결정한다.
- provider-pool model group route(`models[]`)는 candidate를 선택한 뒤 selected provider가 OpenAI-compatible 호출 방식을 지원하면 provider HTTP status/header/body를 Node가 열어 기존 Edge-Node tunnel로 relay하고, Edge가 caller에게 쓴다. 요청 body는 라우팅에 필요한 envelope만 읽고 `model` alias를 selected provider의 served target으로 rewrite하는 것을 기본으로 하며, provider가 지원하는 OpenAI-compatible field와 provider extension field를 보존한다.
- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 prepare 단계의 canonical `prepare(path)` 또는 pair-write 단계의 단일 `iop_write_artifact_pair(plan_content,review_content)` schema만 보여준다. Edge는 검증된 atomic pair를 PLAN/REVIEW 두 write로 확장하고 admitted caller tool의 실제 schema(예: `bash(command)`)로 각각 encode해 caller에게 투영한다. 따라서 selector에게 두 parallel tool call 생성을 요구하지 않는다. Work provider에는 ordinary caller tools와 함께 단일 canonical `iop_write_worker_review(content)` handoff tool을 제공하며, 완료된 REVIEW는 이 도구만 사용한다. Work/Review provider가 반환한 reserved-artifact operation은 실제 provider tool의 path alias(예: `filePath`)를 발행된 canonical path로 복원하고, 선택된 caller binding의 실제 tool 이름·argument mapping으로 정규화한 뒤 같은 codec으로 encode한다. 이 변환이 필요한 command-mode binding stage는 provider delta를 caller에게 선행 방출하지 않고 bounded stage를 수집·검증·매핑한 뒤 endpoint-native 응답으로 한 번 투영하며, 직접 투영 가능한 structured binding의 기존 progressive stream은 유지한다. Cleanup은 이미 caller-stage-only인 단일 call ID를 그대로 발행·해시하며 outer sequencer로 재투영하지 않는다. Caller continuation은 SDK가 assistant message에 추가·생략하는 content/reasoning/provider metadata가 아니라 발행된 tool-call ID·이름·arguments의 의미 해시로 issue를 결속한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature``top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다.
- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 prepare 단계의 canonical `prepare(path)` 또는 pair-write 단계의 단일 `iop_write_artifact_pair(plan_content,review_content)` schema만 보여준다. Selector instruction은 모든 PLAN field를 English ASCII로 작성하도록 요구하고 Edge는 non-ASCII PLAN을 caller write 전에 거부한다. Edge는 검증된 atomic pair를 PLAN/REVIEW 두 write로 확장하고 admitted caller tool의 실제 schema(예: `bash(command)`)로 각각 encode해 caller에게 투영한다. 따라서 selector에게 두 parallel tool call 생성을 요구하지 않는다. Work provider의 전체 task prompt는 relative PLAN path와 고정 영문 `Read it and complete the task.`뿐이며 ordinary caller tools만 보인다. Work가 PLAN을 성공적으로 읽은 뒤 non-empty terminal report를 반환하면 Edge가 completed REVIEW template을 렌더·검증하고 단일 caller write로 투영한다. Work provider는 REVIEW handoff tool이나 protocol을 소유하지 않는다. Work/Review provider가 반환한 reserved-artifact operation은 실제 provider tool의 path alias(예: `filePath`)를 발행된 canonical path로 복원하고, 선택된 caller binding의 실제 tool 이름·argument mapping으로 정규화한 뒤 같은 codec으로 encode한다. 이 변환이 필요한 command-mode binding stage는 provider delta를 caller에게 선행 방출하지 않고 bounded stage를 수집·검증·매핑한 뒤 endpoint-native 응답으로 한 번 투영하며, 직접 투영 가능한 structured binding의 기존 progressive stream은 유지한다. Cleanup은 이미 caller-stage-only인 단일 call ID를 그대로 발행·해시하며 outer sequencer로 재투영하지 않는다. Caller continuation은 SDK가 assistant message에 추가·생략하는 content/reasoning/provider metadata가 아니라 발행된 tool-call ID·이름·arguments의 의미 해시로 issue를 결속한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature``top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다.
- selected provider가 Ollama/native provider처럼 normalized execution을 요구하면 Edge는 normalized `RunRequest` path를 사용한다. 이 경로는 OpenAI-compatible 표면을 입력/출력 compatibility layer로 제공하되, backend 호출은 normalized adapter 계약을 따른다.
- `metadata`는 경로 선택자가 아니다. Edge는 route 결정 뒤 인증 principal, usage/observability 등 IOP가 아는 bounded metadata key만 발췌한다. 이 발췌 정보는 provider body를 바꾸는 selector가 아니며, passthrough 응답 body에 IOP marker/event/envelope를 섞지 않는다.
- Chat Completions 성공 응답의 top-level `model` echo가 provider-served model이면 caller가 요청한 IOP model alias로 정규화할 수 있다. reasoning/content/tool_calls 같은 provider payload field는 보존한다.

View file

@ -208,7 +208,7 @@ Edge가 OpenAI-compatible HTTP 요청을 받아 내부 `adapter + target` 실행
| managed projection auth | `credential_plane.enabled=true` uses the fresh Control Plane projection for inbound token auth and principal route discovery. Static principal/bearer fallback is disabled. |
| managed slot route | Public model id/alias resolves to one projected route, exact slot/profile/upstream model/resource selector, and immutable revisions/generation. Unknown, cross-principal, stale, revoked, or ambiguous bindings fail closed. |
| marked preset single-request admission | An authorized fixed single-request preset compiles one service-owned admission value at request start: requested public model, canonical plan/work/review bindings resolved through managed authorization, opaque workspace capability, and absolute resource caps. Later refresh cannot mutate the admitted shape. No private binding is echoed to the caller. Compiled only after every canonical reference is verified through its catalog binding for the authenticated principal; missing, duplicate, unauthorized, dynamically selected, or option-inconsistent inputs are rejected without fallback. |
| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives a phase-specific selector instruction from the locked artifact frontier. A non-parent-creating write binding first receives `prepare-only` for exactly one admitted `.iop/job/<request_id>` prepare call with PLAN/REVIEW writes prohibited; its successful receipt resumes the same selector with `pair-write` for the accepted artifact grammar. A parent-creating binding receives `pair-write` immediately. The selector provider sees only canonical `prepare(path)` or one atomic `iop_write_artifact_pair(plan_content,review_content)` schema. Edge validates and expands the pair into two independently receipted writes encoded in the admitted caller tool schema such as `bash(command)`; the selector is not required to produce parallel tool calls. Continuation issue identity covers semantic tool-call ID/name/arguments rather than SDK-decorated assistant content, reasoning, or provider metadata, while request prefix and result IDs stay independently validated. The instruction is the final leading Chat `system` message or final Anthropic top-level `system` text block, does not mutate the retained caller snapshot or add a public field, and is absent from Work, Review, cleanup, and marked `single_request` bodies. Selector/Work/Review Chat bodies normalize `max_tokens`/`max_completion_tokens` to the selected profile wire; target-native values win when both aliases exist. The bounded selector PLAN/REVIEW seed is capped at 4096 output tokens independently of a caller SDK's larger completion default. When tools plus reasoning effort are not representable on the selected OpenAI Chat wire, the internal stage alone uses that profile's lossless buffered Responses operation with body/tunnel `stream=false`, omits incompatible generic SDK `temperature`/`top_p` defaults, and restores the common hot-path stage shape by selected operation rather than caller protocol. A selector upstream HTTP 400 becomes one non-retryable caller HTTP 400 `invalid_request_error`, while provider details remain private. The selector's lightweight PLAN and pending REVIEW seed are validated before caller writes. Work reads PLAN, executes/verifies in the caller workspace, and replaces REVIEW with a completed `P1..Pn` handoff. Review reads both artifacts, successfully inspects an ordinary caller result, repairs/re-verifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW. Cleanup removes only the request `.iop/job/<request_id>` directory. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. |
| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives a phase-specific selector instruction from the locked artifact frontier. A non-parent-creating write binding first receives `prepare-only` for exactly one admitted `.iop/job/<request_id>` prepare call with PLAN/REVIEW writes prohibited; its successful receipt resumes the same selector with `pair-write` for the accepted artifact grammar. A parent-creating binding receives `pair-write` immediately. The selector provider sees only canonical `prepare(path)` or one atomic `iop_write_artifact_pair(plan_content,review_content)` schema. The selector must write every PLAN field in English ASCII; Edge rejects non-ASCII PLAN content. Edge validates and expands the pair into two independently receipted writes encoded in the admitted caller tool schema such as `bash(command)`; the selector is not required to produce parallel tool calls. Continuation issue identity covers semantic tool-call ID/name/arguments rather than SDK-decorated assistant content, reasoning, or provider metadata, while request prefix and result IDs stay independently validated. The selector instruction is the final leading Chat `system` message or final Anthropic top-level `system` text block and does not mutate the retained caller snapshot or add a public field. Work receives exactly the relative PLAN path plus `Read it and complete the task.` and only ordinary caller tools. After a successful PLAN read and a non-empty terminal worker report, Edge renders and validates the completed `P1..Pn` REVIEW handoff and projects its single caller write; Work does not manage that protocol. Selector/Work/Review Chat bodies normalize `max_tokens`/`max_completion_tokens` to the selected profile wire; target-native values win when both aliases exist. The bounded selector PLAN/REVIEW seed is capped at 4096 output tokens independently of a caller SDK's larger completion default. When tools plus reasoning effort are not representable on the selected OpenAI Chat wire, the internal stage alone uses that profile's lossless buffered Responses operation with body/tunnel `stream=false`, omits incompatible generic SDK `temperature`/`top_p` defaults, and restores the common hot-path stage shape by selected operation rather than caller protocol. A selector upstream HTTP 400 becomes one non-retryable caller HTTP 400 `invalid_request_error`, while provider details remain private. Review reads both artifacts, successfully inspects an ordinary caller result, repairs/re-verifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW. Cleanup removes only the request `.iop/job/<request_id>` directory. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. |
| marked single-request provider normalization | Plan/Work/Review derive caller-neutral effort/tool/structured-output requirements and let the selected protocol profile choose Chat Completions or Responses. Effort exact misses fall only to the nearest declared lower grade (`max` → `xhigh` when `max` is absent). Explicit resource selectors keep exact provider-ID verification; a `default` selector leaves provider choice to the pool while model group, profile, upstream model, credential slot/revision, and tunnel path remain frozen. Both Chat and Responses results are converted into the private common Chat-shaped stage codec before Plan/Work/Review validation. Chat conversion discards only bounded standard/provider bookkeeping (`service_tier`, `system_fingerprint`, provider `timings`, choice `logprobs`, message `annotations`, null `refusal`) and rejects a non-null refusal or unknown/duplicate fields. |
| marked single-request internal templates | The admission also freezes the operator-configured effective Plan/Review Markdown templates. They are internal artifact shapes only: the Plan stage first performs a bounded read/list-only workspace inspection and requires one successful result in the same request-local conversation, then obtains a strict one-line `goal` plus bounded one-line `steps`/`verification` arrays. Edge owns the bullet formatting and renders `plan.md`; no separate analysis artifact is created. The Review template shapes the private `review.md` artifact. Callers cannot supply, name, or select a template, and template paths, contents, and digests never appear in a response, error, log, or metric label. The caller-visible Messages request/response schema is unchanged and the final text stays the model's `decision.output`. |
| marked single-request ingress | One validated and authorized Messages POST enters the separate service coordinator capability before legacy provider/caller continuation and increments `iop_anthropic_single_request_ingress_total` once. Non-streaming returns one buffered final-only message. Streaming keeps one envelope across the coordinator lifetime, exposes only fixed plan/work/review/repair text blocks plus `event: ping`, and commits one final text/error terminal. Internal reasoning/tool wire never becomes caller `tool_use`; success is acknowledged only after the complete terminal write succeeds. |
@ -420,6 +420,7 @@ sequenceDiagram
- 2026-08-14: Applied the same provider-normalization boundary to private single-request Plan/Work/Review stages. Removed pre-selection Chat operation pinning, added Chat↔Responses stage conversion and nearest-lower effort mapping, and corrected default-selector dispatch validation so the pool-selected provider is accepted without weakening explicit provider, profile, credential, target, or execution-path fences.
- 2026-08-14: Canonicalized private Chat Completions stage responses before strict Plan/Work/Review decoding. Standard OpenAI bookkeeping fields are stripped centrally, a non-null refusal remains fail-closed, and Responses/Chat now converge on the same private envelope instead of making stage codecs provider-specific.
- 2026-08-14: Added provider `timings` to the bounded Chat response bookkeeping stripped before private stage decoding, aligning the common normalizer with the existing Ornith Work codec without admitting unknown response fields.
- 2026-08-15: Reduced the unmarked light Work input to the relative PLAN path plus one fixed English sentence, limited Work to caller tools, moved completed REVIEW projection into Edge after the worker terminal report, and required English-ASCII PLAN content.
- 2026-08-13: Gemini-native path parsing now keeps route IDs strict while accepting the bounded URL-encoded official caller model label `Gemini 3.6 Flash`.
- 2026-08-06: Synchronized always-owned Chat/Responses typed-stall recovery, provider avoidance/fallback admission, and closed-label liveness operational evidence with the current runtime, contracts, and deterministic recovery tests.
- 2026-08-06: Added marked single-request Messages admission through the separate service coordinator capability, one unlabeled runtime ingress counter, buffered sanitized terminal acknowledgement, and deterministic real-POST compatibility evidence.

View file

@ -402,6 +402,9 @@ func mapArtifactOutput(
return normalizedStageOutput{}, nil, err
}
if cleanRelativePath(requiredPath) == cleanRelativePath(issued.PlanPath) {
if err := validateEnglishOnlyPlan(content); err != nil {
return normalizedStageOutput{}, nil, err
}
plan, err := singlerequesttemplate.ParsePlan(
singlerequesttemplate.DefaultPlanTemplate,
content,
@ -424,6 +427,15 @@ func mapArtifactOutput(
return artifactResponseOutput(output, mappedCalls), payloads, nil
}
func validateEnglishOnlyPlan(content string) error {
for _, value := range []byte(content) {
if value >= 0x80 {
return fmt.Errorf("artifact plan must contain English ASCII text only")
}
}
return nil
}
func artifactWriteContent(binding *workspaceBinding, call normalizedToolCall) (string, error) {
if binding == nil {
return "", fmt.Errorf("artifact write binding is unavailable")

View file

@ -15,6 +15,15 @@ import (
"iop/packages/go/singlerequesttemplate"
)
func TestValidateEnglishOnlyPlan(t *testing.T) {
if err := validateEnglishOnlyPlan("# Plan\n\n## Goal\nCreate index.html.\n"); err != nil {
t.Fatalf("English PLAN rejected: %v", err)
}
if err := validateEnglishOnlyPlan("# Plan\n\n## Goal\n파일을 생성한다.\n"); err == nil {
t.Fatal("non-English PLAN was accepted")
}
}
func testLightweightPlan(t *testing.T) string {
t.Helper()
plan, err := singlerequesttemplate.RenderPlan(singlerequesttemplate.DefaultPlanTemplate, singlerequesttemplate.PlanFields{

View file

@ -165,29 +165,28 @@ func TestHotPathAnthropicLightStreamAggregatesStages(t *testing.T) {
fixture.consumeToolResponse(pair, []string{`{"written":true}`, `{"written":true}`})
localRead := fixture.request()
fixture.consumeToolResponse(localRead, []string{`{"written":true}`})
workerHandoff := fixture.request()
fixture.consumeToolResponse(workerHandoff, []string{`{"ok":true}`, `{"written":true}`})
localWork := fixture.request()
fixture.consumeToolResponse(localWork, []string{`{"ok":true}`})
workerReview := fixture.request()
fixture.consumeToolResponse(workerReview, []string{`{"written":true}`})
before := len(fixture.service.snapshots())
response := fixture.requestWithOptions(64, true)
if response.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
if got := len(fixture.service.snapshots()) - before; got != 2 {
t.Fatalf("same-turn provider stages=%d, want 2", got)
if got := len(fixture.service.snapshots()) - before; got != 1 {
t.Fatalf("same-turn provider stages=%d, want 1", got)
}
requests := fixture.service.snapshots()
assertCapturedHotPathBudget(t, requests[len(requests)-2], fixture.service.candidate, 64)
assertCapturedHotPathBudget(t, requests[len(requests)-1], fixture.service.candidate, 61)
assertCapturedHotPathBudget(t, requests[len(requests)-1], fixture.service.candidate, 64)
events := decodeHotPathAnthropicSSE(t, response.Body.String())
assertHotPathAnthropicBlockIndexes(t, events, 7)
assertHotPathAnthropicBlockIndexes(t, events, 5)
wantNames := []string{
"message_start",
"content_block_start", "content_block_delta", "content_block_delta", "content_block_stop",
"content_block_start", "content_block_delta", "content_block_stop",
"content_block_start", "content_block_delta", "content_block_delta", "content_block_stop",
"content_block_start", "content_block_delta", "content_block_stop",
"content_block_start", "content_block_delta", "content_block_stop",
"content_block_start", "content_block_delta", "content_block_stop",
"content_block_start", "content_block_delta", "content_block_stop",
@ -198,11 +197,11 @@ func TestHotPathAnthropicLightStreamAggregatesStages(t *testing.T) {
}
startMessage := hotPathAnthropicMap(t, events[0].payload["message"])
requestID, snapshot := soleHotPathSnapshot(t, fixture.server)
if startMessage["id"] != "msg-light-complete" || startMessage["id"] == requestID || startMessage["model"] != "virtual-model" {
if startMessage["id"] != "msg-review-inspection" || startMessage["id"] == requestID || startMessage["model"] != "virtual-model" {
t.Fatalf("outer identity mismatch: message=%+v logical_request=%s", startMessage, requestID)
}
wantKinds := []string{"thinking", "text", "thinking", "text", "tool_use", "tool_use", "tool_use"}
wantKinds := []string{"thinking", "text", "tool_use", "tool_use", "tool_use"}
var gotKinds, thinking, text []string
var toolIDs, toolNames, toolArgs []string
var stopReason string
@ -229,13 +228,13 @@ func TestHotPathAnthropicLightStreamAggregatesStages(t *testing.T) {
delta := hotPathAnthropicMap(t, event.payload["delta"])
stopReason, _ = delta["stop_reason"].(string)
usage := hotPathAnthropicMap(t, event.payload["usage"])
if usage["input_tokens"] != float64(12) || usage["output_tokens"] != float64(7) {
t.Fatalf("aggregate usage=%+v, want input=12 output=7", usage)
if usage["input_tokens"] != float64(7) || usage["output_tokens"] != float64(4) {
t.Fatalf("review usage=%+v, want input=7 output=4", usage)
}
}
}
if strings.Join(gotKinds, ",") != strings.Join(wantKinds, ",") ||
strings.Join(thinking, "") != "local-reasonreview-reason" || strings.Join(text, "") != "local-visiblereview-visible" ||
strings.Join(thinking, "") != "review-reason" || strings.Join(text, "") != "review-visible" ||
strings.Join(toolNames, ",") != "read_file,read_file,run_command" || len(toolArgs) != 3 || stopReason != "tool_use" {
t.Fatalf("multi-stage output mismatch: kinds=%v thinking=%v text=%v tool=%q/%q/%q stop=%q body=%s",
gotKinds, thinking, text, toolIDs, toolNames, toolArgs, stopReason, response.Body.String())
@ -243,8 +242,8 @@ func TestHotPathAnthropicLightStreamAggregatesStages(t *testing.T) {
if len(snapshot.ExpectedCallIDs) != 3 {
t.Fatalf("tool correlation mismatch: tools=%q snapshot=%+v", toolIDs, snapshot)
}
if strings.Join(toolIDs, ",") != "msg-light-complete-tool-1,msg-light-complete-tool-2,msg-light-complete-tool-3" || strings.Contains(response.Body.String(), "msg-review-inspection") {
t.Fatalf("public identity/tool namespace leaked a later provider id: tools=%q body=%s", toolIDs, response.Body.String())
if strings.Join(toolIDs, ",") != "msg-review-inspection-tool-1,msg-review-inspection-tool-2,msg-review-inspection-tool-3" {
t.Fatalf("public tool namespace mismatch: tools=%q body=%s", toolIDs, response.Body.String())
}
}

View file

@ -152,7 +152,7 @@ func TestHotPathChatMixedProviderStages(t *testing.T) {
{candidate: openAICandidate, body: func(requestID string) string { return scriptedArtifactPrepare("openai", requestID) }},
{candidate: openAICandidate, body: func(requestID string) string { return scriptedArtifactPair("openai", requestID) }},
{candidate: openAICandidate, body: func(requestID string) string { return scriptedArtifactLocalRead("openai", requestID) }},
{candidate: openAICandidate, body: func(requestID string) string { return scriptedLocalWorkAndHandoff("openai", requestID) }},
{candidate: openAICandidate, body: func(string) string { return scriptedLocalWorkTool("openai") }},
{candidate: openAICandidate, contentType: "text/event-stream", body: func(string) string { return hotPathChatMixedLocalSSE() }},
{candidate: anthropicCandidate, body: func(requestID string) string { return scriptedReviewReadsAndInspectionWithUsage(requestID, 7, 3) }},
}
@ -192,7 +192,8 @@ func TestHotPathChatMixedProviderStages(t *testing.T) {
consume(request(false), []string{`{"written":true}`})
consume(request(false), []string{`{"written":true}`, `{"written":true}`})
consume(request(false), []string{`{"written":true}`})
consume(request(false), []string{`{"ok":true}`, `{"written":true}`})
consume(request(false), []string{`{"ok":true}`})
consume(request(false), []string{`{"written":true}`})
response := request(true)
if response.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
@ -203,16 +204,16 @@ func TestHotPathChatMixedProviderStages(t *testing.T) {
}
requestID, snapshot := soleHotPathSnapshot(t, srv)
assertHotPathChatChunks(t, chunks, hotPathChatChunkExpectation{
ResponseID: "chatcmpl-mixed-local", Model: "virtual-model", Content: "local-A local-Breview-visible", Reasoning: "local-think review-reason",
Kinds: []string{"content", "reasoning", "content", "reasoning", "content", "tool", "tool", "tool", "terminal"},
FinishReason: "tool_calls", PromptTokens: 12, CompletionTokens: 7,
ResponseID: "msg-review-inspection", Model: "virtual-model", Content: "review-visible", Reasoning: "review-reason",
Kinds: []string{"reasoning", "content", "tool", "tool", "tool", "terminal"},
FinishReason: "tool_calls", PromptTokens: 7, CompletionTokens: 3,
})
for _, chunk := range chunks {
if chunk["id"] == requestID {
t.Fatalf("logical request identity became the public response id: %+v", chunk)
}
}
for _, internalID := range []string{snapshot.ActiveStageID, "run-chat-gate-5", "run-chat-gate-6", "msg-review-inspection"} {
for _, internalID := range []string{snapshot.ActiveStageID, "run-chat-gate-5", "run-chat-gate-6"} {
if strings.Contains(response.Body.String(), internalID) {
t.Fatalf("internal or later-stage identity %q leaked: %s", internalID, response.Body.String())
}

View file

@ -347,8 +347,10 @@ func preparePrimaryErrorStage(t *testing.T, fixture *scriptedLightFixture, revie
if review {
localRead := fixture.request()
fixture.consumeToolResponse(localRead, []string{`{"written":true}`})
workerHandoff := fixture.request()
fixture.consumeToolResponse(workerHandoff, []string{`{"ok":true}`, `{"written":true}`})
localWork := fixture.request()
fixture.consumeToolResponse(localWork, []string{`{"ok":true}`})
workerReview := fixture.request()
fixture.consumeToolResponse(workerReview, []string{`{"written":true}`})
}
}
@ -434,8 +436,10 @@ func TestHotPathCleanupCancellationStopsWork(t *testing.T) {
fixture.consumeToolResponse(pair, []string{`{"written":true}`, `{"written":true}`})
localRead := fixture.request()
fixture.consumeToolResponse(localRead, []string{`{"written":true}`})
workerHandoff := fixture.request()
fixture.consumeToolResponse(workerHandoff, []string{`{"ok":true}`, `{"written":true}`})
localWork := fixture.request()
fixture.consumeToolResponse(localWork, []string{`{"ok":true}`})
workerReview := fixture.request()
fixture.consumeToolResponse(workerReview, []string{`{"written":true}`})
reviewInspection := fixture.request()
fixture.consumeToolResponse(reviewInspection, []string{`{"written":true}`, `{"written":true}`, `{"ok":true}`})

View file

@ -1764,24 +1764,7 @@ func hotPathAnthropicStageBody(snapshot hotPathDispatchSnapshot, prompt, target
}
func hotPathStageProviderTools(snapshot hotPathDispatchSnapshot) []any {
tools := cloneAnySlice(snapshot.Tools)
if snapshot.Phase != hotPathPhaseLocalActive {
return tools
}
return append(tools, map[string]any{
"type": "function",
"function": map[string]any{
"name": hotPathWorkerReviewToolName,
"description": "Commit the complete validated worker REVIEW handoff after implementation and verification.",
"parameters": map[string]any{
"type": "object",
"properties": map[string]any{
"content": map[string]any{"type": "string", "description": "Complete REVIEW markdown"},
},
"required": []any{"content"}, "additionalProperties": false,
},
},
})
return cloneAnySlice(snapshot.Tools)
}
func applyHotPathStageOptions(body map[string]any, options map[string]any, reserved map[string]struct{}) {

View file

@ -9,6 +9,7 @@ import (
"strings"
"sync"
"time"
"unicode/utf8"
edgeservice "iop/apps/edge/internal/service"
"iop/packages/go/config"
@ -111,6 +112,7 @@ type hotPathLightRecord struct {
reviewerPlanRead bool
reviewerReviewRead bool
reviewerInspected bool
pendingLocalCommit *hotPathStageCorrelation
running bool
pendingKind hotPathPendingKind
pending map[string]hotPathPendingCall
@ -744,7 +746,21 @@ func (s *hotPathLightStore) consume(ownerEdgeID, principalRef, protocol string,
record.pendingHash = ""
record.pendingOutput = normalizedStageOutput{}
previousPhase := record.phase
record.phase = phaseAfterHotPathResult(pendingKind)
if pendingKind == hotPathPendingLocalHandoff {
if !record.workerReviewWritten || record.pendingLocalCommit == nil {
return logicalRequestSnapshot{}, hotPathLightDisposition{}, true, fmt.Errorf("worker completion review projection failed")
}
reviewStageID, err := coordinator.newStageID()
if err != nil {
return logicalRequestSnapshot{}, hotPathLightDisposition{}, true, err
}
record.localCommit = *record.pendingLocalCommit
record.pendingLocalCommit = nil
record.reviewStageID = reviewStageID
record.phase = hotPathPhaseReviewActive
} else {
record.phase = phaseAfterHotPathResult(pendingKind)
}
record.pendingKind = ""
stageID := record.localStageID
if record.phase != hotPathPhaseLocalActive {
@ -881,37 +897,80 @@ func (s *hotPathLightStore) commitLocal(requestID, ownerEdgeID string, output no
return hotPathLightDisposition{RequestID: requestID, StageID: reviewStageID, Phase: record.phase}, nil
}
func (s *hotPathLightStore) annotateWorkerReviewHandoff(
func (s *hotPathLightStore) projectWorkerCompletionReview(
requestID, ownerEdgeID string,
output normalizedStageOutput,
correlation hotPathStageCorrelation,
coordinator *logicalRequestCoordinator,
) (normalizedStageOutput, error) {
if s == nil {
return normalizedStageOutput{}, fmt.Errorf("light flow is unavailable")
}
if coordinator == nil {
return normalizedStageOutput{}, fmt.Errorf("logical request coordinator is unavailable")
}
s.mu.Lock()
defer s.mu.Unlock()
record := s.records[requestID]
if record == nil || record.ownerEdgeID != ownerEdgeID || record.phase != hotPathPhaseLocalActive {
if record == nil || record.ownerEdgeID != ownerEdgeID || record.phase != hotPathPhaseLocalActive || !record.running || len(output.ToolCalls) != 0 {
return normalizedStageOutput{}, fmt.Errorf("local flow state is unavailable")
}
paths := newReservedPaths(requestID)
seen := 0
for index := range output.ToolCalls {
call := &output.ToolCalls[index]
if call.Name != hotPathWorkerReviewToolName {
continue
}
content, ok := call.Arguments["content"].(string)
if !ok || strings.TrimSpace(content) == "" {
return normalizedStageOutput{}, fmt.Errorf("worker REVIEW handoff content is required")
}
call.Path = paths.ReviewPath
seen++
if !record.localPlanRead {
return normalizedStageOutput{}, fmt.Errorf("worker completion requires a successful plan read")
}
if seen > 1 {
return normalizedStageOutput{}, fmt.Errorf("worker REVIEW handoff must be emitted exactly once")
summary := boundedWorkerCompletionSummary(output.Content)
if summary == "" {
return normalizedStageOutput{}, fmt.Errorf("worker completion summary is required")
}
return output, nil
statuses := make([]string, len(record.planItemIDs))
for index, itemID := range record.planItemIDs {
statuses[index] = fmt.Sprintf("- %s: completed", itemID)
}
review, err := singlerequesttemplate.RenderReview(
singlerequesttemplate.DefaultReviewTemplate,
singlerequesttemplate.ReviewFields{
ItemStatus: strings.Join(statuses, "\n"),
Changes: summary,
Verification: "Worker completion report: " + summary,
Deviations: "None reported by the worker.",
},
singlerequesttemplate.MaxTemplateBytes,
)
if err != nil || singlerequesttemplate.ValidateReviewHandoff(review, record.planItemIDs) != nil {
return normalizedStageOutput{}, fmt.Errorf("project worker completion review")
}
providerCallID, err := coordinator.newCallID()
if err != nil {
return normalizedStageOutput{}, err
}
correlation.StageID = record.localStageID
correlation.ResponseID = output.ResponseID
correlation.Terminal = output.TerminalReason
record.pendingLocalCommit = &correlation
projected := cloneNormalizedStageOutput(output)
projected.Content = ""
projected.Reasoning = ""
projected.ProgressivelyReleased = false
projected.CallerStageOnly = true
projected.ToolCalls = []normalizedToolCall{{
ID: providerCallID, ProviderCallID: providerCallID, Name: hotPathWorkerReviewToolName,
Path: newReservedPaths(requestID).ReviewPath,
Arguments: map[string]any{"content": string(review)},
}}
return projected, nil
}
func boundedWorkerCompletionSummary(content string) string {
const maximum = 2048
content = strings.TrimSpace(content)
if len(content) <= maximum {
return content
}
limit := maximum
for limit > 0 && !utf8.RuneStart(content[limit]) {
limit--
}
return strings.TrimSpace(content[:limit]) + "..."
}
func (s *hotPathLightStore) classifyLocalOutput(requestID, ownerEdgeID string, output normalizedStageOutput) (hotPathPendingKind, error) {
@ -1065,8 +1124,21 @@ func (s *Server) runHotPathLightStage(w http.ResponseWriter, r *http.Request, di
return s.writeHotPathPrimaryError(w, r, dispatch, protocol, stream, requestID,
hotPathLightEndpointErrorForCause(protocol, http.StatusBadGateway, snapshot.StageID, err))
}
if snapshot.Phase == hotPathPhaseLocalActive {
output, err = s.lightFlows.annotateWorkerReviewHandoff(requestID, s.edgeIDValue(), output)
collected := false
if snapshot.Phase == hotPathPhaseLocalActive && len(output.ToolCalls) == 0 && !output.ProgressivelyReleased && !output.CallerStageOnly {
if err := runHotPathCollectedStage(r.Context(), outer, snapshot.StageID, output); err != nil {
return s.writeHotPathPrimaryError(w, r, dispatch, protocol, stream, requestID,
hotPathLightEndpointError(protocol, http.StatusBadGateway, err.Error()))
}
collected = true
}
if snapshot.Phase == hotPathPhaseLocalActive && len(output.ToolCalls) == 0 && outer.outputBudget().Exhausted {
return s.writeHotPathLightLengthTerminal(w, r, dispatch, protocol, stream, requestID, output)
}
if snapshot.Phase == hotPathPhaseLocalActive && len(output.ToolCalls) == 0 && !hotPathIsProviderLengthTerminal(output.TerminalReason) {
output, err = s.lightFlows.projectWorkerCompletionReview(
requestID, s.edgeIDValue(), output, correlation, s.requestCoordinator,
)
if err != nil {
return s.writeHotPathPrimaryError(w, r, dispatch, protocol, stream, requestID,
hotPathLightEndpointError(protocol, http.StatusBadRequest, err.Error()))
@ -1077,7 +1149,7 @@ func (s *Server) runHotPathLightStage(w http.ResponseWriter, r *http.Request, di
// endpoint codecs consume released deltas directly. Feed the same
// output into the sequencer now so its usage and terminal boundary span
// local→review transitions in this HTTP turn.
if len(output.ToolCalls) == 0 && !output.ProgressivelyReleased && !output.CallerStageOnly {
if len(output.ToolCalls) == 0 && !output.ProgressivelyReleased && !output.CallerStageOnly && !collected {
if err := runHotPathCollectedStage(r.Context(), outer, snapshot.StageID, output); err != nil {
return s.writeHotPathPrimaryError(w, r, dispatch, protocol, stream, requestID,
hotPathLightEndpointError(protocol, http.StatusBadGateway, err.Error()))

View file

@ -54,50 +54,31 @@ func TestHotPathStageCanonicalReadMapsToCallerCommandTool(t *testing.T) {
}
}
func TestWorkerReviewHandoffUsesCanonicalStageTool(t *testing.T) {
tools := hotPathStageProviderTools(hotPathDispatchSnapshot{Phase: hotPathPhaseLocalActive})
func TestWorkerProviderReceivesOnlyCallerTools(t *testing.T) {
callerTools := []any{openAIChatTool("run_command", commandSchema())}
tools := hotPathStageProviderTools(hotPathDispatchSnapshot{Phase: hotPathPhaseLocalActive, Tools: callerTools})
schemas, err := normalizeToolSchemas(tools)
if err != nil || schemas[hotPathWorkerReviewToolName] == nil {
t.Fatalf("local canonical handoff tool=%+v err=%v", schemas, err)
}
if reviewTools := hotPathStageProviderTools(hotPathDispatchSnapshot{Phase: hotPathPhaseReviewActive}); len(reviewTools) != 0 {
t.Fatalf("review stage unexpectedly received worker handoff tool: %+v", reviewTools)
}
store := newHotPathLightStore(1)
store.records["req_handoff"] = &hotPathLightRecord{
requestID: "req_handoff", ownerEdgeID: "edge", phase: hotPathPhaseLocalActive,
}
output, err := store.annotateWorkerReviewHandoff("req_handoff", "edge", normalizedStageOutput{ToolCalls: []normalizedToolCall{{
ID: "provider_handoff", Name: hotPathWorkerReviewToolName, Arguments: map[string]any{"content": "# Review\ncomplete"},
}}})
if err != nil {
t.Fatal(err)
}
if output.ToolCalls[0].Path != newReservedPaths("req_handoff").ReviewPath {
t.Fatalf("annotated handoff=%+v", output.ToolCalls[0])
if schemas["run_command"] == nil || schemas[hotPathWorkerReviewToolName] != nil || len(schemas) != 1 {
t.Fatalf("worker provider tools=%+v", schemas)
}
}
func TestHotPathLightRequiresCompletedWorkerHandoff(t *testing.T) {
func TestHotPathLightRequiresPlanReadBeforeWorkerCompletion(t *testing.T) {
for _, endpoint := range []string{"openai", "anthropic"} {
endpoint := endpoint
t.Run(endpoint+" missing handoff", func(t *testing.T) {
t.Run(endpoint, func(t *testing.T) {
fixture := newScriptedLightFixture(t, endpoint, false)
fixture.service.responses[3] = func(string) string {
fixture.service.responses[2] = func(string) string {
return scriptedLightCompletion(endpoint, "worker tried to finish early")
}
driveScriptedLightToLocalAfterPlanRead(t, fixture)
assertLightFailureAfterCleanup(t, fixture, "completed review handoff")
})
t.Run(endpoint+" malformed handoff", func(t *testing.T) {
fixture := newScriptedLightFixture(t, endpoint, false)
fixture.service.responses[3] = func(requestID string) string {
return scriptedMalformedWorkerHandoff(endpoint, requestID)
}
driveScriptedLightToLocalAfterPlanRead(t, fixture)
assertLightFailureAfterCleanup(t, fixture, "handoff is malformed")
prepare := fixture.request()
fixture.consumeToolResponse(prepare, []string{`{"written":true}`})
pair := fixture.request()
fixture.consumeToolResponse(pair, []string{`{"written":true}`, `{"written":true}`})
assertLightFailureAfterCleanup(t, fixture, "successful plan read")
})
}
}
@ -146,6 +127,18 @@ func TestHotPathStageInputIsolation(t *testing.T) {
t.Fatalf("stage prompt leaked %q: %s", forbidden, prompt)
}
}
if input.Role == "local" {
want := paths.PlanPath + "\nRead it and complete the task."
if prompt != want {
t.Fatalf("local prompt got=%q want=%q", prompt, want)
}
for _, forbidden := range []string{"immutable user task", paths.ReviewPath, "Committed selector stage success:", "Committed local stage success:", selector.StageID, local.StageID} {
if strings.Contains(prompt, forbidden) {
t.Fatalf("local prompt leaked %q: %s", forbidden, prompt)
}
}
continue
}
if !strings.Contains(prompt, "immutable user task") || !strings.Contains(prompt, paths.PlanPath) || !strings.Contains(prompt, paths.ReviewPath) {
t.Fatalf("stage prompt omitted immutable input: %s", prompt)
}
@ -175,16 +168,6 @@ func TestHotPathStageInputIsolation(t *testing.T) {
t.Fatalf("decoded selector correlation mismatch: got %#v want %#v", selDecoded, selector)
}
// Local stage must NOT carry a local correlation.
if input.Role == "local" {
if strings.Contains(prompt, "Committed local stage success:") {
t.Fatalf("local prompt leaked local correlation: %s", prompt)
}
if strings.Contains(prompt, local.StageID) {
t.Fatalf("local prompt contained local correlation fields: %s", prompt)
}
}
// Review stage must carry both selector and local correlations.
if input.Role == "review" {
if !strings.Contains(prompt, "Committed local stage success:") {
@ -390,7 +373,7 @@ func newScriptedLightFixture(t *testing.T, endpoint string, repair bool) *script
func(requestID string) string { return scriptedArtifactPrepare(endpoint, requestID) },
func(requestID string) string { return scriptedArtifactPair(endpoint, requestID) },
func(requestID string) string { return scriptedArtifactLocalRead(endpoint, requestID) },
func(requestID string) string { return scriptedLocalWorkAndHandoff(endpoint, requestID) },
func(string) string { return scriptedLocalWorkTool(endpoint) },
func(string) string { return scriptedLightCompletion(endpoint, "local-complete-visible") },
func(requestID string) string { return scriptedReviewReadsAndInspection(endpoint, requestID) },
}
@ -460,8 +443,10 @@ func (f *scriptedLightFixture) runToCleanup() *httptest.ResponseRecorder {
f.consumeToolResponse(pair, []string{`{"written":true}`, `{"written":true}`})
localRead := f.request()
f.consumeToolResponse(localRead, []string{`{"written":true}`})
workerHandoff := f.request()
f.consumeToolResponse(workerHandoff, []string{`{"ok":true}`, `{"written":true}`})
localWork := f.request()
f.consumeToolResponse(localWork, []string{`{"ok":true}`})
workerReview := f.request()
f.consumeToolResponse(workerReview, []string{`{"written":true}`})
reviewInspection := f.request()
f.consumeToolResponse(reviewInspection, []string{`{"written":true}`, `{"written":true}`, `{"ok":true}`})
resolution := f.request()
@ -590,8 +575,7 @@ func (f *scriptedLightFixture) assertCleanupStoresRemoved() {
}
// assertLocalCorrelationRegression verifies that a captured local-stage request
// carries the committed selector correlation in Run.Prompt, Run.Input["prompt"],
// and the decoded tunnel body, while omitting any local-stage correlation.
// contains only the issued PLAN path and the fixed short worker instruction.
func assertLocalCorrelationRegression(t *testing.T, req edgeservice.ProviderPoolDispatchRequest, selected edgeservice.ProviderPoolCandidate, selectorStage, selectorResponse string) {
t.Helper()
prompt := req.Run.Prompt
@ -604,25 +588,14 @@ func assertLocalCorrelationRegression(t *testing.T, req edgeservice.ProviderPool
}
inputStr := fmt.Sprint(input)
if !strings.Contains(prompt, "Committed selector stage success:") {
t.Fatalf("local Run.Prompt missing selector correlation: %s", prompt)
want := newReservedPaths(req.Run.Metadata["iop_logical_request_id"]).PlanPath + "\nRead it and complete the task."
if prompt != want || inputStr != want {
t.Fatalf("local prompt got=%q input=%q want=%q", prompt, inputStr, want)
}
if !strings.Contains(prompt, selectorStage) || !strings.Contains(prompt, selectorResponse) {
t.Fatalf("local Run.Prompt missing exact selector stage/response %q/%q: %s", selectorStage, selectorResponse, prompt)
}
if !strings.Contains(inputStr, "Committed selector stage success:") {
t.Fatalf("local Run.Input[\"prompt\"] missing selector correlation: %v", input)
}
if !strings.Contains(inputStr, selectorStage) || !strings.Contains(inputStr, selectorResponse) {
t.Fatalf("local Run.Input[\"prompt\"] missing exact selector stage/response %q/%q: %v", selectorStage, selectorResponse, input)
}
if strings.Contains(prompt, "Committed local stage success:") {
t.Fatalf("local Run.Prompt leaked local correlation: %s", prompt)
}
if strings.Contains(inputStr, "Committed local stage success:") {
t.Fatalf("local Run.Input[\"prompt\"] leaked local correlation: %v", input)
for _, forbidden := range []string{selectorStage, selectorResponse, "Committed selector stage success:", "Committed local stage success:", "User task:", "REVIEW"} {
if forbidden != "" && (strings.Contains(prompt, forbidden) || strings.Contains(inputStr, forbidden)) {
t.Fatalf("local prompt leaked %q: prompt=%q input=%q", forbidden, prompt, inputStr)
}
}
// Mandatory: decode and verify selected protocol tunnel prompt.
@ -633,14 +606,8 @@ func assertLocalCorrelationRegression(t *testing.T, req edgeservice.ProviderPool
if tunnelPrompt != prompt {
t.Fatalf("local decoded tunnel prompt mismatch: got %q want %q", tunnelPrompt, prompt)
}
if !strings.Contains(tunnelPrompt, "Committed selector stage success:") {
t.Fatalf("local tunnel body missing selector correlation: %s", tunnelPrompt)
}
if !strings.Contains(tunnelPrompt, selectorStage) || !strings.Contains(tunnelPrompt, selectorResponse) {
t.Fatalf("local tunnel body missing exact selector stage/response %q/%q: %s", selectorStage, selectorResponse, tunnelPrompt)
}
if strings.Contains(tunnelPrompt, "Committed local stage success:") {
t.Fatalf("local tunnel body leaked local correlation: %s", tunnelPrompt)
if tunnelPrompt != want {
t.Fatalf("local tunnel prompt got=%q want=%q", tunnelPrompt, want)
}
}

View file

@ -112,6 +112,9 @@ func (in hotPathStageInput) prompt(phase hotPathLightPhase) (string, error) {
if err := in.validate(); err != nil {
return "", err
}
if in.Role == "local" {
return in.Artifacts.PlanPath + "\nRead it and complete the task.", nil
}
var b strings.Builder
b.WriteString("User task:\n")
b.WriteString(in.ImmutableTask)
@ -126,8 +129,6 @@ func (in hotPathStageInput) prompt(phase hotPathLightPhase) (string, error) {
b.WriteString("\n\n")
switch in.Role {
case "local":
b.WriteString("Read the exact issued PLAN before doing any work. Perform the task and verification in the caller workspace with ordinary caller tools. Then call iop_write_worker_review exactly once with the complete REVIEW content using this closed handoff grammar: '# Review', '## Worker Item Status' with every PLAN item exactly once as '- Pn: completed', '## Worker Changes', '## Worker Verification', and '## Deviations'; every section must be non-empty. Do not use an ordinary caller tool for the issued REVIEW path. Do not complete without a successful PLAN read and this REVIEW handoff call.")
case "review":
switch phase {
case hotPathPhaseReviewActive:

View file

@ -38,6 +38,7 @@ Return exactly one iop_write_artifact_pair tool call containing both complete ar
PLAN path: %s
REVIEW path: %s
The default PLAN grammar accepts 2-6 consecutive steps and 1-3 verification bullets. For this deterministic seed, use exactly the following two-step, one-verification form and replace only angle-bracketed text:
Write every PLAN field in English using ASCII characters only.
# Plan
## Goal

View file

@ -341,6 +341,10 @@ func (s *Server) applyLightDisposition(snap logicalRequestSnapshot, disposition
metadata["iop_logical_request_id"] = disposition.RequestID
metadata["iop_call_id"] = callID
metadata["iop_stage_id"] = disposition.StageID
if disposition.TransitionFrom == hotPathPhaseLocalActive && disposition.Phase == hotPathPhaseReviewActive {
s.observeHotPathLightTransition(context.Background(), hotPathStageKindReview, hotPathAttemptFirst,
disposition.RequestID, disposition.StageID, presetID)
}
if disposition.TransitionFrom == hotPathPhaseReviewActive && disposition.Phase == hotPathPhaseReviewRepair {
s.observeHotPathLightTransition(context.Background(), hotPathStageKindReview, hotPathAttemptRetry,
disposition.RequestID, disposition.StageID, presetID)