1use chrono::Utc;
5use lean_ctx_protocol::{
6 AgentId, ProjectId, SessionId, TaskComplexity, TaskEnvelopeV1, TaskId, TaskProfileV1, TenantId,
7 TraceId,
8};
9use std::cell::RefCell;
10use std::collections::HashMap;
11use std::sync::{Arc, Mutex, OnceLock};
12
13pub type TaskProfileLocal = TaskProfileV1;
14
15thread_local! {
16 static CURRENT: RefCell<Option<TaskEnvelopeV1>> = const { RefCell::new(None) };
17}
18
19tokio::task_local! {
20 static SCOPED: Option<TaskEnvelopeV1>;
22}
23
24type SessionLineage = (String, String);
26type SessionLineages = Arc<Mutex<HashMap<(ProjectId, Option<TenantId>, String), SessionLineage>>>;
27static SESSION_LINEAGES: OnceLock<SessionLineages> = OnceLock::new();
28
29#[derive(Debug, Clone, PartialEq, Eq, Hash)]
31pub struct AdmittedTaskIdentity {
32 pub(crate) project_id: ProjectId,
33 pub(crate) tenant_id: TenantId,
34}
35
36#[derive(Debug, Clone, Default)]
37pub struct TaskSpine {
39 pub parent_id: Option<String>,
41 pub trace_id: String,
43}
44
45impl TaskSpine {
46 pub fn create_envelope(query: &str, session_id: &str, agent_id: &str) -> TaskEnvelopeV1 {
47 Self::create_envelope_in_project(query, session_id, agent_id, None)
48 }
49
50 pub(crate) fn create_envelope_in_project(
53 query: &str,
54 session_id: &str,
55 agent_id: &str,
56 project_root: Option<&str>,
57 ) -> TaskEnvelopeV1 {
58 Self::create_envelope_with_identity(query, session_id, agent_id, project_root, None)
59 }
60
61 pub(crate) fn create_envelope_with_identity(
62 query: &str,
63 session_id: &str,
64 agent_id: &str,
65 project_root: Option<&str>,
66 identity: Option<&AdmittedTaskIdentity>,
67 ) -> TaskEnvelopeV1 {
68 let project_id = identity
69 .map(|identity| identity.project_id.clone())
70 .or_else(|| project_root.and_then(|project| ProjectId::try_from(project).ok()))
71 .unwrap_or_else(|| {
72 ProjectId::try_from("unknown-project").expect("fallback project id is valid")
73 });
74 let task_id = TaskId::try_from(format!("mcp-task-{}", uuid::Uuid::new_v4()))
75 .expect("generated task id is valid");
76 let initial_trace_id = format!("trace-{}", uuid::Uuid::new_v4());
77 let tenant_id = identity.map(|identity| identity.tenant_id.clone());
78 let spine = Self::for_session(
79 &project_id,
80 tenant_id.as_ref(),
81 session_id,
82 task_id.as_str(),
83 &initial_trace_id,
84 );
85 let envelope = TaskEnvelopeV1 {
86 schema_version: TaskEnvelopeV1::SCHEMA_VERSION,
87 task_id,
88 trace_id: TraceId::try_from(spine.trace_id.clone())
89 .expect("generated trace id is valid"),
90 project_id,
91 session_id: SessionId::try_from(session_id.to_owned())
92 .expect("MCP session id is valid"),
93 agent_id: AgentId::try_from(agent_id.to_owned()).expect("MCP agent id is valid"),
94 complexity: TaskComplexity::Unknown,
95 created_at: Utc::now().to_rfc3339(),
96 parent_task_id: spine
97 .parent_id
98 .clone()
99 .map(TaskId::try_from)
100 .transpose()
101 .expect("stored parent task id is valid"),
102 tenant_id,
103 intent: (!query.trim().is_empty()).then(|| query.to_owned()),
104 task_class: None,
105 risk_class: None,
106 quality_requirement_milli: None,
107 cost_budget_micros: None,
108 latency_budget_ms: None,
109 data_classification: None,
110 region_policy_ref: None,
111 model_policy_ref: None,
112 context_state_ref: None,
113 outcome_contract_ref: None,
114 extensions: Default::default(),
115 };
116 Self::set(envelope.clone());
117 envelope
118 }
119
120 fn for_session(
121 project_id: &ProjectId,
122 tenant_id: Option<&TenantId>,
123 session_id: &str,
124 task_id: &str,
125 initial_trace_id: &str,
126 ) -> Self {
127 if session_id.trim().is_empty() {
128 return Self {
129 parent_id: None,
130 trace_id: initial_trace_id.to_owned(),
131 };
132 }
133
134 let mut lineages = Self::session_lineages()
135 .lock()
136 .unwrap_or_else(std::sync::PoisonError::into_inner);
137 let (parent_id, trace_id) = match lineages.entry((
138 project_id.clone(),
139 tenant_id.cloned(),
140 session_id.to_owned(),
141 )) {
142 std::collections::hash_map::Entry::Occupied(parent) => {
143 let (root_task_id, trace_id) = parent.get();
144 (Some(root_task_id.clone()), trace_id.clone())
145 }
146 std::collections::hash_map::Entry::Vacant(session) => {
147 session.insert((task_id.to_owned(), initial_trace_id.to_owned()));
148 (None, initial_trace_id.to_owned())
149 }
150 };
151 Self {
152 parent_id,
153 trace_id,
154 }
155 }
156
157 fn session_lineages() -> &'static SessionLineages {
158 SESSION_LINEAGES.get_or_init(|| Arc::new(Mutex::new(HashMap::new())))
159 }
160
161 pub fn enrich_from_triage(envelope: &mut TaskEnvelopeV1, profile: &TaskProfileLocal) {
162 envelope.intent = Some(profile.primary_intent.clone());
163 envelope.task_class = Some(profile.task_class.clone());
164 envelope.complexity = profile.complexity;
165 envelope.risk_class = Some(profile.risk_signal);
166 Self::set(envelope.clone());
167 }
168
169 pub fn task_id() -> Option<String> {
170 Self::current().map(|envelope| envelope.task_id.as_str().to_owned())
171 }
172
173 pub fn current() -> Option<TaskEnvelopeV1> {
174 SCOPED
175 .try_with(Clone::clone)
176 .unwrap_or_else(|_| CURRENT.with(|current| current.borrow().clone()))
177 }
178
179 pub fn scoped_current() -> Option<TaskEnvelopeV1> {
181 SCOPED.try_with(Clone::clone).ok().flatten()
182 }
183
184 pub fn set(envelope: TaskEnvelopeV1) {
186 if SCOPED.try_with(|_| ()).is_err() {
187 CURRENT.with(|current| *current.borrow_mut() = Some(envelope));
188 }
189 }
190
191 pub(crate) async fn scope<F: std::future::Future>(
192 envelope: Option<TaskEnvelopeV1>,
193 future: F,
194 ) -> F::Output {
195 SCOPED.scope(envelope, future).await
196 }
197
198 pub fn sync_scope<T>(envelope: Option<TaskEnvelopeV1>, operation: impl FnOnce() -> T) -> T {
200 SCOPED.sync_scope(envelope, operation)
201 }
202
203 pub fn spawn_thread<T: Send + 'static>(
205 operation: impl FnOnce() -> T + Send + 'static,
206 ) -> std::thread::JoinHandle<T> {
207 let envelope = Self::current();
208 let project = crate::core::policy::runtime::REQUEST_PROJECT
209 .try_with(|slot| slot.borrow().clone())
210 .ok();
211 let source_view = crate::core::policy::runtime::inherited_source_view();
212 let admissions = crate::core::context_admission::capture::current();
214 let sources = crate::core::archive::authority::current_capture();
217 let carried = crate::core::context_admission::capture::carry();
219 std::thread::spawn(move || {
220 let operation = move || {
221 crate::core::archive::authority::CAPTURE
222 .sync_scope(sources, || carried.enter(operation))
223 };
224 crate::core::context_admission::capture::ADMISSIONS.sync_scope(admissions, || {
225 match project {
226 Some(project) => crate::core::policy::runtime::REQUEST_PROJECT.sync_scope(
227 std::cell::RefCell::new(project),
228 || {
229 crate::core::policy::runtime::with_inherited_source_view(
230 source_view,
231 || Self::sync_scope(envelope, operation),
232 )
233 },
234 ),
235 None => crate::core::policy::runtime::with_inherited_source_view(
236 source_view,
237 || Self::sync_scope(envelope, operation),
238 ),
239 }
240 })
241 })
242 }
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn detached_read_workers_isolate_overlapping_policy_projects() {
251 use crate::core::policy::runtime::REQUEST_PROJECT;
252 let barrier = std::sync::Arc::new(std::sync::Barrier::new(3));
253 let workers = ["first-request-project", "second-request-project"].map(|name| {
254 let root = std::path::PathBuf::from(name);
255 let worker_barrier = barrier.clone();
256 let worker =
257 REQUEST_PROJECT.sync_scope(std::cell::RefCell::new(Some(root.clone())), || {
258 TaskSpine::spawn_thread(move || {
259 worker_barrier.wait();
260 REQUEST_PROJECT.with(|slot| slot.borrow().clone())
261 })
262 });
263 (root, worker)
264 });
265 barrier.wait();
266 for (root, worker) in workers {
267 assert_eq!(worker.join().unwrap(), Some(root));
268 }
269 }
270
271 #[test]
272 fn detached_worker_inherits_pinned_source_policy() {
273 use crate::core::policy::runtime::{self, REQUEST_PROJECT};
274 use std::fs;
275
276 let _data = crate::core::data_dir::isolated_data_dir();
277 let _override_guard = runtime::lock_test_override();
278 let root = tempfile::tempdir().expect("create project");
279 let policy_dir = root.path().join(".lean-ctx");
280 fs::create_dir_all(&policy_dir).expect("create policy directory");
281 let policy_path = policy_dir.join("policy.toml");
282 fs::write(
283 &policy_path,
284 "name = \"thread-a\"\nversion = \"1.0.0\"\ndescription = \"thread test\"\n",
285 )
286 .expect("write policy A");
287
288 let result =
289 REQUEST_PROJECT.sync_scope(RefCell::new(Some(root.path().to_path_buf())), || {
290 runtime::with_source_view(|| {
291 let worker = TaskSpine::spawn_thread(|| {
292 runtime::active().map(|policy| policy.resolved.name.clone())
293 });
294 fs::write(
295 &policy_path,
296 "name = \"thread-b\"\nversion = \"1.0.0\"\ndescription = \"thread test\"\n",
297 )
298 .expect("write policy B");
299 assert_eq!(worker.join().unwrap().as_deref(), Some("thread-a"));
300 })
301 });
302
303 assert!(result.is_err());
304 }
305
306 #[test]
307 fn detached_threads_preserve_bound_and_explicitly_unbound_identity() {
308 let task = TaskSpine::create_envelope("thread", "scoped-thread", "agent");
309 let worker = TaskSpine::sync_scope(Some(task.clone()), || {
310 TaskSpine::spawn_thread(TaskSpine::current)
311 });
312 assert_eq!(worker.join().unwrap(), Some(task));
313 let unbound = TaskSpine::sync_scope(None, || {
314 TaskSpine::spawn_thread(|| {
315 TaskSpine::create_envelope("decoy", "detached-decoy", "agent");
316 TaskSpine::current()
317 })
318 });
319 assert!(unbound.join().unwrap().is_none());
320 }
321
322 #[tokio::test(flavor = "current_thread")]
323 async fn async_scopes_isolate_interleaved_tasks_and_ignore_legacy_setters() {
324 let first = TaskSpine::create_envelope("first", "scoped-a", "agent-a");
325 let second = TaskSpine::create_envelope("second", "scoped-b", "agent-b");
326 let legacy = TaskSpine::current();
327 let check = |expected: TaskEnvelopeV1, other: TaskEnvelopeV1| async move {
328 TaskSpine::scope(Some(expected.clone()), async {
329 for _ in 0..5 {
330 TaskSpine::set(other.clone());
331 tokio::task::yield_now().await;
332 assert_eq!(TaskSpine::current(), Some(expected.clone()));
333 }
334 TaskSpine::scope(None, async {
335 assert!(TaskSpine::current().is_none());
336 TaskSpine::set(other);
337 assert!(TaskSpine::current().is_none());
338 })
339 .await;
340 assert_eq!(TaskSpine::current(), Some(expected));
341 })
342 .await;
343 };
344 tokio::join!(check(first.clone(), second.clone()), check(second, first));
345 assert_eq!(TaskSpine::current(), legacy);
346 }
347
348 #[test]
349 fn sync_scope_restores_identity_after_panic_and_masks_unbound_workers() {
350 let legacy = TaskSpine::create_envelope("legacy", "legacy-worker", "agent");
351 let scoped = TaskSpine::create_envelope("scoped", "scoped-worker", "agent");
352 TaskSpine::set(legacy.clone());
353 assert!(TaskSpine::scoped_current().is_none());
354 let panic = std::panic::catch_unwind(|| {
355 TaskSpine::sync_scope(Some(scoped.clone()), || {
356 assert_eq!(TaskSpine::scoped_current(), Some(scoped.clone()));
357 assert_eq!(TaskSpine::current(), Some(scoped));
358 panic!("fixture unwind");
359 })
360 });
361 assert!(panic.is_err());
362 assert_eq!(TaskSpine::current(), Some(legacy.clone()));
363 TaskSpine::sync_scope(None, || assert!(TaskSpine::current().is_none()));
364 assert_eq!(TaskSpine::current(), Some(legacy));
365 }
366
367 #[tokio::test(flavor = "current_thread")]
368 async fn cancelled_scope_does_not_replace_parent_identity() {
369 let parent = TaskSpine::create_envelope("parent", "cancel-parent", "agent");
370 let child = TaskSpine::create_envelope("child", "cancel-child", "agent");
371 TaskSpine::scope(Some(parent.clone()), async {
372 let task = TaskSpine::scope(Some(child), async {
373 std::future::pending::<()>().await;
374 });
375 assert!(
376 tokio::time::timeout(std::time::Duration::from_millis(10), task)
377 .await
378 .is_err()
379 );
380 assert_eq!(TaskSpine::current(), Some(parent));
381 })
382 .await;
383 }
384
385 #[test]
386 fn test_envelope_created_on_dispatch() {
387 let envelope =
388 TaskSpine::create_envelope("query", "session-task-spine", "agent-task-spine");
389 assert_eq!(
390 TaskSpine::task_id().as_deref(),
391 Some(envelope.task_id.as_str())
392 );
393 }
394
395 #[test]
396 fn test_envelope_has_valid_task_id() {
397 let first = TaskSpine::create_envelope("one", "session-task-spine", "agent-task-spine");
398 let second = TaskSpine::create_envelope("two", "session-task-spine", "agent-task-spine");
399 assert!(
400 !first.task_id.as_str().is_empty(),
401 "`first.task_id.as_str()` is empty"
402 );
403 assert_ne!(first.task_id, second.task_id);
404 }
405
406 #[test]
407 fn test_enrich_from_triage() {
408 let mut envelope =
409 TaskSpine::create_envelope("query", "session-task-spine", "agent-task-spine");
410 let profile = TaskProfileV1 {
411 primary_intent: "implement".into(),
412 task_class: "coding".into(),
413 complexity: TaskComplexity::High,
414 scope: Default::default(),
415 context_need_milli: 0,
416 reasoning_need_milli: 0,
417 risk_signal: lean_ctx_protocol::RiskClass::High,
418 confidence_milli: 0,
419 capability_id: None,
420 capability_version: None,
421 keywords: vec![],
422 language_hints: vec![],
423 };
424 TaskSpine::enrich_from_triage(&mut envelope, &profile);
425 assert_eq!(envelope.intent.as_deref(), Some("implement"));
426 assert_eq!(envelope.task_class.as_deref(), Some("coding"));
427 assert_eq!(envelope.complexity, TaskComplexity::High);
428 assert_eq!(
429 envelope.risk_class,
430 Some(lean_ctx_protocol::RiskClass::High)
431 );
432 }
433
434 #[test]
435 fn first_session_task_is_parent_of_subsequent_tasks() {
436 let session_id = format!("lineage-session-{}", uuid::Uuid::new_v4());
437 let parent = TaskSpine::create_envelope("first", &session_id, "agent");
438 let child = TaskSpine::create_envelope("second", &session_id, "agent");
439
440 assert!(parent.parent_task_id.is_none());
441 assert_eq!(child.trace_id, parent.trace_id);
442 child.validate_child_of(&parent).unwrap();
443 assert_eq!(
444 child
445 .parent_task_id
446 .as_ref()
447 .map(lean_ctx_protocol::TaskId::as_str),
448 Some(parent.task_id.as_str())
449 );
450 assert_eq!(
451 TaskSpine::current()
452 .as_ref()
453 .map(|envelope| envelope.task_id.as_str()),
454 Some(child.task_id.as_str())
455 );
456 }
457
458 #[test]
459 fn session_traces_are_isolated_and_shared_by_all_children() {
460 let first_session = format!("lineage-first-{}", uuid::Uuid::new_v4());
461 let second_session = format!("lineage-second-{}", uuid::Uuid::new_v4());
462 let first = TaskSpine::create_envelope("root", &first_session, "agent");
463 let second = TaskSpine::create_envelope("root", &second_session, "agent");
464 assert_ne!(first.trace_id, second.trace_id);
465 assert!(second.parent_task_id.is_none());
466 for _ in 0..3 {
467 let child = TaskSpine::create_envelope("child", &first_session, "agent");
468 child.validate_child_of(&first).unwrap();
469 assert_ne!(child.task_id, first.task_id);
470 }
471 }
472}