Files
open-design-crew[bot] 933dc96038 fix(daemon,web): stop a delivered OD Next turn reporting as failed, and name the real gate (#7931)
* fix(daemon,web): stop a refused turn reporting a delivered deck as failed

One predicate answered two different questions, and the strict answer is the
wrong answer to the loose one.

`validateRunDeliverable` asks "did THIS run write the canonical entry". That is
the right gate for accepting an Agent's completion claim — a turn must not pass
by pointing at a file an earlier turn wrote. It is also `false` for the most
ordinary shape of an OD Next refusal: the user types 「继续」, the agent
re-checks work an earlier turn already finished, correctly rewrites nothing,
and the turn is refused over its machine block.

Both safety nets read that one answer, so both went down together. The
coordinator's completion inference declined, and `deliveredDespiteBlock` in the
web provider — the carve-out that exists to keep a delivered turn out of the
failure branch — could never fire. A field report shows the result: a red
「回复已收到,但没能记录下来」card under a finished 16-page deck the user could
see rendered in the preview pane beside it. The physical run had exited 0 and
the 2.27 MB file was on disk.

`validateProjectDeliverable` is the second question: does the user have it,
right now. Same entry resolution, same kind contract, same readability check,
minus the run-scoped gates. `GET /api/runs/:id` resolves it only for a terminal
run whose strategy task settled blocked — the one case a client has to
distinguish — and logs the answer, because withholding a failure the user
cannot act on is right and withholding it invisibly is not.

The verdict does not move: the task still blocks, the strategy contract still
refuses to complete on an unproven claim, and a turn that delivered nothing
still fails with its card. What changes is that a bookkeeping failure no longer
tells a user they lost work that is sitting in their project.

This is also what `docs/design/run-errors/error-ux-design.md` §3 already ruled
for the case: 「文件生成了但进程报错:算成功」, with the stated reason that doing
otherwise "把一堆无害报错变成失败潮".

Red spec: apps/web/tests/providers/strategy-blocked-verdict.test.ts
「does not become a failure when the project holds the deliverable」 — red on
main, green here.

* fix(daemon,web): name the gate that refused a turn, not the parser that noticed

The protocol parser raises `od_next_protocol_runtime_state_missing` before
`validateAcceptedTurn` — the function that owns the real gates — is ever
reached. So every block-less turn was filed under the parser's own code, and
`reasonCodes[0]` is what the failure card, the diagnostics export and the
analytics bucket all read.

The effect on a user who got nothing this round: they were told a marker was
missing. True of the reply, useless to them, and not the reason the turn could
not succeed — the identical failure with a DECLARED block reported
`od_next_canonical_deliverable_invalid`, the host fact they can act on. Two
names for one failure, and the less useful one was the one that shipped.

`reattributeUndeclaredTurn` already existed for exactly this, scoped to one
cell (a clarification turn that asked again). Extend it: when a block-less turn
was SHAPED like a completion the host may infer and the inference still
declined, the evidence is what refused — both helpers gate on the shape, which
just held, and the evidence. Lead with it. The parser's code stays behind it,
because `blocked_reason_codes_json` is the durable record of what the turn
actually wrote, and a triage that cannot tell "declared badly" from "declared
nothing" has lost the one distinction that decides whether a serialization
repair was ever possible.

ATTRIBUTION ONLY — no verdict moves. The two existing specs that pinned the old
name still assert the verdict they own and now assert the new attribution; the
turns they describe block exactly as before.

`od_next_canonical_deliverable_invalid` gets the card it now needs. It is
deliberately not the Runtime State copy: those say "your reply went missing",
this says "nothing came out this round", and only the second is true when the
project is empty. Retry earns its place here — the deliverable is missing
because this turn wrote nothing, and a re-run is the thing that can write it.

⚠️ The copy is engineering's, like the rows around it. `error-ux-design.md` has
no cell for it; S23 (跑完没生成文件) is the nearest and is listed there as
invisible in telemetry. Product should rewrite the wording, not the routing.

Red spec: apps/daemon/tests/strategies/od-next/coordinator.test.ts
「names the evidence that refused an undeclared completion, not the parser」 and
「gives the undeclared and declared shapes of one failure the same name」 — both
red on main, green here.

* feat(daemon): make an OD Next block countable instead of invisible

`run_finished.result` is derived from the physical run status
(`runResultFromStatus`: `succeeded → success`), and a refused OD Next turn
normally exits 0 with a complete reply on screen. So this entire class of
failure reported `result: 'success'` while the user was looking at a red card,
and nothing queryable disagreed — the reason codes lived only in the task
store's `blocked_reason_codes_json` and in a client-side error object.

`error-ux-design.md` §《线上频次与优先级》 already flags the shape of this
problem for S23/S24: 「被算成了成功 —— 看不到是坏事:线上到底有多少,我们不知
道」. OD Next made it worse than invisible by pairing the silent metric with a
visible failure card.

Carrying `reasonCodes[0]` — the same value the card keys on — makes the class
countable without changing what `result` means. Read off the run's own terminal
projection, the object the SSE `end` payload and the failure card were both
built from, so the three cannot drift. Omitted for every run whose strategy
task did not block, which is the common case.

This is the half that makes the suppression in the preceding commits safe to
ship: a failure the user is no longer shown is one we can still count.

* fix(web): do not go silent on a turn that produced no reply either

The preceding fix credits a deliverable an EARLIER turn wrote, which is right
for the case it was written for — the agent re-checks finished work and
correctly rewrites nothing — and wrong for the mirror case in #7564: a blank OD
Next response into a project that already holds a prototype. There the file is
real and the user still got nothing, so withholding the card swaps one wrong
answer for the other.

Require the turn to have actually said something before the looser field can
suppress a failure. The accumulated assistant text is already on hand in the
stream consumer, so this costs nothing and needs no new daemon field.

The stricter field keeps its existing behaviour untouched: an artifact THIS run
produced is delivery whether or not the agent narrated it.

Red spec: apps/web/tests/providers/strategy-blocked-verdict.test.ts
「still fails when the turn produced no reply at all」 — verified red with the
guard removed, green with it.

* refactor(contracts): make the OD Next block dimension a tested pure mapper

Observability that nothing exercises is observability that rots. The derivation
was inline in the run lifecycle, where the only way to reach it is to boot the
whole analytics flow — so it would have failed silently to "field absent",
which is indistinguishable from "no run ever blocked".

Move it next to `harnessAnalyticsFromRolloutDecision`, which answers the
neighbouring question about the same run and already has a suite. The edges
that matter are the ones a wrong answer would quietly mislabel: a task that has
not settled, one that settled on something other than `blocked`, and a blocked
one an older daemon projected without a reason.

---------

Co-authored-by: Cheems <216108319+hihicheems@users.noreply.github.com>
Co-authored-by: lefarcen <935902669@qq.com>
2026-09-11 04:29:23 +00:00
..