diff --git a/crates/webcodex-core/src/validation_evidence.rs b/crates/webcodex-core/src/validation_evidence.rs index e377f44b0..c24729d01 100644 --- a/crates/webcodex-core/src/validation_evidence.rs +++ b/crates/webcodex-core/src/validation_evidence.rs @@ -239,22 +239,20 @@ pub fn parse_node_native_test_diagnostics(stdout: &str, truncated: bool) -> Vali } fn node_native_test_summary(stdout: &str) -> Option { + // Preserve indentation: Node emits nested TAP plans and assertions inside + // suites, and those cannot authorize a root-level test-count summary. let lines: Vec<_> = stdout .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) + .filter(|line| !line.trim().is_empty()) .collect(); if lines.len() < 10 || lines.first().copied() != Some("TAP version 13") { return None; } - let tail = lines.get(lines.len().checked_sub(8)?..)?; - let duration = tail[7] - .strip_prefix("# duration_ms ")? - .parse::() - .ok()?; - if !duration.is_finite() || duration < 0.0 { - return None; - } + let (body, tail) = lines.split_at(lines.len().checked_sub(8)?); + // Native Node appends the root plan immediately before the fixed trailer. + // Its N counts top-level assertions, not all nested tests in # tests. + let root_count = body.last()?.strip_prefix("1..")?.parse::().ok()?; + let mut root_assertions = 0u64; let fields = [ "# tests ", "# suites ", @@ -264,28 +262,60 @@ fn node_native_test_summary(stdout: &str) -> Option { "# skipped ", "# todo ", ]; + for line in body.iter().skip(1).take(body.len().saturating_sub(2)) { + // Reject duplicate root plans and numeric accounting while allowing + // ordinary t.diagnostic prose such as "# pass phase complete". + let repeated_count = fields.iter().any(|prefix| { + line.strip_prefix(prefix) + .and_then(|value| value.parse::().ok()) + .is_some() + }); + let repeated_duration = line + .strip_prefix("# duration_ms ") + .and_then(|value| value.parse::().ok()) + .is_some_and(|value| value.is_finite() && value >= 0.0); + if line.starts_with("1..") + || line.starts_with("TAP version ") + || repeated_count + || repeated_duration + { + return None; + } + let ordinal = line + .strip_prefix("ok ") + .or_else(|| line.strip_prefix("not ok ")); + if let Some(ordinal) = ordinal { + root_assertions = root_assertions.checked_add(1)?; + if ordinal.split_whitespace().next()?.parse::().ok()? != root_assertions { + return None; + } + } + } + if root_assertions != root_count { + return None; + } + // Accounting lines must themselves be top-level (unindented). + let duration = tail[7] + .strip_prefix("# duration_ms ")? + .parse::() + .ok()?; + if !duration.is_finite() || duration < 0.0 { + return None; + } let mut values = [0_u64; 7]; for (i, prefix) in fields.iter().enumerate() { values[i] = tail[i].strip_prefix(prefix)?.parse::().ok()?; } - let [total, _suites, passed, failed, cancelled, skipped, todo] = values; - if cancelled != 0 + let [total, suites, passed, failed, cancelled, skipped, todo] = values; + // With no root assertions, neither nested tests nor suites can exist. + // An empty suite remains valid: Node emits one root suite assertion. + if (root_count == 0 && (total != 0 || suites != 0)) + || cancelled != 0 || passed .checked_add(failed)? .checked_add(skipped)? .checked_add(todo)? != total - || !lines[..lines.len() - 8].iter().any(|line| { - line.strip_prefix("1..") - .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) - }) - { - return None; - } - // A second accounting footer cannot be accepted as authoritative. - if lines[..lines.len() - 8] - .iter() - .any(|line| line.starts_with("# tests ")) { return None; } diff --git a/crates/webcodex-core/src/validation_evidence_tests.rs b/crates/webcodex-core/src/validation_evidence_tests.rs index 1b060ca09..64a20e95c 100644 --- a/crates/webcodex-core/src/validation_evidence_tests.rs +++ b/crates/webcodex-core/src/validation_evidence_tests.rs @@ -812,7 +812,7 @@ fn pytest_summary_proves_only_complete_executed_results() { #[test] fn node_native_tap_counts_only_complete_conservative_reporter_trailers() { use crate::validation_evidence::parse_node_native_test_diagnostics; - let transcript = "TAP version 13\n# Subtest: passes\nok 1 - passes\n1..2\n# tests 3\n# suites 0\n# pass 2\n# fail 0\n# cancelled 0\n# skipped 1\n# todo 0\n# duration_ms 42.41\n"; + let transcript = "TAP version 13\n# Subtest: first\nok 1 - first\n# Subtest: second\nok 2 - second\n# Subtest: skipped\nok 3 - skipped # SKIP\n1..3\n# tests 3\n# suites 0\n# pass 2\n# fail 0\n# cancelled 0\n# skipped 1\n# todo 0\n# duration_ms 42.41\n"; let summary = parse_node_native_test_diagnostics(transcript, false) .test_summary .expect("complete native TAP footer"); @@ -841,3 +841,108 @@ fn node_native_tap_counts_only_complete_conservative_reporter_trailers() { .is_none() ); } + +#[test] +fn node_native_tap_root_plan_and_footer_are_authoritative() { + use crate::validation_evidence::parse_node_native_test_diagnostics; + // Native Node v26.10.0 TAP indentation and trailer shape. + const SIMPLE: &str = "TAP version 13\n# Subtest: passes\nok 1 - passes\n1..1\n# tests 1\n# suites 0\n# pass 1\n# fail 0\n# cancelled 0\n# skipped 0\n# todo 0\n# duration_ms 42.41\n"; + const NESTED: &str = "TAP version 13\n# Subtest: group\n # Subtest: first\n ok 1 - first\n # Subtest: second\n ok 2 - second\n 1..2\nok 1 - group\n1..1\n# tests 3\n# suites 0\n# pass 3\n# fail 0\n# cancelled 0\n# skipped 0\n# todo 0\n# duration_ms 43.1\n"; + const SKIP_TODO: &str = "TAP version 13\n# Subtest: pass\nok 1 - pass\n# Subtest: skip\nok 2 - skip # SKIP\n# Subtest: todo\nok 3 - todo # TODO\n1..3\n# tests 3\n# suites 0\n# pass 1\n# fail 0\n# cancelled 0\n# skipped 1\n# todo 1\n# duration_ms 53.0\n"; + const FAILURE: &str = "TAP version 13\n# Subtest: failure\nnot ok 1 - failure\n1..1\n# tests 1\n# suites 0\n# pass 0\n# fail 1\n# cancelled 0\n# skipped 0\n# todo 0\n# duration_ms 30.5\n"; + const ZERO: &str = "TAP version 13\n1..0\n# tests 0\n# suites 0\n# pass 0\n# fail 0\n# cancelled 0\n# skipped 0\n# todo 0\n# duration_ms 0.0\n"; + // Node describe/it has one root suite plan and counts nested test leaves. + const SUITE: &str = "TAP version 13\n# Subtest: group\n # Subtest: first\n ok 1 - first\n # Subtest: second\n ok 2 - second\n 1..2\nok 1 - group\n1..1\n# tests 2\n# suites 1\n# pass 2\n# fail 0\n# cancelled 0\n# skipped 0\n# todo 0\n# duration_ms 35.1\n"; + // An empty Node suite has one root suite assertion but zero tests. + const EMPTY_SUITE: &str = "TAP version 13\n# Subtest: empty\nok 1 - empty\n1..1\n# tests 0\n# suites 1\n# pass 0\n# fail 0\n# cancelled 0\n# skipped 0\n# todo 0\n# duration_ms 20.0\n"; + for (output, passed, failed, ignored) in [ + (SIMPLE, 1, 0, 0), + (NESTED, 3, 0, 0), + (SUITE, 2, 0, 0), + (EMPTY_SUITE, 0, 0, 0), + (SKIP_TODO, 1, 0, 2), + (FAILURE, 0, 1, 0), + (ZERO, 0, 0, 0), + ] { + let summary = parse_node_native_test_diagnostics(output, false) + .test_summary + .expect("complete native TAP evidence"); + assert_eq!( + (summary.passed, summary.failed, summary.ignored), + (Some(passed), Some(failed), Some(ignored)) + ); + } + + // Real Node t.diagnostic text can begin with an accounting word. + let legitimate_diagnostics = SIMPLE.replacen( + "\n1..1\n# tests", + "\n# pass phase complete\n# skipped optional setup\n1..1\n# tests", + 1, + ); + assert!( + parse_node_native_test_diagnostics(&legitimate_diagnostics, false) + .test_summary + .is_some(), + "non-numeric Node diagnostics must not erase proven tests" + ); + for (case, invalid) in [ + ( + "zero root plan but positive test count", + ZERO.replace("# tests 0", "# tests 1") + .replace("# pass 0", "# pass 1"), + ), + ( + "zero root plan but nonzero suite count", + ZERO.replace("# suites 0", "# suites 1"), + ), + ( + "nested-only plan", + SIMPLE.replace("1..1\n# tests", " 1..1\n# tests"), + ), + ( + "duplicate root plan", + SIMPLE.replace("1..1\n# tests", "1..1\n1..1\n# tests"), + ), + ( + "displaced root plan", + SIMPLE.replace("1..1\n# tests", "1..1\nok 2 - unexpected\n# tests"), + ), + ( + "inconsistent root plan", + SIMPLE.replace("1..1\n# tests", "1..9\n# tests"), + ), + ( + "out-of-order assertion", + SIMPLE.replace("ok 1 - passes", "ok 2 - passes"), + ), + ] { + assert!( + parse_node_native_test_diagnostics(&invalid, false) + .test_summary + .is_none(), + "must not prove tests from {case}" + ); + } + for (field, value) in [ + ("tests", "1"), + ("suites", "0"), + ("pass", "1"), + ("fail", "0"), + ("cancelled", "0"), + ("skipped", "0"), + ("todo", "0"), + ("duration_ms", "42.41"), + ] { + let duplicate = SIMPLE.replacen( + "\n1..1\n# tests", + &format!("\n# {field} {value}\n1..1\n# tests"), + 1, + ); + assert!( + parse_node_native_test_diagnostics(&duplicate, false) + .test_summary + .is_none(), + "must reject duplicated accounting field {field}" + ); + } +} diff --git a/crates/webcodex-tool-contracts/src/registry/output_schemas/discovery.rs b/crates/webcodex-tool-contracts/src/registry/output_schemas/discovery.rs index 08a7a4fc5..36f6b03c7 100644 --- a/crates/webcodex-tool-contracts/src/registry/output_schemas/discovery.rs +++ b/crates/webcodex-tool-contracts/src/registry/output_schemas/discovery.rs @@ -64,7 +64,7 @@ pub(super) fn output_schema_for_tool(name: &str) -> Option { "properties": { "profile": {"type": "string", "enum": ["direct", "host_code_mode"]}, "host_budget_secs": {"type": "integer", "minimum": 1}, - "initial_job_handoff_secs": {"type": "integer", "minimum": 1}, + "initial_job_handoff_secs": {"type": "integer", "minimum": 1, "deprecated": true, "description": "Legacy compatibility-only timing hint. No independent execution consumer; max_sync_wait_secs controls structured handoff."}, "max_sync_wait_secs": {"type": "integer", "minimum": 1}, "continuation_wait_secs": {"type": "integer", "minimum": 1} }, diff --git a/crates/webcodex-tool-contracts/src/tests/input_schemas.rs b/crates/webcodex-tool-contracts/src/tests/input_schemas.rs index 687cb723d..b02388dbc 100644 --- a/crates/webcodex-tool-contracts/src/tests/input_schemas.rs +++ b/crates/webcodex-tool-contracts/src/tests/input_schemas.rs @@ -1471,7 +1471,8 @@ fn project_validation_python_adapter_is_public_without_a_pytest_tool() { .as_str() .unwrap_or_default(); assert!(adapter_description.contains("Python supports test through pytest")); - assert!(adapter_description.contains("Node returns unavailable")); + assert!(adapter_description + .contains("Node supports native script check and opt-in native TAP test")); assert!( schema.to_string().contains("pytest -k"), "project_validate schema must expose the Python pytest -k filter contract" @@ -1479,3 +1480,53 @@ fn project_validation_python_adapter_is_public_without_a_pytest_tool() { assert!(ToolCall::from_tool_name("project_validate", value).is_ok()); assert!(ToolCall::from_tool_name("pytest", json!({"project":"demo"})).is_err()); } + +#[test] +fn project_validation_node_contract_advertises_bounded_test_and_execution_effects() { + let specs = registered_tool_specs(); + let spec = spec_named(&specs, "project_validate"); + let description = spec.description.as_str(); + assert!(description.contains("Node >=22.3: check via node --run")); + assert!(description.contains( + "native TAP test when scripts.test equals node --test or node --test --test-reporter=tap" + )); + assert!(description.contains("fixed Runner argv")); + assert!(description.contains("Node TAP rejects filters")); + assert!(description.contains("Project scripts may modify files or use network")); + assert_eq!( + lookup_tool_definition("project_validate") + .unwrap() + .metadata() + .effect, + ToolEffect::Execute + ); + + let schema = &spec.input_schema; + assert_eq!(schema["additionalProperties"], false); + for action in ["check", "test"] { + let value = json!({"project":"demo", "adapter":"node", "action":action}); + assert!(test_support::validate_schema_instance(&value, schema).is_ok()); + assert!(ToolCall::from_tool_name("project_validate", value).is_ok()); + } + let with_count = json!({"project":"demo", "adapter":"node", "action":"test", + "test":{"require_tests":false, "min_tests":2}}); + assert!(test_support::validate_schema_instance(&with_count, schema).is_ok()); + assert!(ToolCall::from_tool_name("project_validate", with_count).is_ok()); + for (field, value) in [ + ("executable", json!("node")), + ("argv", json!(["--test"])), + ("shell", json!("sh")), + ("script", json!("test")), + ] { + let mut invalid = json!({"project":"demo", "adapter":"node", "action":"test"}); + invalid[field] = value; + assert!( + test_support::validate_schema_instance(&invalid, schema).is_err(), + "{field}" + ); + assert!( + ToolCall::from_tool_name("project_validate", invalid).is_err(), + "{field}" + ); + } +} diff --git a/crates/webcodex-tool-contracts/src/tool_call/canonical_enum.rs b/crates/webcodex-tool-contracts/src/tool_call/canonical_enum.rs index a2580bfbf..f700f8654 100644 --- a/crates/webcodex-tool-contracts/src/tool_call/canonical_enum.rs +++ b/crates/webcodex-tool-contracts/src/tool_call/canonical_enum.rs @@ -1541,8 +1541,8 @@ pub enum ToolCall { timeout_secs: Option, }, - /// Run portable read-only project validation. The Runner resolves the nearest - /// supported Rust/Go or Python recipe and admits one canonical structured validation Job. + /// Run bounded project validation; project-authored tests/scripts can modify files or use network. + /// The Runner resolves a Rust/Go/Python/Node recipe and admits a canonical Job. ProjectValidate { /// Exact registered Runner Project. project: String, @@ -1552,7 +1552,10 @@ pub enum ToolCall { #[serde(default)] cwd: Option, action: webcodex_core::project_validation::ProjectValidationAction, - /// Omission means auto. Rust and Go are supported; Python supports test through pytest and check/format_check through project-local Ruff with explicit target-version; Node returns unavailable. + /// Omission means auto. Rust and Go are supported; Python supports test through pytest and + /// check/format_check through project-local Ruff with explicit target-version. + /// Node supports native script check and opt-in native TAP test; no caller-selected + /// script names or argv. #[serde(default)] adapter: Option, /// Optional portable scope. Explicit packages narrow Rust/Go selection; all_packages=true selects the @@ -1564,8 +1567,8 @@ pub enum ToolCall { #[serde(default)] dependency_policy: Option, /// Test-only selector and count postconditions. Rust uses a libtest substring, - /// Go uses native -run regexp, and Python pytest uses -k. Omission preserves - /// unfiltered positive-test proof. + /// Go uses native -run regexp, and Python pytest uses -k. Node TAP supports + /// count requirements but rejects test filters. Omission keeps unfiltered positive-test proof by default. #[serde(default)] test: Option, /// Total execution budget, clamped to 3600 seconds. Host grace never starts another execution. diff --git a/crates/webcodex-tool-contracts/src/tool_definition/testing.rs b/crates/webcodex-tool-contracts/src/tool_definition/testing.rs index 24527aae0..4b1bebe6d 100644 --- a/crates/webcodex-tool-contracts/src/tool_definition/testing.rs +++ b/crates/webcodex-tool-contracts/src/tool_definition/testing.rs @@ -131,7 +131,7 @@ pub(super) const DEFINITIONS: &[ToolDefinition] = &[ ) .with_composition_policy(super::ToolCompositionPolicy::Sequential) .with_host_orchestration_hint(super::ToolHostOrchestrationHint::sequential()), - "Runner owns project recipes and argv. Rust/Go: format_check/check/test as supported; Python: pytest test, Ruff check/format_check; Node >=22.3: native check script only (check > typecheck > lint); no pre/post or other actions. Project scripts may modify files or use network; exit status proves only script outcome, not lint identity or read-only effects. scope selects packages (1..8) or all_packages=true for Rust/Go check/test; scoped formatting fails closed. Rust all-packages requires a Runner-proven Project-root Cargo workspace; Go keeps ./... single-module scope. Python/Node reject scope. test.filter is a bounded Rust substring, Go -run regexp or pytest -k expression; require_tests/min_tests govern proven counts. Python uses existing configured/profile/PATH Python 3 and pytest/Ruff; never installs or falls back. Ruff needs local pyproject.toml [tool.ruff], explicit target-version, no extend; read-only with no fixes, cache, bytecode or output files. Missing environment is unavailable. dependency_policy.mode=locked maps to Cargo --locked/Go -mod=readonly, not offline; Python and formatting reject it. Additive semantics require Runner support. Go forces GO111MODULE=on/GOWORK=off. No arbitrary executable, argv or shell. Project tests may have effects. Long work keeps the same Job; observe pending, never retry. Advanced options remain on cargo_* / go_test.", ) + "Runner owns project recipes and argv. Rust/Go: format_check/check/test as supported; Python: pytest test, Ruff check/format_check; Node >=22.3: check via node --run (check > typecheck > lint; no pre/post), or native TAP test when scripts.test equals node --test or node --test --test-reporter=tap (fixed Runner argv); no npm/Jest/Vitest. Project scripts may modify files or use network; exit status proves only script outcome, not lint identity or read-only effects. scope selects packages (1..8) or all_packages=true for Rust/Go check/test; scoped formatting fails closed. Rust all-packages requires a Runner-proven Project-root Cargo workspace; Go keeps ./... single-module scope. Python/Node reject scope. test.filter allows Rust substring, Go -run regexp or pytest -k; Node TAP rejects filters. require_tests/min_tests govern proven counts. Python uses existing configured/profile/PATH Python 3 and pytest/Ruff; never installs or falls back. Ruff needs local pyproject.toml [tool.ruff], explicit target-version, no extend; read-only with no fixes, cache, bytecode or output files. Missing environment is unavailable. dependency_policy.mode=locked maps to Cargo --locked/Go -mod=readonly, not offline; Python and formatting reject it. Additive semantics require Runner support. Go forces GO111MODULE=on/GOWORK=off. No arbitrary executable, argv or shell. Project tests may have effects. Long work keeps the same Job; observe pending, never retry. Advanced options remain on cargo_* / go_test.", ) .with_execution(super::ToolExecutionContract::new( super::ToolExecutionForm::StructuredValidation, super::ToolExecutionLifetime::Runner, diff --git a/docs/DESKTOP_DEVELOPMENT.md b/docs/DESKTOP_DEVELOPMENT.md index 7eaeec232..0fa0b56c6 100644 --- a/docs/DESKTOP_DEVELOPMENT.md +++ b/docs/DESKTOP_DEVELOPMENT.md @@ -88,6 +88,11 @@ together, while the shared Server remains available for admitted work to drain. Concurrent Tauri/native exit notifications wait for the same cleanup attempt; lock acquisition, process cleanup and operation settlement share a four-second Windows budget. Work that cannot settle retains the tunnel restart fence. +Managed tunnel children observe parent stdin EOF throughout preparation and +startup, including the first control-plane poll. Startup cancellation retains +the task owner until its shutdown result is observed, so an idle connection can +retire its fence without first becoming ready. Persistent daemon mode continues +to ignore stdin EOF. Abrupt termination or power loss still requires uncertainty resolution; reboot alone is never permission to remove a fence or replay work. @@ -96,6 +101,7 @@ fixture window and checks four real child leases plus shared Server ordering: ```powershell cargo test --locked --manifest-path apps/desktop/src-tauri/Cargo.toml --lib desktop_real_process_windows_session_shutdown_drains_all_profiles -- --ignored --test-threads=1 +cargo test --locked -p webcodex --lib regular_tunnel_real_process_parent_eof_during_local_probe -- --ignored --test-threads=1 ``` This tests the native notification and cleanup boundary, not an actual system diff --git a/docs/MCP.md b/docs/MCP.md index 35aebc826..7166c347e 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -416,7 +416,7 @@ work_on_project `present_work_result` is a one-card presentation layer for substantial coding, not a correctness primitive. Once mounted, its App-only state reads keep current progress, workspace, validation, and review visible without model polling. A non-blocking `finish_coding_task` seals eligible final changes in the presentation cache at closeout; the same card then discovers that immutable snapshot and can lazily expand per-file diffs. Tiny/read-only work should skip the card; repeated presentation of the same Session should be avoided. -For ordinary portable read-only validation, prefer `project_validate`. It accepts only a closed `format_check` / `check` / `test` intent plus an optional `auto` / `rust` / `go` / `python` / `node` adapter hint; the Runner resolves the nearest unambiguous recipe on its own registered filesystem and then starts the existing structured validation Job. Rust maps to `cargo fmt -- --check`, `cargo check --all-targets`, or `cargo test`; Go maps to `go vet ./...` or `go test -json ./...`. Go project validation runs in Runner-owned single-module mode with `GO111MODULE=on` and `GOWORK=off`, so ambient module mode or parent `go.work` selection cannot silently change the gateway's workspace semantics. Its validation target identity is domain-separated from ambient Go specialist evidence, so success under different workspace semantics cannot reconcile a gateway failure. The standalone `go_test` specialist keeps its existing environment behavior. Optional `scope` selects exactly one portable package intent: bounded `packages` (1..8 entries) narrows Rust check/test through repeated Cargo `-p` selectors and Go check/test through project-relative package patterns, while `all_packages=true` selects the complete project unit. Rust all-packages maps to Cargo `--workspace` only when the Runner proves the effective Cargo workspace root is exactly the registered Project root and binds the in-Project Cargo manifest graph into the existing re-plan fence; Go all-packages keeps the canonical `./...` single-module scope. Scoped formatting fails closed. Python supports test only, using an existing configured/profile/PATH Python 3 interpreter and canonical `python -m pytest --color=no -rA`; Python check/format, all scope and dependency policy fail closed. Node supports Runner-owned native script-based `check` through `node --run ` (see #995), plus a separate, opt-in native TAP `test` profile when `package.json` declares exactly `node --test` (or `node --test --test-reporter=tap`). The native test executes fixed Runner argv, parses only complete TAP v13 summaries, and requires the distinct `project_validation_node_tap_v1` capability. Jest/Vitest, custom test scripts, scope and filters remain unsupported. See [Node native TAP validation](implementation/node-native-tap-project-validation.md). Python planning and Job admission require `project_validation_python_pytest_v1`; missing pytest is a definite not-started tooling failure with no automatic installation or fallback. See [Python/pytest validation](implementation/python-pytest-project-validation.md) for environment selection, evidence and same-Job behavior. The request never carries arbitrary executable, argv, shell grammar, installation, or source mutation. Existing `cargo_*` / `go_test` tools remain available for ecosystem-specific advanced options. `project_validate` requires the additive `project_validation_v1` Runner capability; explicit `packages` additionally require `project_validation_package_scope_v1`, while `all_packages=true` requires `project_all_packages_v1`. Go project-validation Job admission additionally requires `project_go_single_module_v1`, so a Server cannot hand a Go gateway plan to an older Runner that may inherit ambient workspace state. +For ordinary bounded project validation, prefer `project_validate`; project-authored scripts and tests may modify files or use network. It accepts only a closed `format_check` / `check` / `test` intent plus an optional `auto` / `rust` / `go` / `python` / `node` adapter hint; the Runner resolves the nearest unambiguous recipe on its own registered filesystem and then starts the existing structured validation Job. Rust maps to `cargo fmt -- --check`, `cargo check --all-targets`, or `cargo test`; Go maps to `go vet ./...` or `go test -json ./...`. Go project validation runs in Runner-owned single-module mode with `GO111MODULE=on` and `GOWORK=off`, so ambient module mode or parent `go.work` selection cannot silently change the gateway's workspace semantics. Its validation target identity is domain-separated from ambient Go specialist evidence, so success under different workspace semantics cannot reconcile a gateway failure. The standalone `go_test` specialist keeps its existing environment behavior. Optional `scope` selects exactly one portable package intent: bounded `packages` (1..8 entries) narrows Rust check/test through repeated Cargo `-p` selectors and Go check/test through project-relative package patterns, while `all_packages=true` selects the complete project unit. Rust all-packages maps to Cargo `--workspace` only when the Runner proves the effective Cargo workspace root is exactly the registered Project root and binds the in-Project Cargo manifest graph into the existing re-plan fence; Go all-packages keeps the canonical `./...` single-module scope. Scoped formatting fails closed. Python uses existing configured/profile/PATH Python 3 for pytest test (`python -m pytest --color=no -rA`) and project-local Ruff check/format_check (explicit target-version, no fixes, cache or bytecode). Python scope and dependency policy fail closed. Node supports Runner-owned native script-based `check` through `node --run ` (see #995), plus a separate, opt-in native TAP `test` profile when `package.json` declares exactly `node --test` (or `node --test --test-reporter=tap`). The native test executes fixed Runner argv, parses only complete TAP v13 summaries, and requires the distinct `project_validation_node_tap_v1` capability. Jest/Vitest, custom test scripts, scope and filters remain unsupported. See [Node native TAP validation](implementation/node-native-tap-project-validation.md). Python test planning and Job admission require `project_validation_python_pytest_v1`, while Ruff check/format_check require the separate `project_validation_python_ruff_v1` capability. Missing selected tooling is a definite not-started failure with no automatic installation or fallback. See [Python/pytest validation](implementation/python-pytest-project-validation.md) for environment selection, evidence and same-Job behavior. The request never carries arbitrary executable, argv, shell grammar, installation or an explicit source-mutation intent; project-authored scripts and tests can still cause effects. Existing `cargo_*` / `go_test` tools remain available for ecosystem-specific advanced options. `project_validate` requires the additive `project_validation_v1` Runner capability; explicit `packages` additionally require `project_validation_package_scope_v1`, while `all_packages=true` requires `project_all_packages_v1`. Go project-validation Job admission additionally requires `project_go_single_module_v1`, so a Server cannot hand a Go gateway plan to an older Runner that may inherit ambient workspace state. Cargo all-packages provenance is a bounded package-selection witness, not a complete build-input snapshot. It requires a contained workspace, or a standalone package with no ancestor `Cargo.toml` marker; parent markers are only probed, never read outside the registered Project. External path dependencies are unavailable in this scope because their `package.workspace` metadata can add out-of-Project members. Relevant manifest/member/dependency aliases remain fenced, while unrelated non-manifest links are ignored. Unproven topology or exhausted bounds returns `validation_scope_unavailable` / `build_scope_unavailable`; explicit package scope and the existing specialist tools retain their contracts. diff --git a/docs/MCP.zh-CN.md b/docs/MCP.zh-CN.md index d232f94d4..6d97ce642 100644 --- a/docs/MCP.zh-CN.md +++ b/docs/MCP.zh-CN.md @@ -312,7 +312,7 @@ work_on_project `present_work_result` 是 substantial coding 的一次性可视化层,不是 correctness primitive。挂载后,卡片通过 App-only state read 持续显示 Progress、Workspace、Validation 与 Review,无需模型轮询。`finish_coding_task` 在 non-blocking closeout 时把 eligible final changes seal 到 presentation cache,同一张卡随后发现这份 immutable snapshot,并按文件 lazy 展开 diff。tiny/read-only 工作应跳过这张卡,同一 Session 不应重复 presentation。 -普通的 portable read-only validation 优先使用 `project_validate`。它只接受封闭的 `format_check` / `check` / `test` intent,以及可选的 `auto` / `rust` / `go` / `python` / `node` adapter hint;Runner 在自己注册的真实文件系统上解析最近且无歧义的 recipe,然后进入现有 structured validation Job。Rust 分别映射到 `cargo fmt -- --check`、`cargo check --all-targets`、`cargo test`;Go 映射到 `go vet ./...` 或 `go test -json ./...`。Go project validation 由 Runner 固定为 single-module 模式(`GO111MODULE=on`、`GOWORK=off`),因此 ambient module mode 或父目录 `go.work` 选择不会静默改变 gateway 的 workspace 语义;其 validation target identity 与 ambient Go specialist evidence 做 domain separation,因此不同 workspace 语义下产生的成功不会消解 gateway failure。独立的 `go_test` specialist 保持现有环境语义。可选 `scope` 只允许二选一的 portable package intent:有界 `packages`(1..8 项)把 Rust check/test 映射为重复 Cargo `-p` selector,把 Go check/test 映射为 project-relative package pattern;`all_packages=true` 则选择完整 project unit。Rust all-packages 只有在 Runner 证明 effective Cargo workspace root 与 registered Project root 完全一致后才映射为 Cargo `--workspace`,并把 Project 内 Cargo manifest graph 绑定到既有 re-plan fence;Go all-packages 保持 canonical `./...` single-module scope。带 scope 的格式检查会 fail closed。Python 仅支持 test,复用 configured/profile/PATH 中已有的 Python 3,canonical argv 为 `python -m pytest --color=no -rA`;Python check/format、所有 scope 与 dependency policy 均 fail closed。Node 已支持 Runner 管理的脚本式 `check`(#995),并在 `package.json` 的 `scripts.test` 精确声明 `node --test`(或 `node --test --test-reporter=tap`)时支持独立的原生 TAP `test`。Node 测试由 Runner 使用固定 argv 直接启动,仅完整 TAP v13 汇总可证明测试数量,且需要独立能力 `project_validation_node_tap_v1`。Jest/Vitest、自定义 test 脚本、scope 和 filter 仍不支持。详见 [Node 原生 TAP 验证](implementation/node-native-tap-project-validation.md)。Python planning 与 Job 准入均要求 `project_validation_python_pytest_v1`;缺失 pytest 为明确的 not-started tooling failure,不自动安装或 fallback。环境、证据与同 Job 行为见 [Python/pytest validation](implementation/python-pytest-project-validation.md)。请求不会携带 arbitrary executable、argv、shell grammar、安装动作或 source mutation;需要 ecosystem-specific 高级参数时继续使用现有 `cargo_*` / `go_test`。`project_validate` 依赖 additive `project_validation_v1` Runner capability;显式 `packages` 额外要求 `project_validation_package_scope_v1`,而 `all_packages=true` 要求 `project_all_packages_v1`;Go project-validation Job 准入还额外要求 `project_go_single_module_v1`,因此 Server 不会把 Go gateway plan 交给仍可能继承 ambient workspace 状态的旧 Runner。 +普通的有界 Project validation 优先使用 `project_validate`;项目自定义脚本与测试仍可能修改文件或访问网络。它只接受封闭的 `format_check` / `check` / `test` intent,以及可选的 `auto` / `rust` / `go` / `python` / `node` adapter hint;Runner 在自己注册的真实文件系统上解析最近且无歧义的 recipe,然后进入现有 structured validation Job。Rust 分别映射到 `cargo fmt -- --check`、`cargo check --all-targets`、`cargo test`;Go 映射到 `go vet ./...` 或 `go test -json ./...`。Go project validation 由 Runner 固定为 single-module 模式(`GO111MODULE=on`、`GOWORK=off`),因此 ambient module mode 或父目录 `go.work` 选择不会静默改变 gateway 的 workspace 语义;其 validation target identity 与 ambient Go specialist evidence 做 domain separation,因此不同 workspace 语义下产生的成功不会消解 gateway failure。独立的 `go_test` specialist 保持现有环境语义。可选 `scope` 只允许二选一的 portable package intent:有界 `packages`(1..8 项)把 Rust check/test 映射为重复 Cargo `-p` selector,把 Go check/test 映射为 project-relative package pattern;`all_packages=true` 则选择完整 project unit。Rust all-packages 只有在 Runner 证明 effective Cargo workspace root 与 registered Project root 完全一致后才映射为 Cargo `--workspace`,并把 Project 内 Cargo manifest graph 绑定到既有 re-plan fence;Go all-packages 保持 canonical `./...` single-module scope。带 scope 的格式检查会 fail closed。Python 使用 configured/profile/PATH 中已有的 Python 3 执行 pytest test(`python -m pytest --color=no -rA`),也支持项目本地 Ruff check/format_check(显式 target-version、禁用自动修复、缓存与字节码);Python 的 scope 与 dependency policy 均 fail closed。Node 已支持 Runner 管理的脚本式 `check`(#995),并在 `package.json` 的 `scripts.test` 精确声明 `node --test`(或 `node --test --test-reporter=tap`)时支持独立的原生 TAP `test`。Node 测试由 Runner 使用固定 argv 直接启动,仅完整 TAP v13 汇总可证明测试数量,且需要独立能力 `project_validation_node_tap_v1`。Jest/Vitest、自定义 test 脚本、scope 和 filter 仍不支持。详见 [Node 原生 TAP 验证](implementation/node-native-tap-project-validation.md)。Python test 的 planning 与 Job 准入要求 `project_validation_python_pytest_v1`,Ruff check/format_check 则独立要求 `project_validation_python_ruff_v1`;所选工具缺失时明确 not-started,不自动安装或 fallback。环境、证据与同 Job 行为见 [Python/pytest validation](implementation/python-pytest-project-validation.md)。请求不会携带 arbitrary executable、argv、shell grammar、安装动作或显式修改源文件的意图;项目脚本和测试仍可能产生副作用;需要 ecosystem-specific 高级参数时继续使用现有 `cargo_*` / `go_test`。`project_validate` 依赖 additive `project_validation_v1` Runner capability;显式 `packages` 额外要求 `project_validation_package_scope_v1`,而 `all_packages=true` 要求 `project_all_packages_v1`;Go project-validation Job 准入还额外要求 `project_go_single_module_v1`,因此 Server 不会把 Go gateway plan 交给仍可能继承 ambient workspace 状态的旧 Runner。 Cargo all-packages provenance 是有界的 package-selection witness,并非完整构建输入快照。它要求 workspace 完全位于 registered Project 内,或独立 package 的祖先目录不存在 `Cargo.toml` marker;对 Project 外的祖先只探测 marker,不读取 manifest 内容。此 scope 不接受外部 path dependency,因为外部 manifest 的 `package.workspace` 可以把 Project 外 package 加入 workspace。相关 manifest/member/dependency alias 仍会被 fence,无关的非 manifest 链接会被忽略。无法证明的 topology 或超出边界上限时返回 `validation_scope_unavailable` / `build_scope_unavailable`;显式 package scope 和现有 specialist tools 保持各自契约。 diff --git a/docs/agent/runtime-tuning-inventory.md b/docs/agent/runtime-tuning-inventory.md new file mode 100644 index 000000000..f5d4db652 --- /dev/null +++ b/docs/agent/runtime-tuning-inventory.md @@ -0,0 +1,59 @@ +# Runtime tuning inventory — #984 Phase 1 + +Source baseline: `main@6f8ed6d8` (2026-10-09). This is an ownership and semantics audit, **not** a proposed change in production defaults or authorization limits. Entries marked *candidate* need separate policy, bounds and measurement review before becoming operator overrides. + +## Host MCP request timings: authoritative consumers + +| Value / source | Effective defaults and bounds | Runtime consumer | Apply / observation | Classification | +| --- | --- | --- | --- | --- | +| `WEBCODEX_MCP_HOST_PROFILE`, `src/mcp_host.rs` | `direct` by default; `host_code_mode` opt-in | Chooses Host timing derivation and omitted model guidance | Server deployment/environment; selected effective profile in `get_runtime_status.effective_config.mcp_host` | Existing operator setting | +| `WEBCODEX_MCP_HOST_BUDGET_SECS` | direct 60s / host_code_mode 55s; positive integer; effective safe wait `max(1, budget - 5s)` | Request-local Host budget, return guard and readiness ceiling; **not** Job execution timeout | Deployment, request-local bounded selection; effective Server status and request-policy trace | Advanced candidate; current invalid env falls back to default | +| `initial_job_handoff_secs` | direct `min(10, safe_wait)`; Host Code Mode `min(5, safe_wait)` | **No independent runtime consumer** found in current source; only policy struct, status/schema, frontend and tests | Read-only legacy wire-compatible diagnostic field, not an operator control | Legacy projection; deprecated, leave key intact to avoid breaking strict clients | +| `max_sync_wait_secs` | direct `min(60, safe_wait)`; Host Code Mode `min(5, safe_wait)` | `mcp_timing::return_timing_policy` → `return_timing::normalize_structured_handoff` → durable Job handoff, intersected with internal orchestration caps | Derived per MCP request; status reports deployment value, which need not equal a request-local narrowed value | Advanced tuning *candidate*, not currently separately configurable | +| `continuation_wait_secs` | direct `min(100, safe_wait)`; Host Code Mode `min(5, safe_wait)` | `mcp_timing::normalize_observation_call_timing` for `observe_jobs`/`job_tail`; `normalize_result_timing` for generated `observe_jobs` continuation | Derived per MCP request; same deployment/request-local distinction | Advanced tuning *candidate*, not currently separately configurable | +| Host return guard `HOST_RETURN_GUARD_SECS` | fixed 5s | Reserve budget for returning the MCP response | Code-owned, not a user control | **Internal invariant** | +| `wait_for_job_readiness` explicit wait | Tool-specific wait capped by `max(1, host_budget - 5)` | Separate branch of `normalize_observation_call_timing`, **not** generic continuation 5s cap | Request-local, bounded by remaining Host wait allowance | **Keep separate** from ordinary Job polling | +| Canonical `sync_wait_secs` | default 10s, protocol ceiling 60s, intersected with execution timeout | `structured_execution::StructuredExecutionBudget` and `return_timing` | Per call, cannot increase Server-owned caps | Existing request preference, **not** Host execution-lifetime control | +| Job observation protocol limit | fixed 100s | `MAX_JOB_OBSERVATION_WAIT_SECS` in Core and tools | Protocol bounded | **Internal invariant** | + +At present, the three derived Host Code Mode values all happen to be 5s, but their **consumers and authority are different**. Raising all three together is not a safe tuning experiment. Request-local narrowing must not be represented as an increase to deployment authority. API transport bypasses these MCP-only normalizers. + +## Broader bounded inventory (Phase 1 discovery) + +The following records identify representative knobs across the required runtime areas; they do not authorize a new global settings surface. Defaults here are sourced from the checked-in code. Values not independently established here are deliberately *not inferred*. + +| Control | Owner / actual source | Default / bounds found | Apply / visibility | Classification / Phase 1 decision | +| --- | --- | --- | --- | --- | +| Runner Job capacity | Runner `runner.toml`, `webcodex_runner/config.rs` | default 4; 1–64 | Runner registration/capacity, Desktop Runner Job capacity panel; explicit save/apply | Existing **normal** operator tuning; do not duplicate | +| Runner polling interval | Runner `poll_interval_ms`, runner-config | default 1000ms; positive, at most 30000ms when polling may be used | Runner config, reload semantics owned by Runner | Existing transport tuning; do not couple to MCP Host budget | +| Runner WebSocket connect timeout | Runner `websocket_connect_timeout_secs` | default 5s; must be positive | Runner config; connection attempts | Existing transport tuning | +| Runner QUIC connect timeout | Runner `quic_connect_timeout_secs` | default 10s | Runner transport config | Existing transport tuning | +| Runner QUIC keepalive | Runner `quic_keepalive_interval_secs` | default 20s | Runner transport config | Transport setting; do not elevate to Desktop without demand | +| Runner command max timeout | Runner-config `max_timeout_secs` | default 3600s | Runner policy, bounded execution | Existing Runner setting, **not** MCP handoff wait | +| Runner max output | Runner-config `max_output_bytes` | default 256KiB | Runner response limit | Safety/resource bound; not automatically a performance knob | +| Coding Agent ACP run capacity | Runner `config.rs` | default 1; range 1–8 | Runner coding-agent configuration | Existing concurrency control; independent of Job capacity | +| ACP permission timeout | Runner `config.rs` | default 5s, range 1–60s | Permission/approval handshake | Authorization-related cap: **keep distinct**, not a generic speed knob | +| Persistent shell count | Runner `config.rs` | default 8, minimum 1 (full bound requires its validator) | Runner shell manager | Candidate only after capacity/resource audit | +| E2a/E2c consequential child handoff | Server `tool_runtime/code_mode.rs` | fixed 5s | Internal consequential child return timing | **Internal orchestration invariant**, not Host profile knob | +| Consequential started-child drain | Server `tool_runtime/code_mode.rs` | fixed 5000ms | Controlled post-start child drain | **Internal correctness/lifetime invariant**, not operator-exposed | +| Effective config diagnostics | Server `tool_runtime/runtime_info.rs` + Tool Contracts schema | safe allowlisted effective fields | `get_runtime_status` + Runtime UI | Read-only observability; never credential values | +| Desktop host deployment defaults | Desktop Tauri `state/readiness.rs` | profile/budget deployment environment | managed Server startup; applies at next managed launch | Deployment owner, not Runner tuning | + +### Phase 1 decisions + +1. **Preserve the JSON key** `initial_job_handoff_secs` for existing typed clients and mark it deprecated as a **legacy derived hint only**. In the Runtime UI, explicitly identify that it is unused for execution; do not drop a required field from the strict output schema without versioning. +2. Test the *actual* timing consumers independently: structured handoff, ordinary observation and generated continuations, readiness joins. A changed legacy hint must have no effect. +3. Keep 5s Host Code Mode defaults, 55s Host budget, Runner/Code Mode internal caps and protocol maxima unchanged. +4. Phase 2, if independently justified, should expose no more than *two* separately bounded Server-owned tuning concepts: structured synchronous handoff ceiling and ordinary continuation wait. Host budget stays a separate existing authority. Request-local headers can only narrow deployment bounds, not raise them. +5. Require before/after end-to-end timing, handoff rate, readiness/observe turns, Host duration and final Job outcomes before proposing different defaults. Successful HTTP/MCP return is not evidence of final Job success. + +## Verification pointers + +- `src/mcp_host.rs`, `src/mcp/request_policy.rs`: config/defaults and request-local policy +- `src/tool_runtime/mcp_timing.rs`, `src/tool_runtime/return_timing.rs`, `src/tool_runtime/structured_execution.rs`: actual consumers +- `src/tool_runtime/code_mode.rs`: separate inner orchestration caps +- `src/tool_runtime/runtime_info.rs`, `crates/webcodex-tool-contracts/src/registry/output_schemas/discovery.rs`: deployment diagnostics wire contract +- `crates/webcodex-runner/src/webcodex_runner/config.rs`, `crates/webcodex-runner-config/src/lib.rs`: Runner policy and defaults +- `apps/desktop/src-tauri/src/state/readiness.rs`: Desktop-managed Server deployment values + +This is a **first-stage representative inventory**, not certification that every internal constant has been audited or that advanced tuning is ready to expose. Re-audit against the then-current main before Phase 2. diff --git a/docs/implementation/node-native-tap-project-validation.md b/docs/implementation/node-native-tap-project-validation.md index a95e22f01..afb66f609 100644 --- a/docs/implementation/node-native-tap-project-validation.md +++ b/docs/implementation/node-native-tap-project-validation.md @@ -28,3 +28,21 @@ Node tests themselves execute Project-authored code and can have filesystem/netw ## Regression requirements Check native TAP pass/fail/skip/suite behavior, zero/contradictory/incomplete/duplicated/truncated report semantics, source drift after scheduling, explicit vs auto Node Runner capabilities, old-Runner denial, fixed argv, Node interpreter selection and environment sanitization, and ledger serialization of validation metadata. Tests for existing Node script-based `check` must remain unchanged. + +## Root plan and accounting evidence + +A complete native Node TAP report has a single unindented root `1..N` plan +immediately before the canonical eight-line accounting trailer. Indented plans +belong to nested suites and cannot substitute for a root plan. `N` counts +top-level assertions (including suite parents), not all nested tests from +the `# tests` footer. Top-level assertion ordinals must match this plan. + +Duplicated/displaced root plans, repeated top-level numeric accounting fields, +bad assertion ordinals, contradictory counts and truncated reports cannot prove +test counts. Ordinary Node diagnostic comments (for example, +`# pass phase complete`) are not themselves reporter accounting entries. +Regression fixtures include real Node v26-shaped pass, failure, nested suites, +skip/TODO, a synthetic zero-test trailer and malformed reports. The existing +unavailable-count path remains authoritative even when `require_tests=false`. +This is an evidence-parser-only extension: no new npm/Jest/Vitest support, +Runner argv changes, source fences or public wire fields. diff --git a/docs/implementation/project-validation-test-options.md b/docs/implementation/project-validation-test-options.md index 7b66cb579..d183bbde6 100644 --- a/docs/implementation/project-validation-test-options.md +++ b/docs/implementation/project-validation-test-options.md @@ -5,9 +5,12 @@ The current tree has advanced beyond the historical slice documented below. Rust/Go `project_validate` now supports shared package scope, `all_packages=true` where the Runner can prove the complete project unit, and `dependency_policy.mode=locked`. -Python `project_validate` supports `action=test` through pytest, including bounded -`test.filter` mapped to pytest `-k`; Python still rejects package scope and dependency -policy. Node remains unavailable. `project_build` remains a separate Rust/Go gateway. +Python `project_validate` supports pytest tests (including bounded `test.filter` +mapped to pytest `-k`) and project-local Ruff `check` / `format_check`; Python +still rejects package scope and dependency policy. Node supports Runner-owned +script `check` (#995) and exact-opt-in native TAP `test` (#997), but not Jest, +Vitest, arbitrary test scripts, test filters or package scope. `project_build` +remains a separate Rust/Go gateway. The historical implementation record below is retained as the state of that slice. ## Status and bounded scope @@ -21,9 +24,9 @@ Direct descriptor, adapter registry, crate, external dependency or scheduler. That validation slice deferred `project_build`. The current tree now has a separate Rust/Go `project_build` v1 gateway with Runner-owned recipe planning, typed `StartBuild`, manifest/lock provenance, and same-Job admission fencing. -Build profile/target/artifact identity, mutating `project_format`, lint, -production Node and additional Python adapters, workspace/exclude, and offline/network -policies are tracked as additive lifecycle extensions in #962. Existing lower-level Cargo/Go tools retain +Build profile/target/artifact identity, mutating `project_format`, broader lint +and formatting adapters, workspace/exclude and offline/network policies remain +tracked as additive lifecycle extensions in #962. Existing lower-level Cargo/Go tools retain their options and default behavior. No claim of complete CLI parity is made. ## Request and planning diff --git a/frontend/dist/admin.js b/frontend/dist/admin.js index b7dc9906f..9992f0571 100644 --- a/frontend/dist/admin.js +++ b/frontend/dist/admin.js @@ -26959,7 +26959,7 @@ var yn = { "OAuth2 shared key bridge": "OAuth2 共享密钥桥接", "MCP host profile": "MCP 主机配置", "Host request budget": "主机请求预算", - "Initial job handoff wait": "首次任务交接等待", + "Legacy handoff hint (unused)": "旧版交接提示值(未参与执行)", "Maximum synchronous wait": "同步等待上限", "Continuation wait": "后续等待时长", "Some running calls are not shown.": "运行中调用仅显示部分记录。", diff --git a/frontend/dist/app.js b/frontend/dist/app.js index 3ff4bae8a..000036c4b 100644 --- a/frontend/dist/app.js +++ b/frontend/dist/app.js @@ -16216,7 +16216,7 @@ var jA = "webcodex.runtime.language.v1", An = { "OAuth2 shared key bridge": "OAuth2 共享密钥桥接", "MCP host profile": "MCP 主机配置", "Host request budget": "主机请求预算", - "Initial job handoff wait": "首次任务交接等待", + "Legacy handoff hint (unused)": "旧版交接提示值(未参与执行)", "Maximum synchronous wait": "同步等待上限", "Continuation wait": "后续等待时长", "Some running calls are not shown.": "运行中调用仅显示部分记录。", @@ -31676,7 +31676,7 @@ var Ej = { oauth2_shared_key_bridge_enabled: "OAuth2 shared key bridge", profile: "MCP host profile", host_budget_secs: "Host request budget", - initial_job_handoff_secs: "Initial job handoff wait", + initial_job_handoff_secs: "Legacy handoff hint (unused)", max_sync_wait_secs: "Maximum synchronous wait", continuation_wait_secs: "Continuation wait" }; diff --git a/frontend/src/runtime-v2/views/RuntimeView.tsx b/frontend/src/runtime-v2/views/RuntimeView.tsx index a56c6b4ac..dbb117e50 100644 --- a/frontend/src/runtime-v2/views/RuntimeView.tsx +++ b/frontend/src/runtime-v2/views/RuntimeView.tsx @@ -24,7 +24,7 @@ const CONFIG_LABELS: Record = { "oauth2_shared_key_bridge_enabled": "OAuth2 shared key bridge", "profile": "MCP host profile", "host_budget_secs": "Host request budget", - "initial_job_handoff_secs": "Initial job handoff wait", + "initial_job_handoff_secs": "Legacy handoff hint (unused)", "max_sync_wait_secs": "Maximum synchronous wait", "continuation_wait_secs": "Continuation wait" }; diff --git a/frontend/src/runtime_i18n.ts b/frontend/src/runtime_i18n.ts index 13e107d95..ab70b01f1 100644 --- a/frontend/src/runtime_i18n.ts +++ b/frontend/src/runtime_i18n.ts @@ -84,7 +84,7 @@ export const RUNTIME_ZH_TEXT: Record = { "OAuth2 shared key bridge": "OAuth2 共享密钥桥接", "MCP host profile": "MCP 主机配置", "Host request budget": "主机请求预算", - "Initial job handoff wait": "首次任务交接等待", + "Legacy handoff hint (unused)": "旧版交接提示值(未参与执行)", "Maximum synchronous wait": "同步等待上限", "Continuation wait": "后续等待时长", diff --git a/frontend/test-v2/runtime-usability.test.tsx b/frontend/test-v2/runtime-usability.test.tsx index ade8ed496..ad22e6428 100644 --- a/frontend/test-v2/runtime-usability.test.tsx +++ b/frontend/test-v2/runtime-usability.test.tsx @@ -78,6 +78,8 @@ describe("Runtime usability", () => { expect(screen.getByText("Server configuration")).toBeTruthy(); expect(screen.getByText("Shared key authentication")).toBeTruthy(); expect(screen.getByText("60 seconds")).toBeTruthy(); + expect(screen.getByText("Legacy handoff hint (unused)")).toBeTruthy(); + expect(screen.queryByText("Initial job handoff wait")).toBeNull(); expect(screen.getByText("Disabled")).toBeTruthy(); }); }); diff --git a/src/mcp_host.rs b/src/mcp_host.rs index 83df2c218..ece404b71 100644 --- a/src/mcp_host.rs +++ b/src/mcp_host.rs @@ -102,6 +102,9 @@ impl McpHostConfig { pub(crate) struct McpHostRuntimePolicy { pub(crate) profile: McpHostProfile, pub(crate) host_budget_secs: u64, + /// Legacy diagnostic-only projection. No execution path reads this field; + /// synchronous handoff uses max_sync_wait_secs instead. Retained for + /// existing runtime_status consumers, not a new operator tuning knob. pub(crate) initial_job_handoff_secs: u64, pub(crate) max_sync_wait_secs: u64, pub(crate) continuation_wait_secs: u64, diff --git a/src/project_entry_openai_tunnel.rs b/src/project_entry_openai_tunnel.rs index e70a3c221..6faa0b8ed 100644 --- a/src/project_entry_openai_tunnel.rs +++ b/src/project_entry_openai_tunnel.rs @@ -4,6 +4,7 @@ use super::{ }; use sha2::{Digest, Sha256}; use std::{ + future::Future, path::{Path, PathBuf}, time::{Duration, Instant}, }; @@ -218,6 +219,27 @@ pub(super) async fn start_openai_tunnel( local_token: &str, deadline: Instant, ) -> Result { + start_openai_tunnel_with_stop( + prerequisites, + mcp_url, + local_token, + deadline, + std::future::pending(), + ) + .await? + .ok_or_else(|| tunnel_error(Error::Uncertain)) +} + +/// Cancellation must settle an already-launched owner rather than drop it. +/// None means shutdown was observed before readiness; uncertainty stays fenced. +pub(super) async fn start_openai_tunnel_with_stop( + prerequisites: &OpenAiTunnelPrerequisites, + mcp_url: &str, + local_token: &str, + deadline: Instant, + stop: impl Future, +) -> Result, ProductError> { + tokio::pin!(stop); let mcp_url = local_mcp_url(mcp_url)?; let probe = reqwest::Client::builder() .no_proxy() @@ -226,12 +248,14 @@ pub(super) async fn start_openai_tunnel( .timeout(Duration::from_secs(2)) .build() .map_err(|_| tunnel_error(Error::Configuration))?; - let local_ready = tokio::time::timeout_at( - deadline.into(), - super::regular_tunnel_service::probe_local_mcp(&probe, &mcp_url, local_token), - ) - .await - .unwrap_or(false); + let local_ready = tokio::select! { + biased; + _ = &mut stop => return Ok(None), + result = tokio::time::timeout_at( + deadline.into(), + super::regular_tunnel_service::probe_local_mcp(&probe, &mcp_url, local_token), + ) => result.unwrap_or(false), + }; if !local_ready { return Err(ProductError::new( "local_mcp_unavailable", @@ -239,21 +263,40 @@ pub(super) async fn start_openai_tunnel( Some("Start the local WebCodex Server and verify its credential."), )); } - let mut tunnel = OpenAiTunnel::launch(prerequisites, &mcp_url, local_token)?; - let ready = async { - loop { - if tunnel.health.is_ready() { - return Ok(()); - } - tokio::select! { - result = tunnel.wait_for_exit() => return result, - _ = tokio::time::sleep(Duration::from_millis(20)) => {}, + let tunnel = OpenAiTunnel::launch(prerequisites, &mcp_url, local_token)?; + finish_openai_tunnel_startup(tunnel, deadline, &mut stop).await +} + +async fn finish_openai_tunnel_startup( + mut tunnel: OpenAiTunnel, + deadline: Instant, + stop: impl Future, +) -> Result, ProductError> { + let readiness = { + let ready = async { + loop { + if tunnel.health.is_ready() { + return Ok(()); + } + tokio::select! { + result = tunnel.wait_for_exit() => return result, + _ = tokio::time::sleep(Duration::from_millis(20)) => {}, + } } + }; + tokio::select! { + biased; + _ = stop => None, + result = tokio::time::timeout_at(deadline.into(), ready) => Some(result), } }; - match tokio::time::timeout_at(deadline.into(), ready).await { - Ok(Ok(())) => Ok(tunnel), - result => { + match readiness { + None => { + tunnel.stop_with_outcome().await?; + Ok(None) + } + Some(Ok(Ok(()))) => Ok(Some(tunnel)), + Some(result) => { tunnel.stop().await; Err(result .unwrap_or_else(|_| Err(tunnel_error(Error::Transport))) diff --git a/src/project_entry_openai_tunnel_tests.rs b/src/project_entry_openai_tunnel_tests.rs index 65ffa9276..9e256dfe3 100644 --- a/src/project_entry_openai_tunnel_tests.rs +++ b/src/project_entry_openai_tunnel_tests.rs @@ -78,8 +78,8 @@ fn diagnostics_do_not_contain_authority_material() { } // Dedicated adapter lifecycle fixture. The crate's domain tests own wire behavior. -async fn idle_tunnel(root: &Path) -> OpenAiTunnel { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; +async fn pending_tunnel(root: &Path) -> (OpenAiTunnel, tokio::net::TcpStream) { + use tokio::io::AsyncReadExt; let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let client = TunnelClient::new( ControlPlaneIdentity::new( @@ -103,34 +103,138 @@ async fn idle_tunnel(root: &Path) -> OpenAiTunnel { let task = tokio::spawn(client.run(async { let _ = rx.await; })); - let mut tunnel = OpenAiTunnel { + let tunnel = OpenAiTunnel { task: Some(task), stop: Some(tx), health, guard, terminal: None, }; - let observed = tokio::time::timeout(Duration::from_secs(5), async { + let socket = tokio::time::timeout(Duration::from_secs(5), async { let (mut socket, _) = listener.accept().await.unwrap(); let mut buf = [0; 8192]; let n = socket.read(&mut buf).await.unwrap(); assert!(n > 0); socket - .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") - .await - .unwrap(); + }) + .await + .expect("fixture did not begin the first control-plane poll"); + assert!(!tunnel.health.is_ready()); + (tunnel, socket) +} + +async fn idle_tunnel(root: &Path) -> OpenAiTunnel { + use tokio::io::AsyncWriteExt; + let (mut tunnel, mut socket) = pending_tunnel(root).await; + socket + .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + if tokio::time::timeout(Duration::from_secs(5), async { while !tunnel.health.is_ready() { tokio::task::yield_now().await; } }) - .await; - if observed.is_err() { + .await + .is_err() + { tunnel.stop().await; panic!("fixture did not reach poll readiness"); } tunnel } +#[tokio::test] +async fn parent_shutdown_during_initial_poll_settles_startup_owner() { + let temp = tempfile::tempdir().unwrap(); + // Hold the first real control-plane request without a response. No command + // has been admitted, and readiness cannot win this cancellation race. + let (tunnel, _socket) = pending_tunnel(temp.path()).await; + let marker = tunnel.guard.clone(); + let abort = tunnel.task.as_ref().unwrap().abort_handle(); + let (polled_tx, polled_rx) = oneshot::channel(); + let (stop_tx, stop_rx) = oneshot::channel(); + let startup = tokio::spawn(finish_openai_tunnel_startup( + tunnel, + Instant::now() + Duration::from_secs(60), + async { + polled_tx.send(()).unwrap(); + let _ = stop_rx.await; + }, + )); + polled_rx.await.unwrap(); + stop_tx.send(()).unwrap(); + let outcome = tokio::time::timeout(Duration::from_secs(2), startup) + .await + .expect("parent shutdown must not await initial poll readiness") + .unwrap() + .unwrap(); + assert!(outcome.is_none()); + assert!(abort.is_finished(), "shutdown must observe task completion"); + assert!( + !marker.exists(), + "clean startup shutdown must retire its fence" + ); + assert!(acquire_guard(temp.path(), "fixture").is_ok()); +} + +#[tokio::test] +async fn startup_shutdown_retains_fence_when_task_cannot_settle_cleanly() { + let temp = tempfile::tempdir().unwrap(); + let guard = acquire_guard(temp.path(), "uncertain-startup").unwrap(); + let (stop, rx) = oneshot::channel(); + let task = tokio::spawn(async { + let _ = rx.await; + Err(Error::Uncertain) + }); + let tunnel = OpenAiTunnel { + task: Some(task), + stop: Some(stop), + health: Health::default(), + guard: guard.clone(), + terminal: None, + }; + let result = finish_openai_tunnel_startup( + tunnel, + Instant::now() + Duration::from_secs(60), + std::future::ready(()), + ) + .await; + assert_eq!(result.err().unwrap().code, "tunnel_restart_uncertain"); + assert!(guard.exists(), "uncertain startup must stay fenced"); +} + +#[tokio::test] +async fn startup_readiness_transfers_owner_until_observed_shutdown() { + let temp = tempfile::tempdir().unwrap(); + let tunnel = idle_tunnel(temp.path()).await; + let marker = tunnel.guard.clone(); + let mut tunnel = finish_openai_tunnel_startup( + tunnel, + Instant::now() + Duration::from_secs(5), + std::future::pending(), + ) + .await + .unwrap() + .expect("a ready connection should transfer its owner"); + assert!(marker.exists()); + tunnel.stop_with_outcome().await.unwrap(); + assert!(!marker.exists()); +} + +#[tokio::test] +async fn startup_timeout_settles_idle_owner_before_reporting_failure() { + let temp = tempfile::tempdir().unwrap(); + let (tunnel, _socket) = pending_tunnel(temp.path()).await; + let marker = tunnel.guard.clone(); + let result = finish_openai_tunnel_startup(tunnel, Instant::now(), std::future::pending()).await; + assert_eq!( + result.err().unwrap().code, + "tunnel_control_plane_unreachable" + ); + assert!(!marker.exists()); +} + #[tokio::test] async fn observed_idle_shutdown_clears_restart_marker() { let temp = tempfile::tempdir().unwrap(); diff --git a/src/project_entry_regular_tunnel.rs b/src/project_entry_regular_tunnel.rs index f42d29dad..b99dcd379 100644 --- a/src/project_entry_regular_tunnel.rs +++ b/src/project_entry_regular_tunnel.rs @@ -1,5 +1,5 @@ use super::client_handoff_service::{copy_text_to_clipboard, mcp_url, ClipboardCopyOutcome}; -use super::openai_tunnel_service::{prepare_openai_tunnel, start_openai_tunnel}; +use super::openai_tunnel_service::{prepare_openai_tunnel, start_openai_tunnel_with_stop}; use super::setup_service::create_private_dir; use super::ProductError; use serde_json::{json, Value}; @@ -59,22 +59,32 @@ async fn run_regular_server_tunnel_inner( ) -> Result<(), ProductError> { let local_server_url = validate_local_server_url(&options.local_server_url)?; let session = RegularTunnelSession::create(&options.runtime_parent)?; + // The parent lease covers preparation and connection startup too. Waiting + // until readiness to observe EOF can leave the owner fenced on Windows exit. + let stop = async { + tokio::select! { + _ = stop => {}, + _ = wait_for_regular_tunnel_stop_signal(options.stop_on_stdin_eof) => {}, + } + }; tokio::pin!(stop); let prerequisites = tokio::select! { + biased; _ = &mut stop => return Ok(()), result = prepare_openai_tunnel() => result?, }; let deadline = Instant::now() + REGULAR_TUNNEL_STARTUP_TIMEOUT; let mcp_endpoint = mcp_url(&local_server_url); - let start = start_openai_tunnel( + let Some(mut tunnel) = start_openai_tunnel_with_stop( &prerequisites, &mcp_endpoint, &options.bootstrap_token, deadline, - ); - let mut tunnel = tokio::select! { - _ = &mut stop => return Ok(()), - result = start => result?, + &mut stop, + ) + .await? + else { + return Ok(()); }; // Managed profiles use explicit Copy ID controls; concurrent starts must not @@ -102,7 +112,6 @@ async fn run_regular_server_tunnel_inner( .then_some(readiness_path) .filter(|path| path.is_file()); let outcome = tokio::select! { - _ = wait_for_regular_tunnel_stop_signal(options.stop_on_stdin_eof) => Ok(()), _ = &mut stop => Ok(()), result = tunnel.wait_for_exit() => result, result = report_regular_tunnel_health(&health, &local_mcp_url, &options.bootstrap_token, service_readiness.as_deref(), options.stop_on_stdin_eof) => result, @@ -336,6 +345,10 @@ fn tunnel_auth_error(message: &str) -> ProductError { ) } +#[cfg(test)] +#[path = "project_entry_regular_tunnel_startup_tests.rs"] +mod startup_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/src/project_entry_regular_tunnel_startup_tests.rs b/src/project_entry_regular_tunnel_startup_tests.rs new file mode 100644 index 000000000..95a4f6bff --- /dev/null +++ b/src/project_entry_regular_tunnel_startup_tests.rs @@ -0,0 +1,85 @@ +//! Parent-stdin lifecycle fixture. Only loopback probes and synthetic credentials. +use super::*; +use std::process::{Command, Stdio}; +use webcodex_process::{ManagedChild, SpawnOptions}; + +const CHILD: &str = + "project_entry::regular_tunnel_service::startup_tests::parent_eof_startup_child"; +const ROOT: &str = "WEBCODEX_REGULAR_TUNNEL_STARTUP_TEST_ROOT"; +const URL: &str = "WEBCODEX_REGULAR_TUNNEL_STARTUP_TEST_URL"; + +#[tokio::test] +#[ignore = "real-process lane: managed tunnel stdin EOF during startup"] +async fn regular_tunnel_real_process_parent_eof_during_local_probe() { + use tokio::io::AsyncReadExt; + let root = tempfile::tempdir().unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let output = std::fs::File::create(root.path().join("child.log")).unwrap(); + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args(["--exact", CHILD, "--ignored", "--nocapture"]) + .env(ROOT, root.path()) + .env(URL, format!("http://{}", listener.local_addr().unwrap())) + .env("HOME", root.path()) + .env("XDG_STATE_HOME", root.path().join("state")) + .env( + "CONTROL_PLANE_TUNNEL_ID", + "tunnel_0123456789abcdef0123456789abcdef", + ) + .env("CONTROL_PLANE_API_KEY", "fixture-control") + .stdin(Stdio::piped()) + .stdout(Stdio::from(output.try_clone().unwrap())) + .stderr(Stdio::from(output)); + let mut child = ManagedChild::spawn_with_options( + &mut command, + SpawnOptions { + windows_creation_flags: 0x08000000, // CREATE_NO_WINDOW, ignored on Unix. + ..SpawnOptions::default() + }, + ) + .unwrap(); + let _held_probe = tokio::time::timeout(Duration::from_secs(5), async { + let (mut socket, _) = listener.accept().await.unwrap(); + assert!(socket.read(&mut [0; 8192]).await.unwrap() > 0); + socket // Keep the local readiness probe pending; never contact OpenAI. + }) + .await + .expect("child did not begin local readiness probe"); + drop(child.child_mut().stdin.take()); + let deadline = Instant::now() + Duration::from_secs(5); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + if Instant::now() >= deadline { + child.terminate_tree().unwrap(); + panic!("parent EOF did not settle startup"); + } + tokio::time::sleep(Duration::from_millis(20)).await; + }; + assert!( + status.success(), + "{}", + std::fs::read_to_string(root.path().join("child.log")).unwrap() + ); + assert!(child.wait_tree_exit(Duration::from_secs(2)).unwrap()); + assert!(root.path().join("passed").is_file()); +} + +#[tokio::test] +#[ignore = "child fixture, invoked only by the managed startup lane"] +async fn parent_eof_startup_child() { + let Some(root) = std::env::var_os(ROOT).map(PathBuf::from) else { + return; + }; + let options = RegularServerTunnelOptions { + local_server_url: std::env::var(URL).unwrap(), + bootstrap_token: "fixture-local".into(), + runtime_parent: root.clone(), + stop_on_stdin_eof: true, + }; + run_regular_server_tunnel_with_stop(&options, std::future::pending()) + .await + .expect("startup EOF should be a clean stop, not a readiness failure"); + std::fs::write(root.join("passed"), "passed").unwrap(); +} diff --git a/src/tool_runtime/mcp_timing.rs b/src/tool_runtime/mcp_timing.rs index ce64a0016..87cd35111 100644 --- a/src/tool_runtime/mcp_timing.rs +++ b/src/tool_runtime/mcp_timing.rs @@ -90,6 +90,65 @@ mod tests { ); } + #[test] + fn host_timing_paths_are_separate_and_legacy_handoff_hint_is_not_authority() { + // The legacy initial_job_handoff_secs is still serialized for older + // runtime_status clients but must not control any execution wait. + for legacy_hint in [1, 99] { + let mut policy = host_code_mode_policy(); + policy.initial_job_handoff_secs = legacy_hint; + policy.max_sync_wait_secs = 3; + policy.continuation_wait_secs = 7; + + assert_eq!( + return_timing_policy(SessionTransport::Mcp, policy).max_handoff_secs(), + Some(3) + ); + + let mut run = ToolCall::from_tool_name( + "run_shell", + serde_json::json!({"project":"demo","command":"true","sync_wait_secs":55}), + ) + .unwrap(); + normalize_structured_for_transport(&mut run, SessionTransport::Mcp, policy); + match run { + ToolCall::RunShell { sync_wait_secs, .. } => assert_eq!(sync_wait_secs, Some(3)), + _ => unreachable!(), + } + + let mut observe = ToolCall::from_tool_name( + "observe_jobs", + serde_json::json!({"items":[{"job_id":"job"}],"wait_secs":55}), + ) + .unwrap(); + normalize_observation_call_timing(&mut observe, SessionTransport::Mcp, policy); + match observe { + ToolCall::ObserveJobs { wait_secs, .. } => assert_eq!(wait_secs, Some(7)), + _ => unreachable!(), + } + + let mut ready = ToolCall::from_tool_name( + "wait_for_job_readiness", + serde_json::json!({"job_ids":["job"],"mode":"any","wait_secs":45}), + ) + .unwrap(); + normalize_observation_call_timing(&mut ready, SessionTransport::Mcp, policy); + match ready { + ToolCall::WaitForJobReadiness { wait_secs, .. } => assert_eq!(wait_secs, 45), + _ => unreachable!(), + } + + let mut result = ToolResult::ok(serde_json::json!({ + "continuation": { + "tool": "observe_jobs", + "arguments": {"wait_secs":55,"items":[{"job_id":"job"}]} + } + })); + normalize_result_timing(&mut result, SessionTransport::Mcp, policy); + assert_eq!(result.output["continuation"]["arguments"]["wait_secs"], 7); + } + } + #[test] fn mcp_structured_execution_defaults_and_clamps_sync_wait() { let policy = host_code_mode_policy(); diff --git a/src/tool_runtime/tests/jobs.rs b/src/tool_runtime/tests/jobs.rs index 459e617b4..4d6e7ccc2 100644 --- a/src/tool_runtime/tests/jobs.rs +++ b/src/tool_runtime/tests/jobs.rs @@ -3423,4 +3423,55 @@ fn node_native_test_evidence_requires_complete_tap_and_agrees_with_process_resul .unwrap(); assert!(!contradiction["passed"].as_bool().unwrap()); assert!(contradiction["tests_run_count"].is_null()); + + let with_plain_diagnostics = output.replacen( + "\n1..1\n# tests", + "\n# pass phase complete\n# skipped optional setup\n1..1\n# tests", + 1, + ); + let accepted = validation_job_projection_with_policy( + Some("node:tap:test"), + Some("test"), + "completed", + Some(0), + &with_plain_diagnostics, + "", + false, + None, + Some(1), + Some(true), + None, + ) + .unwrap(); + assert_eq!(accepted["passed"], true); + assert_eq!(accepted["tests_run_count"], 1); + + // A canonical exit cannot make malformed TAP accounting authoritative, + // including when the caller does not require a positive test count. + for forged in [ + output.replace("\n1..1\n# tests", "\n 1..1\n# tests"), + output.replace("\n1..1\n# tests", "\n# pass 1\n1..1\n# tests"), + output.replace("# Subtest: works\nok 1 - works\n1..1", "1..0"), + ] { + for require_tests in [Some(true), Some(false)] { + let unproven = validation_job_projection_with_policy( + Some("node:tap:test"), + Some("test"), + "completed", + Some(0), + &forged, + "", + false, + None, + None, + require_tests, + None, + ) + .unwrap(); + assert_eq!(unproven["passed"], false); + assert_eq!(unproven["tests_detected"], false); + assert!(unproven["tests_run_count"].is_null()); + assert!(unproven["tests_passed"].is_null()); + } + } }