Repository navigation
feat(managed-agent): Hosted Turn takeover and G1 failover E2E - #13083
Conversation
c904e83 to
e7d26f4
Compare
|
Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration. 中文请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。 |
9331203 to
eb06f7f
Compare
5e81d70 to
fcd2dc2
Compare
yiliang114
left a comment
There was a problem hiding this comment.
Reviewed at fcd2dc2c.
The CI blocker from the previous review is resolved on this head, and I checked the run rather than taking the new green at face value: Hosted process fault gates / MySQL 8.4 / Java 21 finished green on this SHA (run 36719407748), including HostedWorkspaceToolTurnIT 8/8 and HostedProcessCrashIT 1/1 — the same class as the six red never-replay gates last round. The two new steps ran too: in-flight printed executionState SETTLED / dispatchGeneration 1 / promptReplayed false / physicalToolExecutions 1 / terminalTurns 1, continuation printed visibleText CONTINUATION_TURN_RECOVERED / continuationModelRequests 2 / physicalToolExecutions 1 / terminalTurns 1. Both acceptance criteria are now evidenced in CI, not only in the local Linux run.
One thing I'd fix before this merges, and it is the same one flagged last round: the recovery can acquire the Runtime lease and then return without releasing it. Detail inline.
Non-blocking, inline: the projection scan at hosted-harness-session.ts:364 is O(journal) per event and this PR makes the journal grow with the streaming chunk count; ~40 lines duplicated between the recovery route and executeHostedTurn; the trusted-actor filter overriding an existing principal instead of deferring to it; a redundant copy of the broker token on argv in the E2E runner; a no-op messageComplete().
No local run here (no checkout in this session) — the evidence above is the CI logs on this head plus reading the head sources.
中文说明
在 fcd2dc2c 上复核。上一轮的 CI 阻塞已解除,我核对的是运行本身而不是新出现的绿:Hosted process fault gates / MySQL 8.4 / Java 21 在这个 SHA 上绿了(run 36719407748),包含 HostedWorkspaceToolTurnIT 8/8 与 HostedProcessCrashIT 1/1 —— 与上轮红的六个 never-replay 门禁同类。两个新增步骤也确实跑了:in-flight 打印 executionState SETTLED / dispatchGeneration 1 / promptReplayed false / physicalToolExecutions 1 / terminalTurns 1,continuation 打印 visibleText CONTINUATION_TURN_RECOVERED / continuationModelRequests 2 / physicalToolExecutions 1 / terminalTurns 1。两项验收标准现在都有 CI 证据,不再只靠本地 Linux 运行。
合并前建议修掉一处,与上一轮同一个:恢复流程可能在拿到 Runtime 租约后从失败路径直接返回而不释放。详见行内评论。
非阻塞项见行内:投影扫描、约 40 行重复代码、trusted-actor 过滤器覆盖既有 principal、E2E runner 里多余的 argv token、空实现的 messageComplete()。
本次未在本地运行(本会话无检出),依据是当前 head 的 CI 日志与 head 源码。
| if (pending.some((item) => item.toolName === 'run_shell_command')) { | ||
| return undefined; | ||
| } | ||
| await broker.acquire(); |
There was a problem hiding this comment.
[P1] The acquired Runtime lease is not released on any failure exit below this line.
acquire() is a server-side tool-sessions:acquire and acquiredRuntime = true records it, but three exits after it return without reporting the lease:
:245if (argsRef === undefined) return undefined;:249if (typeof stored.payloadJson !== 'string') return undefined;:311if (finalAuthorization.status !== 'runnable') return undefined;
plus anything thrown by broker.execute, toolResultParts, or the oversize branch.
The caller only remembers the lease on a returned value — hosted-harness-session.ts:831 session.runtimeLeaseHeld = recovered.acquiredRuntime — while both failure exits there (:826 and the catch at :833) do managed.close() plus a 409. close() is authority.close() (managed-session-assembly.ts:230), which is not a release: the success path needs the explicit release() in releaseRecoveredRuntime (:1422) precisely because closing the session does not release the lease. So a takeover that acquires and then refuses pins the workspace lease with no owner, and the 409 tells the coordinator to retry into a lease nobody holds.
Moving await broker.acquire() below the per-item validation covers the two return undefined paths; the throw paths still need a catch that releases when acquiredRuntime is set. Nothing covers this today: hosted-runtime-recovery.test.ts mocks acquire as a resolved no-op in every test, and the one rejection test (:392) asserts the opposite branch, where acquire never succeeded and no release is correct.
| .join('') ?? ''; | ||
| // A message whose text already streamed as message.delta events must not | ||
| // project a second chunk, or from-scratch consumers would see it twice. | ||
| const streamed = session.managed.authority |
There was a problem hiding this comment.
[P2] This makes a full replay quadratic, and the per-chunk delta events make N bigger.
eventsInSequenceRange is this.events.filter(...) (managed-session-authority.ts:656) and eventEnvelope is called once per event by both /events loops (:1725, :1788), so each message.committed copies the whole journal to answer one boolean. This PR then adds one message.delta per model chunk, so N grows with the streaming chunk count. The same full-range gather is repeated at :149, :160, :183, :456, :1490 and in hosted-runtime-recovery.ts:236. Building the streamed messageId set once per projection pass (one linear scan, passed into eventEnvelope) is about six lines and puts it back at O(N).
| message: { role: type === 'assistant' ? 'model' : 'user', parts }, | ||
| }); | ||
| const deltas = new HostedTextDeltaStream(session.managed, promptId); | ||
| const commit = async ( |
There was a problem hiding this comment.
[P2] ~40 lines of this route duplicate executeHostedTurn.
messageRecord + commit + the deltas.takeMessageId() handoff here mirror :484-:512, and the settledPrompts set at :1488 mirrors :454. They have already drifted: this copy drops the optional identity parameter the original threads through. Non-blocking if a shared factory would push the file over its size limit, but as written the next change to the recorder has to be applied twice.
| } | ||
| chain.doFilter(new HttpServletRequestWrapper(request) { | ||
| @Override | ||
| public Principal getUserPrincipal() { |
There was a problem hiding this comment.
[P2] This overrides whatever principal already exists instead of standing in for a missing one.
getOrder() is HIGHEST_PRECEDENCE, so the wrapper is installed ahead of everything else in the chain, and getUserPrincipal() here is unconditional — TenantContextFilter, downstream, reads the principal off this wrapper. On a deployment that authenticates and sets qwen.managed-agent.trusted-actor-header, a client that can send the tenant + actor headers is admitted as that actor rather than as whoever it authenticated as. Default-off keeps this inert today, so non-blocking — but if (request.getUserPrincipal() != null) { chain.doFilter(request, response); return; } makes the "stand-in" intent true by construction instead of by documentation.
| ? [ | ||
| '--managed-runtime-broker-url', | ||
| heldStartProxy?.baseUrl ?? `http://127.0.0.1:${brokerPort}`, | ||
| '--managed-runtime-broker-token', |
There was a problem hiding this comment.
[P3] Redundant credential on argv — the same brokerToken is already in the child env (QWEN_RUNTIME_BROKER_TOKEN, lines 932 and 1273), so this pair only adds a ps-visible copy. Dropping both '--managed-runtime-broker-token', brokerToken, pairs (here and 1254) is enough.
There was a problem hiding this comment.
这里不能删:harness 进程并不读 QWEN_RUNTIME_BROKER_TOKEN——serve.ts 只在 argv['managed-runtime-broker-token'] 处取这个值(无任何 env 回退),e2e 里设置的同名环境变量目前是死配置。试过只留 env 的路径,harness 起不来(最早的 dry-run 就卡在这)。argv 泄露问题真实存在,但那是 serve 侧支持 env 回退之后才适合收的事;这个 PR 里先保持可跑。
| } | ||
|
|
||
| /** Called after each model message ends, before its final commit. */ | ||
| async messageComplete(): Promise<void> { |
There was a problem hiding this comment.
[P3] Empty method with a live caller.
messageComplete() has no body and is awaited once per model message (hosted-harness-model.ts:205), which implies buffered state that does not exist. published() is the one that is actually read (hosted-harness-model.ts:163, :171).
| session.active = { promptId, digest: '', abort: new AbortController() }; | ||
| void (async () => { | ||
| try { | ||
| if (brokerOptions) { |
There was a problem hiding this comment.
[P3] The cancel route skips the guard continue has.
continue refuses with 409 when !session.toolProfile || !brokerOptions (:1450); here the broker step is conditional instead, so a session that reaches this route without brokerOptions settles the parked executions and writes a terminal turn_result without ever asking the Broker to cancel — the durable outcome then says cancelled for an execution nothing stopped. If that combination is unreachable because a caller can only hold these ids after a takeover load, could we make the two routes symmetric anyway?
doudouOUC
left a comment
There was a problem hiding this comment.
已完成对 fcd2dc2c5f6b1727641e0fb57bdb27bc20752c13 的全面 review:检查了全部 28 个文件、Harness/Java 恢复契约、已有 review、流式投影、身份与 Workspace 隔离、取消与租约生命周期,以及 E2E/CI 接线。结论:Request changes,3 个 P1 阻塞问题,详见 inline comments。
- 已绑定的健康 Session 在第二轮也无条件执行 takeover load;实际 Harness 拒绝重复挂载,导致正常多轮对话无法继续。
- 恢复取消不确认物理执行已结算;Broker 取消失败或仍在 cancel_requested 时也提交 cancelled 终态并放行后续 prompt。
- 成功的 passive cancellation 不释放原 Turn 持有的 Runtime Session;Workspace 仍由旧 Turn 占用。
验证:本地 npm run build、npm run typecheck 均通过;改动相关的 CLI 5 个测试文件 126/126、Core 3 个测试文件 148/148、Managed Agent Java 246/246 通过。另用当前提交的实际 HTTP routes 和持久 journal、配合受控 Broker/模型场景复现上述边界;这些是 test-script 验证,未在本机重跑 Linux 进程故障 E2E。
已重新核对上一轮结论:最新 Hosted MySQL CI 中 13 个 Hosted IT、44 个 Broker fault gates 和两个新增 failover E2E 均通过,旧 CI 阻塞已解除。失败 active load 后保留的租约,在成功 retry load + continue 后能够释放,不能仅凭第一次 409 就断言永久泄漏;本轮租约阻塞针对的是已成功结算的 passive cancellation。
此外,取消 checkpoint 中的结果未写入 tool_result 历史;实际 OpenAI/Anthropic 转换器会清除未配对应答的 tool call,因此没有证据支持“下一轮必然遭遇 provider 协议拒绝”。本轮不以该推测阻塞。
| if (session.harnessBootId() != null) { | ||
| // A previously attached Session may hold a parked Turn; the | ||
| // takeover load settles or reports it. Plain loads stay inert. | ||
| attachment = harness.recoverManagedRuntime(session.tenantId(), | ||
| session.sessionId(), recoveringCancellation); |
There was a problem hiding this comment.
[P1] 健康的已挂载 Session 应复用 attachment
harnessBootId != null 在第一轮完成后就成立,并不能证明需要 owner takeover。因此同一健康 Session 的第二轮也会走这里,而 QwenHostedHarnessConnector.recoverManagedRuntime() 无条件调用 client().loadSession(),绕过已有 attachment 缓存。同一个 Hosted Harness 的 load 路由对已挂载 Session 返回 409 hosted_session_already_attached,协调器随后重试并最终耗尽该轮的 admission retries,第二轮从未送到模型。当前提交的实际 route 复现:create=200、第一轮 prompt=202 且正常结算,接着 driveRuntimeRecovery load=409/hosted_session_already_attached。请区分健康 attachment 的复用和真正的 takeover;正常第二轮及同一 owner 的 stream 重连不应重新加载已经挂载的 Session。
There was a problem hiding this comment.
在 b4e9d71b 上核过:
QwenHostedHarnessConnector.recoverManagedRuntime 现在先查 attachments 缓存(QwenHostedHarnessConnector.java:344),命中即复用、不再 loadSession;并且只有 pendingRecovery 仍含该 key 时才把 takeover 快照交出去(:356 起),已经挂载的健康 Session 只拿 bootId / watermark。
按你给的复现路径(create=200 → 第一轮 prompt=202 正常结算 → 第二轮),第二轮会走缓存分支,不再触发 hosted_session_already_attached,也就不会再耗尽该轮的 admission retries。方便的话用你原来的 harness 再跑一遍确认。
| ); | ||
| for (const item of authorization.checkpoint.tools?.items ?? []) { | ||
| if (item.state === 'in_progress' && item.outcomeSource === 'runtime') { | ||
| await broker.cancel(item.executionCallId).catch(() => undefined); |
There was a problem hiding this comment.
[P1] 确认物理结算后才能提交 recovered cancellation
这里吞掉 cancel 错误,而且 HostedWorkspaceBroker.cancel() 也不会检查返回的执行状态。Java 的 isCancellationReady() 接受 known/executing 和 known/cancel_requested,随后新的 /managed-runtime/cancel 路由无条件调用 settleParkedTurnCancelled(),生成 cancelled outcome 并写入 Turn 终态。受控 Broker 配合当前提交实际 route/journal 的两个复现均得到 cancel=200/accepted、terminal cancelled、下一次 prompt=202:一例 cancel 抛错而物理状态仍 executing;另一例取消只进入 cancel_requested。实际 Broker 的取消契约允许这些非终态,因此这不是确认执行已停止的证据。请保留失败/未确认取消的阻塞状态,观察原 executionCallId 的物理终态(或可信停止证据)后再结算;不能仅凭发送 cancel 就解除 durable wait。
There was a problem hiding this comment.
在 b4e9d71b 上核过:
stopParkedRuntimeExecutions 现在是先 status()、非 settled 才发 cancel,然后轮询 status() 直到终态,30s 未到终态直接抛错(hosted-runtime-recovery.ts:186–:201);cancel 路由 await 它,抛错走 503 managed_runtime_cancel_failed,不再仅凭「发过 cancel」就写 cancelled 终态。
有一个点仍建议你确认:Broker 的 409 runtime_broker_execution_unknown(UNKNOWN/ABANDONED 记录)现在被 status() 映射为 undefined,而 stopParkedRuntimeExecutions 把 undefined 当作「已经停住」直接跳过——这正是 R1-4 要求的编码。按你「必须确认物理结算才能解除 durable wait」的判据,这条路径等于没有确认。两条要求在此处有张力,若要按你的判据收口,这一处需要一并处理(例如把 unknown 与「确定不存在」区分开)。
| session: HostedSession, | ||
| promptId: string, | ||
| ): void => { | ||
| if (!session.runtimeLeaseHeld || !brokerOptions) return; |
There was a problem hiding this comment.
[P1] passive cancellation 也必须释放原 Turn 的 Runtime Session
这个 guard 只认本次 recovery 是否新执行过 acquire。取消 takeover 使用 passive load,recoverHostedRuntimeTurn() 在该分支始终返回 acquiredRuntime=false,于是 session.runtimeLeaseHeld=false,取消 route 的 finally 永远跳过 release。原 owner 在崩溃前已经 acquire 的 Runtime Session 和 Workspace lease 并不会因一次 execution cancel 自动释放:真实 WorkspaceRuntimeTransport.cancel() 仅转发取消,ownership.release() 在 Runtime Session release 中才执行。即使物理取消已确认 settled,复现仍得到 cancel=200、release 调用 0 次、旧 promptId 继续持有 lease,下一 Runtime Session acquire 被 workspace_busy 拒绝。这条路径也不会再有未结算 Turn 触发清理。请在确认停止后显式释放原 Runtime Session,并让失败的释放可重试,不能用“本次有没有 acquire”判断原 Turn 是否需要清理。
There was a problem hiding this comment.
在 b4e9d71b 上核过:
cancel 路由在确认执行停止后显式 broker.release()(hosted-harness-session.ts:2126–:2136;broker 以原 promptId 构造,404 容忍),不再以「本次 recovery 有没有 acquire」决定是否释放;releaseRecoveredRuntime 也改成释放确认后才清 runtimeLeaseHeld(:1797)。
你那条「passive load → cancel → 旧 promptId 仍持 lease → 下一次 Runtime Session acquire 被 workspace_busy 拒绝」的复现,建议再跑一次确认。
fcd2dc2 to
13cbd97
Compare
Real-environment verification of
|
| Fixed on this head | F1. On fcd2dc2c the second Turn of any Hosted Session failed and a re-entered Turn never finished. On 13cbd974 both behave like main again. |
| Should be fixed here | F2. After a continuation takeover the Workspace stays locked for every other Session. F3. A Turn that was taken over and is then re-entered by the coordinator never finishes, and its answer is never published. |
| Follow-up or document | F4. A lost reply to the takeover load leaves the Turn stuck. F5. message.delta makes a journal unreadable for a Harness of an older build. F6. The new runner modes fail about 1.5% of the time on a token that starts with -. |
| Holds | Both failover modes. Exactly-once execution under every fault I injected. The trusted-actor header is inert unless configured. CI is green, including the six fault gates that were red in the first review. |
A candidate patch for F2 and F3 (4 files, +78 −1) is linked at the end. I ran it through the same scenarios.
What was built
- The head moved four times while I was testing:
eb06f7f9→1606fe07(rebase onto feat(managed-agent): Add durable remote Shell result delivery #12894) →5e81d704→fcd2dc2c(rebase onto feat(managed-agent): Implement private Hosted MCP runtime (H1) #12946) →13cbd974(review fixes). I builteb06f7f9,1606fe07,fcd2dc2cand13cbd974. - Everything below is from
13cbd974unless a head is named. The mutants and the quiet-host timings come from earlier heads and are marked. - A/B arms: base =
mainat3b18cfe5, the previous headfcd2dc2c, the current head, the current head plus the candidate. I did not rebuild the base arm at the current merge base7827a3ff. - Linux: Ubuntu 24.04.5, kernel 6.8, arm64, JDK 21.0.12, Node 24.18.1, private
mysqld8.0.46. macOS: arm64, JDK 26, Node 24.18.1, Homebrewmysqld. - The host was busy with unrelated builds for most of the run (load average 40 to 220 on 10 cores). Where that affected a result I say so.
1. The central claim holds
--inflight-failover,--continuation-failoverand--session-failoverpass on Linux: 5/5, 5/5 and 2/2 on13cbd974. On earlier heads they passed 10/10, 10/10 and 3/3, once on a quiet host and once under load. One earlier pass at load 60 to 170 had 7/10 and 2/3; each of those runs stopped onThe Managed Session writer grant is stale, the runner's 1 s writer lease expiring on a starved host, and--session-failover, which this PR does not change, failed the same way. CI on13cbd974ran both new modes green.- I re-implemented both scenarios in a separate driver with its own oracles, because the runner prints
promptReplayed: falseandphysicalToolExecutions: 1as literals and infers them from row counts and file bytes. An inotify watcher on the Workspace mount, a ledger of every Harness→Broker call and the file's inode and timestamps agree with the runner. In-flight executes once, on the replacement, under the originalexecutionCallId. Continuation executes nothing (no Broker call, file untouched), blanks the dead owner's chunk and publishes only the replacement's answer. - Removing pieces makes the modes fail, so they are not passing by accident (section 6).
- On the base commit both modes exit at once with the stale "not yet enabled" error, as the description says.
- F6. The runner generates the Broker token as base64url and passes it to
qwen serveas a separate argv word. 1.5% of such tokens start with-;qwen servethen prints its usage and exits 0, and the mode fails before the scenario starts. That is about 1.5% of runs of each new mode and about 3% of CI jobs.--managed-runtime-broker-token=<value>or a hex token avoids it. My own driver had copied the pattern and hit it once.
2. F1 is fixed
No crash is involved. One Session, one Spring, one Harness.
base 3b18cfe5 |
fcd2dc2c |
13cbd974 |
|
|---|---|---|---|
| Turn 2 of the same Session | completed, 0.5 s | turn.failed hosted_harness_unavailable after 31.7 s, model never called |
completed, 0.7 s |
| One dropped coordinator→Harness SSE connection during Turn 1 | completed 1.3 s after the cut | no terminal event, Turn stays RUNNING |
completed 1.4 s after the cut |
Linux with MySQL 8.0 agrees: Turn 2 failed after 32.1 s on the earlier rebase 1606fe07 and completes in 0.5 s on 13cbd974, and the cut Turn completes 1.3 s after the cut. This is the defect doudouOUC raised inline on fcd2dc2c from a route-level reproduction of the first row; the second row is the stream-reconnect half that the same comment anticipated. QwenHostedHarnessConnector.recoverManagedRuntime now reuses the cached attachment, and both rows are back to base behaviour.
3. Faults on the replacement owner
In every case the execution row stayed single and SETTLED at generation 1, the file was written once or not at all, and the Prompt was never sent to the model a second time. What differs is whether the Turn finishes.
- Takeover load lost before it reaches the Harness: completes on the retry.
- First
:startlost before it reaches the Broker: completes, 124 s later. The load waits out the 120 s observation window (2,137 status polls), the coordinator's request times out, and the next load starts the execution under the same id. The release that this head adds on that failure exit is refused by the Broker (409runtime_session_busy, the execution is still pending), so the lease is kept until the retry finishes the Turn. The defaultrequest-timeoutis 30 s; I did not run that combination. - F3. A taken-over Turn that is re-entered does not finish. Two triggers, same cause.
- Reply to
continuelost. The Harness ran the continuation exactly once. The coordinator retried, got the cached attachment together with its old recovery snapshot, and sentcontinueagain. The Turn had already settled, sosettledReplay(hosted-harness-session.ts:1388) answered with the current sequence, 30, where the admission had been at 18. The coordinator stored 30 as its cursor and opened the stream after the Turn's own events. It never saw the answer or the terminal event: the Turn staysRUNNINGand the public transcript has no text. - Event stream cut while the replacement is answering. The coordinator re-entered the Turn and retracted the output of the current epoch, which by then is the replacement's own first chunk. It sent
continueagain and got the watermark 31, where the admission had been at 15. The public transcript ends with two blank text events and no terminal event. - Two things combine. The connector hands out the recovery snapshot of the takeover load on every later call (
QwenHostedHarnessConnector.java:260), so a Turn whose continuation is already admitted is retracted and continued again. And thecontinueroute does not remember its admission the way the prompt route does, so a replay returns the wrong watermark. The candidate fixes both and both scenarios complete.
- Reply to
- F4. Reply to the takeover load lost: never finishes, on this head and with the candidate. The Harness has settled the execution and attached the Session, and refuses every later load with 409
hosted_session_already_attached. Nothing is executed twice, but the recovery snapshot cannot be fetched again. This needs an idempotent takeover load on the Harness side; I did not write that. Note that the load can legitimately take up to 120 s while the defaultrequest-timeoutis 30 s. - Owner dies in the first model round, text only: the Turn stays
RUNNING(409hosted_turn_recovery_requiredon every retry, no bound) and the dead owner's partial text stays public. This answers the question in the first review. The description scopes it out, and before this PR the Turn was stuck as well, but without visible partial text. - A public
agent.session.cancelon a parked Workspace Turn returns 409workspace_unavailable, so themanaged-runtime/cancelroute cannot be reached through the public API yet. It is covered by unit tests only. I did not test the cancellation changes made on this head.
4. F2: the Workspace stays locked after a continuation takeover
After the taken-over Turn ended I created a second Session on the same Workspace and asked for one write_file.
| lease after the Turn | second Session | |
|---|---|---|
in-flight takeover, 13cbd974 |
released | completed |
continuation takeover, 13cbd974 |
still held by the finished Turn | turn.failed hosted_turn_failed; Broker answers 409 workspace_busy |
| continuation takeover, candidate | released | completed |
When the tool had already settled before the crash, recoverHostedRuntimeTurn has nothing to drive, never calls acquire, and so runtimeLeaseHeld is false and the terminal route releases nothing. The lease row has no expiry. --continuation-failover does not look at the lease, so it passes. This is the continue-path sibling of the cancel-path lease finding that this head addresses. Releasing without re-attaching does not work on this path (the Broker answers 503 runtime_reconciliation_required; I tried that on the first head); the candidate re-attaches first.
5. Trusted-actor header, rollback, streaming cost
- Trusted-actor header. With the property unset every request behaves as on the base build: the header is ignored and a bound create is 401
actor_required. With it set, a granted actor gets 202, an actor without a grant or from another tenant gets 404workspace_not_found, a blank value gets 401, and unbound Sessions are unaffected. It replaces a missing principal and does not bypass the grant check. The matrix is identical before and after this head's change that lets an existing principal win; the packaged server has no other source of a principal, so I could not exercise that branch. - F5, rollback. A Workspace-bound Session whose journal contains
message.deltacannot be opened by a Harness of the base build: 503managed_session_open_failed. The control (an unbound Session, which writes no deltas) opens on the base build, and the bound Session opens on a PR-build Harness. The description says "Breaking changes / migration notes: none". That is true for readers of this build, not for a rollback or a mixed fleet during a rolling deploy. - Cost of durable deltas. One journal transaction and about 1.9 KB of journal per model chunk, regardless of chunk size. On a quiet host (first head) a Workspace-bound Turn took 2.4 s, 5.2 s and 15.7 s for 200, 1,000 and 3,000 chunks, about 5 ms per chunk; the same answers on an unbound Session took 0.3 to 0.4 s. On the loaded host the bound figures were two to three times higher. First visible text is as fast as before. This matches the tradeoff named in the description; with a database that is not on localhost the per-chunk round trip sets the ceiling on streaming speed.
6. What the tests pin
I compiled seven mutants into the bundle (one into the jar) on 1606fe07 and ran the five vitest files this PR changes, and both modes, against each. Under load a whole-file vitest run had one or two unrelated failures that pass alone, so a mutant counts as caught only when a test also fails alone.
- With the takeover switched off in the Harness, both modes fail. This is the A/B the first review asked for.
- The text de-duplication and the durable deltas of a live Turn are pinned only by
--continuation-failover, which runs on Linux only. Re-checked on13cbd974: its 127 unit tests still pass with either one removed. - Removing the coordinator's retraction is caught by
HarnessCoordinatorTestand by the continuation mode, where the public text becomesCONTINUATION_PARTIALCONTINUATION_TURN_RECOVERED. - Never releasing the recovered Runtime Session is caught by one unit test, for the in-flight case, and by neither mode. No test covers the continuation case, which is F2.
7. Earlier review points
- Six red fault gates, and the two modes not running in CI: resolved. CI on
13cbd974: Hosted ITs 13/13, Broker fault gates 44/44, both modes ran. - Second Turn on a healthy Session: fixed, section 2.
- Runtime lease not released when recovery bails out after
acquire: this head releases on those exits. In the lost-:startrun the Broker refused that release while the execution was pending, and the next attempt finished the Turn and released the lease. - Cancellation settling without confirmed stop, and cancellation not releasing the Runtime Session: changed on this head; not tested here, and not reachable from the public API (section 3).
- The projection scan, the empty
messageComplete(), and the actor filter overriding a principal: changed on this head. The Turn time grew linearly up to 3,000 chunks on the earlier heads.
Candidate patch
candidate.patch applies to 13cbd974:
QwenHostedHarnessConnector.recoverManagedRuntimehands out the cached recovery snapshot only until a continue or cancel has been admitted for it. One unit test; it fails on the unpatched head. (F3)- The
continueroute records its admission and replays it, as the prompt route does. (F3) recoverHostedRuntimeTurnre-attaches to the Runtime Session when nothing is left to drive, so the terminal route can release it. (F2)
With it, on 13cbd974: both F3 scenarios complete with one continuation and the whole answer public; the lease is released after a continuation takeover and the second Session runs; the scenarios without faults are unchanged; the three runner modes pass 3/3, 3/3 and 1/1; QwenHostedHarnessConnectorTest plus HarnessCoordinatorTest pass 22/22; the five changed vitest files pass (127 tests, 3 of them only when run alone on the loaded host). F4 is unchanged. I did not run the Hosted IT job against the candidate, so treat it as a starting point.
Not covered
- No real model. The scripted model is what makes the crash points exact.
- The Hosted IT job and the Broker fault gates were not replayed locally; I relied on CI for those.
- Multi-Runtime takeover, the Shell profile and Windows are out of scope of the PR and of this run.
- Locally, under load,
hosted-harness-session.test.tsfailed one test per whole-file run, a different one each time, and each passes alone. CI is green. I count that as host noise.
Driver scripts, raw results for all four heads and the patch are under pr13083/.
中文版
13cbd974 真实环境验证
维护者侧的验证,供合并决策参考。我在本地构建了分支并跑了打包后的整套栈:Spring fat jar、dist/cli.js Hosted Harness、带 durable 本地 Worker 的 Runtime Broker 和真实 MySQL,Linux(专用 VM 里的 Ubuntu 24.04 容器)和 macOS 各一套。模型是脚本化的本地 OpenAI 兼容服务,所以每个场景都可重复。
结论:接管是成立的,我没能让它把工具执行两次;上一轮评审发现的普通轮次回归,在这个 head 上已经修好。接管路径自身还剩两个缺陷。它们都没有让任何事情比 main 更糟,但修起来都很小,我建议合并前修掉。
| 本 head 已修 | F1。 在 fcd2dc2c 上,任何 Hosted 会话的第二个轮次都会失败,被重新进入的轮次永远结束不了。在 13cbd974 上两者都恢复到和 main 一样。 |
| 建议在本 PR 修 | F2。 continuation 接管完成后,Workspace 对其他所有会话一直锁着。F3。 被接管过的轮次一旦被协调器重新进入,就永远结束不了,回答也不会公开。 |
| 后续处理或写进文档 | F4。 接管 load 的应答丢失后轮次卡住。F5。 含 message.delta 的 journal,旧版本 Harness 打不开。F6。 两个新 runner 模式约有 1.5% 的概率因为 token 以 - 开头而失败。 |
| 成立 | 两个 failover 模式;我注入的所有故障下都保持恰好一次执行;trusted-actor 头不配置就不生效;CI 全绿,包括首轮评审里红的六个 fault gate。 |
F2 和 F3 的候选补丁(4 个文件,+78 −1)链接在文末,我用同一批场景验证过。
构建了什么
- 验证期间 head 动了四次:
eb06f7f9→1606fe07(变基到 feat(managed-agent): Add durable remote Shell result delivery #12894 之上)→5e81d704→fcd2dc2c(变基到 feat(managed-agent): Implement private Hosted MCP runtime (H1) #12946 之上)→13cbd974(评审修复)。我构建了eb06f7f9、1606fe07、fcd2dc2c和13cbd974。 - 下面的内容除非注明 head,都来自
13cbd974。变异体和空闲宿主机上的耗时来自更早的 head,已标注。 - A/B 各臂:base =
main的3b18cfe5、上一个 headfcd2dc2c、当前 head、当前 head 加候选补丁。base 臂没有在当前合并基7827a3ff上重建。 - Linux:Ubuntu 24.04.5,内核 6.8,arm64,JDK 21.0.12,Node 24.18.1,私有
mysqld8.0.46。macOS:arm64,JDK 26,Node 24.18.1,Homebrewmysqld。 - 大部分时间里宿主机被无关的构建占着(10 核,负载 40 到 220)。结果受影响的地方我都注明了。
1. 核心主张成立
(图 01)
--inflight-failover、--continuation-failover、--session-failover在 Linux 上通过:13cbd974上是 5/5、5/5、2/2。更早的 head 上是 10/10、10/10、3/3,空闲宿主机和高负载下各跑过一遍。另有一遍在负载 60 到 170 下是 7/10 和 2/3;失败的每一次都停在The Managed Session writer grant is stale,也就是 runner 的 1 秒 writer 租约在被饿死的宿主机上过期,本 PR 没有改动的--session-failover也以同样的方式失败。13cbd974的 CI 里两个新模式都是绿的。- 我另写了一个驱动把两个场景重新实现了一遍,并配了独立判据,因为 runner 输出里的
promptReplayed: false和physicalToolExecutions: 1是字面量,是从行数和文件内容推出来的。Workspace 挂载目录上的 inotify 监视、Harness→Broker 每次调用的台账、文件的 inode 和时间戳,三者与 runner 的结论一致。in-flight 只在替代方上按原executionCallId执行一次。continuation 什么都不执行(没有 Broker 调用,文件没动),死亡 owner 的那段文本被清空,只公开替代方的回答。 - 拿掉关键部分后两个模式会失败,说明它们不是碰巧通过(见第 6 节)。
- base 提交上两个模式如描述所说,立即以过期的「尚未启用」错误退出。
- F6。 runner 用 base64url 生成 Broker token,并把它作为单独的一个 argv 词传给
qwen serve。这样的 token 有 1.5% 以-开头;这时qwen serve打印用法并以 0 退出,模式在场景开始之前就失败。每个新模式约 1.5% 的运行、约 3% 的 CI 任务会碰到。改用--managed-runtime-broker-token=<值>或十六进制 token 即可避免。我自己的驱动照抄了这个写法,也中过一次。
2. F1 已修复
(图 02)
不涉及任何崩溃。一个会话,一个 Spring,一个 Harness。
base 3b18cfe5 |
fcd2dc2c |
13cbd974 |
|
|---|---|---|---|
| 同一会话的第 2 个轮次 | 完成,0.5 秒 | 31.7 秒后 turn.failed hosted_harness_unavailable,模型一次都没被调用 |
完成,0.7 秒 |
| 第 1 个轮次中协调器→Harness 的 SSE 连接断一次 | 断开后 1.3 秒完成 | 没有终态事件,轮次停在 RUNNING |
断开后 1.4 秒完成 |
Linux + MySQL 8.0 上结论一致:在更早一次变基的 1606fe07 上第 2 个轮次 32.1 秒后失败,在 13cbd974 上 0.5 秒完成;断流的轮次在断开后 1.3 秒完成。 这就是 doudouOUC 在 fcd2dc2c 的行内评论里用路由级复现指出的缺陷(第一行);第二行是同一条评论里预见到的「流重连」那一半。QwenHostedHarnessConnector.recoverManagedRuntime 现在复用缓存的 attachment,两行都回到了 base 的表现。
3. 在替代 owner 上注入故障
(图 03)
所有情况下执行记录始终只有一行、SETTLED、generation 1,文件写一次或没写,Prompt 从未第二次发给模型。差别在于轮次能否结束。
- 接管 load 在到达 Harness 前丢失:重试后完成。
- 第一次
:start在到达 Broker 前丢失:能完成,但要 124 秒。load 等满 120 秒观察窗口(2,137 次状态轮询),协调器请求超时,下一次 load 按同一个 id 启动执行。本 head 在这个失败出口上新加的 release 被 Broker 拒绝(409runtime_session_busy,执行还挂着),所以租约一直保留到重试把轮次跑完。request-timeout默认是 30 秒,这个组合我没有跑。 - F3。被接管过的轮次一旦被重新进入,就结束不了。 两种触发方式,同一个原因。
continue的应答丢失。Harness 侧 continuation 只跑了一次。协调器重试时拿到的是缓存的 attachment 连同旧的恢复快照,于是又发了一次continue。此时轮次已经结算,settledReplay(hosted-harness-session.ts:1388)返回的是当前序号 30,而准入时是 18。协调器把 30 存成游标,从该轮次自己的事件之后开始读流,既看不到回答也看不到终态事件:轮次停在RUNNING,公开记录里没有文本。- 替代方回答过程中事件流被切断。协调器重新进入轮次,回退了当前 epoch 的输出,而此时那正是替代方自己的第一段文本。它又发了一次
continue,拿到的水位是 31,而准入时是 15。公开记录以两个空文本事件结尾,没有终态事件。 - 这是两件事叠加的结果。connector 在之后每次调用时都把接管 load 的恢复快照再发一遍(
QwenHostedHarnessConnector.java:260),于是 continuation 已被准入的轮次会再被回退、再被 continue 一次。而continue路由不像 prompt 路由那样记住自己的 admission,重放时返回了错误的水位。候选补丁两处都修,两个场景都能完成。
- F4。 接管 load 的应答丢失:本 head 和候选补丁上都结束不了。Harness 已经结算了执行并挂载了会话,之后每次 load 都以 409
hosted_session_already_attached拒绝。没有任何东西被执行两次,但恢复快照再也取不回来。这需要 Harness 侧把接管 load 做成幂等,我没有写。另外要注意,这个 load 正常情况下最长可以跑 120 秒,而request-timeout默认是 30 秒。 - owner 死在第一个模型轮次、只有文本:轮次停在
RUNNING(每次重试都是 409hosted_turn_recovery_required,没有上限),死亡 owner 的半截文本留在公开记录里。这回答了首轮评审里的那个问题。描述已把它划在范围外,本 PR 之前轮次同样会卡住,只是那时没有可见的半截文本。 - 对挂起的 Workspace 轮次发公开的
agent.session.cancel返回 409workspace_unavailable,所以managed-runtime/cancel路由目前无法经公开 API 到达,只有单测覆盖。本 head 上取消相关的改动我没有测。
4. F2:continuation 接管后 Workspace 一直锁着
(图 04)
被接管的轮次结束后,我在同一个 Workspace 上新建第二个会话,让它执行一次 write_file。
| 轮次结束后的租约 | 第二个会话 | |
|---|---|---|
in-flight 接管,13cbd974 |
已释放 | 完成 |
continuation 接管,13cbd974 |
仍被已结束的轮次持有 | turn.failed hosted_turn_failed;Broker 返回 409 workspace_busy |
| continuation 接管,候选 | 已释放 | 完成 |
工具在崩溃前已经结算时,recoverHostedRuntimeTurn 没有东西要驱动,不会调用 acquire,于是 runtimeLeaseHeld 为 false,终态路由什么也不释放。租约行没有过期时间。--continuation-failover 不检查租约,所以照样通过。本 head 处理了 cancel 路径上的租约问题,这是它在 continue 路径上的同类问题。在这条路径上不重新挂载直接 release 行不通(Broker 返回 503 runtime_reconciliation_required,这是我在第一个 head 上试的);候选补丁先重新挂载再释放。
5. trusted-actor 头、回滚、流式成本
(图 05)
- trusted-actor 头。 属性不配置时,所有请求的表现与 base 构建一致:头被忽略,绑定 Workspace 的创建返回 401
actor_required。配置后,有授权的 actor 得到 202,没有授权或来自其他租户的 actor 得到 404workspace_not_found,空值得到 401,未绑定的会话不受影响。它替代的是缺失的 principal,没有绕过授权检查。本 head 改成「已有 principal 优先」之后矩阵没有变化;打包的服务端没有其他 principal 来源,那个分支我测不到。 - F5,回滚。 journal 里含
message.delta的 Workspace 绑定会话,base 构建的 Harness 打不开:503managed_session_open_failed。对照组(未绑定会话,不写 delta)在 base 构建上能打开,绑定会话在 PR 构建的 Harness 上也能打开。描述里写的是「兼容/迁移说明:无」,这对本构建的读取方成立,对回滚或滚动发布期间的混合机群不成立。 - 持久 delta 的成本。 每个模型 chunk 一次 journal 事务、约 1.9 KB journal,与 chunk 大小无关。宿主机空闲时(第一个 head),Workspace 绑定的轮次在 200、1,000、3,000 个 chunk 下分别用了 2.4、5.2、15.7 秒,约每 chunk 5 毫秒;同样的回答在未绑定会话上是 0.3 到 0.4 秒。高负载的宿主机上,绑定会话的数字是这个的两到三倍。首段可见文本和以前一样快。这与描述里写的取舍一致;数据库不在本机时,每个 chunk 的往返时间就是流式速度的上限。
6. 测试钉住了什么
我在 1606fe07 上把七个变异体编进 bundle(其中一个编进 jar),对每个都跑了本 PR 改动的五个 vitest 文件和两个模式。高负载下 vitest 整文件运行会有一两个无关用例失败、单独跑又通过,所以只有单独跑也失败才算被捕获。
(图 06)
- 在 Harness 里关掉接管后,两个模式都失败。这就是首轮评审要的 A/B。
- 文本去重和活跃轮次的持久 delta,只有
--continuation-failover能钉住,而这个模式只在 Linux 上跑。在13cbd974上复查过:去掉任意一个,它的 127 个单测照样全过。 - 去掉协调器的回退,会被
HarnessCoordinatorTest和 continuation 模式抓到,公开文本变成CONTINUATION_PARTIALCONTINUATION_TURN_RECOVERED。 - 从不释放恢复得到的 Runtime Session,会被一个针对 in-flight 情况的单测抓到,两个模式都抓不到。continuation 情况没有任何测试覆盖,这就是 F2。
7. 先前评审的各点
- 六个红的 fault gate,以及两个模式没在 CI 里跑:已解决。
13cbd974的 CI:Hosted IT 13/13,Broker fault gate 44/44,两个模式都跑了。 - 健康会话的第二个轮次:已修,见第 2 节。
- 恢复流程在
acquire之后放弃时不释放 Runtime 租约:本 head 在这些出口上做了释放。在:start丢失的那次运行里,执行还挂着时 Broker 拒绝了这次释放,下一次尝试把轮次跑完后释放了租约。 - 取消在未确认停止时就结算、取消不释放 Runtime Session:本 head 已改;这里没有测,公开 API 也到不了(见第 3 节)。
- 投影扫描、空的
messageComplete()、actor 过滤器覆盖已有 principal:本 head 已改。在更早的 head 上,到 3,000 个 chunk 为止轮次耗时是线性增长的。
候选补丁
candidate.patch(链接见英文部分)可直接应用到 13cbd974:
QwenHostedHarnessConnector.recoverManagedRuntime只在 continue 或 cancel 被准入之前发出缓存的恢复快照。附一个单测,在未打补丁的 head 上失败。(F3)continue路由记录自己的 admission 并重放,和 prompt 路由一样。(F3)recoverHostedRuntimeTurn在没有执行需要驱动时重新挂载 Runtime Session,终态路由才能释放它。(F2)
打上之后,在 13cbd974 上:F3 的两个场景都能完成,continuation 只跑一次,完整回答公开;continuation 接管后租约被释放,第二个会话能跑;无故障的场景没有变化;runner 的三个模式 3/3、3/3、1/1;QwenHostedHarnessConnectorTest 加 HarnessCoordinatorTest 22/22;五个改动过的 vitest 文件通过(127 个用例,其中 3 个在高负载宿主机上要单独跑才过)。F4 没有变化。 我没有用候选补丁跑 Hosted IT 任务,请把它当作起点。
没有覆盖的部分
- 没用真实模型。精确的崩溃点要靠脚本化模型。
- Hosted IT 任务和 Broker fault gate 没有在本地重放,这部分依赖 CI。
- 多 Runtime 接管、Shell profile、Windows 不在本 PR 范围内,也不在这次验证范围内。
- 本地高负载下,
hosted-harness-session.test.ts整文件跑时每次失败一个用例,每次不是同一个,单独跑都通过。CI 是绿的。我把它算作宿主机噪声。
驱动脚本、四个 head 的原始结果和补丁都在 pr13083/ 目录下(链接见英文部分)。
13cbd97 to
777774c
Compare
777774c to
51e8e27
Compare
wenshao
left a comment
There was a problem hiding this comment.
Not reviewed: the executable-script lint — qwen review script-lint produced no report.
Not explored to full depth (tool budget reached): "agent reverse-audit (round 1)": did not run the packages/cli suite or the managed-agent E2E to capture the next prompt's outgoing request payload; the orphan-functionCall outcome rests on th…; "agent reverse-audit (round 4)": did not execute the reclaim→409→ recoverHostedRuntimeTurn chain or run packages/cli /Java tests — the review worktree has no built dist/ for packages/core …; "agent reverse-audit (round 5)": not resolved — whether any Java caller submits a *new* prompt while a takeover load holds an unreleased Runtime lease ( runtimeLeaseHeld ); the daemon's /promp…; "agent reverse-audit (round 2)": did not read HarnessEventProjector.java / DaemonSessionClient (Java consumers of the agent_message_chunk and managed_journal_event frames) to confirm th…; "agent reverse-audit (round 2)": did not verify whether the Java runtime broker expires a held Runtime Session on its own (I checked only the JS side — no TTL in HostedWorkspaceBroker / broker…, and 4 more.
中文说明
未审查(原文为英文):the executable-script lint — qwen review script-lint produced no report.
未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 1)":did not run the packages/cli suite or the managed-agent E2E to capture the next prompt's outgoing request payload; the orphan-functionCall outcome rests on th…;"agent reverse-audit (round 4)":did not execute the reclaim→409→ recoverHostedRuntimeTurn chain or run packages/cli /Java tests — the review worktree has no built dist/ for packages/core …;"agent reverse-audit (round 5)":not resolved — whether any Java caller submits a *new* prompt while a takeover load holds an unreleased Runtime lease ( runtimeLeaseHeld ); the daemon's /promp…;"agent reverse-audit (round 2)":did not read HarnessEventProjector.java / DaemonSessionClient (Java consumers of the agent_message_chunk and managed_journal_event frames) to confirm th…;"agent reverse-audit (round 2)":did not verify whether the Java runtime broker expires a held Runtime Session on its own (I checked only the JS side — no TTL in HostedWorkspaceBroker / broker…,另有 4 条。
— DeepSeek/deepseek-v4.1-flash via Qwen Code /review (v0.24.7)
| outcomeSource: 'text', | ||
| }, | ||
| }, | ||
| 'message.delta': { |
There was a problem hiding this comment.
[Critical] R1-1: [fails-closed] [new-surface] The new message.delta event declares its model-text field as text: 'text', and the text field kind is validated by boundedString, which rejects every C0 control character — including \n, \t and \r. Raw model output is exactly the class of value that contains them, so the first streamed chunk carrying a line break fails journal validation and aborts the turn this feature exists to make durable: the chunks already published before the newline stay in the journal and in the public projection, and the user sees a truncated answer plus an errored turn.
Witness:
plain: ACCEPTED
newline: THREW payload.text must not contain control characters.
tab: THREW payload.text must not contain control characters.
crlf: THREW payload.text must not contain control characters.
emoji: ACCEPTED
Give the payload a field kind that admits free-form text (a rawText kind validated only for non-empty plus MANAGED_SESSION_LIMITS.maxTextBytes) and use it for this schema entry. Do not fix it by sanitizing at the producer: the delta text is emitted verbatim into the public agent_message_chunk projection, so stripping control characters would mangle the user-visible answer into a single line.
Importantly, boundedString is shared with assertManagedSessionStableId and the id/idOrNull/ids kinds, which require valid UTF-8 and NFC — relaxing boundedString itself would let identifiers carry control characters, so the new kind has to be a separate one. A test that streams 'line one\nline two' (and a chunk straddling the newline at the 3072-byte split boundary) and asserts the committed payload texts rejoin to the input is red under the current schema and green after the fix.
中文说明
[Critical] 新增的 message.delta 事件把模型文本字段声明为 text: 'text',而 text 这一字段类型由 boundedString 校验,它会拒绝所有 C0 控制字符 —— 包括 \n、\t、\r。原始模型输出正是含有这些字符的一类值,因此第一条带换行的流式分片就会让 journal 校验失败,并让这个特性本要保证持久化的轮次直接出错:换行之前已经发布的分片留在 journal 与公开投影里,用户看到被截断的回答和一个报错的轮次。
Witness:
plain: ACCEPTED
newline: THREW payload.text must not contain control characters.
tab: THREW payload.text must not contain control characters.
crlf: THREW payload.text must not contain control characters.
emoji: ACCEPTED
请为该 payload 引入一个接受自由文本的字段类型(例如只校验非空与 MANAGED_SESSION_LIMITS.maxTextBytes 的 rawText),并在此 schema 条目中使用它。不要在生产端做清洗来修:delta 文本会原样进入公开的 agent_message_chunk 投影,剥掉控制字符会把用户可见的回答压成一行。
需要注意:boundedString 与 assertManagedSessionStableId 以及 id/idOrNull/ids 类型共用,后者要求合法 UTF-8 与 NFC —— 放宽 boundedString 本身会让标识符带上控制字符,所以必须新增一个独立的字段类型。用例:流式输入 'line one\nline two'(以及一条在 3072 字节切分边界上跨换行的分片),断言提交后的各分片文本拼回原文 —— 当前 schema 下为红,修复后为绿。
— DeepSeek/deepseek-v4.1-flash via Qwen Code /review (v0.24.7)
| return; | ||
| } | ||
| recovery = recovered.report; | ||
| session.runtimeLeaseHeld = recovered.acquiredRuntime; |
There was a problem hiding this comment.
[Critical] R1-2: [fails-closed] [new-surface] The Runtime Session a takeover load acquires is handed back only from the continue and cancel routes' finally blocks, so several other exits leave the Workspace lease pinned with no owner. A drive takeover sets session.runtimeLeaseHeld = true, and these exits never reach releaseRecoveredRuntime: the load route's incompletePublication refusal and its second stores.assertWritable() failure (the Session is closed and never registered, so no later route can release it); close(), reached by POST /session/:id/detach and DELETE /session/:id, and POST /session/:id/cancel; the continue and cancel routes' early returns (the admissions replay, settledReplay's 200, and the identity-mismatch / turn_active / recovery_required 409s); and recoverHostedRuntimeTurn's post-loop harnessRunAuthorization() rejection, whose release guard covers only the non-runnable return. The Java lease row has no TTL, so the Workspace stays pinned and the next Runtime Session acquire is refused workspace_busy. Separately, releaseRecoveredRuntime clears runtimeLeaseHeld before the async release, so a release that fails is logged and then forgotten.
Witness:
load=200 acquireCalls=1 releaseAfterDelete=0 (DELETE returned 204, release never attempted)
load=409 code=hosted_turn_recovery_required acquireCalls=1 releaseCalls=0
with the release added at those exits: releaseCalls=1
Make the handback unconditional rather than per-exit: keep the lease's promptId on the session alongside the flag and release it in close() and in POST /session/:id/cancel, release before the continue/cancel early returns once the flag is set, and give the recovery's post-acquire span a single release-on-failure guarantee. Clear runtimeLeaseHeld only after the release settles, so an unconfirmed release stays owed and the next terminal route retries it.
The release must go through the existing identity triple (brokerOptions, session.managed.authority.sessionHeader.sessionKey, promptId) and stay safe to call twice — HostedWorkspaceToolTurn.finish() already releases the broker it acquired when the turn has no MCP session, and the cancel route tolerates only a 404 from a redundant release. A test that loads a parked Session with driveRuntimeRecovery: true and then POSTs /session/:id/detach, asserting release was called with the recovered promptId, is red today.
中文说明
[Critical] 接管 load 取得的 Runtime Session 只在 continue 与 cancel 路由的 finally 中归还,因此其他若干出口会让 Workspace 租约被无主持有。drive 接管会置 session.runtimeLeaseHeld = true,而以下出口都到不了 releaseRecoveredRuntime:load 路由的 incompletePublication 拒绝与其第二次 stores.assertWritable() 失败(会话被 close 且从未注册,之后的任何路由都无法释放它);close()(POST /session/:id/detach 与 DELETE /session/:id)以及 POST /session/:id/cancel;continue/cancel 路由的提前返回(admission 重放、settledReplay 的 200、以及 identity mismatch / turn_active / recovery_required 三个 409);以及 recoverHostedRuntimeTurn 循环后 harnessRunAuthorization() 的 reject —— 它的释放守卫只覆盖 non-runnable 的 return。Java 的租约行没有 TTL,于是 Workspace 一直被占用,下一次 Runtime Session acquire 会被 workspace_busy 拒绝。另外,releaseRecoveredRuntime 在异步 release 之前就把 runtimeLeaseHeld 清零,释放失败只打一行日志便被遗忘。
Witness:
load=200 acquireCalls=1 releaseAfterDelete=0 (DELETE 返回 204,从未尝试 release)
load=409 code=hosted_turn_recovery_required acquireCalls=1 releaseCalls=0
在这些出口补上 release 后:releaseCalls=1
请把归还做成无条件而不是逐出口处理:把租约的 promptId 与标志一起存在会话上,在 close() 与 POST /session/:id/cancel 中释放;标志置位后,在 continue/cancel 的提前返回前释放;并给恢复流程 acquire 之后的区间一个统一的失败即释放保证。只有在释放确认后才清 runtimeLeaseHeld,让未确认的释放保持「欠账」,由下一个终态路由重试。
释放必须沿用现有身份三元组(brokerOptions、session.managed.authority.sessionHeader.sessionKey、promptId),且必须可重复调用 —— HostedWorkspaceToolTurn.finish() 在没有 MCP 会话时已经释放它取得的 broker,而 cancel 路由只容忍重复释放返回 404。用例:以 driveRuntimeRecovery: true 加载停驻会话后 POST /session/:id/detach,断言以恢复出的 promptId 调用了 release —— 当前为红。
— DeepSeek/deepseek-v4.1-flash via Qwen Code /review (v0.24.7)
There was a problem hiding this comment.
Re-checked on b4e9d71b. Most of this is closed by the unconditional-handback work — one window is still open.
Now covered: the load route's incompletePublication and assertWritable refusals release before closing (hosted-harness-session.ts:1251, :1260); close() releases before deleting the Session, which is how POST /session/:id/detach and DELETE /session/:id reach it (:2506); the continue/cancel early returns (admission replay, settledReplay's 200, and the 409s) release (:1839–:1867, :2050–:2090); and releaseRecoveredRuntime clears the flag only once the release settles (:1797), so an unconfirmed release stays owed.
Still open — recoverHostedRuntimeTurn has no single failure guarantee across its post-acquire span. The try at hosted-runtime-recovery.ts:263 starts after acquiredRuntime = true (:262), and its catch (:358) covers only the drive loop. Two exits sit outside it:
- the re-attach branch —
await broker.acquire()+acquiredRuntime = trueat:371–:376— has no guard at all; const finalAuthorization = await session.authority.harnessRunAuthorization()at:378: the release guard at:379covers the non-runnable return, not a rejection.
harnessRunAuthorization() rethrows anything that is not a ManagedSessionRecordError (managed-session-authority.ts:1212–:1221), and it reads the state blob through readCheckpointState() → store.read(stateRef) (:1177–:1187), so a failing resource read on that line throws out of the function with the lease held and acquiredRuntime never returned. The caller only learns about the lease from the returned report (hosted-harness-session.ts:1152–:1153), and its catch does managed.close() (:1147) — which is releaseActivation() + authority.close() (managed-session-assembly.ts:230–:235), not a Broker release. So runtimeLeaseHeld is never set, and no later route can hand this one back.
Wrapping the whole span from broker.acquire() (:261, :375) to the final return in one release-on-failure guard closes it.
| } | ||
| response.response = { | ||
| ...response.response, | ||
| executionStatus: 'cancelled', |
There was a problem hiding this comment.
[Critical] R1-3: [certifies-falsely] [new-surface] settleParkedTurnCancelled publishes the cancelled outcome and resolves the checkpoint, but — unlike both other settlement paths — it writes no tool_result record. After a recovered cancellation the next prompt's history is built from the settled turns, so the parked round's assistant functionCall enters the request with no matching functionResponse: providers that require every tool call to be answered reject the request, and where it is accepted the model proceeds believing its call is still outstanding.
Witness:
brokerSettled-success run: cancel=200 toolResultRecords=0
nextHistory functionCalls=["call-1"] functionResponses=[]
with the record written: toolResultRecords=1 functionResponses=["call-1"]
(drive-path control: the sibling recovery path journals the same record → toolResultRecords=1)
Write the matching tool_result ChatRecord for each settled item before resolving, mirroring recoverHostedRuntimeTurn: the same daemonPromptId, parentUuid: item.modelMessageId, message: { role: 'user', parts } from the parts already computed, with the same already-journaled dedup and the same oversized-body fallback.
The write must be deduplicated against results already journaled for the same functionCallId — the same write-then-resolve crash window exists here, and a second tool_result for one functionCall is as malformed as none. A test that parks a tool turn, drives /managed-runtime/cancel, and asserts the projection holds one tool_result whose functionResponse.id is the parked functionCallId is red without the fix.
中文说明
[Critical] settleParkedTurnCancelled 会发布 cancelled outcome 并 resolve checkpoint,但与另外两条结算路径不同 —— 它不写 tool_result 记录。恢复式取消之后,下一轮的 history 由已结算轮次构成,于是停驻轮次的 assistant functionCall 会带着空 functionResponse 进入请求:要求所有工具调用都被应答的 provider 会直接拒绝该请求,而在接受的 provider 上模型会以为这次调用仍未完成。
Witness:
brokerSettled-success 运行: cancel=200 toolResultRecords=0
nextHistory functionCalls=["call-1"] functionResponses=[]
补上记录后: toolResultRecords=1 functionResponses=["call-1"]
(drive 侧对照: 同类恢复路径会写下同一条记录 → toolResultRecords=1)
请在 resolve 之前,为每个已结算条目写入对应的 tool_result ChatRecord,照 recoverHostedRuntimeTurn 的方式:相同的 daemonPromptId、parentUuid: item.modelMessageId、message: { role: 'user', parts }(用已经算出的 parts),并沿用同样的已写入去重与超长兜底。
该写入必须按同一个 functionCallId 与已落盘结果去重 —— 这里同样存在「先写后 resolve」的崩溃窗口,同一个 functionCall 写两条 tool_result 与一条都不写一样是畸形。用例:停驻一个工具轮次、驱动 /managed-runtime/cancel,断言投影中存在一条 functionResponse.id 等于停驻 functionCallId 的 tool_result —— 未修复时为红。
— DeepSeek/deepseek-v4.1-flash via Qwen Code /review (v0.24.7)
| if ( | ||
| cause instanceof HostedWorkspaceBrokerRejection && | ||
| cause.status === 404 && | ||
| cause.code === 'runtime_execution_not_found' |
There was a problem hiding this comment.
[Critical] R1-4: [fails-closed] [new-surface] The new read-only status() maps only the Broker's 404 to undefined and rethrows its other definitive answer — 409 runtime_broker_execution_unknown, which the Broker answers for a record in state UNKNOWN or ABANDONED. That is exactly the answer the recovery report's outcome: unknown exists to carry, so the state the feature is built to report can no longer be reported: the passive recovery load answers the retryable 409 hosted_turn_recovery_required for a terminal Broker state, so every retry re-runs the same read and the Turn never settles; and the cancel route aborts for an execution that is already terminal and needs no cancel. The same terminal answer also reaches the continuation path through broker.execute without waitForUnknown.
Witness:
prepared: resolved -> {"state":"prepared"}
missing: resolved -> undefined # 404 runtime_execution_not_found
abandoned: REJECTED -> status=409 code=runtime_broker_execution_unknown
unknown: REJECTED -> status=409 code=runtime_broker_execution_unknown
with the 409 arm added: both resolve -> undefined
Map 409 runtime_broker_execution_unknown to undefined, which is the encoding both callers already handle: the passive branch maps undefined to {outcome: 'unknown'} with no status, and stopParkedRuntimeExecutions treats it as already stopped. Keep the 404 branch and the rethrow for every other status/code.
The fix must return no state rather than a state named unknown — the Java client rejects a report whose execution carries a status alongside outcome: unknown, and accepts only prepared|executing|cancel_requested|settled. A test whose fixture answers {code: 409, body: {code: 'runtime_broker_execution_unknown', details: {terminal: true}}} and asserts status() resolves undefined is red today.
中文说明
[Critical] 新增的只读 status() 只把 Broker 的 404 映射为 undefined,而把它的另一个确定答案 —— 409 runtime_broker_execution_unknown(Broker 对状态为 UNKNOWN 或 ABANDONED 的记录返回)—— 直接抛出。而这正是恢复报告里 outcome: unknown 要承载的答案,于是这个特性本要上报的状态再也报不出来:passive 恢复 load 对一个终态的 Broker 状态回答了可重试的 409 hosted_turn_recovery_required,每次重试都重复同一次读取、轮次永不结算;cancel 路由也会为一个已终态、根本不需要取消的执行中断。同一个终态答案还会经未传 waitForUnknown 的 broker.execute 到达续跑路径。
Witness:
prepared: resolved -> {"state":"prepared"}
missing: resolved -> undefined # 404 runtime_execution_not_found
abandoned: REJECTED -> status=409 code=runtime_broker_execution_unknown
unknown: REJECTED -> status=409 code=runtime_broker_execution_unknown
补上 409 分支后:两者都 resolve -> undefined
请把 409 runtime_broker_execution_unknown 映射为 undefined —— 这正是两个调用方都已支持的编码:passive 分支把 undefined 映射为不带 status 的 {outcome: 'unknown'},stopParkedRuntimeExecutions 把它当作已停止。404 分支与其他 status/code 的抛出保持不变。
修复必须返回没有状态而不是名为 unknown 的状态 —— Java 客户端会拒绝「outcome: unknown 同时带 status」的报告,且只接受 prepared|executing|cancel_requested|settled。用例:fixture 返回 {code: 409, body: {code: 'runtime_broker_execution_unknown', details: {terminal: true}}},断言 status() resolve 为 undefined —— 当前为红。
— DeepSeek/deepseek-v4.1-flash via Qwen Code /review (v0.24.7)
| // The original owner's Runtime Session keeps the Workspace lease | ||
| // pinned; a passive takeover never re-acquired it, so release it | ||
| // here once the executions are confirmed stopped. | ||
| await broker.release().catch((cause: unknown) => { |
There was a problem hiding this comment.
[Critical] R1-5: [certifies-falsely] [new-surface] Both cancel-path helpers return silently when the checkpoint's authorization is not runnable — settleParkedTurnCancelled and stopParkedRuntimeExecutions each open with if (authorization.status !== 'runnable') return; — and the route calls them as Promise<void> and never inspects a result. So the route releases the Runtime Session, appends the cancelled terminal record and answers 200 {accepted: true} for a cancellation it never performed. The load path refuses in the same condition, which is the shape the route's own comment asks for.
Witness:
cancelStatus=200 accepted:true brokerCancelCalls=0 brokerReleaseCalls=1
terminalRecords=[turn_complete stopReason=cancelled]
with the refusal added: cancelStatus=409 hosted_turn_recovery_required brokerReleaseCalls=0 terminalRecords=[]
matchesRecovery reads checkpoint metadata, while harnessRunAuthorization() separately reads and parses the state blob and returns blocked when that read or parse fails — and session.blocked is not set by that condition. So for a parked Turn whose checkpoint body became unreadable after a recovery report was issued, the durable outcome says cancelled for executions nothing stopped, the Workspace lease is given up while they may still be running, and the checkpoint still reads await_runtime with in-progress items — so every later load or continue is refused hosted_turn_recovery_required.
Mirror the load path: check harnessRunAuthorization() in the cancel route before driving and answer 409 hosted_turn_recovery_required, or change both helpers to return a discriminated result and refuse on 'not runnable'. The new refusal must not replace the existing unconfirmable-stop status — 'refuses to settle a cancellation the Broker never confirmed' pins 503 for an unreadable Broker status, so 409 belongs to the non-runnable-authorization case only.
中文说明
[Critical] 当 checkpoint 授权不是 runnable 时,两个取消侧辅助函数都静默返回 —— settleParkedTurnCancelled 与 stopParkedRuntimeExecutions 都以 if (authorization.status !== 'runnable') return; 开头 —— 而路由把它们当 Promise<void> 调用,从不检查结果。于是路由会释放 Runtime Session、写入 cancelled 终态记录并对一次根本没有执行的取消回答 200 {accepted: true}。load 路径在同一条件下是拒绝的,这正是该路由自己注释所要求的形态。
Witness:
cancelStatus=200 accepted:true brokerCancelCalls=0 brokerReleaseCalls=1
terminalRecords=[turn_complete stopReason=cancelled]
加上拒绝后:cancelStatus=409 hosted_turn_recovery_required brokerReleaseCalls=0 terminalRecords=[]
matchesRecovery 读的是 checkpoint 元数据,而 harnessRunAuthorization() 另外读取并解析 state blob,读或解析失败时返回 blocked —— 且该条件不会置 session.blocked。因此对「在发出恢复报告之后 checkpoint body 变得不可读」的停驻轮次:持久结果声称未停止的执行已 cancelled,在它们可能仍在运行时就让出了 Workspace 租约,且 checkpoint 仍是带 in-progress 条目的 await_runtime —— 之后任何 load 或 continue 都会被 hosted_turn_recovery_required 拒绝。
请照 load 路径:在 cancel 路由驱动之前检查 harnessRunAuthorization() 并回答 409 hosted_turn_recovery_required,或让两个辅助函数返回可判别结果并在「非 runnable」时拒绝。新拒绝不能取代既有的「停止未确认」状态 —— refuses to settle a cancellation the Broker never confirmed 用例把不可读 Broker 状态钉在 503,所以 409 只属于授权非 runnable 的情形。
— DeepSeek/deepseek-v4.1-flash via Qwen Code /review (v0.24.7)
| this.messageId ??= randomUUID(); | ||
| let rest = text; | ||
| while (rest.length > 0) { | ||
| let end = Math.min(rest.length, DELTA_MAX_BYTES); |
There was a problem hiding this comment.
[Suggestion] R1-27: The byte-limit scan decrements one character at a time and re-measures the whole prefix on each step, so a single large multi-byte delta costs thousands of synchronous Buffer.byteLength scans on the daemon's event loop. Math.min(rest.length, DELTA_MAX_BYTES) caps the slice at 3072 characters, so up to 9216 bytes must be walked back to 3072 — about 2048 re-measurements of a ~3 KB substring, with no await inside the loop.
Witness:
real class, instrumented Buffer.byteLength count:
CJK 4 000 chars → 4 932 scans 2.9 ms
CJK 20 000 chars → 36 948 scans 20.0 ms
CJK 100 000 chars → 197 026 scans 106.9 ms (98 chunk commits)
ASCII 100 000 → 33 scans 0.1 ms (never enters the inner loop)
with a binary search for the byte-limited end:
CJK 100 000 → 1 261 scans 0.7 ms — and byte-identical chunk boundaries
across five inputs incl. the emoji/surrogate case (roundtrip=true)
Compute the byte-limited end in O(log n) scans instead of O(n²) — binary-search the largest end whose UTF-8 length fits, or walk the string once accumulating per-character byte lengths — keeping the byte cap and the surrogate rule below it unchanged. The one-line change measured above costs ~150× less and changes no committed byte.
DELTA_MAX_BYTES = 3072 exists because the journal rejects a text field over MANAGED_SESSION_LIMITS.maxTextBytes = 4096, and the surrogate-pair guard immediately below must keep holding, since a split pair would corrupt the reassembled text. The existing split test uses ASCII only, so it never enters the inner loop — extend it with a multi-byte fixture (e.g. '好'.repeat(2000)) asserting each emitted chunk is within the byte cap and the chunks rejoin to the input.
中文说明
[Suggestion] 这段字节上限扫描每次只减一个字符、并对每次结果重新测量整个前缀,因此单个较大的多字节 delta 会在守护进程事件循环上产生数千次同步 Buffer.byteLength 调用。Math.min(rest.length, DELTA_MAX_BYTES) 按字符把切片限制在 3072,于是最多有 9216 字节需要回退到 3072 —— 约 2048 次对约 3 KB 子串的重复测量,而循环内没有任何 await。
Witness:
真实类,插桩统计 Buffer.byteLength 次数:
CJK 4 000 字符 → 4 932 次 2.9 ms
CJK 20 000 字符 → 36 948 次 20.0 ms
CJK 100 000 字符 → 197 026 次 106.9 ms(98 次分片提交)
ASCII 100 000 → 33 次 0.1 ms(根本不进入内层循环)
改用二分查找确定字节上限后:
CJK 100 000 → 1 261 次 0.7 ms —— 且分片边界逐字节相同
覆盖五种输入(含 emoji/代理对场景,roundtrip=true)
请以 O(log n) 次扫描而不是 O(n²) 计算字节上限 —— 对「UTF-8 长度仍在上限内的最大 end」做二分查找,或单次遍历累加每个字符的字节长度 —— 并保持字节上限与其下的代理对规则不变。上面实测的这处一行改动代价降低约 150 倍,且不改变任何已提交字节。
DELTA_MAX_BYTES = 3072 的存在是因为 journal 会拒绝超过 MANAGED_SESSION_LIMITS.maxTextBytes = 4096 的 text 字段,而紧随其后的代理对守卫必须继续成立,否则被拆开的代理对会破坏重组后的文本。现有切分测试只使用 ASCII,因此从不进入内层循环 —— 请补一个多字节 fixture(例如 '好'.repeat(2000)),断言每个产出分片都在字节上限内且各分片能拼回输入。
— DeepSeek/deepseek-v4.1-flash via Qwen Code /review (v0.24.7)
| ); | ||
| }); | ||
|
|
||
| it('refuses to settle a cancellation the Broker never confirmed', async () => { |
There was a problem hiding this comment.
[Suggestion] R1-28: The cancel route's recovery-identity refusal has no witness, while its continue mirror does. The cancel route refuses a mismatched identity before it touches the Broker and is reachable in exactly the parked state the suite builds, yet every managed-runtime/cancel request in the suite sends the identity the load just returned; the continue route, by contrast, has a three-case mismatch witness. If that guard is dropped or reordered — e.g. moved after stopParkedRuntimeExecutions — the suite still passes, and a coordinator mixing a stale checkpoint from another recovery could cancel the wrong parked execution.
Witness:
mutation: the cancel route's `if (!matchesRecovery(...)) { … }` guard deleted → the takeover block 8 passed (79 skipped)
the sibling continue route already pins the same shape (400 missing ids, 409 wrong checkpointId/activationId, 404 unknown session)
Add one case that sends a cancel with a stale checkpointId/activationId (the ids from some other epoch) and asserts 409 hosted_recovery_identity_mismatch with no turn_complete for that prompt. The new case must go red when the guard is deleted.
中文说明
[Suggestion] cancel 路由的恢复身份拒绝没有见证,而它的 continue 对照有。cancel 路由在接触 Broker 之前就拒绝身份不匹配,并且在套件构造的那个停驻状态里确实可达,然而套件中每个 managed-runtime/cancel 请求都发送 load 刚返回的身份;作为对照,continue 路由有三条不匹配用例的见证。若该守卫被删除或重排 —— 例如移到 stopParkedRuntimeExecutions 之后 —— 套件仍然通过,而一个混用了其他恢复流程陈旧 checkpoint 的协调器可能取消错误的停驻执行。
Witness:
变异:删除 cancel 路由的 `if (!matchesRecovery(...)) { … }` 守卫 → 接管块 8 passed(79 skipped)
对照的 continue 路由已钉住同样形态(缺 id 400、checkpointId/activationId 错误 409、会话不存在 404)
请新增一个用例:发送携带陈旧 checkpointId/activationId(来自其他 epoch 的 id)的 cancel,断言 409 hosted_recovery_identity_mismatch 且该 prompt 没有 turn_complete。删除守卫时新用例必须变红。
— DeepSeek/deepseek-v4.1-flash via Qwen Code /review (v0.24.7)
| (entry) => | ||
| entry.daemonPromptId === PROMPT_ID && entry.type === 'tool_result', | ||
| ); | ||
| expect(results).toHaveLength(1); |
There was a problem hiding this comment.
[Suggestion] R1-29: The only test of the recovery's journaled idempotency guard asserts a record count and nothing else, so it cannot distinguish "skipped the duplicate write" from "skipped the whole iteration". The count comes from the fixture's pre-existing record and recovered is still defined, so a change that turns the guard into an early continue (the natural way to avoid re-publishing an outcome) still leaves results.length === 1 — while harness.resolveAwaitRuntime(...) then never runs, the checkpoint stays at await_runtime, the report's phase never reaches results_ready, and the turn remains parked. No other test reds: no session-level case pre-journals a result before a takeover load.
Witness:
mutation: `if (!journaled.has(item.functionCallId)) { await session.sink.write(record); }`
replaced by `if (journaled.has(item.functionCallId)) continue;` (also skipping the resolve)
→ the file's 11 tests all pass
The sibling fixture asserts `phase: 'results_ready'`, which is exactly what this test omits
Assert the report the test already holds — recovered!.report.phase === 'results_ready' and the execution's {outcome: 'known', status: {state: 'settled'}} — as the sibling fixture does. The added assertions must go red against the continue mutation while the current count assertion stays green.
中文说明
[Suggestion] 恢复流程 journaled 幂等守卫唯一的测试只断言记录数量,其他什么都没有,因此它无法区分「跳过了重复写入」与「跳过了整个迭代」。数量来自 fixture 里预先存在的记录,而 recovered 仍然有定义,所以把守卫改成提前 continue(避免重复发布 outcome 的最自然写法)后 results.length 仍是 1 —— 而此时 harness.resolveAwaitRuntime(...) 从不运行,checkpoint 停在 await_runtime,报告 phase 永不达到 results_ready,轮次保持停驻。没有其他测试会变红:没有任何会话级用例在接管 load 之前预写结果。
Witness:
变异:`if (!journaled.has(item.functionCallId)) { await session.sink.write(record); }`
替换为 `if (journaled.has(item.functionCallId)) continue;`(同时跳过 resolve)
→ 该文件 11 个测试全部通过
相邻 fixture 断言了 `phase: 'results_ready'`,而这正是本测试所缺少的
请断言该测试本已持有的报告 —— recovered!.report.phase === 'results_ready' 以及该执行的 {outcome: 'known', status: {state: 'settled'}} —— 与相邻 fixture 一致。新增断言在 continue 变异下必须变红,而当前的计数断言保持绿。
— DeepSeek/deepseek-v4.1-flash via Qwen Code /review (v0.24.7)
| expect(acquire).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('reports parked executions without dispatching on a passive load', async () => { |
There was a problem hiding this comment.
[Suggestion] R1-30: The passive-read contract is pinned only for dispatch, never for writes: all three passive: true cases assert only that execute was not called and then inspect the returned report, so a passive read that writes passes every one of them. The contract the code documents is "A passive load only reads execution states for the cancellation path and never dispatches"; a change that routed the Broker-reported states through the settle path in the passive branch would write a tool_result for a parked turn whose live owner may still produce that result — the duplicate history the dedup exists to prevent. The route-level passive test cannot catch it either, because it asserts the transcript with expect.arrayContaining and therefore tolerates extra records.
Witness:
unmodified PR, probe: executeCalled=0 tool_resultRecords=0 phase=await_runtime (the branch is write-free today)
with a writing passive read: all 10 PR tests in hosted-runtime-recovery.test.ts stay green;
every takeover-block test stays green — only an added assertion reds
Add the missing side-effect assertion to the passive cases: after the passive read, the projection holds zero tool_result records for the prompt. That assertion reds against a writing passive read while every existing passive assertion stays green.
中文说明
[Suggestion] passive 读取的契约只钉住了「不派发」,从未钉住「不写入」:三个 passive: true 用例都只断言 execute 未被调用、随后检查返回的报告,因此一个会写入的 passive 读取可以通过全部三个用例。代码记录的契约是「passive load 只读取执行状态、用于取消路径,永不派发」;若在 passive 分支中把 Broker 上报的状态走结算路径,就会为一个「其存活 owner 可能仍会产出该结果」的停驻轮次写入 tool_result —— 正是去重机制要防止的重复 history。路由级 passive 测试也抓不到,因为它用 expect.arrayContaining 断言 transcript,从而容忍多余记录。
Witness:
未修改的 PR,探针: executeCalled=0 tool_resultRecords=0 phase=await_runtime (该分支今天是只读的)
在被动读取改为可写后:hosted-runtime-recovery.test.ts 的 10 个 PR 测试全部保持绿;
接管块所有测试也保持绿 —— 只有新增断言会变红
请为 passive 用例补上缺失的副作用断言:passive 读取之后,投影中该 prompt 的 tool_result 记录数为 0。该断言在「可写 passive 读取」下变红,而所有既有 passive 断言保持绿。
— DeepSeek/deepseek-v4.1-flash via Qwen Code /review (v0.24.7)
| status: { state: 'prepared' }, | ||
| }), | ||
| ]); | ||
| expect(status).toHaveBeenCalled(); |
There was a problem hiding this comment.
[Suggestion] R1-31: The new takeover block states the Runtime-lease premise in comments but never asserts it. parkToolTurn installs vi.spyOn(HostedWorkspaceBroker.prototype, 'acquire').mockResolvedValue(); and never captures the spy, so no test in the block can fail if the takeover starts acquiring; both expect(release).toHaveBeenCalled() assertions are presence-only and cannot tell which side released; and the bare-load test pins only that execute was not called. A later edit that makes the bare or passive load acquire — or that moves the handback from settle to the load — therefore leaves all eight tests green while the Workspace ends up pinned to a Runtime Session nobody drives, which is exactly the class this PR's handback logic exists to prevent.
Witness:
mutant: the passive arm of recoverHostedRuntimeTurn made to `await broker.acquire(); acquiredRuntime = true;`
→ the takeover block: Tests 8 passed | 79 skipped (87) (all eight stay green)
→ with the proposed `expect(acquire).not.toHaveBeenCalled()` added: it reds on the mutant and passes unmutated
(one correction: "all tests green" holds for this block; the same mutant does red 4 cases in
hosted-runtime-recovery.test.ts, but only incidentally, via an unreachable-URL error from a fixture
that never stubs `acquire` — not as an assertion about the lease)
Capture the spy in parkToolTurn, mockClear() it after the parked owner's legitimate acquire, then assert expect(acquire).not.toHaveBeenCalled() after the bare load and after the passive load; in the settle test, assert expect(release).not.toHaveBeenCalled() right after loadReplacement() and toHaveBeenCalledOnce() after the continue settles. The added assertion is red against the mutant above.
中文说明
[Suggestion] 新增接管块在注释里陈述了 Runtime 租约前提,却从未断言它。parkToolTurn 安装了 vi.spyOn(HostedWorkspaceBroker.prototype, 'acquire').mockResolvedValue(); 却从不保存该 spy,因此接管若开始 acquire,该块中没有任何测试会失败;两处 expect(release).toHaveBeenCalled() 只是「存在性」断言,无法区分是哪一侧释放的;而裸 load 测试只钉住 execute 未被调用。于是之后若某个改动让裸 load 或 passive load 开始 acquire —— 或把归还从结算时移到了 load 时 —— 八个测试全部保持绿,而 Workspace 最终被钉在一个无人驱动的 Runtime Session 上,正是本 PR 归还逻辑要防止的那一类问题。
Witness:
变异:把 recoverHostedRuntimeTurn 的 passive 分支改为 `await broker.acquire(); acquiredRuntime = true;`
→ 接管块:Tests 8 passed | 79 skipped (87) (八个全部保持绿)
→ 加上建议的 `expect(acquire).not.toHaveBeenCalled()`:在变异下变红,未变异时通过
(一处更正:本块「全绿」成立;同一变异确实会让 hosted-runtime-recovery.test.ts 的 4 个用例变红,
但那只是偶然 —— 来自一个从不 stub `acquire` 的 fixture 抛出的不可达 URL 错误,并非关于租约的断言)
请在 parkToolTurn 中保存该 spy,在停驻 owner 合法的 acquire 之后 mockClear(),然后断言裸 load 与 passive load 之后 expect(acquire).not.toHaveBeenCalled();在结算用例中,断言 loadReplacement() 之后紧接着 expect(release).not.toHaveBeenCalled()、continue 结算之后 toHaveBeenCalledOnce()。新增断言在上述变异下为红。
— DeepSeek/deepseek-v4.1-flash via Qwen Code /review (v0.24.7)
51e8e27 to
912c3b5
Compare
|
Thanks for the thorough round — all five Criticals were real and are fixed at the rebased head, along with most Suggestions. Summary below; the commit message and design doc carry the details. Criticals — all fixed:
Suggestions — addressed: R1-6 (behavior change documented in the design doc, both languages), R1-7 (dead Deferred, with reasons:
Verification at this head: 中文摘要本轮 5 个 Critical 全部属实并已修复:R1-1 为 |
Real-environment verification, round 2:
|
13cbd974 |
912c3b57 |
|
|---|---|---|
| F1 later Turns / re-entered Turns on a live owner | fixed | still fixed: Turn 2 0.3 s, dropped SSE recovers in 1.1 s (Linux 0.5 s / 1.3 s) |
| F2 Workspace locked after a continuation takeover | lease held, second Session hosted_turn_failed |
fixed: lease released, second Session completes |
| F3 re-entered takeover never finishes | stream cut and lost continue reply both stuck |
fixed: both complete (4.1 s, 3.9 s), one continuation, whole answer public |
| F4 lost reply to the takeover load | stuck | still stuck; now listed as a known follow-up in the design doc |
F5 message.delta unreadable for an older Harness |
503 managed_session_open_failed |
unchanged behaviour; now documented in the design doc (the PR description still says "Breaking changes / migration notes: none") |
F6 runner token starting with - |
~1.5% of runs per mode | fixed: --managed-runtime-broker-token=<value> |
| R1-1 streamed text containing a newline (from the /review round) | Workspace Turn hosted_turn_failed, 0 bytes public |
fixed: newlines, CRLF, tab, BEL/ESC and a 7.6 KB multi-byte answer across the 3072-byte split are all public byte-exact |
What I ran on 912c3b57
- Takeover with faults (Linux). No fault, stream cut during the replacement's answer, lost
continuereply, lost takeover load, lost:start, lost takeover-load reply, death in the first model round, public cancel. Every run kept one execution row at generation 1, one physical write or none, and no second Prompt. Every Turn that completed released the Workspace lease, and a second Session on the same Workspace then ran its tool. The lost:startstill takes 124 s (the 120 s observation window); the release this head adds on that failure exit is refused by the Broker while the execution is pending (409runtime_session_busy), and the next attempt finishes and releases. - The PR's runner (Linux, quiet host).
--inflight-failover10/10,--continuation-failover9/10,--session-failover3/3. CI on912c3b57ran both new modes green. The one failure stopped before the scripted crash: owner A's writer grant went stale ("The Managed Session writer grant is stale or unavailable") right after the first prompt was admitted. That is the same signature as the loaded-host failures in round 1, here at load 5. The runner uses a 1 s writer lease (QWEN_MANAGED_AGENT_SESSION_STORE_WRITER_LEASE_DURATION). I would lengthen it if CI shows the same thing; I did not change it to test that. - Streamed text (macOS and Linux). See the image below. On
13cbd974any answer with a newline failed the Workspace Turn with "payload.text must not contain control characters", so R1-1 was real. My round-1 scenarios used one-line answers and missed it. - Mid-stream provider drop (macOS). The provider cut the stream after the first chunk. Both a Workspace-bound and an unbound Session retried as a continuation and completed. I could not reach the non-continuation retry or model-fallback path that the design doc now lists as failing the Turn, so that limit is untested.
- Unchanged. The trusted-actor header matrix is identical to round 1; the rollback load is still 503 on a base-build Harness.
What the new tests pin
On 912c3b57 I compiled the fixes away one at a time and ran the five changed vitest files (151 tests; a test counts as catching only if it also fails when run alone) and the connector tests:
- F2 re-attach removed: caught by
re-attaches the Runtime Session when nothing is left to drive. - F3 admission replay removed: caught by two tests, including
replays a lost continue admission at its original watermark. - F3 connector snapshot handed out after admission: caught by
takeoverSnapshotIsReportedUntilItsContinuationIsAdmitted. - R1-1
rawTextreverted totext: caught bycommits model text with newlines and tabs verbatim. - Still not caught by any unit test: the de-duplication of a message whose text already streamed, and the live Turn's durable deltas. Only the Linux-only
--continuation-failovercatches them. This is R1-12, which the author deferred with a reason.
Before merging
- Rebase onto
main.912c3b57conflicts withe083d6a6(feat(managed-agent): Add hosted file history and undo #13110 "hosted file history and undo") inhosted-harness-session.tsandhosted-workspace-broker.test.ts. - After the rebase, re-run the takeover scenarios once (the in-flight and continuation modes, and ideally the lost-
continue-reply case), since feat(managed-agent): Add hosted file history and undo #13110 changes the same session routes. The rig is ready for that. - Optional: change the "Breaking changes / migration notes" line of the description to match the design doc's rollback note.
Not covered
- No real model. The Hosted IT job and the Broker fault gates were not replayed locally; CI covered them.
- The cancellation fixes (R1-3, R1-4, R1-5) are not reachable from the public API at this head (a public cancel of a parked Workspace Turn is 409
workspace_unavailable), so I did not test them beyond the unit suites. - In one whole-file vitest run at load 7,
replays a lost continue admission at its original watermarkfailed once and passed alone twice.
Evidence, driver scripts and raw results: pr13083/r2/ (round 1 files are still in pr13083/).
中文版
真实环境验证第二轮:912c3b57
接着 13cbd974 上的第一轮。装置不变:打包的 Spring fat jar、dist/cli.js Hosted Harness、带 durable 本地 Worker 的 Runtime Broker、真实 MySQL、脚本化本地模型,Linux(Ubuntu 24.04 容器)和 macOS 各一套。这次宿主机是空闲的(负载 3 到 10)。
结论:第一轮建议合并前修的项都已修好,并在真实栈上成立。我没有发现新缺陷。离合并还差两件事:自 #13110 起分支又和 main 冲突了;变基之后要再跑一次接管场景,因为 #13110 改的是同一批会话路由。
13cbd974 |
912c3b57 |
|
|---|---|---|
| F1 owner 活着时的后续轮次 / 重入轮次 | 已修 | 仍然正常:第 2 个轮次 0.3 秒,SSE 断一次 1.1 秒恢复(Linux 0.5 秒 / 1.3 秒) |
| F2 continuation 接管后 Workspace 被锁 | 租约不放,第二个会话 hosted_turn_failed |
已修:租约释放,第二个会话完成 |
| F3 被接管的轮次重入后永不结束 | 断流、continue 应答丢失都卡住 |
已修:都能完成(4.1 秒、3.9 秒),continuation 只跑一次,完整回答公开 |
| F4 接管 load 的应答丢失 | 卡住 | 仍卡住;设计文档已列为已知后续项 |
F5 旧版本 Harness 读不了 message.delta |
503 managed_session_open_failed |
行为不变;已写进设计文档(PR 描述仍写「兼容/迁移说明:无」) |
F6 runner token 以 - 开头 |
每个模式约 1.5% 的运行失败 | 已修:改用 --managed-runtime-broker-token=<值> |
| R1-1 流式文本带换行(/review 这一轮发现) | Workspace 轮次 hosted_turn_failed,公开 0 字节 |
已修:换行、CRLF、制表符、BEL/ESC,以及跨过 3072 字节切分点的 7.6 KB 多字节回答,全部逐字节一致地公开 |
(图 r2-01)
在 912c3b57 上跑了什么
- 带故障的接管(Linux)。 无故障、替代方回答时断流、
continue应答丢失、接管 load 丢失、:start丢失、接管 load 应答丢失、死在第一个模型轮次、公开取消。每次运行都只有一行执行记录(generation 1),物理写入一次或没有,Prompt 从未发第二次。所有完成的轮次都释放了 Workspace 租约,同一 Workspace 上的第二个会话随后能执行工具。:start丢失仍然要 124 秒(120 秒观察窗口);本 head 在这个失败出口新加的释放在执行还挂着时被 Broker 拒绝(409runtime_session_busy),下一次尝试跑完后再释放。 - PR 自带的 runner(Linux,空闲宿主机)。
--inflight-failover10/10,--continuation-failover9/10,--session-failover3/3。912c3b57的 CI 里两个新模式都是绿的。那一次失败发生在脚本化崩溃之前:第一条 prompt 刚被准入,owner A 的 writer grant 就过期了("The Managed Session writer grant is stale or unavailable")。这和第一轮高负载下失败的特征相同,只是这次负载只有 5。runner 用的是 1 秒的 writer 租约(QWEN_MANAGED_AGENT_SESSION_STORE_WRITER_LEASE_DURATION)。如果 CI 上也出现,我建议调长;我没有改它去验证。 - 流式文本(macOS 和 Linux)。 见下图。在
13cbd974上,只要回答里有换行,Workspace 轮次就以 "payload.text must not contain control characters" 失败,所以 R1-1 是真的。我第一轮的场景用的是单行回答,没抓到。 - provider 中途断流(macOS)。 provider 在第一个 chunk 之后断开。Workspace 绑定会话和未绑定会话都按 continuation 重试并完成。设计文档新列出的「非 continuation 重试或模型降级会让轮次失败」这条路径我触发不了,这个限制没有测到。
- 不变的部分。 trusted-actor 头的矩阵与第一轮完全相同;回滚 load 在 base 构建的 Harness 上仍是 503。
(图 r2-02)
新测试钉住了什么
在 912c3b57 上我逐个把修复改回去,跑本 PR 改动的五个 vitest 文件(151 个用例;只有单独跑也失败才算抓到)和 connector 测试:
- 去掉 F2 的重新挂载:被
re-attaches the Runtime Session when nothing is left to drive抓到。 - 去掉 F3 的 admission 重放:被两个测试抓到,其中包括
replays a lost continue admission at its original watermark。 - connector 在准入后仍发出恢复快照(F3):被
takeoverSnapshotIsReportedUntilItsContinuationIsAdmitted抓到。 - R1-1 的
rawText改回text:被commits model text with newlines and tabs verbatim抓到。 - 仍然没有单测能抓到的:已流式发布过文本的消息的去重,以及活跃轮次的持久 delta。只有 Linux 上才跑的
--continuation-failover能抓到。这就是 R1-12,作者说明了理由并暂缓。
合并前
- 变基到
main。912c3b57与e083d6a6(feat(managed-agent): Add hosted file history and undo #13110「hosted file history and undo」)在hosted-harness-session.ts和hosted-workspace-broker.test.ts上冲突。 - 变基后把接管场景再跑一次(in-flight 和 continuation 两个模式,最好再加
continue应答丢失的场景),因为 feat(managed-agent): Add hosted file history and undo #13110 改的是同一批会话路由。装置随时可以跑。 - 可选:把 PR 描述里「兼容/迁移说明」那一行改成与设计文档的回滚说明一致。
没有覆盖的部分
- 没用真实模型。Hosted IT 任务和 Broker fault gate 没有在本地重放,由 CI 覆盖。
- 取消相关的修复(R1-3、R1-4、R1-5)在这个 head 上公开 API 到不了(对挂起的 Workspace 轮次公开取消返回 409
workspace_unavailable),除单测外我没有测。 - 负载 7 时有一次整文件 vitest 运行里,
replays a lost continue admission at its original watermark失败了一次,单独跑两次都通过。
证据、驱动脚本和原始结果见英文部分的链接(pr13083/r2/,第一轮的文件仍在 pr13083/)。
912c3b5 to
b4e9d71
Compare
|
@qwen-code /triage |
yiliang114
left a comment
There was a problem hiding this comment.
Re-reviewed at b4e9d71b — the head has not moved since my last pass, but CI has: Hosted process fault gates / MySQL 8.4 / Java 21 is now green (22m52s), and both new modes ran inside it — step 14 Run in-flight owner failover E2E and step 15 Run continuation owner failover E2E, both successful. So the six never-replay gates agree with the takeover now, and the CI evidence for the central claim exists. Everything I raised earlier that this head addresses checks out: the lease handback work, the status() 409 mapping, rawText, the tool_result write in settleParkedTurnCancelled, the cancel route's authorization check, and the attachment reuse.
One item from my previous pass is still open — the release window in recoverHostedRuntimeTurn, detailed in the R1-2 thread. A fresh pass over the surfaces I had not covered turns up the following.
1. [Important] continue answers 200 before it knows the checkpoint is continuable, and the failure path parks the session for good
packages/cli/src/serve/hosted-harness-session.ts:1871–:1881 sets session.active, records the admission and sends 200 {accepted: true}. Only afterwards, inside the async body, does :1898–:1902 require authorization.checkpoint.continuation.phase === 'results_ready' and otherwise throw.
That throw lands in the catch at :2030–:2040, which writes turnResultRecord('error'). A turn result is committed as turn.settled, but through commitTurnComplete only when the checkpoint phase is in HARNESS_MODEL_START_PHASES (managed-session-record-sink.ts:346, :350); await_runtime is not in that set (managed-harness-checkpoint.ts:40–:46), so the sink appends a bare turn.settled and no next-turn checkpoint. unsettledInputsThrough (:178) then stops counting that turn as unsettled, which is exactly what the load route's takeover branch tests (:1125). So no later load, continue or cancel can drive or settle those executions again, while the checkpoint stays await_runtime with in_progress runtime items — the opposite of what the cancel route's own comment (:2105–:2107) says that route exists to prevent.
This is the same class as R1-5, which this head fixed for cancel by checking harnessRunAuthorization() before answering (:2085–:2090). Moving the same check — including the phase — above the 200 in continue closes it. What I could not settle is which coordinator state reaches it without a protocol violation: the snapshot recoverHostedRuntimeTurn returns reports phase: 'results_ready' only when the post-settlement checkpoint already says so (hosted-runtime-recovery.ts:410), so a drive load that settled the executions but left the checkpoint at await_runtime would hand the coordinator a snapshot it may treat as continuation-ready. You will know immediately.
2. [Minor] cancel records no admission, so a lost-reply replay gets 409 hosted_turn_active
session.admissions is written in the prompt route (:1500) and in continue (:1872) and nowhere in cancel — the only admissions sites are :106, :1033, :1430, :1500, :1872. cancel sets session.active at :2097 and only answers 200 at :2148, after stopParkedRuntimeExecutions (up to 30 s per execution), the settlement, the file-history rewrite and the release. A retry arriving in that window is refused by :2062 if (session.active) return error(res, 409, 'hosted_turn_active'), where continue documents the opposite requirement for the same replays — "it must get the watermark it was admitted at, running or settled, or the coordinator would stream from after the Turn's own events" (:1842). cancel is also the one path with no E2E mode. Worth the same receipt.
3. [Minor] continue does not hand session.mcp to the tool turn
new HostedWorkspaceToolTurn(...) at :1958–:1993 stops at the approval argument; executeHostedTurn passes session.mcp as the tenth (:768, against hosted-workspace-tool-turn.ts:233). With mcp undefined the turn builds its own promptId-keyed broker instead of the MCP session's (hosted-workspace-tool-turn.ts:243), advertises no MCP tools, and finish() releases that broker. Whether an MCP-profile turn can reach this route is the part I could not settle — the profile gate at :1858 admits it, but the recovery drive's broker identity for an MCP execution is a separate question. If it can, the two recovery paths disagree about which session they are driving.
4. [Minor] The continuation failover E2E pins continuationRequests.length < 2, not === 2
scripts/run-managed-agent-server-e2e.ts:1457. The in-flight mode pins its counterpart exactly (:1361, !== 1), and the PR body's evidence block quotes continuationModelRequests: 2 while the design says the replacement issues one further continuation. A regression that re-issues continue passes this gate. Same block: at :1471–:1472 promptReplayed: false and physicalToolExecutions: 1 are literals, not derived from the run (:1473 shows what a derived field looks like), so the printed "After" evidence is not itself an assertion.
5. [Minor — needs your confirmation] An accepted cancel for a Turn whose admission was never recorded ends as a failed Turn without cancelling anything
packages/sdk-java/.../service/HarnessCoordinator.java:273–:279 fails the Turn with hosted_harness_recovery_generation_mismatch whenever bindRecoveredHarness returns false, and that method returns false when turn.harnessEventEpoch() == null (ManagedAgentStore.java:1264). For a CANCELLING Turn with submissionAttempted() == TRUE and no recorded epoch, cancelBeforeAdmission is skipped (HarnessCoordinator.java:217), the passive takeover produces a ready cancellation snapshot, the retraction is skipped (:267), and the gate then fails the Turn — cancelManagedRuntime at :294 is never reached. So the user's accepted cancel is neither performed nor reported as a cancellation, and the code names a generation change that did not happen. The gate lives in a file this PR does not touch, so this may be pre-existing behaviour that the new passive snapshot merely makes reachable — flagging it because it is now on the cancellation path this slice adds.
Read-only pass over the head tree: I ran no unit suite, no Java build and no E2E, so every claim above is a reading of b4e9d71b. I did not review the design docs, the latency harness, or the Runtime Broker's own fault gates.
qqqys
left a comment
There was a problem hiding this comment.
Not approving at b4e9d71b. I verified the lease-handback finding myself and it is fixed here, but two blocking-class items remain that I could not confirm inside this pass, and one narrow window I could not rule out.
Confirmed fixed at this head — the acquired Runtime lease is now given back on the failure exits. All three exits named in the earlier pass release before returning. argsRef === undefined no longer returns directly: it throws RecoveryDeclined (line 291), which lands in the catch at 358 whose comment states the invariant — "The caller only learns about the lease from a returned report, so every failure exit here must give it back first" — and that catch calls broker.release() (361-365), clears acquiredRuntime (366), and only then returns undefined for a declined recovery or rethrows (367-368). The stored.payloadJson shape check (295) and the broker.execute / toolResultParts / oversize-record branches sit inside that same try, so they are covered by the same release. The final authorization exit is handled separately and correctly: if (acquiredRuntime) { await broker.release()... } before return undefined (379-387). The returned report still carries acquiredRuntime (408) so the caller can hold the lease on the success path. I also agree the other items this head addresses check out.
Not confirmed — the reason this is a comment.
-
The standing first-pass finding on committing a recovered cancellation is still an unresolved thread and I did not verify it at this head. Its substance is that a cancel is settled, and a terminal cancelled outcome written, on the strength of having sent the cancel — while the Broker's cancel contract permits non-terminal answers (a cancel that threw with the execution still physically executing, or one that only reached
cancel_requested). If that still holds, a Turn is recorded cancelled while its physical execution may continue, which is a settlement-evidence problem rather than a reporting one. It needs either observation of the original execution's physical terminal state before settling, or an explicit recorded decision that a sent cancel is sufficient evidence. -
The new finding on the
continueroute — answering200 {accepted: true}after settingsession.activeand recording the admission, and only then requiringcontinuation.phase === 'results_ready', so the throw commits a bareturn.settledwith no next-turn checkpoint and the turn stops counting as unsettled — is graded Important by its author, who also states they could not settle which coordinator state reaches it without a protocol violation. I did not have budget to trace that reachability either, so I am recording it as unconfirmed rather than asserting it. If it is reachable, the fix is the same shape the cancel route already got: move the authorization and phase check above the 200.
One narrow window I could not rule out. The re-attach branch acquires the lease outside any try — await broker.acquire(); acquiredRuntime = true; (375-376) — and the next statement is await session.authority.harnessRunAuthorization() (378). If that call throws rather than returning a non-runnable status, the release at 380-386 is skipped, because it is guarded by the status check and not by a catch. I could not confirm within budget whether an outer handler covers it, so I am flagging it for your confirmation rather than filing it as a defect.
CI shows 22 checks passing with no failures and two pending; pending checks are not treated as a gate, and the Hosted MySQL 8.4 fault-gate lane ran both new failover modes green.
Next step: settle item 1 with physical-stop evidence or an explicit recorded acceptance, and settle item 2's reachability — either of which may already be answered in the threads, but neither is something I could confirm from the code at this head.
b4e9d71 to
3ddcb12
Compare
|
Thanks for the re-review at The still-open R1-2 window is closed. Finding 1 (Important) — fixed. The continue route now proves the checkpoint is continuable before answering: Finding 2 (Minor) — fixed. The cancel route now records the admission ( Finding 3 (Minor) — fixed. The continue route now hands Finding 4 (Minor) — fixed. The continuation failover gate now pins Finding 5 — confirmed pre-existing, not this slice. The The P1 regression is fixed with the regression test you asked for. The continue route now calls Also fixed since the last pass: the Verification at this head: the takeover/recovery suites pass, One environment note, for completeness: 中文摘要本轮全部收口:R1-2 遗留窗口(re-attach 分支与最终授权读取纳入失败即释放保护);Finding 1(continue 在回答 200 前先证明 checkpoint 可续,不再写裸 turn.settled 卡死会话);Finding 2(cancel 路由记准入,重放拿水位而不是 409,首个应答也改为准入水位,失败则删准入让重试重驱);Finding 3(continue 把 session.mcp 传给 tool turn);Finding 4(continuation E2E 钉 |
…ckpoint A replacement Harness feeding the settled batch to the model ran under its own activation, so the next tool batch was refused as Runtime work of the prior activation and the Turn stayed RUNNING with no terminal event. When consumeRuntimeResults runs under a different activation with every item settled, commit the consumed checkpoint under the current activation. Co-authored-by: Qwen-Coder <[email protected]>
|
Thanks for the round-3 real-stack run — and for the candidate patch. Status at the new head ( F7 (file-history marker left by an in-flight takeover) — fixed, using the resume-reconciliation approach doudouOUC suggested (which you preferred over candidate A): the continue route now runs F8 (taken-over Turn cannot run another tool batch) — fixed with your candidate B, unchanged: The earlier review round's items are also in this head (the continue route now proves the checkpoint is continuable before answering 200, cancel records its admission and replays at its watermark, the recovery's whole post-acquire span releases on failure, and continue hands Verification at 中文摘要第三轮实测的两个问题都已修:F7 用 doudouOUC 建议的 resume 和解方案(continue 路由在推理前跑 resumeCommittedResults,比对原 Runtime 快照后清 marker;cancel 路径也清)——没取 candidate A,因为 drive 时清会在 load 与 continue/cancel 之间留下 undo 仍 409 的窗口,且少了一层快照一致性检查;F8 原样采用了你的 candidate B(settled 批被接管方消费后把 checkpoint 记到当前 activation 下),你的测试原样进入、无修复时红、有修复时绿,相邻的跨 activation 审批守卫用例不破。验证:三模式 failover E2E 在这台 Linux 机器上全过;FG6 全量除 receipt-failure(main 在此机同样挂,环境性,CI 绿)外全绿。 |
yiliang114
left a comment
There was a problem hiding this comment.
Reviewed at the current head 7ae1fa056c. The threads below were filed against pre-squash heads; I re-verified each named blocker in the code at this head rather than trusting thread flags.
The standing Criticals/P1s are fixed at this head:
- File-history continuation regression (latest CR's P1): the continuation route now calls
toolTurn.resumeCommittedResults()before inference, with the comment naming the exact failure (a text-only continuation never re-acquires, so the pending marker would outlive the turn and wedge later cold loads). - R1-2 lease handback:
releaseLeaseNowexists for exits no route can retry; it runs on the load route'sincompletePublication/unsettled-input refusal and the secondassertWritablefailure, and insideclose()(detach/DELETE). The continue/cancel early returns all callreleaseRecoveredRuntime. - Passive-cancel lease (the P1 on the cancel path): the cancel route now explicitly releases the original owner's Runtime Session after executions are confirmed stopped (
broker.release()with the 404 carve-out), and drops the cancelled turn's pending file-history marker. - R1-5 + the physical-settle P1: the cancel route refuses with 409 when the authorization is not
runnablebefore touching the helpers, andstopParkedRuntimeExecutionsnow polls to a terminal state with a deadline and throws (→ 503, admission dropped for retry) instead of treating an accepted cancel as proof. - R1-1:
message.deltanow declarestext: 'rawText'. - The failure-exit lease P1 in
recoverHostedRuntimeTurn: every post-acquire exit (RecoveryDeclined, oversized, final authorization rejection, lost acquire reply) hands the lease back before returning/throwing — the comment states the contract ("the caller only learns about the lease from a returned report"). - The new head commit itself (activation adoption): the adopt arm keys on a fully settled batch only, so a partially in-flight batch is still refused as prior-activation work; the new test deadlocks without it.
Scope I did not re-verify: full-file breadth beyond the named findings (doudouOUC's all-files read covers the reviewed head), the Java coordinator side, and the Linux-only packaged failover modes (CI green per the PR). With the named gates closed at this head, the remaining exits are the human reviewers' own re-checks.
yiliang114
left a comment
There was a problem hiding this comment.
Approving at 7ae1fa056c. My comment above itemizes the per-finding verification: every Critical/P1 named by the existing reviews is fixed at this head (file-history reconciliation in the continuation route, lease handback on all no-retry exits, passive-cancel release, physical-settle confirmation before cancel settles, non-runnable refusal, rawText delta field, post-acquire failure releases), and the new activation-adoption commit keys on fully-settled batches only. The outstanding threads are anchored on the pre-squash head; doudouOUC's stated approval conditions are met.
|
@qwen-code /triage |
qqqys
left a comment
There was a problem hiding this comment.
COMMENT — reviewed at head 7ae1fa056c8fe73da59766c53464d75c8b147b92. No blocking finding: every blocker named across this PR's review rounds is fixed in the code at this head, and I verified each one first-hand rather than inheriting the newest approval. This is a comment only because a 36-file / +4,426-line diff was not fully read inside this pass's budget, and the unread part is listed at the end so nobody mistakes it for a clean bill of health.
Historical blocking issues, each re-judged on this exact head
1. The continuation/file-history P1 (the finding the standing CHANGES_REQUESTED was opened for): fixed. The POST /session/:id/managed-runtime/continue route now calls await toolTurn.resumeCommittedResults() before runHostedHarnessTextTurn, with the comment naming the exact failure it prevents ("a text-only continuation never re-acquires, so without this the marker outlives the turn and wedges every later cold load"). The reconciliation itself is not a blind clear: resumeCommittedResults() returns early only when the turn already holds the broker, otherwise it awaits warmed, re-acquires with recovering = true, and that path refuses unless saved.pendingTurn === this.promptId and canSettleHostedFileHistory holds, re-reads the broker snapshot, compares it against the saved snapshots with isDeepStrictEqual and throws on any drift, and only then commits pendingTurn: null, pendingUndo: null. So the durable obligation is discharged before inference and a changed-on-disk history still fails closed.
2. The R1-2 lease handback on the final authorization read: fixed. In hosted-runtime-recovery.ts the final read now has its own boundary — try { finalAuthorization = await session.authority.harnessRunAuthorization(); } catch (cause) { … } — and the catch releases the broker when acquiredRuntime is set, clears the flag, and only then rethrows, with the invariant stated in the comment ("The caller only learns about the lease from a returned report, so a rejection here must hand it back first"). The separate non-runnable exit releases too and returns undefined. This closes the throwing case, which is the one the earlier probe measured as acquireCalls=1, releaseCalls=0; the returned report still carries acquiredRuntime so the success path keeps holding the lease deliberately.
3. The settlement-evidence finding on the cancel path: fixed. stopParkedRuntimeExecutions no longer treats an accepted cancel as proof. It reads before = await broker.status(...), skips anything already settled or absent, sends the cancel with the rejection swallowed, and then polls broker.status every 250 ms until the execution is settled or gone, throwing Runtime execution did not reach a terminal state after cancellation. once a 30 s deadline passes. A cancel that leaves the execution physically running therefore surfaces as a failure instead of a committed cancelled terminal.
4. The continue route answering 200 before proving the turn continuable: fixed. The route now reads the authorization with .catch(() => undefined) and refuses with 409 hosted_turn_recovery_required — after releaseRecoveredRuntime(session) — unless the status is runnable and continuation.phase === 'results_ready'. Only past that gate does it set session.active, record the admission and answer 200, so the bare-turn.settled wedge is no longer reachable. The earlier 200 in the same handler is the replay arm for an already-admitted recovery keyed on recovery:${checkpointId}:${activationId}, which correctly returns the watermark it was admitted at rather than re-admitting.
5. The message.delta payload field (R1-1): fixed and consistent with its producer. managed-session-records.ts adds message.delta to MANAGED_SESSION_EVENT_KINDS, a new rawText field kind, the schema { messageId: 'id', turnId: 'id', role: 'text', text: 'rawText' }, EVENT_ACTORS['message.delta'] = ['harness'] and ACTIVATION_SUBJECT_KINDS['message.delta'] = true. HostedTextDeltaStream.commit emits exactly those four payload fields, an activation-scoped subject, and a harness-class actor, so producer and schema agree. Its commandId/eventId are assistant-delta:${turnId}:${messageId}:${ordinal} with ordinal incremented per chunk, which keeps the append idempotent per chunk.
Also checked and not filed: the end -= 1 surrogate-pair guard in HostedTextDeltaStream.delta can only produce a zero-length chunk — and therefore a non-advancing loop — if the byte-cap binary search returned end === 1, which needs a single character larger than the 3,072-byte cap. Unreachable, so no defect. releaseLeaseNow is keyed on session.runtimeLeaseHeld, no-ops when nothing is held, clears the marker only on a successful release of the same prompt identity, and is called on the load route's unregistered-session and second-assertWritable refusals and inside the close route before mcp.close() / managed.close() / sessions.delete(); a failed release is logged and swallowed by design, since those exits leave no route that could retry it. TrustedActorHeaderFilter is inert at the shipped default (trustedActorHeader = "", application.yml binds it to an empty default), and even when configured it only stands in where request.getUserPrincipal() == null and both the actor and tenant headers are non-blank, so an upstream-authenticated identity always wins.
On the standing CHANGES_REQUESTED: it was written against b4e9d71b and has not been re-reviewed or dismissed, so GitHub's decision still reads that way. Both findings it named are the ones verified fixed in items 1 and 2 above. That is a statement about the code at this head, not about that reviewer's authority to re-check.
Not read within this pass's budget
The pass covered hosted-runtime-recovery.ts (lease, cancel and final-authorization regions), the continue route, the file-history reconciliation, hosted-text-deltas.ts in full, the message.delta schema registration, releaseLeaseNow and its call sites, and the new Java filter. It did not cover: the rest of the 629-line hosted-harness-session.ts change (the load, detach and stream routes outside the arms quoted above); the continuation-load dispatch half of recoverHostedRuntimeTurn, i.e. the non-passive path that re-dispatches in-progress executions under their original executionCallId and the exactly-once claim that rests on the Broker's durable record; the Java coordinator and connector changes (HarnessCoordinator, QwenHostedHarnessConnector, LoadHarnessSession, the removed HarnessRuntimeRecovery block); scripts/run-managed-agent-server-e2e.ts (+119/-44) and the sdk-java.yml workflow change that put both failover modes into the Hosted MySQL job; and the ~2,600 lines of new and updated tests, so I have not confirmed that any of the five fixes above is pinned by a test that would fail without it. The retraction of a dead owner's published prefix by the per-epoch machinery is likewise taken from the design doc, not traced.
The single highest-value check for whoever picks this up: that the continuation-load dispatch half keeps executionCallId stable across owners and that a lost dispatch reply cannot produce a second physical execution, since that is the no-replay claim the whole slice is gated on and the one place where a defect would be a duplicate side effect rather than a wedged session.
CI at this head
No check failed: 22 succeeded and web-shell E2E Smoke plus delay-automatic-review were still running at the time of writing. Pending checks are not treated as a gate here, and neither pending leg exercises this PR's recovery paths.
Real-environment verification, round 4:
|
b4e9d71b |
7ae1fa05 |
7ae1fa05 + candidate C |
|
|---|---|---|---|
| F7: file-history marker after an in-flight takeover | left set in 4/4 in-flight runs; undo 409; fresh load 409 | cleared in 4/4; undo 200; fresh load 200 | |
| F8: taken-over Turn asks for a second tool | Turn never ends | completes in all 5 scenarios (2.5 to 4.7 s) | |
| F9: cancellation takeover (state set through the store transition) | Turn stays CANCELLING (in-flight and continuation) |
Turn stays CANCELLING (in-flight and continuation) |
turn.cancelled in all 4 scenarios |
| Rounds 1 and 2 (F1, F2, F3, F6, R1-1) | fixed | still fixed | |
| F4 lost takeover-load reply, F5 rollback load | documented | unchanged |
F7 and F8 at 7ae1fa05
F7 (resume reconciliation in continue):
- Every in-flight scenario ends with the marker cleared, undo returning 200 and removing the file, and a fresh Harness loading the Session with 200. The scenarios are: no fault, lost
continuereply, lost takeover load, and lost:start(124 s, as before). - Continuation takeovers are unaffected: the saved history binds to the reclaimed Runtime and undo still works.
F8 (candidate B as committed): two-tool takeovers complete in every scenario:
- in-flight: 2.5 s;
- continuation: 2.6 s;
- continuation with a stream cut: 4.7 s;
- in-flight with a lost
continuereply: 3.9 s; - in-flight with a lost takeover load: 4.7 s.
In every one of them, both executions stay at generation 1, undo restores both files, and a second Session on the same Workspace then runs its tool.
F9: the cancellation takeover cannot finish against a replacement Broker
How I reached it. The public API refuses cancel for Workspace Sessions: insertCancelCommand answers 409 workspace_unavailable. So after owner A dies, the rig applies the same transition the store would (status = 'CANCELLING') and then starts owner B. Owner B's coordinator then does exactly what it does for a real cancel: a passive takeover load, then managed-runtime/cancel.
In-flight (the execution is parked and not settled):
- The passive load reads the execution status from Broker B without adopting the Runtime Session.
- Broker B answers 404
runtime_session_not_found: for an unsettled execution,RuntimeBrokerService.getExecutioncallsrequireReadySession, which needs the Runtime Session to be active in this Broker process. - The load answers 409
hosted_turn_recovery_required. - The coordinator retried 6 times in 90 s, and the Turn stays
CANCELLING.
Continuation (the execution is settled):
- The passive load succeeds, because a terminal execution is answered from the durable record.
- The cancel route's explicit
broker.release()gets 503runtime_reconciliation_required("Runtime Session is not active in this Broker process"). This is the same release-without-acquire refusal that was behind round 1's F2. - The cancel route answers 503
managed_runtime_cancel_failedon every retry.
b4e9d71b behaves the same way. The unit tests stub status and release so that they succeed without an acquire. reports a parked execution passively and cancels the turn also asserts expect(acquireSpy).not.toHaveBeenCalled(). So the tests encode a Broker contract that the real Broker does not offer after an owner change.
Candidate C (about 30 lines in hosted-runtime-recovery.ts): when a passive load has Runtime items, it acquires the Runtime Session first. Acquiring dispatches nothing. If the acquire or a status read fails, it hands the lease back. In the tests, the candidate flips that one assertion and adds an acquire stub to the three passive recovery tests. With it:
- Every case ends
turn.cancelled, in 1.6 to 4.0 s. - In-flight, the tool never runs. There are no Workspace file events, and undo has nothing to restore.
- The Workspace is freed. The lease is released, and a second Session on the same Workspace then runs.
- A lost cancel reply is replayed at the same watermark (13 → 13 and 18 → 18). This is the new cancel admission replay working end to end.
- The drive path is unchanged. Two-tool in-flight and continuation takeovers complete, and the runner passes 3/3 in both modes.
- The changed CLI test files pass (173 tests; the 4 that failed in the loaded full run pass when run alone, as on the unmodified head).
Patch: candidate-r4.patch. Adopting at managed-runtime/cancel instead would also work, but the status read happens during the load, so the load is where the adoption has to happen.
Everything else at 7ae1fa05
- Takeover with faults (Linux). Every scenario has the same outcome as on
b4e9d71b:- each run keeps one execution row at generation 1, with one physical write or none, and no second Prompt;
- every completed Turn releases the Workspace lease, and a second Session runs.
- F4 and the out-of-scope first-round crash are still stuck, as documented.
- The PR's runner.
--inflight-failover10/10,--continuation-failover10/10 (it now pins exactly 2 continuation requests),--session-failover3/3. - Unchanged.
- F1: Turn 2 on a live owner takes 0.3 s, and a dropped SSE recovers in 1.1 s.
- R1-1: streamed text stays byte-exact on macOS and Linux.
- A provider drop after the first chunk is retried as a continuation.
- The trusted-actor header matrix is identical to earlier heads.
- Rollback load on a base-build Harness is still 503 (F5, documented).
- Mutants (the changed CLI test files; a test counts only if it also fails alone):
- Caught: removing the F7 reconciliation (2 tests), the F8 adoption (1 core test,
managed-harness-factory), the cancel admission replay and its watermark (1 test each), and the round-2 fixes R01 to R03. - Not caught by any unit test:
- the
continuecheck before the 200 (yiliang114, item 1); - all three new release-on-failure exits in
recoverHostedRuntimeTurn(R1-2); - handing
session.mcpto the continuation; - dropping the admission after a failed cancel.
- the
- My rig cannot reach any of those either, so I checked them by reading only.
- T02 and T03 are unchanged since round 3.
- Caught: removing the F7 reconciliation (2 tests), the F8 adoption (1 core test,
Follow-ups on main
- F9: take candidate C, or add it to the design doc's known follow-ups: the cancellation takeover does not work against a replacement Broker yet, and the unit assertion that a passive load never acquires does not match the real Broker.
- Carried over, optional: the description's "Breaking changes / migration notes" line, and R1-12 (T02/T03 have no unit coverage).
Not covered
- No real model. CI covers the Hosted IT job and the Broker fault gates.
- The rig set the cancel state through the store transition, because the public API refuses cancel for Workspace Sessions.
- Restarting only the Harness behind a live Spring owner.
Evidence, driver scripts and raw results: pr13083/r4/ (earlier rounds are in pr13083/, pr13083/r2/ and pr13083/r3/).
中文版
真实环境验证第四轮:7ae1fa05(合入后确认)
本轮验证进行期间,PR 已于 16:35 UTC 合入(squash 提交 728c13de),所以这是合入后的确认,不是合并门禁。实测的代码就是落地的代码: git diff 7ae1fa05 728c13de 限定在本 PR 的 36 个文件上为空。
这一轮接着 b4e9d71b 上的第三轮。与上一轮相比:
- 接管提交在同一个 base(
e083d6a6,feat(managed-agent): Add hosted file history and undo #13110)上做了修订。 - 候选 B 单独成为一个提交。
- 没有 Java 改动,server jar 与第三轮是同一份构建。
装置不变:Linux(Ubuntu 24.04 容器)和 macOS 各一套。宿主机负载在 20 到 35 之间。
结论:F7、F8 在真实栈上已修好,前三轮的结果都没有回退。本 head 里其他评审修复,在装置能触达的地方也都成立。
有一个新发现 F9:cancel 接管在替代方的 Broker 上走不完。 本 slice 中公开 API 到不了这条路径:store 拒绝对 Workspace 会话取消,设计文档也写明 cancel 接管「没有 E2E 模式」。它是 main 上的后续项:要么修掉(下面的候选 C,约 30 行,可直接应用到 728c13de 的 main),要么在文档里写明它目前还不可用。
b4e9d71b |
7ae1fa05 |
7ae1fa05 + 候选 C |
|
|---|---|---|---|
| F7:in-flight 接管后的文件历史标记 | 4 次 in-flight 运行全部留着;撤销 409;新 Harness load 409 | 4 次全部清掉;撤销 200;新 Harness load 200 | |
| F8:被接管的轮次要调第二个工具 | 轮次永不结束 | 5 个场景全部完成(2.5 到 4.7 秒) | |
| F9:cancel 接管(状态经 store 的取消转移设置) | 轮次一直停在 CANCELLING(in-flight 与 continuation) |
轮次一直停在 CANCELLING(in-flight 与 continuation) |
4 个场景全部 turn.cancelled |
| 前两轮(F1、F2、F3、F6、R1-1) | 已修 | 仍然正常 | |
| F4 接管 load 应答丢失、F5 回滚 load | 已写入文档 | 不变 |
7ae1fa05 上的 F7 和 F8
F7(在 continue 里做 resume 对账):
- 每个 in-flight 场景结束时,标记都已清掉,撤销返回 200 并删除文件,新 Harness load 会话返回 200。这些场景是:无故障、
continue应答丢失、接管 load 丢失、:start丢失(124 秒,与之前相同)。 - continuation 接管不受影响:保存的历史能绑定到回收后的 Runtime,撤销照常可用。
F8(按提交的候选 B):两个工具的接管在每个场景下都能完成:
- in-flight:2.5 秒;
- continuation:2.6 秒;
- continuation 加断流:4.7 秒;
- in-flight 加
continue应答丢失:3.9 秒; - in-flight 加接管 load 丢失:4.7 秒。
每个场景里,两次执行都停在 generation 1,撤销能恢复两个文件,同一 Workspace 上的第二个会话随后能执行工具。
(图 r4-01)
F9:cancel 接管在替代方的 Broker 上走不完
怎么触达的。 公开 API 拒绝对 Workspace 会话取消:insertCancelCommand 返回 409 workspace_unavailable。所以在 owner A 死后,装置直接执行 store 本来会做的同一个转移(status = 'CANCELLING'),再启动 owner B。owner B 的协调器随后做的事和真实取消时完全一样:先被动接管 load,再调 managed-runtime/cancel。
in-flight(执行挂起且未结算):
- 被动 load 在没有接管 Runtime Session 的情况下,向 Broker B 读执行状态。
- Broker B 返回 404
runtime_session_not_found:对未结算的执行,RuntimeBrokerService.getExecution会调requireReadySession,它要求该 Runtime Session 在本 Broker 进程中处于活动状态。 - load 返回 409
hosted_turn_recovery_required。 - 协调器在 90 秒内重试了 6 次,轮次一直停在
CANCELLING。
continuation(执行已结算):
- 被动 load 成功,因为已终结的执行由持久记录直接回答。
- cancel 路由显式调用的
broker.release()得到 503runtime_reconciliation_required("Runtime Session is not active in this Broker process")。这和第一轮 F2 背后的「未 acquire 就 release」被拒是同一回事。 - cancel 路由每次重试都返回 503
managed_runtime_cancel_failed。
b4e9d71b 上表现相同。 单测把 status 和 release 桩成不需要 acquire 也能成功;reports a parked execution passively and cancels the turn 还断言了 expect(acquireSpy).not.toHaveBeenCalled()。也就是说,测试编码了一个真实 Broker 在 owner 变更后并不提供的契约。
候选 C(hosted-runtime-recovery.ts 约 30 行):被动 load 有 Runtime 项时,先 acquire 这个 Runtime Session。acquire 不会派发任何东西。如果 acquire 或读状态失败,就把 lease 交还。测试方面,候选把上面那条断言反过来,并给三个被动恢复测试补了 acquire 桩。加上之后:
- 每个场景都以
turn.cancelled结束,耗时 1.6 到 4.0 秒。 - in-flight 下工具从未执行。 Workspace 上没有文件事件,撤销也没有可恢复的内容。
- Workspace 被释放。 lease 已释放,同一 Workspace 上的第二个会话随后能运行。
- cancel 应答丢失后,重放拿到同一个水位(13 → 13、18 → 18)。这说明新加的 cancel admission 重放在端到端上成立。
- 驱动路径不变。 两个工具的 in-flight 和 continuation 接管都能完成,runner 两个模式都是 3/3。
- 改动的 CLI 测试文件全部通过(173 个用例;满载整跑时失败的 4 个单独跑都通过,与未改动的 head 情况相同)。
(图 r4-02)
补丁: 见英文部分的 candidate-r4.patch 链接。改成在 managed-runtime/cancel 里接管也可以,但读状态发生在 load 期间,所以接管必须放在 load 里。
7ae1fa05 上的其他检查
- 带故障的接管(Linux)。 每个场景的结果都与
b4e9d71b相同:- 每次运行都只有一行执行记录(generation 1),物理写入一次或没有,Prompt 从未发第二次;
- 所有完成的轮次都释放了 Workspace 租约,第二个会话能运行。
- F4 和超出范围的第一轮崩溃仍然卡住,与文档一致。
- PR 自带的 runner。
--inflight-failover10/10,--continuation-failover10/10(现在钉死恰好 2 次 continuation 请求),--session-failover3/3。 - 不变的部分。
- F1:owner 活着时第 2 个轮次 0.3 秒,SSE 断开 1.1 秒恢复。
- R1-1:流式文本在 macOS 和 Linux 上都逐字节一致。
- 第一个 chunk 之后 provider 断开,会按 continuation 重试。
- trusted-actor 头矩阵与之前完全相同。
- base 构建的 Harness 回滚 load 仍是 503(F5,已写入文档)。
- mutants(跑改动的 CLI 测试文件;只有单独跑也失败才算抓到):
- 能抓到:去掉 F7 的对账(2 个测试)、F8 的接管(1 个 core 测试,
managed-harness-factory)、cancel admission 重放及其水位(各 1 个测试),以及第二轮的 R01 到 R03。 - 没有任何单测能抓到:
continue回 200 之前的检查(yiliang114 第 1 条);recoverHostedRuntimeTurn里新加的三个失败即释放出口(R1-2);- 把
session.mcp传给 continuation; - cancel 失败后删除 admission。
- 这几处我的装置也触达不了,只做了读代码确认。
- T02、T03 与第三轮相同。
- 能抓到:去掉 F7 的对账(2 个测试)、F8 的接管(1 个 core 测试,
(图 r4-03)
main 上的后续项
- F9:采用候选 C,或者把它写进设计文档的已知后续项:cancel 接管目前在替代方的 Broker 上还走不通,单测里「被动 load 从不 acquire」的断言与真实 Broker 不符。
- 沿用上一轮的可选项:PR 描述里「Breaking changes / migration notes」那一行,以及 R1-12(T02、T03 没有单测覆盖)。
没有覆盖的部分
- 没用真实模型。Hosted IT 任务和 Broker fault gate 由 CI 覆盖。
- cancel 状态由装置经 store 的取消转移设置,因为公开 API 拒绝对 Workspace 会话取消。
- 没测「Spring owner 不变、只重启 Harness」的路径。
证据、驱动脚本和原始结果见英文部分的链接(pr13083/r4/;前几轮在 pr13083/、pr13083/r2/、pr13083/r3/)。
wenshao
left a comment
There was a problem hiding this comment.
Not explored to full depth (tool budget reached): chunk 7: not executed — that the turn path emits message.delta in the default/fixture configuration (gated at hosted-harness-model.ts:162,170 ), so the effect of the …; "agent reverse-audit (round 2)": Did not execute the suite or build a live Shell crash-window fixture (durable receipt + missing sink record + await_runtime ), so the finding above is filed at…; "agent reverse-audit (round 2)": Did not extend the takeover / fileHistory.pendingTurn skip analysis to the MCP-profile session ( session.mcp ) refusal semantics; unexamined, not cleared.; "agent reverse-audit (round 2)": MCP re-dispatch reuses the payload stored at the original dispatch, grant included ( hosted-mcp-session.ts:963-978 , leaseDurationMs: 300_000 ), while the live…; "agent reverse-audit (round 3)": the MCP-operation guard — both new routes omit the session.mcpBusy || session.mcpRecovering refusal that /prompt (and /rewind ) apply, so an MCP operation …, and 9 more.
Not linted (tool limitation, not a blocker): .github/workflows/sdk-java.yml — actionlint embedded-shell source mapping is not yet supported.
中文说明
未探索到全部深度(达到工具调用预算):chunk 7:not executed — that the turn path emits message.delta in the default/fixture configuration (gated at hosted-harness-model.ts:162,170 ), so the effect of the …;"agent reverse-audit (round 2)":Did not execute the suite or build a live Shell crash-window fixture (durable receipt + missing sink record + await_runtime ), so the finding above is filed at…;"agent reverse-audit (round 2)":Did not extend the takeover / fileHistory.pendingTurn skip analysis to the MCP-profile session ( session.mcp ) refusal semantics; unexamined, not cleared.;"agent reverse-audit (round 2)":MCP re-dispatch reuses the payload stored at the original dispatch, grant included ( hosted-mcp-session.ts:963-978 , leaseDurationMs: 300_000 ), while the live…;"agent reverse-audit (round 3)":the MCP-operation guard — both new routes omit the session.mcpBusy || session.mcpRecovering refusal that /prompt (and /rewind ) apply, so an MCP operation …,另有 9 条。
未检查(工具限制,非阻断):.github/workflows/sdk-java.yml——actionlint 对 workflow 内嵌 shell 的源映射尚未支持。
— DeepSeek/deepseek-v4.1-flash via Qwen Code /review (v0.24.7)
Both conflicts are the same shape — each side added one optional member at the same position — so both members are kept: - hosted-harness-model.ts: this PR's `workspaceContext` input slot stays alongside main's new `textDeltas` (QwenLM#13083). The dispatch call site already forwards both. - hosted-harness-session.ts: `HostedSession.workspaceContext` stays alongside main's new `runtimeLeaseHeld`. Left as-is on purpose: main's recovered-turn path (QwenLM#13083) constructs HostedWorkspaceToolTurn without the tool profile or the context slot, so a taken-over turn still runs without Workspace context. Wiring it would add durable executions to a path whose oracle is already unsettled. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-conflict/jmupvw3d00b
|
@doudouOUC Your two named blockers are both fixed at the current head
Could you re-check at the current head and lift the CR if your probes agree? |
…main The merge folded #13083's workflow growth into the file (23,524 bytes now); the ratchet asks for the number in the same PR.
Both sides reworked the hosted harness turn files: main gained durable Hosted Hooks (H2, QwenLM#13129) and the Hosted Turn takeover (QwenLM#13083) while this branch bound the Workspace context read to the turn's abort signal. The merge keeps main's hook/runtime structure and carries this branch's context-binding semantics. Co-authored-by: Qwen-Coder <[email protected]>
…letting it go silent Review round R5 (Critical R5-1). The two post-adoption load refusals close the Session before it ever registers, so no harness route can ever hand the owed lease back — every discharger resolves Sessions through the registry. A persistent refusal (durable capture evidence, read-only or full workspace store) then re-adopts and re-refuses on every coordinator retry, straining silently until session retirement, and the same silent strand hit the continuation takeover too. The refusals now record the stranded (sessionId, runtimeSessionId) and report it once per identity on stderr, a successful later load of the Session drains the record, and the release wedge stays closed: nothing re-adds a release to either exit, so the transient case this PR's tests pin (release never runs, retried load settles) is untouched. From the same round: - The rewritten runtimeLeaseHeld contract is scoped to the cancellation path — the continuation route keeps its #13083 handback discipline, and the comment names that as the recorded follow-up instead of stating an invariant four continue-route refusals falsify (R4-4, fix-induced). - The handback-failure log names the prompt and session identity, like both sibling release-failure logs (R5-2). - The final-handback test now asserts the release count right after the replay — distinguishing the replay-path discharge from the close-path one — and rewords the comment to match reality (R5-3). - Design docs (EN+ZH) qualify the retirement sentence: a load refused before registration has no session to retire; the record-and-drain path covers it. R4-3's two remaining fixture asks are deferred per the round-5 gate (Critical-only), recorded in the thread. Mutation-checked: dropping the stranded line reddens both load tests, dropping the registration drain reddens the persistent-refusal variant, and dropping the replay-branch release reddens the handback test at count one.











What this PR does
Implements the Harness half of Stage G Turn takeover (#12952, slice G1). A replacement Hosted Harness now loads a Session whose Turn parked at an
await_runtime/results_readycheckpoint: it settles the parked Runtime executions under their originalexecutionCallId(the Broker's durable record keeps that exactly-once), reports the recovery snapshot the Java coordinator already parses (_meta.qwen.daemon.managedRuntimeRecovery), and continues or cancels the Turn through newPOST /session/:id/managed-runtime/continueand/cancelroutes. Assistant text streams durably as activation-scopedmessage.deltajournal events, so a prefix published before a crash survives its writer and the dead owner's prefix is retracted from the public projection by the existing per-epoch machinery.With takeover in place,
--inflight-failoverand--continuation-failoverlose their stale gate and run against the packaged stack: the public route admits the Workspace-bound Session with the fixedhosted-workspace-files/1profile, the runner seeds the Workspace registry/grant as deployment data and opts into durable local Workers, and both modes run in the Hosted MySQL CI job. A new default-offqwen.managed-agent.trusted-actor-headerproperty stands in for the trusted gateway's actor principal on single-host/E2E deployments (documented as never safe on untrusted networks).Why it's needed
The two modes have been gated since #12801 with a reason that went stale when tool-capable Hosted turns landed; the real blockers were (a) no public Workspace admission (G0, #12955) and (b) no Harness-side Turn takeover at all — the Java recovery contract existed but no Harness implemented it, so a killed owner left every in-flight Turn permanently blocked. This closes the gap the proposal puts last: a Session now outlives its Harness process with a proven no-replay continuation, which is the evidence G3's owner-affinity removal is gated on.
Reviewer Test Plan
How to verify
Run the two ungated modes on Linux (they need the W0e durable local-Worker reclaim):
npm run build && npm run bundle mvn -f packages/sdk-java/qwencode/pom.xml -DskipTests -Dgpg.skip=true install mvn -f packages/sdk-java/runtime-broker/pom.xml -DskipTests install mvn -f packages/sdk-java/managed-agent-server/pom.xml clean package npm run test:e2e:managed-inflight-failover npm run test:e2e:managed-continuation-failoverIn-flight must report the same
executionCallIdacross owners,dispatchGeneration0→1,physicalToolExecutions: 1,promptReplayed: falseand one terminal event. Continuation must report one tool execution, two continuation model requests, public text equal to only the replacement's answer (the dead owner's partial is retracted), and one terminal event.npm run test:e2e:managed-session-failover(any platform) must keep passing unchanged. Unit suites:packages/cli(hosted-*),packages/core(managed-runtime), andmvn -f packages/sdk-java/managed-agent-server/pom.xml verifyall pass.Evidence (Before & After)
Before: both modes exited immediately with a stale
not-yet-enablederror blaming the no-tool slice. After (Linux container, MySQL 8.0): in-flight prints{"executionState":"SETTLED","dispatchGeneration":1,"physicalToolExecutions":1,"promptReplayed":false,"terminalTurns":1}; continuation prints{"visibleText":"CONTINUATION_TURN_RECOVERED","continuationModelRequests":2,"physicalToolExecutions":1,"terminalTurns":1}. Both E2E modes also run in thehosted-harness-mysqlCI job on this PR.Tested on
--session-failoverE2E; the two tool-driven modes refuse non-Linux by designEnvironment (optional)
macOS arm64 (JDK 26, Node 24, Homebrew MySQL 26.7 for unit/regression) + colima Docker Linux container for the two failover modes.
Risk & Scope
message.deltacommits one journal transaction per model chunk on tool-capable Hosted turns.Designs: English · 简体中文. Both versions include the same decisions, limits and acceptance criteria.
Linked Issues
Part of #12952 (slice G1) and #12380 (Stage G).
中文说明
本 PR 的内容
实现 Stage G 轮次接管的 Harness 半侧(#12952 的 G1 切片)。替代 Hosted Harness 现在能加载停在
await_runtime/results_readycheckpoint 的会话:按原executionCallId结算挂起的 Runtime 执行(Broker 的持久记录保证恰好一次)、上报 Java 协调器本就会解析的恢复快照(_meta.qwen.daemon.managedRuntimeRecovery)、并经新的POST /session/:id/managed-runtime/continue与/cancel路由续跑或取消该轮次。assistant 文本以 activation 作用域的message.deltajournal 事件持久流式发布,崩溃前已公开的前缀在写者死亡后存活,死亡 owner 的前缀由现有按 epoch 的机制从公开投影中回退。接管就位后,
--inflight-failover与--continuation-failover移除过期门禁并在打包栈上运行:公开路由以固定的hosted-workspace-files/1profile 准入 Workspace 绑定会话,runner 把 Workspace 注册表/授权当作部署数据种子化并启用 durable 本地 Worker,两个模式进入 Hosted MySQL CI 任务。新的默认关闭属性qwen.managed-agent.trusted-actor-header在单机/E2E 部署中充当可信网关 actor principal 的替身(文档明确:不可信网络上绝不能开启)。原因
两个模式自 #12801 起被门禁,其理由在可带工具的 Hosted 轮次落地后已过期;真正的阻塞是 (a) 公开 Workspace 准入缺失(G0,#12955)和 (b) Harness 侧轮次接管完全不存在 —— Java 恢复契约早已存在但没有 Harness 实现它,被杀的 owner 让每个在途轮次永久阻塞。本 PR 补上提案刻意放在最后的那块:会话如今能比它的 Harness 进程活得更久且续接不重放,这正是 G3 取消 owner 粘性所依赖的证据。
评审验收计划
如何验证
在 Linux 上跑两个解禁模式(它们需要 W0e 的 durable 本地 Worker 回收):
npm run build && npm run bundle mvn -f packages/sdk-java/qwencode/pom.xml -DskipTests -Dgpg.skip=true install mvn -f packages/sdk-java/runtime-broker/pom.xml -DskipTests install mvn -f packages/sdk-java/managed-agent-server/pom.xml clean package npm run test:e2e:managed-inflight-failover npm run test:e2e:managed-continuation-failoverin-flight 必须报告跨 owner 复用同一
executionCallId、dispatchGeneration0→1、physicalToolExecutions: 1、promptReplayed: false且只有一个终态事件。continuation 必须报告工具只执行一次、两次 continuation 模型请求、公开文本只含替代方的回答(死亡 owner 的残段被回退)、一个终态事件。npm run test:e2e:managed-session-failover(任意平台)必须保持通过不变。单测:packages/cli(hosted-*)、packages/core(managed-runtime)与mvn -f packages/sdk-java/managed-agent-server/pom.xml verify全部通过。前后证据
改前:两个模式立即以过期的
not-yet-enabled错误退出,把原因指向无工具切片。改后(Linux 容器,MySQL 8.0):in-flight 打印{"executionState":"SETTLED","dispatchGeneration":1,"physicalToolExecutions":1,"promptReplayed":false,"terminalTurns":1};continuation 打印{"visibleText":"CONTINUATION_TURN_RECOVERED","continuationModelRequests":2,"physicalToolExecutions":1,"terminalTurns":1}。两个 E2E 模式同时在本 PR 的hosted-harness-mysqlCI 任务中运行。验证平台
--session-failoverE2E;两个工具驱动模式按设计拒绝非 Linux环境(可选)
macOS arm64(JDK 26、Node 24、Homebrew MySQL 26.7 用于单测/回归)+ colima Docker Linux 容器跑两个 failover 模式。
风险与范围
message.delta在可带工具的 Hosted 轮次上每个模型 chunk 提交一次 journal 事务。设计文档:English · 简体中文。两版包含相同的决策、限制与验收标准。
关联
属于 #12952(G1 切片)与 #12380(Stage G)。