Repository navigation
fix(managed-agent): return 404 for unknown tool publications - #13602
Conversation
|
独立复核确认:本 PR 修复与 #13563 一致,把未知 publication 的响应从 三条路由的 404 落点按 PR head
三条路由都收敛到同一对 writer 认证仍在 publication 查询之前
关于改动范围的一个观察(非阻塞)
测试新增 6 条控制器级回归( 结论:修复正确、范围与描述一致、测试到位,建议合入。 |
|
Thanks for the PR! This is a re-run — the head commit has not moved since the last pass, so what changed is the evidence, not the code. Template looks good ✓ — every required heading is present and filled in, including a complete parallel Chinese translation. Problem: observed, not theoretical. #13563 (still open) names the three routes, quotes the actual exception ( Direction: aligned — and unusually well specified, since the linked issue already prescribed the fix ("follow the Size: not applicable. Approach: minimal, and close to what I'd have written. Two sites — exactly the two the issue identified — reusing the existing Risk: no elevated risk signals — I re-ran the high-risk path check against both changed files and neither matches. What changed since the last pass: the fork's held workflow runs were released. Moving on to code review. 🔍 中文说明感谢贡献!这是一次重跑 —— head commit 与上一轮相同,变化的是证据,不是代码。 模板完整 ✓ —— 所有必需小节都已填写,并附有完整对应的中文翻译。 问题: 是已观测到的 bug,不是理论性加固。#13563(仍处于 open 状态)点名了三条路由,引用了真实异常( 方向: 对齐 —— 而且方向非常明确:关联 issue 已经给出修复方案(沿用 规模: 不适用。 方案: 足够精简,和我自己会写的方案基本一致。只改两处 —— 正是 issue 指出的两处 —— 复用已有的 风险: 无升级风险信号 —— 我对两个改动文件重新跑了高风险路径检查,均不匹配。 与上一轮的差别: 该 fork 被挂起的 workflow 已放行。 进入代码审查 🔍 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
Code reviewMy independent proposal first. From the title, the "Why it's needed" section and #13563's diagnosis — before weighing the diff: swap the two unguarded No critical blockers, and no AGENTS.md violations. What follows is what I verified myself this pass rather than inherited from the last one. The one real semantic risk in the diff, and why it's safe. Every consumer of the two changed methods, named.
One non-blocking observation, and it is pre-existing. That last point leaves the referenced-stream path answering The new error code is not a published-contract change. Reuse: One non-blocking nit in the test. Each of the six parameterized invocations builds a fresh Testing evidenceThis comment carries CI check results fetched through the API — and unlike last time, they are real and green on the reviewed commit. I did not build, run, or test anything from this PR; triage never executes code from the tree under review. The fork's held runs were released, so The load-bearing part is not "CI is green", it is which job ran these tests. I read the logs rather than trusting the check names:
Does the green suite actually pin the change? Yes, and this is the part CI alone cannot prove. @wenshao ran a two-arm A/B (head Final CI results for
One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。 Not verified, and why:
The sandboxed lane I asked for last time was "release the held CI runs", and that has now happened and settled the behavioural claim. No further Real-scenario tmux testing: N/A — this is an unattended CI-path run ( 中文说明代码审查先说我自己的独立方案。 只看标题、「为什么需要」和 #13563 的根因分析(在权衡 diff 之前):把两处未加保护的 没有阻断性问题,也没有违反 AGENTS.md。 以下是本轮我亲自核验、而非沿用上一轮结论的部分。 diff 中唯一真正的语义风险,以及它为何安全。 两个被改方法的全部调用方,逐一点名。
一条不阻断的观察,且属于既有问题。 上面这点意味着:referenced-stream 路径对未知 publication 仍返回 新错误码不属于已发布契约的变更。 复用情况: 测试中一条不阻断的小问题。 六次参数化调用各自创建一个 测试证据本评论携带的是通过 API 取回的 CI 检查结果 —— 与上次不同,这次是真实存在且在受审 commit 上全绿的。 我没有构建、运行或测试本 PR 的任何内容;triage 从不执行被审查代码树中的代码。该 fork 被挂起的运行已放行,因此 关键不在于「CI 是绿的」,而在于是哪个 job 跑了这些测试。我读了日志,而不是只信检查名:
这套绿色测试真的钉住了改动吗? 是的,而这恰恰是单看 CI 无法证明的部分。@wenshao 做了双臂 A/B(head 上方表格给出了各检查项的真实名称与结论。未验证项及原因:
上一轮我要求的沙箱通道是「放行被挂起的 CI 运行」,这件事已经发生并定论了那个行为性论断。不需要再跑 真实场景 tmux 测试:N/A —— 本次为无人值守的 CI 路径运行( — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
|
Confidence: 4/5 — the ten production lines are correct, minimal, and now backed by executed evidence on both sides (green CI at head, red base arm in the maintainer's A/B); the missing point is for two named non-blocking nits, not for any doubt about the fix. Stepping back, this is what a good narrow fix looks like. Two sites, exactly the two #13563 identified, reusing the helper and the error envelope that already exist instead of adding parallel machinery. The author also made two judgement calls I'd have made — declining the optional shared read helper, and declining the blanket Back to my independent proposal: the PR matches it. I found no simpler path it missed, and it is not trying too hard — if anything the test harness is the heaviest part, and it is heavy because it wires real stores over a real migrated H2 instead of mocking the thing under test, which is the right trade. Every change in the diff is necessary for the stated goal; there is nothing to split out and no unrelated edits rode along. In six months I'd thank the author for this rather than curse them. Why this pass lands differently from the last one. I deferred at 3/5 last time for one reason only: nobody had watched the code run. The fork's CI was held at I also closed the loop the static review could not: The nits, neither blocking. The new test leaks six unclosed in-memory H2 databases per run, which matches the existing house pattern, so changing it here would be inconsistent rather than better. And the referenced-stream path still answers On the vote. @wenshao's approval at this commit is a separate vote, not mine, and I did not treat it as a substitute for checking the work — I re-derived the consumer list, the primary-key invariant, and the CI coverage independently, and read the job logs rather than the check names. Approving, pinned to the reviewed commit. ✅ 中文说明信心度:4/5 —— 那 10 行生产代码是正确的、精简的,并且现在两侧都有已执行的证据支撑(head 上 CI 全绿,维护者 A/B 中 base 臂为红);扣掉的一分是给两条已点名的不阻断小问题,而不是对修复本身有任何怀疑。 退一步看,这是一个范围收得很好的修复该有的样子。两处改动,正是 #13563 指出的两处,复用了已有的 helper 和错误响应结构,而没有另起一套。作者还做了两个我同样会做的判断 —— 谢绝可选的共享读取 helper,以及谢绝兜底的 回到我的独立方案:本 PR 与之一致。我没有找到它漏掉的更简路径,它也没有过度设计 —— 真要说重,重的是测试脚手架,而它之所以重,是因为它接了真实的 store 和真实迁移后的 H2,而不是把被测对象 mock 掉,这个取舍是对的。diff 中每一处改动都是达成目标所必需的;没有可拆出去的东西,也没有夹带无关修改。半年后接手,我会感谢作者而不是抱怨。 这一轮为何与上一轮结论不同。 上一轮我给 3/5 并转交,只有一个原因:没有人看这段代码跑过。该 fork 的 CI 停在 我也补上了静态审查无法闭环的部分: 两条小问题,都不阻断。 新测试每轮运行会泄漏六个未关闭的内存 H2 数据库,但这与仓库现有写法一致,所以在这里改反而是不一致。另外 referenced-stream 路径对未知 publication 仍返回 关于这一票。 @wenshao 在这个 commit 上的批准是另一票,不是我的,我也没有拿它当作免于核查的替代 —— 我独立重新推导了调用方清单、主键不变量和 CI 覆盖范围,并且读的是 job 日志而不是检查名称。 已批准,并绑定到受审 commit。✅ — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
Local A/B verification — merge-ready ✅Verified locally on macOS 15 (Intel), JDK 21, Maven 3.9.9, using a two-arm worktree A/B: detached trees at head Core result — the regression tests are load-bearing
Head, focused suite green: Base, same suite red on exactly the three new 404 cases: Independent oracle (not the PR's own test code)I compiled a small probe against each arm's Full-suite failure attribution (environmental, not PR-introduced)Neither arm is fully green on this host; the residuals are timing flakes that flap independently of the code version:
Methodology / disclosures
Not covered locallyMariaDB-backed profile (covered by CI's 中文摘要(点击展开)结论:可以合并 ✅(本地 A/B 验证,macOS 15 Intel + JDK 21 + Maven 3.9.9)
|
qwen-code-review-bot
left a comment
There was a problem hiding this comment.
Not explored to full depth (tool budget reached): "agent reverse-audit (round 1)": base-side bytecode measurement — I measured only the HEAD side from the pre-built packages/sdk-java/managed-agent-server/target/classes ( finishedInternal 39…; "agent reverse-audit (round 1)": the HTTP consumer of POST /publications/{publicationId}/range — an in-repo search over *.ts/*.tsx/*.js/*.py/*.go/*.md/*.java found only the route declaratio…; "agent 6c": static javac -proc:none + javap -c -p size measurement of finishedInternal / verifiedStream at head vs merge-base (a target/ dir and ~/.m2/repository …; "agent 5": I did not execute the new tests (no Maven/JVM run in the shared worktree), so "the suite is green at this commit" is reasoned from signatures and handler mappin….
中文说明
未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 1)":base-side bytecode measurement — I measured only the HEAD side from the pre-built packages/sdk-java/managed-agent-server/target/classes ( finishedInternal 39…;"agent reverse-audit (round 1)":the HTTP consumer of POST /publications/{publicationId}/range — an in-repo search over *.ts/*.tsx/*.js/*.py/*.go/*.md/*.java found only the route declaratio…;"agent 6c":static javac -proc:none + javap -c -p size measurement of finishedInternal / verifiedStream at head vs merge-base (a target/ dir and ~/.m2/repository …;"agent 5":I did not execute the new tests (no Maven/JVM run in the shared worktree), so "the suite is green at this commit" is reasoned from signatures and handler mappin…。
— qwen3.8-max via Qwen Code /review (v0.25.0)
|
@qwen-code /triage |
|
Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check. Scripted assertions: 34 passed · 0 failed · 34 total Flakiness gate: not applicable — no runnable changed test files (0 out-of-scope file(s) noted in the log) 中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查。 脚本断言:34 通过 · 0 失败 · 34 总计 抖动门:不适用 — no runnable changed test files (0 out-of-scope file(s) noted in the log) Verification reportPR 13602 deep verification —
|
| Cell | Environment | Observable oracle | Result |
|---|---|---|---|
| HEAD (PR) | merge tree 39fd278, H2 + Flyway (63 migrations), real ManagedSessionStore / WriterCredentialPolicy / ToolPublicationStore / ToolPublicationDataStore / ToolPublicationAdmissionStore, real ApiExceptionHandler, MockMvc |
surefire counts + per-case status | 7 run, 0 failures, 0 errors ✅ |
| BASE (control) | git worktree at HEAD^1 6936c77, identical harness, PR test file copied in verbatim (diff against head: identical) |
surefire counts + assertion text | 7 run, 3 failures ✅ (expected red — the control arm must fail) |
Failing assertion on the control arm, verbatim:
[ERROR] ToolPublicationControllerTest.returnsNotFoundForUnknownPublication:55 Status expected:<404> but was:<500>
This is the intended assertion (expected-vs-actual status values), not a compile/import/fixture break — so the revert reaches the behaviour the test exists to catch.
Per-route flip (index → route, from the test's @ValueSource):
| # | Route | BASE | HEAD | Δ |
|---|---|---|---|---|
| [1] | GET /publications/{id}/finished |
500 internal_error |
404 managed_tool_publication_unknown |
fixed |
| [2] | POST /publications/{id}/admissions/prepare |
500 internal_error |
404 managed_tool_publication_unknown |
fixed |
| [3] | POST /publications/{id}/range |
500 internal_error |
404 managed_tool_publication_unknown |
fixed |
| — | writer-auth ordering × 3 | 403 writer_credential_invalid |
403 writer_credential_invalid |
unchanged |
| — | pre-existing range-coercion case | pass | pass | unchanged |
3/3 flip from broken to fixed; 4/4 unrelated cases unchanged. Witness: 01-ab-base-500-vs-head-404.png.
Scenario axes
The diff changes how a request is refused (not cancelled/parked/retried), so the relevant axes are credential validity, route, and publication existence. Settings that ran:
| Axis | Settings exercised | Not exercised |
|---|---|---|
| Route | all 12 publication routes (see sweep below) | — |
| Credential | valid writer token + acquired lease; invalid writer token | expired lease; revoked writer; wrong generation |
| Publication existence | nonexistent id | exists-but-not-FINISHED; exists-but-quarantined; exists in another scope (inferred only, see Not covered) |
| Approval/tenancy | single tenant/workspace/session | cross-tenant id collision |
Mutation matrix (marker-verified)
Every mutant was proven to reach the compiled artifact before counting: javap -c -p on ToolPublicationDataStore.class, scoped to the mutated method's bytecode, asserting the expected instruction/constant is present (or absent). All 6 markers read ok.
| Mutant | Marker in bytecode | Red cases | Interpretation |
|---|---|---|---|
| (unmutated baseline) | — | none — 7/7 green | runner is live |
revert-site1 (finishedInternal → queryForMap) |
queryForMap in finishedInternal |
returnsNotFound[1], [2] |
site 1 is load-bearing for /finished and /admissions/prepare |
revert-site2 (verifiedStream → queryForMap) |
queryForMap in verifiedStream |
returnsNotFound[3] |
site 2 is load-bearing for /range only |
revert-both |
both methods | [1], [2], [3] |
reproduces the base arm exactly ✅ |
ctl-status-conflict (positive control) |
HttpStatus.CONFLICT in finishedInternal |
all 3 returnsNotFound |
the status().isNotFound() assertion is live |
ctl-code-marker (positive control) |
mutant_marker_code in finishedInternal |
all 3 returnsNotFound |
the jsonPath("$.error.code") assertion is live |
no-writer-auth (delete both sessions.restore(…)) |
ManagedSessionStore.restore absent from finished( and readRange( |
all 3 authenticatesWriterBefore… |
the ordering test is non-vacuous and load-bearing |
6/6 killed, 0 survivors. No coverage gap, no dead code, no redundant defence: the two hunks guard a disjoint route set, so neither masks the other (this is the combination-row question, and revert-both is that row — it adds no red beyond the union of the two single-hunk rows, confirming disjointness rather than layered defence).
Two facts the matrix settles that reading cannot:
/admissions/prepare's 404 comes from site 1, not fromToolPublicationStore.requireStagedCall. That method returns immediately when no transaction is active (if (!TransactionSynchronizationManager.isActualTransactionActive()) return;), andprepareAdmissioncalls it before opening one — so it cannot be the source.revert-site1turning[2]red is the proof.- The pre-existing
rejectsCoercedOrOverflowedRangeNumbersBeforeReadingcase is untouched by every mutant — it never reaches the store.
Witness: 03-mutation-matrix-marker-verified.png.
Sibling sweep — is the bug class closed?
The mechanism here is a parser-shaped one: queryForMap on qwen_tool_publication throwing EmptyResultDataAccessException, which falls through to ApiExceptionHandler's generic @ExceptionHandler(Exception.class) → 500 internal_error + LOG.error. The three routes in the issue are three doors into that room, so I drove every publication route with an unknown id, on both arms, under one valid writer credential and lease.
| Route | Cred | BASE | HEAD | Δ |
|---|---|---|---|---|
GET /publications/{id}/finished |
writer | 500 internal_error |
404 managed_tool_publication_unknown |
changed |
POST /publications/{id}/admissions/prepare |
writer | 500 internal_error |
404 managed_tool_publication_unknown |
changed |
POST /publications/{id}/range |
writer | 500 internal_error |
404 managed_tool_publication_unknown |
changed |
POST /publications/{id}/receipts/commit |
writer | 400 invalid_request |
400 invalid_request |
identical |
POST /receipts/verify |
writer | 400 invalid_request |
400 invalid_request |
identical |
GET /publications/{id}/operations/{op} |
pub-token | 404 managed_tool_publication_operation_unknown |
same | identical |
POST /publications/{id}/operations/{op}/recover |
pub-token | 400 invalid_request |
400 invalid_request |
identical |
POST /publications/{id}/finish |
pub-token | 400 invalid_request |
400 invalid_request |
identical |
POST /publications/{id}/streams/stdout/seal |
pub-token | 400 invalid_request |
400 invalid_request |
identical |
POST /publications/{id}/streams/stdout/prefix |
pub-token | 400 invalid_request |
400 invalid_request |
identical |
POST /publications/{id}/segments/stdout/0 |
pub-token | 400 invalid_request |
400 invalid_request |
identical |
POST /publications/{id}/resources/{kind}/{slot} |
pub-token | 400 invalid_request |
400 invalid_request |
identical |
12 routes swept per arm; 3 changed; 9 byte-identical. Zero 500s remain at head (base had exactly 3). So the bug class is closed across the whole writer-reachable publication surface, and the change carries no collateral effect on any route the PR did not intend to touch. The other two writer routes already refused via queryForList + require(...), which is why they were never 500s.
Witness: 02-route-sweep-sibling-base-vs-head.png.
Targeted gate — full managed-agent-server module at head
Tests run: 1983, Failures: 0, Errors: 1, Skipped: 85
The single error is RuntimeBrokerDefaultOnTest.defaultCombinationBootsWithTheYmlDefaultsBound, a Spring context-load failure whose root cause is java.nio.file.NoSuchFileException: /etc/machine-id. A/A control: the same test run on the base arm errors identically (Tests run: 1, Failures: 0, Errors: 1, same root cause), and /etc/machine-id genuinely does not exist in this container (ls: cannot access '/etc/machine-id': No such file or directory). This is the sandbox, not a regression — measured, not assumed. No other test class failed or errored.
Note this is a stronger result than the PR body reports: the author measured 1268 tests: 1204 passed, 1 failure, 59 errors on a Windows host and attributed 60 failures to filesystem/mount behaviour. On Linux the same module is green apart from one container-specific missing file, which supports the author's attribution rather than contradicting it.
Checkstyle / SpotBugs
Measured at head on the module: mvn -DskipTests checkstyle:check spotbugs:check → You have 0 Checkstyle violations., BugInstance size is 0, BUILD SUCCESS, exit 0 (gate-head-static.log). This independently confirms the PR body's static-check claim.
Gate liveness
The gates above are only evidence if they can fail, so each was proven live rather than assumed green: the A/B control arm is the liveness proof for the test suite (it goes red 3/7 on base with the intended assertion); the two positive-control mutants (ctl-status-conflict, ctl-code-marker) are the liveness proof for the new assertions, each turning all three cases red; and the mutation runner requires the unmutated baseline green before any row counts. The full-module gate's one error is attributed by an A/A control on the base arm, not by inspection.
Corrections
Two statements in the PR description are inaccurate in ways that would mislead the next reader. Both are corrections to the description, not requests to change the code.
-
"The two shared reads check for a missing record" understates the reach.
verifiedStream(site 2) is not private to the range-read route — it is also entered byManagedArtifactReader.openReferencedStream, i.e. by the artifact-download API. The description's scope statement ("from the finished-result, admission-prepare, and range-read routes") and its breaking-change note ("callers requesting a missing publication receive the new status and error code") both read as covering three routes; a fourth API family is affected. Evidence and consequence in Finding 1. -
"gives the caller a consistent refusal" holds for the three routes changed, but not across the writer surface. After this PR an unknown publication yields
404 managed_tool_publication_unknownon three writer routes and400 invalid_request("The request body is invalid.") on the other two (/receipts/commit,/receipts/verify). Measured — see the sweep table. Those two 400s are pre-existing (identical on both arms) and are not this PR's doing; the correction is only that "consistent" is scoped to three of five writer routes.
Findings
1. Medium — an unnamed fourth consumer: artifact download turns a retryable 503 into a permanent 404
Status: inferred from code, not driven end-to-end. The call chain and the handler are quoted below; building a REFERENCED artifact and then deleting its publication row was out of budget (see Not covered).
Chain to changed site 2:
ManagedArtifactService.read(...) // HTTP artifact download
→ reader.readRange(artifact, …) / reader.open(artifact, …)
→ ManagedArtifactReader.verified(artifact, lease, guard) :121
→ data().openReferencedStream(source.sessionKey(), artifact.publicationId(), …) :135
→ ToolPublicationDataStore.verifiedStream(…) // ← changed site 2
ManagedArtifactService's handler for that block (ManagedArtifactService.java:275-287):
} catch (RuntimeException error) {
if (response.isCommitted()) {
throw new IOException("Artifact stream interrupted", error);
}
if (error instanceof ApiException api) {
if ("tool_output_session_retired".equals(api.getCode())
|| "tool_output_read_expired".equals(api.getCode())) {
session(tenant, sessionId);
throw unavailable(); // 503 artifact_unavailable
}
throw api; // ← the new 404 escapes here, untranslated
}
throw unavailable(); // ← base: EmptyResultDataAccessException landed here → 503
}with unavailable() = new ApiException(SERVICE_UNAVAILABLE, "artifact_unavailable", "Artifact content is unavailable.").
| BASE | HEAD | |
|---|---|---|
exception from verifiedStream |
EmptyResultDataAccessException (not an ApiException) |
ApiException(404, managed_tool_publication_unknown) |
| branch taken | final throw unavailable() |
throw api |
| HTTP response | 503 artifact_unavailable |
404 managed_tool_publication_unknown |
| client reading | retryable — content temporarily unavailable | permanent — resource does not exist |
Why this deserves attention rather than a shrug:
- The allowlist shows intent. The author of this handler explicitly enumerated the publication-side codes that must be translated back into the artifact vocabulary, and mapped them to a retryable 503.
managed_tool_publication_unknownis a new member of exactly that family and is not in the list, so it bypasses a deliberate translation layer. - A retryable signal becomes permanent. If a publication row is transiently invisible to a reader whose artifact row still exists (retention/GC race, a mid-migration window), a client that previously retried on 503 will now treat the artifact as gone and may drop it. This is the "rank by observability" case: the loud, retryable failure became a quiet, terminal one.
- Namespace leak across an API boundary. An artifact-download client switching on
artifact_not_found/artifact_unavailablenow receives amanaged_tool_publication_*code on anartifact_*route.
Blast radius — exactly one surface, and I narrowed it by checking the other candidate. Artifact download is the only consumer whose behaviour changes: both its full-read and its partial/range-read branches go through ManagedArtifactReader.verified(…) (:121 → :135), and ManagedArtifactService is the only caller with an ApiException-specific translation that the new code escapes.
The other consumer of changed site 2 is the async projection path ManagedToolResultProjector.project(claim) → resolve(…) → reader.verified(artifact, lease, guard) at ManagedToolResultProjector.java:222. That path is unchanged, and I verified it rather than assuming it: its handler is
} catch (NoSuchElementException error) {
fail(claim, "UNSUPPORTED", "public_turn_mapping_missing", error);
} catch (IllegalArgumentException error) {
fail(claim, "QUARANTINED", "tool_result_source_invalid", error);
} catch (RuntimeException | IOException error) {
fail(claim, "RETRYABLE", "tool_result_source_unavailable", error);
}ApiException extends RuntimeException (ApiException.java:6), and base's EmptyResultDataAccessException is also a RuntimeException; neither is a NoSuchElementException or an IllegalArgumentException. Both therefore fall into the same third catch and produce the same RETRYABLE / tool_result_source_unavailable outcome — no behaviour change, and the retry semantics that Finding 1 loses on the HTTP path are preserved here. Note the line that looks like the call site, ManagedToolResultProjector.java:248 (reader.readRange(selected, stream, …)), is not one: it consumes the already-built VerifiedStream from the map populated at :222.
Sites 1 and 2 are reached only from these consumers plus the three writer routes already measured.
What this is not. It is not a failure of the central claim (that A/B is clean), not a new 500, and not an authentication or tenancy change — the guard runs after sessions.restore(...), and the sweep confirms the three writer routes and all nine untouched routes behave as intended. No data loss or security consequence was demonstrated.
Suggested minimal fix (preserves the commit's intent)
Add the new code to the existing translation list in ManagedArtifactService, so the artifact route keeps its own vocabulary and its retryable semantics:
if (error instanceof ApiException api) {
if ("tool_output_session_retired".equals(api.getCode())
|| "tool_output_read_expired".equals(api.getCode())
|| "managed_tool_publication_unknown".equals(api.getCode())) {
session(tenant, sessionId);
throw unavailable();
}
throw api;
}Not measured. Per this skill's own bar a suggested fix must be driven through the same harnesses; I did not build the artifact fixture, so I am not claiming the three usual results (hostile fixtures clean / benign fixtures byte-identical / suite counts unchanged). If the maintainer prefers to accept the 404 as more accurate instead, the actionable item reduces to naming the fourth route in the description and the breaking-change note. Either way a fixture that would pin this axis is missing: an artifact-read test whose publication row is deleted, asserting the intended status. Today the module suite is green with and without the change on this axis, so nothing would catch a regression here.
2. Suggestion — the suppressed LOG.error is correct, and the reason still reaches the caller
Checked per the "follow the value" rule, because this PR suppresses output. Base path: EmptyResultDataAccessException → generic @ExceptionHandler(Exception.class) → LOG.error("Managed Agent request failed", error) + 500. Head path: ApiException → @ExceptionHandler(ApiException.class) → no logging, but the cause is carried in the response envelope the caller reads (error.code = managed_tool_publication_unknown, error.message = "Publication is unknown").
So the information survives in a field with a reader, and the suppression is exactly what the description intends — not a finding. One residual tradeoff worth naming: for the genuinely anomalous variant (a publication row that vanished underneath a live writer lease), operators lose the server-side log line entirely and see only a client-reported 404. Acceptable for an expected missing-resource case; just not free.
3. Suggestion — pre-existing, not this PR: two writer routes answer "unknown publication" with a misleading 400
/receipts/commit refuses via require(candidates.size() == 1, "Admission candidate is missing") and /receipts/verify via require(rows.size() == 1, "Original committed publication is missing or ambiguous"). ToolPublicationContract.require throws IllegalArgumentException, which ApiExceptionHandler.invalid(...) renders as 400 invalid_request / "The request body is invalid." — but the body is fine; the publication does not exist. Each predicate also conflates "publication unknown" with "publication exists but has no admission object".
Measured identical on both arms, so pre-existing and out of this PR's scope; recorded only because it bounds correction 2 above and because it is the same bug class in a milder costume. A follow-up could map these to the same 404.
Checked and disproved (no finding)
queryForMap→queryForList().getFirst()does not weaken a uniqueness check.queryForMapalso throwsIncorrectResultSizeDataAccessExceptionon >1 row, whichgetFirst()would silently swallow. That concern does not apply:qwen_tool_publicationhasPRIMARY KEY (scope_key, publication_id)(V20__managed_tool_publication.sql:23), and both queries filter on exactly those two columns plus narrowing tenant/workspace/session predicates. At most one row can match, sogetFirst()is total after theisEmpty()guard and no duplicate-row behaviour changed.- No
catchclause on the path is bypassed by the new exception type. The onlyEmptyResultDataAccessExceptioncatches inmanaged-agent-serverandruntime-brokerareManagedAgentStore.java:3572,3592— a different store, unrelated to publications. (TheApiExceptioncatch that does matter is Finding 1.) 404follows existing house precedent.GET …/operations/{op}already returns404 managed_tool_publication_operation_unknown(measured in the sweep), so a scoped 404 + specific code is the established shape, not a new convention.
Not covered
- Finding 1 was not driven end-to-end. No fixture was built for "REFERENCED artifact whose publication row is missing". The chain and handler are quoted from source at head; the 503→404 delta is inferred from code, not observed on the wire. What would close it: a
ManagedArtifactReadIntegrationTest-style case that deletes the publication row and asserts the status. - Cross-scope existence leak — inferred only. A publication that exists under a different tenant/workspace/session takes the same empty-result branch (the
WHEREclause carriestenant_id,workspace_id,session_id), so it should return the identical404 managed_tool_publication_unknownwith no existence leak. I did not plant a row in a second scope to measure it. - Broker-only path
finishedForBroker. It also reaches changed site 1, and is not exercised by the PR's HTTP tests. Inferred unchanged: its own scope-less query finds the row, it derives the key from that row's tenant/workspace/session, so the scoped re-query infinishedInternalmatches. Not measured. - MySQL/MariaDB failsafe integration profiles and the hosted harness — need a real database and a bundled
dist/cli.js; this lane is JDK 21 + H2 only. - Real OSS object storage —
ToolPublicationObjectStorewas mocked in every harness (as it was in the PR's own). - Java 11/17 matrix — stays on
sdk-java.yml; this lane has Temurin 21 only. - Node/TypeScript gates — not run. The diff touches no Node code (
git diff HEAD^1..HEAD --stat: 2 Java files), so there is nothing to gate. - Trial merge into current
main— the CI checkout already is the merge ref, and the full-module gate ran on it. The metadata snapshot'sbaseRefOid(9a02f405…) differs fromHEAD^1(6936c77e…), i.e.mainmoved between snapshot and merge; the merge ref is what lands and is what I measured. Depth-2 history cannot enumerate whatmainadded in between, so I cannot list base-side deltas — the green 1983-test gate on the merged tree is the coverage for semantic-conflict risk. - Per-commit attribution — not needed and not skipped:
git rev-list HEAD^1..HEAD^2returns 1 commit, matching the metadatacommitsarray length of 1, so the aggregate diff is the single commit. - Real OSS-backed large-output tail read after replacing the store instance (Reviewer Test Plan step 3, second half) — needs real object storage. The in-process half of step 3 (an existing publication can still be finished/admitted/range-read) is covered by the 1983-test module gate, which includes the publication storage and admission acceptance suites.
Reviewer Test Plan, walked step by step
| Step | Result |
|---|---|
1. Valid writer credential + lease; unknown id through /finished, /admissions/prepare, /range → 404 managed_tool_publication_unknown |
Performed, all three. Measured in the sweep and in the PR's own parameterised test at head. ✅ |
2. Same three requests with an invalid writer credential → still 403 writer_credential_invalid before publication lookup |
Performed, all three. Green at head and at base (so the ordering is preserved, not newly introduced), and the no-writer-auth mutant turns all three red — the claim is pinned by a live assertion. ✅ |
| 3. An existing publication can still be finished, admitted, and range-read, including the tail of a large output after replacing the store instance | Partially performed. The no-regression half is covered by the full-module gate rather than by a purpose-built accept-path probe of my own — measured inside it: ToolPublicationStoreTest 110/110, SurfaceAdmissionAcceptanceTest 195/195, ToolPublicationAcknowledgementTest 25/25, ToolPublicationCollectorTest 40/40, ToolPublicationRetentionStoreTest 18/18, AliyunToolPublicationReadRetryTest 15/15, all 0 failures. The 110 matches the PR body's own figure exactly; the acceptance suite reads 195 here against the body's 165, which is expected drift — the author measured on base ad6039aa, this ran on the merge tree, and main moved in between. The "replace the store instance" / large-output-tail half needs real object storage and was not run — see Not covered. |
No step was structurally impossible to perform.
Methodology
CI verify lane: node:22-bookworm container, no GitHub token, working tree at refs/pull/13602/merge (depth 2). Temurin JDK 21.0.12.1 + Maven 3.9.11, MAVEN_ARGS=-Dmaven.repo.local=/__w/_temp/verify-maven-repo; qwencode and runtime-broker were pre-installed from this checkout (java-prepare.log: qwencode=0 runtime-broker=0).
The A/B control arm is a scratch worktree (git worktree add tmp/base-tree HEAD^1). Reusing the shared Maven repository for the sibling modules is a clean control here because the PR leaves them untouched — asserted, not assumed: git diff HEAD^1..HEAD --stat -- packages/sdk-java/qwencode packages/sdk-java/runtime-broker is empty, and qwencode/runtime-broker are consumed as installed jars, not as symlinks into the head tree, so no head code can leak into the base arm.
All harnesses are mock-free with respect to the unit under test: real H2 + Flyway (63 migrations applied), real ManagedSessionStore / WriterCredentialPolicy / ToolPublicationStore / ToolPublicationDataStore / ToolPublicationAdmissionStore, real controller mappings and the real ApiExceptionHandler, driven over Spring MockMvc. Only ToolExecutionRepository, RuntimeBindingRepository and ToolPublicationObjectStore — none of which these read paths touch — are mocked. Oracles are HTTP status plus the parsed error.code from the response envelope, taken from the handler's output rather than from any store-internal signal.
The route sweep is an arm-agnostic recorder: the identical VerifyRouteSweepTest.java (harness only, not part of the PR, removed from both trees after running — git status --porcelain clean) prints one SWEEP|route|status|code line per route and asserts no statuses, so base and head outputs are directly diffable. The mutation runner (run-mutants.sh + mutate.mjs) restores the pristine source from a copy before each mutant, aborts unless every anchor matches its expected count exactly, verifies each mutant in the compiled bytecode via method-scoped javap -c -p, and restores the tree at the end (SOURCE-RESTORED-OK).
Every number in this report is produced by score.mjs from the raw logs in this directory — none is hand-counted. Raw artifacts: head-test-1.log, base-test.log, sweep-head.log, sweep-base.log, mutant-*.log (7), mutation-matrix.txt, gate-head-full-module.log, gate-base-RuntimeBrokerDefaultOnTest.log, assertions-detail.txt, assertions.json.
Flakiness gate log
verdict: n/a
summary: no runnable changed test files (0 out-of-scope file(s) noted in the log)
Evidence images
Harness scripts and raw logs are in the workflow run artifacts (7-day retention).
— Qwen Code · sandboxed verification
qwen-code-review-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
What this PR does
Authenticated writer requests for an unknown tool publication now receive
404 managed_tool_publication_unknownfrom the finished-result, admission-prepare, and range-read routes. The two shared reads check for a missing record and return the existing API error envelope. Writer authentication still runs before the publication lookup.Why it's needed
A stale or incorrect publication ID currently produces
500 internal_errorafter successful writer authentication. Recognizing a missing publication as a domain error gives the caller a consistent refusal and avoids logging an expected missing-resource case as an unexpected server exception.Reviewer Test Plan
How to verify
GET …/finished,POST …/admissions/prepare, andPOST …/range. Each response should be404witherror.code = managed_tool_publication_unknown.403 writer_credential_invalidbefore publication lookup.Evidence (Before & After)
N/A — no user-facing UI change. HTTP integration checks used real migrated H2 data, the writer credential policy, Session/publication stores, controller mappings, and error handler. Only unused broker repositories and external object storage were mocked.
GET …/finished500 internal_error404 managed_tool_publication_unknownPOST …/admissions/prepare500 internal_error404 managed_tool_publication_unknownPOST …/range500 internal_error404 managed_tool_publication_unknown403 writer_credential_invalid403 writer_credential_invalidThe same independent reproduction passed after the fix; all three cases failed with expected-404/actual-500 on unmodified upstream
464f486e. Permanent HTTP regression coverage has 7 passing cases, including the 6 new cases. The existing publication storage suite has 110 passing cases, and the permission/admission acceptance suite has 165 passing cases.Java validation on base
ad6039aa:The full Java test run is not green on this Windows host: the 60 failing cases are in six existing Workspace filesystem suites. Replaying those six suites on unmodified upstream
ad6039aareproduced exactly the same 60 failed case names and failure/error types (68 cases: 1 failure, 59 errors, 3 skipped). They require filesystem/mount behavior unavailable on this host.Root
npm run preflightpassed formatting, lint, build, and typecheck. Its Node test phase is not green on this Windows host. All 14 failed ACP Workspace/symlink cases were reproduced on upstreamad6039aa(three replayed files: 1167 passed, 14 failed, 1 skipped). The unchanged Chrome extension ZIP symlink fixture also failed with WindowsEPERMon both checkouts (base replay: 6 passed, 1 failed, 1 skipped). The remaining unrelated Node workspace tests were stopped after confirming these existing host failures; no overall preflight pass is claimed.Tested on
Environment (optional)
Java 21.0.12.1, Maven 3.9.11, Node 24.19.0, and pnpm 11.24.0. Java tests ran in the Managed Agent server module with its SDK and Runtime Broker dependencies built from this checkout.
Risk & Scope
Linked Issues
Fixes #13563
中文说明
此 PR 的改动
已认证 writer 通过 finished-result、admission-prepare 和 range-read 三个接口请求不存在的 tool publication 时,统一收到
404 managed_tool_publication_unknown。两个共享读取点检查记录是否缺失,并返回已有的 API 错误响应结构。writer 认证仍在 publication 查询之前执行。修改原因
过期或错误的 publication ID 目前会在 writer 认证成功后产生
500 internal_error。把缺失 publication 识别为领域错误,让调用方得到一致的拒绝响应,也避免将预期的资源缺失当成意外服务端异常记录。审查者测试计划
验证方式
GET …/finished、POST …/admissions/prepare和POST …/range请求一个不存在的 publication ID。每个响应应为404,且error.code = managed_tool_publication_unknown。403 writer_credential_invalid。修改前后的证据
N/A — 没有面向用户的 UI 改动。HTTP 集成检查使用了真实迁移后的 H2 数据、writer 凭据策略、Session/publication stores、控制器映射和错误处理器。仅未被这些路径使用的 broker repositories 和外部对象存储使用了 mock。
GET …/finished500 internal_error404 managed_tool_publication_unknownPOST …/admissions/prepare500 internal_error404 managed_tool_publication_unknownPOST …/range500 internal_error404 managed_tool_publication_unknown403 writer_credential_invalid403 writer_credential_invalid同一份独立复现脚本在修复后通过;三个案例在未修改的上游
464f486e上均以 expected-404/actual-500 失败。正式 HTTP 回归测试共 7 个案例通过,其中 6 个为新增案例。已有 publication 存储测试的 110 个案例通过,权限/admission 验收测试的 165 个案例通过。基于
ad6039aa的 Java 验证:全量 Java 测试在本 Windows 环境未全部通过:60 个失败案例来自六个已有的 Workspace 文件系统测试类。在未修改的上游
ad6039aa上重跑这六个类,复现了完全一致的 60 个失败案例名称及 failure/error 类型(68 个案例:1 个断言失败,59 个执行错误,3 个跳过)。它们依赖本环境不支持的文件系统/挂载行为。根目录
npm run preflight的格式、lint、构建和类型检查通过。Node 测试阶段在本 Windows 环境未全部通过。14 个失败的 ACP Workspace/符号链接案例全部在上游ad6039aa复现(重跑的三个文件中,1167 个通过,14 个失败,1 个跳过)。未修改的 Chrome 扩展 ZIP 符号链接测试也在两个 checkout 上因 WindowsEPERM失败(上游重跑:6 个通过,1 个失败,1 个跳过)。确认这些已有环境失败后停止了余下不相关的 Node workspace 测试;这里不声称完整 preflight 通过。测试平台
环境(可选)
Java 21.0.12.1、Maven 3.9.11、Node 24.19.0 和 pnpm 11.24.0。Java 测试在 Managed Agent server 模块执行,其 SDK 和 Runtime Broker 依赖从本 checkout 构建。
风险与范围
关联 Issue
Fixes #13563