diff --git a/crates/webcodex-runner/src/webcodex_runner/lsp/tests.rs b/crates/webcodex-runner/src/webcodex_runner/lsp/tests.rs index 2d642114..0c7d6747 100644 --- a/crates/webcodex-runner/src/webcodex_runner/lsp/tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/lsp/tests.rs @@ -1541,7 +1541,7 @@ fn lsp_initialize_timeout_cleanup_uses_configured_shutdown_budget() { initialize_timeout: Duration::from_millis(80), shutdown_timeout, idle_ttl: Duration::from_secs(60), - background_reaper: true, + background_reaper: false, }); let started = Instant::now(); @@ -1550,14 +1550,16 @@ fn lsp_initialize_timeout_cleanup_uses_configured_shutdown_budget() { .unwrap_err(); let elapsed = started.elapsed(); assert!( - matches!( - error, - LspError::RestartExhausted(_) | LspError::InitializeFailed(_) - ), + matches!(error, LspError::RestartExhausted(_)), "unexpected error: {error:?}" ); - // Two attempts each: initialize_timeout + shutdown_timeout, plus slack. - // Must stay well below using the multi-second DEFAULT_SHUTDOWN_TIMEOUT. + // The supervisor consumes its one restart after the first initialize + // failure. The child-owned start marker is not an authoritative attempt + // counter: on Windows a newly spawned process can remain unscheduled past + // this intentionally tiny initialize deadline and be killed before main() + // writes the marker. RestartExhausted proves the second attempt was + // consumed; this test owns only the configured cleanup-budget invariant. + // Both attempts must stay well below using the multi-second default. assert!( elapsed < Duration::from_secs(2), "initialize cleanup used an oversized budget: {elapsed:?}" @@ -1566,12 +1568,6 @@ fn lsp_initialize_timeout_cleanup_uses_configured_shutdown_budget() { elapsed < DEFAULT_SHUTDOWN_TIMEOUT.saturating_mul(2), "cleanup appears to use DEFAULT_SHUTDOWN_TIMEOUT: {elapsed:?}" ); - let starts = fs::read_to_string(&marker) - .unwrap_or_default() - .lines() - .filter(|line| line.starts_with("start:")) - .count(); - assert_eq!(starts, 2); for line in fs::read_to_string(&marker).unwrap_or_default().lines() { if let Some(rest) = line.strip_prefix("start:") { if let Some(pid) = rest.split(':').next().and_then(|p| p.parse::().ok()) { diff --git a/src/mcp_tests/http_transport.rs b/src/mcp_tests/http_transport.rs index 1fbbe0f5..ae4ae5d4 100644 --- a/src/mcp_tests/http_transport.rs +++ b/src/mcp_tests/http_transport.rs @@ -694,8 +694,31 @@ async fn http_mcp_accepts_chatgpt_2025_11_25_protocol_header() { assert!(body["result"].get("resultType").is_none()); } -#[tokio::test] -async fn http_mcp_2026_request_scoped_ack_redelivers_until_durable_resolution() { +#[test] +fn http_mcp_2026_request_scoped_ack_redelivers_until_durable_resolution() { + // This integration fixture intentionally keeps several large MCP response + // trees alive across many await points. Run the test harness itself on an + // explicit stack so CI/libtest thread-stack variance cannot abort the + // process; production request handling is unchanged and each HTTP request + // still executes through the normal Server runtime path. + std::thread::Builder::new() + .name("mcp-request-scoped-ack-test".to_string()) + .stack_size(8 * 1024 * 1024) + .spawn(|| { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build ACK integration test runtime"); + runtime.block_on( + http_mcp_2026_request_scoped_ack_redelivers_until_durable_resolution_body(), + ); + }) + .expect("spawn ACK integration test thread") + .join() + .expect("ACK integration test thread panicked"); +} + +async fn http_mcp_2026_request_scoped_ack_redelivers_until_durable_resolution_body() { let config = test_config(Some("secret")); let (_tmp, db) = test_db(); let runtime = Arc::new(test_runtime_with_surface(ModelSurface::FullOperatorRuntime)); diff --git a/src/runtime_http_tests.rs b/src/runtime_http_tests.rs index b553f302..7e7e0ff1 100644 --- a/src/runtime_http_tests.rs +++ b/src/runtime_http_tests.rs @@ -2112,10 +2112,12 @@ async fn oauth2_tools_call_scope_matrix() { async fn session_tools_oauth_scope_policy() { let (_tmp, service, tokens) = phase2_oauth_service_with_scopes(&[ crate::auth::SCOPE_RUNTIME_READ, + crate::auth::SCOPE_SESSION_COLLABORATE, crate::auth::SCOPE_PROJECT_READ, ]); let runtime_read = &tokens[0]; - let project_read = &tokens[1]; + let session_collaborate = &tokens[1]; + let project_read = &tokens[2]; let (status, body, _) = oauth_tools_call( &service, @@ -2127,33 +2129,44 @@ async fn session_tools_oauth_scope_policy() { assert_eq!(status, StatusCode::OK, "body: {body}"); let session_id = body["output"]["session_id"].as_str().unwrap(); - for (tool, params) in [ - ("session_summary", json!({"session_id": session_id})), - ( - "post_session_message", - json!({"session_id": session_id, "kind": "note", "message": "oauth metadata"}), - ), - ] { - let (status, body, _) = oauth_tools_call(&service, runtime_read, tool, params).await; - assert_eq!(status, StatusCode::OK, "{tool}: {body}"); - } + let (status, body, _) = oauth_tools_call( + &service, + runtime_read, + "session_summary", + json!({"session_id": session_id}), + ) + .await; + assert_eq!(status, StatusCode::OK, "session_summary: {body}"); + let (status, body, _) = oauth_tools_call( + &service, + session_collaborate, + "post_session_message", + json!({"session_id": session_id, "kind": "note", "message": "oauth metadata"}), + ) + .await; + assert_eq!(status, StatusCode::OK, "post_session_message: {body}"); - for (tool, params) in [ - ("start_session", json!({})), - ( - "post_session_message", - json!({"session_id": "wc_sess_missing", "kind": "note", "message": "denied"}), - ), - ] { - let (status, body, challenge) = - oauth_tools_call(&service, project_read, tool, params).await; - assert_oauth_scope_rejected( - status, - &body, - challenge.as_deref(), - Some(crate::auth::SCOPE_RUNTIME_READ), - ); - } + let (status, body, challenge) = + oauth_tools_call(&service, project_read, "start_session", json!({})).await; + assert_oauth_scope_rejected( + status, + &body, + challenge.as_deref(), + Some(crate::auth::SCOPE_RUNTIME_READ), + ); + let (status, body, challenge) = oauth_tools_call( + &service, + runtime_read, + "post_session_message", + json!({"session_id": "wc_sess_missing", "kind": "note", "message": "denied"}), + ) + .await; + assert_oauth_scope_rejected( + status, + &body, + challenge.as_deref(), + Some(crate::auth::SCOPE_SESSION_COLLABORATE), + ); } #[tokio::test] diff --git a/src/tool_runtime/registry/output_schemas/coding_tasks.rs b/src/tool_runtime/registry/output_schemas/coding_tasks.rs index 464e5dd1..27012974 100644 --- a/src/tool_runtime/registry/output_schemas/coding_tasks.rs +++ b/src/tool_runtime/registry/output_schemas/coding_tasks.rs @@ -446,12 +446,14 @@ fn startup_workflow_schema() -> Value { "session_context_ack": {"type": "string", "maxLength": 640}, "session_recording": {"type": "string", "maxLength": 720}, "session_message_ack": {"type": "string", "maxLength": 720}, + "session_message_resolution": {"type": "string", "maxLength": 480}, "normal_closeout": {"type": "string", "maxLength": 480} }, "required": [ "session_context_ack", "session_recording", "session_message_ack", + "session_message_resolution", "normal_closeout" ], "additionalProperties": false diff --git a/src/tool_runtime/startup_brief.rs b/src/tool_runtime/startup_brief.rs index 3aa6da1b..d7218850 100644 --- a/src/tool_runtime/startup_brief.rs +++ b/src/tool_runtime/startup_brief.rs @@ -56,9 +56,9 @@ pub(crate) fn builtin_coding_workflow_projection() -> Value { "role_selection": "Apply a named role only when the task says so; role guidance creates no Session mode or authority.", "model_protocol": { "session_context_ack": "Schema has ack_session_context_revision: copy latest returned session_context_revision exactly; never increment/derive. No returned revision: keep ACK. If unavailable/unknown, omit. Missing/stale ACK is nonblocking.", - "session_recording": "After work_on_project creates or continues an execution Workflow Session, when a later WebCodex schema exposes recording_session_id, keep passing that execution/recording Session as recording_session_id. It is recorder provenance/context only: a concrete business session_id may target a different Session, and recording_session_id grants no business authority.", - "session_message_ack": "When session_attention returns open requires_ack guidance still present in the model context, keep echoing those message ids in ack_session_message_ids on later calls. This ACK is request-scoped model-context proof only: it does not resolve messages, grant authority, or gate execution; missing/stale ACK remains nonblocking.", - "session_message_resolution": "After a non-todo Session message is already handled, a stateless MCP schema may expose session_message_resolution. Piggyback {message_id, resolution} on the next ordinary WebCodex call together with recording_session_id instead of making a separate resolve call. For requires_ack guidance, echo the same id in ack_session_message_ids on that request. The target is always the exact recording Session; the wrapper field is removed before concrete tool parsing. Do not use it to predict the success of the current tool call. Todo completion still uses complete_session_message.", + "session_recording": "When work_on_project creates or continues a Workflow Session, pass it as recording_session_id. It is recorder provenance/context only: business session_id may target a different Session; recording_session_id grants no business authority.", + "session_message_ack": "When session_attention shows open requires_ack guidance still in context, echo ids in ack_session_message_ids. This is request-scoped model-context proof only: it does not resolve messages, grant authority, or gate execution; missing/stale ACK remains nonblocking.", + "session_message_resolution": "For a handled non-todo message, put session_message_resolution {message_id,resolution} on the next ordinary WebCodex call with recording_session_id; for requires_ack also echo ack_session_message_ids. It targets only that Session. Do not use it to predict the main call. Todos use complete_session_message.", "normal_closeout": "Normal success: finish_coding_task(summary_only=true); full closeout only for unresolved validation/evidence or handoff/debug detail." }, "roles": { diff --git a/src/tool_runtime/tests/collaboration.rs b/src/tool_runtime/tests/collaboration.rs index 543c75d8..682be3b4 100644 --- a/src/tool_runtime/tests/collaboration.rs +++ b/src/tool_runtime/tests/collaboration.rs @@ -1532,6 +1532,7 @@ async fn project_scoped_session_authority_rejects_recycled_project_identity() { "recycled-authority-a", &[ crate::auth::SCOPE_RUNTIME_READ, + crate::auth::SCOPE_SESSION_COLLABORATE, crate::auth::SCOPE_PROJECT_READ, crate::auth::SCOPE_PROJECT_WRITE, crate::auth::SCOPE_JOB_RUN, diff --git a/src/tool_runtime/tests/support/auth.rs b/src/tool_runtime/tests/support/auth.rs index 7dc9e700..a6466396 100644 --- a/src/tool_runtime/tests/support/auth.rs +++ b/src/tool_runtime/tests/support/auth.rs @@ -41,6 +41,7 @@ pub(in crate::tool_runtime::tests) fn shared_key_auth_context( role: Some("shared-key".to_string()), scopes: vec![ crate::auth::SCOPE_RUNTIME_READ.to_string(), + crate::auth::SCOPE_SESSION_COLLABORATE.to_string(), crate::auth::SCOPE_PROJECT_READ.to_string(), crate::auth::SCOPE_PROJECT_WRITE.to_string(), crate::auth::SCOPE_JOB_RUN.to_string(),