Repository navigation
Conversation
added 30 commits
September 2, 2026 18:06
Legacy deepseek_theme token module was never user-selectable; expose its flat phosphor-teal era as ThemeId::UnderwaterRetro (underwater-retro, retro) with underwater ink roles on a flat unpainted ground.
…er_layer dispatch)
Merge groups of tiny same-area #[test]/#[tokio::test] fns into single scenario fns; every statement/assert moved verbatim into its own block scope, zero assertions dropped (assert-line counts identical before/after in each file). No test deleted, so no golden-file / cucumber / cross-test justification needed. Non-test code untouched; cfg-gated, ignored, should_panic, and early-return tests left in place. #[test]/#[tokio::test] counts: - crates/tui/src/config/tests.rs: 413 -> 337 - crates/tui/src/core/engine/tests.rs: 412 -> 340 - crates/tui/src/tui/app/tests.rs: 301 -> 218 - crates/tui/src/client.rs (tests module only): 206 -> 133 - crates/tui/src/runtime_api/tests.rs: 171 -> 162 - crates/tui/src/runtime_threads/tests.rs: 170 -> 159 Total: 1673 -> 1349 (-324) via 129 <area>_scenario fns. Verify: RUST_MIN_STACK=16777216 cargo test -p codewhale-tui --lib -- config::tests:: core::engine::tests:: tui::app::tests:: client::tests:: runtime_api::tests:: runtime_threads::tests:: => 1466 passed, 0 failed, 6 ignored (pre-existing ignores). Note: clippy flags pre-existing too_many_arguments in tui/ocean.rs (untouched by this slice).
… labels (#59) * fix(context): prompt instructions source as file name The <project_instructions source="..."> label carried the absolute path of the loaded AGENTS.md. The label sits inside the pinned system prompt, so loading an unchanged file from a moved or recased directory rewrote the label and emitted a spurious <context_update> history append, and it leaked the absolute project path into a provider-bound prompt label. The label now reports the file name only. Directory identity is discoverable at runtime via the shell; the label is an origin tag, not a locator. A regression test pins byte-identity of the block for identical content loaded from two different directories. Signed-off-by: qiuYliangM <185303122+qiuYliangM@users.noreply.github.com> * refactor(context): share the project_instructions source label helper v0.9.12-line adaptation: the helper lands in the split-out project_context/types.rs and is re-exported through project_context. This line's compaction was fully restructured, so the verbatim AGENTS.md reinjection (project_instructions_section) no longer exists and the old-line forkguard_compaction_reinject_* tests have no injection point to port; the <!-- global: {path} --> label in merge_global_and_project_instructions is deliberately untouched because the home path does not drift with the workspace. - Extract the shared project_instructions_source_label helper (file name, fallback "project"); as_system_block and the context report's base_source_entries now share it instead of keeping one file-name logic copy each. - Rename the source-relativization regression test with the forkguard_ prefix. Signed-off-by: qiuYliangM <185303122+qiuYliangM@users.noreply.github.com> * fix(context): apply the file-name source label to the constitution block Review round 1 on the split PR: the constitution block sits in the same pinned system-prompt region as <project_instructions> and still carried an absolute source path, while its loader always resolves the fixed relative path (".codewhale/constitution.json"), so the shared helper applies with no new decision. The locator stays available via constitution_source_path in the report and /constitution. Also aligns the story with what this line actually does: the pinned system prompt is never rewritten (workspace drift reaches the model as a bounded <context_update> history append), so the honest benefits of the file-name label are (1) no spurious context_update append after a directory move or recase, (2) absolute project paths stay out of provider-bound prompt labels, and (3) one shared helper for both label renderings. Ancestor-chain and project-rule labels keep their absolute spellings for now; relativizing those needs a repo-relative decision and is tracked in the parent repo (Pinvou/pinvou-agent#514). Also includes: a unit test for the helper's "project" fallback, the test comment and inline comment translations to match the base language, and an assert message scoped to what the regression test actually pins. Signed-off-by: qiuYliangM <185303122+qiuYliangM@users.noreply.github.com> * docs(context): correct the constitution source comment The rendered source is a runtime-canonicalized absolute path; only its final segment (REPO_CONSTITUTION_RELATIVE_PATH) is a compile-time constant. The basename-stability claim holds, but the earlier wording implied the whole path was constant. Signed-off-by: asto18089 <asto18089@126.com> --------- Signed-off-by: qiuYliangM <185303122+qiuYliangM@users.noreply.github.com> Signed-off-by: asto18089 <asto18089@126.com> Co-authored-by: qiuYliangM <185303122+qiuYliangM@users.noreply.github.com> Co-authored-by: asto18089 <asto18089@126.com>
…est clippy (#60) * chore(baseline): resync the tui changelog slice, surface facts, and test clippy Split out of CodeWhale#56 per CONTRIBUTING.md: changelog entries are written on main at merge time and PRs carrying changelog hunks are asked to strip them, so the DDG-to-Bing degradation entry (behavior already merged in #44 via ff299f9; the root CHANGELOG already carried it, the packaged tui slice did not) moves here together with its generated web twin. The surface-facts trio moves with it because no gate on the pinvou3-clean lane checks them; note v0.9.13 published on 2026-09-14, so the next receipts run should refresh latestPublishedRelease again. The redundant-closure cleanup in engine/tests.rs rides here because it blocks the master/main and release lint lanes, not the fork lane. Signed-off-by: asto18089 <asto18089@126.com> * fix(web): evolve telemetry trust prose with the release bump The surface-facts contract interpolated latestPublishedRelease.version into a required 'published <version> release asked first' phrase. Once the pin moves to v0.9.12 that demanded phrase becomes false: 0.9.12 counts by default and never asked. Name the historically fixed asking release instead, matching the contract's upstream evolution: prose says 'earlier 0.9.11 release asked first' (en + zh), the test asserts it as a literal, and the roadmap/faq/telemetryLead copies follow. Catalogs regenerated with npm run i18n:gt -- export; npm test 407/407 green. Signed-off-by: asto <asto18089@126.com> * test(web): anchor the asking-release phrase across site faces Review follow-up: the telemetry trust prose this PR evolves is mirrored across the en/zh dictionaries and the faq/roadmap pages with no test anchor, so a future edit could update one face and silently leave the others behind. Assert the fixed 'earlier 0.9.11 release asked first' phrasing (and its zh mirror) in every face and reject the interpolated 'published 0.9.11' wording; make providerCountDefinition version-free ('the providers of any published release') so the same stale-pin drift cannot recur there; refresh the stale comment above the telemetry assertions. Signed-off-by: asto18089 <asto18089@126.com> --------- Signed-off-by: asto18089 <asto18089@126.com> Signed-off-by: asto <asto18089@126.com>
#61) * fix(fork): route the stopship scout through tool_search and grep_files The read-only scout brief commanded a `File` call with `action` `search_content`, but `File` is a hidden replay-only alias (`model_visible=false`): it never reaches a model-visible catalog, and the child step loop fails any call outside the child's policy-filtered catalog outright. The release-acceptance explore gate's first step therefore could not succeed; dispatch-by-alias only worked below the catalog gate (replay tests calling `registry.resolve` directly). Route the scout through the live surface instead: response 1 activates the deferred `grep_files` with one `tool_search` call, response 2 runs the same alternation search through `grep_files` (path/include/pattern/max_results/context_lines), and response 3 returns the verdict. The step and token caps are unchanged; the evidence turn grows one small activation response. Guard both halves so neither side silently drifts again: one forkguard test pins the scout surface to an active `tool_search` plus a deferred, searchable `grep_files` with no `File`, and another pins the fixture definition to catalog-visible tool names only. Signed-off-by: asto <asto18089@126.com> * test(fork): align stopship guards with scout repair The review of #61 caught one real break and three guard-fidelity gaps: - js_authoring.rs still pinned the retired `File`/`search_content` scout wording, so the workspace suite and CI went red on this branch. Pin the new three-response contract instead. - The scout surface guard built its registry with no explicit scope, while the fixture scout really runs under the read-only lowering `["File"]`. Build it with that scope so the alias-family intersection that keeps grep_files discoverable is guarded too. - The brief guard only denied backticked `File` and bare `search_content`; deny the hidden replay aliases as whole words so an unbackticked citation cannot slip past. - Add the behavioral counterpart: one tool_search call must make grep_files dispatchable through execute_from_surface, and a File call must keep failing the catalog gate. Signed-off-by: asto <asto18089@126.com> --------- Signed-off-by: asto <asto18089@126.com> Signed-off-by: asto18089 <asto18089@126.com>
* fix: preserve tool rounds after compaction Signed-off-by: z27014 <zhuo.wenpei@h3c.com> * fix: normalize compacted tool rounds on chat wire Signed-off-by: z27014 <zhuo.wenpei@h3c.com> * fix: preserve real user boundary during recompaction Signed-off-by: z27014 <zhuo.wenpei@h3c.com> * fix: preserve compacted turns across restores Signed-off-by: z27014 <zhuo.wenpei@h3c.com> * fix: identify generated compaction summaries Signed-off-by: z27014 <zhuo.wenpei@h3c.com> * fix: scope restored topology wire merging Signed-off-by: z27014 <zhuo.wenpei@h3c.com> * fix(tui): repair the compaction checkpoint tiers and pre-fix restore order The checkpoint is one engine-owned history message, but the tiers that find, delete, and replace it disagreed with the tier that reorders it. Restore and recompaction decided by marker substring, so an ordinary user turn that quoted the header was deleted — silently, because the coverage floor used the same substring and exempted the message it lost — and `/edit` still treated the generated summary as an editable turn. Recognize the carrier by structure instead: a user message whose first text block begins with the summary header and whose remaining block, if any, is the engine-written provenance marker. Sessions saved before that marker existed carry the bare summary, so the single-block header-prefix form stays recognized; only the provenance-stamped form may be relocated or merged on the wire, which is what keeps a genuine turn that pastes a header in place. Sessions saved before the placement fix also stay broken across restore: their topology sidecar was appended to the end of the replacement history, request rewriting moves only the summary, and the first request after restore is `tool -> user` again. Relocate the restored carrier to the placement anchor — after the prompt its summary merges into, before that round's assistant/tool chain — and teach the recompaction replay to drop a superseded restored carrier instead of stacking a second one. One anchor definition now serves save-time placement, restore repair, and request-time relocation, replacing three copies that had already drifted on which messages count as a prompt. The summary relocation also no longer requires the header text to be the current one: a pre-0.9.6 carrier that begins at the legacy marker carries the provenance block too, so it is relocated like any other generated checkpoint. Signed-off-by: z27014 <zhuo.wenpei@h3c.com> --------- Signed-off-by: z27014 <zhuo.wenpei@h3c.com>
…t state host-side (#57) * fix(computer-use): honor the zoom raster contract and keep ssh element state host-side Two model-facing defects in the codewhale-cu MCP server, found by the 2026-09-13 Pinvou prompt/tool/skill audit: - zoom told the model to aim from the returned child raster (tools.mjs: coordinate targets are 'the latest returned raster (screenshot or zoom)'), but the server never rebound the raster after a zoom, so child pixels were resolved against the stale full shot and clicks landed in the wrong place. After a local zoom, rebind the raster frame offset by the crop region at the parent scale (all three desktop backends crop 1:1 via sips/GDI/ffmpeg). - over ssh the agent is a fresh process per call, so get_app_state never issued a state_id and element actions dead-looped on 'call get_app_state again'; zoom and recording_* failed with misleading backend errors (or orphaned a remote ffmpeg). Element state is now remembered host-side (prepareWireArgs already resolves targets host-side), element states are checked against the acting computer (state_computer_mismatch), and zoom/recording_start/stop/status fail closed over ssh with named, actionable reasons. Tests: new raster.test.mjs pins the child-raster geometry; new server-protocol tests cover the ssh state_id round trip, the cross-computer mismatch, and the ssh fail-closed reasons (POSIX-only, like the existing ssh shim tests). Signed-off-by: asto18089 <asto18089@126.com> * fix(computer-use): make the zoom rebind bind, and work it over ssh Review of the first cut found the rebind computed the child frame but bindRaster reads `points`, so the zoom fix never took effect - and the tests only covered the pure geometry function, so CI stayed green on a no-op. The rebind now stores the computed origin (bindRaster accepts `origin` for zoom children) and the child file becomes the bound raster; all three desktop backends also advance their own last raster to the child, so chained zooms crop from the child the way the "last-raster pixels" contract promises. zoomChildRaster is unchanged. ssh zoom is implemented instead of refused: the host remembers the remote raster path from the screenshot receipt, injects it as the crop source, and rebinds the child geometry - mirroring the screenshot flow that already ships "pixels stay remote + scp". Recording stays fail-closed over ssh (a recorder cannot survive the one-shot agent), with one accurate reason for all three tools. While wiring the zoom e2e, prepareWireArgs turned out to forward coordinate targets as raw raster pixels over the wire, so the remote clicked at pixel values as if they were screen points. Coordinate and element targets are now resolved host-side for ssh exactly as prepareLocalArgs does locally; computer_remove also evicts remembered element states so a re-registered id cannot reuse stale observations. Tests: the ssh shim gains a canned remote endpoint (env-gated) that logs every wire payload, so the element-state loop, the zoom rebind geometry (child pixel (60,45) must click at screen (90,68)), and the eviction are pinned deterministically on headless CI instead of vacuously. Tool descriptions, both SKILL.md files, the plugin README, and plugin.json now match the actual ssh behavior. Signed-off-by: asto18089 <asto18089@126.com> * fix(computer-use): forget per-computer state on re-register and align ssh guards with upstream Round-2 review of this PR found the protection it advertises only half-true: computer_remove evicted a computer's remembered element states and raster binding, but computer_register updates an existing id in place and evicted nothing - so re-pointing an id at another host kept the old host's observations (still matching the id, so they dispatched onto the new host) and its raster geometry (silent wrong clicks). Registration now detects an existing entry and drops the id's backend cache, raster binding, and element states; the eviction lives in one forgetComputer helper shared with computer_remove. The ssh fail-closed family also gains the sibling fix upstream ships: left_mouse_down over ssh fails closed (persistent_session_required) - a press outlives the one-shot agent process, and if the follow-up release never arrives the remote button stays stuck with nothing left to detect or release it. Error codes align with upstream's computer-use plugin (state_wrong_computer instead of state_computer_mismatch; persistent_session_required instead of unsupported_over_ssh) to avoid re-port churn. Smaller review items: rebindAfterZoom says so when it runs with no bound raster instead of returning silently, and zoomChildRaster returns the guarded scale instead of raw prev.scale (the raster test had pinned scale: undefined as expected - flipped). Docs now say exactly what works over ssh: recording_list still lists remote files (the README wrongly grouped it into the refusal), and ssh zoom delivers correct aiming while the crop file can only be viewed out-of-band (the plugin has no pull tool). New tests: in-place re-register eviction, ssh press-and-hold refusal, drag endpoints resolving against the rebound child raster, and the foreign-source zoom note path. Suite: 44/44. Signed-off-by: asto18089 <asto18089@126.com> * fix(computer-use): make win32 left_mouse_down actually press the button A review pass found the win32 press-and-hold implementation silently reporting success without pressing: the script argument read target ? "SetCursorPos...;" : "" + "mouse_event LEFTDOWN..." and since + binds tighter than ?:, a target-bearing call (the model's standard usage) parsed as target ? (cursor move only) : (move + press), so the cursor moved, nothing was pressed, and the receipt still said action_sent: true. Parenthesize the conditional so the press is part of both arms; only the no-target arm skips the cursor move. Pre-existing bug on the base, fixed here because this PR rewrites the tool's per-transport guidance and left_mouse_down is its subject. No test: the script body is only assembled on win32, where this suite cannot run; the change is a pure JavaScript precedence fix. Suite: 47 tests (46 pass, 1 platform-skip). Signed-off-by: asto18089 <asto18089@126.com> Signed-off-by: asto <asto18089@126.com> * fix(computer-use): route press-and-hold guidance to computers that support it The round-3 disclosure pass claimed more than the backends deliver: left_mouse_down was advertised as "Local and hdc computers only" and the ssh refusal told the model to "use a local or hdc computer", but the HarmonyOS backend throws on press-and-hold (uitest uiInput exposes no press/release) - the guidance would route the model into a guaranteed backend error, the exact model-facing falsehood this PR set out to remove. Describe press-and-hold as local-only and name left_click_drag as the hdc alternative in the tool description, the ssh refusal, and the README. Two smaller description mismatches while in the file: recording_status said "unknown over ssh" where it actually fails closed with persistent_session_required (matching the start/stop wording, so the model does not retry hoping for a status), and switch_display now discloses that the choice dies with the one-shot ssh agent process - pass display to screenshot/recording_start instead. Signed-off-by: asto18089 <asto18089@126.com> Signed-off-by: asto <asto18089@126.com> * test(computer-use): pin zoom crop pixels, chained advance, and remove eviction Review found the ssh shim tests pin only the host side of the zoom contract: the remote 1:1 crop (sips --cropOffset, ffmpeg crop, GDI Rectangle) and the backends' last-raster advance had no automated coverage, so a swapped crop argument would keep the suite green while real clicks landed wrong. Add a minimal PNG codec (8-bit RGB/RGBA, non-interlaced, PNG filters 0-4 on decode) and pixel-level zoom tests against the real croppers: sips on macOS, ffmpeg on linux (skipped elsewhere). A deterministic gradient makes every sampled pixel identify its source coordinate, so a swapped cropOffset/crop order, a wrong region, or a dropped last-raster advance all fail the assertions; the source-less chained zoom also pins that the child becomes the last raster. Tests set CODEWHALE_CU_RECORDINGS_DIR so the recordings mkdir never touches the real home. The computer_remove eviction test could not see the difference between remove forgetting states and the re-register eviction covering for it: probing through a second registered computer id surfaces the distinction (state_wrong_computer would mean the state survived the remove). Verified both directions: dropping the darwin advance or the remove's eviction turns the new tests red. Suite: 47 tests, 46 pass + 1 platform-skip. Signed-off-by: asto18089 <asto18089@126.com> Signed-off-by: asto <asto18089@126.com> * fix(computer-use): close switch_display and repoint races over ssh Review of this PR found two model-facing holes in the ssh contract it establishes: - switch_display was still dispatched to the one-shot remote agent, where it sets backend state in a throwaway process and returns ok:true while the choice silently evaporates — the exact silent-success class the recording fail-closed family fixes. It now fails closed with persistent_session_required before any ssh spawn, naming the way out (pass display to screenshot/recording_start), and the tool description says it refuses instead of implying the setting quietly dies. The screenshot description also gains the zoom carve-out: coordinate targets refer to the latest returned raster, not whichever raster the sentence happens to sit on. - get_app_state remembered the observed tree, and zoom rebound the raster, AFTER the wire await; a racing computer_remove + computer_register could repoint the id at another host in between and the old host's observation or crop path would be stored under an id that now names the new host (state_wrong_computer cannot catch it, and the wrong click lands silently). assertComputerUnchanged re-reads the registry after the await and fails closed with computer_repointed before any host-side memory write — applied to the screenshot/zoom/ get_app_state writes on both the ssh and local/hdc branches, which race the same way through await getBackend. Verified: switch_display against an unreachable ssh host returns the error in ~120 ms with no ssh spawn; npm test unchanged apart from the test-commit fixes (32 pass, 3 pre-existing environmental fail, 12 skip on win32). Signed-off-by: asto18089 <44870036+asto18089@users.noreply.github.com> * test(computer-use): make ssh shim tests win32-safe and leak-free - The pre-existing registering-ssh test had no win32 skip: the extensionless POSIX shims cannot execute on Windows, so node fell through to the real ssh.exe and the test attempted a genuine network connection to box.test. It now skips on win32 like the newer shim tests (whose skip messages point at it). - The ssh drag-endpoints test silently relied on the previous test's zoom binding, so it failed under --test-name-pattern; it now performs its own screenshot+zoom setup and binds the same child frame itself. - The after() hook never removed binDir, leaking cu-proto-bin-* on every run; the two zoom pixel tests leaked cu-zoom-* dirs on CI (macOS/ linux) — both now clean up (t.after, so cleanup survives assertion failures). Suite on win32: 32 pass, 3 pre-existing environmental fail (CRLF manifest regex, two URL.pathname agent-spawn tests), 12 skip — the registering-ssh test moved from fail to skip as intended. Signed-off-by: asto18089 <44870036+asto18089@users.noreply.github.com> * docs(computer-use): fix press-and-hold grammar in README "cannot keep a recorder alive or guarantee a press its release" was missing a word; a press must outlive its release. Signed-off-by: asto18089 <44870036+asto18089@users.noreply.github.com> * fix(computer-use): compare the whole connection when guarding in-flight results The repoint guard compared only transport and host, but a re-register merges in place and can repoint an id by changing just the port (host:22 and host:2222 can be different machines behind NAT), the user, or the hdc target - hdc entries have no host, so both sides compared undefined === undefined and any device swap passed. The guard now compares a connection key built from transport/host/port/user/target; reproduced end-to-end before the fix: an observe in flight on box.test:22 plus a re-register to box.test:2222 returned a state_id that then dispatched the old host's element path onto the new registration. computer_register now also evicts remembered state only when the connection actually changed. The unconditional eviction silently reset a local computer's display choice and orphaned a running recorder on a byte-identical re-register; a no-op is not a repoint. Signed-off-by: asto18089 <44870036+asto18089@users.noreply.github.com> * fix(computer-use): validate zoom region on linux and win32 like darwin darwin.zoom rejects a malformed region with a named ExecError before touching anything; linux and win32 dereferenced region directly, so a missing, short, non-numeric, or negative region died as a TypeError inside the ffmpeg argv or the PowerShell script instead of the named error the tool contract promises. Both now apply darwin's guard verbatim, before the ffmpeg tool probe and the recordings dir setup so the check is cheap and cross-platform testable. Signed-off-by: asto18089 <44870036+asto18089@users.noreply.github.com> * fix(computer-use): give ssh holds wire time and scope the switch_display way out - hold_key runs down, sleep, and up inside the one remote call, but the ssh wire timeout was a flat 30s while hold_key allows 30s durations - a full-length hold was killed at the wire right as it finished, leaving the remote key down with the agent process gone (the exact stuck-state class the left_mouse_down refusal exists for). The timeout now adds the hold duration on top of the 30s base. - The switch_display refusal's way out, pass display to screenshot/recording_start, is only real on macOS: linux and windows screenshots grab the whole desktop/virtual screen and ignore display. The refusal and the tool description now say so instead of promising a knob that does nothing on two of the three remote platforms. Signed-off-by: asto18089 <44870036+asto18089@users.noreply.github.com> * test(computer-use): pin the repoint guard, eviction scope, and ssh refusals The round-3 guards shipped with two of their number untested. Now pinned, each reverse-verified (reverting the fixed behavior turns the test red): - computer_repointed: a get_app_state kept in flight by a canned sentinel (app_ref "slow-app" holds the fake remote for 700ms) with a computer_remove landing mid-call, and the port-only repoint the previous guard comparison could not see. - switch_display over ssh fails closed with persistent_session_required, like its two sibling guards already asserted. - a byte-identical re-register keeps runtime state: eviction is for repoints, not no-ops. - linux and win32 zoom refuse malformed regions with the named error (runs on every platform: the check fires before any tool probe). Also removes the pre-existing cu-agent-deny-* temp dir leak so the suite's mkdtemp footprint is fully cleaned (the leak-free claim covered the new dirs; this makes it true for the suite). Suite: 53 tests, 52 pass + 1 platform skip. Signed-off-by: asto18089 <44870036+asto18089@users.noreply.github.com> * fix(computer-use): clip zoom regions to the source raster and bind the clip An out-of-bounds zoom region exposed a binding defect: the croppers adjust such a request on their own — ffmpeg clamps the origin silently (a region [50,0,32,32] on a 64-wide raster crops from x=32, exit 0, no warning even at default verbosity; oversized w/h die as an opaque error) — while every backend echoed the requested region, so the server bound child pixels against a crop that was never taken and the zoomed-in click landed 18 raster px beside the target. Linux and win32 also accepted a zero-area region past validation into an opaque cropper error. Every backend now clips the region against the actual source raster size before cropping and returns the clipped rect, so the receipt's region is the crop actually taken; the host binding needed no change — it already binds the returned region. The clip lives in one shared clampRegion (src/raster.mjs): round, reject w/h below 1 with the named region error, intersect with the raster bounds. linux probes the size with ffprobe (ships with the ffmpeg zoom already requires), darwin with sips -g pixelWidth/pixelHeight, and the win32 script clamps the rect it draws itself and reports the pixel size for node to recompute the same clip. Zero-area regions are rejected in the pre-probe validation of all three backends. Also fix ssh zoom on a real linux remote, found while wiring this in: the one-shot agent process never runs probeSession, so the need() cache was empty and every remote zoom failed with "needs ffmpeg" even with ffmpeg on PATH. zoom now primes the probe itself before checking the tools. Tool description, README, and the recording skill disclose the clip. Signed-off-by: asto18089 <44870036+asto18089@users.noreply.github.com> * test(computer-use): pin the zoom clip, the chained clip, and the ssh aiming clampRegion unit tests pin the intersection, the rounding, and the degenerate rejects. Pixel-level regressions against the real croppers (sips on macOS, ffmpeg on linux/CI) pin an out-of-bounds region end to end: the receipt carries the clipped region and the child pixels come from the clipped origin — [50,30,32,32] on a 64x48 gradient crops from (32,16) — and a chained zoom clips against the child raster. The malformed-region loop gains a zero-width region, which must die as the named error before any probe. The ssh shim gains a canned clip mirror so the host-side half is pinned on headless CI: after a clipped zoom, a child-pixel click must cross the wire at the clipped screen point (26,20), not the one the raw request would have bound (35,20). Each pin is reverse-verified: reverting the fixed behavior turns it red. Suite: 58 tests, 56 pass + 2 macOS-only skips on linux. Signed-off-by: asto18089 <44870036+asto18089@users.noreply.github.com> --------- Signed-off-by: asto18089 <asto18089@126.com> Signed-off-by: asto <asto18089@126.com> Signed-off-by: asto18089 <44870036+asto18089@users.noreply.github.com>
…on tokio Semaphore (#55) * refactor(subagent): correct governor comments, drop dead launch-note branch PR#43 review follow-up outside governor.rs: - mod.rs: the attempt-report comment claimed the AIMD ratio denominator counts retried attempts, but the client inner with_retry (default max_retries = 3) aggregates up to 4 real HTTP attempts into one visible result, so one loop iteration is at most one governor event. The no-governor probe-period comment claimed the wall-time deadline always fires first, but the probe tick is simply a no-op without a governor. The rate_limit_governor() doc named the engine as the stamper; the actual stamp site is session bootstrap in lib.rs. - mod.rs/engine.rs: update_runtime_limits always returned true since the DynamicGate applies capacity live, so the engine branch that appended 'launch_concurrency takes full effect after active sub-agents finish...' was unreachable. Drop the dead branch and the now-meaningless bool return. - mod.rs/tests.rs: extract is_governor_reported_rate_limit (the predicate deciding what feeds the governor window) and anchor the QuotaExhausted exclusion with a forkguard_ test: quota exhaustion must produce no governor event and keep the fatal non-retryable path, while a genuine 429 does feed the governor. Signed-off-by: asto18089 <asto18089@126.com> * refactor(subagent): rebuild DynamicGate on tokio Semaphore forget_permits Review follow-up to PR#43's adaptive rate-limit governor: - DynamicGate replaced its hand-rolled FIFO waiter queue (Mutex + VecDeque + oneshot pre-counted permit handover + disarm-on-drop) with a thin accounting layer over tokio::sync::Semaphore. Tokio 1.53's forget_permits makes shrink-below-active possible on the stock primitive: shrink forgets free slots, holders above the new capacity finish naturally, and their releases are absorbed instead of re-admitted. The semaphore owns the wait queue, FIFO fairness, and cancellation safety (a cancelled acquirer dequeues itself, so the stale-entry/disarm machinery is deleted outright). Invariant: sem.available_permits() == capacity.saturating_sub(outstanding). Behavior contract is unchanged; the cancel/redispatch and abort/pause stress tests pass unmodified. - The ratio heuristic contradicted its own comment: with THROTTLE_EVENT_THRESHOLD == 2, requiring the ratio branch to also see >= 2 events would make it dead code, and as written one success plus one 429 halved the gate on the first blip. Drop the ratio branch and its constant; the absolute event threshold owns all shrinking. The test that pinned the old behavior is rewritten as forkguard_single_rate_limit_blip_does_not_shrink_gate. - Rename the remaining eight fork-behavior tests with the forkguard_ prefix so the parent repo's fork-guard fingerprints can lock them. - Correct module docs: successes are counted fleet-wide per successful LLM call (not per gate-holder), and the Semaphore can't-shrink justification is replaced with the forget_permits design note. Signed-off-by: asto18089 <asto18089@126.com> * chore(tui): drop needless returns breaking clippy -D warnings Pre-existing on the v0.9.12 baseline (unrelated to the governor follow-up): clippy 1.97 flags needless_return at mcp.rs:288 and lib.rs:12547, failing 'cargo clippy -p codewhale-tui --lib --locked' for every PR. Remove the returns; on Windows the cfg(windows) block becomes the function tail, on non-Windows the cfg(not(windows)) block already was, and the not(unix) early-return block is redundant with the cfg(unix) gate on the spawn statement. Signed-off-by: asto18089 <asto18089@126.com> * docs(subagent): fix invariant and tokio version notes Review follow-ups to the governor follow-up itself, all comment-level: - governor.rs: `Semaphore::forget_permits` is stable since tokio 1.37, not 1.53 as the module doc claimed. The available/capacity/outstanding invariant holds at rest; an admission between taking its semaphore permit and registering `outstanding` (and a capacity change racing that window) can bend it transiently by the number of in-flight admissions, and the drop accounting restores it as holders release. - mod.rs: the pinned acquire future now matters because re-creating it per probe tick would dequeue and re-enqueue at the back of the FIFO wait queue; the old stale-entry-purge rationale died with the hand-rolled queue. Signed-off-by: asto18089 <asto18089@126.com> * docs(subagent): correct DynamicGate invariant drift wording The struct doc claimed the drop accounting restores the invariant once holders release. Not guaranteed: a capacity shrink racing an in-flight admission (permit taken, registration pending) cannot see the slot, and a subsequent expand over-adds one semaphore permit per racing admission. The surplus is bounded and never loses a slot, but it drains only when demand pushes the fleet past capacity (excess releases are absorbed) or a later shrink clamps at the available count — until then it persists as effective extra capacity across further pause/resume cycles. State that precisely, align the remaining 'active' prose with the outstanding field name, and describe available_permits as what it actually reads. Signed-off-by: asto <asto18089@126.com> * test(subagent): pin gate semaphore permits across capacity changes The capacity-focused gate tests assert the bookkeeping capacity() field alone; the stress test's drain assertion was the only direct pin on the semaphore-side expand/shrink arithmetic and the Drop absorb guard. Add a deterministic test that holds permits outstanding across an expand and a shrink-below-outstanding and asserts available_permits() at each step, so breaking that arithmetic fails on its own instead of depending on the stress test staying strict. Signed-off-by: asto <asto18089@126.com> * fix(subagent): refuse surplus gate tokens that bypass pause A grant handed to a queued waiter that is cancelled before polling again is re-injected into the semaphore pool by tokio's cancel path (batch_semaphore Drop for Acquire re-releases partial grants). A shrink cannot see it (forget_permits covers free tokens only), so a 429 pause (capacity 0) could find a spare token in the pool and admit via try_acquire/acquire while throttled. The grant-races- shrink window had the same flaw from the opposite side. The slot ledger becomes the admission authority on both paths via a shared admit_grant: a token granted while outstanding >= capacity is surplus drift and is destroyed with forget (never dropped; a drop would re-inject it), and acquire discards it and keeps waiting. Releases still mint one token per slot freed below capacity, so no slot is ever lost, and acquire's discard loop consumes one pool token per round, so it cannot spin. The gate can no longer over-admit, replacing the previously documented surplus window. Adds forkguard_cancelled_grant_returned_after_shrink_cannot_bypass_ pause (deterministic current-thread interleaving: release, shrink to pause, abort the granted waiter, both admission paths refuse) and rewrites the DynamicGate invariant docs to match. Verified: fmt clean; clippy -p codewhale-tui --lib --locked zero warnings; subagent::governor 16 passed; forkguard_ 59 passed; subagent:: 567 passed (a stack overflow in coord::tests:: coordination_resolution_survives_reload_and_resolving_claim_eviction reproduces on the unmodified PR head on aarch64 Linux and is unrelated). Signed-off-by: asto <asto18089@126.com> --------- Signed-off-by: asto18089 <asto18089@126.com> Signed-off-by: asto <asto18089@126.com>
fix(tui): bound the Windows PowerShell fallback Review round 3 follow-ups to the execution-policy work; the topic's behavior tests also move under the forkguard_ prefix so the parent fork-guard floor covers them instead of relying on textual fingerprints alone. - Retry only our own temporary script: the refusal must name the exact -File path this invocation passed. A nested `powershell -File inner.ps1` child is refused at its own top level (no <path>.ps1:<line> location line) after the outer script already ran its earlier statements, so the exact-path rule is what keeps that shape from repeating the outer script's side effects. - Bound the encoded form by the Windows command-line limit (32767): UTF-16LE base64 expands a payload to ~2.67 characters per input character, so a payload that cannot be spawned inline keeps its first result instead of trading a policy refusal for a spawn failure. - Keep the first attempt's refusal, annotated with the retry error, when the inline retry cannot even start, instead of propagating the recovery path's error and discarding the refusal evidence. - Merge the maintenance branch (pinvou3-clean) into the candidate so the candidate tree equals the squash tree, which also picks up its needless_return clippy fix. Verified on Windows: cargo test -p codewhale-tui --lib powershell (14 passed) and windows_shell_text (1 passed); cargo fmt clean; cargo clippy -p codewhale-tui --lib --all-targets --locked -- -D warnings clean. Signed-off-by: z27014 <zhuo.wenpei@h3c.com> fix(tui): key the UTF-8 prefix on the shell family Round-2 review follow-up on #66: the constructor dedup moved the encoded fallback onto `windows_shell_text`, which prefixed only the two detected PowerShell variants, so a `ShellKind::Custom` PowerShell path (accepted by `is_powershell`) would have run the retry without the UTF-8 output prefix and decoded non-ASCII stdout with the console code page. Key the prefix on the PowerShell family instead, which also aligns the normal shell spec: a custom PowerShell path now gets the prefix it always needed. Pinned by `windows_shell_text_matches_the_shell_family` across `Pwsh`, `WindowsPowerShell`, `Cmd`, `Custom{pwsh}`, and `Custom{bash}`. Signed-off-by: z27014 <zhuo.wenpei@h3c.com> fix(tui): tighten the PowerShell retry gate Round-1 review follow-up on #66. - Only a `Failed` attempt may be retried: a `Killed` (the user cancelled) or `TimedOut` result previously reached the refusal predicate, and re-running those would defeat the stop or double the wall clock. The gate is now the testable `powershell_refusal_needs_inline_retry` helper and is still evaluated at the call site. - Cover the glue: a gate test across every `ShellStatus`, plus a test that drives `retry_powershell_without_script_file` and asserts the inline rerun ran the payload, annotated the fallback, and accounted for both attempts. - Collapse the duplicated Windows shell-text/env/struct construction shared by `CommandSpec::shell` and `CommandSpec::powershell_encoded_shell` into one private constructor so the two cannot drift. Signed-off-by: z27014 <zhuo.wenpei@h3c.com> fix(tui): run PowerShell under a policy bypass A Windows machine whose execution policy is Restricted (the client default), AllSigned, or RemoteSigned with a Mark-of-the-Web script refuses the temp `.ps1` this dispatcher writes for multiline, quote-heavy, or non-ASCII commands - in a Chinese session that is most commands - with "cannot be loaded because running scripts is disabled". PowerShell rejects the file before a single statement runs, so the tool loses the command outright. Pass `-ExecutionPolicy Bypass`, which sets only the process scope: it needs no administrator rights, leaves the user's own shells untouched, and the execution policy is not a security boundary. A Group Policy still overrides that parameter, so when the `-File` form is refused the tool re-runs the identical payload once inline as `-EncodedCommand` (UTF-16LE base64: not a script file, no quoting or BOM surface), which survives Group Policy and AppLocker script rules. The retry keeps the sandboxed exec path and the same policy/env, and it is side-effect free because a refused script ran nothing. Whether to retry is decided at the call site, not inside the retry helper. Refusal detection reads error-record metadata instead of the localized message: `UnauthorizedAccess` plus the refusal's exception type (`ParentContainsErrorRecordException` on the host `-File` path, `PSSecurityException` in-session), with the English `about_Execution_Policies` topic as fallback. A `<path>.ps1:<line>` statement location rules every branch out, because it proves a statement already ran - the case where an inner script was refused after the outer one did work - and re-running there would duplicate side effects. The `LinkID=135170` URL is deliberately not matched: the console formatter wraps it mid-token. Tests: launch flags carry the bypass for both forms, the encoded payload round-trips to the wrapped command, the predicate matches the captured en-US/zh-CN/ja-JP refusals and rejects both in-script failures and inner refusals, and both forms run a non-ASCII payload end to end on Windows. Signed-off-by: z27014 <zhuo.wenpei@h3c.com>
* ci(sync): skip the CNB mirror when its token secret is absent `sync` fails on every push to a mirrored branch with "CNB_GIT_TOKEN secret is not set; cannot push to CNB". That is a repository-secret state, not a property of the commit: the check has been red for ten consecutive runs across six unrelated branches, and reviewers of unrelated PRs correctly call it pre-existing. Forks cannot inherit the secret at all, so they can never make the check green. Probe the secret in a gate job and skip the push when it is absent, with a notice naming the missing secret. Configured repositories keep the previous behavior, including the existing hard failure inside the push step. Signed-off-by: z27014 <zhuo.wenpei@h3c.com> * ci(sync): document the missing-secret skip path Review follow-up on #64 (CHANGES_REQUESTED): - docs/CNB_MIRROR.md: document the new behavior. When `CNB_GIT_TOKEN` is absent the `credentials` gate reports `configured=false`, the `sync` job is skipped instead of failed, and the run emits a notice naming the missing secret; the rotating section now distinguishes "skipped because absent or misspelled" from "failed because expired". - Reflow the kept-guard comment: a `workflow_dispatch` always uses the dispatched ref's revision of this file, so the guard's real coverage is a push carrying an older revision of the workflow plus the window where the secret is deleted between the gate probe and the push step. Verified: the workflow parses (`yaml.safe_load`, jobs credentials+sync, `sync.if = needs.credentials.outputs.configured == 'true'`), and the skip path is exercised on this branch by a `workflow_dispatch` run linked in the PR. Signed-off-by: z27014 <zhuo.wenpei@h3c.com> --------- Signed-off-by: z27014 <zhuo.wenpei@h3c.com>
* fix(agent): pass agent receipts through bounded
Non-snapshot agent receipts (roster catalogs, wait/settled status,
peek summaries) were destroyed by the context summarizer into
placeholder lines. Bound and pass them through verbatim instead.
Signed-off-by: asto <asto18089@126.com>
* fix(engine): make handle_read hint honest
The parent-context hint and the slash dispatch brief promised that a
deferred tool "hydrates when called by name", but tool_search hydration
only reaches allowlisted catalog entries and returns a schema, not an
execution; the compactor now mentions transcript_handle only when the
receipt actually carries one, and the shared hint degrades honestly.
Signed-off-by: asto <asto18089@126.com>
(cherry picked from commit f54583b923f674404e7ea282485fcee7a29aede2)
* fix(agent): disclose the wait timeout bound
The wait schema text said a wait "returns when any one child settles"
without disclosing the finite 30s-default/120s-max block, so a timed-out
wait read as a hang; both surfaces now state the bound and the
timed_out=true receipt shape.
Signed-off-by: asto <asto18089@126.com>
(cherry picked from commit 6197b2398c229fa8fe3c9ef8380d5daff535bcc0)
* fix(agent): use canonical Fleet role vocabulary
The agent tool description still advertised the legacy spellings
(worker/scout/builder/verifier/consultant) while the schema's type field
and the roster use the canonical names, giving the model two words for
one role; the description now lists the canonical names and notes that
legacy aliases remain accepted.
Signed-off-by: asto <asto18089@126.com>
(cherry picked from commit 4c4042382d8a1ce3801f09d5f29c19a4efe0bfb2)
* fix(search): honor locale in bing/ddg scrapes
Keyless Bing SERP scrapes sent no market hint with an English
Accept-Language, so Chinese queries got unrelated Japanese results;
pass mkt/setlang (Bing) and a translated kl region (DuckDuckGo) plus a
matching Accept-Language through the scrape paths.
Signed-off-by: asto <asto18089@126.com>
(cherry picked from commit dda493128336c393e676d7abcea401902d7346ce)
* fix(search): declare scrape locale support
Bing and DuckDuckGo scrapes now honor locale in-request, so their
receipts must report the knob as supported instead of emitting
knob_ignored.
Signed-off-by: asto <asto18089@126.com>
(cherry picked from commit 39eae6929cc08fd37f94675e310619576e591476)
* fix(vision): report real image size metadata
The tool had the original image bytes yet let the vision model guess
dimensions from the picture, so the result now carries header-derived
width/height/format (omitted when the header cannot be parsed) and the
description tells the model to trust that metadata.
Signed-off-by: asto <asto18089@126.com>
(cherry picked from commit 52e5f06efda481e08a767019c6a1387fc931adf3)
* test(mcp): pin boot event failure reasons
The finished McpSessionBoot receipt must keep carrying each failed
server's connection diagnosis so hosts have one engine-side record.
Signed-off-by: asto <asto18089@126.com>
(cherry picked from commit eeffa32cc5466baf52080b6aa738016ef5f0a61d)
* fix(mcp): brief model when boot servers fail
A failed boot previously left the failure invisible to the model for
the whole session, so the briefing rides the existing runtime-handoff
channel once per boot pass into the next turn's context.
Signed-off-by: asto <asto18089@126.com>
(cherry picked from commit 5c7e69a3de6857e2ab932bd6bf68669fcf511352)
* fix(agent): summarize fleet lists per child
The unscoped status/peek fleet listing is a collection, not one child;
passing it through the bounded receipt passthrough hard-truncated every
child past the cap once two running projections exceeded 2,000 chars.
Detect the agents[] envelope and summarize its rows like snapshots,
with a fleet-specific header. Also make the transcript_handle hint gate
structural (field presence, not a substring of the raw JSON), pin the
claim receipt's passthrough classification, and tie the passthrough
bound assertion to the real cap.
Signed-off-by: asto <asto18089@126.com>
* fix(mcp): make boot briefing self-voiding
The boot-failure briefing told the model the mcp_* tools were
unavailable "for this entire session" and banned calling them. But MCP
recovers inside a session: per-turn connect_all retries cooled-down
servers, /mcp retry and reload reconnect on demand, and auth-required
failures put a synthetic mcp_<server>_authenticate tool in the catalog
that the ban told the model never to call. Nothing retracted the
briefing, so a recovered surface stayed shadowed by a startup ban.
The briefing now describes startup only and defers to the live tool
list, and the engine appends a corrective mcp_boot_recovered notice
when a briefed server reconnects through the user-driven retry/reload
seams (which run while idle). The per-turn connect_all refresh relies
on the self-voiding wording instead of a mid-turn append. Briefed
server names are tracked so the notice corrects exactly the servers
the model was told were unavailable, exactly once.
Signed-off-by: asto <asto18089@126.com>
* fix(search): map only verified DDG regions
The generic BCP 47 reversal produced kl values outside DuckDuckGo's
fixed, non-systematic region list (ja-JP became jp-ja; the real value
is jp-jp; kr-kr, tw-tzh, hk-tzh are likewise not reversible), so most
non-English locales silently lost the region signal while the receipt
claimed it was honored. Replace the reversal with the verified pairs
and send no kl at all for anything else. Also derive the Bing setlang
script tag from the market region (zh-TW/zh-HK/zh-MO are zh-Hant, not
zh-Hans), shape-check the model-supplied locale before it reaches URLs
and the Accept-Language header, state the BCP 47 format in the tool
schema, and correct the comments that claimed byte-identical headers
and hid the pure-Han Japanese cost of the Han heuristic.
Signed-off-by: asto <asto18089@126.com>
* fix(subagent): align role vocabulary end to end
The tool description now advertises canonical roles, but the same drift
survived elsewhere: sub-agent briefs still self-identified with legacy
tokens (role: scout/builder/consultant/verifier while every receipt
prints explore/implement/advisor/test), the explore output contract
kept the scout heading, and the resume_from example taught
"explore -> implementer -> verifier". Switch the tokens, heading, and
example to canonical names; keep the legacy aliases parse-accepted.
Also tie the advertised wait bounds to the runtime constants in tests
so constant drift cannot silently falsify the copy, fix the stale
"schema advertises 5" comment, and stop the slash dispatch brief from
naming "the returned transcript_handle" - the default spawn receipt
strips the handle before the model sees it.
Signed-off-by: asto <asto18089@126.com>
* fix(vision): qualify the metadata promise
The description promised width/height unconditionally while BMP (no
decoder feature) and unparsable headers always omit them — the same
unconditional-promise class this PR removes elsewhere. Scope the
promise to when the image header can be read.
Signed-off-by: asto <asto18089@126.com>
* fix(mcp): keep the boot briefing session-scoped
Fresh-eyes audit fixes for the MCP boot briefing. Briefing state was
engine-level while the host hydrates every spawned engine with a one-shot
Op::SyncSession whose restored history can never contain a briefing this
process injected during startup boot: the sync wiped the briefing and the
generation stamp suppressed any re-briefing for the life of the process,
and a stale briefed-server set could fire a corrective notice into a
conversation that never saw the briefing. The wipe was reproduced as a
failing test before the fix.
Op::SyncSession now rebuilds the bookkeeping on its idle seam: it
re-briefs when the failures still hold and the restored history carries
no briefing, and reseeds the briefed set from the persisted briefing
(minus servers the history reports as recovered) when it does. Payload
parsers back the reseed structurally, so a user-authored lookalike is
never matched.
Also from the audit: the boot ban now explicitly exempts the synthetic
mcp_<server>_authenticate recovery tool (an auth-required failure
exposes it from the first turn, so the previous blanket ban contradicted
the tool list in the same request); each diagnosis is bounded to keep a
multi-server briefing compact; the briefing and recovery orderings are
pinned by a multi-server byte-stability test; and the main briefing test
moves under the forkguard_ anchor so the parent-side register batch can
pin it directly.
Replayed MCP handoffs render as system cells instead of user turns.
Signed-off-by: asto <asto18089@126.com>
* fix: receipt, vocabulary, locale, and vision honesty
Fresh-eyes audit fixes across the remaining surfaces.
Agent receipts: the transcript_handle hint fired whenever the raw
receipt carried the field while the row summarizer dropped the value -
a phantom value of exactly the class this PR pins. Summarized rows now
print the handle, and the hint gate also covers fleet-listing rows.
Action receipts (message/followup/interrupt acks, the unchanged nudge,
spawn starts) carry an action key and were snapshot-summarized into
placeholder noise, losing queued/woke/queue_depth/note and continuation
handles; they now pass through bounded like roster/wait/claim, while
the fleet envelope still summarizes per child (matched first). The
2,000-character passthrough cap gains exact-boundary pins.
Vocabulary: the sub-agent brief prose no longer mixes legacy role nouns
("Fleet scout" becomes "Fleet explorer", "for a consultant" and "for a
verifier" become advisor/test-agent, "scout-style" becomes
"explore-style"), and reconciliation role evidence accepts canonical
tokens through the same alias migration the parser uses.
Search: finalize_search_response no longer claims an ignored locale
knob was honored - malformed values and DuckDuckGo regions outside the
verified kl list are flagged KnobIgnored so the receipt keeps the knob
unhonored; the schema says which backend honors what; the
model-visible Web tool schema gains the BCP 47 note; zh_CN normalizes
to zh-CN; an explicit zh-Hant script subtag wins over region-derived
Simplified; Accept-Language stops duplicating the en range for English
markets.
Vision: the promise names stored dimensions, says camera rotation
metadata is not applied, and omits the fields when the container
cannot be sized.
Signed-off-by: asto <asto18089@126.com>
* test(search): align accept-language test with the dedup rule
The round-two dedup change stopped appending a duplicate en range for
English markets but left this test expecting the pre-dedup output for a
"-CN" input that the locale shape check never lets through, so the test
failed deterministically and turned CI red. Pin the reachable bare-language
case instead: locale "en" emits a single range with no duplicate en
fallback, exercising both the no-panic primary extraction and the dedup
rule the DDG path shares with Bing.
Signed-off-by: asto <asto18089@126.com>
* fix(mcp): reconcile sync against boot and delta state
Three review findings, one seam:
A mid-boot SyncSession used to stamp the briefing with a freshly allocated
generation, which advanced the event counter past the in-flight boot and
made every later update of that boot stale: the authoritative error map
never landed and the finish-seam briefing never fired, so a server that
failed to connect stayed unbriefed for the life of the conversation. The
reconcile now leaves the counter alone while the boot is in flight and
lets the finish seam brief with the complete map.
A restored briefing naming fewer servers than currently fail suppressed
the delta forever (and a richer pre-sync briefing was wiped with the
history). The reconcile now re-briefs from the live error map unless the
restored briefing covers every current failure, and the once-per-boot
stamp skips only while the briefing still covers them, so the finish seam
self-heals stale subsets.
A workspace-switching sync dropped the pool but kept its failure
receipts, so the new conversation could be briefed about the previous
workspace's servers; the receipts now clear with the pool.
Red-green: the two new forkguard tests fail against the previous reconcile
(engine.rs stashed) and pass with it; the reseed test's fixture moved to
the covered case, since a live error for a server the history reports as
recovered now legitimately re-briefs.
Signed-off-by: asto <asto18089@126.com>
* fix(agent): print the transcript value the hint names
The round-two row printer read transcript_handle as a string, but every
producer that reaches the summarizer serializes the field as a full
var_handle object (the only string producer, the subagent.failed sentinel,
is an injected event, never a tool result). as_str() was therefore always
None: no summarized row ever printed a transcript value, while the
presence-based hint gate still fired — naming a value the model never
received, the exact phantom class the PR pins. The pinning tests passed
only because they fabricated string handles no producer emits.
Both the row printer and the hint gate now reduce the field to the
session_id/name form handle_read accepts directly, from either the string
or the var_handle shape, and the gate looks through the same snapshot
wrapper the row summarizer unwraps. An empty or shapeless handle suppresses
the hint instead of summoning it. The fixtures now serialize real VarHandle
values, and the claim fixture mirrors the real PersistedWriteClaim shape
(no action key), plus the code comments no longer list spawn starts and
write claims among action-key receipts.
Signed-off-by: asto <asto18089@126.com>
* fix(mcp): flatten control characters in briefing reasons
A failed stdio server's captured stderr is embedded multi-line in the
connection error, and redaction preserves line structure, so the failure
reason carried into the boot briefing could forge '- other_server:' rows
inside the runtime-event envelope and even carry a premature envelope
close — server-controlled prose inside a channel the model must read as
runtime-authored. The reseed parser would then ingest the forged names
into the correction bookkeeping on session sync.
bounded_briefing_reason now flattens control characters to spaces and
collapses whitespace runs before the 280-char bound. Red-green: the new
test asserts the parser reads only the real server from a briefing whose
reason carried hostile multi-line stderr; with the flattening neutralized
it fails.
Signed-off-by: asto <asto18089@126.com>
* fix(search): veto kana and hangul in the Han market fallback
The fallback picks the zh-CN market for a locale-less query containing
any Han ideograph, and kanji sit inside those ranges — so Japanese queries
like a weather lookup for Tokyo were pushed onto the Chinese market with a
Chinese Accept-Language: the inverse of the cross-market drift the
fallback exists to fix. Real Japanese queries almost always carry kana,
and Korean ones hangul, so a Han-bearing query with either script now
keeps the no-market request; only a purely Han-script query (search-rare
and indistinguishable from Chinese) and unmarked Traditional Chinese keep
the accepted fallback. An explicit locale always wins.
Red-green: with the veto disabled the new test fails on the kana cases
and passes with it; the pure-Han fallback test is unchanged.
Signed-off-by: asto <asto18089@126.com>
* fix(search): tighten locale shape and fallback receipt honesty
Three review follow-ups in one seam:
The shape check admitted digit-only tags like 12345, which were then
transmitted as mkt/setlang and receipted with honored.locale=true,
contradicting the schema text promising malformed values are ignored.
The primary language subtag must now be alphabetic and subtags at most
8 characters.
Script+region Chinese tags (zh-Hant-TW, zh-Hant-HK, zh-Hans-CN) resolved
no kl pair even though their region forms are verified, so a model
following the documented BCP 47 style lost the region signal on
DuckDuckGo; they now reduce to the verified region pair.
On the DuckDuckGo-to-Bing fallback the DDG leg's locale KnobIgnored flag
survived even when Bing carried the locale via mkt/setlang, keeping the
receipt on honored=false; the flag is now re-derived from Bing's own rule
by a testable helper (red-green via inverted-retain mutation).
Signed-off-by: asto <asto18089@126.com>
* fix: align remaining reviewed surfaces with the vocabulary and honesty round
Small review-round items across the surfaces the audit touched:
The roster description for the general role still read 'General-purpose
worker' (roster catalog, the seeded legacy-alias roster entry, and the
fleet setup view) — the last model-visible surface teaching a legacy
noun; it now says agent, and the roster receipt fixture quotes the real
FleetRole descriptions instead of fabricated strings.
The two wait faces advertise from independent constant sets, so a numeric
drift between them could pass both per-face pins; a cross-assertion test
now ties the sets together.
The image_analyze description said the metadata comes from the image
container, but the format label is derived from the file extension; the
wording now says so.
The new fork-behavior pins (boot briefing lifecycle, recovery notices,
isolation, drained finish, boot event payload, history rendering, and the
handoff round-trip/sanitization tests) move under the forkguard_ anchor
so the parent-side register batch can pin them.
Signed-off-by: asto <asto18089@126.com>
* style: collapse the reconcile coverage branches and reformat
Signed-off-by: asto <asto18089@126.com>
* fix(agent): print snapshot-wrapped transcript handles in summary rows
The summarizer recursed into the nested snapshot wrapper before reading
the receipt's transcript_handle, so the real SubAgentSessionProjection
shape (handle on the outer envelope, none inside) lost the value: the
presence-gated hint fired while the summary row the model reads never
printed the handle it names — the same phantom-value class the receipt
work pins. Lift the extraction above the unwrap and thread it through
the recursion as a fallback, inner values still winning.
Also gate the hint on the rows that are actually rendered: the summary
prints at most eight rows, and a handle carried only past that cut made
the hint name an invisible value. The gate now scans the same visible
window as the printer (fleet rows included), with the shared constant
pinned to the printer's cut.
Signed-off-by: asto <asto18089@126.com>
* fix(mcp): invalidate the in-flight boot pass on a workspace switch
Op::SyncSession drops the pool and the connection-error map when the
synced conversation belongs to another workspace, but the connect pass
started for the old workspace kept running: its Finished update still
passed the generation check, re-filled the cleared error map, and
injected the old workspace's failure briefing into the freshly synced
conversation. The desktop host's one-shot sync lands while the spawn
boot is still connecting, so the race was reachable on every workspace
switch. Drop the pass's generation, in-flight latch, and update channel
together with the briefing bookkeeping (a same-named server recovering
later must not announce a briefing the new history never saw); late
updates then fall into the stale-generation drop.
The new reload-path recovery test also closes the loopback fixture's
keep-alive hole: it answers one request per socket, so pooled reuse
raced the server's close under load ('connection closed before message
completed'). The SSE response now declares Connection: close.
Signed-off-by: asto <asto18089@126.com>
* fix(text): stop promising deferred-tool hydration by name
Hydration only reaches tools the host allowlist kept in the catalog, so
'registered deferred tools hydrate when called by name' was false
wherever the tool was filtered out entirely. The agent dispatch brief
lost that tail in the receipt work, but the same claim survived in the
goal continuation prompt, the /goal brief, and both skills surfaces,
and one fork pin required the false wording verbatim.
Make every surface honest and uniform: activate via tool_search first,
try the direct call when that cannot surface the tool (it succeeds when
the tool merely sits deferred in the catalog), and only declare the
capability unavailable if the direct call errors. The handle_read
hint gains the same direct-call-first fallback instead of its
over-claiming 'unavailable', the goal pin asserts the mechanism-neutral
shape, and the grep surface for the old claim is now only test
negative-assertions.
Signed-off-by: asto <asto18089@126.com>
* fix(agent): put wait-receipt control fields before the fan-out
serde_json preserves insertion order, so the wait receipts built their
scalar controls (timed_out, all_settled, waited_ms, note) after the
children arrays. A large until=all fan-out blows the 2,000-char
passthrough cap on child rows alone, and head-truncation then dropped
exactly the timed_out=true signal the schema promises unconditionally.
Build the receipts controls-first; the content is unchanged, only the
key order.
Signed-off-by: asto <asto18089@126.com>
* fix(search): keep malformed locales unhonored on the DDG leg
A malformed locale on a pure-Han query rode the Han market fallback
into Some(zh-CN), so the DDG leg reported honored.locale=true — while
the schema promises malformed values are ignored and the Bing leg
flagged the same input honestly. Decide malformed independently of the
market/kl resolution so both legs agree, and correct the rationale
comment: an unmapped locale sends no kl region signal but the
Accept-Language header still carries it.
Signed-off-by: asto <asto18089@126.com>
* fix(session): skip internal runtime handoffs for saved-session titles
Session titles (and their previews, which reuse the title) take the
first role=user message; a session saved before its first real input
would title itself with the truncated MCP boot briefing envelope. Both
title paths now take the first user message that is not an internal
runtime handoff, falling back to the default title when only handoffs
are present.
Signed-off-by: asto <asto18089@126.com>
* fix(mcp): sanitize briefing server names and share the line flattener
Server names are unvalidated config keys; a name containing ': ' or
whitespace was truncated by the line-protocol parser, so its recovery
notice could never fire and every sync re-briefed it. Names are
canonicalized for the line protocol (':' and whitespace become '-'),
and the recovery line uses the same form, so the text round-trips; the
bounded consequences for such pathological names are documented at the
sanitizer.
bounded_briefing_reason also grew as a near-verbatim copy of
cloud_dispatch's one_line. Both now delegate to one pub(crate)
flatten_and_bound_text with a fold-whitespace switch, keeping the
callers' pinned ellipsis-budget semantics.
Signed-off-by: asto <asto18089@126.com>
---------
Signed-off-by: asto <asto18089@126.com>
* fix: stop finite timeouts from killing legitimate work
A timeout-behaviour audit found several finite wall-clock caps sitting on
paths whose work legitimately runs minutes to hours, auto-expiring human
decisions, and local waits with no bound at all. Fix the high-impact set:
- External tool approvals no longer auto-deny after 300s: the decision
wait is unbounded (human-paced) and ends on turn interrupt or runtime
shutdown instead, emitting approval.decided{interrupted} so clients
clear pending UI. The engine's own approval wait was already unbounded;
request_user_input now waits unboundedly too instead of answering the
model with a ToolError::Timeout after 300s.
- Background-task idle detection now treats a running tool as progress:
the journal only records tool calls at start/completion, so the 2-minute
idle deadline fired during any silent build, test suite, or MCP call.
The wall-time budget remains the backstop.
- MCP tool calls: the TUI pool default execute timeout 60s -> 1800s and
the response-read wait derives from max(read, execute) so raising the
documented knob actually governs; the engine-side stdio proxy gets a
dedicated 1800s tools/call budget instead of the 120s generic request
cap; the headless runtime dispatch backstop 300s -> 1800s; MCP OAuth
browser-callback wait 300s -> 900s.
- js_execution and code_execution: 120s -> 600s and the child is killed
on timeout instead of being left running orphaned.
- image_analyze vision calls: replace the 120s total deadline with
connect + per-read idle timeouts; a multi-MB upload plus a long
non-streaming generation legitimately exceeds two minutes.
- RLM child completions now honor the advertised sub_query_timeout_secs
(it was stored but never read; the bridge always used its 120s const),
and inline repl rounds 180s -> 900s to match the model-thinking
reality the RLM path already accommodates.
- web fetch hard cap 60s -> 300s and the SSRF pre-flight DNS lookup is
bounded at 10s so a hung resolver cannot stall the fetch envelope.
- Skill/plugin installs get connect + total timeouts (previously none:
a stalled connection hung installs forever); snapshot git subprocesses
are bounded at 300s with degrade-on-timeout instead of blocking the
turn pipeline on a wedged git.
- Non-streaming model requests get a 30-minute total envelope around the
retry loop (all attempts + honored Retry-After included). The shared
client intentionally has no client-level total timeout, so compaction,
translate, and similar non-streaming callers previously had no bound
against a provider that accepts and stalls, nor against a gateway
answering 429 + Retry-After: 3600 forever.
- Sub-agent tool timeout default 300s -> 1800s (single source of truth;
the heartbeat floor derives from it automatically).
Verified: cargo check --tests on tui/mcp/core; targeted suites for
runtime_threads (157), task_manager (46), web (192), mcp (76), core (82),
vision, js_execution, repl, snapshot, skills install, client all pass.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(tasks): keep the idle watchdog honest about in-flight tools
The timeout-audit fix for background-task idle detection tracked running
tools with mismatched keys and only refreshed progress on event arrival:
- item.started carries the engine tool-use id under "tool", but
item.completed/item.failed only carry the journal item id under
"item", so the tracking set was never drained: after the first tool
call the watchdog was disarmed for the rest of the turn (only the
wall-time budget remained).
- note_progress was evaluated per event, but the very scenario being
fixed is a tool whose execution window produces no journal events at
all, so the 2-minute idle deadline still fired during silent builds,
test suites, and MCP calls.
Track both lifecycle edges by the journal item id, and refresh progress
from the steady-state loop (which wakes at least every catch-up poll)
for as long as any tool item is in flight. The wall-time budget stays
the backstop for a tool that never completes.
Pin both edges with tests: a silent tool survives well past the idle
deadline and the deadline resumes after completion, and a hung tool
still hits the wall budget.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(vision): bound image_analyze with a real total envelope
The audit change replaced the 120s total deadline with connect(10s) +
read_timeout(120s), but reqwest's read_timeout is not a per-read idle
bound for the request phase: its timer starts at send() and is never
reset until the response headers arrive, so it silently re-introduced a
~120s total deadline on the multi-MB upload plus the full non-streaming
generation - the exact healthy work the change meant to protect (the
per-read idle semantics only apply once the response body is streaming).
The accompanying comment described the opposite of the actual behavior.
Bound only the connect handshake on the client and put a 30-minute
envelope (matching the non-streaming model request budget) around the
retry loop and response body consumption, reporting a clear timeout
when it fires. Wiremock tests pin both directions: a stalled provider
is cut off by the envelope with a timeout error, and a prompt answer
arriving in time still flows through.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(snapshot): drain git pipes while the child runs
run_git waited for the child to exit before reading stdout/stderr, so
any invocation producing more than the 64 KiB OS pipe buffer deadlocked
on its own output and died at the 300s command timeout. That is not a
corner case: ls-tree -r -z --name-only on the restore path lists one
path per workspace file, and diff --name-only after a large refactor
grows the same way, so medium-size workspaces would have seen /undo and
snapshot listing stall 300s and then fail - the exact turn-pipeline
blocking the audit meant to remove (the old .output() call drained
concurrently and had no such cliff; the removed 'quiet-output' comment
was wrong about the tree-listing and diff commands).
Spawn two reader threads (mirroring the sandbox exec plumbing in
crate::run_sandboxed_exec) so the pipes are drained while the child
runs, join them on both the success and the kill path, and use a
tighter test-only command timeout so the large-output regression test
fails in seconds rather than hanging.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(mcp): widen the read wait per request instead of clamping the knob
The audit fix set the connection-level read timeout to
max(read, execute) so a raised execute_timeout actually governs a
tools/call. That traded one bug for two more: the read_timeout knob
documented in docs/MCP.md was silently disabled (a user-configured read
budget of 30s or 180s was raised to the 1800s execute default, so fast
requests on a wedged server waited out 30 minutes before failing), and
every non tools/call request on the connection - resources/read,
prompts/get, notifications - inherited the same 30-minute liveness
regression for detecting a dead server.
Restore the connection read timeout to the user's knob and widen the
read wait per request instead: call_method now reads for at least its
own outer budget (so a silent server is not declared dead mid tool
call), while everything else keeps the configured read budget. The HTTP
transport's client-level total stays a separate ceiling computed as
max(read, execute) because it must cover the longest request the
transport carries; both decisions are extracted into named pure
functions and pinned with tests.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(runtime): resolve pending approvals when the engine dies or the runtime shuts down
The unbounded external-approval wait only ended on turn interrupt or
runtime-shutdown cancellation, but two of its documented exits were
not actually reachable. A crashed engine dropped its op receiver while
the wait kept polling for a decision that could never come, suspending
the turn and the pending approval forever (the pre-PR 300s timeout was
accidentally masking this); and nothing in the tree ever cancelled the
manager token, so the shutdown exit was dead code.
Probe the engine's op channel with tx_op.is_closed() - a non-consuming
check - in the wait's poll arm, and give hosts an explicit
RuntimeThreadManager::shutdown() that cancels the token the wait (and
turn monitoring) already observe. Both exits resolve the pending
approval through the same deny+interrupted path as a turn interrupt,
emitting approval.decided so clients clear pending UI.
Pin both exits with tests.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(runtime-api): publish approval interrupted flag over SSE and document resolution semantics
The compat SSE projection forwarded only the legacy timeout field of
approval.decided, so external HTTP/SSE clients could not distinguish a
deny forced by an interrupt or shutdown from a deny the user actually
chose; the projection silently dropped the interrupted flag this PR
introduced. Forward it alongside timeout (which now only ever arrives
from legacy journal replays - as does the approval.timeout event, whose
arm is kept for exactly that reason).
Document the full resolution contract in docs/RUNTIME_API.md: approvals
wait unbounded for a human, and every forced resolution carries
decision deny plus interrupted true while a real user decision never
does.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(tools): close the remaining high-impact timeout outliers
The audit normalized tool execution, human-paced waits, and download
budgets, but four outliers of the same classes survived in code the
audit's own arguments apply to:
- Plugin script tools: the model-invoked interpreters kept the 120s
cap the audit raised to 600s for js/code_execution, and a timeout
left the script running orphaned (no kill_on_drop). Align the budget
and kill the child on timeout, matching the interpreter surfaces.
- Provider PKCE browser sign-in: the callback wait stayed at 300s while
the identical human-paced argument moved the MCP OAuth callback to
900s. Apply the same budget.
- Self-update asset download: the total stayed at 300s, which needs an
implausible >1.6 Mbps to fetch tens-of-MB release binaries - tighter
than the 600s the audit gives 20 MiB skill tarballs. Use 600s and
state the arithmetic.
- image_ocr and pandoc_convert ran sync child processes (and blocking
Vision FFI) directly on the async executor with no wall-clock bound:
a wedged tesseract/pandoc stalled the executor and the turn
indefinitely. Bound OCR at 300s (spawn_blocking + timeout, shared by
the image_ocr tool and read_file's image path) and convert pandoc to
a tokio Command with kill_on_drop and a 600s timeout so a wedged
converter is killed instead of orphaned.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(client): bound the non-streaming response body and the Anthropic Messages request
The 30-minute envelope only resolved at response headers: the shared
client has no client-level total timeout, so after a successful response
a provider that trickles the body (a byte every few minutes) still
wedged non-streaming callers - compaction, translate, list-models,
provider-native search - with no per-chunk protection on this path.
Give every attempt in the non-streaming retry loop the same budget as a
reqwest per-request request timeout, which covers connect through body
end, so both the attempt and the whole retry loop are bounded.
The Anthropic dialect's non-streaming Messages request bypasses
send_with_retry entirely (direct send plus a one-shot JSON decode) and
was left completely unbounded - the same accept-and-stall wedge the
envelope fixed elsewhere. Apply the same budget there.
A wiremock test pins the envelope by injecting a short budget through a
test-only override and asserting a stalled provider fails with a
timeout instead of hanging (serialized through the shared test-env lock
because the injected budget is process-global).
Signed-off-by: asto18089 <asto18089@126.com>
* fix(tools): make the interpreter timeout actually kill the child
The kill_on_drop fix relied on the timeout dropping the Command::output()
future to kill the interpreter, but that is not how tokio behaves:
output() moves the Child into wait_with_output(), and dropping that
future mid-wait does not run the kill - a minimal reproduction (spawn
node with kill_on_drop(true), wrap output() in a timeout, let it elapse)
leaves the child alive. So js_execution, code_execution, and the plugin
runner still orphaned their interpreters on timeout; the audit claim
'the interpreter child is killed on timeout' did not hold. Only a plain
drop of an owned Child handle kills via kill_on_drop.
Restructure all three sites: spawn explicitly, keep the Child handle,
drain stdout/stderr with concurrent tasks (so a child blocked on a full
pipe buffer cannot turn the timeout into a guaranteed kill), and kill +
reap explicitly when the deadline fires. kill_on_drop stays as the
cancel-path backstop, where the owned handle really is dropped.
Both interpreters get regression tests that capture the child's pid and
assert it is gone after the timeout (previously these fail: the child
survives the kill).
Also pin the RLM sub_query_timeout_secs wiring with a hanging-child
test asserting the configured budget governs the deadline.
Signed-off-by: asto18089 <asto18089@126.com>
* docs(mcp): refresh timeout defaults and explain the read/execute split
The minimal example still showed the retired 60s execute default, and
neither the field list nor the Chinese reference explained how the
budgets interact: execute_timeout bounds a whole tool call (a server is
silent until its tool finishes, so slow tools raise this knob), while
read_timeout bounds the response wait of quick requests and a tools/call
wait is automatically widened to at least its execute_timeout.
Signed-off-by: asto18089 <asto18089@126.com>
* style: apply rustfmt to the timeout-fix commits
Signed-off-by: asto18089 <asto18089@126.com>
* fix(config): follow the sub-agent heartbeat expectations past the new tool-timeout floor
Raising the sub-agent tool timeout default to 1800s moved the
tool-timeout heartbeat floor (tool_timeout + 30) above two stale test
expectations that predate it: a 900s api timeout no longer dominates
the floor (1830 wins, and that is the point - cleanup must never fire
before a 1800s tool finishes), and the /config subagents status shows
1830 for the api-timeout-0/heartbeat-1 scenario. Update both, keep the
'heartbeat follows a genuinely long api timeout' scenario by adding a
maxed-out api timeout case (3600s api floor wins up to the heartbeat
clamp), and document the dominance in comments.
These surfaced only now because the original commit's compile break
masked every test behind it; CI's full-workspace run caught them.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(client): keep per-attempt request totals off the streaming open path
The per-attempt reqwest total added to send_with_retry covers connect
through body end — which is exactly why it must not apply to streaming
opens: reqwest wraps the response body in TotalTimeoutBody, so the total
rode on the returned body and hard-cut any live stream 30 minutes after
send, on the default ChatCompletions dual-stack open and on every
Responses open. The old comment claimed the envelope was inert under the
callers' 45s open budget; that budget only bounds the open, not the body
that outlives it.
Stream opens now go through send_stream_open_with_retry (and the JSON
twin open_stream_json_with_retry), which sets no total and skips the
loop envelope; their bounds stay the caller's stream_open_timeout plus
per-chunk idle checks. list_models moves to send_with_retry_total so its
pinned 30s survives — .timeout() on the builder is a pure overwrite, so
the shared envelope silently stretched it to 1800s before — and the
loop envelope now dominates the per-attempt total it wraps instead of
strangling a larger pinned budget.
Wiremock regression tests pin both: a stream open answering past the
injected envelope must succeed, and a caller-pinned total must not be
overwritten.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(runtime): exclude the user-input human wait from the turn wall clock
The unbounded request_user_input wait claimed to mirror the approval
wait, but only the approval wait pauses the per-turn wall-clock budget
(begin_human_wait had exactly one non-test caller). An answer submitted
past the budget was collected and then discarded: the next provider
boundary saw an exhausted budget and failed the turn — the exact
failure class this PR removed for approvals, now with an unbounded
burn instead of the old 300s cap.
Wrap the wait in begin/end_human_wait like await_tool_approval, and pin
it with an engine test: a 1s budget plus a 1.2s-delayed answer must
complete the turn and reach the second model request.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(tools): return from timeouts even when grandchildren hold the pipes
Killing the direct child closes only its pipe write ends. A grandchild
that inherited them (a background process a script spawned, a
post-checkout hook, a gc pack worker) keeps them open, so the drain
tasks' read_to_end never sees EOF — and the timeout arms joined those
tasks, hanging the caller past the 600s/300s bound and re-introducing
exactly the wedge the bounds were added to remove. The old
join-cannot-hang comments asserted the opposite of the real pipe
semantics; run_git's comment also cited a helper that does not exist
(crate::run_sandboxed_exec; the mirror is run_sandbox_command, whose
timeout path in fact detaches rather than joins).
All three interpreter timeout arms now abort the drain tasks instead of
joining them (dropping the read ends hands grandchildren an EPIPE), and
run_git detaches its reader threads on the timeout path. A js regression
test spawns a pipe-holding grandchild and pins the prompt return: the
join implementation measured 30.0s (the grandchild's full sleep) against
the 5s test budget.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(sandbox): bound the two unbounded external-request paths the audit missed
Two paths sit outside the timeout fixes this PR already shipped while
carrying the exact same failure modes:
- The OpenSandbox backend hardcoded a 30s client-level total for the
HTTP exec — and that request IS the remote command, so any remote
build or test run past 30s was cut off at the HTTP layer, with no way
to raise it. 600s now matches the Bash tool's foreground cap for the
same class of work, under a named constant.
- The app-server OpenAI-compatible proxy built its upstream client from
the shared platform builder, which sets no timeouts at all: on a
non-streaming forward, a provider that accepts the connection and
stalls (or trickles the body) wedged the handler — the accept-and-
stall wedge fixed in the TUI client. Bound the connect at 10s and the
whole round trip at 1800s, mirroring the TUI non-streaming envelope.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(snapshot): clear a stale side-repo index.lock on open instead of failing forever
Snapshot git commands are bounded at 300s, and the timeout path SIGKILLs
the child — which skips git's own lock cleanup. A killed `git add -A`
or `checkout` (the commands most likely to hit the bound) leaves
index.lock behind, and every later snapshot then fails fast on the lock:
the degrade-to-disabled story silently becomes permanently-disabled,
with nothing user-visible pointing at the cause.
On open, remove an index.lock older than one hour (matching the stale
tmp_pack bound; no live git of ours can hold it past the command bound)
and warn, since its presence means snapshots were silently failing.
Fresh locks are left alone. Covered by a stale-vs-fresh regression test.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(tools): close the small gaps the review round flagged
- task gate runs: kill_on_drop on the gate command, so a timed-out gate
child is not left running orphaned (the same bug the interpreter tools
fixed; the gate kept the plain timeout(cmd.output()) shape).
- fetch_url schema: the timeout_ms description still said max 60,000
after the hard cap moved to 300s, so the model-visible contract
understated the ceiling it clamps to.
- rlm recursion: the nested bridge built for rlm_query never inherited
sub_query_timeout_secs, so the knob governed only the top-level
bridge's llm_query calls; it now follows the recursion. The parameter
is mandatory at every run_rlm_turn_inner call site, which keeps the
wiring enforced.
- plugin scripts: the execution timeout is now 5s under cfg(test) like
the js/code interpreters, and the timeout kill is pinned by a
pid-liveness test (the PR claimed pid tests at all three interpreter
sites; plugin was the one missing it).
- shutdown(): the doc now states what the token actually resolves —
approvals only — and that there is no production caller yet, instead
of implying all suspended waits are covered.
- SSE projection: regression coverage for the interrupted flag and the
legacy approval.timeout replay arm.
Signed-off-by: asto18089 <asto18089@126.com>
* style: satisfy clippy on the rlm recursion threading
Signed-off-by: asto18089 <asto18089@126.com>
* fix(tasks): feed the worker idle watchdog from the in-flight tool window
The in-flight-tool suppression added for silent builds only refreshed
drive_engine_turn's own guard. The worker supervisor (run_task) wraps the
executor in a second ExecutionGuard fed exclusively from the task-event
channel, where a silent tool produces nothing after ToolStarted — so on
the only production path (automation tasks through
EngineTaskExecutor::execute) the 2-minute idle deadline still fired and
cancelled the task. The previous tests all drove drive_engine_turn
directly and structurally could not see the supervisor.
Publish the in-flight window as a ToolHeartbeat task event while
running_tools is non-empty: the supervisor counts it as progress, and it
is deliberately not persisted or surfaced on the timeline. The seam is
pinned from both sides — the runtime loop must emit the heartbeat, and
the supervisor must count it — each side fails if its half is reverted.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(tests): gate the code-execution pid test to unix
The pid-liveness assertion polls the process via libc, which is only a
dependency on unix targets, unlike the js/plugin kill tests that carry
Signed-off-by: asto18089 <asto18089@126.com>
#[cfg(unix)]. Ungated, the Windows test leg fails to compile.
* fix(tools): bound the interpreter pipe drain after a clean exit
The budget wrapped only child.wait(): after a normally-exiting child,
the stdout/stderr drain joins on the success path were unbounded, so a
grandchild that inherited the pipes (the old 'npm run dev &' pattern)
held the tool call past the deadline — the old timeout(output()) shape
bounded the whole operation, so this was a regression introduced by the
explicit-kill restructure. The timeout arms already aborted their drains
for exactly this reason; the success arm now applies the same rule with
a short grace: a clean child sees EOF immediately, and only an
inherited pipe can hold it, in which case the captured output is
returned with a note on stderr instead of truncating silently.
The three near-identical interpreter blocks (js, code, plugin) and
their byte-identical drain helpers collapse into one shared
tools::process::run_bounded_child, which also writes the plugin stdin
payload and aborts it on the timeout path instead of leaving it
detached.
The comments also stop claiming that dropping a wait_with_output()
future does not kill the child: on the pinned tokio the ChildDropGuard
does kill on drop. The real reasons for holding the Child explicitly
are a synchronous, testable reap and the ability to return promptly at
child exit instead of coupling the deadline to pipe EOF.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(snapshot): route every git call through the bounded drain core
git init and the date-pinned commit-tree ran as bare .output() calls
with no timeout — commit-tree is reachable from the per-turn prune
path — while the stale-lock doc comment claimed every git invocation
in this module is bounded. Both now go through the same bounded core as
run_git, making that comment true.
The core itself closes two ways the 300s bound could be escaped: the
success-path readers are joined under a short grace (a hook or gc pack
worker that inherited the pipes could otherwise hold read_to_end, and
with it the turn pipeline, indefinitely — partial output is returned
with a note on stderr), and the kill's reaping wait moves to a detached
thread, because on a hard-wedged mount git dies only when its
uninterruptible syscall returns and a blocking wait() would hang the
pipeline exactly like the wedged git would.
Readers stream into shared buffers so a grace expiry keeps what was
captured instead of losing it with the thread. A timed-out git also
reaches the user's stderr through the once-per-workspace snapshot
notice now — the fresh index.lock it leaves behind fast-fails
snapshots for about an hour, and that must not be log-only.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(app-server): bound the runtime bridge client and event stream
The bridge client was built from the platform builder with no timeouts
at all — the same shape commit b32fc69 fixed for the upstream proxy
in this crate, one module away. The inner bridge lock is held for the
whole turn, so one request to a live-but-wedged runtime child queues
every later JSON-RPC message behind it forever.
The client gets a 10s connect budget; the bridging POSTs (enqueue and
return by design) get a 1800s total matching the TUI non-streaming
envelope; and the SSE event stream gets a 60s per-chunk idle bound that
re-arms every chunk — the runtime emits 15s keepalives, so this catches
a wedged stream without total-capping a live one (a per-request total
would ride the body and hard-cut long turns, the same trap the model
client's stream-open path avoids).
Signed-off-by: asto18089 <asto18089@126.com>
* docs: sync operator docs and comments with the current wait semantics
The heartbeat docs still resolved the default to 630s from
api_timeout_secs alone and never mentioned the tool-timeout floor the
code has applied since the sub-agent tool timeout moved to 1800s;
operators reading SUBAGENTS.md or CONFIGURATION.md compute 630 and
observe 1830 from /config with no documented rule. Both languages now
state both floors.
RUNTIME_API.md presented runtime shutdown as an active approval
resolution path, but RuntimeThreadManager::shutdown has no production
caller yet — the contract is now stated as latent until a host wires
it up. The MCP OAuth comment in handlers.rs still said five minutes
after the wait moved to fifteen.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(mcp): give the send leg the same per-request budget
call_method awaited self.send outside every budget: a server that
wedged without draining stdin blocked write_all forever — the same
liveness hole the per-request read budget closed on the receive side.
The send now shares the request budget; a timed-out partial write
desyncs the line protocol, so it routes through finish_guarded_error
like the read-side timeout and the caller reconnects.
Two wiring tests pin the receive leg's widening and the send bound at
the call_method level, not just in the pure functions: a server that
answers slower than a small read knob but well inside a raised
request budget must survive, and a stalled write side must hit the
budget with a send-specific error.
Also make the 900s OAuth browser wait consistent everywhere: the tool
description and two comments still said five minutes, and the
synthetic-tool test pinned the old wording.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(client): widen the injected test envelope against parallel load
The non-streaming envelope tests inject a process-global 250ms budget;
tests that issue real non-streaming requests without holding the test
env lock can run concurrently with that window, and a localhost round
trip over 250ms under CI load fails them spuriously. Inject 2s with
scaled server delays instead — the pins are unchanged, only the
safety margin.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(sandbox): connect-bound the OpenSandbox exec client
The 600s total arrived without a connect timeout, so a black-holed
host ate the whole exec budget stalling in TCP connect while every
other client this PR bounds fails on the 10s connect family.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(tools): restore the pandoc tool docs and complete the OCR disclosure
The PANDOC_TIMEOUT constant was inserted between PandocConvertTool and
its doc comment, leaving the tool undocumented and the rustdoc lost.
The image_ocr timeout disclosure now also mentions the orphaned
tesseract child a timed-out call leaves behind, not just the blocked
thread.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(snapshot): make git pipe readers cancellable
A drain-grace expiry on the success path dropped the reader
JoinHandles, and the timeout path detached them outright: each reader
thread stayed blocked in a pipe read for as long as a grandchild held
the write end, so a hook or subprocess that daemonized leaked one OS
thread per pipe per git call. The regression only backgrounded a
sleep, so the leak self-healed after 30s and the coverage hole went
unnoticed.
The drain loop is now cancellable on both platforms — a non-blocking,
polled read on Unix, a PeekNamedPipe probe loop on Windows — and both
exits cancel and join the readers, so the joins are bounded by one
poll interval and the call provably leaves no reader threads behind.
A live-reader counter makes the join observable to the new regression,
which drives two calls with a never-exiting grandchild and fails
('4 still running') if the join reverts to a detach.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(snapshot): pass git paths via env, not argv
The refactor that routed run_git through the bounded drain core moved
the --git-dir/--work-tree sinks, and code scanning re-flagged the
unchanged flow as a new command-line-injection alert, turning the PR
gate red on a finding that already exists as two open alerts on
pinvou3-clean. Both paths now ride git's documented GIT_DIR /
GIT_WORK_TREE environment interface — the same mechanism and the same
never-touch-the-user's-repo guarantee, with the paths out of argv so
the accepted flow stops re-flagging on every refactor.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(runtime-api): restore only known snapshot ids
CodeQL's PR analysis kept re-flagging run_git because the flow it
reports starts at the /v1/snapshots/{id}/restore route: the request
path id reached git as a treeish, so every refactor of the sink
re-registered the finding instead of matching base alert codewhale-hq#198. The
restore endpoint now resolves the id against the snapshots the side
repo actually knows and 404s on anything else — the allowlist contains
is the scanner's modeled trust boundary, and unknown ids no longer
reach a git command line at all.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(tasks): keep liveness heartbeats out of the persist path
ToolHeartbeat was missing from the persist-urgent exclusion list, so
every ~200ms heartbeat during a silent build rewrote the whole task
record while holding the manager-wide state lock — contradicting the
variant's own 'never persisted' contract. Also comment the disclosed
compaction blind spot at the in-flight tracking set.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(mcp): poison the connection on request-leg timeouts
A timed-out send can leave a partial line in the pipe, and a timed-out
outer read wait can still be answered late — both desync the line
protocol for the next request, yet neither path marked the connection
Disconnected, so the pool handed the broken transport right back out.
Both arms now mark it dead (matching the inner read timeout) and the
comment states the real behavior; wiring tests pin the state.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(snapshot): surface session failures through the stderr notice
The once-per-workspace snapshot notice was wired only to open_or_init
errors, so the motivating case — a wedged 'git add -A' timing out and
leaving a fresh index.lock — stayed log-only, the exact silence the
notice exists to prevent. Session and prune failures now reach it too;
the TimedOut kind gate keeps it to the cases that matter.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(snapshot): ignore ambient GIT_DIR for the side-repo init
git init was the one command not cleared of an ambient GIT_DIR /
GIT_WORK_TREE exported by the launching shell, which could redirect the
one-time init away from the hashed side-repo path; every later command
would then fail against the intended repository. Also fixes the stale
comment that still described flag-based path routing.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(tools): let plugin callers detect a truncated drain
The drain-grace truncation note lands on stderr, but the plugin surface
reports stdout only, so an unparseable, possibly cut-off output passed
as a silent success. The note is now a shared constant with a probe,
and the plugin arm fails loudly when the output did not parse and the
drain was truncated.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(runtime): honor a queued approval decision over cancel
The wait's biased select favors the cancel token, so a decision already
queued at the instant of an interrupt or shutdown was discarded and
reported as an interrupted deny — contradicting the documented contract
that a decision the user actually made never carries interrupted. Both
exit arms now rescue a queued decision via try_recv; a test pins the
race deterministically.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(app-server): bound the runtime stream header wait
A runtime child that accepts the bridge's event-stream GET but never
writes response headers held the bridge lock forever — the queue-forever
scenario the client bounds were meant to remove, surviving through the
headers phase. The header wait now shares the 60s chunk idle deadline;
the live stream itself keeps no total.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(sandbox): correct the exec budget rationale
The 600s constant claimed to match the Bash tool's foreground cap, but
the Bash cap is effectively unbounded — the real parity class is the
interpreter tools' 600s execution cap. State that, and note the bound
is a transport backstop rather than a per-command policy.
Signed-off-by: asto18089 <asto18089@126.com>
* fix(tests): widen the human-wait wall-clock margin
The 1s budget against a 1.2s answer left the engine's pre-pause work a
hair of headroom on a loaded CI host. The margins move to 2s/3s; the
exclusion semantics the test pins are unchanged.
Signed-off-by: asto18089 <asto18089@126.com>
* docs: sync stale wait wording and the restore 404 contract
The MCP OAuth login comment still said five minutes (now 900s), and
SUBAGENTS.md presented tool_timeout_secs as a resolvable config key
when only api_timeout_secs exists — the tool timeout is a compile-time
constant. RUNTIME_API.md now states the restore endpoint's
exact-match/404 contract.
Signed-off-by: asto18089 <asto18089@126.com>
---------
Signed-off-by: asto18089 <asto18089@126.com>
…T7) (#65) * feat(subagent): list host-presented profiles in agent roster action (T7) The agent tool's action=roster only ever returned the 8 builtin Fleet roles, while the base contract tells hosts to present exact prompt-only profile ids themselves — leaving embedder-injected profiles (Pinvou's expert cards) undiscoverable by the model. The roster payload now adds host_profiles (sorted by member id, capped at 48 with host_profiles_truncated and host_profile_count), listing exactly the members profile=<member_id> can resolve: the acceptance contract (config origin, prompt-only, no built-in role-token shadow, bounded selector) is single-sourced in is_spawnable_host_profile and shared by the listing and spawn resolution, so the roster never advertises an id spawn would refuse. Builtin members keys are unchanged and the payload stays stable when no host profiles exist. Tests: forkguard_agent_roster_lists_only_spawnable_host_profiles pins that the listing mirrors spawn acceptance (mixed origins, route pins, role-token shadows, oversized selectors); agent_roster_lists_host_presented_profiles_spawn_resolves; agent_roster_truncates_host_profiles_at_the_listing_cap; the existing roster action test pins the new empty-shape keys. Signed-off-by: asto18089 <asto18089@126.com> * feat(subagent): let roster profile_query filter host profiles (T7) The roster host-profile listing caps at 48 rows and the agent schema has no pagination, so embedding hosts presenting more spawnable profiles than the cap left their tail permanently undiscoverable: the model could see host_profiles_truncated=true but had no way to reach the dropped ids, and profile=<member_id> requires knowing the exact id. Host-review follow-up on the swarm phase 2 parent PR. Add an advertised, roster-scoped profile_query keyword: a bounded, case-insensitive substring match over member ids and the same description the listing displays (matched before output bounding). The filter narrows the spawnable pool before the cap; host_profile_count and host_profiles_truncated describe the filtered set, so a zero-match answer stays honest. Blank or non-string values filter nothing. The advertised surface grows 12 -> 13 fields; budgets, routing and workspace knobs stay off (pins updated). Tests: the 13-field schema pin, the roster dependentSchemas branch advertising profile_query, and a discovery regression that builds 50 spawnable profiles, proves exp-host-049 is cut from the default listing, found by description and id keywords, honest on zero matches, ignored on blank input, and resolvable at spawn via profile=. Signed-off-by: asto18089 <asto18089@126.com> * test(subagent): close review-pinned coverage gaps (T7) Fresh-review follow-ups on the roster/profile_query wave: the forkguard shadow mirror now resolves against a roster that actually contains the renamed role-token member, so FleetRole::from_str-over-roster precedence is load-bearing (a roster without the id takes the role path under any ordering); the discovery regression pins partial-id substring matching so an exact-equality regression on the id leg fails; the ROSTER_PROFILE_ QUERY_MAX_CHARS doc now states the truncate-not-refuse convention it actually implements; docs/FLEET.md cross-reference follows the advertised surface to 13 fields. Signed-off-by: asto18089 <asto18089@126.com> * fix(fleet): dedupe host ids that get() conflates [fleet.profiles] is a byte-order BTreeMap, so case-variant and whitespace-variant keys (Expert / expert / ' expert') are distinct legal entries while FleetRoster::get resolves all of them to the first member in order. The host roster built by from_host_config listed every key verbatim, so the roster action could advertise an id whose selection silently spawned a different profile's prompt — every other profile layer (directory loaders, ambient merge) already fails closed or shadows on exactly this collision. Collapse each duplicate to the member get actually resolves to, and pin the listing/spawn invariant with a case/whitespace collision regression: every advertised member_id must resolve back to itself. Signed-off-by: asto18089 <asto18089@126.com> * fix(fleet): collapse conflatable config ids at load too The roster listing and the spawn path build the [fleet.profiles] layer through two independent constructions. The listing reads the engine-installed config.fleet_roster, built by FleetRoster::load, whose merge_member folds each config key into the merged roster last-wins (byte-order-last key replaces its conflatable siblings in place). The spawn path re-resolves profile=<id> against a roster rebuilt per spawn by refresh_spawn_route_sources via FleetRoster::from_host_config, which 0576deb teaches to collapse ids get() conflates (trimmed, ASCII case-insensitive) FIRST-wins. For case-variant config keys such as 'Expert' and 'expert' — both legal, distinct TOML keys — load advertised the last key's member while get() on the spawn roster resolved every spelling to the first one: the roster could list a profile whose prompt silently differs from the one a selection actually spawns. The invariant was previously established only on the from_host_config side; both regressions (agent_roster_never_lists_ids_that_spawn_a_ different_profile, from_host_config_collapses_ids_that_get_resolves_ together) constructed their rosters through from_host_config directly, so the listing/spawn pairing gap could not surface. Extract the first-wins collapse (and the config-entry-to-member mapping) into shared helpers and apply the same collapse to the config-extracted profile list in load before merge_member, so the last-wins layer merge never sees two conflatable config keys. Cross-source precedence is preserved exactly: config entries still override built-ins and are still overridden by plugin, personal, and workspace layers; only intra-config duplicates are removed, in the same direction from_host_config already pins. Regressions: - agent_roster_listing_and_spawn_agree_on_conflatable_config_ids builds the listing side the way the engine does (FleetRoster::load) and drives the spawn side through the real refresh_spawn_route_sources, asserting the listed row is the member spawn resolves, with the same id, description, and prompt overlay. Verified to fail on the pre-fix code. - load_collapses_conflatable_config_ids_like_from_host_config pins the load-side direction at the roster unit level. - roster_host_profile_listing_cap_is_pinned_at_48 literal-pins ROSTER_HOST_PROFILE_LIMIT to the 48-row cap the app-side roster contract teaches. Signed-off-by: asto18089 <asto18089@126.com> --------- Signed-off-by: asto18089 <asto18089@126.com>
* fix(tui): 恢复历史隐藏内部运行时 handoff,保留 MCP system cell 例外 吸收上游 6362e1e 的通用过滤:history_cells_from_message 在 restored checkpoint 分支之后接入 is_internal_runtime_handoff, background shell completion 等运行时 handoff 恢复后不再渲染为 User cell(fork 同样存在该 bug)。过滤仅作用于显示层,持久化与 模型侧 api_messages 逐字节不变。MCP briefing/recovery 在更早分支 已按 fork 决策返回 System cell,不会落入新过滤器,行为保持。 测试按上游语义改写并新增: - apply_loaded_session_never_restores_background_shell_event_as_composer_draft 覆盖 current/legacy 两种 provenance、保存与会话消息字节不变、 恢复后 cell 序列精确为 [User, Tool, Assistant]; - 新增 apply_loaded_session_keeps_user_authored_shell_event_lookalikes: 无 provenance 的用户手打 envelope 形似物仍可见; - 新增 restored_background_shell_completions_yield_no_cells_but_user_text_survives: 单元级钉住两种 provenance 隐藏、普通用户文本幸存; - backtrack 回归测试追加 handoff 不计为用户发言的断言。 红→绿:改写后的 resume 回归、单元级隐藏断言与 backtrack 断言在 改动前均失败,改动后通过;forkguard MCP system cell 测试全程通过。 验证:cargo fmt -p codewhale-tui;cargo clippy -p codewhale-tui --lib -- -D warnings 通过;history::/ui::/runtime_handoff::/ session_manager::/session_peek::/compaction:: 共 924 项通过;全量 lib 11849 项通过(4 个 remote_control/runtime_threads 用例因共享 runtime 目录在并行下环境性失败,单独运行通过,与本次改动无关)。 Signed-off-by: asto18089 <asto18089@126.com> * fix(tui): read 结果按预算自限后不再被上下文压缩器二次截断 移植上游 e7f7c71 的 context 压缩层豁免守卫。read 工具按字节预算自我 截断并以页脚声明"从 offset 续读",但压缩器硬限 12,000 字符会把打满预算 的 read 结果再做一次 head/tail 压缩,内容被切、续读契约丢失。 - compact_tool_result_for_route:metadata 带 read_budget_bytes 且 raw 字节长度不超过预算时原样放行(位置与上游一致:evidence_available 早退之后、subagent 摘要器之前);超出自身预算仍走常规压缩。 - read 工具两条物理路径在受预算约束的成功结果上写入 read_budget_bytes: execute_contract_read(read 原语,50KiB 预算)与 render_line_window (read_file 遗留阅读器,16KiB 预算)。值取最终输出实际字节长度: 窗口预算约束的是内容,续读页脚与 <file> 包装叠加其上,只有按最终 payload 声明,守卫条件才对打满预算的结果成立。 - 测试:engine 层验证带 metadata 原样放行、无 metadata 与超预算仍压缩; file 层两条路径各验证打满预算的结果携带 metadata 且过压缩器后页脚 完整存活(端到端)。 验证:cargo fmt;cargo clippy -p codewhale-tui --lib -- -D warnings 通过; cargo test -p codewhale-tui --lib -- core::engine::tests tools::file 531 通过 0 失败;contract_read read_budget budgeted_read 过滤 7 通过。 改动前 3 个新测试均红(被二次压缩 / 缺 metadata)。 Signed-off-by: asto18089 <asto18089@126.com> * fix(tui): 行摘要器输出有界的 child 路由回执 上游 0c03b5a 在 emitter 侧让每行 compact status 携带有界化的 typed child_route,fork 的 subagent 行摘要器此前丢弃该字段,unscoped fleet 监视面上 child 实际使用的 provider/model 路由到不了模型。 - summarize_subagent_snapshot 每行在 receipt 可打印时输出 route 行: provider/model,附 route_source 与 resolved_profile_id(连接身份); 字段缺失或形状不符时静默跳过,不打印空占位。 - 有界:各字符串字段 preview 截 64 字符,整行硬性守卫 200 字符,不 依赖 emitter 侧 compact_child_route 的 ≤1024 字节保证。 - 兼容三形状:对象 receipt、裸 "provider/model" 字符串、snapshot wrapper 包裹(wrapped 行优先,envelope 兜底,与 transcript_handle 的 fallback 规则一致)。 验证:cargo fmt -p codewhale-tui;cargo clippy -p codewhale-tui --lib -- -D warnings 通过;RUST_MIN_STACK=16777216 cargo test -p codewhale-tui --lib -- core::engine::tests 381 通过(新增 forkguard_fleet_summary_rows_carry_bounded_child_route 与 forkguard_subagent_projection_child_route_reaches_summary_row, 先红后绿)。 Signed-off-by: asto18089 <asto18089@126.com> * style: 自检微调——守卫注释改 fall-through 表述,route 行缺失段不再打占位 Signed-off-by: asto18089 <asto18089@126.com> --------- Signed-off-by: asto18089 <asto18089@126.com>
* fix(mcp): stream session boot, make tool_search boot-aware The session-boot connect pass returned only after every pending server settled, and only then stored results into the pool. Two slow remote 401s could therefore hold the local command servers' tools off the shelf for the whole boot window (~88s in the field), while tool_search consulted a turn catalog built from the empty pool — and a bare empty result is indistinguishable from "no server configured", so a forced-capability request issued shortly after launch collapsed into a definitive "capability unavailable" verdict even though the capability came up moments later. Three coordinated seams: - boot: `spawn_pending_connects` returns the unfinished JoinSet and the session-boot task applies each result under a short pool lock the moment it settles — first-settled-first-available — sending Progress per settle; `connect_all` keeps the drained batch wrapper. - turn loop: while boot is in flight, each tool batch folds servers that became ready since the turn started into the live catalog (append-only, deferral mirrored from the turn build), so an in-turn tool_search retry can find them. The boot update channel is left to the idle seam, which owns the failure briefing — it must never be appended mid-tool-loop. - tool_search: while boot is in flight the result carries an `mcp_boot` block naming the servers still connecting, and the tool description states the retry contract, so a miss reads as "not yet", never as "absent". A settled boot keeps every existing contract byte-compatible: the payload carries no booting block, the refresh is a no-op, and the drained batch shape remains for `connect_all`. Tests use a real stdio fixture server: fast-server tools become snapshot-able while a hung server is mid-timeout (mutation-checked against the batch-return shape); the refresh append/deferral/ no-duplicate contracts; the connecting-status truthfulness boundaries; and the settled-boot payload shape. Signed-off-by: asto <asto18089@126.com> * fix(mcp): narrow boot appends and trust pool diagnosis Review of 79853e3 surfaced four MAJOR seams the first cut left open; this closes them without changing the streaming shape. - The boot-window catalog refresh now applies the same narrowing the turn build does: the MCP feature and tool-security gates, the turn's ToolSurfacePolicy allow/deny predicates, and apply_mcp_tool_deferral itself for the deferral treatment. A tool the build stripped can no longer re-enter mid-turn, where a search hit would defeat the denied-name concealment and a declared activation would serialize an operator-excluded schema into the request. - An always_load append now resolves eager and joins the active set, with the caller declaring the growth through the existing tool_surface re-pin; a turn starting while boot is in flight stamps "mcp-session-boot" up front, so the next turn's snapshot picking up a newly settled tool is a declared re-pin instead of undeclared drift tripping the C5 guard (the stamp is inert when nothing changed). - The connecting status reads the pool's live truth: catalog_authorized servers count as answered (wider than is_ready, matching all_tools) and connect_backoff entries count as diagnosed, so a server that already settled with a failure is never reported "still connecting" before the queued boot update is drained; collect_pending_connects drops the backoff entry when it re-pends a server, preserving "backoff entry => no attempt in flight". - The booting note and comments now describe what the code does, the JoinError attribution lives in one helper, and the misspelled test name is fixed. New tests drive the turn loop end to end (search finds the refreshed tool and carries the boot note; the boot-window turn start declares its surface change), plus policy/security, always-load, pool-side-diagnosis and drain-lag regression tests. Signed-off-by: asto <asto18089@126.com> * fix(mcp): keep the retry ladder and close gate gaps Round-2 review of the branch surfaced one consumer regression and two boot-window gate gaps; this closes them without changing the streaming shape. - collect_pending_connects marked a re-pended server by dropping its backoff entry, which also reset the failure ladder: every automatic reconnect cycle restarted a dead server at the 30s base instead of climbing toward the 600s cap, and mcp_tools' per-turn connect_all paid a full spawn+handshake for each reset. The entry now survives the re-pend and a connects_in_flight set marks the attempt, so settled_failure_names still splits "diagnosed, cooling down" from "connecting right now" — and the ladder climbs. - The tool_search seam re-folds the live pool before searching: a server that settled while earlier tools of the batch were executing used to produce a bare miss with no note at all, because the status correctly stopped naming it while the batch-start fold had not picked it up yet. - mcp_boot_search_status now applies the same MCP feature and tool-security gates as the refresh: a tool-security turn's catalog has no MCP surface, so its search must not carry a connecting note for servers that turn can never reach. - The boot re-pin declaration is first-set wins across all three boot stamps (turn start, Finished, drain): an already-pending `resume` or `tool_surface` reason keeps ownership instead of being washed into `mcp-session-boot`. - allows_tool is promoted out of #[cfg(test)] so the refresh and the build spell the narrowing in one place, the batch wrapper's doc describes its actual caller, and two overreaching comments (the backoff entry's "exactly while no attempt is in flight" claim and drop_all_connections' settle guarantee) are rewritten around what the code actually does. Four new tests pin the behavior fixes and were mutation-checked: restoring the entry drop, deleting the search-time re-fold, removing the status gate, and overwriting an already-declared reason each turn the matching test red. Signed-off-by: asto <asto18089@126.com> --------- Signed-off-by: asto <asto18089@126.com>
…71) * fix: add activation hints to rlm handle_read text Signed-off-by: asto18089 <asto18089@126.com> * fix: add the fourth side to the registry cap pin Signed-off-by: asto18089 <asto18089@126.com> * test: cover every submission-echo self-start path Signed-off-by: asto18089 <asto18089@126.com> * fix(rlm): teach the activation path on the stdout preview and truncation marker The two same-class residuals registered by the first round of this PR are now fixed instead of tracked: the handle-route preview line ("N chars; retrieve via handle_read", emitted on every eval output past the handle threshold) and the preview_output truncation marker both embed the shared HANDLE_READ_ACTIVATION_HINT, each with a runtime pin. Signed-off-by: asto18089 <asto18089@126.com> * test(registry): pin the registry_sync description wording and sync the side list The enumeration comment now names all four text sides and both compile-time pins, and the registry_sync tool description gains the missing runtime "eight" pin; the mcp-discovery skill markdown remains the one side reached only by the tripwire message. Signed-off-by: asto18089 <asto18089@126.com> * test(forkguard): make the composer-shell accidental-dispatch defense real The composer shell test pre-advances the model-client call counter so every provider request blocks, making the documented bounded-timeout defense true (the first request used to be absorbed by the canned completion); the goal continuation test documents why its host-status sync stays even though the constructor already armed the objective. Signed-off-by: asto18089 <asto18089@126.com> --------- Signed-off-by: asto18089 <asto18089@126.com>
Chain-segment ("<!-- scoped instructions: ... -->") and
<project_rule source=...> labels carried absolute paths into the
pinned system prompt, so a checkout move or recase changed the prompt
prefix and could emit a spurious <context_update> history append.
Render both labels repo-relative (git-root-relative, forward
slashes), falling back to workspace-relative for rules outside any
checkout and to the absolute spelling only for paths outside the
label root (unreachable by construction).
Pins Pinvou/pinvou-agent#514.
Signed-off-by: asto18089 <asto18089@126.com>
* fix: bind region origin in screenshot rasters Signed-off-by: asto18089 <asto18089@126.com> * test: pin screenshot raster-origin binding Signed-off-by: asto18089 <asto18089@126.com> * fix(computer-use): aim screenshot regions in global screen points and bind measured pixels Review of the codewhale-hq#554 first cut surfaced three gaps this closes: - darwin derived scale from _spdisplays_resolution, which is the POINT resolution on Retina panels — every region shot bound scale 1 while the PNG carries 2x, so clicks still landed 2x off (verified on real hardware). The binding now measures the actual PNG (sips, as zoom already does) and the metadata fallback uses the real pixel field _spdisplays_pixels. - darwin treated -R as display-space with -D; the weight of evidence says screencapture crops -R in GLOBAL screen points. A region now pins the crop itself (no -D) and binds the region verbatim — which also makes region shots work on displays whose origin macOS will not report — and clamps only against a complete display union, since clamping on a partial union could pull another display's region into the main one. - linux rejected negative regions although grim/scrot/import crop in global layout points that go negative on exactly the layouts the virtual-screen binding exists for; and it hardcoded scale 1 while grim renders at the highest output scale, so virtualScreen now carries the contributing scale. An unbound shot no longer advances the backend's last raster — the crop rides along as 'path' so the previous binding stays whole and a follow-up zoom cannot slip past the foreign-source guard with a stale frame. The server stops guessing: a screenshot with neither points nor origin keeps the previous binding (or none — coordinate targets then fail closed with no_raster) instead of binding (0,0), and over ssh the backend's own note is joined with the scp hint instead of overwritten. The screenshot schema now describes the global frame and the clip. Signed-off-by: asto18089 <asto18089@126.com> * test: pin global region frames, measured pixels, and the unbound-raster contract Six mutation checks revert each binding fix and fail the suite: the raw -R passthrough, resolution-only metadata scale, the linux n >= 0 gate, the linux scale-1 hardcode, the server (0,0) fallback, and the darwin file-swap under a stale frame. The Retina fixtures use the real system_profiler field pair (resolution = points, pixels = backing). Signed-off-by: asto18089 <asto18089@126.com> * fix(computer-use): aim win32 screenshot regions in global points Review round on #72 surfaced the last cross-platform coordinate mismatch: the shared schema, darwin, and linux all define the screenshot region in GLOBAL screen points — the frame list_displays and cursor_position report, negative on multi-monitor layouts — but win32 required non-negative x/y and consumed them as offsets from the VirtualScreen top-left. On layouts whose VirtualScreen origin is not (0,0) the same tool arguments cropped a different position, and a region on the left/upper display (negative global coordinates, e.g. [-1500,100,400,300] with VirtualScreen x=-1920) was rejected outright. The win32 backend now consumes global points like the other backends: the script derives the VirtualScreen-relative crop offset itself (rx = region.x - bounds.X; node cannot know the bounds before the script echoes them), clamps it against the virtual screen, and copies at bounds + offset as before. Node recomputes the same clip with clampRegion and restores global points, so the bound points remain the crop actually taken. Tests pin the contract from global-coordinate inputs — including a negative region aiming at the left display and a region clamping past the left/top edge — and the malformed-region gate now covers junk, arity, and degenerate sizes without the negativity clause. Signed-off-by: asto18089 <asto18089@126.com> --------- Signed-off-by: asto18089 <asto18089@126.com>
* refactor: extract checkpoint recognition to leaf module Signed-off-by: asto18089 <asto18089@126.com> * docs(tui): point the moved recognition docs at their consumers The leaf has no consumers of its own, so "every consumer here" was vacuous; name the compaction/runtime_handoff consumers instead. The module doc's no-reverse-edge claim gains its production qualifier and names the disclosed test-module residue. Signed-off-by: asto18089 <asto18089@126.com> --------- Signed-off-by: asto18089 <asto18089@126.com>
#77) The bounded-child regression test in tools/process.rs is #[cfg(unix)] (it drives a real `sh` and a backgrounded grandchild holding the pipes), but the module-level `use super::*` and the `shell_command` helper it feeds on stayed ungated. Under cfg(test) on Windows the test is compiled out and the leftovers trip `-D warnings` (unused import + dead code), which is how pinjou-agent#595's windows-rust-test caught it after the 2026-09-22/23 batch landed (#63 introduced the module). Gate both items with #[cfg(unix)] so the module is empty rather than broken on Windows; no behavior change on unix targets. Signed-off-by: asto18089 <asto18089@126.com>
* fix: recognize restore carriers by provenance Signed-off-by: asto18089 <asto18089@126.com> * fix: correct restore residual docs and pin the anchor order Review follow-ups on the restore-by-provenance change: - The loose fallback fires on the structural absence of a stamped carrier, not on session age: it also covers histories that never compacted, where checkpoint is None and a pasted header is dropped outright. State that in the doc comment instead of equating the branch with pre-provenance saves. - Point the history_recognition doc at the live system-prompt scanners (extract_compaction_summary, strip_summary_text); is_compaction_summary_text has no callers. - Add the mirror-order regression test: a pasted full summary BEFORE the real carrier is the anchor-theft order, so the test pins the authoritative summary landing at the real carrier's index, with distinct carrier/checkpoint texts so leaving the old carrier in place cannot pass. Signed-off-by: asto18089 <asto18089@users.noreply.github.com> --------- Signed-off-by: asto18089 <asto18089@126.com> Signed-off-by: asto18089 <asto18089@users.noreply.github.com> Co-authored-by: asto18089 <asto18089@users.noreply.github.com>
* fix(tui): allow the fetch_update rename deprecation Rust 1.99 renamed Atomic::fetch_update to try_update and deprecated the old name, which now fails the workspace warnings = "deny" gate whenever the floating stable toolchain resolves to 1.99 (first seen as pinjou-agent#505's windows-rust-test regression on 2026-10-01, where the Windows runner picked up stable 1.99.0 mid-day). The workspace MSRV (1.88) predates the new name, so keep the old call and silence the rename deprecation at the only call site until the MSRV passes 1.99. Verified: RUSTUP_TOOLCHAIN=1.99.0 cargo check --workspace --all-targets --locked is clean, and the forkguard_ (131) plus forkguard_benchmark_ (6) lib suites pass under 1.99.0. Signed-off-by: asto18089 <asto18089@126.com> Signed-off-by: asto <asto18089@126.com> * fix(tui): clear clippy 1.99 findings on existing code The floating stable toolchain now resolves to 1.99.0, whose clippy adds three findings on lines this PR does not otherwise touch: a single-element for loop over ["args"] in agent_plugin's mcp.json validation (unrolled; identical checks and error strings) and needless_borrows_for_generic_args on the two &bounded closure borrows in workflow_panel (the closure is non-capturing and Copy). The workspace lint gate runs clippy under the same floating toolchain, so these block the fetch_update deprecation fix from merging. Signed-off-by: asto18089 <asto18089@126.com> Signed-off-by: asto <asto18089@126.com> --------- Signed-off-by: asto18089 <asto18089@126.com> Signed-off-by: asto <asto18089@126.com>
* fix(route): honor custom wire on runtime routes A named [providers.<name>] table with wire = "responses" (or "anthropic") minted a Chat Completions candidate: RouteRequest had no wire channel, so candidate.protocol() came from Custom's backward-compatible static policy and every per-turn client built by DeepSeekClient::from_candidate bound Chat regardless of the table's dialect. The ambient spawn-time client (DeepSeekClient::new) and provider_capability_with_wire honored the override, so hosts that resolve routes per turn - the codewhale-hq#3384 architecture - silently talked to {base}/chat/completions while their config said Responses. RouteRequest gains a wire_override honored only for ProviderKind::Custom (built-ins keep their descriptor policy); the tui route layer populates it from the active table's dialect (custom_wire_override_for, the same source provider_wire_format_for_config reads), and the client-internal rebind/request/limit resolutions pin the transport's own wire so an engine-internal model switch cannot downgrade it. The minted candidate is now wire-true end to end: receipts, preflight, and the per-turn client all read the same protocol. Pinvou PR codewhale-hq#625 routes catalog GPT models through such a table and hit exactly this gap (three review rounds missed it because no test pinned the wire on the runtime path). Signed-off-by: asto <asto18089@126.com> * feat(tui): capture reasoning on custom responses Encrypted reasoning-item capture was gated on the Codex provider, so a wire = "responses" Custom table streamed reasoning items that were never persisted and replayed id-less function_call continuations without their paired reasoning items - the per-round reasoning-continuity loss the OpenAI cookbook documents for store:false. Widen the capture gate to every Responses route that sends include: ["reasoning.encrypted_content"] (Codex plus Custom tables; DeepSeek and Concentrate stay excluded with their own contracts). The replay gate already matches the captured provider tag, so Custom states replay unchanged; both directions are pinned for the Custom route. Signed-off-by: asto <asto18089@126.com> * fix(route): scope the custom wire override to the resolved identity Review round 1 of #79 (B1, S1): the wire override was read from the ambient `Config` while `resolve_runtime_route_for_identity*` resolves the endpoint from the identity-scoped clone, so on every pinned turn path (per-thread routing, `reload_config`, fleet pins, session restore) the override could name a different `[providers.<name>]` table than the one supplying the endpoint: a pinned responses table rode Chat, and an ambient responses table wired Chat-only relays for `/responses` (a regression against the static-policy base for multi-table setups). The override now comes from the scoped clone and is computed only for `ProviderKind::Custom`. The per-config `wire` dialect parse moves into the config crate next to the field it reads (`wire_dialect_override` plus the alias predicates), so the tui wire-format/capability readers share one alias list with the resolver instead of byte-identical private copies. The app-server `/v1/chat/completions` pass-through threads the same override from the `provider_cfg` that supplies its endpoint, so a `wire = "responses"` table now fails closed at the handler's ChatCompletions-only guard instead of silently receiving a chat body. Also pins the explicit `chat` dialect arm and the `messages` endpoint key in tests, and drops a copy-pasted `#[allow(clippy::too_many_arguments)]` on the one-argument helper. Signed-off-by: asto <asto18089@126.com> * fix(responses): endpoint-scope reasoning replay; capture zen responses Review round 1 of #79 (S2, S3): every named Custom table shares the `custom` provider slug, so Custom-tagged opaque reasoning state replayed onto any table resolving the same model id — table A's encrypted reasoning rode table B's wire after a provider switch. The replay gate now compares an endpoint-scoped tag (`custom/<table>` from the client's frozen provider identity); no released build mints the old bare tag, so nothing stops replaying. Capture also widens to the OpenCode Zen Responses roster, which sends `include: ["reasoning.encrypted_content"]` and `store: false` but never captured — the same unpaired-function_call replay loss this PR fixed for Custom tables. Include and capture now cover the same route set by construction, and the turn-path pin expressions collapse into `pinned_wire_override` / `reasoning_provider_tag` helpers. Signed-off-by: asto <asto18089@126.com> * fix(route): bind the custom wire gate once; pin the override's edges Round-1 review residue: the resolver's Custom wire channel was gated by two `provider_kind == Custom` checks forty lines apart - the endpoint-key remap and the protocol selection - so a future edit to one gate could silently desync the key from the protocol. The override is now bound once above both consumers. The new field is also spelled `RequestProtocol` per the route module's naming convention (same alias; request-side wire shapes are spelled that way there). Test pins: the override mints the wire even on the descriptor's loopback placeholder base URL; a wire-true custom candidate still carries default capabilities and UnknownOrStale pricing (no first-party fact resurrection); a Chat override cannot demote a Responses-descriptor builtin (OpenCode Zen); and Codex-minted opaque reasoning state never replays onto a Custom route. The protocol-mismatch tail of `rebound_for_model_protocol` now documents that the pinned override makes it unreachable for Custom clients. Signed-off-by: asto <asto18089@126.com> * fix(tui): pin wire-capture edges; readiness reads the table wire Round-1 residue pins for the custom wire channel, rebased onto the identity-scoped override work: - A Chat-wire Custom table must not mint opaque reasoning state even when a Responses-shaped stream reaches handle_responses_stream - the capture gate keys on the transport's wire, not the event shape. Deleting the wire half of the gate fails exactly this pin. - Missing or empty encrypted_content must not be captured (an empty blob would poison every later turn) while the stream keeps flowing and both reasoning blocks close. Deleting the empty-content filter fails exactly this pin. - A typo'd wire = "respones" dialect degrades to the legacy Chat default at both the override reader and the runtime candidate, so the silent-degrade contract of the shared dialect parser stays deliberate. - route_is_valid_for_model now validates the wire the per-turn route would mint: the dialect of the same table provider_config_for resolves, which also supplies the validated base URL. Outcome-identical today (Custom route validation is protocol-independent), but any future protocol-conditional validation would otherwise silently drift from the turn path. Signed-off-by: asto <asto18089@126.com> * fix(responses): bind reasoning replay to the capture endpoint The endpoint-scoped replay key compared the provider tag, api shape, and model - but the tag only pins the named Custom table's NAME, not the URL behind it. A table whose base_url is edited between sessions (proxy to vendor-direct, relay swap) replayed the old endpoint's encrypted blobs to the new one, producing sticky 400s whose cause was invisible. OpaqueReasoningState gains a serde-default endpoint fingerprint (the catalog's base_url_fingerprint of the client's frozen base URL). Capture mints it; the replay gate requires it to match, while fingerprint-less states from fixed-endpoint providers and pre-existing sessions keep replaying. The custom capture/replay/turn tests pin capture binding, mismatch drop, and legacy replay. Signed-off-by: asto <asto18089@126.com> * fix(responses): derive the capture gate from the include predicate The capture gate enumerated its providers positively (Codex, Zen, Custom Responses) while the body builder's include gate was a negative list (not DeepSeek, not Concentrate). They coincided only through the current provider roster: a future Responses-wire builtin would silently start sending include: ["reasoning.encrypted_content"] without capturing, re-creating the unpaired function_call replay loss on multi-turn tool continuations. Both gates now derive from one shared predicate (responses_route_sends_encrypted_reasoning_include), so the lockstep the PR body claims holds by construction. The transport-wire half stays in the capture gate; the current route set is unchanged. Signed-off-by: asto <asto18089@126.com> * fix(config): warn on unrecognized wire dialects; pin the parse A typo'd wire value ("respones") degraded silently to the default Chat Completions policy on every surface, so a user who typo'd the one string that switches their endpoint's protocol saw an opaque wrong-wire failure at request time with no diagnostic anywhere. wire_dialect_override now logs a warning for non-empty unrecognized values; recognized explicit chat spellings (chat, openai, ...) stay silent, the parse stays total, and the degrade contract is unchanged. Also pins the canonical alias sets, the normalization (case, whitespace, underscore/hyphen), and the degrade behavior with direct unit tests in the crate that owns them — coverage was previously indirect via the tui. Signed-off-by: asto <asto18089@126.com> * fix(tui): thread the table wire into candidate display receipts The /provider picker resolved rows through the config-free candidate wrapper, which pins the static Chat policy — so after the runtime fix a wire = "responses" row bound Responses per turn while the picker still displayed Chat Completions as its supported protocol. The wrapper now takes the wire override explicitly; the picker passes the dialect of the same row-scoped table that supplied its base URL, and the session-state and model-switch receipts thread the ambient table's dialect the same way. Callers that never consume candidate.protocol() (unpinned child admission, the /model receipt) pass None with that reason stated. Also corrects three inaccurate comments left by earlier rounds: the two-pass resolver note (the wire override rides both passes), the readiness claim (it reads the ambient selection, the same table as its base URL — identity-pinned tables are the route layer's job), and the app-server claim (this ingress reads the literal [providers.custom] field, not the named-table map), plus the ProviderConfigToml::wire field doc, which still described only the built-in dual-wire vendors. Signed-off-by: asto <asto18089@126.com> * test: pin the wire plumbs the audit found untested Three seams could regress silently because nothing bound them: - config: the runtime receipt's wire_override (deleting it changed no receipt a test asserted) - the new test resolves responses/anthropic/ chat tables through resolve_runtime_options and pins protocol plus endpoint key. - tui: route_is_valid_for_model's dialect threading now has a resolution pin for a wire table. The bool interface cannot observe protocol directly (validation is protocol-independent); the resolver and receipt pins above carry the protocol assertions. - app-server: the ChatCompletions-only guard is now posted through the real router and asserted as a 400 provider_wire_format_unsupported. This replaces non_chat_completions_provider_rejected, which built a struct and asserted a field against itself without invoking the handler. Signed-off-by: asto <asto18089@126.com> * test: pin the capture-to-replay seam and the custom Anthropic transport Two coverage gaps the audit found: - Capture was tested at the client and replay at the pure builder, so an asymmetric edit to the tag or endpoint fingerprint derivation would only be caught by luck. The new seam test captures off a real stream on the per-turn client, places the state into history the way the turn loop commits it, and asserts turn 2's prepared request body leads with the paired reasoning item. - wire = "anthropic" custom tables were pinned only at the candidate level, one layer short of the wire. The new transport test drives resolve_runtime_route -> from_candidate against a loopback mock and asserts the POST path, x-api-key/anthropic-version headers, and the verbatim model. Also builds the custom responses turn-path client from the resolved route's identity-scoped config instead of the ambient config, mirroring the production install path - in a two-table setup the ambient table would freeze the wrong identity onto the pinned endpoint. Signed-off-by: asto <asto18089@126.com> * style: cargo fmt Signed-off-by: asto <asto18089@126.com> * fix(responses): fail closed on fingerprint-less Custom reasoning replay Review on this PR flagged the legacy-state arm of the endpoint gate: state.endpoint == None matched unconditionally, so a Custom-table state saved before the endpoint field existed kept replaying after the table's base_url was edited — exactly the cross-endpoint leak the fingerprint was added to close, and one that bricks the old session against the new endpoint (the undecryptable blob stays in history and keeps matching). Custom tags (the legacy root 'custom' and named 'custom/<table>') are the only tags whose endpoint can move under a stable tag, so they now fail closed: no proof of origin, no replay. Built-in providers have fixed catalog URLs, so their legacy states provably came from the only endpoint the tag ever had and keep replaying. The upgrade cost on Custom is one turn of reasoning continuity; fresh captures carry the fingerprint and replay resumes. Pins: the legacy Custom state assertion is flipped to require a reasoning-free request body, the legacy-root 'custom' tag gets the same pin, and a fixed-endpoint (Codex) legacy state pins the carve-out side. Signed-off-by: asto <asto18089@126.com> * fix(responses): gate fingerprint-less reasoning replay on the official endpoint Built-in base URLs are not fixed: config, env (OPENAI_CODEX_BASE_URL / CODEX_BASE_URL), and CLI flags can re-point any built-in provider while its capture tag stays put. The legacy arm of the replay gate therefore treated fingerprint-less built-in states as provably local when they are nothing of the sort — the exact cross-endpoint replay the endpoint fingerprint was added to close, flagged in review. The gate's `state.endpoint == None` arm now additionally requires provider_base_url_is_official(kind, current_base_url): legacy states keep replaying on the official route (pre-upgrade sessions keep their continuity) and fail closed the moment the client points elsewhere. The client threads its frozen base URL into the body builder; the comments that carried the false fixed-URL premise are rewritten, and the migration-cost claim is corrected (a fail-closed state stays in history and is dropped every turn until compaction, not one turn). Tests: a re-pointed built-in legacy state now fails closed (the reviewer-found hole), the official-route replay pin keeps passing, and the named-table fixtures these files hand-rolled collapse into one test_support::custom_named_table_config helper. Signed-off-by: asto <asto18089@126.com> * fix(tui): gate the prompt suggestion on the resolved route's protocol route_is_supported_suggestion_provider is a static pre-filter that cannot see a named table's wire dialect, so a wire = "responses" / "anthropic" Custom table passed it. Before the runtime-route fix the turn itself also rode Chat, so the opt-in ghost-text suggestion and the turn agreed; with wire-true turns it became the one remaining path still POSTing a hard-coded Chat body to {base}/chat/completions on a table whose resolved route answers on another protocol. resolve_credentials_for_identity now fails closed unless the resolved route candidate speaks Chat Completions — the same identity-scoped resolution the turn path uses, so the suggestion can never disagree with the wire the turn ran on. Pinned both ways: a wire=responses table resolves nothing, the same table on its legacy Chat wire still does. Signed-off-by: asto <asto18089@126.com> * test: route the last named-table fixture through the helper; pin the anthropic dialect on the pass-through guard The readiness wire test now builds its table through test_support::custom_named_table_config like every other named-table test, and the app-server fail-closed pin gains the wire = "anthropic" arm next to the responses one: the Chat-Completions-only guard must reject both dialects through the real router. Signed-off-by: asto <asto18089@126.com> * fix(config): share the anthropic alias list; scope the dialect warning to custom tables wire_prefers_anthropic (built-in dual-wire base-URL resolution) kept a byte-identical copy of the anthropic alias list that provider::wire_dialect_prefers_anthropic now owns; it delegates to the canonical parse so one alias list genuinely cannot drift. The unrecognized-dialect warning and the wire field doc are scoped to what actually happens: every consumer gates the override on ProviderKind::Custom, so the warning can only fire for a custom table, while a built-in vendor's dialect space degrades silently. Signed-off-by: asto <asto18089@126.com> * fix(config): fingerprint authority-glued query URLs by their host A base_url like https://gw.example?tenant=1 glues the query onto the authority, and secret_free_fingerprint_input mapped every such URL to one shared redacted constant — two distinct hosts shared a single fingerprint, i.e. one replay scope for captured opaque reasoning state across genuinely different endpoints. Fingerprint the credential-free host instead; userinfo and the query/fragment still never enter the digest. The truly unreachable forms (scheme-less, non-http(s), empty authority) keep the sentinel, so persisted keys for them stay stable. Pinned: distinct hosts differ; the same host under different glued queries or fragments agrees with the ordinary spelling of the endpoint; userinfo cannot influence the digest. Signed-off-by: asto <asto18089@126.com> * test(tui): pin the wire-bound client's internal rebinds rebound_for_model_protocol and bind_request_to_protocol feed pinned_wire_override back into the resolver so an engine-internal model switch cannot downgrade an endpoint-scoped Custom client; the guarantee was documented but untested. Deleting either plumb now fails exactly this pin: the rebind rebuilds the client as Chat, the per-request route bails on the protocol mismatch. Signed-off-by: asto <asto18089@126.com> * fix(config): name the table in the unrecognized wire-dialect warning The warning carried only the raw dialect, so with several named tables a typo'd wire value was not locatable, and nothing pinned that the diagnostic fires at all. wire_dialect_override now takes the table label from every consumer (runtime receipt, tui route layer, preflight, app-server pass-through) and includes it in the log line; a hand-rolled capture subscriber pins the emission contract: exactly the unrecognized non-empty dialect warns. Also scope the config-free resolver wrapper's comment to its real contract — the fleet hermetic fallback does read candidate.protocol(), as a display label on an arm that excludes Custom. Signed-off-by: asto <asto18089@126.com> * test(tui): pin the /provider row's wire-table protocol Round 3 of #79 shipped exactly this class of bug: the picker row resolved through the config-free wrapper and showed chat for a wire = "responses" table. The display site had no regression pin of its own. Signed-off-by: asto <asto18089@126.com> * docs(core): note the downgrade direction of the endpoint fingerprint A state carrying endpoint read by a pre-fingerprint build drops the unknown field and restores that build's gate for the session; the provider tag alone still blocks Custom states the old build cannot produce. Signed-off-by: asto <asto18089@126.com> * test(config): share the tracing capture fixture across config tests The wire-dialect warning pin carried its own inline CapturedEvents subscriber. Lift it into a cfg(test) module so the forced-custom label pin can capture events too, instead of growing a second copy. Signed-off-by: asto <asto18089@126.com> * fix(config): name the read table in the forced-custom dialect warning resolve_runtime_options read the dialect from the literal legacy [providers.custom] table whenever the provider came from CLI/env, but labeled the warning with named_custom_provider_id() — so a config selecting relay_a plus --provider custom warned about relay_a's table while degrading [providers.custom]'s typo. Scope the label by the same ProviderSource::Config condition that picks the named table. Pinned by cli_forced_custom_receipt_warns_against_the_table_it_reads; deleting the condition fails exactly that pin (mutation-checked). Signed-off-by: asto <asto18089@126.com> * fix(tui): scope the launch wire override to the launch identity The launch receipt read the wire override from the ambient config while the base URL came from effective_auth_config, the clone scoped to the resolved launch identity. Those diverge when settings.default_provider re-points the launch at a different custom table than config.provider selects: the receipt minted the ambient table's protocol for the launch identity's endpoint. Receipt consumers are protocol-independent today, so this was latent; the sibling call sites (apply, session_state) verified identity-aligned and are unchanged. Signed-off-by: asto <asto18089@126.com> * fix(responses): gate reasoning replay on the include predicate Replay rode state fields alone and was safe only because the body builder is invoked from the Responses arm. Derive the same include-sending predicate the capture side uses and require it in the replay gate, so a route that omits include (DeepSeek's plain reasoning_text, Concentrate's documented-fields contract) can never receive an encrypted replay item even if a matching state exists — include and replay cannot drift, mirroring the round-3 include/capture lockstep. Pinned by forkguard_replay_never_rides_a_route_that_omits_the_include; deleting the predicate half fails exactly that pin. Also proves the chat capture test's stream flows (one reasoning block closes) so its empty capture is the wire gate's doing, and adds a capture-to-replay seam test across a real base_url edit: the state captured off a live stream at endpoint X is dropped by a client rebuilt through the production resolve → from_candidate path for the re-pointed table. Signed-off-by: asto <asto18089@126.com> * fix(tui): log the suggestion gate's protocol skip The round-5 gate returned None silently, so an opt-in suggestion quietly produced nothing on a responses/anthropic custom table. Name the provider, identity, and protocol in a debug event — the feature's established fail-closed style keeps it out of the user's face while making the skip diagnosable. Also scopes capture_route_authority's doc to its real contract: the static gate cannot see a named table's wire dialect, so an authority may be captured for a table whose resolved route ends up on another protocol and is then never dispatched. Signed-off-by: asto <asto18089@126.com> * test(tui): make the readiness wire threading observable custom_wire_tables_validate_through_the_route_resolver stayed green with the wire_override plumb deleted, because Custom validation is protocol-independent — it pinned the outcome, not the threading. Split readiness_route_request out of the bool probe and pin the request itself: the override must carry the table's dialect and mint the same protocol the per-turn authority binds for the same config. Deleting the threading fails exactly this pin. Signed-off-by: asto <asto18089@126.com> * test(tui): lock the route runtime wire tests against live env Every other new test in this stack takes lock_test_env; the custom_wire_override_tests module silently depended on no env read ever being added to the route path. Align it with the convention. Signed-off-by: asto <asto18089@126.com> * docs: reference the custom table wire key; correct fingerprint claims wire is now route-decisive (turn binding, receipts, preflight, the /provider row, app-server rejection) and had no user-facing documentation: CONFIGURATION.md's provider-table key list gains the key with its dialect spaces, degrade-with-warning contract, and the built-in vendors' narrower silent space; PROVIDERS.md's custom-tables section points at it. Also corrects three doc claims the review found inaccurate: the fingerprint sentinel set (scheme-less inputs take the fall-through branch and hash their credential-free authority and path, they do not map to the constant), the OpaqueReasoningState downgrade corner (a bare-custom-tagged state minted by this build replays on a pre-fingerprint build without an endpoint check), and from_candidate's config contract (it must be the candidate's own identity-scoped config, or captured state is tagged with the wrong table). Signed-off-by: asto <asto18089@126.com> * style: drop the stray blank lines before wire_override literals The round-2 fixture insertions left an inconsistent blank line between limit_overrides and wire_override in most test literals but not all of them. Normalize; no behavior change. Signed-off-by: asto <asto18089@126.com> * docs(config): scope the fingerprint constructibility claim to the constant The base_url_fingerprint doc claimed none of its special forms ever constructs a client, so no captured state could carry their digest — but the glued-query branch exists precisely because that form does construct one and can carry captured opaque state. State the asymmetry instead of the false universal. Signed-off-by: asto <asto18089@126.com> * test(tui): finish the named-table fixture consolidation Route the route_runtime local builder and the hand-rolled literal in the capture-to-replay seam pin through test_support::custom_named_table_config, and quote the replay gate's real predicate in the capture test's doc: the gate compares the client's provider tag, not ApiProvider::Custom.as_str(). Signed-off-by: asto <asto18089@126.com> * test(config): keep the dialect-warning pin immune to ambient env warns The receipt test asserted an exact total event count, so a shell exporting junk CODEWHALE_* values — which EnvRuntimeOverrides::load legitimately warns about — would fail it despite env_lock. Filter to the dialect event. Signed-off-by: asto <asto18089@126.com> * docs(providers): state the anthropic-wire auth contract for custom tables A wire = "anthropic" custom table authenticates with x-api-key and drops a conflicting Authorization header; auth_mode = "disabled" suppresses credential-shaped headers too, so a Bearer-only Messages relay is not servable through this dialect yet. Say so instead of leaving the gap to be discovered at request time. Signed-off-by: asto <asto18089@126.com> --------- Signed-off-by: asto <asto18089@126.com>
…#88) * fix(compaction): restore without a checkpoint must not delete history `restore_compaction_checkpoint` ran its carrier-deletion scan unconditionally. On a history with no provenance-stamped carrier the scan falls back to the loose single-block matcher, which reads any user turn that begins with the summary header as a carrier — and on a never-compacted session (`checkpoint == None`, the load path at `core/engine.rs`) that meant such a turn was silently deleted from the persisted history at every restore, with nothing reinserted in its place. A summary pasted into the session by the user is indistinguishable from a pre-provenance carrier by content alone, so the only safe reading is: with no checkpoint to insert, restore deletes nothing. Gate the deletion on checkpoint presence. The stamped-carrier path (replace-and-insert) and legacy loose replacement both keep their behavior whenever a checkpoint is being restored; a checkpoint-less restore now only runs topology relocation, which moves stamped carriers and never deletes. Covered by two forkguard pins: a pasted summary turn survives a checkpoint-less restore, and so does the saved stamped carrier. Found while porting this area upstream: current upstream gates the same legacy replacement on a saved checkpoint, so this aligns the fork with the safer reading instead of diverging. Validation: cargo test -p codewhale-tui --lib restore_ (56 passed, incl. the two new pins); cargo fmt --all -- --check; clippy delta clean vs base. Signed-off-by: asto <asto18089@126.com> * fix(compaction): calibrate restore comments; pin None-arm relocation Review round on this PR confirmed the fix and its pins, and surfaced two wording defects in the comments the fix added. The early-return comment called the relocated messages "stamped carriers", but restore-time topology relocation moves Agent-topology sidecars; the compaction stamped carrier is only their anchor reference. The same comment and the doc glossed checkpoint == None as "a never-compacted session", while the fork's design - the stable system prefix never carries the summary - also lands a compacted session resumed from its session file on that path, with the genuine saved carrier at stake. Say both precisely. Also pin the None arm's relocation call: no existing test reached relocate_restored_compaction_topology through a checkpoint-less restore, so dropping the call survived the full suite. The new pin fails with the call dropped (sidecar left trailing the tool result) and passes with it (sidecar at its anchor, directly after the prompt). Validation: cargo test -p codewhale-tui --lib restore_ (57 passed, incl. the new forkguard_restore_without_checkpoint_still_relocates_topology_sidecars); cargo fmt --all -- --check; clippy delta clean for compaction.rs. Signed-off-by: asto <asto18089@126.com> --------- Signed-off-by: asto <asto18089@126.com>
* fix(session): confine archive artifact reads to the session store `session_artifacts_dir` validated the session id but then trusted the joined `<sessions>/<id>/artifacts` path. `Path::is_dir` follows links, so a symlinked session directory or artifacts root redirected the whole archive walk outside the session store — the member-level symlink skip cannot see a redirected containing directory, which made the module's "an export cannot reach outside through a link" claim true for members only. Reject the escape loudly: a symlink at either level is an `InvalidInput` error naming the store, the canonicalized artifacts root must stay inside the canonicalized store as a backstop for link chains, and a genuine absence still returns `None` (transcript-only export). `session_artifacts_dir` returns `io::Result<Option<PathBuf>>` now; the one production caller (`sessions export`) propagates the error. A forkguard pin drives both link levels through the real resolver and asserts the confinement error. Hardening parity: current upstream confines the same reads to the trusted session store, so this ports the safer reading instead of diverging. Validation: cargo test -p codewhale-tui --lib session_archive (6 passed, incl. the new pin); cargo fmt --all -- --check; clippy delta clean vs base. Signed-off-by: asto <asto18089@126.com> * fix(session): harden export claims, hard links, and outputs Review fixes on #89: the module doc claimed an export "cannot read outside the session directory through a link at any level", but a hard link planted inside artifacts/ aliases outside content in with no symlink for the root probe to see - the walker now rejects multi-link members like upstream (unix nlink / Windows number_of_links). The writer takes the trusted sessions root and resolves artifacts internally, so confinement is not opt-in per caller, and it rejects outputs inside the store the way upstream does: records live at <store>/<id>.json and a forced in-store output clobbered a live session. The resolver hands back the canonical path it validated, invalid ids are a loud InvalidInput like upstream instead of a silent transcript-only export, probe errors no longer claim a resolution that did not happen (and name the detected link), and canonicalize failures carry context. The export walkthrough states exactly what is enforced plus the race residual instead of an absolute guarantee. Validation: the new forkguard pins (hard-link escape, in-store output, canonical return, traversal rejection) fail on the prior head and pass here; cargo test -p codewhale-tui --lib session_archive 8 passed; cargo fmt --all -- --check; clippy delta clean on touched files. Signed-off-by: asto <asto18089@126.com> * fix(session): loud probe errors, side-effect-free export rejects Review fixes on #89 (JensenChen28 round + leftover re-review minors): the link probes folded every symlink_metadata error into "no link" and Path::is_dir folded the artifacts stat into false, so an unsearchable session directory (EACCES on the artifacts probe) resolved to Ok(None) and a full-fidelity export silently degraded to transcript-only. Only genuine absence maps to Ok(None) now: NotFound is absence, every other probe or stat error propagates with the path it could not inspect (reproduced red on the prior head with a 0o600 session dir). A rejected in-store output no longer mutates the store either: the containment check runs against the nearest existing ancestor before create_dir_all, so a mistyped nested --output leaves no fresh directories next to the session records; the post-create canonicalize re-check stays and covers symlinked components created in between. The residual disclosure covers the output side now too (a swapped component of the output's parent between the checks and the rename can still land the archive in the store), the writer doc separates None from clearing include_artifacts (the latter keeps store checks and skips id re-validation), the probe error no longer asserts "artifact root" when the detected link is the session directory, and cross-session redirection is pinned (an artifacts root linked at another session's artifacts is rejected like any other link). Validation: cargo test -p codewhale-tui --lib session_archive 9 passed; the new pins kill their mutants (restored probe-error swallow, create-dirs-first rejection, outside-only link rejection); RUST_MIN_STACK=16777216 cargo test -p codewhale-tui --lib: 11925 passed, 5 failed all in the known local-environment families (remote_control/runtime_threads flock flakes pass in isolation; the no_new_privs child-process pair fails identically on the pristine head); cargo fmt -p codewhale-tui -- --check and clippy -D warnings clean. Signed-off-by: asto <asto18089@126.com> --------- Signed-off-by: asto <asto18089@126.com>
* fix: land the timeout-audit review fixes for the merged #63 #63 (d349f25) merged mid-review, so its final review wave never reached `pinvou3-clean`. This PR carries that wave plus the fixes from the subsequent review rounds onto the current base, as one commit; per-area detail lives in the PR description. - exec: headless runs resolve `request_user_input` via `cancel_user_input` instead of parking forever once the engine wait became unbounded; the cancellation contract is pinned. - snapshot: every pre-git window on a wedged mount is bounded (workspace canonicalize, the safety classifier's re-canonicalization, the first-init size walk, honoring `max_workspace_gb = 0`); a killed `git init` heals instead of wedging (HEAD-less, half-written, leftover config.lock/HEAD.lock); wedge failures surface as errors with stderr evidence and stage-keyed remedy hints; drain captures cap at 16 MiB per stream. - runtime: every approval exit publishes `approval.decided` and is race-free by construction (delivery races, monitor-death settlement, channel-closed exit); compat streams forward the approval posture. - tasks: liveness heartbeats short-circuit before the manager-wide state lock. The human-wait clock pause lives in #78 instead: as first landed it changed task-lifecycle behavior and regressed the TUI, so it was taken back out. - vision: the envelope timeout classifies as `ToolError::Timeout`, and the inference permit is acquired inside the envelope. - client: envelope-exceeded requests mark connection health and run the recovery probe on every dialect arm, including the Anthropic transport arm; the non-streaming budget got an injectable test seam. - app-server: a dropped stream resumes from the last consumed seq (bounded reconnects) before falling back to a best-effort interrupt, on every turn surface; the `/health` boot probe bounds each attempt. - tools: drain aborts are one helper covering every exit including cancel/drop; timeouts SIGKILL the child's process group so forked commands no longer outlive the call. - rlm: the sub-query budget is floored at 1s and the session default references `CHILD_TIMEOUT_SECS`. - mcp: the send-timeout error states the budget instead of tokio's `Elapsed` display. - docs: the 1800s timeout family anchors one complete roster; MCP/SUBAGENTS disclose the per-leg budget and wall-clock interactions; the RUNTIME_API.md restore contract is corrected. - tests: every fix above has a regression pin (mutation-verified red-green), timing margins are widened, and the Anthropic endpoint tests hold the env lock. Deliberately not fixed here (pre-existing on base unless noted, follow-up scale): Windows job-object group-kill parity; the contested inference-permit shape in web_search/voice/ prompt_suggestion; the pandoc.rs old-shape timeout; the two bounded runners' parallel drain implementations; the engine-side MCP stdio proxy's unbounded send leg; debounce starvation while heartbeats stream; the `CODEWHALE_MAX_OUTPUT_TOKENS` test-isolation race. Verified: `cargo fmt --check`; clippy `-D warnings` on codewhale-tui, codewhale-app-server, codewhale-core and codewhale-mcp; `cargo test -p codewhale-tui --lib` 11969/0 and `-p codewhale-app-server --lib` 103/0 on the final tree; the load-bearing pins were each mutation-checked to fail red. No-Issue: review-fix replay for the merged #63; no separate tracking issue exists. The full findings ledger is the round-6 review comment on #63. Signed-off-by: asto <asto18089@126.com> * fix(snapshot): refuse truncated git captures in the restore and history parsers The 16 MiB drain cap appended a truncation note that no parser consumed: an over-cap `ls-tree -r` listing would silently shrink the restore diff's target set, making restore delete files that exist in the snapshot (the pre-restore safety snapshot makes the loss recoverable, but the resulting tree is still wrong), and a truncated `git log` could end in a half-parsed pseudo entry. Both parsers now refuse a capture that ends with the cap note, and the note itself is pinned by a real-child drain test. Signed-off-by: asto <asto18089@126.com> * fix(tools): treat the drain size cap as truncation evidence on the plugin surface `drain_truncated` only matched the drain-grace note and only on stderr, so a plugin whose stdout hit the 16 MiB capture cap came back as `Ok(success)` carrying cut-off JSON plus a tail note no detector read. The detector now matches both truncation notes on both streams, the plugin escalation message names both causes, and a combination test pins the detector. Signed-off-by: asto <asto18089@126.com> * fix(app-server): write the consumed seq back when a turn ends in stream failure The cursor was only persisted on the `Ok` path, so a thread whose turn died past the resume budget replayed the failed turn's events from the store on every retry (turn_id filtering kept it correct; the cost grew with the history). The error path now persists the last consumed seq, pinned by extending the exhausted-resume test. Signed-off-by: asto <asto18089@126.com> * fix(tasks): lead the init-failure remedy with the stale-lock sweep The GIT_INIT_FAILED hint told the user to remove the side-repo directory first, but that state does clear itself: the open-path sweep clears a lock once it has been stale for an hour (this PR's own mechanism, pinned by its own tests), and a second host initializing the same workspace concurrently lands here transiently. Following the old hint discarded the workspace's whole undo history for a state that heals within the hour; the hint now leads with waiting and names removal only as the persistent-failure fallback. The hint test's negative assertion pinned the wrong premise and now pins the new one. Signed-off-by: asto <asto18089@126.com> * fix(runtime): log a dropped forced-approval publish instead of swallowing it The interrupted/channel-closed exit published `approval.decided` with `.ok()`, so a failed publish silently stranded the client's pending UI; it now logs at error severity like the settle path does. Signed-off-by: asto <asto18089@126.com> * fix(tasks): skip liveness heartbeats in the trailing drain loop The heartbeat short-circuit covered the main event loop, but the post-execution drain called `apply_execution_event` directly, taking the manager-wide state lock for a no-op against the invariant the short-circuit states. Signed-off-by: asto <asto18089@126.com> * refactor(client): keep the injectable envelope seam private `non_streaming_request_envelope` was widened to `pub(crate)` for a caller in the `client::anthropic` child module, which can already see the parent's private items. Signed-off-by: asto <asto18089@126.com> * docs(mcp): disclose the engine-side stdio proxy's unbounded send leg in code The debt was only stated in the PR description; `Connection::send` now carries the disclosure where a reader of the code will meet it. Signed-off-by: asto <asto18089@126.com> * docs: qualify the budget and approval-contract claims, sync zh_hans - MCP: the per-leg budget shape holds for stdio servers only; HTTP servers are bounded by a single total timeout. Scope the sentence and state the HTTP bound (en + zh). - RUNTIME_API: scope "every resolution is published as `approval.decided`" to approvals whose `approval.required` event was published and disclose the event-failure exit; the compat stream maps an absent `posture` to `null` while the raw stream leaves it absent. - zh_hans/SUBAGENTS: the example `default_max_steps = 120` and the missing omitted/zero sentences were stale against the en doc and the code (defaults are zero/unbounded); sync them. Signed-off-by: asto <asto18089@126.com> * fix(tools): anchor drain truncation evidence to the capture tail The drain detector matched its notes anywhere in the buffer, while both writers append their note as the buffer's last bytes and stop retaining past it. Anchor the match to the tail — the same contract the snapshot capture's capture_hit_the_cap pins — so a stream that merely quotes a note mid-body is not truncation evidence, and pin the stricter semantics with mid-buffer negative cases. Also close the integration gap the round-7 review flagged: a real child flooding 17 MiB of unparseable stdout through the plugin surface must be refused as truncated, not reported as a silent success. Signed-off-by: asto18089 <asto18089@126.com> * fix(runtime): log dropped approval resolutions instead of swallowing them Four approval.decided publishes still ended in .ok(), so a storage failure silently dropped the event that tells clients to clear their pending approval UI — the same failure mode the forced-resolution arm already logs loudly. Log all four (auto-approve, auto-review deny, user allow, user deny) with the approval id; no control-flow change. Signed-off-by: asto18089 <asto18089@126.com> * docs(runtime): align the settle deny comment with live-receiver settlement The per-entry comment still claimed the send cannot deliver because the receiver died with the monitor, contradicting the block comment above it: entries whose receiver is still live are settled too, and the deny is then pushed through the decision channel (the first settle fixture pins exactly that). Signed-off-by: asto18089 <asto18089@126.com> * fix(tasks): name the cost of removing a half-initialized snapshot repo The init-failure remedy already led with the self-healing sweep, but the user-facing hint never said what the removal fallback costs — that warning lived only in a code comment. State it inline, and pin the ordering with a test assertion so a regression that puts removal before the sweep cannot come back green. Signed-off-by: asto18089 <asto18089@126.com> * docs(tasks): disclose the debounce flush starvation shape in code The biased select polls the event arm before the flush arm and the debounce sleep restarts every iteration, so a stream denser than the debounce interval defers persistence until the stream pauses or the loop's trailing flush lands it. Heartbeats never arm dirty, so the silent-tool tick stream cannot starve it; a dense run of unpersisted deltas can. Disclose it where the delay lives instead of leaving the debt only in the commit message. Signed-off-by: asto18089 <asto18089@126.com> * docs(subagents): correct the omitted-vs-zero max_steps fallback "Omitted or zero max_steps remains unbounded even when an operator default is configured" is half false: only an explicit zero ignores the operator default (resolve_max_steps keeps 0 as unbounded), while an omitted budget falls back to it — pinned by resolve_max_steps(Scout, None, Some(90)) == 90 and contradicted by the priority list two paragraphs up. State the real rule in English and sync the Chinese doc that round-7 translated from the wrong sentence. Signed-off-by: asto18089 <asto18089@126.com> * docs(runtime): state the required-emit failure outcome without pinning one path "The tool call is denied" only literally describes the registration branch; when the auto-approve branch's required emit fails, the tool call is stopped by the monitor's cancel instead. The externally observable contract is the same either way — the tool never executes — so state that. Signed-off-by: asto18089 <asto18089@126.com> * docs(mcp): disclose the unbounded stdio send leg in the budget contract The budget paragraph read as if both stdio legs were bounded, but the send leg has no deadline — a server that stops draining stdin can park the write past every budget (disclosed at the write site itself in round-7). Mirror that caveat in the English and Chinese docs so the contract matches the code. Signed-off-by: asto18089 <asto18089@126.com> * fix(tasks): keep a timed-out gate's exit code absent The gate moved onto the bounded runner and mapped a timed-out child to the conventional 128+SIGKILL so the record schema always carried an integer. That integer reaches hook `exit_code` conditions through tool metadata, and `reported_tool_exit_code` exists precisely so an `exit_code` condition never matches a fabricated value (128+SIGKILL doubles as the OOM-kill code, so OOM hooks fired on every gate timeout). Base recorded no exit code for a timed-out gate; `timed_out: true` in the same metadata stays the timeout signal. Pin the absence with a real timed-out gate, and fix the bool-assert comparison in the neighbouring spawn-evidence test while there. Signed-off-by: asto18089 <asto18089@126.com> * fix(snapshot): sweep stale ref-update locks on open The recovery work covered config.lock/HEAD.lock at init and index.lock at open, but the commit path's `update-ref HEAD` (and prune's, and pack-refs) takes refs/heads/<branch>.lock or packed-refs.lock, and a git killed mid-update leaves those behind on exactly the wedged mounts this recovery targets. Nothing ages them out, every later snapshot fails at the ref update, and the failure is ErrorKind::Other with no marker, so the once-per-workspace notice gate swallows it: silent undo-history loss. Sweep both ref-lock shapes on the same staleness rule as index.lock when the side repo opens; fresh locks stay untouched. Signed-off-by: asto18089 <asto18089@126.com> * fix(client): keep isolated anthropic requests off shared health The new transport-failure marking in the Anthropic arm fires on every request, including the isolated Auto-classifier one. That clone shares connection_health through the Arc, so a read-only inspection wrote the shared health and could fire a real /models recovery probe — the same contract the generic send paths enforce by dispatching isolated requests to send_with_isolated_retry. Gate the transport arm on isolated_request_state and pin the isolation: two stalls from an isolated client must leave the shared health untouched and fire no probe. Signed-off-by: asto18089 <asto18089@126.com> * fix(runtime): guard the approval rollback and settlement log Two approval-settlement follow-ups: The approval.required-emit-failure rollback removed its registration with a blanket map remove. Providers reuse tool-call ids across threads, so under a reused id the entry may already belong to another thread's live waiter and the rollback would strand it — the exact case the cancel_closed_pending_approval doc forbids. Remove only a same-thread match (cancel_thread_pending_approval), which retires the now-unused blanket helper, and pin both sides of the guard. The monitor-death settlement logged a dropped approval.decided publish without naming the approval — the one path where several approvals can be stranded at once, and the only one of the six error logs missing the id. Add it. Signed-off-by: asto18089 <asto18089@126.com> * fix(tools): fall through the group-kill ESRCH arm to the child kill kill_the_run returned without any kill when kill(-pgid) failed with ESRCH, on the reasoning that an empty group means the child is dead. That holds only for a child that stayed in its group: a direct child can setpgid itself into another same-session group and stay alive, so the empty group left the un-timed child.wait() running past the budget with nothing enforced. Fall through to the child-only kill on ESRCH — a no-op on a genuinely dead child, the last chance to stop an escaped one. Signed-off-by: asto18089 <asto18089@126.com> * fix(tools): bound the echoed stdout in the plugin truncation error The truncation refusal embedded the whole captured stdout in the ToolError message, so a 16 MiB capped capture rode the error text toward the transcript. Echo the first bounded characters instead (codewhale_hooks::bounded_text also strips control bytes, so a capture of raw NULs echoes nothing); the diagnostic job is the note plus a preview. Signed-off-by: asto18089 <asto18089@126.com> * docs(snapshot): align the paths module doc with the bounded probe snapshot_dir_for now runs the workspace canonicalization under the bounded probe on every call, so the module doc's "cheap to call repeatedly" promise and the comment's premise that the turn pipeline already canonicalized the workspace no longer described this code. State the actual contract: every caller pays one bounded probe, hot paths should cache, and the raw-path fallback hashes identically for canonical input. Signed-off-by: asto18089 <asto18089@126.com> * docs(mcp): scope the unbounded send leg to the engine-side proxy The send-leg disclosure landed in the Server Fields paragraph, but that paragraph documents the TUI pool's per-server settings — and that pool's send leg is deadline-bounded (the request budget wraps the send at mcp.rs call_method), so the sentence was not true of the client the settings govern and contradicted the per-leg sentence above it. The unbounded blocking write belongs to the engine-side stdio proxy, which does not consume these knobs at all; drop the false sentence and give the proxy its own short paragraph with its fixed budgets, in English and Chinese. Signed-off-by: asto18089 <asto18089@126.com> * fix(snapshot): sweep a stale HEAD.lock on the open path update-ref HEAD takes the HEAD lock and the resolved branch's lock one after another, so a git killed mid-update can leave a lone HEAD.lock. A leftover one does not make the side repo unready, so the init-path sweep never sees it again on an initialized repo, and the open-path ref-lock sweep only covered packed-refs.lock and refs/heads/**.lock — every later snapshot then fails at the ref update with the loss surfacing nowhere, the exact wedge this sweep exists to age out. Add the HEAD lock to the open sweep and pin both the stale-removed and fresh-kept rule for it (mutation-checked red against the reverted sweep). Signed-off-by: asto <asto18089@126.com> * fix(client): gate the anthropic status arm off isolated requests The round-9 isolation gate covered only the transport-failure arm; check_anthropic_response still marked shared connection health (and could fire the recovery probe) for HTTP 4xx/5xx envelopes on the isolated Auto-classifier request, while the generic dialect's isolated dispatch path writes nothing. Gate the status arm's failure and success marks the same way, and pin it with a two-500 isolation test whose expect(0) probe would go red against the reverted gate. Signed-off-by: asto <asto18089@126.com> * fix(tasks): name ref locks in the generic git timeout hint The default arm of the stage-keyed remedy router promised a stale index.lock for every non-init git timeout, but an interrupted update-ref/pack-refs leaves a ref lock (HEAD.lock, refs/heads/**.lock, packed-refs.lock) instead — the sweep covers all of them, so the hint should not send a debugging user to one file name. Signed-off-by: asto <asto18089@126.com> * docs(tasks): correct the heartbeat starvation disclosure Heartbeats never arm dirty, but their ~200ms cadence still restarts the 250ms debounce, so dirty state armed just before a silent-tool phase stays unpersisted for that whole phase — the old note claimed the tick stream cannot starve the flush at all. Signed-off-by: asto <asto18089@126.com> * docs(tools): reword the group-kill error rationale The child leads its own process group and a group leader cannot leave it, so the setpgid-escape scenario the ESRCH comment described is not reachable; the fall-through stays correct as a defensive last resort on any kill error. Signed-off-by: asto <asto18089@126.com> * docs: tighten the posture and proxy timeout wording RUNTIME_API: posture rides execution-policy denies only (the full-access auto-approve also forces outcomes but carries auto: true, not a posture). MCP: the engine-side stdio proxy ignores the timeout settings, not the whole server config — command/args/env still apply (en + zh_hans). Signed-off-by: asto <asto18089@126.com> --------- Signed-off-by: asto <asto18089@126.com> Signed-off-by: asto18089 <asto18089@126.com>
…ly (#78) * Revert "revert(tasks): move the human-wait clock pause to its own PR" This reverts commit ce5a01f. Signed-off-by: asto <asto18089@126.com> * feat(tasks): pause the wall and idle clocks for human waits, answerably A durable task whose turn hit an approval or user-input prompt was still bounded by the 30-minute task wall_time, which interrupted the turn at expiry and denied the pending decision on the human's behalf. The window is now excluded from the wall clock (like the engine-side turn budget), the idle clock pauses and restarts fresh when the window closes, and the exclusion is bounded by a fail-safe cap. Three design holes close the gaps the first landing shipped with: - Answerability: the pause applies only where a host can actually deliver the decision. The runtime API's task threads are served by HTTP decide_approval/submit_user_input and pause; the TUI's private task runtime has no such channel, so a prompt there never arms the window and the wall clock releases the worker at its own deadline (before, it parked the worker for the 24h cap). - Aggregate bound: the cap now bounds the windows summed across the whole task, not only each open window — a task chaining short prompts can no longer sum its way past the cap once per prompt. - Park-poll backoff: while a window is parked the journal poll backs off from the 200ms catch-up cadence, doubling up to 2s; an arriving decision broadcasts on the subscription and wakes the loop early, so the backoff only removes the empty polls. Pinned by red-green tests: an unanswerable surface keeps the wall clock running (mutation check fails without the gate), chained short waits sum to the cap and terminalize between windows (fails without the hoisted aggregate check), and every wait-writer surface that arms the window still completes on a late answer. Signed-off-by: asto <asto18089@126.com> * fix(tasks): make the parked poll backoff real and the answerability opt-in explicit The park-poll backoff shipped inert: evaluate() clamped the loop wait to the 200ms catch-up cadence unconditionally, so the doubling and PARKED_POLL_CAP never took effect and the PR's own comments described a behavior that did not exist. The clamp now applies only on the non-parked path, and the parked cap wait excludes the already-closed windows so the aggregate budget, not the open window alone, bounds the sleep. The aggregate HumanWaitTimeout check moved into the pending chain after shutdown and cancel (it previously jumped the queue and mislabeled a canceled or shutting-down task as human_wait_timeout), and the window- closing event arm is now gated like the opening arm, so unanswerable surfaces no longer emit unpaired HumanWaitEnded signals. The answerability flag moved from a positional bool parameter to the named TaskManagerConfig::human_waits_answerable field, defaulting to false (the TUI's safe default); the runtime API opts in through server_task_config, which carries the contract in its name and doc. New deterministic pins cover the backoff cadence and its 2s ceiling, the aggregate-budget wake, the between-windows terminalization, cancel and shutdown outranking a spent cap, the idle restart on window close, the from_runtime default, and the server opt-in. Signed-off-by: asto <asto18089@126.com> * test(tasks): pin the human-wait guards at their claimed mechanisms A fresh from-scratch re-review confirmed the guard behavior but caught the chained-waits integration test claiming the wrong mechanism: with back-to-back windows the open-window check fires mid-window (window one's spend is already in the aggregate total), so deleting the between-windows aggregate arm leaves the test green. The test is renamed and recommented to say what it actually pins; the between-windows arm itself stays pinned by execution_guard_spent_aggregate_cap_terminalizes_between_windows, which does go red under that mutation. Also from the review: - the backoff pins assert literal durations (800ms, 2s) instead of restating the constants, so drifting EVENT_CATCHUP_POLL or PARKED_POLL_CAP off their documented 200ms / 2s values fails them; - the supervised wall-exclusion executor delay grows from 900ms to 2s, clearing the thin 500ms scheduling-starvation margin on loaded CI; - the shared evaluate() comment now names both wake paths (the turn loop's event subscription and the supervisor's forwarded task event); - the human-wait event and config docs state that a custom executor emitting the HumanWaitStarted/Ended pair self-declares its waits answerable, independent of human_waits_answerable. Signed-off-by: asto <asto18089@126.com> * test(tasks): pin the server answerability wiring end to end The fresh review's MAJOR: the server opt-in had no test below the config builder, so reverting run_http_server's call site or dropping the flag's forward into EngineTaskExecutor kept the whole suite green — the one mutation the PR's own red-green standard did not cover. run_http_server's task-runtime wiring moves into open_server_task_runtime, the single wiring point the new pin also drives. The pin reads the flag where it lands (the executor field the manager runs tasks on) through a cfg(test) as_engine_executor seam, and goes red if any of the three hops (builder, forward, call site) regresses to the unanswerable default. Verified by mutation: inlining the builder in open_server_task_runtime and hardcoding the forward to false each turn only this pin red (the builder pin stays green — it never saw those hops); dropping the builder's flag assignment turns both red. Signed-off-by: asto <asto18089@126.com> --------- Signed-off-by: asto <asto18089@126.com>
The Windows has_single_link arm called MetadataExt::number_of_links, which is still unstable (windows_by_handle) and fails the parent's Windows stable legs with E0658 - the fork gate never compiled this crate for Windows, so #89's confinement fix shipped with a Windows-only compile break. Read the count through the stable GetFileInformationByHandle on an opened handle instead (the same mechanism as the Runtime store identity check), with an open/query failure counting as single-linked: the archive writer opens the member again for streaming and surfaces any real problem there. unix/fallback arms only gain an unused path argument to keep one call site. Verified: host check/clippy/fmt; scratch-crate check of the new arm against windows-sys 0.61.2 for x86_64-pc-windows-msvc (ring's build script blocks a full-crate cross check from macOS). Signed-off-by: asto <asto18089@126.com>
#75's drain_pipe_caps_the_capture_and_notes_the_truncation reaches drain_pipe, DRAIN_CAPTURE_CAP and DRAIN_CAP_TRUNCATED_NOTE through the tests mod's unix-gated 'use super::*' (the #77 pattern), so the parent's Windows regression leg fails codewhale-tui (lib test) with 7 E0425/E0433 errors: the production items are platform-independent but the glob that re-exports them into the test scope is not. Import the four names directly instead - the test is tokio-duplex based and platform-independent, so gating it away would only surrender Windows coverage. Verified: cargo test -p codewhale-tui --lib drain_pipe_caps, fmt, clippy on the host; the parent's windows-rust-test (regression) leg is the authoritative Windows compile gate. Signed-off-by: asto <asto18089@126.com>
Moonshot MFJS validation fails any empty schema node with MissingType,
and sanitize_moonshot_chat_tools answers by silently excluding the whole
tool from the request — the model only sees a tool_search reference it
can never call and keeps emitting the nearest advertised tool instead.
JSON Schema reads an empty items schema as unconstrained items, which is
exactly the MFJS-legal shape with the keyword absent, so the kimi
parameters sanitizer now removes the empty keyword instead of the tool.
Untyped nodes without a semantics-preserving equivalent (empty property
schemas) still fail closed.
Found in the wild: the Pinvou browser MCP advertised
evaluate_script.args = {"type": "array", "items": {}}, so Kimi
requests never carried the tool no matter how often it was activated.
Signed-off-by: asto <asto18089@126.com>
The bounded activation cache (8 cached tools, 16KiB of serialized schemas) evicts least-recently-used entries when a search admits new tools, and the evicted tools silently vanished from the model's function list between turns — a tool that executed successfully earlier in the same conversation became uncallable with no explanation. The search result now names the evictions (evicted_tool_references plus a toolbox_note stating the recovery contract); searches without evictions keep the legacy result shape unchanged. Signed-off-by: asto <asto18089@126.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
x