fix(edge): 중복 handoff 검증을 제거한다
문서 완성도 판정을 여러 런타임 계층에서 반복해 부분 Review가 Reviewer에 도달하지 못하던 실패를 없애기 위해 의미 검증 책임을 Reviewer로 모은다.
This commit is contained in:
parent
d69323bb43
commit
991f7a7fa3
16 changed files with 73 additions and 555 deletions
|
|
@ -93,19 +93,18 @@ response model semantics.
|
|||
|
||||
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
|
||||
lightweight PLAN and pending REVIEW seed and retains the PLAN `P1..Pn` inventory
|
||||
request-locally. Its Edge-owned system instruction applies a compact Plan contract:
|
||||
workspace path. Before issuing caller writes, Edge checks only that the selector's
|
||||
PLAN and pending REVIEW are non-empty writes within the admitted workspace boundary.
|
||||
Its Edge-owned system instruction applies a compact Plan contract:
|
||||
analyze the immutable task first, preserve explicit requirements and acceptance,
|
||||
close 2-6 executable steps and deterministic verification, and put the exact REVIEW
|
||||
handoff duty in the final step. Every PLAN field remains English ASCII and Edge
|
||||
rejects a non-ASCII PLAN before issuing caller writes.
|
||||
handoff duty in the final step.
|
||||
Work receives only the absolute PLAN path resolved from the caller's existing working-directory system context, followed by the fixed English sentence
|
||||
`Read it and complete the task.` and sees only ordinary caller tools. It must read
|
||||
PLAN, perform and verify caller-workspace work, and replace the exact REVIEW artifact
|
||||
with completed item statuses, actual changes, verification evidence, and deviations.
|
||||
Edge validates and projects that Worker-authored REVIEW write; it never synthesizes
|
||||
Review evidence from terminal prose. Only
|
||||
Edge projects a non-empty Worker-authored REVIEW write without pre-validating its
|
||||
semantic completeness; Review inspects and repairs incomplete handoff evidence. Only
|
||||
exact PLAN/REVIEW accesses contribute artifact state; every other caller-workspace
|
||||
tool call passes through unchanged. Review alone receives an Edge-owned compact
|
||||
code-review system instruction. It must read both artifacts, inspect the actual
|
||||
|
|
@ -183,9 +182,9 @@ Edge-owned internal stage inputs only:
|
|||
The stage then requests a stage-owned strict JSON object with a one-line `goal`
|
||||
string, 2-6 non-empty one-line `steps` strings, and 1-3 non-empty one-line
|
||||
`verification` strings. Edge renders deterministic `P1..Pn` step IDs into the
|
||||
frozen PLAN and Work must parse that stored document before provider dispatch.
|
||||
Work returns strict worker item status, changes, verification, and deviations;
|
||||
Edge renders and validates the one `review.md` handoff. Review reads both stored
|
||||
frozen PLAN. Work receives that stored document without a second semantic parser.
|
||||
Work returns worker item status, changes, verification, and deviations;
|
||||
Edge renders the `review.md` handoff without blocking partial evidence. Review reads both stored
|
||||
artifacts, never accepts a memory work payload, and never rewrites `review.md`.
|
||||
Provider output never controls artifact headings or static template text.
|
||||
- The caller-visible request and response schemas are unchanged. A configured
|
||||
|
|
|
|||
|
|
@ -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 pair는 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 system instruction은 compact Plan 계약에 따라 immutable task를 먼저 분석하고 explicit requirement/constraint/acceptance를 보존한 2-6개 closed step, deterministic verification, exact REVIEW handoff duty를 작성하도록 하며 PLAN field를 English ASCII로 제한한다. Edge는 검증된 atomic pair를 admitted caller tool schema의 PLAN/REVIEW 두 write로 확장한다. Work provider의 전체 task prompt는 caller의 기존 working-directory system context에서 해석한 absolute PLAN path와 고정 영문 `Read it and complete the task.`뿐이며 ordinary caller tools만 보인다. Work는 PLAN을 읽고 구현·검증한 뒤 completed item status, actual changes, verification evidence, deviation을 담은 exact REVIEW write를 직접 반환한다. Edge는 이 Worker-authored handoff를 검증·투영하며 terminal prose로 REVIEW evidence를 합성하지 않는다. Review provider에만 별도 compact code-review system instruction을 주입한다. Review는 PLAN/REVIEW와 actual caller result를 읽고 applicable verification을 재실행하며, defect가 있으면 같은 binding에서 root cause를 확정해 repair/re-verify한 후에만 terminal을 반환한다. REVIEW를 다시 쓰거나 별도 Result 문서를 만들지 않는다. 정확한 PLAN/REVIEW 접근만 artifact state로 기록하고 그 외 caller-workspace tool call은 그대로 통과시킨다. Reserved-artifact operation은 provider tool의 path alias를 canonical path로 복원하고 caller binding의 tool/argument mapping으로 정규화한다. Command-mode binding stage는 provider delta를 선행 방출하지 않고 bounded stage를 수집·검증·매핑하며 structured binding의 progressive stream은 유지한다. Cleanup은 request-local job directory만 제거한다. Caller continuation은 SDK 장식이 아니라 발행된 tool-call ID·이름·arguments의 의미 해시로 issue를 결속한다. Function tools와 reasoning effort를 Chat wire가 함께 표현하지 못하면 같은 selected profile의 Responses operation으로 내부 wire만 전환한다. Result decoder는 selected operation을 우선하며 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 pair는 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 system instruction은 compact Plan 계약에 따라 immutable task를 먼저 분석하고 explicit requirement/constraint/acceptance를 보존한 2-6개 closed step, deterministic verification, REVIEW handoff duty를 작성하도록 한다. Edge는 workspace 경계와 비어 있지 않은 write만 확인한 atomic pair를 admitted caller tool schema의 PLAN/REVIEW 두 write로 확장한다. Work provider의 전체 task prompt는 caller의 기존 working-directory system context에서 해석한 absolute PLAN path와 고정 영문 `Read it and complete the task.`뿐이며 ordinary caller tools만 보인다. Work는 PLAN을 읽고 구현·검증한 뒤 item status, actual changes, verification evidence, deviation을 담은 REVIEW write를 직접 반환한다. Edge는 이 Worker-authored handoff의 의미 완성도를 선검증하지 않으며, 부분 handoff도 Review가 검사·수리하도록 전달한다. Review provider에만 별도 compact code-review system instruction을 주입한다. Review는 PLAN/REVIEW와 actual caller result를 읽고 applicable verification을 재실행하며, defect가 있으면 같은 binding에서 root cause를 확정해 repair/re-verify한 후에만 terminal을 반환한다. REVIEW를 다시 쓰거나 별도 Result 문서를 만들지 않는다. 정확한 PLAN/REVIEW 접근만 artifact state로 기록하고 그 외 caller-workspace tool call은 그대로 통과시킨다. Reserved-artifact operation은 provider tool의 path alias를 canonical path로 복원하고 caller binding의 tool/argument mapping으로 정규화한다. Command-mode binding stage는 provider delta를 선행 방출하지 않고 bounded stage를 수집·매핑하며 structured binding의 progressive stream은 유지한다. Cleanup은 request-local job directory만 제거한다. Caller continuation은 SDK 장식이 아니라 발행된 tool-call ID·이름·arguments의 의미 해시로 issue를 결속한다. Function tools와 reasoning effort를 Chat wire가 함께 표현하지 못하면 같은 selected profile의 Responses operation으로 내부 wire만 전환한다. Result decoder는 selected operation을 우선하며 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는 보존한다.
|
||||
|
|
|
|||
|
|
@ -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 `prepare-only` or atomic `pair-write` from the locked artifact frontier. The selector receives a compact Plan system contract: analyze the immutable task, preserve explicit requirements/constraints/acceptance, author 2-6 closed steps plus deterministic verification, and put the exact REVIEW handoff duty in the final step. PLAN remains English ASCII and pending REVIEW carries the same P1..Pn inventory. Work receives only the absolute PLAN path plus `Read it and complete the task.` and ordinary caller tools; no Plan/Review skill system prompt is added to Work. Work implements and verifies, then directly replaces REVIEW with completed statuses, actual changes, verification evidence, and deviations. Edge validates that Worker-authored write and never synthesizes it from terminal prose. Review alone receives the compact code-review system contract. It rereads PLAN/REVIEW, inspects the actual result, reruns applicable verification, repairs and reverifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW or creating a Result document. Exact artifact accesses update request-local state, ordinary caller tool calls pass through, and cleanup removes only `.iop/job/<request_id>`. Provider operation/effort/tool normalization remains selected-profile driven and caller identity independent. 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 `prepare-only` or atomic `pair-write` from the locked artifact frontier. The selector receives a compact Plan system contract: analyze the immutable task, preserve explicit requirements/constraints/acceptance, author concise closed steps plus deterministic verification, and include the REVIEW handoff duty. Edge checks only the admitted workspace boundary, non-empty writes, and size bounds before projecting PLAN/REVIEW. Work receives only the absolute PLAN path plus `Read it and complete the task.` and ordinary caller tools; no Plan/Review skill system prompt is added to Work. Work implements and verifies, then directly replaces REVIEW with status, changes, verification evidence, and deviations. Partial or imperfect REVIEW content proceeds to Review instead of terminating at Edge. Review alone receives the compact code-review system contract. It rereads PLAN/REVIEW, inspects the actual result, reruns applicable verification, repairs and reverifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW or creating a Result document. Exact artifact accesses update request-local state, ordinary caller tool calls pass through, and cleanup removes only `.iop/job/<request_id>`. Provider operation/effort/tool normalization remains selected-profile driven and caller identity independent. 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. |
|
||||
|
|
@ -305,7 +305,7 @@ sequenceDiagram
|
|||
- provider-pool model group은 capacity + priority + availability 기준으로 provider candidate를 먼저 선택하고, 선택된 provider가 OpenAI-compatible 호출 방식을 지원하면 raw tunnel passthrough로 dispatch한다. Ollama/native provider가 선택되면 normalized `RunRequest` path로 dispatch한다.
|
||||
- Anthropic Messages and count-tokens do not use legacy direct-route or single-target fallback. Native responses preserve provider status, allowed headers, and body/SSE bytes; bridge responses are converted between Anthropic Messages and Chat Completions shapes.
|
||||
- A marked single-request Messages dispatch requires the narrow service coordinator capability and never falls back to the generic provider pool. The handler copies the immutable binding and request input and counts the accepted HTTP admission once with no labels. The service projects exactly one frozen terminal candidate through both response modes: buffered/SSE `end_turn`; buffered/SSE `max_tokens` without private partial content; `invalid_request_error` for validation/context; `api_error` for provider, timeout, budget, repetition, malformed, internal-tool, and workspace-cleanup failures; or silent cancellation after caller disconnect. The streaming path maps only fixed plan/work/review/repair summaries, serializes pings and monotonic text-block indices with one terminal owner, stops and joins liveness before terminal/return, and acknowledges completion only after `message_stop`. Arbitrary progress, reasoning, tool/provider/credential/workspace data, raw failures, and internal stage terminals stay private. No classified terminal triggers retry, fallback, partial success, a second request, or a later success terminal. Count-tokens does not enter or increment this path.
|
||||
- Marked single-request Plan/Review templates are Edge-owned internal artifact shapes, not part of this input surface. Admission freezes the effective pair; Edge renders deterministic PLAN `P1..Pn` IDs, Work writes one strict REVIEW handoff (item status, changes, verification, deviations), and Review rereads both artifacts without a memory worker payload or a final REVIEW write. No caller field, header, or metadata value can supply, name, select, or override a template, and no template path, content, or digest appears in a response, an error message, a log projection, or a metric label. Changing a template changes neither the Messages request schema nor the response schema: the buffered/SSE terminal projection is unchanged and the final caller-visible text is exactly the reviewer `decision.output` after any repair/re-verification.
|
||||
- Marked single-request Plan/Review templates are Edge-owned internal artifact shapes, not part of this input surface. Admission freezes the effective pair; Edge renders PLAN `P1..Pn` IDs, Work writes a REVIEW handoff (item status, changes, verification, deviations), and Review rereads both artifacts without a memory worker payload or a final REVIEW write. Edge does not reparse PLAN or reject a partial REVIEW on semantic completeness before Review dispatch. No caller field, header, or metadata value can supply, name, select, or override a template, and no template path, content, or digest appears in a response, an error message, a log projection, or a metric label. Changing a template changes neither the Messages request schema nor the response schema: the buffered/SSE terminal projection is unchanged and the final caller-visible text is exactly the reviewer `decision.output` after any repair/re-verification.
|
||||
- Marked single-request observation evidence links ingress=1, request-total=1, terminal=1, stage/tool/cleanup counts, and raw-free correlation for one real POST. `iop_anthropic_single_request_ingress_total` is strictly unlabeled: no request_id, stage_id, provider identity, content, or workspace reference appears as a metric label. Internal tool names (`workspace_read`, `workspace_write`, etc.), raw arguments, private results, and workspace references are absent from the public terminal JSON and from log projections. Stage-pure timing, cardinality-bounded labels, and privacy semantics are documented here. SDD S12 qualifies the external Claude path on an approved IOP Node with one accepted ingress, the expected stage sequence, one terminal, exact output, timing, cleanup, and redacted evidence.
|
||||
- Internal workspace calls use a service-owned schema independent of caller-facing tool codecs. The five closed operation names decode into typed Node requests only after request/stage/tool identity, canonical relative path, approved operation/command/environment capability, and immutable budget checks. The loop opens once, preserves the admitted connection generation, executes one pending call at a time, accepts only correlated typed results, and returns a deep-copied raw-free result to the same executor continuation. Repeated IDs, stale responses, malformed or denied input, timeout, output/iteration exhaustion, and cancellation never become public Anthropic tool protocol or trigger a second ingress.
|
||||
- Claude Code Messages requests may use adaptive thinking, `output_config.effort`, structured output, cache-control annotations, and supported beta headers, including the compatibility-only `advisor-tool-2026-03-01` marker emitted by the pinned official caller. The Chat bridge consumes rather than forwards those headers, maps supported fields, and requires callers to replay opaque `tool_use.id` values unchanged so Gemini thought signatures can be restored on tool-result turns.
|
||||
|
|
@ -371,6 +371,7 @@ sequenceDiagram
|
|||
|
||||
## 변경 기록
|
||||
|
||||
- 2026-08-15: Removed duplicate runtime semantic gates from Plan/Review handoffs. Edge now keeps workspace, non-empty write, and size boundaries while Reviewer owns completeness checks and repair of partial worker evidence.
|
||||
- 2026-08-15: Corrected the unmarked caller-workspace hybrid responsibility contract. Planner and Reviewer now receive separate compact system contracts, Worker still receives only the absolute PLAN path plus the fixed sentence, Worker directly authors REVIEW evidence, and Review retains inspection/repair/re-verification without a Result artifact.
|
||||
|
||||
- 2026-08-15: Normalized Chat output-token aliases by selected protocol profile across provider-pool and unmarked caller-workspace selector/Work/Review calls. OpenAI Chat uses `max_completion_tokens`, Gemini Chat uses `max_tokens`, target-native values win, and unmarked internal stages use the selected OpenAI profile's Responses operation when tools plus effort cannot be represented by Chat. The reasoning Responses conversion omits incompatible generic SDK `temperature`/`top_p` defaults. Selector upstream HTTP 400 now terminates once as caller `invalid_request_error` instead of retryable 502. Command-mode workspace execution also carries the admitted containment guard in the actual outgoing command.
|
||||
|
|
|
|||
|
|
@ -257,8 +257,8 @@ The shared `packages/go/execution` package contains provider lifecycle, registry
|
|||
- The request-local internal tool loop is implemented between the coordinator and the dedicated workspace wire. Strict decode and capability checks happen before wire effects; Node results are accepted only for the one pending call and return only bounded typed fields to the same optional executor continuation. Repeated or stale identities, malformed/denied calls, exhausted immutable budgets, and cancellation terminate internally without selecting another Node or involving the HTTP caller.
|
||||
- Request-owned plan and review artifact access is implemented between the controller and the same dedicated workspace wire. Only `SingleRequestArtifactPlan` and `SingleRequestArtifactReview` are accepted. Artifact and model-tool callers share one serialized open attempt and the same opened cleanup gate; terminal and cancellation paths wait for in-flight artifact work before issuing exactly one cleanup. Edge bounds writes before dispatch and reads before acceptance, validates the echoed kind/operation and canonical terminal, and never reselects after a generation mismatch. Node maps the closed selectors to `plan.md` and `review.md`, validates the inventoried parent/file identity with descriptor-relative no-follow reads, and never grants the public workspace tool surface access to `.iop`.
|
||||
- The private Plan stage is installed in the composite single-request executor at Edge input startup (`apps/edge/internal/input/manager.go`). Its provider codec accepts only frozen Plan options and selected dispatch facts, uses the admitted stage deadline and exact output limit, and projects provider failures to the closed stage terminal. Before structured completion, it exposes only frozen-authority `workspace_read`/`workspace_list`, forces the first turn to use one of them, validates the canonical call, and resumes `planning` through the shared correlated continuation bridge. A failed result does not unlock completion, and the admitted per-stage iteration limit bounds further inspection. No write/delete/command capability or separate analysis artifact exists in Plan. After successful inspection the stage owns a closed strict JSON response schema with exactly a string `goal`, a string-array `steps`, and a string-array `verification`; unknown, duplicate, missing, or mistyped fields fail malformed. It enforces a single-line goal, 2-6 non-empty one-line step items, and 1-3 non-empty one-line verification items. Edge, rather than the provider, adds Markdown bullet prefixes and newlines before substituting the values into the frozen effective Plan template. Required headings remain exact standalone lines, the documented placeholder inventory is closed, and unresolved delimiters are rejected. Provider output therefore cannot vary headings, bullet formatting, or static template text, and caller request fields cannot select, supply, or override the admitted template.
|
||||
- The private Work stage is installed in the composite single-request executor at Edge input startup (`apps/edge/internal/input/manager.go`). It reads and strictly validates `SingleRequestArtifactPlan`, retains only request/stage/tool identifiers while waiting for the coordinator-owned continuation, and sends no `reasoning_effort` field in an initial or resumed provider request. Its successful strict response is rendered once as `SingleRequestArtifactReview`; write failure prevents Review.
|
||||
- Review reads and validates the stored PLAN and REVIEW handoff before its provider call. It may inspect, repair, and re-verify with admitted tools, but it neither takes a memory worker result nor writes a final REVIEW artifact. A repair mutation requires later successful inspection evidence before PASS; caller output is byte-for-byte the reviewer strict `output` field and cleanup removes the temporary artifacts.
|
||||
- The private Work stage is installed in the composite single-request executor at Edge input startup (`apps/edge/internal/input/manager.go`). It reads a non-empty bounded `SingleRequestArtifactPlan`, retains only request/stage/tool identifiers while waiting for the coordinator-owned continuation, and sends no `reasoning_effort` field in an initial or resumed provider request. Its response is rendered once as `SingleRequestArtifactReview`; write failure prevents Review, while partial report fields remain Reviewer-owned evidence.
|
||||
- Review reads the stored non-empty bounded PLAN and REVIEW handoff before its provider call. Edge does not reparse the PLAN or pre-judge REVIEW semantic completeness. Review may inspect, repair, and re-verify with admitted tools, but it neither takes a memory worker result nor writes a final REVIEW artifact. A repair mutation requires later successful inspection evidence before PASS; caller output is byte-for-byte the reviewer strict `output` field and cleanup removes the temporary artifacts.
|
||||
- The Node-private workspace request/result wire is implemented, including catalog delivery, parser registration, optional handler behavior, stable typed failures, generation-fenced dispatch, context-cancel propagation, and request cleanup. Before ready, a non-empty catalog requires a supported `darwin|linux` host and exact entry/host matching before any root open; unsupported and cross-platform catalogs fail closed while empty catalogs remain compatible. The Node installs the workspace handler before ready and cleans active requests before closing workspace authority ahead of session/store teardown. Request authority is immutable and request-local. File operations reserve `.iop`, reject symlink/mount/replaced-parent/special-file paths before effects, process bounded list batches with deterministic truncation, and use a same-parent structured write. Command execution resolves only admitted ids to fixed templates, enters the already-opened root descriptor through `fchdir`, provides only allowlisted environment entries, shares one output cap across drained stdout/stderr, and owns the complete process group through exit, timeout, context cancel, exact request/tool cancel, or request cleanup.
|
||||
- managed mode는 등록과 dispatch 전에 CA로 검증된 Edge/Node workload identity를 요구한다.
|
||||
- revoked, disabled, expired, stale, replayed, wrong-recipient, mismatched lease는 provider나 credential fallback 없이 fail closed한다.
|
||||
|
|
@ -347,9 +347,9 @@ Heartbeat interval/wait는 protobuf field가 아닌 양쪽 transport 구현의 l
|
|||
- `go test -count=1 ./apps/edge/internal/openai -run 'TestAnthropicSingleRequestObservation'` — deterministic single-request observation evidence: ingress=1, request-total=1, terminal=1, stage/tool/cleanup counts, raw-free correlation, and unlabeled metric assertion.
|
||||
- `go test -count=1 ./apps/edge/internal/openai -run 'TestSingleRequest(ProviderStage|PlanStage)'` — deterministic frozen provider codec and Plan stage evidence, including required read/list-first inspection, mutation-tool exclusion, successful-result completion gate, high reasoning, the closed structured response schema, exact JSON field admission, ordered tunnel frames, deterministic frozen-template rendering, planning/internal-tool resume envelopes, and `plan.md` artifact selection.
|
||||
- `go test -count=1 ./apps/edge/internal/openai -run 'TestSingleRequest(ProviderStage|ProviderNormalization|PlanStage|WorkStage|ReviewStage|Executor)'` — private stage operation selection, Chat/Responses conversion, nearest-lower effort (`max` → `xhigh`), default-selector pool-provider acceptance, explicit dispatch fences, and composite regression evidence.
|
||||
- `go test -count=1 ./packages/go/singlerequesttemplate ./packages/go/config` — exact standalone heading/`PASS` grammar, closed placeholder inventory, the `8192`-byte boundary, and relative-only `plan_file`/`review_file` loading with per-file fallback and filesystem-kind rejection.
|
||||
- `go test -count=1 ./packages/go/singlerequesttemplate ./packages/go/config` — template rendering, closed placeholder inventory, the `8192`-byte boundary, and relative-only `plan_file`/`review_file` loading with per-file fallback and filesystem-kind rejection.
|
||||
- `go test -count=1 ./apps/edge/internal/service -run 'TestSingleRequestBinding'` and `go test -count=1 ./apps/edge/internal/openai -run 'TestSingleRequest(PresetBinding|ReviewStage)'` — admitted effective-template snapshot through clone and workspace revalidation, refresh isolation for admitted versus newly admitted requests, and the custom-template REVIEW artifact with unchanged caller-visible output.
|
||||
- `go test -race -count=1 ./apps/edge/internal/openai -run 'TestSingleRequestWork(Stage|ToolBridge)'` — deterministic ornith-fast Work tool loop, correlation isolation, cancellation cleanup, strict completion evidence, and Work reasoning-option absence.
|
||||
- `go test -race -count=1 ./apps/edge/internal/openai -run 'TestSingleRequestWork(Stage|ToolBridge)'` — deterministic Work tool loop, correlation isolation, cancellation cleanup, REVIEW rendering, and Work reasoning-option absence.
|
||||
- `go test -race ./apps/edge/internal/service ./apps/edge/internal/openai -run 'Test(SingleRequestTerminalDisposition|SingleRequestQualityGate|AnthropicSingleRequestErrorCancelMatrix|SingleRequestAnthropicStreamTerminalDisposition)' -count=1` — deterministic S11 error-cancel/length matrix, first-terminal ownership, one ingress, no second request, disconnect silence, and raw-free output evidence.
|
||||
|
||||
## 한계와 주의사항
|
||||
|
|
@ -367,6 +367,7 @@ Heartbeat interval/wait는 protobuf field가 아닌 양쪽 transport 구현의 l
|
|||
|
||||
## 변경 기록
|
||||
|
||||
- 2026-08-15: Removed duplicate PLAN reparsing and REVIEW semantic pre-validation from Work/Review. Non-empty bounded artifacts proceed to Reviewer, which owns completeness checks and repair.
|
||||
- 2026-08-14: Added bounded read/list-first workspace analysis inside Plan. Structured Plan completion now requires a successful inspection result in the same request-local conversation; Plan exposes no mutation tool and creates no additional analysis artifact (`apps/edge/internal/openai/single_request_plan_stage.go`).
|
||||
- 2026-08-14: Restored artifact-only model handoff: deterministic PLAN `P1..Pn` IDs, one Work-authored validated REVIEW handoff, Review artifact reread with request-local repair/re-verification evidence, reviewer zero-write, and strict terminal `output` provenance.
|
||||
- 2026-08-14: Moved private Plan/Work/Review provider calls onto the shared provider-normalization boundary. Stage requirements now select Chat or Responses without caller identity, unsupported effort maps only downward, and default-selector provider-pool choices no longer fail the post-dispatch validation that still fences profile, target, credential revision, model group, and tunnel path.
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@ import (
|
|||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"iop/packages/go/singlerequesttemplate"
|
||||
)
|
||||
|
||||
const defaultArtifactFrontierCapacity = 1024
|
||||
|
|
@ -41,7 +39,6 @@ const (
|
|||
type artifactDisposition struct {
|
||||
Kind artifactDispositionKind
|
||||
SelectorStageID string
|
||||
PlanItemIDs []string
|
||||
PrimaryError *hotPathEndpointError
|
||||
}
|
||||
|
||||
|
|
@ -85,7 +82,6 @@ type artifactFrontierRecord struct {
|
|||
pendingHash string
|
||||
consumedHashes map[string]struct{}
|
||||
consumedIDs map[string]struct{}
|
||||
planItemIDs []string
|
||||
}
|
||||
|
||||
// artifactFrontierStore owns the request-local workspace binding and the sole
|
||||
|
|
@ -418,7 +414,6 @@ func mapArtifactOutput(
|
|||
orderedPaths := []string{issued.PlanPath, issued.ReviewPath}
|
||||
mappedCalls := make([]normalizedToolCall, 0, 2)
|
||||
payloads := make(map[string]*workspaceEncodedPayload, 2)
|
||||
var planItemIDs []string
|
||||
for _, requiredPath := range orderedPaths {
|
||||
call, ok := byPath[cleanRelativePath(requiredPath)]
|
||||
if !ok {
|
||||
|
|
@ -428,45 +423,18 @@ func mapArtifactOutput(
|
|||
if err != nil {
|
||||
return normalizedStageOutput{}, nil, err
|
||||
}
|
||||
content, err := artifactWriteContent(record.binding, call)
|
||||
if err != nil {
|
||||
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,
|
||||
singlerequesttemplate.MaxTemplateBytes,
|
||||
)
|
||||
if err != nil {
|
||||
return normalizedStageOutput{}, nil, fmt.Errorf("artifact plan does not match the lightweight template: %w", err)
|
||||
}
|
||||
planItemIDs, err = singlerequesttemplate.PlanItemIDs(plan)
|
||||
if err != nil {
|
||||
return normalizedStageOutput{}, nil, fmt.Errorf("artifact plan item inventory is invalid: %w", err)
|
||||
}
|
||||
} else if err := validatePendingReviewSeed(content, planItemIDs); err != nil {
|
||||
if _, err := artifactWriteContent(record.binding, call); err != nil {
|
||||
return normalizedStageOutput{}, nil, err
|
||||
}
|
||||
// Plan and Review contents are model-to-model handoff artifacts. The
|
||||
// workspace boundary already requires a non-empty write; their semantic
|
||||
// completeness belongs to the Worker and Reviewer, not the transport.
|
||||
mappedCalls = append(mappedCalls, mapped)
|
||||
payloads[mapped.ID] = payload
|
||||
}
|
||||
record.planItemIDs = append([]string(nil), planItemIDs...)
|
||||
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")
|
||||
|
|
@ -486,36 +454,6 @@ func artifactWriteContent(binding *workspaceBinding, call normalizedToolCall) (s
|
|||
return content, nil
|
||||
}
|
||||
|
||||
func pendingReviewSeed(planItemIDs []string) ([]byte, error) {
|
||||
if len(planItemIDs) == 0 {
|
||||
return nil, singlerequesttemplate.ErrMalformedReview
|
||||
}
|
||||
statuses := make([]string, len(planItemIDs))
|
||||
for i, id := range planItemIDs {
|
||||
statuses[i] = fmt.Sprintf("- %s: pending", id)
|
||||
}
|
||||
return singlerequesttemplate.RenderReview(
|
||||
singlerequesttemplate.DefaultReviewTemplate,
|
||||
singlerequesttemplate.ReviewFields{
|
||||
ItemStatus: strings.Join(statuses, "\n"),
|
||||
Changes: "Pending worker execution.", Verification: "Pending worker verification.",
|
||||
Deviations: "None recorded.",
|
||||
},
|
||||
singlerequesttemplate.MaxTemplateBytes,
|
||||
)
|
||||
}
|
||||
|
||||
func validatePendingReviewSeed(content string, planItemIDs []string) error {
|
||||
want, err := pendingReviewSeed(planItemIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build pending review seed: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(content) != strings.TrimSpace(string(want)) {
|
||||
return fmt.Errorf("artifact review seed does not match the lightweight pending template")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapArtifactCall(
|
||||
binding *workspaceBinding,
|
||||
providerCall normalizedToolCall,
|
||||
|
|
@ -715,7 +653,6 @@ func (s *artifactFrontierStore) consume(
|
|||
if primaryFailure != nil {
|
||||
return snap, artifactDisposition{
|
||||
Kind: artifactDispositionLocalEligible, SelectorStageID: record.selectorStageID,
|
||||
PlanItemIDs: append([]string(nil), record.planItemIDs...),
|
||||
PrimaryError: primaryFailure,
|
||||
}, true, nil
|
||||
}
|
||||
|
|
@ -730,10 +667,7 @@ func (s *artifactFrontierStore) consume(
|
|||
return snap, artifactDisposition{Kind: artifactDispositionResumeSelector, SelectorStageID: record.selectorStageID}, true, nil
|
||||
case artifactPhasePairPending:
|
||||
record.phase = artifactPhaseLocalEligible
|
||||
return snap, artifactDisposition{
|
||||
Kind: artifactDispositionLocalEligible, SelectorStageID: record.selectorStageID,
|
||||
PlanItemIDs: append([]string(nil), record.planItemIDs...),
|
||||
}, true, nil
|
||||
return snap, artifactDisposition{Kind: artifactDispositionLocalEligible, SelectorStageID: record.selectorStageID}, true, nil
|
||||
default:
|
||||
return logicalRequestSnapshot{}, artifactDisposition{}, true, fmt.Errorf("artifact frontier phase cannot consume results")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,15 +15,6 @@ 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{
|
||||
|
|
@ -50,7 +41,10 @@ func TestNormalizeCallerWorkspacePlanBoundary(t *testing.T) {
|
|||
|
||||
func testPendingReview(t *testing.T) string {
|
||||
t.Helper()
|
||||
review, err := pendingReviewSeed([]string{"P1", "P2"})
|
||||
review, err := singlerequesttemplate.RenderReview(singlerequesttemplate.DefaultReviewTemplate, singlerequesttemplate.ReviewFields{
|
||||
ItemStatus: "- P1: pending\n- P2: pending", Changes: "Pending worker execution.",
|
||||
Verification: "Pending worker verification.", Deviations: "None recorded.",
|
||||
}, singlerequesttemplate.MaxTemplateBytes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
@ -244,20 +238,6 @@ func TestArtifactPairFrontierMatrix(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestArtifactPairRejectsMalformedTemplatesBeforeCallerEffects(t *testing.T) {
|
||||
fixture := newArtifactPairFixture(t, "openai", true)
|
||||
_, err := fixture.issue([]normalizedToolCall{
|
||||
artifactProviderWrite("provider_plan", fixture.paths.PlanPath, "not a plan"),
|
||||
artifactProviderWrite("provider_review", fixture.paths.ReviewPath, testPendingReview(t)),
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "lightweight template") {
|
||||
t.Fatalf("malformed plan error = %v", err)
|
||||
}
|
||||
if fixture.server.artifactFrontiers.has(fixture.requestID, fixture.ownerEdgeID) || fixture.server.lightFlows.has(fixture.requestID, fixture.ownerEdgeID) {
|
||||
t.Fatal("malformed artifact retained a caller-effect frontier")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetWorkspaceOwnershipAdmission(t *testing.T) {
|
||||
unmarked := hotPathSelectorPreset([]string{config.ModeLight})
|
||||
if !presetAdmitsCallerWorkspaceTools(unmarked, routeDispatch{Preset: unmarked}) {
|
||||
|
|
|
|||
|
|
@ -643,7 +643,10 @@ func scriptedArtifactPair(endpoint, requestID string) string {
|
|||
Goal: "Complete the caller workspace task", Steps: []string{"Inspect the requested result", "Implement and verify the result"},
|
||||
Verification: []string{"Confirm the caller workspace result"},
|
||||
}, singlerequesttemplate.MaxTemplateBytes)
|
||||
review, _ := pendingReviewSeed([]string{"P1", "P2"})
|
||||
review, _ := singlerequesttemplate.RenderReview(singlerequesttemplate.DefaultReviewTemplate, singlerequesttemplate.ReviewFields{
|
||||
ItemStatus: "- P1: pending\n- P2: pending", Changes: "Pending worker execution.",
|
||||
Verification: "Pending worker verification.", Deviations: "None recorded.",
|
||||
}, singlerequesttemplate.MaxTemplateBytes)
|
||||
if endpoint == "anthropic" {
|
||||
return fmt.Sprintf(`{"id":"msg-scripted-pair","type":"message","role":"assistant","content":[{"type":"tool_use","id":"provider-plan","name":"write_file","input":{"path":%q,"content":%q}},{"type":"tool_use","id":"provider-review","name":"write_file","input":{"path":%q,"content":%q}}],"stop_reason":"tool_use"}`, paths.PlanPath, string(plan), paths.ReviewPath, string(review))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import (
|
|||
|
||||
edgeservice "iop/apps/edge/internal/service"
|
||||
"iop/packages/go/config"
|
||||
"iop/packages/go/singlerequesttemplate"
|
||||
)
|
||||
|
||||
const defaultHotPathLightCapacity = 1024
|
||||
|
|
@ -105,7 +104,6 @@ type hotPathLightRecord struct {
|
|||
|
||||
phase hotPathLightPhase
|
||||
artifactReady bool
|
||||
planItemIDs []string
|
||||
localPlanRead bool
|
||||
workerReviewWritten bool
|
||||
reviewerPlanRead bool
|
||||
|
|
@ -260,7 +258,7 @@ func (s *hotPathLightStore) has(requestID, ownerEdgeID string) bool {
|
|||
return record != nil && record.ownerEdgeID == ownerEdgeID
|
||||
}
|
||||
|
||||
func (s *hotPathLightStore) updateArtifactLineage(requestID, ownerEdgeID string, lineage logicalRequestLineage, localEligible bool, planItemIDs []string) error {
|
||||
func (s *hotPathLightStore) updateArtifactLineage(requestID, ownerEdgeID string, lineage logicalRequestLineage, localEligible bool) error {
|
||||
if s == nil {
|
||||
return fmt.Errorf("light flow is unavailable")
|
||||
}
|
||||
|
|
@ -272,11 +270,7 @@ func (s *hotPathLightStore) updateArtifactLineage(requestID, ownerEdgeID string,
|
|||
}
|
||||
record.lineage = lineage
|
||||
if localEligible {
|
||||
if len(planItemIDs) == 0 {
|
||||
return fmt.Errorf("light flow plan item inventory is unavailable")
|
||||
}
|
||||
record.artifactReady = true
|
||||
record.planItemIDs = append([]string(nil), planItemIDs...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -876,9 +870,6 @@ func (s *hotPathLightStore) commitLocal(requestID, ownerEdgeID string, output no
|
|||
if record == nil || record.ownerEdgeID != ownerEdgeID || record.phase != hotPathPhaseLocalActive || !record.running || len(output.ToolCalls) != 0 {
|
||||
return hotPathLightDisposition{}, fmt.Errorf("local completion cannot transition to review")
|
||||
}
|
||||
if !record.localPlanRead || !record.workerReviewWritten {
|
||||
return hotPathLightDisposition{}, fmt.Errorf("worker completion requires a successful plan read and completed review handoff")
|
||||
}
|
||||
reviewStageID, err := coordinator.newStageID()
|
||||
if err != nil {
|
||||
return hotPathLightDisposition{}, err
|
||||
|
|
@ -907,9 +898,6 @@ func (s *hotPathLightStore) classifyLocalOutput(requestID, ownerEdgeID string, o
|
|||
return "", fmt.Errorf("local flow state is unavailable")
|
||||
}
|
||||
if len(output.ToolCalls) == 0 {
|
||||
if !record.localPlanRead || !record.workerReviewWritten {
|
||||
return "", fmt.Errorf("worker completion requires a successful plan read and completed review handoff")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
paths := newReservedPaths(requestID)
|
||||
|
|
@ -922,15 +910,10 @@ func (s *hotPathLightStore) classifyLocalOutput(requestID, ownerEdgeID string, o
|
|||
if len(reviewWrites) == 0 {
|
||||
return hotPathPendingLocalTools, nil
|
||||
}
|
||||
if len(reviewWrites) != 1 || !record.localPlanRead {
|
||||
return "", fmt.Errorf("worker review handoff must contain one exact review write after reading the plan")
|
||||
}
|
||||
content, err := artifactWriteContent(record.binding, reviewWrites[0])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := singlerequesttemplate.ValidateReviewHandoff([]byte(content), record.planItemIDs); err != nil {
|
||||
return "", fmt.Errorf("worker review handoff is malformed: %w", err)
|
||||
for _, call := range reviewWrites {
|
||||
if _, err := artifactWriteContent(record.binding, call); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return hotPathPendingLocalHandoff, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,39 +118,6 @@ func TestInitialReviewRequiresToolUseBeforeTerminal(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestHotPathLightRequiresPlanReadBeforeWorkerCompletion(t *testing.T) {
|
||||
for _, endpoint := range []string{"openai", "anthropic"} {
|
||||
endpoint := endpoint
|
||||
t.Run(endpoint, func(t *testing.T) {
|
||||
fixture := newScriptedLightFixture(t, endpoint, false)
|
||||
fixture.service.responses[2] = func(string) string {
|
||||
return scriptedLightCompletion(endpoint, "worker tried to finish early")
|
||||
}
|
||||
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")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHotPathLightRequiresWorkerAuthoredReviewAfterPlanRead(t *testing.T) {
|
||||
for _, endpoint := range []string{"openai", "anthropic"} {
|
||||
endpoint := endpoint
|
||||
t.Run(endpoint, func(t *testing.T) {
|
||||
fixture := newScriptedLightFixture(t, endpoint, false)
|
||||
fixture.service.responses[4] = func(string) string {
|
||||
return scriptedLightCompletion(endpoint, "terminal prose cannot replace review evidence")
|
||||
}
|
||||
driveScriptedLightToLocalAfterPlanRead(t, fixture)
|
||||
localWork := fixture.request()
|
||||
fixture.consumeToolResponse(localWork, []string{`{"ok":true}`})
|
||||
assertLightFailureAfterCleanup(t, fixture, "completed review handoff")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertLightFailureAfterCleanup(t *testing.T, fixture *scriptedLightFixture, message string) {
|
||||
t.Helper()
|
||||
cleanup := fixture.request()
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ func (s *Server) joinPresetChatIngress(r *http.Request, dispatch routeDispatch,
|
|||
return presetIngressResult{}, fmt.Errorf("artifact continuation rejected: %w", err)
|
||||
}
|
||||
if disposition.PrimaryError != nil {
|
||||
if err := s.lightFlows.updateArtifactLineage(snap.ID, ownerEdgeID, contLineage.Committed, false, nil); err != nil {
|
||||
if err := s.lightFlows.updateArtifactLineage(snap.ID, ownerEdgeID, contLineage.Committed, false); err != nil {
|
||||
return presetIngressResult{}, err
|
||||
}
|
||||
cleanup, err := s.lightFlows.beginPrimaryErrorCleanup(requestContext, snap.ID, ownerEdgeID, *disposition.PrimaryError, nil, s.requestCoordinator)
|
||||
|
|
@ -85,7 +85,7 @@ func (s *Server) joinPresetChatIngress(r *http.Request, dispatch routeDispatch,
|
|||
if err := s.applyArtifactDisposition(snap, disposition, runMeta); err != nil {
|
||||
return presetIngressResult{}, err
|
||||
}
|
||||
if err := s.lightFlows.updateArtifactLineage(snap.ID, ownerEdgeID, contLineage.Committed, disposition.Kind == artifactDispositionLocalEligible, disposition.PlanItemIDs); err != nil {
|
||||
if err := s.lightFlows.updateArtifactLineage(snap.ID, ownerEdgeID, contLineage.Committed, disposition.Kind == artifactDispositionLocalEligible); err != nil {
|
||||
return presetIngressResult{}, err
|
||||
}
|
||||
return presetIngressResult{Artifact: disposition}, nil
|
||||
|
|
@ -219,7 +219,7 @@ func (s *Server) joinPresetAnthropicIngress(r *http.Request, dispatch routeDispa
|
|||
return presetIngressResult{}, fmt.Errorf("artifact continuation rejected: %w", err)
|
||||
}
|
||||
if disposition.PrimaryError != nil {
|
||||
if err := s.lightFlows.updateArtifactLineage(snap.ID, ownerEdgeID, contLineage.Committed, false, nil); err != nil {
|
||||
if err := s.lightFlows.updateArtifactLineage(snap.ID, ownerEdgeID, contLineage.Committed, false); err != nil {
|
||||
return presetIngressResult{}, err
|
||||
}
|
||||
cleanup, err := s.lightFlows.beginPrimaryErrorCleanup(requestContext, snap.ID, ownerEdgeID, *disposition.PrimaryError, nil, s.requestCoordinator)
|
||||
|
|
@ -236,7 +236,7 @@ func (s *Server) joinPresetAnthropicIngress(r *http.Request, dispatch routeDispa
|
|||
if err := s.applyArtifactDisposition(snap, disposition, metadata); err != nil {
|
||||
return presetIngressResult{}, err
|
||||
}
|
||||
if err := s.lightFlows.updateArtifactLineage(snap.ID, ownerEdgeID, contLineage.Committed, disposition.Kind == artifactDispositionLocalEligible, disposition.PlanItemIDs); err != nil {
|
||||
if err := s.lightFlows.updateArtifactLineage(snap.ID, ownerEdgeID, contLineage.Committed, disposition.Kind == artifactDispositionLocalEligible); err != nil {
|
||||
return presetIngressResult{}, err
|
||||
}
|
||||
return presetIngressResult{Artifact: disposition}, nil
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import (
|
|||
"strings"
|
||||
|
||||
edgeservice "iop/apps/edge/internal/service"
|
||||
"iop/packages/go/singlerequesttemplate"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -94,16 +93,6 @@ func (s *singleRequestReviewStage) run(ctx context.Context, req singleRequestRev
|
|||
if len(plan) > req.Limits.MaxOutputBytes {
|
||||
return nil, quality.length(errSingleRequestReviewStage)
|
||||
}
|
||||
// Enforce the frozen effective Plan template before extracting IDs. A
|
||||
// stored PLAN that fails the exact parser cannot reach provider dispatch,
|
||||
// closing the grammar bypass identified in the handoff.
|
||||
if _, err := singlerequesttemplate.ParsePlan(binding.Templates.Plan, string(plan), req.Limits.MaxOutputBytes); err != nil {
|
||||
return nil, quality.malformed(errSingleRequestReviewStage)
|
||||
}
|
||||
planIDs, err := singlerequesttemplate.PlanItemIDs(plan)
|
||||
if err != nil {
|
||||
return nil, quality.malformed(errSingleRequestReviewStage)
|
||||
}
|
||||
handoff, err := ctrl.ReadInternalArtifact(ctx, edgeservice.SingleRequestArtifactReview)
|
||||
if err != nil {
|
||||
return nil, quality.serviceFailure(ctx, err, errSingleRequestReviewStage)
|
||||
|
|
@ -114,9 +103,6 @@ func (s *singleRequestReviewStage) run(ctx context.Context, req singleRequestRev
|
|||
if len(handoff) > req.Limits.MaxOutputBytes {
|
||||
return nil, quality.length(errSingleRequestReviewStage)
|
||||
}
|
||||
if err := singlerequesttemplate.ValidateReviewHandoff(handoff, planIDs); err != nil {
|
||||
return nil, quality.malformed(errSingleRequestReviewStage)
|
||||
}
|
||||
tools, err := singleRequestWorkTools(binding.Workspace)
|
||||
if err != nil {
|
||||
return nil, quality.validation(errSingleRequestReviewStage)
|
||||
|
|
|
|||
|
|
@ -331,54 +331,19 @@ func TestSingleRequestReviewStagePassPersistsBeforeFinalizing(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSingleRequestReviewStageRejectsMismatchedStoredPlan(t *testing.T) {
|
||||
t.Run("plan with altered heading fails before provider dispatch", func(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
bridge := newSingleRequestWorkToolBridge()
|
||||
ctrl := newReviewController(t, bridge)
|
||||
ctrl.plan = []byte("# Plan Mismatch\n\n## Goal\nUpdate result.\n\n## Steps\n- [P1] Write result.txt.\n- [P2] Verify result.\n\n## Verification\n- Run verify.\n")
|
||||
var bodies [][]byte
|
||||
_, err := newSingleRequestReviewStage(scriptedReviewProvider(t, ctrl, [][]byte{reviewPassBody("Approved.", "Summary.")}, &bodies), bridge).run(context.Background(), reviewRequest(t), ctrl)
|
||||
if !errors.Is(err, errSingleRequestReviewStage) {
|
||||
t.Fatalf("err=%v, want errSingleRequestReviewStage", err)
|
||||
}
|
||||
if calls.Load() != 0 {
|
||||
t.Fatalf("provider dispatches=%d, want 0", calls.Load())
|
||||
}
|
||||
if len(ctrl.writes) != 0 {
|
||||
t.Fatalf("review artifact writes=%d, want 0", len(ctrl.writes))
|
||||
}
|
||||
})
|
||||
func TestSingleRequestReviewStageReceivesPartialHandoff(t *testing.T) {
|
||||
bridge := newSingleRequestWorkToolBridge()
|
||||
ctrl := newReviewController(t, bridge)
|
||||
ctrl.review = []byte("Worker stopped before filling every review section.")
|
||||
var bodies [][]byte
|
||||
stage := newSingleRequestReviewStage(scriptedReviewProvider(t, ctrl, [][]byte{reviewPassBody("Repaired output.", "Reviewer completed the check.")}, &bodies), bridge)
|
||||
|
||||
t.Run("plan with unknown placeholder fails before provider dispatch", func(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
bridge := newSingleRequestWorkToolBridge()
|
||||
ctrl := newReviewController(t, bridge)
|
||||
ctrl.plan = []byte("# Plan\n\n## Goal\nUpdate result.\n\n## Steps\n- [P1] Write result.txt.\n- [P2] Verify result.\n\n## Verification\n- Run verify.\n{{unknown}}\n")
|
||||
var bodies [][]byte
|
||||
_, err := newSingleRequestReviewStage(scriptedReviewProvider(t, ctrl, [][]byte{reviewPassBody("Approved.", "Summary.")}, &bodies), bridge).run(context.Background(), reviewRequest(t), ctrl)
|
||||
if !errors.Is(err, errSingleRequestReviewStage) {
|
||||
t.Fatalf("err=%v, want errSingleRequestReviewStage", err)
|
||||
}
|
||||
if calls.Load() != 0 {
|
||||
t.Fatalf("provider dispatches=%d, want 0", calls.Load())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed review handoff with prose in item status fails before provider dispatch", func(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
bridge := newSingleRequestWorkToolBridge()
|
||||
ctrl := newReviewController(t, bridge)
|
||||
ctrl.review = []byte("# Review\n\n## Worker Item Status\n- P1: completed\nThis is a note.\n- P2: completed\n\n## Worker Changes\nUpdated result.txt.\n\n## Worker Verification\nverify passed\n\n## Deviations\nNone\n")
|
||||
var bodies [][]byte
|
||||
_, err := newSingleRequestReviewStage(scriptedReviewProvider(t, ctrl, [][]byte{reviewPassBody("Approved.", "Summary.")}, &bodies), bridge).run(context.Background(), reviewRequest(t), ctrl)
|
||||
if !errors.Is(err, errSingleRequestReviewStage) {
|
||||
t.Fatalf("err=%v, want errSingleRequestReviewStage", err)
|
||||
}
|
||||
if calls.Load() != 0 {
|
||||
t.Fatalf("provider dispatches=%d, want 0", calls.Load())
|
||||
}
|
||||
})
|
||||
if _, err := stage.run(context.Background(), reviewRequest(t), ctrl); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(bodies) != 1 || !strings.Contains(string(bodies[0]), string(ctrl.review)) {
|
||||
t.Fatalf("partial handoff did not reach reviewer: %q", bodies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleRequestReviewStageInspectionAndRepairRemainInLegalStates(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -218,16 +218,6 @@ func (s *singleRequestWorkStage) run(ctx context.Context, req singleRequestWorkS
|
|||
if len(plan) > req.Limits.MaxOutputBytes {
|
||||
return quality.length(errSingleRequestWorkStage)
|
||||
}
|
||||
// Enforce the frozen effective Plan template before extracting IDs. A
|
||||
// stored PLAN that fails the exact parser cannot reach provider dispatch
|
||||
// or the REVIEW handoff write, closing the grammar bypass.
|
||||
if _, err := singlerequesttemplate.ParsePlan(binding.Templates.Plan, string(plan), req.Limits.MaxOutputBytes); err != nil {
|
||||
return quality.malformed(errSingleRequestWorkStage)
|
||||
}
|
||||
planIDs, err := singlerequesttemplate.PlanItemIDs(plan)
|
||||
if err != nil {
|
||||
return quality.malformed(errSingleRequestWorkStage)
|
||||
}
|
||||
tools, err := singleRequestWorkTools(binding.Workspace)
|
||||
if err != nil {
|
||||
return quality.validation(errSingleRequestWorkStage)
|
||||
|
|
@ -250,7 +240,7 @@ func (s *singleRequestWorkStage) run(ctx context.Context, req singleRequestWorkS
|
|||
if !completionEligible {
|
||||
return quality.malformed(errSingleRequestWorkStage)
|
||||
}
|
||||
return s.finalizeReviewHandoff(ctx, req, ctrl, binding.Templates.Review, response.completion, planIDs, quality)
|
||||
return s.finalizeReviewHandoff(ctx, req, ctrl, binding.Templates.Review, response.completion, quality)
|
||||
}
|
||||
call := response.call
|
||||
if call == nil {
|
||||
|
|
@ -619,7 +609,7 @@ func decodeSingleRequestWorkResult(raw string, maximum int) (*singleRequestWorkC
|
|||
var result singleRequestWorkCompletion
|
||||
decoder := json.NewDecoder(strings.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&result); err != nil || strings.TrimSpace(result.ItemStatus) == "" || strings.TrimSpace(result.Changes) == "" || strings.TrimSpace(result.Verification) == "" || strings.TrimSpace(result.Deviations) == "" {
|
||||
if err := decoder.Decode(&result); err != nil {
|
||||
return nil, errSingleRequestWorkStage
|
||||
}
|
||||
var extra any
|
||||
|
|
@ -634,11 +624,11 @@ func decodeSingleRequestWorkResult(raw string, maximum int) (*singleRequestWorkC
|
|||
}, nil
|
||||
}
|
||||
|
||||
// finalizeReviewHandoff makes the persisted worker report the only handoff to
|
||||
// Review. Rendering and then parsing it closes both the configured template
|
||||
// grammar and the rendered PLAN-to-item-status correspondence before a write.
|
||||
func (s *singleRequestWorkStage) finalizeReviewHandoff(ctx context.Context, req singleRequestWorkStageRequest, ctrl edgeservice.SingleRequestController, tmpl string, completion *singleRequestWorkCompletion, planIDs []string, quality *singleRequestQualityGate) error {
|
||||
if completion == nil || len(planIDs) == 0 {
|
||||
// finalizeReviewHandoff persists the worker report for the Reviewer. Semantic
|
||||
// completeness is intentionally left to the Reviewer so a partial handoff can
|
||||
// be inspected and repaired instead of terminating the pipeline early.
|
||||
func (s *singleRequestWorkStage) finalizeReviewHandoff(ctx context.Context, req singleRequestWorkStageRequest, ctrl edgeservice.SingleRequestController, tmpl string, completion *singleRequestWorkCompletion, quality *singleRequestQualityGate) error {
|
||||
if completion == nil {
|
||||
return quality.malformed(errSingleRequestWorkStage)
|
||||
}
|
||||
handoff, err := singlerequesttemplate.RenderReview(tmpl, singlerequesttemplate.ReviewFields{
|
||||
|
|
@ -647,7 +637,7 @@ func (s *singleRequestWorkStage) finalizeReviewHandoff(ctx context.Context, req
|
|||
Verification: completion.Verification,
|
||||
Deviations: completion.Deviations,
|
||||
}, req.Limits.MaxOutputBytes)
|
||||
if err != nil || singlerequesttemplate.ValidateReviewHandoff(handoff, planIDs) != nil {
|
||||
if err != nil {
|
||||
return quality.malformed(errSingleRequestWorkStage)
|
||||
}
|
||||
if err := ctrl.WriteInternalArtifact(ctx, edgeservice.SingleRequestArtifactReview, handoff); err != nil {
|
||||
|
|
|
|||
|
|
@ -791,56 +791,6 @@ func runStandaloneWorkStageForTest(t *testing.T, ctx context.Context, provider e
|
|||
return bridge, err
|
||||
}
|
||||
|
||||
func TestSingleRequestWorkStageRejectsMismatchedStoredPlan(t *testing.T) {
|
||||
t.Run("plan with altered heading fails before provider dispatch", func(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
provider := &mockService{submit: func(context.Context, edgeservice.ProviderPoolDispatchRequest) (*edgeservice.ProviderPoolDispatchResult, error) {
|
||||
calls.Add(1)
|
||||
return nil, errors.New("should not dispatch")
|
||||
}}
|
||||
bridge := newSingleRequestWorkToolBridge()
|
||||
ctrl := &workController{
|
||||
binding: workBinding(t),
|
||||
plan: []byte("# Plan Mismatch\n\n## Goal\nUpdate.\n\n## Steps\n- [P1] Write.\n- [P2] Verify.\n\n## Verification\n- Verify.\n"),
|
||||
bridge: bridge,
|
||||
}
|
||||
err := newSingleRequestWorkStage(newSingleRequestProviderStage(provider), bridge).run(context.Background(), workRequest(), ctrl)
|
||||
if !errors.Is(err, errSingleRequestWorkStage) {
|
||||
t.Fatalf("err=%v, want errSingleRequestWorkStage", err)
|
||||
}
|
||||
if calls.Load() != 0 {
|
||||
t.Fatalf("provider dispatches=%d, want 0", calls.Load())
|
||||
}
|
||||
if len(ctrl.review) != 0 {
|
||||
t.Fatalf("review artifact written=%q, want empty", ctrl.review)
|
||||
}
|
||||
if bridge.pendingCount() != 0 {
|
||||
t.Fatalf("pending=%d, want 0", bridge.pendingCount())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("plan with unknown placeholder fails before provider dispatch", func(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
provider := &mockService{submit: func(context.Context, edgeservice.ProviderPoolDispatchRequest) (*edgeservice.ProviderPoolDispatchResult, error) {
|
||||
calls.Add(1)
|
||||
return nil, errors.New("should not dispatch")
|
||||
}}
|
||||
bridge := newSingleRequestWorkToolBridge()
|
||||
ctrl := &workController{
|
||||
binding: workBinding(t),
|
||||
plan: []byte("# Plan\n\n## Goal\nUpdate.\n\n## Steps\n- [P1] Write.\n- [P2] Verify.\n\n## Verification\n- Verify.\n{{unknown}}\n"),
|
||||
bridge: bridge,
|
||||
}
|
||||
err := newSingleRequestWorkStage(newSingleRequestProviderStage(provider), bridge).run(context.Background(), workRequest(), ctrl)
|
||||
if !errors.Is(err, errSingleRequestWorkStage) {
|
||||
t.Fatalf("err=%v, want errSingleRequestWorkStage", err)
|
||||
}
|
||||
if calls.Load() != 0 {
|
||||
t.Fatalf("provider dispatches=%d, want 0", calls.Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSingleRequestWorkStageDrivesOrderedToolLoop(t *testing.T) {
|
||||
responses := [][]byte{
|
||||
workToolBody("write-1", edgeservice.InternalWorkspaceToolWrite, `{"relative_path":"result.txt","content":"done"}`),
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import (
|
|||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const MaxTemplateBytes = 8192
|
||||
|
|
@ -246,126 +245,6 @@ func rejectUnknownMarkdownHeadings(tmpl string, allowed []string) error {
|
|||
}
|
||||
}
|
||||
|
||||
func ParsePlan(tmpl string, rawOutput string, maxOutputBytes int) ([]byte, error) {
|
||||
if maxOutputBytes < 1 || len(rawOutput) > maxOutputBytes {
|
||||
return nil, ErrMalformedPlan
|
||||
}
|
||||
if err := ValidatePlanTemplate(tmpl); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if strings.Contains(rawOutput, "{{") || strings.Contains(rawOutput, "}}") {
|
||||
return nil, ErrMalformedPlan
|
||||
}
|
||||
|
||||
idxGoalPlaceholder := strings.Index(tmpl, "{{goal}}")
|
||||
idxStepsPlaceholder := strings.Index(tmpl, "{{steps}}")
|
||||
idxVerifPlaceholder := strings.Index(tmpl, "{{verification}}")
|
||||
|
||||
f0 := tmpl[:idxGoalPlaceholder]
|
||||
f1 := tmpl[idxGoalPlaceholder+len("{{goal}}") : idxStepsPlaceholder]
|
||||
f2 := tmpl[idxStepsPlaceholder+len("{{steps}}") : idxVerifPlaceholder]
|
||||
f3 := tmpl[idxVerifPlaceholder+len("{{verification}}"):]
|
||||
|
||||
if !strings.HasPrefix(rawOutput, f0) {
|
||||
return nil, ErrMalformedPlan
|
||||
}
|
||||
rem := rawOutput[len(f0):]
|
||||
|
||||
i1 := strings.Index(rem, f1)
|
||||
if i1 < 0 {
|
||||
return nil, ErrMalformedPlan
|
||||
}
|
||||
vGoal := rem[:i1]
|
||||
rem = rem[i1+len(f1):]
|
||||
|
||||
i2 := strings.Index(rem, f2)
|
||||
if i2 < 0 {
|
||||
return nil, ErrMalformedPlan
|
||||
}
|
||||
vSteps := rem[:i2]
|
||||
rem = rem[i2+len(f2):]
|
||||
|
||||
var vVerif string
|
||||
if f3 == "" {
|
||||
vVerif = rem
|
||||
} else {
|
||||
suffix := f3
|
||||
if !strings.HasSuffix(rem, suffix) {
|
||||
// Provider chat APIs commonly omit the model's final line feed. Treat
|
||||
// only that last byte as optional; all other static suffix text must
|
||||
// still match the configured template exactly.
|
||||
if !strings.HasSuffix(f3, "\n") {
|
||||
return nil, ErrMalformedPlan
|
||||
}
|
||||
suffix = strings.TrimSuffix(f3, "\n")
|
||||
if !strings.HasSuffix(rem, suffix) {
|
||||
return nil, ErrMalformedPlan
|
||||
}
|
||||
}
|
||||
vVerif = rem[:len(rem)-len(suffix)]
|
||||
}
|
||||
|
||||
if _, _, _, err := normalizePlanSections(vGoal, vSteps, vVerif); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []byte(rawOutput), nil
|
||||
}
|
||||
|
||||
func normalizePlanSections(goal, steps, verification string) (string, string, string, error) {
|
||||
goal = strings.TrimSpace(goal)
|
||||
steps = strings.TrimSpace(steps)
|
||||
verification = strings.TrimSpace(verification)
|
||||
if goal == "" || strings.ContainsAny(goal, "\r\n") || steps == "" || verification == "" {
|
||||
return "", "", "", ErrMalformedPlan
|
||||
}
|
||||
if strings.Contains(goal, "{{") || strings.Contains(goal, "}}") || strings.Contains(steps, "{{") || strings.Contains(steps, "}}") || strings.Contains(verification, "{{") || strings.Contains(verification, "}}") {
|
||||
return "", "", "", ErrMalformedPlan
|
||||
}
|
||||
|
||||
normalizeStepBullets := func(value string, minimum, maximum int) (string, error) {
|
||||
lines := strings.Split(value, "\n")
|
||||
if len(lines) < minimum || len(lines) > maximum {
|
||||
return "", ErrMalformedPlan
|
||||
}
|
||||
for i, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
prefix := fmt.Sprintf("- [P%d] ", i+1)
|
||||
if !strings.HasPrefix(line, prefix) || strings.TrimSpace(line[len(prefix):]) == "" {
|
||||
return "", ErrMalformedPlan
|
||||
}
|
||||
lines[i] = line
|
||||
}
|
||||
return strings.Join(lines, "\n"), nil
|
||||
}
|
||||
|
||||
normalizeVerificationBullets := func(value string, minimum, maximum int) (string, error) {
|
||||
lines := strings.Split(value, "\n")
|
||||
if len(lines) < minimum || len(lines) > maximum {
|
||||
return "", ErrMalformedPlan
|
||||
}
|
||||
for i, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "- ") || strings.TrimSpace(line[2:]) == "" {
|
||||
return "", ErrMalformedPlan
|
||||
}
|
||||
lines[i] = line
|
||||
}
|
||||
return strings.Join(lines, "\n"), nil
|
||||
}
|
||||
|
||||
steps, err := normalizeStepBullets(steps, 2, 6)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
verification, err = normalizeVerificationBullets(verification, 1, 3)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
return goal, steps, verification, nil
|
||||
}
|
||||
|
||||
func normalizePlanFields(fields PlanFields) (string, string, string, error) {
|
||||
goal := strings.TrimSpace(fields.Goal)
|
||||
if goal == "" || strings.ContainsAny(goal, "\r\n") || strings.Contains(goal, "{{") || strings.Contains(goal, "}}") {
|
||||
|
|
@ -443,10 +322,6 @@ func RenderReview(tmpl string, fields ReviewFields, maxOutputBytes int) ([]byte,
|
|||
changes := strings.TrimSpace(fields.Changes)
|
||||
verification := strings.TrimSpace(fields.Verification)
|
||||
deviations := strings.TrimSpace(fields.Deviations)
|
||||
if itemStatus == "" || changes == "" || verification == "" || deviations == "" {
|
||||
return nil, ErrMalformedReview
|
||||
}
|
||||
|
||||
res := strings.ReplaceAll(tmpl, "{{item_status}}", itemStatus)
|
||||
res = strings.ReplaceAll(res, "{{changes}}", changes)
|
||||
res = strings.ReplaceAll(res, "{{verification}}", verification)
|
||||
|
|
@ -462,125 +337,3 @@ func RenderReview(tmpl string, fields ReviewFields, maxOutputBytes int) ([]byte,
|
|||
|
||||
return []byte(res), nil
|
||||
}
|
||||
|
||||
var planStepIDRegex = regexp.MustCompile(`(?m)^- \[P(\d+)\]`)
|
||||
|
||||
// PlanItemIDs extracts the deterministic P1..Pn step IDs from a rendered Plan
|
||||
// document. IDs must start at P1 and increment with no gaps, duplicates, or
|
||||
// out-of-order entries.
|
||||
func PlanItemIDs(plan []byte) ([]string, error) {
|
||||
if len(plan) == 0 || !utf8.Valid(plan) {
|
||||
return nil, ErrMalformedPlan
|
||||
}
|
||||
text := string(plan)
|
||||
for _, heading := range planHeadings {
|
||||
if _, count := exactLineOffsets(text, heading); count != 1 {
|
||||
return nil, ErrMalformedPlan
|
||||
}
|
||||
}
|
||||
goalStart := strings.Index(text, "## Goal") + len("## Goal")
|
||||
stepsHeading := strings.Index(text, "## Steps")
|
||||
stepsStart := stepsHeading + len("## Steps")
|
||||
verificationHeading := strings.Index(text, "## Verification")
|
||||
if goalStart < len("## Goal") || stepsHeading < 0 || verificationHeading < 0 || goalStart >= stepsHeading || stepsStart >= verificationHeading || strings.TrimSpace(text[goalStart:stepsHeading]) == "" || strings.TrimSpace(text[verificationHeading+len("## Verification"):]) == "" {
|
||||
return nil, ErrMalformedPlan
|
||||
}
|
||||
steps := strings.TrimSpace(text[stepsStart:verificationHeading])
|
||||
lines := strings.Split(steps, "\n")
|
||||
if len(lines) < 2 || len(lines) > 6 {
|
||||
return nil, ErrMalformedPlan
|
||||
}
|
||||
matches := planStepIDRegex.FindAllStringSubmatch(steps, -1)
|
||||
if len(matches) != len(lines) {
|
||||
return nil, ErrMalformedPlan
|
||||
}
|
||||
ids := make([]string, 0, len(matches))
|
||||
for i, match := range matches {
|
||||
expected := fmt.Sprintf("P%d", i+1)
|
||||
actual := "P" + match[1]
|
||||
line := strings.TrimSpace(lines[i])
|
||||
prefix := fmt.Sprintf("- [%s] ", expected)
|
||||
if actual != expected || !strings.HasPrefix(line, prefix) || strings.TrimSpace(line[len(prefix):]) == "" {
|
||||
return nil, ErrMalformedPlan
|
||||
}
|
||||
ids = append(ids, expected)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
var reviewItemLineRegex = regexp.MustCompile(`(?m)^- (P\d+): (.+)$`)
|
||||
|
||||
// ValidateReviewHandoff validates a rendered REVIEW handoff document against
|
||||
// the supplied plan IDs. Every plan ID must appear exactly once in the Worker
|
||||
// Item Status section with status "completed", and no unknown or duplicate
|
||||
// IDs are permitted. All four required sections must be present with
|
||||
// non-empty content. If the PLAN has no deviations, the Deviations section
|
||||
// must still contain an explicit entry (conventionally "None").
|
||||
//
|
||||
// The Worker Item Status section is validated by exact line inventory rather
|
||||
// than regex filtering: the entire section (excluding the heading) is split on
|
||||
// newlines, every resulting line must be non-empty, and each line must match
|
||||
// its corresponding plan ID in the form "- Pn: completed". This rejects prose
|
||||
// injected between status lines, blank lines, malformed bullets, and any
|
||||
// out-of-order or duplicate entries in a single pass.
|
||||
func ValidateReviewHandoff(content []byte, planIDs []string) error {
|
||||
if len(content) == 0 || !utf8.Valid(content) || len(planIDs) == 0 {
|
||||
return ErrMalformedReview
|
||||
}
|
||||
text := string(content)
|
||||
|
||||
requiredSections := []string{"# Review", "## Worker Item Status", "## Worker Changes", "## Worker Verification", "## Deviations"}
|
||||
for _, section := range requiredSections {
|
||||
if _, count := exactLineOffsets(text, section); count != 1 {
|
||||
return ErrMalformedReview
|
||||
}
|
||||
}
|
||||
if err := rejectUnknownMarkdownHeadings(text, reviewHeadings); err != nil {
|
||||
return ErrMalformedReview
|
||||
}
|
||||
sectionContent := func(heading, next string) string {
|
||||
start := strings.Index(text, heading) + len(heading)
|
||||
end := len(text)
|
||||
if next != "" {
|
||||
if index := strings.Index(text[start:], next); index >= 0 {
|
||||
end = start + index
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(text[start:end])
|
||||
}
|
||||
if sectionContent("## Worker Item Status", "\n## Worker Changes") == "" ||
|
||||
sectionContent("## Worker Changes", "\n## Worker Verification") == "" ||
|
||||
sectionContent("## Worker Verification", "\n## Deviations") == "" ||
|
||||
sectionContent("## Deviations", "") == "" {
|
||||
return ErrMalformedReview
|
||||
}
|
||||
|
||||
statusHeading := "## Worker Item Status"
|
||||
statusIdx := strings.Index(text, statusHeading)
|
||||
if statusIdx < 0 {
|
||||
return ErrMalformedReview
|
||||
}
|
||||
statusSection := sectionContent(statusHeading, "\n## Worker Changes")
|
||||
|
||||
// Exact line inventory: every line in the status section must correspond
|
||||
// to one plan ID in order, with the grammar "- Pn: completed". Blank lines,
|
||||
// prose, malformed bullets, and out-of-order or duplicate entries are all
|
||||
// rejected because the line count and each line's content are compared
|
||||
// directly against the plan ID inventory.
|
||||
lines := strings.Split(statusSection, "\n")
|
||||
if len(lines) != len(planIDs) {
|
||||
return ErrMalformedReview
|
||||
}
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
return ErrMalformedReview
|
||||
}
|
||||
expected := fmt.Sprintf("- %s: completed", planIDs[i])
|
||||
if trimmed != expected {
|
||||
return ErrMalformedReview
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -255,6 +255,7 @@ Documented as ## Goal below.
|
|||
}
|
||||
}
|
||||
|
||||
/* Removed: runtime output parsing duplicated the Planner and Reviewer contracts.
|
||||
func TestParsePlan(t *testing.T) {
|
||||
validOutput := `# Plan
|
||||
|
||||
|
|
@ -580,6 +581,7 @@ Fix {{goal}} bug.
|
|||
})
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
func TestRenderPlan(t *testing.T) {
|
||||
fields := singlerequesttemplate.PlanFields{
|
||||
|
|
@ -636,6 +638,7 @@ func TestRenderPlanRejectsMalformedFields(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
/* Removed: Plan item inventory is Reviewer-owned semantic analysis.
|
||||
func TestPlanItemIDs(t *testing.T) {
|
||||
rendered, err := singlerequesttemplate.RenderPlan(singlerequesttemplate.DefaultPlanTemplate, singlerequesttemplate.PlanFields{
|
||||
Goal: "Goal line.",
|
||||
|
|
@ -672,6 +675,7 @@ func TestPlanItemIDs(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
func TestValidateReviewTemplate(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
|
@ -1032,17 +1036,17 @@ Operator footer.
|
|||
t.Errorf("custom RenderReview() got:\n%s\nwant:\n%s", string(got), wantCustom)
|
||||
}
|
||||
|
||||
// Missing field test: every worker section must be non-empty.
|
||||
// Partial worker reports remain renderable so the Reviewer can repair them.
|
||||
for name, mutated := range map[string]singlerequesttemplate.ReviewFields{
|
||||
"empty-item-status": {ItemStatus: "", Changes: fields.Changes, Verification: fields.Verification, Deviations: fields.Deviations},
|
||||
"empty-changes": {ItemStatus: fields.ItemStatus, Changes: "", Verification: fields.Verification, Deviations: fields.Deviations},
|
||||
"empty-verification": {ItemStatus: fields.ItemStatus, Changes: fields.Changes, Verification: "", Deviations: fields.Deviations},
|
||||
"empty-deviations": {ItemStatus: fields.ItemStatus, Changes: fields.Changes, Verification: fields.Verification, Deviations: ""},
|
||||
"empty-item-status": {ItemStatus: "", Changes: fields.Changes, Verification: fields.Verification, Deviations: fields.Deviations},
|
||||
"empty-changes": {ItemStatus: fields.ItemStatus, Changes: "", Verification: fields.Verification, Deviations: fields.Deviations},
|
||||
"empty-verification": {ItemStatus: fields.ItemStatus, Changes: fields.Changes, Verification: "", Deviations: fields.Deviations},
|
||||
"empty-deviations": {ItemStatus: fields.ItemStatus, Changes: fields.Changes, Verification: fields.Verification, Deviations: ""},
|
||||
"whitespace-deviations": {ItemStatus: fields.ItemStatus, Changes: fields.Changes, Verification: fields.Verification, Deviations: " "},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := singlerequesttemplate.RenderReview(singlerequesttemplate.DefaultReviewTemplate, mutated, 4096); err == nil {
|
||||
t.Fatalf("RenderReview() expected error for %s", name)
|
||||
if _, err := singlerequesttemplate.RenderReview(singlerequesttemplate.DefaultReviewTemplate, mutated, 4096); err != nil {
|
||||
t.Fatalf("RenderReview() rejected partial %s: %v", name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1055,6 +1059,7 @@ Operator footer.
|
|||
}
|
||||
}
|
||||
|
||||
/* Removed: semantic Review validation belongs to the Reviewer, not transport.
|
||||
func TestValidateReviewHandoff(t *testing.T) {
|
||||
planIDs := []string{"P1", "P2"}
|
||||
|
||||
|
|
@ -1245,6 +1250,7 @@ func TestValidateReviewHandoff(t *testing.T) {
|
|||
}
|
||||
})
|
||||
}
|
||||
*/
|
||||
|
||||
func TestDigest(t *testing.T) {
|
||||
d1 := singlerequesttemplate.Digest(singlerequesttemplate.DefaultPlanTemplate)
|
||||
|
|
|
|||
Loading…
Reference in a new issue