Uh oh!
There was an error while loading. Please reload this page.
feat(workhub): add typed action gate - #3818
Conversation
Astro-Han
left a comment
There was a problem hiding this comment.
Update on b65c5c2313:
CODE NO-GO — 4×P2
- P2-1 correction without explicit intent fails
confirmation_requiredbefore Stop. - P2-2 dropping
replacewhen source outside bounded set silently forks while reporting corrected. - P2-3 concurrent replacements lack source lock → fan-out to different targets after Stop.
- P2-4
record48 KiB vs retry probe 32 KiB → retry after success hitscommit_outcome_unknown.
Fix: make correction carry explicit stop text or relax gate; keep replace mandatory or fail; add source lease across actions; align byte limits. Hosted test: SUCCESS does not waive these.
简体中文
四处权限/一致性阻塞。
Astro-Han
left a comment
There was a problem hiding this comment.
Update on 48666a0ceb:
CODE NO-GO — 3×P2
- P2 candidate path does not write
admitted.targetTurnIdto local map → natural-language correction cannot send replacement (fail-closed at 814). E2Eworkhub-reconstructionfails (32879372758). - P2 Stop-then-submit with swallowed replay leaves source stopped and target unconfirmed → retry gets
stop_not_owned. - P2 48 KiB user + 8 KiB assistant JSON record can exceed 72 KiB retry lookup after escaping → retry becomes
commit_outcome_unknown.
简体中文
存在路径阻塞与重放问题。
Astro-Han
left a comment
There was a problem hiding this comment.
Update on f2785c5dc0:
CODE NO-GO — 2×P2 plus required check red
- P2 Stop-then-submit with swallowed replay leaves source stopped and target unconfirmed → retry
stop_not_owned. - P2 JSON record escaped bytes exceed 72 KiB retry lookup → retry
commit_outcome_unknown.
Note: natural-language correction P2 from prior head is now closed (gated receipt saved). Hosted test: FAILURE on format check (new test ternary).
简体中文
仍有两处阻塞,另需格式化修复。
Astro-Han
left a comment
There was a problem hiding this comment.
Update on 05d3d26e20:
CODE NO-GO — 2×P2 (carry-over, formatting fix only)
- P2 Stop-then-submit without replay on target failure → retry
stop_not_owned. - P2 JSON escaped record may exceed 72 KiB retry lookup →
commit_outcome_unknown.
Formatting failure from prior head fixed; logic unchanged. Hosted test: QUEUED — not green.
简体中文
仍有两处阻塞。
Astro-Han
left a comment
There was a problem hiding this comment.
Update on 92d0947890:
[P2] #replacementRecoveries can exhaust to Host-wide outage
Capacity 256 is only released on Stop failure or target success. After Stop succeeds, permanent target rejections (e.g. session_busy) keep the recovery forever with no TTL/reaper — 256 failures exhaust replacements Host-wide as host_not_ready until restart.
Fix: give recoveries reconciled lifecycle / TTL for permanent failures.
Checks on 92d0947890d2aeec9a6363f17b68ff0850deb5b0 are test: SUCCESS — code is NO-GO.
简体中文
异常恢复容量会耗尽。
Astro-Han
left a comment
There was a problem hiding this comment.
I reviewed this head and found no blocking issues.
Fixes replacement recovery lifecycle (typed failure releases checkpoint, unknown retains fingerprint with 5-min TTL) — closes prior 256-capacity outage; definitive/unknown regression tests pass. Hosted test: SUCCESS (32924223915).
No new P0-P3.
简体中文
该头无新增阻断。Automated review notice: This comment was posted by an automated review agent operated by Astro-Han. It is not an independent human review and does not replace one.
3beac53 to
9532d2dCompare
Astro-Han
left a comment
There was a problem hiding this comment.
I reviewed this head and found no blocking issues.
Fixes Action Gate classification to check correction before explicit new-session creation; replace/Stop removed from production. Hosted test: SUCCESS (32935316010) and windows_recovery: SUCCESS.
No P0-P3.
简体中文
该头无阻断。Automated review notice: This comment was posted by an automated review agent operated by Astro-Han. It is not an independent human review and does not replace one.
c9d74e5 to
2650a94Compare
Astro-Han
left a comment
There was a problem hiding this comment.
I reviewed this head and found no blocking issues.
Completes typed Action Gate: opaque candidateRef with fresh validation, idempotent replay with fingerprint conflict, self-route/target-waiting fail-closed; hosted test+windows_recovery SUCCESS.
No P0-P3.
简体中文
该头无阻断。Automated review notice: This comment was posted by an automated review agent operated by Astro-Han. It is not an independent human review and does not replace one.
2650a94 to
781a12fCompareGenerated-by: Codex
Generated-by: Codex
Preserve Runtime-admitted root receipts for natural-language corrections and avoid deleting newer ownership after a concurrent Stop.\n\nGenerated-by: Codex
Generated-by: Codex
Resume the exact target submission after a replacement Stop and budget summary replay reads for worst-case JSON escaping.\n\nGenerated-by: Codex
Release recovery checkpoints after definitive target failures and expire uncertain outcomes after a bounded reconciliation window.\n\nGenerated-by: Codex
Generated-by: Codex
Generated-by: Codex
Recognize correction cues independently from punctuation, politeness, and the creation clause so focused corrections fail closed without weakening no-focus creation. Generated-by: Codex
781a12f to
3844445Compare
Astro-Han
left a comment
There was a problem hiding this comment.
Reviewed the Slice 4 gate as a whole rather than line by line, since the interesting question is whether the admission boundary actually holds.
It does. Two things I checked specifically because they looked risky and turned out to be right:
Epoch 51 is correctly derived above main's 50, with its own note — no collision.
The create_new create→submit pair is non-atomic but safe. A submit failure clears the replay entry, so a retry re-enters create. That is fine because workHubCreatedSessionId(actionId) is deterministic and #create probes probeStableSessionCreate by request fingerprint, returning success for existing. Same action id and same input yield the same fingerprint, so the retry reuses the session instead of conflicting or orphaning it. Worth a comment at the effect site, because the safety depends on a property two packages away.
The candidate-ref indirection is the right shape: a strategy proposal cannot name a Session id, and refreshing the set before admission means a model-selected reference cannot outlive the state it was chosen from.
Approving. Three P3s and one question inline; none of them blocks, and I am merging on that basis.
AI use: Claude Code assisted with source investigation; the analysis and conclusions are my own.
简体中文
我是把 Slice 4 的 gate 当成一个整体来看的,因为真正要回答的问题是这个准入边界站不站得住。
站得住。有两处看着危险、核完确认是对的:
epoch 51 在 main 的 50 之上正确推导,并带了自己的说明,没有撞号。
create_new 的 create→submit 非原子,但是安全的。 submit 失败会清掉重放记录,重试因此会重新进入 create。这没问题:workHubCreatedSessionId(actionId) 是确定性的,而 #create 通过 probeStableSessionCreate 按请求 fingerprint 探测,对 existing 直接返回成功。相同的 action id 和相同输入产生相同 fingerprint,所以重试会复用同一个 Session,既不冲突也不会留下孤儿。建议在 effect 处加一句注释,因为这个安全性依赖的是两个包之外的性质。
candidateRef 这层间接是对的形状:策略提案无法指名 Session id,而准入前刷新候选集意味着模型选出的引用不会比它所依据的状态活得更久。
Approve。行内三条 P3 和一个问题,都不阻塞,我据此合并。
| result: WorkHubSubmission, | ||
| ): result is Extract<WorkHubSubmission, { kind: 'submitted' }> { | ||
| return result.kind === 'submitted' && !result.steered; | ||
| export function workHubSurfaceFailure(error: unknown): WorkHubSurfaceFailure { |
There was a problem hiding this comment.
[P3] This reconstructs a typed distinction by matching English prose, in the PR whose thesis is that the distinction is typed.
The code already exists: WorkHubActionGateFailureCode is candidate_set_stale | candidate_unavailable | target_waiting_for_user | self_route | action_conflict. The coordinator's #act then discards it, collapsing everything to session_busy or operation_conflict plus a message, and this function greps the message to get it back.
Every branch matches correctly today, which is why this is P3 and not higher. What makes it worth fixing anyway is that nothing protects it: the producer and the consumer are in different packages, so rewording a Host error silently downgrades a user from specific guidance to delivery_failed, with no compile error and no test that spans the boundary. 'source or target is not in' already matches no message I can find in this diff, which is roughly what that drift looks like.
Carrying the gate's code through the operation failure instead of flattening it would remove the second representation entirely.
| const candidates = await this.candidates(); | ||
| if (candidates.candidateSetId !== input.candidateSetId) { | ||
| throw new WorkHubActionGateFailure( | ||
| 'candidate_set_stale', |
There was a problem hiding this comment.
[P3] Freshness is enforced set-wide, but the invariant that matters is per-target. candidateSetId digests every candidate's updatedAt and status, so a message in any unrelated Session rotates it and this rejects a delegation whose own target never changed.
The window is small — the routing policy is synchronous, so it is about one routingEvidence() round trip — which is why P3. But it is the multi-Session case that WorkHub exists for, and that is exactly when other Sessions are producing messages.
I do not think there is a one-line fix: candidateRef is derived from candidateSetId, so per-candidate validation would need refs bound to per-Session state instead of to the set. Worth deciding deliberately rather than inheriting.
| #assertTarget(target: WorkHubCoordinationCandidate): void { | ||
| if (target.sessionId === WORKHUB_COORDINATION_SESSION_ID) { | ||
| throw new WorkHubActionGateFailure('self_route', 'WorkHub cannot delegate to itself'); |
There was a problem hiding this comment.
[P3] Unreachable. isCandidateSession already filters out the Coordination Session via isWorkHubCoordinationSessionTarget, and this only runs against a candidate found in that set, so self_route cannot fire. Either drop the branch or, if it is meant as a belt-and-braces assertion against a future candidate source, say so — as written it reads like a live guard.
| } | ||
| /** @internal Transitional R2.4 regression harness; application code must use the Action Gate. */ | ||
| export function createLegacyWorkHubControllerForTests(deps: { |
There was a problem hiding this comment.
Not a finding — a question about the exit. The shared implementation now carries both behaviours behind deps.coordination, and production only ever takes one of them, so the other set of branches lives in production code kept reachable only by tests. That is the parallel path AGENTS.md asks us not to leave behind.
You have documented it as transitional, which is the right call for a slice boundary, so I am not treating it as a defect. What I would like recorded somewhere durable: which change deletes createLegacyWorkHubControllerForTests and the deps.coordination forks — Slice 5? If the answer lives only in this PR description it tends to become permanent.
Summary
Implements WorkHub Slice 4 from #3492 on top of the merged Slice 3 coordination-session work:
answer_here,delegate_existing,create_new, andclarify;This changes behavior: destructive cross-Session correction is deferred to Slice 5. Production recognizes correction before explicit creation, fails closed before a second delegation, and tells the user to stop the original work from its Session. Explicit creation without correction context remains available.
Refs #3492
Verification
89e4e20: normal variants containingplease, an em dash,请, or不对still reachedcreate_newand failed withWorkHub Action Gate returned an unexpected disposition.create_new.WorkHub defers destructive correction until linked delegation existspassed against the fixed production build (1 passed). The exact-head CI also runs the full Desktop E2E surface.git diff --checkpassed.2650a94ff5396454f0b239acced341a10273ce42CI:testrun32948070394/ job98113143994passed, including the full Desktop E2E surface;windows_recoveryrun32948070439/ job98113065733passed.UI evidence
Before (
ba1eec3): destructive correction exposed a “更正目标” control and replaced the running target.After (
89e4e20): the correction control is absent and production shows the Slice-5 deferral before a second delegation.Both screenshots were captured from the real Electron E2E fixture using the same two-Session correction scenario. The before fixture passed 1/1 at
ba1eec3; the after fixture passed 1/1 on the fixed branch.Safety and authority boundaries
Slice boundary: destructive correction is deferred to Slice 5
This PR intentionally does not expose
replaceorStopthrough the Slice 4 Action Gate.A destructive correction must prove durable linkage between the original delegation, the exact root Turn WorkHub owns, the correcting action, and the replacement submission. That linkage and recovery across the non-atomic Stop-to-submit seam must survive Runtime Host restart. Host-lifetime maps, TTLs, and retry lanes cannot provide that authority after restart.
Slice 4 therefore owns the typed gate, bounded candidates, fresh validation, replay, and non-destructive
answer_here,clarify,delegate_existing, andcreate_new. Slice 5 will persist delegation/action linkage first, then add natural-language replacement and exact Stop ownership.The decoder rejects
replace; the production Gate has no Stop effect; Desktop exposes no correction picker. The legacy R2.4 correction path is test-only.AI use
Select exactly one:
Tool(s) and scope: Codex implemented the Runtime Host/Desktop changes, regression tests, review fixes, and verification. Material commits include
Generated-by: Codextrailers.Checklist
Does this PR entail a change in behavior?