From 33a761bf4fa7c749026deb5a26f91b3efb10149f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 22 Aug 2026 20:16:04 +0200 Subject: [PATCH] Let model transports own attempt deadlines --- crates/ghostlight-dungeon/src/model.rs | 48 ++++++++++++++++++++- docs/architecture/ghostlight-dungeon-mvp.md | 8 ++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/crates/ghostlight-dungeon/src/model.rs b/crates/ghostlight-dungeon/src/model.rs index a446d71..e3374df 100644 --- a/crates/ghostlight-dungeon/src/model.rs +++ b/crates/ghostlight-dungeon/src/model.rs @@ -117,13 +117,16 @@ pub trait ModelPort: Send + Sync { }) } fn provider(&self) -> &'static str; + fn attempt_timeout(&self, _request: &ModelStageRequest) -> std::time::Duration { + std::time::Duration::from_secs(45) + } } pub async fn run_validated_stage( port: &dyn ModelPort, request: &ModelStageRequest, ) -> Result { - run_validated_stage_with_timeout(port, request, std::time::Duration::from_secs(45)).await + run_validated_stage_with_timeout(port, request, port.attempt_timeout(request)).await } pub async fn run_validated_stage_with_timeout( @@ -304,6 +307,7 @@ mod tests { } struct NeverReturns; + struct ShortDeadlineNeverReturns; struct CorrectionAware { calls: AtomicUsize, } @@ -320,6 +324,21 @@ mod tests { } } + #[async_trait] + impl ModelPort for ShortDeadlineNeverReturns { + async fn run(&self, _: &ModelStageRequest) -> Result { + std::future::pending().await + } + + fn provider(&self) -> &'static str { + "slow-fixture" + } + + fn attempt_timeout(&self, _: &ModelStageRequest) -> std::time::Duration { + std::time::Duration::from_millis(5) + } + } + #[async_trait] impl ModelPort for InvalidThenValid { async fn run(&self, _: &ModelStageRequest) -> Result { @@ -446,6 +465,24 @@ mod tests { assert!(error.to_string().contains("timed out")); } + #[tokio::test] + async fn validated_stage_uses_the_transport_owned_attempt_deadline() { + let request = ModelStageRequest { + stage: "transport-timeout-stage".into(), + model: "fixture".into(), + snapshot_binding: "campaign:one:revision:4".into(), + lived_stream: "fixture".into(), + output_schema: None, + source_receipt_ids: vec![], + temperature: None, + max_output_tokens: None, + }; + let error = run_validated_stage(&ShortDeadlineNeverReturns, &request) + .await + .unwrap_err(); + assert!(error.to_string().contains("timed out")); + } + #[tokio::test] async fn receipt_hashes_the_physical_provider_model_not_the_logical_class() { let request = ModelStageRequest { @@ -563,6 +600,11 @@ mod tests { assert_eq!(body["plugins"][0]["id"], "response-healing"); assert_eq!(body["temperature"].as_f64(), Some(0.0)); assert!(body.get("thinking").is_none()); + let port = OpenRouterPort::new("test-key".into(), "stealth/ox-alpha", "stealth/ox-alpha"); + assert_eq!( + port.attempt_timeout(&request), + std::time::Duration::from_secs(120) + ); let mut narrative = request.clone(); narrative.output_schema = None; @@ -882,6 +924,10 @@ impl ModelPort for OpenRouterPort { fn provider(&self) -> &'static str { "openrouter" } + + fn attempt_timeout(&self, _request: &ModelStageRequest) -> std::time::Duration { + std::time::Duration::from_secs(120) + } } fn resolve_model(request: &ModelStageRequest, fast_model: &str, capable_model: &str) -> String { diff --git a/docs/architecture/ghostlight-dungeon-mvp.md b/docs/architecture/ghostlight-dungeon-mvp.md index 14ddaae..1b492d9 100644 --- a/docs/architecture/ghostlight-dungeon-mvp.md +++ b/docs/architecture/ghostlight-dungeon-mvp.md @@ -733,6 +733,14 @@ Structured stages request JSON Output and then validate locally. Empty or malformed output gets one retry against the same snapshot. A second failure, timeout, retrieval outage, or stale result produces no mutation. +The provider port owns its per-attempt transport deadline because queueing and +generation throughput are properties of the selected transport, not fictional +state or stage semantics. The default and DeepSeek deadline is 45 seconds; the +OpenRouter test port allows 120 seconds so a congested route remains slow +instead of being misclassified as invalid. Stage orchestration still owns the +same-snapshot retry and local validation, and an expired attempt still produces +no receipt or mutation. + Prompt projection follows the authority boundary instead of shipping one large state packet to every stage: