diff --git a/AGENTS.md b/AGENTS.md index 240ad60..ff43a78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,18 +118,23 @@ Keep module boundaries clear and testable: filtering, debounce, fallback backend selection, and refresh signaling. - `crates/spacetop-core/src/git_sync.rs` owns the explicit read-refresh sync path and must remain limited to audited fast-forward pulls. -- `crates/spacetop-core/src/session_activity.rs` scans local agent session logs - and reduces exact structured events into `EntityActivity`; `domain/mod.rs` +- `crates/spacetop-core/src/session_activity.rs` is the local agent-session + facade; its `session_activity/{projection,state,codex,claude,reducer}.rs` + modules project privacy-safe typed facts, retain coherent scan evidence, + correlate each runtime, and reduce exact structured events into + `EntityActivity`; `domain/mod.rs` owns the three visible states (`Idle`, `Running`, and `HumanGate`) plus the typed `Worker`/`FirstOfficer` handler carried only by `Running`. Detection requires canonical dispatch/session correlation and structured start, stop, FO-action, or approve/reject gate records. Process presence, mtimes, generic path mentions, and filesystem writes alone must remain idle. JSONL scanning - must retain projected summaries behind per-file byte cursors rather than drop - or reread large unchanged artifacts. Codex child evidence requires a non-empty - parent thread, code-mode activity uses only nested executable commands, and - Claude worker lifecycle evidence stays scoped to its exact parent session and - dispatch call. + must retain typed facts behind per-file byte cursors rather than drop or + reread large unchanged artifacts. Codex child evidence requires a non-empty + parent thread plus exact parent-start or legacy assignment correlation; + code-mode activity uses only nested executable commands or direct structured + `exec_command` calls. Claude worker lifecycle evidence stays scoped to its + exact parent session and dispatch call and reopens only after an attributed + teammate-message boundary plus a later same-agent assistant record. - `crates/spacetop-core/src/editor.rs` owns opening selected files in an external editor/viewer path; it must not become a workflow-state writer without explicit policy change. diff --git a/crates/spacetop-core/src/session_activity.rs b/crates/spacetop-core/src/session_activity.rs index cfa9c38..2114dbd 100644 --- a/crates/spacetop-core/src/session_activity.rs +++ b/crates/spacetop-core/src/session_activity.rs @@ -1,24 +1,38 @@ -use std::collections::{HashMap, HashSet}; -use std::ffi::OsStr; -use std::fs; -use std::io::{BufRead, BufReader, Read, Seek, SeekFrom}; +mod claude; +mod codex; +mod projection; +mod reducer; +mod state; + +use std::collections::HashMap; use std::path::{Path, PathBuf}; #[cfg(test)] use std::sync::{LazyLock, Mutex}; use std::time::{SystemTime, UNIX_EPOCH}; -use serde_json::{json, Value}; -use walkdir::WalkDir; +use crate::domain::{AgentRuntime, Entity, EntityActivityAttribution, SessionScanReport}; +use projection::{contains_dispatch, ProjectedToolInput}; -use crate::domain::{ - ActivityHandler, AgentRuntime, Entity, EntityActivity, EntityActivityAttribution, - SessionScanReport, -}; -use crate::entity_identity::entity_slug; +pub use reducer::{reduce_activity, ActivityEvent, ActivityEventKind}; +pub use state::{SessionEvidenceStore, SessionFileCursor, SessionScanState}; -const DISPATCH_PREFIX: &str = "/tmp/spacedock-dispatch/spacedock-ensign-"; -const CHECKPOINT_BYTES: u64 = 128; const STAGES: &[&str] = &["shape", "plan", "implement", "verify", "done", "pr-merge"]; +const UNSTABLE_GENERATION: &str = "session files changed during scan; retrying"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct EvidenceTimestamp { + unix_seconds: i64, + subsecond_nanos: u32, +} + +impl EvidenceTimestamp { + fn whole_seconds(unix_seconds: i64) -> Self { + Self { + unix_seconds, + subsecond_nanos: 0, + } + } +} #[derive(Debug, Clone, PartialEq)] pub struct SessionScanRequest { @@ -26,18 +40,7 @@ pub struct SessionScanRequest { pub repo_root: PathBuf, pub entities: Vec, pub roots: SessionRoots, - pub previous_session_files: HashMap, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SessionFileSnapshot { - modified: Option, - len: u64, - cursor: u64, - complete_lines: u64, - checkpoint: Vec, - records: Vec, - parse_errors: Vec, + pub previous_state: SessionScanState, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -97,6 +100,12 @@ pub struct SessionScanError { pub message: String, } +impl SessionScanError { + pub fn retry_immediately(&self) -> bool { + self.message == UNSTABLE_GENERATION + } +} + impl std::fmt::Display for SessionScanError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.message) @@ -125,109 +134,17 @@ impl ProcessProbe for StdProcessProbe {} #[derive(Debug, Clone, PartialEq)] pub struct SessionActivityScan { pub report: SessionScanReport, - pub session_files: HashMap, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ActivityEvent { - pub runtime: AgentRuntime, - pub session_id: String, - pub updated_unix: i64, - pub kind: ActivityEventKind, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ActivityEventKind { - WorkerStarted, - WorkerStopped, - FirstOfficerStarted, - FirstOfficerStopped, - HumanGateOpened { call_id: String }, - HumanGateResolved { call_id: String }, -} - -pub fn reduce_activity(events: &[ActivityEvent]) -> EntityActivity { - let mut ordered: Vec<(usize, &ActivityEvent)> = events.iter().enumerate().collect(); - ordered.sort_by_key(|(index, event)| (event.updated_unix, *index)); - - let mut workers = HashMap::new(); - let mut first_officers = HashMap::new(); - let mut gates = HashMap::new(); - let mut latest = None; - - for (_, event) in ordered { - latest = Some(latest.unwrap_or(i64::MIN).max(event.updated_unix)); - let session_key = (event.runtime, event.session_id.clone()); - match &event.kind { - ActivityEventKind::WorkerStarted => { - workers.insert(session_key, event.updated_unix); - } - ActivityEventKind::WorkerStopped => { - workers.remove(&session_key); - } - ActivityEventKind::FirstOfficerStarted => { - first_officers.insert(session_key, event.updated_unix); - } - ActivityEventKind::FirstOfficerStopped => { - first_officers.remove(&session_key); - } - ActivityEventKind::HumanGateOpened { call_id } => { - gates.insert( - (event.runtime, event.session_id.clone(), call_id.clone()), - event.updated_unix, - ); - } - ActivityEventKind::HumanGateResolved { call_id } => { - gates.remove(&(event.runtime, event.session_id.clone(), call_id.clone())); - } - } - } - - if let Some(((runtime, session_id, _), updated_unix)) = gates - .into_iter() - .max_by_key(|((runtime, session, _), at)| (*at, *runtime, session.clone())) - { - return EntityActivity::HumanGate { - runtime, - session_id, - updated_unix, - }; - } - if let Some(((runtime, session_id), updated_unix)) = workers - .into_iter() - .max_by_key(|((runtime, session), at)| (*at, *runtime, session.clone())) - { - return EntityActivity::Running { - handler: ActivityHandler::Worker, - runtime, - session_id, - updated_unix, - }; - } - if let Some(((runtime, session_id), updated_unix)) = first_officers - .into_iter() - .max_by_key(|((runtime, session), at)| (*at, *runtime, session.clone())) - { - return EntityActivity::Running { - handler: ActivityHandler::FirstOfficer, - runtime, - session_id, - updated_unix, - }; - } - EntityActivity::Idle { - updated_unix: latest, - } + pub state: SessionScanState, } pub fn scan_local_sessions( request: SessionScanRequest, ) -> Result { - scan_local_sessions_with_snapshots(&request, &StdProcessProbe, SystemTime::now()) + scan_local_sessions_with_state(&request, &StdProcessProbe, SystemTime::now()) .map(|scan| scan.report) } -pub fn scan_local_sessions_with_snapshots( +pub fn scan_local_sessions_with_state( request: &SessionScanRequest, _process_probe: &P, now: SystemTime, @@ -247,92 +164,36 @@ fn scan_local_sessions_inner( request: &SessionScanRequest, now: SystemTime, ) -> Result { - let mut errors = Vec::new(); - let mut session_files = HashMap::new(); - let mut parsed_by_runtime: HashMap> = HashMap::new(); - let scanned_roots: Vec = request - .roots - .all_roots() - .map(|(_, root)| root.clone()) - .collect(); - - for (runtime, root) in request.roots.all_roots() { - if !root.exists() { - continue; - } - if let Err(err) = fs::read_dir(root) { - return Err(SessionScanError { - message: format!( - "{} session root {} is unreadable: {err}", - runtime.label(), - root.display() - ), - }); - } - for entry in WalkDir::new(root) - .into_iter() - .filter_entry(|entry| !is_pruned_dir(entry.path())) - { - let entry = match entry { - Ok(entry) => entry, - Err(err) => { - errors.push(format!("{} scan skipped entry: {err}", runtime.label())); - continue; - } - }; - if !entry.file_type().is_file() || !is_session_file(entry.path()) { - continue; + let loaded = + state::load_generation(&request.roots, &request.previous_state).map_err(|error| { + SessionScanError { + message: match error { + state::LoadGenerationError::Root(message) => message, + state::LoadGenerationError::Unstable => UNSTABLE_GENERATION.to_string(), + }, } - let metadata = match entry.metadata() { - Ok(metadata) => metadata, - Err(err) => { - errors.push(format!( - "{} scan could not read metadata for {}: {err}", - runtime.label(), - entry.path().display() - )); - continue; - } - }; - let snapshot = match load_session_snapshot( - entry.path(), - &metadata, - request.previous_session_files.get(entry.path()), - ) { - Ok(snapshot) => snapshot, - Err(err) => { - errors.push(format!( - "{} scan could not read {}: {err}", - runtime.label(), - entry.path().display() - )); - continue; - } - }; - errors.extend(snapshot.parse_errors.iter().cloned()); - parsed_by_runtime - .entry(runtime) - .or_default() - .push(ParsedFile { - path: entry.path().to_path_buf(), - records: snapshot.records.clone(), - }); - session_files.insert(entry.path().to_path_buf(), snapshot); - } - } - + })?; let fallback_time = system_time_unix(now).unwrap_or_default(); + let records = loaded.state.evidence.all_records(); let mut per_entity: HashMap> = request .entities .iter() .map(|entity| (entity.id.clone(), Vec::new())) .collect(); - if let Some(files) = parsed_by_runtime.get(&AgentRuntime::Codex) { - collect_codex_events(files, &request.entities, fallback_time, &mut per_entity); - } - if let Some(files) = parsed_by_runtime.get(&AgentRuntime::ClaudeCode) { - collect_claude_events(files, &request.entities, fallback_time, &mut per_entity); - } + codex::collect( + &records, + &request.entities, + &request.repo_root, + fallback_time, + &mut per_entity, + ); + claude::collect( + &records, + &request.entities, + &request.repo_root, + fallback_time, + &mut per_entity, + ); let mut attributions: Vec<_> = request .entities @@ -348,6 +209,11 @@ fn scan_local_sessions_inner( }) .collect(); attributions.sort_by(|left, right| left.entity_id.cmp(&right.entity_id)); + let scanned_roots = request + .roots + .all_roots() + .map(|(_, root)| root.clone()) + .collect(); Ok(SessionActivityScan { report: SessionScanReport { @@ -355,1355 +221,218 @@ fn scan_local_sessions_inner( repo_root: request.repo_root.clone(), scanned_roots, attributions, - errors, + errors: loaded.errors, }, - session_files, + state: loaded.state, }) } -#[derive(Debug)] -struct ParsedFile { - path: PathBuf, - records: Vec, -} - -#[derive(Debug)] -struct ParsedChunk { - records: Vec, - cursor: u64, - complete_lines: u64, - errors: Vec, -} - -fn load_session_snapshot( - path: &Path, - metadata: &fs::Metadata, - previous: Option<&SessionFileSnapshot>, -) -> Result { - let modified = metadata.modified().ok(); - let len = metadata.len(); - if let Some(previous) = previous { - if previous.len == len && previous.modified == modified && modified.is_some() { - return Ok(previous.clone()); - } - } - - if path.extension().and_then(OsStr::to_str) == Some("json") { - return parse_json_snapshot(path, modified, len); - } - - if let Some(previous) = previous { - if len > previous.len && append_checkpoint_matches(path, previous)? { - let mut parsed = parse_jsonl_from(path, previous.cursor, previous.complete_lines)?; - let mut records = previous.records.clone(); - records.append(&mut parsed.records); - let mut parse_errors = previous.parse_errors.clone(); - parse_errors.append(&mut parsed.errors); - return Ok(SessionFileSnapshot { - modified, - len, - cursor: parsed.cursor, - complete_lines: parsed.complete_lines, - checkpoint: read_checkpoint(path, parsed.cursor)?, - records, - parse_errors, - }); - } +fn cwd_matches_entity(cwd: &Path, repo_root: &Path, entity: &SessionScanEntity) -> bool { + if cwd == repo_root + || cwd.starts_with(repo_root) && !cwd.starts_with(repo_root.join(".worktrees")) + { + return true; } - - let parsed = parse_jsonl_from(path, 0, 0)?; - Ok(SessionFileSnapshot { - modified, - len, - cursor: parsed.cursor, - complete_lines: parsed.complete_lines, - checkpoint: read_checkpoint(path, parsed.cursor)?, - records: parsed.records, - parse_errors: parsed.errors, - }) -} - -fn parse_json_snapshot( - path: &Path, - modified: Option, - len: u64, -) -> Result { - #[cfg(test)] - record_session_file_parse(path, 0); - let file = fs::File::open(path)?; - let reader = BufReader::new(file); - let (records, parse_errors) = match serde_json::from_reader(reader) { - Ok(value) => (project_record(value).into_iter().collect(), Vec::new()), - Err(err) => ( - Vec::new(), - vec![format!( - "malformed session record {}: {err}", - path.display() - )], - ), - }; - Ok(SessionFileSnapshot { - modified, - len, - cursor: len, - complete_lines: 0, - checkpoint: read_checkpoint(path, len)?, - records, - parse_errors, - }) -} - -fn parse_jsonl_from( - path: &Path, - start: u64, - starting_line: u64, -) -> Result { - #[cfg(test)] - record_session_file_parse(path, start); - let mut file = fs::File::open(path)?; - file.seek(SeekFrom::Start(start))?; - let mut reader = BufReader::new(file); - let mut records = Vec::new(); - let mut errors = Vec::new(); - let mut cursor = start; - let mut complete_lines = starting_line; - loop { - let mut line = Vec::new(); - let read = reader.read_until(b'\n', &mut line)?; - if read == 0 { - break; - } - let terminated = line.last() == Some(&b'\n'); - if line.iter().all(u8::is_ascii_whitespace) { - cursor += read as u64; - continue; - } - match serde_json::from_slice(&line) { - Ok(value) => { - cursor += read as u64; - complete_lines += 1; - if let Some(projected) = project_record(value) { - records.push(projected); - } - } - Err(_) if !terminated => break, - Err(err) => { - cursor += read as u64; - complete_lines += 1; - errors.push(format!( - "malformed session record {}:{}: {err}", - path.display(), - complete_lines - )); - } + if let Some(worktree) = entity.worktree.as_ref() { + let worktree_root = repo_root.join(worktree); + if cwd == worktree_root || cwd.starts_with(&worktree_root) { + return true; } } - Ok(ParsedChunk { - records, - cursor, - complete_lines, - errors, - }) -} - -fn append_checkpoint_matches( - path: &Path, - previous: &SessionFileSnapshot, -) -> Result { - if previous.cursor == 0 || previous.checkpoint.is_empty() { - return Ok(false); - } - Ok(read_checkpoint(path, previous.cursor)? == previous.checkpoint) + entity + .worktree_source + .as_ref() + .is_some_and(|source| source.starts_with(cwd)) } -fn read_checkpoint(path: &Path, cursor: u64) -> Result, std::io::Error> { - if cursor == 0 { - return Ok(Vec::new()); - } - let start = cursor.saturating_sub(CHECKPOINT_BYTES); - let mut file = fs::File::open(path)?; - file.seek(SeekFrom::Start(start))?; - let mut checkpoint = vec![0; (cursor - start) as usize]; - file.read_exact(&mut checkpoint)?; - Ok(checkpoint) -} - -fn project_record(record: Value) -> Option { - if record.get("taskKind").is_some() { - return Some(project_claude_meta(&record)); +fn call_scopes_entity( + tool_name: &str, + input: &ProjectedToolInput, + entity: &SessionScanEntity, + slug: &str, + parent_session_id: Option<&str>, +) -> bool { + if tool_name.ends_with("spawn_agent") || tool_name == "Agent" { + let expected_dash = format!("spacedock-ensign-{slug}-"); + let expected_underscore = format!("spacedock_ensign_{}_", slug.replace('-', "_")); + return input.task_name.as_deref().is_some_and(|name| { + (name.starts_with(&expected_dash) || name.starts_with(&expected_underscore)) + && STAGES.iter().any(|stage| name.ends_with(stage)) + }) && contains_dispatch(&input.dispatches, slug, parent_session_id); + } + + if matches!(tool_name, "exec" | "exec_command" | "Bash") { + return input.commands.iter().any(|command| { + command_contains_exact_path(command, &entity.path) + || contains_dispatch_markers(command, slug, parent_session_id) + }); } - let record_type = record.get("type").and_then(Value::as_str)?; - let timestamp = record.get("timestamp").cloned().unwrap_or(Value::Null); - match record_type { - "session_meta" => Some(json!({ - "type": record_type, - "timestamp": timestamp, - "payload": { - "id": record.pointer("/payload/id").cloned().unwrap_or(Value::Null), - "source": { - "subagent": { - "thread_spawn": { - "agent_path": record.pointer("/payload/source/subagent/thread_spawn/agent_path").cloned().unwrap_or(Value::Null), - "parent_thread_id": record.pointer("/payload/source/subagent/thread_spawn/parent_thread_id").cloned().unwrap_or(Value::Null), - } - } - } - } - })), - "event_msg" => Some(json!({ - "type": record_type, - "timestamp": timestamp, - "payload": { - "type": record.pointer("/payload/type").cloned().unwrap_or(Value::Null), - "turn_id": record.pointer("/payload/turn_id").cloned().unwrap_or(Value::Null), - "kind": record.pointer("/payload/kind").cloned().unwrap_or(Value::Null), - "agent_thread_id": record.pointer("/payload/agent_thread_id").cloned().unwrap_or(Value::Null), - "agent_path": record.pointer("/payload/agent_path").cloned().unwrap_or(Value::Null), - } - })), - "response_item" => project_codex_response_item(&record, timestamp), - "assistant" | "user" => project_claude_record(&record, timestamp), - _ => None, - } + [&input.file_path, &input.path, &input.uri] + .into_iter() + .flatten() + .any(|value| { + value == &entity.path.to_string_lossy() + || contains_dispatch_markers(value, slug, parent_session_id) + }) } -fn project_claude_meta(record: &Value) -> Value { - json!({ - "taskKind": record.get("taskKind").cloned().unwrap_or(Value::Null), - "name": record.get("name").cloned().unwrap_or(Value::Null), - "agentId": record.get("agentId").cloned().unwrap_or(Value::Null), - "parentSessionId": first_value(record, &["parentSessionId", "parentSessionID", "parent_session_id"]), - "parentToolUseId": first_value(record, &["parentToolUseId", "parentToolUseID", "parent_tool_use_id"]), - }) +fn contains_dispatch_markers(text: &str, slug: &str, parent_session_id: Option<&str>) -> bool { + let markers = STAGES.iter().flat_map(|stage| { + let canonical = format!("/tmp/spacedock-dispatch/spacedock-ensign-{slug}-{stage}.md"); + let prefixed = parent_session_id.map(|parent| { + format!("/tmp/spacedock-dispatch/{parent}-spacedock-ensign-{slug}-{stage}.md") + }); + std::iter::once(canonical).chain(prefixed) + }); + markers.into_iter().any(|marker| text.contains(&marker)) } -fn project_codex_response_item(record: &Value, timestamp: Value) -> Option { - let payload = record.get("payload")?; - let payload_type = payload.get("type").and_then(Value::as_str)?; - match payload_type { - "message" if payload.get("role").and_then(Value::as_str) == Some("user") => { - let dispatches: Vec = payload - .get("content") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(|item| { - item.get("text") - .or_else(|| item.get("input_text")) - .and_then(Value::as_str) - }) - .flat_map(dispatch_markers) - .map(|text| json!({ "type": "input_text", "text": text })) - .collect(); - (!dispatches.is_empty()).then(|| { - json!({ - "type": "response_item", - "timestamp": timestamp, - "payload": { - "type": "message", - "role": "user", - "content": dispatches, - } - }) - }) - } - "function_call" | "custom_tool_call" => { - let name = payload.get("name").and_then(Value::as_str)?; - let raw = payload - .get("arguments") - .or_else(|| payload.get("input")) - .cloned() - .unwrap_or(Value::Null); - Some(json!({ - "type": "response_item", - "timestamp": timestamp, - "payload": { - "type": payload_type, - "name": name, - "call_id": payload.get("call_id").or_else(|| payload.get("id")).cloned().unwrap_or(Value::Null), - "arguments": project_tool_input(name, raw), - } - })) - } - "function_call_output" => Some(json!({ - "type": "response_item", - "timestamp": timestamp, - "payload": { - "type": payload_type, - "call_id": payload.get("call_id").cloned().unwrap_or(Value::Null), - } - })), - _ => None, - } +fn command_contains_exact_path(command: &str, path: &Path) -> bool { + let path = path.to_string_lossy(); + command + .match_indices(path.as_ref()) + .any(|(start, matched)| { + let before = command[..start].chars().next_back(); + let after = command[start + matched.len()..].chars().next(); + before.is_none_or(is_command_boundary) && after.is_none_or(is_command_boundary) + }) } -fn project_claude_record(record: &Value, timestamp: Value) -> Option { - let record_type = record.get("type").and_then(Value::as_str)?; - let projected_content: Vec = record - .pointer("/message/content") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(|block| match block.get("type").and_then(Value::as_str) { - Some("tool_use") => { - let name = block.get("name").and_then(Value::as_str)?; - Some(json!({ - "type": "tool_use", - "id": block.get("id").cloned().unwrap_or(Value::Null), - "name": name, - "input": project_tool_input(name, block.get("input").cloned().unwrap_or(Value::Null)), - })) - } - Some("tool_result") => Some(json!({ - "type": "tool_result", - "tool_use_id": block.get("tool_use_id").cloned().unwrap_or(Value::Null), - })), - _ => None, - }) - .collect(); - let teammate_content = record - .pointer("/message/content") - .and_then(Value::as_str) - .and_then(project_teammate_envelope); - - Some(json!({ - "type": record_type, - "timestamp": timestamp, - "sessionId": record.get("sessionId").cloned().unwrap_or(Value::Null), - "isSidechain": record.get("isSidechain").cloned().unwrap_or(Value::Bool(false)), - "agentId": record.get("agentId").cloned().unwrap_or(Value::Null), - "parentSessionId": first_value(record, &["parentSessionId", "parentSessionID", "parent_session_id"]), - "message": { - "content": teammate_content.unwrap_or(Value::Array(projected_content)), - "stop_reason": record.pointer("/message/stop_reason").cloned().unwrap_or(Value::Null), - } - })) +fn is_command_boundary(character: char) -> bool { + character.is_whitespace() + || matches!( + character, + '\'' | '"' | '`' | '=' | ':' | ';' | '|' | '&' | '(' | ')' | '[' | ']' | '{' | '}' + ) } -fn project_tool_input(name: &str, raw: Value) -> Value { - let parsed = raw - .as_str() - .and_then(|text| serde_json::from_str(text).ok()) - .unwrap_or(raw); - if name == "exec" { - return json!({ "commands": code_mode_exec_commands(&parsed) }); - } - if name == "Bash" { - return command_text(&parsed) - .map(|command| json!({ "commands": [command] })) - .unwrap_or_else(|| json!({ "commands": [] })); - } - if name.ends_with("spawn_agent") || name == "Agent" { - return json!({ - "task_name": parsed.get("task_name").or_else(|| parsed.get("name")).cloned().unwrap_or(Value::Null), - "message": parsed - .get("message") - .or_else(|| parsed.get("prompt")) - .and_then(Value::as_str) - .map(dispatch_markers) - .unwrap_or_default() - .join("\n"), +fn is_gate_question(input: &ProjectedToolInput) -> bool { + input.questions.iter().any(|question| { + let gate_named = [&question.id, &question.header] + .into_iter() + .flatten() + .any(|value| value.to_ascii_lowercase().contains("gate")); + let labels: Vec<_> = question + .labels + .iter() + .map(|label| label.to_ascii_lowercase()) + .collect(); + let accepts = labels.iter().any(|label| { + ["approve", "pass", "accept"] + .iter() + .any(|term| label.contains(term)) }); - } - if matches!(name, "request_user_input" | "AskUserQuestion") { - return json!({ "questions": project_questions(parsed.get("questions")) }); - } - json!({ - "file_path": parsed.get("file_path").cloned().unwrap_or(Value::Null), - "path": parsed.get("path").cloned().unwrap_or(Value::Null), - "cmd": parsed.get("cmd").cloned().unwrap_or(Value::Null), - "command": parsed.get("command").cloned().unwrap_or(Value::Null), - "uri": parsed.get("uri").cloned().unwrap_or(Value::Null), + let rejects = labels.iter().any(|label| { + ["reject", "bounce back"] + .iter() + .any(|term| label.contains(term)) + }); + gate_named && accepts && rejects }) } -fn command_text(value: &Value) -> Option { - if let Some(text) = value.as_str() { - return Some(text.to_string()); - } - ["cmd", "command", "input"] - .iter() - .find_map(|key| value.get(*key).and_then(command_text)) -} - -fn code_mode_exec_commands(value: &Value) -> Vec { - if let Some(module) = value.as_str() { - return nested_exec_commands(module); - } - if let Some(command) = value - .get("cmd") - .or_else(|| value.get("command")) - .and_then(command_text) - { - return vec![command]; - } - ["input", "arguments"] - .iter() - .find_map(|key| value.get(*key)) - .map(code_mode_exec_commands) - .unwrap_or_default() -} +fn parse_rfc3339_timestamp(value: &str) -> Option { + let (date, rest) = value.split_once('T')?; + let mut date_parts = date.split('-'); + let year = date_parts.next()?.parse::().ok()?; + let month = date_parts.next()?.parse::().ok()?; + let day = date_parts.next()?.parse::().ok()?; -fn nested_exec_commands(module: &str) -> Vec { - const CALL: &str = "tools.exec_command"; - - let mut commands = Vec::new(); - let mut offset = 0; - while let Some(relative_start) = module[offset..].find(CALL) { - let call_start = offset + relative_start + CALL.len(); - let after_name = &module[call_start..]; - let whitespace = after_name.len() - after_name.trim_start().len(); - let argument_start = call_start + whitespace; - if module.as_bytes().get(argument_start) != Some(&b'(') { - offset = call_start; - continue; - } - let source = &module[argument_start + 1..]; - let Some((argument, consumed)) = balanced_call_argument(source) else { - break; + let timezone_index = rest + .char_indices() + .find_map(|(index, ch)| (ch == 'Z' || ch == '+' || ch == '-').then_some(index))?; + let (clock, zone) = rest.split_at(timezone_index); + let mut clock_parts = clock.split(':'); + let hour = clock_parts.next()?.parse::().ok()?; + let minute = clock_parts.next()?.parse::().ok()?; + let second_and_fraction = clock_parts.next()?; + let (second, subsecond_nanos) = + if let Some((second, fraction)) = second_and_fraction.split_once('.') { + ( + second.parse::().ok()?, + parse_fractional_nanos(fraction)?, + ) + } else { + (second_and_fraction.parse::().ok()?, 0) }; - if let Ok(value) = serde_json::from_str::(argument.trim()) { - if let Some(command) = command_text(&value) { - commands.push(command); - } - } - offset = argument_start + 1 + consumed; - } - commands + let offset = if zone == "Z" { + 0 + } else { + let sign = if zone.starts_with('-') { -1 } else { 1 }; + let mut parts = zone[1..].split(':'); + let hours = parts.next()?.parse::().ok()?; + let minutes = parts.next().unwrap_or("0").parse::().ok()?; + sign * (hours * 3600 + minutes * 60) + }; + Some(EvidenceTimestamp { + unix_seconds: days_from_civil(year, month, day) * 86_400 + + hour * 3600 + + minute * 60 + + second + - offset, + subsecond_nanos, + }) } -fn balanced_call_argument(source: &str) -> Option<(&str, usize)> { - let mut depth = 1_u32; - let mut quote = None; - let mut escaped = false; - for (index, character) in source.char_indices() { - if let Some(expected) = quote { - if escaped { - escaped = false; - } else if character == '\\' { - escaped = true; - } else if character == expected { - quote = None; - } - continue; - } - match character { - '\'' | '"' | '`' => quote = Some(character), - '(' => depth += 1, - ')' => { - depth -= 1; - if depth == 0 { - return Some((&source[..index], index + character.len_utf8())); - } - } - _ => {} - } +fn parse_fractional_nanos(fraction: &str) -> Option { + if fraction.is_empty() + || fraction.len() > 9 + || !fraction.bytes().all(|byte| byte.is_ascii_digit()) + { + return None; } - None + let parsed = fraction.parse::().ok()?; + Some(parsed * 10_u32.pow(9 - fraction.len() as u32)) } -fn project_questions(value: Option<&Value>) -> Vec { - value - .and_then(Value::as_array) - .into_iter() - .flatten() - .map(|question| { - let options: Vec = question - .get("options") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(|option| option.get("label").and_then(Value::as_str)) - .map(|label| json!({ "label": label })) - .collect(); - json!({ - "id": question.get("id").cloned().unwrap_or(Value::Null), - "header": question.get("header").cloned().unwrap_or(Value::Null), - "options": options, - }) - }) - .collect() +fn days_from_civil(year: i32, month: u32, day: u32) -> i64 { + let year = year - i32::from(month <= 2); + let era = if year >= 0 { year } else { year - 399 } / 400; + let year_of_era = year - era * 400; + let shifted_month = month as i32 + if month > 2 { -3 } else { 9 }; + let day_of_year = (153 * shifted_month + 2) / 5 + day as i32 - 1; + let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; + (era * 146_097 + day_of_era - 719_468) as i64 } -fn project_teammate_envelope(content: &str) -> Option { - let start = content.find("")? + "".len(); - let end = content[start..].find("")? + start; - let envelope: Value = serde_json::from_str(content[start..end].trim()).ok()?; - let projected = json!({ - "type": envelope.get("type").cloned().unwrap_or(Value::Null), - "from": envelope.get("from").cloned().unwrap_or(Value::Null), - "idleReason": envelope.get("idleReason").cloned().unwrap_or(Value::Null), - }); - Some(Value::String(format!( - "{projected}" - ))) +fn system_time_unix(time: SystemTime) -> Option { + time.duration_since(UNIX_EPOCH) + .ok() + .map(|duration| duration.as_secs() as i64) } -fn dispatch_markers(text: &str) -> Vec { - let mut markers = Vec::new(); - let mut remainder = text; - while let Some(start) = remainder.find(DISPATCH_PREFIX) { - let candidate = &remainder[start..]; - let Some(end) = candidate.find(".md") else { - break; - }; - let marker = &candidate[..end + 3]; - let stem = &candidate[DISPATCH_PREFIX.len()..end]; - if marker.len() <= 512 - && stem - .chars() - .all(|character| character.is_ascii_alphanumeric() || character == '-') - && STAGES - .iter() - .any(|stage| stem.ends_with(&format!("-{stage}"))) - { - markers.push(marker.to_string()); - } - remainder = &candidate[end + 3..]; - } - markers +#[cfg(test)] +static SESSION_FILE_PARSE_STARTS: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +#[cfg(test)] +fn record_session_file_parse(path: &Path, start: u64) { + SESSION_FILE_PARSE_STARTS + .lock() + .expect("session parse count lock") + .entry(path.to_path_buf()) + .or_default() + .push(start); } -fn first_value(value: &Value, keys: &[&str]) -> Value { - keys.iter() - .find_map(|key| value.get(*key)) +#[cfg(test)] +fn session_file_parse_starts(path: &Path) -> Vec { + SESSION_FILE_PARSE_STARTS + .lock() + .expect("session parse count lock") + .get(path) .cloned() - .unwrap_or(Value::Null) + .unwrap_or_default() } -fn collect_codex_events( - files: &[ParsedFile], - entities: &[SessionScanEntity], - fallback_time: i64, - per_entity: &mut HashMap>, -) { - for entity in entities { - let Some(slug) = entity_slug(&entity.path) else { - continue; - }; - let mut matched_children = Vec::new(); - for file in files { - let session_id = file - .records - .iter() - .find_map(|record| { - (record_type(record) == Some("session_meta")) - .then(|| string_at(record, &["payload", "id"])) - .flatten() - }) - .unwrap_or_else(|| file_id(&file.path)); - let agent_path = file.records.iter().find_map(|record| { - string_at( - record, - &[ - "payload", - "source", - "subagent", - "thread_spawn", - "agent_path", - ], - ) - }); - let parent_thread_id = file.records.iter().find_map(|record| { - string_at( - record, - &[ - "payload", - "source", - "subagent", - "thread_spawn", - "parent_thread_id", - ], - ) - }); - let child_matches = agent_path - .as_deref() - .is_some_and(|path| canonical_codex_name(path, &slug)) - && parent_thread_id - .as_deref() - .is_some_and(|parent| !parent.is_empty()) - && file.records.iter().any(|record| { - codex_assignment_text(record) - .is_some_and(|text| contains_dispatch(&text, &slug)) - }); - if child_matches { - matched_children.push((session_id.clone(), agent_path.unwrap_or_default())); - collect_codex_worker( - &file.records, - per_entity.entry(entity.id.clone()).or_default(), - &session_id, - fallback_time, - ); - } else { - collect_codex_first_officer( - &file.records, - per_entity.entry(entity.id.clone()).or_default(), - &session_id, - entity, - &slug, - fallback_time, - ); - } - } - for file in files { - for record in &file.records { - if event_type(record) != Some("sub_agent_activity") - || string_at(record, &["payload", "kind"]).as_deref() != Some("interrupted") - { - continue; - } - let thread_id = string_at(record, &["payload", "agent_thread_id"]); - let agent_path = string_at(record, &["payload", "agent_path"]); - if let Some((session_id, _)) = matched_children.iter().find(|(session, path)| { - thread_id.as_deref() == Some(session.as_str()) - && agent_path.as_deref() == Some(path.as_str()) - }) { - push_event( - per_entity.entry(entity.id.clone()).or_default(), - AgentRuntime::Codex, - session_id, - record_timestamp(record, fallback_time), - ActivityEventKind::WorkerStopped, - ); - } - } - } - } -} +#[cfg(test)] +mod tests { + use std::fs; + use std::io::Write; -fn collect_codex_worker( - records: &[Value], - events: &mut Vec, - session_id: &str, - fallback_time: i64, -) { - let mut open_turn = None; - for record in records { - match event_type(record) { - Some("task_started") => { - open_turn = string_at(record, &["payload", "turn_id"]); - if open_turn.is_none() { - continue; - } - push_event( - events, - AgentRuntime::Codex, - session_id, - record_timestamp(record, fallback_time), - ActivityEventKind::WorkerStarted, - ); - } - Some("task_complete") - if open_turn.as_deref() - == string_at(record, &["payload", "turn_id"]).as_deref() => - { - push_event( - events, - AgentRuntime::Codex, - session_id, - record_timestamp(record, fallback_time), - ActivityEventKind::WorkerStopped, - ); - open_turn = None; - } - _ => {} - } - } -} - -fn collect_codex_first_officer( - records: &[Value], - events: &mut Vec, - session_id: &str, - entity: &SessionScanEntity, - slug: &str, - fallback_time: i64, -) { - let mut open_turn = None; - let mut scoped_turns = HashSet::new(); - for record in records { - if event_type(record) == Some("task_started") { - open_turn = string_at(record, &["payload", "turn_id"]); - continue; - } - if let Some(call) = codex_call(record) { - let Some(turn) = open_turn.clone() else { - continue; - }; - if call_scopes_entity(&call.name, &call.arguments, entity, slug) { - scoped_turns.insert(turn.clone()); - push_event( - events, - AgentRuntime::Codex, - session_id, - record_timestamp(record, fallback_time), - ActivityEventKind::FirstOfficerStarted, - ); - } - if scoped_turns.contains(&turn) - && call.name == "request_user_input" - && is_gate_question(&call.arguments) - { - push_event( - events, - AgentRuntime::Codex, - session_id, - record_timestamp(record, fallback_time), - ActivityEventKind::HumanGateOpened { - call_id: call.call_id, - }, - ); - } - } - if let Some(call_id) = codex_call_output_id(record) { - push_event( - events, - AgentRuntime::Codex, - session_id, - record_timestamp(record, fallback_time), - ActivityEventKind::HumanGateResolved { call_id }, - ); - } - if event_type(record) == Some("task_complete") { - let completed = string_at(record, &["payload", "turn_id"]).unwrap_or_default(); - if scoped_turns.remove(&completed) { - push_event( - events, - AgentRuntime::Codex, - session_id, - record_timestamp(record, fallback_time), - ActivityEventKind::FirstOfficerStopped, - ); - } - if open_turn.as_deref() == Some(completed.as_str()) { - open_turn = None; - } - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ClaudeDispatch { - parent_session_id: String, - call_id: String, - worker_name: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ClaudeTeammateMeta { - parent_session_id: String, - parent_call_id: Option, - worker_name: String, - agent_id: String, -} - -fn collect_claude_events( - files: &[ParsedFile], - entities: &[SessionScanEntity], - fallback_time: i64, - per_entity: &mut HashMap>, -) { - let teammate_meta: Vec = - files.iter().filter_map(claude_teammate_meta).collect(); - - for entity in entities { - let Some(slug) = entity_slug(&entity.path) else { - continue; - }; - let mut dispatches = Vec::new(); - for file in files { - if file.records.iter().any(is_claude_sidechain) { - continue; - } - let session_id = claude_session_id(file); - collect_claude_first_officer( - &file.records, - per_entity.entry(entity.id.clone()).or_default(), - &session_id, - entity, - &slug, - fallback_time, - &mut dispatches, - ); - } - - for dispatch in &dispatches { - let matching_meta: Vec<_> = teammate_meta - .iter() - .filter(|meta| { - meta.parent_session_id == dispatch.parent_session_id - && meta.worker_name == dispatch.worker_name - && meta - .parent_call_id - .as_deref() - .is_none_or(|call_id| call_id == dispatch.call_id) - }) - .collect(); - let same_name_dispatches = dispatches - .iter() - .filter(|candidate| { - candidate.parent_session_id == dispatch.parent_session_id - && candidate.worker_name == dispatch.worker_name - }) - .count(); - if matching_meta.len() != 1 - || (matching_meta[0].parent_call_id.is_none() && same_name_dispatches != 1) - { - continue; - } - let meta = matching_meta[0]; - for file in files { - if claude_parent_session_from_path(&file.path).as_deref() - != Some(dispatch.parent_session_id.as_str()) - { - continue; - } - let agent_id = file.records.iter().find_map(|record| { - is_claude_sidechain(record) - .then(|| string_at(record, &["agentId"])) - .flatten() - }); - if agent_id.as_deref() != Some(meta.agent_id.as_str()) { - continue; - } - if let Some(start) = file.records.iter().find(|record| { - is_claude_sidechain(record) - && string_at(record, &["type"]).as_deref() == Some("assistant") - }) { - push_event( - per_entity.entry(entity.id.clone()).or_default(), - AgentRuntime::ClaudeCode, - &meta.agent_id, - record_timestamp(start, fallback_time), - ActivityEventKind::WorkerStarted, - ); - } - } - if same_name_dispatches == 1 { - for file in files { - if file.records.iter().any(is_claude_sidechain) { - continue; - } - if claude_session_id(file) != dispatch.parent_session_id { - continue; - } - if let Some(stop) = file.records.iter().find(|record| { - teammate_idle_notification(record) - .is_some_and(|from| from == dispatch.worker_name) - }) { - push_event( - per_entity.entry(entity.id.clone()).or_default(), - AgentRuntime::ClaudeCode, - &meta.agent_id, - record_timestamp(stop, fallback_time), - ActivityEventKind::WorkerStopped, - ); - } - } - } - } - } -} - -#[allow(clippy::too_many_arguments)] -fn collect_claude_first_officer( - records: &[Value], - events: &mut Vec, - session_id: &str, - entity: &SessionScanEntity, - slug: &str, - fallback_time: i64, - dispatches: &mut Vec, -) { - let mut scoped = false; - let mut handoff_pending = false; - let mut dispatched_names = HashSet::new(); - for record in records { - let is_assistant = string_at(record, &["type"]).as_deref() == Some("assistant"); - if handoff_pending && is_assistant { - scoped = true; - handoff_pending = false; - push_event( - events, - AgentRuntime::ClaudeCode, - session_id, - record_timestamp(record, fallback_time), - ActivityEventKind::FirstOfficerStarted, - ); - } - if let Some(blocks) = record - .get("message") - .and_then(|message| message.get("content")) - .and_then(Value::as_array) - { - for block in blocks { - if block.get("type").and_then(Value::as_str) == Some("tool_use") { - let name = block - .get("name") - .and_then(Value::as_str) - .unwrap_or_default(); - let input = block.get("input").cloned().unwrap_or(Value::Null); - let call_id = block - .get("id") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(); - if call_scopes_entity(name, &input, entity, slug) { - scoped = true; - push_event( - events, - AgentRuntime::ClaudeCode, - session_id, - record_timestamp(record, fallback_time), - ActivityEventKind::FirstOfficerStarted, - ); - } - if name == "Agent" && call_scopes_entity(name, &input, entity, slug) { - if let Some(worker_name) = input - .get("task_name") - .or_else(|| input.get("name")) - .and_then(Value::as_str) - { - dispatched_names.insert(worker_name.to_string()); - dispatches.push(ClaudeDispatch { - parent_session_id: session_id.to_string(), - call_id: call_id.clone(), - worker_name: worker_name.to_string(), - }); - } - } - if scoped && name == "AskUserQuestion" && is_gate_question(&input) { - push_event( - events, - AgentRuntime::ClaudeCode, - session_id, - record_timestamp(record, fallback_time), - ActivityEventKind::HumanGateOpened { call_id }, - ); - } - } - } - } - if let Some(blocks) = record - .get("message") - .and_then(|message| message.get("content")) - .and_then(Value::as_array) - { - for block in blocks { - if block.get("type").and_then(Value::as_str) == Some("tool_result") { - if let Some(call_id) = block.get("tool_use_id").and_then(Value::as_str) { - push_event( - events, - AgentRuntime::ClaudeCode, - session_id, - record_timestamp(record, fallback_time), - ActivityEventKind::HumanGateResolved { - call_id: call_id.to_string(), - }, - ); - } - } - } - } - if scoped && string_at(record, &["message", "stop_reason"]).as_deref() == Some("end_turn") { - push_event( - events, - AgentRuntime::ClaudeCode, - session_id, - record_timestamp(record, fallback_time), - ActivityEventKind::FirstOfficerStopped, - ); - scoped = false; - } - if teammate_idle_notification(record).is_some_and(|from| dispatched_names.contains(&from)) { - // The linked envelope scopes the handoff, but the next observable - // assistant record is what makes FO work visible. - scoped = false; - handoff_pending = true; - } - } -} - -fn claude_teammate_meta(file: &ParsedFile) -> Option { - let record = file.records.iter().find(|record| { - string_at(record, &["taskKind"]).as_deref() == Some("in_process_teammate") - })?; - let meta = ClaudeTeammateMeta { - parent_session_id: string_at(record, &["parentSessionId"]) - .or_else(|| claude_parent_session_from_path(&file.path))?, - parent_call_id: string_at(record, &["parentToolUseId"]), - worker_name: string_at(record, &["name"])?, - agent_id: string_at(record, &["agentId"])?, - }; - claude_parent_session_from_path(&file.path) - .as_deref() - .is_some_and(|parent| parent == meta.parent_session_id) - .then_some(meta) -} - -fn claude_parent_session_from_path(path: &Path) -> Option { - let subagents = path - .ancestors() - .find(|ancestor| ancestor.file_name().and_then(OsStr::to_str) == Some("subagents"))?; - subagents - .parent()? - .file_name() - .and_then(OsStr::to_str) - .map(str::to_string) -} - -struct ParsedCall { - name: String, - call_id: String, - arguments: Value, -} - -fn codex_call(record: &Value) -> Option { - let payload = record.get("payload")?; - let payload_type = payload.get("type").and_then(Value::as_str)?; - if !matches!(payload_type, "function_call" | "custom_tool_call") { - return None; - } - let name = payload.get("name").and_then(Value::as_str)?.to_string(); - let call_id = payload - .get("call_id") - .or_else(|| payload.get("id")) - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(); - let raw = payload - .get("arguments") - .or_else(|| payload.get("input")) - .cloned() - .unwrap_or(Value::Null); - let arguments = raw - .as_str() - .and_then(|text| serde_json::from_str(text).ok()) - .unwrap_or(raw); - Some(ParsedCall { - name, - call_id, - arguments, - }) -} - -fn codex_call_output_id(record: &Value) -> Option { - let payload = record.get("payload")?; - (payload.get("type").and_then(Value::as_str) == Some("function_call_output")) - .then(|| { - payload - .get("call_id") - .and_then(Value::as_str) - .map(str::to_string) - }) - .flatten() -} - -fn call_scopes_entity( - tool_name: &str, - arguments: &Value, - entity: &SessionScanEntity, - slug: &str, -) -> bool { - if tool_name.ends_with("spawn_agent") || tool_name == "Agent" { - let expected_name = format!("spacedock-ensign-{slug}-"); - let expected_codex_name = format!("spacedock_ensign_{}_", slug.replace('-', "_")); - let name = first_string(arguments, &["task_name", "name"]).unwrap_or_default(); - let prompt = first_string(arguments, &["message", "prompt"]).unwrap_or_default(); - return (name.starts_with(&expected_name) || name.starts_with(&expected_codex_name)) - && contains_dispatch(prompt, slug); - } - - if matches!(tool_name, "exec" | "Bash") { - return arguments - .get("commands") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(Value::as_str) - .any(|command| { - command_contains_exact_path(command, &entity.path) - || contains_dispatch(command, slug) - }); - } - - let keys: &[&str] = match tool_name { - "Read" | "Edit" | "Write" => &["file_path", "path"], - _ => &["path", "uri"], - }; - first_string(arguments, keys).is_some_and(|value| { - value == entity.path.to_string_lossy() || contains_dispatch(value, slug) - }) -} - -fn first_string<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a str> { - keys.iter() - .find_map(|key| value.get(*key).and_then(Value::as_str)) -} - -fn command_contains_exact_path(command: &str, path: &Path) -> bool { - let path = path.to_string_lossy(); - command - .match_indices(path.as_ref()) - .any(|(start, matched)| { - let before = command[..start].chars().next_back(); - let after = command[start + matched.len()..].chars().next(); - before.is_none_or(is_command_boundary) && after.is_none_or(is_command_boundary) - }) -} - -fn is_command_boundary(character: char) -> bool { - character.is_whitespace() - || matches!( - character, - '\'' | '"' | '`' | '=' | ':' | ';' | '|' | '&' | '(' | ')' | '[' | ']' | '{' | '}' - ) -} - -fn is_gate_question(input: &Value) -> bool { - let Some(questions) = input.get("questions").and_then(Value::as_array) else { - return false; - }; - questions.iter().any(|question| { - let gate_named = ["id", "header"] - .iter() - .filter_map(|field| question.get(*field).and_then(Value::as_str)) - .any(|value| value.to_ascii_lowercase().contains("gate")); - let labels: Vec = question - .get("options") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(|option| option.get("label").and_then(Value::as_str)) - .map(|label| label.to_ascii_lowercase()) - .collect(); - let accepts = labels.iter().any(|label| { - ["approve", "pass", "accept"] - .iter() - .any(|term| label.contains(term)) - }); - let rejects = labels.iter().any(|label| { - ["reject", "bounce back"] - .iter() - .any(|term| label.contains(term)) - }); - gate_named && accepts && rejects - }) -} - -fn codex_assignment_text(record: &Value) -> Option { - let payload = record.get("payload")?; - if payload.get("role").and_then(Value::as_str) != Some("user") { - return None; - } - payload - .get("content") - .and_then(Value::as_array)? - .iter() - .filter_map(|item| { - item.get("text") - .or_else(|| item.get("input_text")) - .and_then(Value::as_str) - }) - .collect::>() - .join("\n") - .into() -} - -fn canonical_codex_name(path: &str, slug: &str) -> bool { - STAGES.iter().any(|stage| { - path.rsplit('/').next() - == Some(format!("spacedock_ensign_{}_{}", slug.replace('-', "_"), stage).as_str()) - || path.rsplit('/').next() == Some(format!("spacedock_ensign_{slug}_{stage}").as_str()) - }) -} - -fn contains_dispatch(text: &str, slug: &str) -> bool { - STAGES - .iter() - .any(|stage| text.contains(&format!("{DISPATCH_PREFIX}{slug}-{stage}.md"))) -} - -fn is_claude_sidechain(record: &Value) -> bool { - record - .get("isSidechain") - .and_then(Value::as_bool) - .unwrap_or(false) -} - -fn claude_session_id(file: &ParsedFile) -> String { - file.records - .iter() - .find_map(|record| string_at(record, &["sessionId"])) - .unwrap_or_else(|| file_id(&file.path)) -} - -fn teammate_idle_notification(record: &Value) -> Option { - if string_at(record, &["type"]).as_deref() != Some("user") { - return None; - } - let content = record - .get("message") - .and_then(|message| message.get("content")) - .and_then(Value::as_str)?; - let start = content.find("")? + "".len(); - let end = content[start..].find("")? + start; - let envelope: Value = serde_json::from_str(content[start..end].trim()).ok()?; - (envelope.get("type").and_then(Value::as_str) == Some("idle_notification") - && envelope.get("idleReason").and_then(Value::as_str) == Some("available")) - .then(|| { - envelope - .get("from") - .and_then(Value::as_str) - .map(str::to_string) - }) - .flatten() -} - -fn push_event( - events: &mut Vec, - runtime: AgentRuntime, - session_id: &str, - updated_unix: i64, - kind: ActivityEventKind, -) { - events.push(ActivityEvent { - runtime, - session_id: session_id.to_string(), - updated_unix, - kind, - }); -} - -fn record_type(record: &Value) -> Option<&str> { - record.get("type").and_then(Value::as_str) -} - -fn event_type(record: &Value) -> Option<&str> { - (record_type(record) == Some("event_msg")) - .then(|| { - record - .get("payload") - .and_then(|payload| payload.get("type")) - .and_then(Value::as_str) - }) - .flatten() -} - -fn string_at(value: &Value, path: &[&str]) -> Option { - path.iter() - .try_fold(value, |current, key| current.get(*key)) - .and_then(Value::as_str) - .map(str::to_string) -} - -fn record_timestamp(record: &Value, fallback: i64) -> i64 { - record - .get("timestamp") - .and_then(|timestamp| { - timestamp - .as_i64() - .or_else(|| timestamp.as_str().and_then(parse_rfc3339_unix)) - }) - .unwrap_or(fallback) -} - -fn parse_rfc3339_unix(value: &str) -> Option { - let (date, rest) = value.split_once('T')?; - let mut date_parts = date.split('-'); - let year = date_parts.next()?.parse::().ok()?; - let month = date_parts.next()?.parse::().ok()?; - let day = date_parts.next()?.parse::().ok()?; - - let timezone_index = rest - .char_indices() - .find_map(|(index, ch)| (ch == 'Z' || ch == '+' || ch == '-').then_some(index))?; - let (clock, zone) = rest.split_at(timezone_index); - let mut clock_parts = clock.split(':'); - let hour = clock_parts.next()?.parse::().ok()?; - let minute = clock_parts.next()?.parse::().ok()?; - let second = clock_parts.next()?.split('.').next()?.parse::().ok()?; - let offset = if zone == "Z" { - 0 - } else { - let sign = if zone.starts_with('-') { -1 } else { 1 }; - let mut parts = zone[1..].split(':'); - let hours = parts.next()?.parse::().ok()?; - let minutes = parts.next().unwrap_or("0").parse::().ok()?; - sign * (hours * 3600 + minutes * 60) - }; - Some(days_from_civil(year, month, day) * 86_400 + hour * 3600 + minute * 60 + second - offset) -} - -fn days_from_civil(year: i32, month: u32, day: u32) -> i64 { - let year = year - i32::from(month <= 2); - let era = if year >= 0 { year } else { year - 399 } / 400; - let year_of_era = year - era * 400; - let shifted_month = month as i32 + if month > 2 { -3 } else { 9 }; - let day_of_year = (153 * shifted_month + 2) / 5 + day as i32 - 1; - let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; - (era * 146_097 + day_of_era - 719_468) as i64 -} - -fn file_id(path: &Path) -> String { - path.file_stem() - .and_then(OsStr::to_str) - .unwrap_or("unknown-session") - .to_string() -} - -fn is_pruned_dir(path: &Path) -> bool { - path.file_name() - .and_then(OsStr::to_str) - .is_some_and(|name| matches!(name, ".git" | "node_modules" | "target")) -} - -fn is_session_file(path: &Path) -> bool { - matches!( - path.extension().and_then(OsStr::to_str), - Some("jsonl" | "json") - ) -} - -fn system_time_unix(time: SystemTime) -> Option { - time.duration_since(UNIX_EPOCH) - .ok() - .map(|duration| duration.as_secs() as i64) -} - -#[cfg(test)] -static SESSION_FILE_PARSE_STARTS: LazyLock>>> = - LazyLock::new(|| Mutex::new(HashMap::new())); - -#[cfg(test)] -fn record_session_file_parse(path: &Path, start: u64) { - SESSION_FILE_PARSE_STARTS - .lock() - .expect("session parse count lock") - .entry(path.to_path_buf()) - .or_default() - .push(start); -} - -#[cfg(test)] -fn session_file_parse_count(path: &Path) -> usize { - session_file_parse_starts(path).len() -} - -#[cfg(test)] -fn session_file_parse_starts(path: &Path) -> Vec { - SESSION_FILE_PARSE_STARTS - .lock() - .expect("session parse count lock") - .get(path) - .cloned() - .unwrap_or_default() -} - -#[cfg(test)] -mod tests { - use super::*; + use super::*; + use crate::domain::{ActivityHandler, EntityActivity}; fn event( at: i64, @@ -1715,12 +444,16 @@ mod tests { runtime, session_id: session.to_string(), updated_unix: at, + updated_subsecond_nanos: 0, + source: PathBuf::from("manual"), + byte_offset: at as u64, + evidence_kind_rank: 0, kind, } } #[test] - fn reducer_covers_handoff_next_worker_gate_and_precedence() { + fn reducer_is_deterministic_and_preserves_precedence_and_handoff() { let events = vec![ event( 1, @@ -1731,577 +464,739 @@ mod tests { event( 2, AgentRuntime::Codex, - "worker-1", + "worker", ActivityEventKind::WorkerStarted, ), event( 3, AgentRuntime::Codex, - "worker-1", + "worker", ActivityEventKind::WorkerStopped, ), ]; - assert_eq!( - reduce_activity(&events).status_label(), - "running · FO", - "worker completion must reveal the still-open FO handoff" - ); - - let mut next_worker = events.clone(); - next_worker.push(event( + assert_eq!(reduce_activity(&events).status_label(), "running · FO"); + let mut shuffled = vec![events[2].clone(), events[0].clone(), events[1].clone()]; + assert_eq!(reduce_activity(&shuffled), reduce_activity(&events)); + shuffled.push(event( 4, AgentRuntime::ClaudeCode, "worker-2", ActivityEventKind::WorkerStarted, )); - assert_eq!( - reduce_activity(&next_worker).status_label(), - "running · worker" - ); - - next_worker.push(event( + shuffled.push(event( 5, AgentRuntime::Codex, "fo", ActivityEventKind::HumanGateOpened { - call_id: "gate-1".to_string(), + call_id: "gate".to_string(), }, )); - assert_eq!(reduce_activity(&next_worker).status_label(), "human-gate"); - next_worker.push(event( - 6, + assert_eq!(reduce_activity(&shuffled).status_label(), "human-gate"); + + let mut same_time_start = event( + 10, AgentRuntime::Codex, - "fo", - ActivityEventKind::HumanGateResolved { - call_id: "gate-1".to_string(), - }, - )); - assert_eq!( - reduce_activity(&next_worker).status_label(), - "running · worker" + "same-time", + ActivityEventKind::WorkerStarted, ); - } - - #[test] - fn reducer_returns_idle_with_terminal_timestamp() { - let activity = reduce_activity(&[ - event( - 10, - AgentRuntime::Codex, - "worker", - ActivityEventKind::WorkerStarted, - ), - event( - 12, - AgentRuntime::Codex, - "worker", - ActivityEventKind::WorkerStopped, - ), - ]); + same_time_start.byte_offset = 1; + let mut same_time_stop = event( + 10, + AgentRuntime::Codex, + "same-time", + ActivityEventKind::WorkerStopped, + ); + same_time_stop.byte_offset = 2; assert_eq!( - activity, - EntityActivity::Idle { - updated_unix: Some(12) - } + reduce_activity(&[same_time_stop, same_time_start]).status_label(), + "idle", + "source byte order must settle equal-timestamp lifecycle records" ); } - #[test] - fn rfc3339_parser_handles_utc_and_offsets() { - assert_eq!(parse_rfc3339_unix("1970-01-01T00:00:00Z"), Some(0)); - assert_eq!(parse_rfc3339_unix("1970-01-01T08:00:00+08:00"), Some(0)); - } - fn fixture_root(name: &str) -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../tests/fixtures/session-activity") .join(name) } - fn scan_fixture(runtime: AgentRuntime, fixture: &str) -> SessionScanReport { - let entity = SessionScanEntity { - id: "069".to_string(), - path: PathBuf::from("/repo/docs/state/detect-entity-activity-state.md"), - worktree: None, + fn entity() -> SessionScanEntity { + SessionScanEntity { + id: "075".to_string(), + path: PathBuf::from("/repo/docs/state/stable-agent-activity-detection.md"), + worktree: Some(".worktrees/stable-agent-activity-detection".to_string()), worktree_source: None, - }; - let root = fixture_root(fixture); + } + } + + fn fixture_request(runtime: AgentRuntime, fixture: &str) -> SessionScanRequest { let roots = match runtime { AgentRuntime::Codex => SessionRoots { - codex: vec![root], + codex: vec![fixture_root(fixture)], claude_code: Vec::new(), }, AgentRuntime::ClaudeCode => SessionRoots { codex: Vec::new(), - claude_code: vec![root], + claude_code: vec![fixture_root(fixture)], }, }; - scan_local_sessions_with( - &SessionScanRequest { - workflow_dir: PathBuf::from("/repo/docs"), - repo_root: PathBuf::from("/repo"), - entities: vec![entity], - roots, - previous_session_files: HashMap::new(), - }, + SessionScanRequest { + workflow_dir: PathBuf::from("/repo/docs"), + repo_root: PathBuf::from("/repo"), + entities: vec![entity()], + roots, + previous_state: SessionScanState::default(), + } + } + + fn legacy_fixture_request(runtime: AgentRuntime, fixture: &str) -> SessionScanRequest { + let mut request = fixture_request(runtime, fixture); + request.entities = vec![SessionScanEntity { + id: "069".to_string(), + path: PathBuf::from("/repo/docs/state/detect-entity-activity-state.md"), + worktree: None, + worktree_source: None, + }]; + request + } + + #[test] + fn codex_v2_parent_start_fixture_runs_and_exact_mismatches_fail_closed() { + let report = scan_local_sessions_with( + &fixture_request(AgentRuntime::Codex, "codex-v2-worker-open"), + &StdProcessProbe, + UNIX_EPOCH, + ) + .expect("fixture"); + assert_eq!( + report.attributions[0].activity, + EntityActivity::Running { + handler: ActivityHandler::Worker, + runtime: AgentRuntime::Codex, + session_id: "codex-child".to_string(), + updated_unix: 2, + } + ); + + for fixture in [ + "codex-v2-wrong-parent", + "codex-v2-wrong-path", + "codex-v2-wrong-cwd", + ] { + let report = scan_local_sessions_with( + &fixture_request(AgentRuntime::Codex, fixture), + &StdProcessProbe, + UNIX_EPOCH, + ) + .expect("negative fixture"); + assert_eq!( + report.attributions[0].activity.status_label(), + "idle", + "{fixture} must fail closed" + ); + } + } + + #[test] + fn legacy_worker_correlations_and_terminals_remain_supported() { + let codex_open = scan_local_sessions_with( + &legacy_fixture_request(AgentRuntime::Codex, "codex-worker-open"), + &StdProcessProbe, + UNIX_EPOCH, + ) + .expect("legacy Codex"); + assert_eq!( + codex_open.attributions[0].activity.session_id(), + Some("codex-worker-redacted") + ); + let codex_complete = scan_local_sessions_with( + &legacy_fixture_request(AgentRuntime::Codex, "codex-worker-complete"), + &StdProcessProbe, + UNIX_EPOCH, + ) + .expect("legacy Codex complete"); + assert_eq!( + codex_complete.attributions[0].activity.status_label(), + "idle" + ); + let codex_unlinked = scan_local_sessions_with( + &legacy_fixture_request(AgentRuntime::Codex, "codex-worker-unlinked"), &StdProcessProbe, UNIX_EPOCH, ) - .expect("fixture scan") - } - - #[test] - fn codex_worker_fixture_requires_canonical_child_and_exact_assignment() { - let report = scan_fixture(AgentRuntime::Codex, "codex-worker-open"); - assert_eq!(report.errors, Vec::::new()); + .expect("legacy Codex unlinked"); assert_eq!( - report.attributions[0].activity.status_label(), - "running · worker" + codex_unlinked.attributions[0].activity.status_label(), + "idle" ); + + let claude_open = scan_local_sessions_with( + &legacy_fixture_request(AgentRuntime::ClaudeCode, "claude-worker-open"), + &StdProcessProbe, + UNIX_EPOCH, + ) + .expect("legacy Claude"); assert_eq!( - report.attributions[0].activity.session_id(), - Some("codex-worker-redacted") + claude_open.attributions[0].activity.session_id(), + Some("claude-worker-redacted") ); + let claude_idle = scan_local_sessions_with( + &legacy_fixture_request(AgentRuntime::ClaudeCode, "claude-worker-idle"), + &StdProcessProbe, + UNIX_EPOCH, + ) + .expect("legacy Claude idle"); + assert_eq!(claude_idle.attributions[0].activity.status_label(), "idle"); } #[test] - fn codex_worker_fixture_requires_non_empty_parent_thread_linkage() { - let report = scan_fixture(AgentRuntime::Codex, "codex-worker-unlinked"); + fn claude_modern_fixture_correlates_prefixed_dispatch_meta_and_sidechain() { + let report = scan_local_sessions_with( + &fixture_request(AgentRuntime::ClaudeCode, "claude-modern-worker-open"), + &StdProcessProbe, + UNIX_EPOCH, + ) + .expect("fixture"); + assert_eq!( + report.attributions[0].activity.session_id(), + Some("claude-worker") + ); assert_eq!( report.attributions[0].activity.status_label(), - "idle", - "a child-shaped session without its parent thread must fail closed" + "running · worker" ); } #[test] - fn codex_task_complete_closes_only_the_open_worker_turn() { - let report = scan_fixture(AgentRuntime::Codex, "codex-worker-complete"); - assert_eq!(report.attributions[0].activity.status_label(), "idle"); - assert!(report.attributions[0].activity.updated_unix().is_some()); - } - - #[test] - fn codex_gate_fixture_requires_scoped_fo_turn_and_balanced_options() { - let report = scan_fixture(AgentRuntime::Codex, "codex-fo-gate"); - assert_eq!(report.attributions[0].activity.status_label(), "human-gate"); + fn claude_modern_correlation_fails_closed_across_cwd_parent_and_ambiguous_calls() { + for fixture in [ + "claude-modern-wrong-cwd", + "claude-modern-cross-parent", + "claude-modern-duplicate-call", + ] { + let report = scan_local_sessions_with( + &fixture_request(AgentRuntime::ClaudeCode, fixture), + &StdProcessProbe, + UNIX_EPOCH, + ) + .expect("negative fixture"); + assert_eq!( + report.attributions[0].activity.status_label(), + "idle", + "{fixture} must fail closed" + ); + } } #[test] - fn codex_exec_custom_call_scopes_nested_executable_commands_only() { - for fixture in ["codex-fo-exec", "codex-fo-exec-nested"] { - let report = scan_fixture(AgentRuntime::Codex, fixture); + fn existing_first_officer_and_gate_evidence_keeps_exact_scope() { + for fixture in [ + "codex-fo-exec", + "codex-fo-exec-nested", + "codex-fo-exec-command", + ] { + let report = scan_local_sessions_with( + &legacy_fixture_request(AgentRuntime::Codex, fixture), + &StdProcessProbe, + UNIX_EPOCH, + ) + .expect("Codex FO fixture"); assert_eq!( report.attributions[0].activity.status_label(), - "running · FO", - "fixture {fixture} must recognize exact entity scope" + "running · FO" ); } - let text_only = scan_fixture(AgentRuntime::Codex, "codex-fo-exec-text-only"); + let text_only = scan_local_sessions_with( + &legacy_fixture_request(AgentRuntime::Codex, "codex-fo-exec-text-only"), + &StdProcessProbe, + UNIX_EPOCH, + ) + .expect("text-only fixture"); + assert_eq!(text_only.attributions[0].activity.status_label(), "idle"); + + let codex_gate = scan_local_sessions_with( + &legacy_fixture_request(AgentRuntime::Codex, "codex-fo-gate"), + &StdProcessProbe, + UNIX_EPOCH, + ) + .expect("Codex gate"); assert_eq!( - text_only.attributions[0].activity.status_label(), - "idle", - "a non-executing text(path) mention in the module must fail closed" + codex_gate.attributions[0].activity.status_label(), + "human-gate" ); - } - - #[test] - fn claude_worker_fixture_correlates_agent_call_meta_and_sidechain() { - let report = scan_fixture(AgentRuntime::ClaudeCode, "claude-worker-open"); - assert_eq!(report.errors, Vec::::new()); + let claude_gate = scan_local_sessions_with( + &legacy_fixture_request(AgentRuntime::ClaudeCode, "claude-fo-gate"), + &StdProcessProbe, + UNIX_EPOCH, + ) + .expect("Claude gate"); assert_eq!( - report.attributions[0].activity.status_label(), - "running · worker" + claude_gate.attributions[0].activity.status_label(), + "human-gate" ); + let claude_complete = scan_local_sessions_with( + &legacy_fixture_request(AgentRuntime::ClaudeCode, "claude-fo-complete"), + &StdProcessProbe, + UNIX_EPOCH, + ) + .expect("Claude complete"); assert_eq!( - report.attributions[0].activity.session_id(), - Some("claude-worker-redacted") + claude_complete.attributions[0].activity.status_label(), + "idle" ); } #[test] - fn claude_idle_notification_closes_the_correlated_worker() { - let report = scan_fixture(AgentRuntime::ClaudeCode, "claude-worker-idle"); - assert_eq!(report.attributions[0].activity.status_label(), "idle"); - assert!(report.attributions[0].activity.updated_unix().is_some()); - } - - #[test] - fn claude_same_name_activity_stays_linked_to_exact_parent_and_call() { - let report = scan_fixture(AgentRuntime::ClaudeCode, "claude-two-parent-same-name"); + fn codex_evidence_survives_unchanged_partial_truncate_rotate_and_delete() { + let temp = tempfile::tempdir().expect("temp"); + let child = temp.path().join("child.jsonl"); + let parent = temp.path().join("parent.jsonl"); + fs::copy(fixture_root("codex-v2-worker-open/child.jsonl"), &child).expect("child"); + fs::copy(fixture_root("codex-v2-worker-open/parent.jsonl"), &parent).expect("parent"); + let base = SessionScanRequest { + roots: SessionRoots { + codex: vec![temp.path().to_path_buf()], + claude_code: Vec::new(), + }, + ..fixture_request(AgentRuntime::Codex, "codex-v2-worker-open") + }; + let first = + scan_local_sessions_with_state(&base, &StdProcessProbe, UNIX_EPOCH).expect("first"); assert_eq!( - report.attributions[0].activity.status_label(), + first.report.attributions[0].activity.status_label(), "running · worker" ); + let unchanged = scan_local_sessions_with_state( + &SessionScanRequest { + previous_state: first.state, + ..base.clone() + }, + &StdProcessProbe, + UNIX_EPOCH, + ) + .expect("unchanged"); assert_eq!( - report.attributions[0].activity.session_id(), - Some("worker-a"), - "parent B metadata and idle notification must not start or stop parent A's worker" + unchanged.report.attributions[0].activity.status_label(), + "running · worker" ); - } - #[test] - fn claude_ambiguous_same_parent_calls_without_call_metadata_fail_closed() { - let temp = tempfile::tempdir().expect("temp"); - let subagents = temp.path().join("parent/subagents"); - fs::create_dir_all(&subagents).expect("subagents"); - let dispatch = "Read /tmp/spacedock-dispatch/spacedock-ensign-detect-entity-activity-state-implement.md and treat its content as your assignment."; - fs::write( - temp.path().join("parent.jsonl"), - format!( - r#"{{"timestamp":1,"type":"assistant","sessionId":"parent","isSidechain":false,"message":{{"content":[{{"type":"tool_use","id":"call-a","name":"Agent","input":{{"name":"spacedock-ensign-detect-entity-activity-state-implement","prompt":"{dispatch}"}}}},{{"type":"tool_use","id":"call-b","name":"Agent","input":{{"name":"spacedock-ensign-detect-entity-activity-state-implement","prompt":"{dispatch}"}}}}],"stop_reason":"end_turn"}}}}"# - ), - ) - .expect("parent"); - fs::write( - subagents.join("worker.meta.json"), - r#"{"taskKind":"in_process_teammate","name":"spacedock-ensign-detect-entity-activity-state-implement","agentId":"worker"}"#, + let mut append = fs::OpenOptions::new() + .append(true) + .open(&child) + .expect("append"); + write!(append, "{{\"timestamp\":3").expect("partial"); + let partial = scan_local_sessions_with_state( + &SessionScanRequest { + previous_state: unchanged.state, + ..base.clone() + }, + &StdProcessProbe, + UNIX_EPOCH, ) - .expect("meta"); + .expect("partial"); + assert_eq!( + partial.report.attributions[0].activity.status_label(), + "running · worker" + ); + fs::write( - subagents.join("worker.jsonl"), - r#"{"timestamp":2,"type":"assistant","sessionId":"child","isSidechain":true,"agentId":"worker","message":{"content":[{"type":"text","text":"accepted"}],"stop_reason":null}}"#, + &child, + "{\"timestamp\":1,\"type\":\"session_meta\",\"payload\":{\"id\":\"codex-child\",\"cwd\":\"/repo/.worktrees/stable-agent-activity-detection\",\"source\":{\"subagent\":{\"thread_spawn\":{\"agent_path\":\"/root/spacedock_ensign_stable_agent_activity_detection_implement\",\"parent_thread_id\":\"codex-parent\"}}}}}\n", ) - .expect("child"); - - let report = scan_local_sessions_with( + .expect("truncate"); + let truncated = scan_local_sessions_with_state( &SessionScanRequest { - workflow_dir: PathBuf::from("/repo/docs"), - repo_root: PathBuf::from("/repo"), - entities: vec![SessionScanEntity { - id: "069".to_string(), - path: PathBuf::from("/repo/docs/state/detect-entity-activity-state.md"), - worktree: None, - worktree_source: None, - }], - roots: SessionRoots { - codex: Vec::new(), - claude_code: vec![temp.path().to_path_buf()], - }, - previous_session_files: HashMap::new(), + previous_state: partial.state, + ..base.clone() }, &StdProcessProbe, UNIX_EPOCH, ) - .expect("scan"); + .expect("truncated"); assert_eq!( - report.attributions[0].activity.status_label(), - "idle", - "metadata without a call id must not choose between same-parent duplicate names" + truncated.report.attributions[0].activity.status_label(), + "running · worker", + "truncation without a terminal fact must retain the proven start" ); - } - #[test] - fn claude_ambiguous_same_parent_idle_name_does_not_stop_exact_call() { - let temp = tempfile::tempdir().expect("temp"); - let subagents = temp.path().join("parent/subagents"); - fs::create_dir_all(&subagents).expect("subagents"); - let dispatch = "Read /tmp/spacedock-dispatch/spacedock-ensign-detect-entity-activity-state-implement.md and treat its content as your assignment."; - fs::write( - temp.path().join("parent.jsonl"), - format!( - r#"{{"timestamp":1,"type":"assistant","sessionId":"parent","isSidechain":false,"message":{{"content":[{{"type":"tool_use","id":"call-a","name":"Agent","input":{{"name":"spacedock-ensign-detect-entity-activity-state-implement","prompt":"{dispatch}"}}}},{{"type":"tool_use","id":"call-b","name":"Agent","input":{{"name":"spacedock-ensign-detect-entity-activity-state-implement","prompt":"{dispatch}"}}}}],"stop_reason":"end_turn"}}}} -{{"timestamp":3,"type":"user","sessionId":"parent","isSidechain":false,"message":{{"content":"{{\"type\":\"idle_notification\",\"from\":\"spacedock-ensign-detect-entity-activity-state-implement\",\"idleReason\":\"available\"}}"}}}}"# - ), - ) - .expect("parent"); + let rotated_child = temp.path().join("child.jsonl.1"); + fs::rename(&child, &rotated_child).expect("rotate old child"); fs::write( - subagents.join("worker.meta.json"), - r#"{"taskKind":"in_process_teammate","name":"spacedock-ensign-detect-entity-activity-state-implement","agentId":"worker","parentSessionId":"parent","parentToolUseId":"call-a"}"#, + &child, + "{\"timestamp\":4,\"type\":\"session_meta\",\"payload\":{\"id\":\"codex-child\",\"cwd\":\"/repo/.worktrees/stable-agent-activity-detection\",\"source\":{\"subagent\":{\"thread_spawn\":{\"agent_path\":\"/root/spacedock_ensign_stable_agent_activity_detection_implement\",\"parent_thread_id\":\"codex-parent\"}}},\"replacement\":true}}\n", ) - .expect("meta"); - fs::write( - subagents.join("worker.jsonl"), - r#"{"timestamp":2,"type":"assistant","sessionId":"child","isSidechain":true,"agentId":"worker","message":{"content":[{"type":"text","text":"accepted"}],"stop_reason":null}}"#, + .expect("replacement child"); + let rotated = scan_local_sessions_with_state( + &SessionScanRequest { + previous_state: truncated.state, + ..base.clone() + }, + &StdProcessProbe, + UNIX_EPOCH, ) - .expect("child"); + .expect("rotated"); + assert_eq!( + rotated.report.attributions[0].activity.status_label(), + "running · worker", + "renaming the old log and replacing it without a terminal fact must retain the start" + ); - let report = scan_local_sessions_with( + fs::remove_file(&child).expect("delete"); + fs::remove_file(&parent).expect("delete"); + fs::remove_file(&rotated_child).expect("delete rotated"); + let deleted = scan_local_sessions_with_state( &SessionScanRequest { - workflow_dir: PathBuf::from("/repo/docs"), - repo_root: PathBuf::from("/repo"), - entities: vec![SessionScanEntity { - id: "069".to_string(), - path: PathBuf::from("/repo/docs/state/detect-entity-activity-state.md"), - worktree: None, - worktree_source: None, - }], - roots: SessionRoots { - codex: Vec::new(), - claude_code: vec![temp.path().to_path_buf()], - }, - previous_session_files: HashMap::new(), + previous_state: rotated.state, + ..base }, &StdProcessProbe, UNIX_EPOCH, ) - .expect("scan"); + .expect("deleted"); assert_eq!( - report.attributions[0].activity.status_label(), + deleted.report.attributions[0].activity.status_label(), "running · worker", - "name-only idle evidence must not choose between same-parent duplicate dispatches" + "deletion alone must not synthesize a stop" ); } #[test] - fn claude_gate_and_end_turn_records_drive_exact_fo_transitions() { - let gate = scan_fixture(AgentRuntime::ClaudeCode, "claude-fo-gate"); - assert_eq!(gate.attributions[0].activity.status_label(), "human-gate"); - - let complete = scan_fixture(AgentRuntime::ClaudeCode, "claude-fo-complete"); - assert_eq!(complete.attributions[0].activity.status_label(), "idle"); - assert!(complete.attributions[0].activity.updated_unix().is_some()); - } - - #[test] - fn ordinary_path_mentions_do_not_create_activity() { + fn exact_codex_terminal_closes_retained_worker() { let temp = tempfile::tempdir().expect("temp"); - fs::write( - temp.path().join("mention.jsonl"), - r#"{"timestamp":1,"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"/repo/docs/state/detect-entity-activity-state.md approve"}]}}"#, + let child = temp.path().join("child.jsonl"); + fs::copy(fixture_root("codex-v2-worker-open/child.jsonl"), &child).expect("child"); + fs::copy( + fixture_root("codex-v2-worker-open/parent.jsonl"), + temp.path().join("parent.jsonl"), ) - .expect("fixture"); - let entity = SessionScanEntity { - id: "069".to_string(), - path: PathBuf::from("/repo/docs/state/detect-entity-activity-state.md"), - worktree: None, - worktree_source: None, + .expect("parent"); + let base = SessionScanRequest { + roots: SessionRoots { + codex: vec![temp.path().to_path_buf()], + claude_code: Vec::new(), + }, + ..fixture_request(AgentRuntime::Codex, "codex-v2-worker-open") }; - let report = scan_local_sessions_with( + let first = + scan_local_sessions_with_state(&base, &StdProcessProbe, UNIX_EPOCH).expect("first"); + let first_cursor = first.state.files[&child].cursor; + let second = scan_local_sessions_with_state( &SessionScanRequest { - workflow_dir: PathBuf::from("/repo/docs"), - repo_root: PathBuf::from("/repo"), - entities: vec![entity], - roots: SessionRoots { - codex: vec![temp.path().to_path_buf()], - claude_code: Vec::new(), - }, - previous_session_files: HashMap::new(), + previous_state: first.state, + ..base.clone() + }, + &StdProcessProbe, + UNIX_EPOCH, + ) + .expect("second"); + let third = scan_local_sessions_with_state( + &SessionScanRequest { + previous_state: second.state, + ..base.clone() + }, + &StdProcessProbe, + UNIX_EPOCH, + ) + .expect("third"); + let mut append = fs::OpenOptions::new() + .append(true) + .open(&child) + .expect("append"); + writeln!(append, r#"{{"timestamp":4,"type":"event_msg","payload":{{"type":"task_complete","turn_id":"turn"}}}}"#).expect("terminal"); + let complete = scan_local_sessions_with_state( + &SessionScanRequest { + previous_state: third.state, + ..base }, &StdProcessProbe, UNIX_EPOCH, ) - .expect("scan"); - assert_eq!(report.attributions[0].activity, EntityActivity::default()); + .expect("complete"); + assert_eq!( + complete.report.attributions[0].activity.status_label(), + "idle" + ); + println!("Codex replay: running, running, running, idle"); + assert_eq!(session_file_parse_starts(&child), vec![0, first_cursor]); } #[test] - fn malformed_lines_are_reported_without_discarding_valid_transitions() { + fn malformed_claude_meta_does_not_replace_valid_identity() { let temp = tempfile::tempdir().expect("temp"); - let fixture = fs::read_to_string(fixture_root("codex-worker-open/rollout.jsonl")) - .expect("source fixture"); - fs::write( - temp.path().join("rollout.jsonl"), - format!("{fixture}\nnot-json\n"), - ) - .expect("fixture"); - let entity = SessionScanEntity { - id: "069".to_string(), - path: PathBuf::from("/repo/docs/state/detect-entity-activity-state.md"), - worktree: None, - worktree_source: None, + copy_fixture_tree(&fixture_root("claude-modern-worker-open"), temp.path()); + let base = SessionScanRequest { + roots: SessionRoots { + codex: Vec::new(), + claude_code: vec![temp.path().to_path_buf()], + }, + ..fixture_request(AgentRuntime::ClaudeCode, "claude-modern-worker-open") }; - let report = scan_local_sessions_with( + let first = + scan_local_sessions_with_state(&base, &StdProcessProbe, UNIX_EPOCH).expect("first"); + let meta = temp.path().join("claude-parent/subagents/worker.meta.json"); + fs::write(&meta, "{").expect("malformed"); + let malformed = scan_local_sessions_with_state( &SessionScanRequest { - workflow_dir: PathBuf::from("/repo/docs"), - repo_root: PathBuf::from("/repo"), - entities: vec![entity], - roots: SessionRoots { - codex: vec![temp.path().to_path_buf()], - claude_code: Vec::new(), - }, - previous_session_files: HashMap::new(), + previous_state: first.state, + ..base }, &StdProcessProbe, UNIX_EPOCH, ) - .expect("scan"); + .expect("malformed"); assert_eq!( - report.attributions[0].activity.status_label(), + malformed.report.attributions[0].activity.status_label(), "running · worker" ); - assert_eq!(report.errors.len(), 1); + assert!(!malformed.report.errors.is_empty()); } #[test] - fn large_append_truncation_and_deletion_never_create_false_idle() { - use std::io::Write; - + fn claude_replay_is_stable_then_stops_reopens_and_stops() { let temp = tempfile::tempdir().expect("temp"); - let path = temp.path().join("rollout.jsonl"); - let fixture = fs::read_to_string(fixture_root("codex-worker-open/rollout.jsonl")) - .expect("source fixture"); - let mut file = fs::File::create(&path).expect("large fixture"); - for _ in 0..90_000 { - writeln!(file, r#"{{"type":"noise","padding":"{}"}}"#, "x".repeat(32)).expect("noise"); - } - file.write_all(fixture.as_bytes()).expect("worker records"); - drop(file); - assert!( - fs::metadata(&path).expect("metadata").len() > 4_000_000, - "regression fixture must exceed the removed cutoff" - ); - - let base_request = SessionScanRequest { - workflow_dir: PathBuf::from("/repo/docs"), - repo_root: PathBuf::from("/repo"), - entities: vec![SessionScanEntity { - id: "069".to_string(), - path: PathBuf::from("/repo/docs/state/detect-entity-activity-state.md"), - worktree: None, - worktree_source: None, - }], + copy_fixture_tree(&fixture_root("claude-modern-worker-open"), temp.path()); + let parent = temp.path().join("parent.jsonl"); + let child = temp.path().join("claude-parent/subagents/worker.jsonl"); + let base = SessionScanRequest { roots: SessionRoots { - codex: vec![temp.path().to_path_buf()], - claude_code: Vec::new(), + codex: Vec::new(), + claude_code: vec![temp.path().to_path_buf()], }, - previous_session_files: HashMap::new(), + ..fixture_request(AgentRuntime::ClaudeCode, "claude-modern-worker-open") }; - let first = scan_local_sessions_with_snapshots(&base_request, &StdProcessProbe, UNIX_EPOCH) - .expect("large scan"); + let first = + scan_local_sessions_with_state(&base, &StdProcessProbe, UNIX_EPOCH).expect("first"); + let second = scan_local_sessions_with_state( + &SessionScanRequest { + previous_state: first.state, + ..base.clone() + }, + &StdProcessProbe, + UNIX_EPOCH, + ) + .expect("second"); + let third = scan_local_sessions_with_state( + &SessionScanRequest { + previous_state: second.state, + ..base.clone() + }, + &StdProcessProbe, + UNIX_EPOCH, + ) + .expect("third"); assert_eq!( - first.report.attributions[0].activity.status_label(), - "running · worker" + [ + first.report.attributions[0].activity.status_label(), + second.report.attributions[0].activity.status_label(), + third.report.attributions[0].activity.status_label(), + ], + ["running · worker"; 3] ); - let first_cursor = first.session_files[&path].cursor; - assert_eq!(session_file_parse_starts(&path), vec![0]); - let mut append = fs::OpenOptions::new() + let mut parent_append = fs::OpenOptions::new() .append(true) - .open(&path) - .expect("append"); + .open(&parent) + .expect("parent append"); writeln!( - append, - r#"{{"timestamp":"2026-07-27T10:00:03Z","type":"event_msg","payload":{{"type":"task_complete","turn_id":"worker-turn-redacted"}}}}"# + parent_append, + r#"{{"timestamp":3,"type":"user","sessionId":"claude-parent","cwd":"/repo","isSidechain":false,"message":{{"content":"{{\"type\":\"idle_notification\",\"from\":\"spacedock-ensign-stable-agent-activity-detection-implement\",\"idleReason\":\"available\"}}"}}}}"# ) - .expect("terminal event"); - let appended = scan_local_sessions_with_snapshots( + .expect("idle"); + let stopped = scan_local_sessions_with_state( &SessionScanRequest { - previous_session_files: first.session_files, - ..base_request.clone() + previous_state: third.state, + ..base.clone() }, &StdProcessProbe, UNIX_EPOCH, ) - .expect("append scan"); + .expect("stopped"); assert_eq!( - appended.report.attributions[0].activity.status_label(), + stopped.report.attributions[0].activity.status_label(), "idle" ); - assert_eq!( - session_file_parse_starts(&path), - vec![0, first_cursor], - "append scanning must resume at the saved byte cursor" - ); - fs::write(&path, &fixture).expect("truncate to open worker"); - let truncated = scan_local_sessions_with_snapshots( + writeln!( + parent_append, + r#"{{"timestamp":4,"type":"user","sessionId":"claude-parent","cwd":"/repo","isSidechain":false,"message":{{"content":"{{\"type\":\"teammate_message\",\"from\":\"spacedock-ensign-stable-agent-activity-detection-implement\"}}"}}}}"# + ) + .expect("follow-up boundary"); + let mut child_append = fs::OpenOptions::new() + .append(true) + .open(&child) + .expect("child append"); + writeln!( + child_append, + r#"{{"timestamp":5,"type":"assistant","sessionId":"claude-child","cwd":"/repo/.worktrees/stable-agent-activity-detection","isSidechain":true,"agentId":"claude-worker","message":{{"content":[{{"type":"text","text":"follow-up"}}],"stop_reason":null}}}}"# + ) + .expect("follow-up assistant"); + let reopened = scan_local_sessions_with_state( &SessionScanRequest { - previous_session_files: appended.session_files, - ..base_request.clone() + previous_state: stopped.state, + ..base.clone() }, &StdProcessProbe, UNIX_EPOCH, ) - .expect("truncation scan"); + .expect("reopened"); assert_eq!( - truncated.report.attributions[0].activity.status_label(), + reopened.report.attributions[0].activity.status_label(), "running · worker" ); + + writeln!( + parent_append, + r#"{{"timestamp":6,"type":"user","sessionId":"claude-parent","cwd":"/repo","isSidechain":false,"message":{{"content":"{{\"type\":\"idle_notification\",\"from\":\"spacedock-ensign-stable-agent-activity-detection-implement\",\"idleReason\":\"available\"}}"}}}}"# + ) + .expect("second idle"); + let final_stop = scan_local_sessions_with_state( + &SessionScanRequest { + previous_state: reopened.state, + ..base + }, + &StdProcessProbe, + UNIX_EPOCH, + ) + .expect("final stop"); assert_eq!( - session_file_parse_starts(&path), - vec![0, first_cursor, 0], - "truncation must invalidate the cursor and rebuild the summary" + final_stop.report.attributions[0].activity.status_label(), + "idle" ); + println!("Claude replay: running, running, idle, running, idle"); + } + + #[test] + fn claude_reopens_when_lifecycle_records_share_one_second() { + let temp = tempfile::tempdir().expect("temp"); + copy_fixture_tree(&fixture_root("claude-modern-worker-open"), temp.path()); + let parent = temp.path().join("parent.jsonl"); + let child = temp.path().join("claude-parent/subagents/worker.jsonl"); + let base = SessionScanRequest { + roots: SessionRoots { + codex: Vec::new(), + claude_code: vec![temp.path().to_path_buf()], + }, + ..fixture_request(AgentRuntime::ClaudeCode, "claude-modern-worker-open") + }; + let first = + scan_local_sessions_with_state(&base, &StdProcessProbe, UNIX_EPOCH).expect("first"); + + let mut parent_append = fs::OpenOptions::new() + .append(true) + .open(&parent) + .expect("parent append"); + writeln!( + parent_append, + r#"{{"timestamp":"2026-07-28T10:00:00.100Z","type":"user","sessionId":"claude-parent","cwd":"/repo","isSidechain":false,"message":{{"content":"{{\"type\":\"idle_notification\",\"from\":\"spacedock-ensign-stable-agent-activity-detection-implement\",\"idleReason\":\"available\"}}"}}}}"# + ) + .expect("idle"); + writeln!( + parent_append, + r#"{{"timestamp":"2026-07-28T10:00:00.200Z","type":"user","sessionId":"claude-parent","cwd":"/repo","isSidechain":false,"message":{{"content":"{{\"type\":\"teammate_message\",\"from\":\"spacedock-ensign-stable-agent-activity-detection-implement\"}}"}}}}"# + ) + .expect("follow-up"); + drop(parent_append); + let mut child_append = fs::OpenOptions::new() + .append(true) + .open(&child) + .expect("child append"); + writeln!( + child_append, + r#"{{"timestamp":"2026-07-28T10:00:00.300Z","type":"assistant","sessionId":"claude-child","cwd":"/repo/.worktrees/stable-agent-activity-detection","isSidechain":true,"agentId":"claude-worker","message":{{"content":[{{"type":"text","text":"follow-up"}}],"stop_reason":null}}}}"# + ) + .expect("assistant"); + drop(child_append); - fs::remove_file(&path).expect("delete fixture"); - let deleted = scan_local_sessions_with_snapshots( + let reopened = scan_local_sessions_with_state( &SessionScanRequest { - previous_session_files: truncated.session_files, - ..base_request + previous_state: first.state, + ..base }, &StdProcessProbe, UNIX_EPOCH, ) - .expect("deletion scan"); + .expect("reopened"); assert_eq!( - deleted.report.attributions[0].activity.status_label(), - "idle" + reopened.report.attributions[0].activity.status_label(), + "running · worker", + "the fractional RFC3339 order idle -> boundary -> assistant must reopen the worker" ); - assert!(deleted.session_files.is_empty()); assert_eq!( - session_file_parse_starts(&path), - vec![0, first_cursor, 0], - "deletion must drop the snapshot without reading a missing file" + reopened.report.attributions[0].activity.updated_unix(), + Some(1_785_232_800), + "the display timestamp remains whole Unix seconds" ); } #[test] - fn unchanged_scan_reuses_projected_summary_without_rereading_file() { + fn codex_restart_after_parent_stop_in_same_second_remains_running() { let temp = tempfile::tempdir().expect("temp"); - let path = temp.path().join("rollout.jsonl"); - fs::copy(fixture_root("codex-worker-open/rollout.jsonl"), &path).expect("fixture"); - let base_request = SessionScanRequest { - workflow_dir: PathBuf::from("/repo/docs"), - repo_root: PathBuf::from("/repo"), - entities: vec![SessionScanEntity { - id: "069".to_string(), - path: PathBuf::from("/repo/docs/state/detect-entity-activity-state.md"), - worktree: None, - worktree_source: None, - }], + copy_fixture_tree(&fixture_root("codex-v2-worker-open"), temp.path()); + let parent = temp.path().join("parent.jsonl"); + let child = temp.path().join("child.jsonl"); + let base = SessionScanRequest { roots: SessionRoots { codex: vec![temp.path().to_path_buf()], claude_code: Vec::new(), }, - previous_session_files: HashMap::new(), + ..fixture_request(AgentRuntime::Codex, "codex-v2-worker-open") }; - let first = scan_local_sessions_with_snapshots(&base_request, &StdProcessProbe, UNIX_EPOCH) - .expect("first scan"); - assert_eq!(session_file_parse_count(&path), 1); - assert_eq!( - first.report.attributions[0].activity.status_label(), - "running · worker" - ); + let first = + scan_local_sessions_with_state(&base, &StdProcessProbe, UNIX_EPOCH).expect("first"); + + let mut parent_append = fs::OpenOptions::new() + .append(true) + .open(&parent) + .expect("parent append"); + writeln!( + parent_append, + r#"{{"timestamp":"2026-07-28T10:00:00.100Z","type":"event_msg","payload":{{"type":"sub_agent_activity","kind":"interrupted","agent_thread_id":"codex-child","agent_path":"/root/spacedock_ensign_stable_agent_activity_detection_implement"}}}}"# + ) + .expect("parent stop"); + drop(parent_append); + let mut child_append = fs::OpenOptions::new() + .append(true) + .open(&child) + .expect("child append"); + writeln!( + child_append, + r#"{{"timestamp":"2026-07-28T10:00:00.200Z","type":"event_msg","payload":{{"type":"task_started","turn_id":"follow-up"}}}}"# + ) + .expect("child restart"); + drop(child_append); - let second = scan_local_sessions_with_snapshots( + let restarted = scan_local_sessions_with_state( &SessionScanRequest { - previous_session_files: first.session_files, - ..base_request + previous_state: first.state, + ..base }, &StdProcessProbe, UNIX_EPOCH, ) - .expect("unchanged scan"); + .expect("restarted"); assert_eq!( - session_file_parse_count(&path), - 1, - "unchanged metadata must reuse the safe projected summary" + restarted.report.attributions[0].activity.status_label(), + "running · worker", + "the fractional RFC3339 order parent stop -> child restart must remain running" ); assert_eq!( - second.report.attributions[0].activity.status_label(), - "running · worker" + restarted.report.attributions[0].activity.updated_unix(), + Some(1_785_232_800), + "the display timestamp remains whole Unix seconds" ); } - #[test] - fn record_projection_drops_transcript_text() { - let projected = project_record(json!({ - "timestamp": 1, - "type": "assistant", - "sessionId": "session", - "isSidechain": false, - "message": { - "content": [ - {"type": "text", "text": "private transcript"}, - {"type": "tool_use", "id": "call", "name": "Read", "input": {"file_path": "/repo/entity.md"}} - ], - "stop_reason": null + fn copy_fixture_tree(source: &Path, destination: &Path) { + for entry in walkdir::WalkDir::new(source) { + let entry = entry.expect("fixture entry"); + let relative = entry.path().strip_prefix(source).expect("relative"); + let target = destination.join(relative); + if entry.file_type().is_dir() { + fs::create_dir_all(&target).expect("dir"); + } else { + fs::copy(entry.path(), target).expect("file"); } - })) - .expect("projection"); - assert!(!projected.to_string().contains("private transcript")); - assert!(projected.to_string().contains("/repo/entity.md")); + } } #[test] @@ -2311,26 +1206,15 @@ mod tests { fs::write(¬_a_directory, "not a directory").expect("fixture"); let result = scan_local_sessions_with( &SessionScanRequest { - workflow_dir: PathBuf::from("/repo/docs"), - repo_root: PathBuf::from("/repo"), - entities: vec![SessionScanEntity { - id: "069".to_string(), - path: PathBuf::from("/repo/docs/state/detect-entity-activity-state.md"), - worktree: None, - worktree_source: None, - }], roots: SessionRoots { codex: vec![not_a_directory], claude_code: Vec::new(), }, - previous_session_files: HashMap::new(), + ..fixture_request(AgentRuntime::Codex, "codex-v2-worker-open") }, &StdProcessProbe, UNIX_EPOCH, ); - assert!( - result.is_err(), - "root IO failure must preserve prior app state" - ); + assert!(result.is_err()); } } diff --git a/crates/spacetop-core/src/session_activity/claude.rs b/crates/spacetop-core/src/session_activity/claude.rs new file mode 100644 index 0000000..be5b7da --- /dev/null +++ b/crates/spacetop-core/src/session_activity/claude.rs @@ -0,0 +1,488 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +use crate::domain::AgentRuntime; +use crate::entity_identity::entity_slug; + +use super::projection::{ClaudeBlock, ProjectedRecord, ProjectedRecordKind, TeammateEnvelope}; +use super::reducer::{push_event, ActivityEvent, ActivityEventKind}; +use super::{call_scopes_entity, cwd_matches_entity, is_gate_question, SessionScanEntity}; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ClaudeDispatch { + parent_session_id: String, + call_id: String, + worker_name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ClaudeTeammateMeta { + parent_session_id: String, + parent_call_id: Option, + worker_name: String, + agent_id: String, + source_stem: String, +} + +pub(crate) fn collect( + records: &[&ProjectedRecord], + entities: &[SessionScanEntity], + repo_root: &Path, + fallback_time: i64, + per_entity: &mut HashMap>, +) { + let files = records_by_source(records); + let teammate_meta = teammate_metadata(&files); + + for entity in entities { + let Some(slug) = entity_slug(&entity.path) else { + continue; + }; + let mut dispatches = Vec::new(); + for file_records in files.values() { + if is_sidechain_file(file_records) { + continue; + } + let Some(parent_session_id) = claude_session_id(file_records) else { + continue; + }; + if !file_records.iter().any(|record| { + message_cwd(record) + .is_some_and(|cwd| cwd_matches_entity(cwd.as_path(), repo_root, entity)) + }) { + continue; + } + collect_first_officer( + file_records, + per_entity.entry(entity.id.clone()).or_default(), + &parent_session_id, + entity, + &slug, + fallback_time, + &mut dispatches, + ); + } + + for dispatch in &dispatches { + let matching_meta: Vec<_> = teammate_meta + .iter() + .filter(|meta| { + meta.parent_session_id == dispatch.parent_session_id + && meta.worker_name == dispatch.worker_name + && meta + .parent_call_id + .as_deref() + .is_none_or(|call_id| call_id == dispatch.call_id) + }) + .collect(); + let same_name_dispatches = dispatches + .iter() + .filter(|candidate| { + candidate.parent_session_id == dispatch.parent_session_id + && candidate.worker_name == dispatch.worker_name + }) + .count(); + if matching_meta.len() != 1 + || (matching_meta[0].parent_call_id.is_none() && same_name_dispatches != 1) + { + continue; + } + let meta = matching_meta[0]; + let child_records: Vec<_> = files + .iter() + .filter(|(path, records)| { + claude_parent_session_from_path(path).as_deref() + == Some(dispatch.parent_session_id.as_str()) + && normalized_source_stem(path) == meta.source_stem + && records.iter().any(|record| { + matches!( + &record.kind, + ProjectedRecordKind::ClaudeMessage { + is_sidechain: true, + agent_id: Some(agent_id), + cwd: Some(cwd), + .. + } if agent_id == &meta.agent_id + && cwd_matches_entity(cwd, repo_root, entity) + ) + }) + }) + .flat_map(|(_, records)| records.iter().copied()) + .collect(); + if child_records.is_empty() { + continue; + } + let parent_records: Vec<_> = files + .values() + .filter(|records| { + !is_sidechain_file(records) + && claude_session_id(records).as_deref() + == Some(dispatch.parent_session_id.as_str()) + }) + .flatten() + .copied() + .collect(); + collect_worker_lifecycle( + &child_records, + &parent_records, + meta, + same_name_dispatches == 1, + per_entity.entry(entity.id.clone()).or_default(), + fallback_time, + ); + } + } +} + +fn collect_worker_lifecycle( + child_records: &[&ProjectedRecord], + parent_records: &[&ProjectedRecord], + meta: &ClaudeTeammateMeta, + idle_is_unambiguous: bool, + events: &mut Vec, + fallback_time: i64, +) { + let mut assistants: Vec<_> = child_records + .iter() + .filter(|record| { + matches!( + &record.kind, + ProjectedRecordKind::ClaudeMessage { + record_type, + is_sidechain: true, + agent_id: Some(agent_id), + .. + } if record_type == "assistant" && agent_id == &meta.agent_id + ) + }) + .copied() + .collect(); + assistants.sort(); + let Some(first) = assistants.first() else { + return; + }; + push_event( + events, + AgentRuntime::ClaudeCode, + &meta.agent_id, + &first.order, + fallback_time, + ActivityEventKind::WorkerStarted, + ); + + if !idle_is_unambiguous { + return; + } + let mut idle_records: Vec<_> = parent_records + .iter() + .filter(|record| { + teammate_envelope(record) + .is_some_and(|envelope| is_idle_from(envelope, &meta.worker_name)) + }) + .copied() + .collect(); + idle_records.sort(); + for idle in &idle_records { + push_event( + events, + AgentRuntime::ClaudeCode, + &meta.agent_id, + &idle.order, + fallback_time, + ActivityEventKind::WorkerStopped, + ); + } + + let mut boundaries: Vec<_> = parent_records + .iter() + .filter(|record| { + teammate_envelope(record).is_some_and(|envelope| { + envelope.from.as_deref() == Some(meta.worker_name.as_str()) + && !is_idle_from(envelope, &meta.worker_name) + }) + }) + .copied() + .collect(); + boundaries.sort(); + for boundary in boundaries { + let boundary_at = boundary.order.effective_timestamp(fallback_time); + let stopped_before = idle_records + .iter() + .any(|idle| idle.order.effective_timestamp(fallback_time) < boundary_at); + if !stopped_before { + continue; + } + if let Some(reopened) = assistants + .iter() + .find(|assistant| assistant.order.effective_timestamp(fallback_time) > boundary_at) + { + push_event( + events, + AgentRuntime::ClaudeCode, + &meta.agent_id, + &reopened.order, + fallback_time, + ActivityEventKind::WorkerStarted, + ); + } + } +} + +#[allow(clippy::too_many_arguments)] +fn collect_first_officer( + records: &[&ProjectedRecord], + events: &mut Vec, + session_id: &str, + entity: &SessionScanEntity, + slug: &str, + fallback_time: i64, + dispatches: &mut Vec, +) { + let mut scoped = false; + let mut handoff_pending = false; + let mut dispatched_names = HashSet::new(); + for record in records { + let ProjectedRecordKind::ClaudeMessage { + record_type, + blocks, + stop_reason, + teammate, + .. + } = &record.kind + else { + continue; + }; + if handoff_pending && record_type == "assistant" { + scoped = true; + handoff_pending = false; + push_event( + events, + AgentRuntime::ClaudeCode, + session_id, + &record.order, + fallback_time, + ActivityEventKind::FirstOfficerStarted, + ); + } + for block in blocks { + match block { + ClaudeBlock::ToolUse { id, name, input } => { + if call_scopes_entity(name, input, entity, slug, Some(session_id)) { + scoped = true; + push_event( + events, + AgentRuntime::ClaudeCode, + session_id, + &record.order, + fallback_time, + ActivityEventKind::FirstOfficerStarted, + ); + } + if name == "Agent" + && call_scopes_entity(name, input, entity, slug, Some(session_id)) + { + if let Some(worker_name) = input.task_name.as_ref() { + dispatched_names.insert(worker_name.clone()); + dispatches.push(ClaudeDispatch { + parent_session_id: session_id.to_string(), + call_id: id.clone(), + worker_name: worker_name.clone(), + }); + } + } + if scoped && name == "AskUserQuestion" && is_gate_question(input) { + push_event( + events, + AgentRuntime::ClaudeCode, + session_id, + &record.order, + fallback_time, + ActivityEventKind::HumanGateOpened { + call_id: id.clone(), + }, + ); + } + } + ClaudeBlock::ToolResult { tool_use_id } => push_event( + events, + AgentRuntime::ClaudeCode, + session_id, + &record.order, + fallback_time, + ActivityEventKind::HumanGateResolved { + call_id: tool_use_id.clone(), + }, + ), + } + } + if scoped && stop_reason.as_deref() == Some("end_turn") { + push_event( + events, + AgentRuntime::ClaudeCode, + session_id, + &record.order, + fallback_time, + ActivityEventKind::FirstOfficerStopped, + ); + scoped = false; + } + if teammate.as_ref().is_some_and(|envelope| { + envelope + .from + .as_ref() + .is_some_and(|from| dispatched_names.contains(from)) + && is_idle_from(envelope, envelope.from.as_deref().unwrap_or_default()) + }) { + scoped = false; + handoff_pending = true; + } + } +} + +fn teammate_metadata(files: &BTreeMap>) -> Vec { + files + .iter() + .filter_map(|(path, records)| { + let (worker_name, explicit_agent, explicit_parent, parent_call_id) = + records.iter().find_map(|record| { + let ProjectedRecordKind::ClaudeMeta { + worker_name, + agent_id, + parent_session_id, + parent_call_id, + } = &record.kind + else { + return None; + }; + Some(( + worker_name.clone(), + agent_id.clone(), + parent_session_id.clone(), + parent_call_id.clone(), + )) + })?; + let parent_from_path = claude_parent_session_from_path(path)?; + let parent_session_id = explicit_parent.unwrap_or_else(|| parent_from_path.clone()); + if parent_session_id != parent_from_path { + return None; + } + let source_stem = normalized_source_stem(path); + let sibling_agent_ids: HashSet<_> = files + .iter() + .filter(|(candidate_path, _)| { + claude_parent_session_from_path(candidate_path).as_deref() + == Some(parent_session_id.as_str()) + && normalized_source_stem(candidate_path) == source_stem + }) + .flat_map(|(_, candidate_records)| candidate_records.iter()) + .filter_map(|record| { + let ProjectedRecordKind::ClaudeMessage { + is_sidechain: true, + agent_id, + .. + } = &record.kind + else { + return None; + }; + agent_id.clone() + }) + .collect(); + let agent_id = match explicit_agent { + Some(agent_id) + if sibling_agent_ids.is_empty() || sibling_agent_ids.contains(&agent_id) => + { + agent_id + } + None if sibling_agent_ids.len() == 1 => { + sibling_agent_ids.into_iter().next().unwrap_or_default() + } + _ => return None, + }; + Some(ClaudeTeammateMeta { + parent_session_id, + parent_call_id, + worker_name, + agent_id, + source_stem, + }) + }) + .collect() +} + +fn records_by_source<'a>( + records: &'a [&ProjectedRecord], +) -> BTreeMap> { + let mut files: BTreeMap> = BTreeMap::new(); + for record in records { + files + .entry(record.order.source.clone()) + .or_default() + .push(*record); + } + for records in files.values_mut() { + records.sort(); + } + files +} + +fn is_sidechain_file(records: &[&ProjectedRecord]) -> bool { + records.iter().any(|record| { + matches!( + record.kind, + ProjectedRecordKind::ClaudeMessage { + is_sidechain: true, + .. + } + ) + }) +} + +fn claude_session_id(records: &[&ProjectedRecord]) -> Option { + records.iter().find_map(|record| { + let ProjectedRecordKind::ClaudeMessage { session_id, .. } = &record.kind else { + return None; + }; + session_id.clone() + }) +} + +fn message_cwd(record: &ProjectedRecord) -> Option { + let ProjectedRecordKind::ClaudeMessage { cwd, .. } = &record.kind else { + return None; + }; + cwd.clone() +} + +fn teammate_envelope(record: &ProjectedRecord) -> Option<&TeammateEnvelope> { + let ProjectedRecordKind::ClaudeMessage { teammate, .. } = &record.kind else { + return None; + }; + teammate.as_ref() +} + +fn is_idle_from(envelope: &TeammateEnvelope, worker_name: &str) -> bool { + envelope.envelope_type.as_deref() == Some("idle_notification") + && envelope.idle_reason.as_deref() == Some("available") + && envelope.from.as_deref() == Some(worker_name) +} + +fn claude_parent_session_from_path(path: &Path) -> Option { + let subagents = path + .ancestors() + .find(|ancestor| ancestor.file_name().and_then(OsStr::to_str) == Some("subagents"))?; + subagents + .parent()? + .file_name() + .and_then(OsStr::to_str) + .map(str::to_string) +} + +fn normalized_source_stem(path: &Path) -> String { + path.file_stem() + .and_then(OsStr::to_str) + .unwrap_or_default() + .strip_suffix(".meta") + .unwrap_or_else(|| path.file_stem().and_then(OsStr::to_str).unwrap_or_default()) + .to_string() +} diff --git a/crates/spacetop-core/src/session_activity/codex.rs b/crates/spacetop-core/src/session_activity/codex.rs new file mode 100644 index 0000000..db377ba --- /dev/null +++ b/crates/spacetop-core/src/session_activity/codex.rs @@ -0,0 +1,343 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +use crate::domain::AgentRuntime; +use crate::entity_identity::entity_slug; + +use super::projection::{ + contains_dispatch, ProjectedRecord, ProjectedRecordKind, ProjectedToolInput, +}; +use super::reducer::{push_event, ActivityEvent, ActivityEventKind}; +use super::{call_scopes_entity, cwd_matches_entity, is_gate_question, SessionScanEntity}; + +pub(crate) fn collect( + records: &[&ProjectedRecord], + entities: &[SessionScanEntity], + repo_root: &Path, + fallback_time: i64, + per_entity: &mut HashMap>, +) { + let files = records_by_source(records); + for entity in entities { + let Some(slug) = entity_slug(&entity.path) else { + continue; + }; + let mut matched_children = Vec::new(); + for file_records in files.values() { + let Some(meta) = codex_meta(file_records) else { + continue; + }; + let child_started = file_records.iter().any(|record| { + matches!( + &record.kind, + ProjectedRecordKind::CodexEvent { event_type, turn_id, .. } + if event_type == "task_started" && turn_id.is_some() + ) + }); + let legacy_assignment = file_records.iter().any(|record| { + matches!( + &record.kind, + ProjectedRecordKind::CodexAssignment { dispatches } + if contains_dispatch(dispatches, &slug, None) + ) + }); + let parent_started = meta.parent_thread_id.as_deref().is_some_and(|parent_id| { + parent_confirms_start( + &files, + parent_id, + &meta.session_id, + meta.agent_path.as_deref().unwrap_or_default(), + entity, + repo_root, + ) + }); + let child_matches = meta + .agent_path + .as_deref() + .is_some_and(|path| canonical_codex_name(path, &slug)) + && meta + .parent_thread_id + .as_deref() + .is_some_and(|parent| !parent.is_empty()) + && meta + .cwd + .as_deref() + .is_some_and(|cwd| cwd_matches_entity(cwd, repo_root, entity)) + && child_started + && (legacy_assignment || parent_started); + + if child_matches { + let agent_path = meta.agent_path.clone().unwrap_or_default(); + matched_children.push((meta.session_id.clone(), agent_path)); + collect_worker( + file_records, + per_entity.entry(entity.id.clone()).or_default(), + &meta.session_id, + fallback_time, + ); + } else { + collect_first_officer( + file_records, + per_entity.entry(entity.id.clone()).or_default(), + &meta.session_id, + entity, + &slug, + fallback_time, + ); + } + } + + for file_records in files.values() { + for record in file_records { + let ProjectedRecordKind::CodexEvent { + event_type, + kind, + agent_thread_id, + agent_path, + .. + } = &record.kind + else { + continue; + }; + if event_type != "sub_agent_activity" || kind.as_deref() != Some("interrupted") { + continue; + } + if let Some((session_id, _)) = matched_children.iter().find(|(session, path)| { + agent_thread_id.as_deref() == Some(session.as_str()) + && agent_path.as_deref() == Some(path.as_str()) + }) { + push_event( + per_entity.entry(entity.id.clone()).or_default(), + AgentRuntime::Codex, + session_id, + &record.order, + fallback_time, + ActivityEventKind::WorkerStopped, + ); + } + } + } + } +} + +struct CodexMeta { + session_id: String, + parent_thread_id: Option, + agent_path: Option, + cwd: Option, +} + +fn codex_meta(records: &[&ProjectedRecord]) -> Option { + records.iter().find_map(|record| { + let ProjectedRecordKind::CodexSession { + session_id, + parent_thread_id, + agent_path, + cwd, + } = &record.kind + else { + return None; + }; + Some(CodexMeta { + session_id: session_id.clone(), + parent_thread_id: parent_thread_id.clone(), + agent_path: agent_path.clone(), + cwd: cwd.clone(), + }) + }) +} + +fn parent_confirms_start( + files: &BTreeMap>, + parent_id: &str, + child_id: &str, + agent_path: &str, + entity: &SessionScanEntity, + repo_root: &Path, +) -> bool { + files.values().any(|records| { + let Some(parent) = codex_meta(records) else { + return false; + }; + parent.session_id == parent_id + && parent + .cwd + .as_deref() + .is_some_and(|cwd| cwd_matches_entity(cwd, repo_root, entity)) + && records.iter().any(|record| { + matches!( + &record.kind, + ProjectedRecordKind::CodexEvent { + event_type, + kind, + agent_thread_id, + agent_path: started_path, + .. + } if event_type == "sub_agent_activity" + && kind.as_deref() == Some("started") + && agent_thread_id.as_deref() == Some(child_id) + && started_path.as_deref() == Some(agent_path) + ) + }) + }) +} + +fn collect_worker( + records: &[&ProjectedRecord], + events: &mut Vec, + session_id: &str, + fallback_time: i64, +) { + let mut open_turn = None; + for record in records { + let ProjectedRecordKind::CodexEvent { + event_type, + turn_id, + .. + } = &record.kind + else { + continue; + }; + match event_type.as_str() { + "task_started" if turn_id.is_some() => { + open_turn.clone_from(turn_id); + push_event( + events, + AgentRuntime::Codex, + session_id, + &record.order, + fallback_time, + ActivityEventKind::WorkerStarted, + ); + } + "task_complete" if open_turn.as_deref() == turn_id.as_deref() => { + push_event( + events, + AgentRuntime::Codex, + session_id, + &record.order, + fallback_time, + ActivityEventKind::WorkerStopped, + ); + open_turn = None; + } + _ => {} + } + } +} + +fn collect_first_officer( + records: &[&ProjectedRecord], + events: &mut Vec, + session_id: &str, + entity: &SessionScanEntity, + slug: &str, + fallback_time: i64, +) { + let mut open_turn = None; + let mut scoped_turns = HashSet::new(); + for record in records { + match &record.kind { + ProjectedRecordKind::CodexEvent { + event_type, + turn_id, + .. + } if event_type == "task_started" => { + open_turn.clone_from(turn_id); + } + ProjectedRecordKind::CodexToolCall { + name, + call_id, + input, + } => { + let Some(turn) = open_turn.clone() else { + continue; + }; + if call_scopes_entity(name, input, entity, slug, None) { + scoped_turns.insert(turn.clone()); + push_event( + events, + AgentRuntime::Codex, + session_id, + &record.order, + fallback_time, + ActivityEventKind::FirstOfficerStarted, + ); + } + if scoped_turns.contains(&turn) + && name == "request_user_input" + && is_gate_question(input) + { + push_event( + events, + AgentRuntime::Codex, + session_id, + &record.order, + fallback_time, + ActivityEventKind::HumanGateOpened { + call_id: call_id.clone(), + }, + ); + } + } + ProjectedRecordKind::CodexToolResult { call_id } => push_event( + events, + AgentRuntime::Codex, + session_id, + &record.order, + fallback_time, + ActivityEventKind::HumanGateResolved { + call_id: call_id.clone(), + }, + ), + ProjectedRecordKind::CodexEvent { + event_type, + turn_id, + .. + } if event_type == "task_complete" => { + let completed = turn_id.clone().unwrap_or_default(); + if scoped_turns.remove(&completed) { + push_event( + events, + AgentRuntime::Codex, + session_id, + &record.order, + fallback_time, + ActivityEventKind::FirstOfficerStopped, + ); + } + if open_turn.as_deref() == Some(completed.as_str()) { + open_turn = None; + } + } + _ => {} + } + } +} + +fn records_by_source<'a>( + records: &'a [&ProjectedRecord], +) -> BTreeMap> { + let mut files: BTreeMap> = BTreeMap::new(); + for record in records { + files + .entry(record.order.source.clone()) + .or_default() + .push(*record); + } + for records in files.values_mut() { + records.sort(); + } + files +} + +fn canonical_codex_name(path: &str, slug: &str) -> bool { + super::STAGES.iter().any(|stage| { + path.rsplit('/').next() + == Some(format!("spacedock_ensign_{}_{}", slug.replace('-', "_"), stage).as_str()) + || path.rsplit('/').next() == Some(format!("spacedock_ensign_{slug}_{stage}").as_str()) + }) +} + +#[allow(dead_code)] +fn _projected_input_is_typed(_: &ProjectedToolInput) {} diff --git a/crates/spacetop-core/src/session_activity/projection.rs b/crates/spacetop-core/src/session_activity/projection.rs new file mode 100644 index 0000000..17d47e6 --- /dev/null +++ b/crates/spacetop-core/src/session_activity/projection.rs @@ -0,0 +1,561 @@ +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +use super::{parse_rfc3339_timestamp, EvidenceTimestamp, STAGES}; + +const DISPATCH_DIR: &str = "/tmp/spacedock-dispatch/"; +const DISPATCH_BASENAME_PREFIX: &str = "spacedock-ensign-"; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct EvidenceOrder { + pub(crate) timestamp: Option, + pub(crate) source: PathBuf, + pub(crate) byte_offset: u64, + pub(crate) kind_rank: u8, +} + +impl EvidenceOrder { + pub(crate) fn effective_timestamp(&self, fallback_time: i64) -> EvidenceTimestamp { + self.timestamp + .unwrap_or_else(|| EvidenceTimestamp::whole_seconds(fallback_time)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct ProjectedRecord { + pub(crate) order: EvidenceOrder, + pub(crate) kind: ProjectedRecordKind, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum ProjectedRecordKind { + CodexSession { + session_id: String, + parent_thread_id: Option, + agent_path: Option, + cwd: Option, + }, + CodexEvent { + event_type: String, + turn_id: Option, + kind: Option, + agent_thread_id: Option, + agent_path: Option, + }, + CodexAssignment { + dispatches: Vec, + }, + CodexToolCall { + name: String, + call_id: String, + input: ProjectedToolInput, + }, + CodexToolResult { + call_id: String, + }, + ClaudeMeta { + worker_name: String, + agent_id: Option, + parent_session_id: Option, + parent_call_id: Option, + }, + ClaudeMessage { + record_type: String, + session_id: Option, + is_sidechain: bool, + agent_id: Option, + parent_session_id: Option, + cwd: Option, + blocks: Vec, + stop_reason: Option, + teammate: Option, + }, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct ProjectedToolInput { + pub(crate) task_name: Option, + pub(crate) dispatches: Vec, + pub(crate) commands: Vec, + pub(crate) questions: Vec, + pub(crate) file_path: Option, + pub(crate) path: Option, + pub(crate) uri: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct ProjectedQuestion { + pub(crate) id: Option, + pub(crate) header: Option, + pub(crate) labels: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum ClaudeBlock { + ToolUse { + id: String, + name: String, + input: ProjectedToolInput, + }, + ToolResult { + tool_use_id: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct TeammateEnvelope { + pub(crate) envelope_type: Option, + pub(crate) from: Option, + pub(crate) idle_reason: Option, +} + +pub(crate) fn project_record( + record: Value, + source: &Path, + byte_offset: u64, +) -> Option { + let timestamp = record.get("timestamp").and_then(|timestamp| { + timestamp + .as_i64() + .map(EvidenceTimestamp::whole_seconds) + .or_else(|| timestamp.as_str().and_then(parse_rfc3339_timestamp)) + }); + let kind = if record.get("taskKind").is_some() { + project_claude_meta(&record)? + } else { + match record.get("type").and_then(Value::as_str)? { + "session_meta" => project_codex_session(&record)?, + "event_msg" => project_codex_event(&record)?, + "response_item" => project_codex_response_item(&record)?, + "assistant" | "user" => project_claude_message(&record)?, + _ => return None, + } + }; + Some(ProjectedRecord { + order: EvidenceOrder { + timestamp, + source: source.to_path_buf(), + byte_offset, + kind_rank: kind.rank(), + }, + kind, + }) +} + +impl ProjectedRecordKind { + fn rank(&self) -> u8 { + match self { + Self::CodexSession { .. } => 0, + Self::ClaudeMeta { .. } => 1, + Self::CodexAssignment { .. } => 2, + Self::CodexEvent { .. } => 3, + Self::CodexToolCall { .. } => 4, + Self::CodexToolResult { .. } => 5, + Self::ClaudeMessage { .. } => 6, + } + } +} + +fn project_codex_session(record: &Value) -> Option { + Some(ProjectedRecordKind::CodexSession { + session_id: record.pointer("/payload/id")?.as_str()?.to_string(), + parent_thread_id: string_at( + record, + &[ + "payload", + "source", + "subagent", + "thread_spawn", + "parent_thread_id", + ], + ), + agent_path: string_at( + record, + &[ + "payload", + "source", + "subagent", + "thread_spawn", + "agent_path", + ], + ), + cwd: path_at(record, &["payload", "cwd"]), + }) +} + +fn project_codex_event(record: &Value) -> Option { + Some(ProjectedRecordKind::CodexEvent { + event_type: string_at(record, &["payload", "type"])?, + turn_id: string_at(record, &["payload", "turn_id"]), + kind: string_at(record, &["payload", "kind"]), + agent_thread_id: string_at(record, &["payload", "agent_thread_id"]), + agent_path: string_at(record, &["payload", "agent_path"]), + }) +} + +fn project_codex_response_item(record: &Value) -> Option { + let payload = record.get("payload")?; + match payload.get("type").and_then(Value::as_str)? { + "message" if payload.get("role").and_then(Value::as_str) == Some("user") => { + let dispatches: Vec = payload + .get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|item| { + item.get("text") + .or_else(|| item.get("input_text")) + .and_then(Value::as_str) + }) + .flat_map(dispatch_markers) + .collect(); + (!dispatches.is_empty()).then_some(ProjectedRecordKind::CodexAssignment { dispatches }) + } + "function_call" | "custom_tool_call" => { + let name = payload.get("name").and_then(Value::as_str)?.to_string(); + let raw = payload + .get("arguments") + .or_else(|| payload.get("input")) + .cloned() + .unwrap_or(Value::Null); + Some(ProjectedRecordKind::CodexToolCall { + input: project_tool_input(&name, raw), + name, + call_id: payload + .get("call_id") + .or_else(|| payload.get("id")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + }) + } + "function_call_output" => Some(ProjectedRecordKind::CodexToolResult { + call_id: payload + .get("call_id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + }), + _ => None, + } +} + +fn project_claude_meta(record: &Value) -> Option { + (record.get("taskKind").and_then(Value::as_str) == Some("in_process_teammate")).then(|| { + ProjectedRecordKind::ClaudeMeta { + worker_name: record + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + agent_id: string_from_keys(record, &["agentId"]), + parent_session_id: string_from_keys( + record, + &["parentSessionId", "parentSessionID", "parent_session_id"], + ), + parent_call_id: string_from_keys( + record, + &["parentToolUseId", "parentToolUseID", "parent_tool_use_id"], + ), + } + }) +} + +fn project_claude_message(record: &Value) -> Option { + let mut blocks = Vec::new(); + for block in record + .pointer("/message/content") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + match block.get("type").and_then(Value::as_str) { + Some("tool_use") => { + let name = block.get("name").and_then(Value::as_str)?.to_string(); + blocks.push(ClaudeBlock::ToolUse { + id: block + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + input: project_tool_input( + &name, + block.get("input").cloned().unwrap_or(Value::Null), + ), + name, + }); + } + Some("tool_result") => blocks.push(ClaudeBlock::ToolResult { + tool_use_id: block + .get("tool_use_id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + }), + _ => {} + } + } + let teammate = record + .pointer("/message/content") + .and_then(Value::as_str) + .and_then(project_teammate_envelope); + Some(ProjectedRecordKind::ClaudeMessage { + record_type: record.get("type")?.as_str()?.to_string(), + session_id: string_from_keys(record, &["sessionId"]), + is_sidechain: record + .get("isSidechain") + .and_then(Value::as_bool) + .unwrap_or(false), + agent_id: string_from_keys(record, &["agentId"]), + parent_session_id: string_from_keys( + record, + &["parentSessionId", "parentSessionID", "parent_session_id"], + ), + cwd: path_at(record, &["cwd"]), + blocks, + stop_reason: string_at(record, &["message", "stop_reason"]), + teammate, + }) +} + +fn project_tool_input(name: &str, raw: Value) -> ProjectedToolInput { + let parsed = raw + .as_str() + .and_then(|text| serde_json::from_str(text).ok()) + .unwrap_or(raw); + let mut input = ProjectedToolInput { + task_name: string_from_keys(&parsed, &["task_name", "name"]), + dispatches: string_from_keys(&parsed, &["message", "prompt"]) + .map(|text| dispatch_markers(&text)) + .unwrap_or_default(), + questions: project_questions(parsed.get("questions")), + file_path: string_from_keys(&parsed, &["file_path"]), + path: string_from_keys(&parsed, &["path"]), + uri: string_from_keys(&parsed, &["uri"]), + ..ProjectedToolInput::default() + }; + input.commands = match name { + "exec" => code_mode_exec_commands(&parsed), + "exec_command" | "Bash" => command_text(&parsed).into_iter().collect(), + _ => Vec::new(), + }; + input +} + +fn project_questions(value: Option<&Value>) -> Vec { + value + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(|question| ProjectedQuestion { + id: string_from_keys(question, &["id"]), + header: string_from_keys(question, &["header"]), + labels: question + .get("options") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|option| option.get("label").and_then(Value::as_str)) + .map(str::to_string) + .collect(), + }) + .collect() +} + +fn project_teammate_envelope(content: &str) -> Option { + let start = content.find("")? + "".len(); + let end = content[start..].find("")? + start; + let envelope: Value = serde_json::from_str(content[start..end].trim()).ok()?; + Some(TeammateEnvelope { + envelope_type: string_from_keys(&envelope, &["type"]), + from: string_from_keys(&envelope, &["from"]), + idle_reason: string_from_keys(&envelope, &["idleReason"]), + }) +} + +pub(crate) fn contains_dispatch( + dispatches: &[String], + slug: &str, + parent_session_id: Option<&str>, +) -> bool { + dispatches.iter().any(|marker| { + let Some(basename) = marker.strip_prefix(DISPATCH_DIR) else { + return false; + }; + STAGES.iter().any(|stage| { + let canonical = format!("{DISPATCH_BASENAME_PREFIX}{slug}-{stage}.md"); + basename == canonical + || parent_session_id + .is_some_and(|parent| basename == format!("{parent}-{canonical}")) + }) + }) +} + +fn dispatch_markers(text: &str) -> Vec { + let mut markers = Vec::new(); + let mut remainder = text; + while let Some(start) = remainder.find(DISPATCH_DIR) { + let candidate = &remainder[start..]; + let Some(end) = candidate.find(".md") else { + break; + }; + let marker = &candidate[..end + 3]; + let basename = &marker[DISPATCH_DIR.len()..]; + if marker.len() <= 640 + && basename.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '.') + }) + && basename.contains(DISPATCH_BASENAME_PREFIX) + && STAGES + .iter() + .any(|stage| basename.ends_with(&format!("-{stage}.md"))) + { + markers.push(marker.to_string()); + } + remainder = &candidate[end + 3..]; + } + markers +} + +fn command_text(value: &Value) -> Option { + if let Some(text) = value.as_str() { + return Some(text.to_string()); + } + ["cmd", "command", "input"] + .iter() + .find_map(|key| value.get(*key).and_then(command_text)) +} + +fn code_mode_exec_commands(value: &Value) -> Vec { + if let Some(module) = value.as_str() { + return nested_exec_commands(module); + } + if let Some(command) = value + .get("cmd") + .or_else(|| value.get("command")) + .and_then(command_text) + { + return vec![command]; + } + ["input", "arguments"] + .iter() + .find_map(|key| value.get(*key)) + .map(code_mode_exec_commands) + .unwrap_or_default() +} + +fn nested_exec_commands(module: &str) -> Vec { + const CALL: &str = "tools.exec_command"; + + let mut commands = Vec::new(); + let mut offset = 0; + while let Some(relative_start) = module[offset..].find(CALL) { + let call_start = offset + relative_start + CALL.len(); + let after_name = &module[call_start..]; + let whitespace = after_name.len() - after_name.trim_start().len(); + let argument_start = call_start + whitespace; + if module.as_bytes().get(argument_start) != Some(&b'(') { + offset = call_start; + continue; + } + let source = &module[argument_start + 1..]; + let Some((argument, consumed)) = balanced_call_argument(source) else { + break; + }; + if let Ok(value) = serde_json::from_str::(argument.trim()) { + if let Some(command) = command_text(&value) { + commands.push(command); + } + } + offset = argument_start + 1 + consumed; + } + commands +} + +fn balanced_call_argument(source: &str) -> Option<(&str, usize)> { + let mut depth = 1_u32; + let mut quote = None; + let mut escaped = false; + for (index, character) in source.char_indices() { + if let Some(expected) = quote { + if escaped { + escaped = false; + } else if character == '\\' { + escaped = true; + } else if character == expected { + quote = None; + } + continue; + } + match character { + '\'' | '"' | '`' => quote = Some(character), + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + return Some((&source[..index], index + character.len_utf8())); + } + } + _ => {} + } + } + None +} + +fn string_from_keys(value: &Value, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| value.get(*key).and_then(Value::as_str)) + .map(str::to_string) +} + +fn string_at(value: &Value, path: &[&str]) -> Option { + path.iter() + .try_fold(value, |current, key| current.get(*key)) + .and_then(Value::as_str) + .map(str::to_string) +} + +fn path_at(value: &Value, path: &[&str]) -> Option { + string_at(value, path).map(PathBuf::from) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn projection_drops_transcript_text() { + let projected = project_record( + json!({ + "timestamp": 1, + "type": "assistant", + "sessionId": "session", + "cwd": "/repo", + "isSidechain": false, + "message": { + "content": [ + {"type": "text", "text": "private transcript"}, + {"type": "tool_use", "id": "call", "name": "Read", "input": {"file_path": "/repo/entity.md"}} + ], + "stop_reason": null + } + }), + Path::new("session.jsonl"), + 0, + ) + .expect("projection"); + assert!(!format!("{projected:?}").contains("private transcript")); + assert!(format!("{projected:?}").contains("/repo/entity.md")); + } + + #[test] + fn prefixed_dispatch_is_scoped_to_exact_parent() { + let dispatches = + dispatch_markers("Read /tmp/spacedock-dispatch/parent-spacedock-ensign-task-plan.md"); + assert!(contains_dispatch(&dispatches, "task", Some("parent"))); + assert!(!contains_dispatch(&dispatches, "task", Some("other"))); + assert!(!contains_dispatch(&dispatches, "other", Some("parent"))); + } +} diff --git a/crates/spacetop-core/src/session_activity/reducer.rs b/crates/spacetop-core/src/session_activity/reducer.rs new file mode 100644 index 0000000..ebeca9a --- /dev/null +++ b/crates/spacetop-core/src/session_activity/reducer.rs @@ -0,0 +1,146 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use crate::domain::{ActivityHandler, AgentRuntime, EntityActivity}; + +use super::projection::EvidenceOrder; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActivityEvent { + pub runtime: AgentRuntime, + pub session_id: String, + pub updated_unix: i64, + pub updated_subsecond_nanos: u32, + pub source: PathBuf, + pub byte_offset: u64, + pub evidence_kind_rank: u8, + pub kind: ActivityEventKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ActivityEventKind { + WorkerStarted, + WorkerStopped, + FirstOfficerStarted, + FirstOfficerStopped, + HumanGateOpened { call_id: String }, + HumanGateResolved { call_id: String }, +} + +pub fn reduce_activity(events: &[ActivityEvent]) -> EntityActivity { + let mut ordered: Vec<_> = events.iter().collect(); + ordered.sort_by_key(|event| { + ( + event.updated_unix, + event.updated_subsecond_nanos, + &event.source, + event.byte_offset, + event.evidence_kind_rank, + event_kind_rank(&event.kind), + event.runtime, + &event.session_id, + ) + }); + + let mut workers = HashMap::new(); + let mut first_officers = HashMap::new(); + let mut gates = HashMap::new(); + let mut latest = None; + + for event in ordered { + let timestamp = (event.updated_unix, event.updated_subsecond_nanos); + latest = Some(latest.unwrap_or((i64::MIN, 0)).max(timestamp)); + let session_key = (event.runtime, event.session_id.clone()); + match &event.kind { + ActivityEventKind::WorkerStarted => { + workers.insert(session_key, timestamp); + } + ActivityEventKind::WorkerStopped => { + workers.remove(&session_key); + } + ActivityEventKind::FirstOfficerStarted => { + first_officers.insert(session_key, timestamp); + } + ActivityEventKind::FirstOfficerStopped => { + first_officers.remove(&session_key); + } + ActivityEventKind::HumanGateOpened { call_id } => { + gates.insert( + (event.runtime, event.session_id.clone(), call_id.clone()), + timestamp, + ); + } + ActivityEventKind::HumanGateResolved { call_id } => { + gates.remove(&(event.runtime, event.session_id.clone(), call_id.clone())); + } + } + } + + if let Some(((runtime, session_id, _), (updated_unix, _))) = gates + .into_iter() + .max_by_key(|((runtime, session, _), at)| (*at, *runtime, session.clone())) + { + return EntityActivity::HumanGate { + runtime, + session_id, + updated_unix, + }; + } + if let Some(((runtime, session_id), (updated_unix, _))) = workers + .into_iter() + .max_by_key(|((runtime, session), at)| (*at, *runtime, session.clone())) + { + return EntityActivity::Running { + handler: ActivityHandler::Worker, + runtime, + session_id, + updated_unix, + }; + } + if let Some(((runtime, session_id), (updated_unix, _))) = first_officers + .into_iter() + .max_by_key(|((runtime, session), at)| (*at, *runtime, session.clone())) + { + return EntityActivity::Running { + handler: ActivityHandler::FirstOfficer, + runtime, + session_id, + updated_unix, + }; + } + EntityActivity::Idle { + updated_unix: latest.map(|(seconds, _)| seconds), + } +} + +fn event_kind_rank(kind: &ActivityEventKind) -> u8 { + match kind { + ActivityEventKind::WorkerStarted => 0, + ActivityEventKind::FirstOfficerStarted => 1, + ActivityEventKind::HumanGateOpened { .. } => 2, + ActivityEventKind::HumanGateResolved { .. } => 3, + ActivityEventKind::WorkerStopped => 4, + ActivityEventKind::FirstOfficerStopped => 5, + } +} + +pub(crate) fn push_event( + events: &mut Vec, + runtime: AgentRuntime, + session_id: &str, + order: &EvidenceOrder, + fallback_time: i64, + kind: ActivityEventKind, +) { + let timestamp = order.effective_timestamp(fallback_time); + events.push(ActivityEvent { + runtime, + session_id: session_id.to_string(), + updated_unix: timestamp.unix_seconds, + updated_subsecond_nanos: timestamp.subsecond_nanos, + source: order.source.clone(), + byte_offset: order.byte_offset, + evidence_kind_rank: order.kind_rank, + kind, + }); +} diff --git a/crates/spacetop-core/src/session_activity/state.rs b/crates/spacetop-core/src/session_activity/state.rs new file mode 100644 index 0000000..6af9458 --- /dev/null +++ b/crates/spacetop-core/src/session_activity/state.rs @@ -0,0 +1,537 @@ +use std::collections::HashMap; +use std::ffi::OsStr; +use std::fs; +use std::io::{BufRead, BufReader, Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use walkdir::WalkDir; + +use super::projection::{project_record, ProjectedRecord, ProjectedRecordKind}; +use super::{AgentRuntime, SessionRoots}; + +const CHECKPOINT_BYTES: u64 = 128; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +enum SessionEvidenceKey { + RuntimeSession(AgentRuntime, String), + Source(AgentRuntime, PathBuf), +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SessionEvidenceStore { + records: HashMap>, +} + +impl SessionEvidenceStore { + fn merge( + &mut self, + runtime: AgentRuntime, + source: &Path, + preferred_key: Option<&SessionEvidenceKey>, + records: Vec, + ) -> SessionEvidenceKey { + let key = detected_evidence_key(runtime, &records) + .or_else(|| preferred_key.cloned()) + .unwrap_or_else(|| SessionEvidenceKey::Source(runtime, source.to_path_buf())); + let retained = self.records.entry(key.clone()).or_default(); + for record in records { + if let Err(index) = retained.binary_search(&record) { + retained.insert(index, record); + } + } + key + } + + pub(crate) fn all_records(&self) -> Vec<&ProjectedRecord> { + let mut records: Vec<_> = self.records.values().flatten().collect(); + records.sort(); + records + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionFileCursor { + modified: Option, + len: u64, + pub(super) cursor: u64, + complete_lines: u64, + checkpoint: Vec, + evidence_key: SessionEvidenceKey, + parse_errors: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SessionScanState { + pub(crate) files: HashMap, + pub(crate) evidence: SessionEvidenceStore, +} + +#[derive(Debug)] +pub(crate) struct LoadedGeneration { + pub(crate) state: SessionScanState, + pub(crate) errors: Vec, +} + +#[derive(Debug)] +pub(crate) enum LoadGenerationError { + Root(String), + Unstable, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct InventoryEntry { + runtime: AgentRuntime, + modified: Option, + len: u64, +} + +pub(crate) fn load_generation( + roots: &SessionRoots, + previous: &SessionScanState, +) -> Result { + load_generation_with_hook(roots, previous, || {}) +} + +fn load_generation_with_hook( + roots: &SessionRoots, + previous: &SessionScanState, + after_read: impl FnOnce(), +) -> Result { + let (before, mut errors) = inventory(roots)?; + let mut next = previous.clone(); + next.files.clear(); + + let mut paths: Vec<_> = before.keys().cloned().collect(); + paths.sort(); + for path in paths { + let entry = &before[&path]; + match load_file(&path, entry, previous.files.get(&path), &mut next.evidence) { + Ok(cursor) => { + errors.extend(cursor.parse_errors.iter().cloned()); + next.files.insert(path, cursor); + } + Err(err) => errors.push(format!( + "{} scan could not read {}: {err}", + entry.runtime.label(), + path.display() + )), + } + } + + after_read(); + let (after, after_errors) = inventory(roots)?; + errors.extend(after_errors); + if before != after { + return Err(LoadGenerationError::Unstable); + } + Ok(LoadedGeneration { + state: next, + errors, + }) +} + +fn inventory( + roots: &SessionRoots, +) -> Result<(HashMap, Vec), LoadGenerationError> { + let mut files = HashMap::new(); + let mut errors = Vec::new(); + for (runtime, root) in roots.all_roots() { + if !root.exists() { + continue; + } + if let Err(err) = fs::read_dir(root) { + return Err(LoadGenerationError::Root(format!( + "{} session root {} is unreadable: {err}", + runtime.label(), + root.display() + ))); + } + for entry in WalkDir::new(root) + .into_iter() + .filter_entry(|entry| !is_pruned_dir(entry.path())) + { + let entry = match entry { + Ok(entry) => entry, + Err(err) => { + errors.push(format!("{} scan skipped entry: {err}", runtime.label())); + continue; + } + }; + if !entry.file_type().is_file() || !is_session_file(entry.path()) { + continue; + } + match entry.metadata() { + Ok(metadata) => { + files.insert( + entry.path().to_path_buf(), + InventoryEntry { + runtime, + modified: metadata.modified().ok(), + len: metadata.len(), + }, + ); + } + Err(err) => errors.push(format!( + "{} scan could not read metadata for {}: {err}", + runtime.label(), + entry.path().display() + )), + } + } + } + Ok((files, errors)) +} + +fn load_file( + path: &Path, + entry: &InventoryEntry, + previous: Option<&SessionFileCursor>, + evidence: &mut SessionEvidenceStore, +) -> Result { + if let Some(previous) = previous { + if previous.len == entry.len + && previous.modified == entry.modified + && entry.modified.is_some() + && append_checkpoint_matches(path, previous)? + { + return Ok(previous.clone()); + } + } + + if path.extension().and_then(OsStr::to_str) == Some("json") { + let (records, parse_errors) = parse_json(path)?; + let evidence_key = if records.is_empty() { + previous + .map(|cursor| cursor.evidence_key.clone()) + .unwrap_or_else(|| SessionEvidenceKey::Source(entry.runtime, path.to_path_buf())) + } else { + evidence.merge( + entry.runtime, + path, + previous.map(|cursor| &cursor.evidence_key), + records, + ) + }; + return Ok(SessionFileCursor { + modified: entry.modified, + len: entry.len, + cursor: entry.len, + complete_lines: 0, + checkpoint: read_checkpoint(path, entry.len)?, + evidence_key, + parse_errors, + }); + } + + let (start, starting_line) = match previous { + Some(previous) + if entry.len > previous.len && append_checkpoint_matches(path, previous)? => + { + (previous.cursor, previous.complete_lines) + } + _ => (0, 0), + }; + let parsed = parse_jsonl_from(path, start, starting_line)?; + let evidence_key = if parsed.records.is_empty() { + previous + .map(|cursor| cursor.evidence_key.clone()) + .unwrap_or_else(|| SessionEvidenceKey::Source(entry.runtime, path.to_path_buf())) + } else { + evidence.merge( + entry.runtime, + path, + previous.map(|cursor| &cursor.evidence_key), + parsed.records, + ) + }; + Ok(SessionFileCursor { + modified: entry.modified, + len: entry.len, + cursor: parsed.cursor, + complete_lines: parsed.complete_lines, + checkpoint: read_checkpoint(path, parsed.cursor)?, + evidence_key, + parse_errors: parsed.errors, + }) +} + +#[derive(Debug)] +struct ParsedChunk { + records: Vec, + cursor: u64, + complete_lines: u64, + errors: Vec, +} + +fn parse_json(path: &Path) -> Result<(Vec, Vec), std::io::Error> { + #[cfg(test)] + super::record_session_file_parse(path, 0); + let file = fs::File::open(path)?; + let reader = BufReader::new(file); + Ok(match serde_json::from_reader(reader) { + Ok(value) => ( + project_record(value, path, 0).into_iter().collect(), + Vec::new(), + ), + Err(err) => ( + Vec::new(), + vec![format!( + "malformed session record {}: {err}", + path.display() + )], + ), + }) +} + +fn parse_jsonl_from( + path: &Path, + start: u64, + starting_line: u64, +) -> Result { + #[cfg(test)] + super::record_session_file_parse(path, start); + let mut file = fs::File::open(path)?; + file.seek(SeekFrom::Start(start))?; + let mut reader = BufReader::new(file); + let mut records = Vec::new(); + let mut errors = Vec::new(); + let mut cursor = start; + let mut complete_lines = starting_line; + loop { + let record_offset = cursor; + let mut line = Vec::new(); + let read = reader.read_until(b'\n', &mut line)?; + if read == 0 { + break; + } + let terminated = line.last() == Some(&b'\n'); + if line.iter().all(u8::is_ascii_whitespace) { + cursor += read as u64; + continue; + } + match serde_json::from_slice(&line) { + Ok(value) => { + cursor += read as u64; + complete_lines += 1; + if let Some(projected) = project_record(value, path, record_offset) { + records.push(projected); + } + } + Err(_) if !terminated => break, + Err(err) => { + cursor += read as u64; + complete_lines += 1; + errors.push(format!( + "malformed session record {}:{}: {err}", + path.display(), + complete_lines + )); + } + } + } + Ok(ParsedChunk { + records, + cursor, + complete_lines, + errors, + }) +} + +fn detected_evidence_key( + runtime: AgentRuntime, + records: &[ProjectedRecord], +) -> Option { + let session = records.iter().find_map(|record| match &record.kind { + ProjectedRecordKind::CodexSession { session_id, .. } => Some(session_id.clone()), + ProjectedRecordKind::ClaudeMessage { + is_sidechain: true, + agent_id: Some(agent_id), + .. + } => Some(agent_id.clone()), + ProjectedRecordKind::ClaudeMessage { + session_id: Some(session_id), + .. + } => Some(session_id.clone()), + ProjectedRecordKind::ClaudeMeta { + agent_id: Some(agent_id), + .. + } => Some(agent_id.clone()), + _ => None, + }); + session.map(|session| SessionEvidenceKey::RuntimeSession(runtime, session)) +} + +fn append_checkpoint_matches( + path: &Path, + previous: &SessionFileCursor, +) -> Result { + if previous.cursor == 0 || previous.checkpoint.is_empty() { + return Ok(false); + } + Ok(read_checkpoint(path, previous.cursor)? == previous.checkpoint) +} + +fn read_checkpoint(path: &Path, cursor: u64) -> Result, std::io::Error> { + if cursor == 0 { + return Ok(Vec::new()); + } + let start = cursor.saturating_sub(CHECKPOINT_BYTES); + let mut file = fs::File::open(path)?; + file.seek(SeekFrom::Start(start))?; + let mut checkpoint = vec![0; (cursor - start) as usize]; + file.read_exact(&mut checkpoint)?; + Ok(checkpoint) +} + +fn is_pruned_dir(path: &Path) -> bool { + path.file_name() + .and_then(OsStr::to_str) + .is_some_and(|name| matches!(name, ".git" | "node_modules" | "target")) +} + +fn is_session_file(path: &Path) -> bool { + matches!( + path.extension().and_then(OsStr::to_str), + Some("jsonl" | "json") + ) +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use serde_json::json; + + use super::*; + + #[test] + fn merge_keeps_evidence_sorted_and_deduplicated() { + let source = Path::new("/sessions/worker.jsonl"); + let first = project_record( + json!({ + "timestamp": 1, + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "turn-1"} + }), + source, + 0, + ) + .expect("first"); + let second = project_record( + json!({ + "timestamp": 2, + "type": "event_msg", + "payload": {"type": "task_complete", "turn_id": "turn-1"} + }), + source, + 100, + ) + .expect("second"); + let third = project_record( + json!({ + "timestamp": 3, + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "turn-2"} + }), + source, + 200, + ) + .expect("third"); + let mut store = SessionEvidenceStore::default(); + + let key = store.merge( + AgentRuntime::Codex, + source, + None, + vec![third.clone(), first.clone(), second.clone(), first], + ); + store.merge(AgentRuntime::Codex, source, Some(&key), vec![second, third]); + + let retained = &store.records[&key]; + assert_eq!(retained.len(), 3, "replayed evidence must be deduplicated"); + assert!( + retained.windows(2).all(|pair| pair[0] < pair[1]), + "each evidence vector must remain strictly sorted" + ); + } + + #[test] + fn same_metadata_rewrite_reparses_when_checkpoint_changes() { + let temp = tempfile::tempdir().expect("temp"); + let path = temp.path().join("session.jsonl"); + let started = concat!( + "{\"timestamp\":1,\"type\":\"event_msg\",\"payload\":", + "{\"type\":\"task_started\",\"turn_id\":\"turn\"}} \n" + ); + let completed = concat!( + "{\"timestamp\":1,\"type\":\"event_msg\",\"payload\":", + "{\"type\":\"task_complete\",\"turn_id\":\"turn\"}}\n" + ); + assert_eq!( + started.len(), + completed.len(), + "fixture must preserve file length" + ); + fs::write(&path, started).expect("started fixture"); + let roots = SessionRoots { + codex: vec![temp.path().to_path_buf()], + claude_code: Vec::new(), + }; + let first = + load_generation(&roots, &SessionScanState::default()).expect("initial generation"); + let original_modified = first.state.files[&path].modified.expect("fixture mtime"); + + fs::write(&path, completed).expect("same-length terminal rewrite"); + fs::File::options() + .write(true) + .open(&path) + .expect("rewritten fixture") + .set_modified(original_modified) + .expect("restore coarse mtime"); + let second = load_generation(&roots, &first.state).expect("rewritten generation"); + + assert!( + second.state.evidence.all_records().iter().any(|record| { + matches!( + &record.kind, + ProjectedRecordKind::CodexEvent { event_type, .. } + if event_type == "task_complete" + ) + }), + "a changed checkpoint must expose the replacement lifecycle fact" + ); + assert_eq!( + super::super::session_file_parse_starts(&path), + vec![0, 0], + "same metadata with changed bytes must force a full reparse" + ); + } + + #[test] + fn changed_inventory_rejects_the_generation_for_immediate_retry() { + let temp = tempfile::tempdir().expect("temp"); + let path = temp.path().join("session.jsonl"); + fs::write( + &path, + "{\"timestamp\":1,\"type\":\"event_msg\",\"payload\":{\"type\":\"task_started\",\"turn_id\":\"turn\"}}\n", + ) + .expect("fixture"); + let roots = SessionRoots { + codex: vec![temp.path().to_path_buf()], + claude_code: Vec::new(), + }; + let result = load_generation_with_hook(&roots, &SessionScanState::default(), || { + let mut append = fs::OpenOptions::new() + .append(true) + .open(&path) + .expect("append"); + writeln!( + append, + r#"{{"timestamp":2,"type":"event_msg","payload":{{"type":"task_complete","turn_id":"turn"}}}}"# + ) + .expect("terminal"); + }); + assert!(matches!(result, Err(LoadGenerationError::Unstable))); + } +} diff --git a/crates/spacetop/src/app/session_activity_worker.rs b/crates/spacetop/src/app/session_activity_worker.rs index a2cb074..4f22319 100644 --- a/crates/spacetop/src/app/session_activity_worker.rs +++ b/crates/spacetop/src/app/session_activity_worker.rs @@ -1,12 +1,11 @@ -use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::mpsc::{self, Receiver}; use std::thread; use spacetop_core::domain::SessionScanReport; use spacetop_core::session_activity::{ - scan_local_sessions_with_snapshots, SessionFileSnapshot, SessionRoots, SessionScanEntity, - SessionScanError, SessionScanRequest, + scan_local_sessions_with_state, SessionRoots, SessionScanEntity, SessionScanError, + SessionScanRequest, SessionScanState, }; #[derive(Debug, Clone, PartialEq)] @@ -15,7 +14,7 @@ pub struct SessionActivityWorkerRequest { pub repo_root: PathBuf, pub entities: Vec, pub roots: SessionRoots, - pub previous_session_files: HashMap, + pub previous_state: SessionScanState, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -23,7 +22,8 @@ pub struct SessionActivityWorkerResult { pub workflow_dir: PathBuf, pub repo_root: PathBuf, pub result: Result, - pub session_files: HashMap, + pub state: SessionScanState, + pub retry_immediately: bool, } impl SessionActivityWorkerRequest { @@ -37,7 +37,7 @@ impl SessionActivityWorkerRequest { repo_root: repo_root.to_path_buf(), entities, roots: SessionRoots::from_env(), - previous_session_files: HashMap::new(), + previous_state: SessionScanState::default(), } } } @@ -52,22 +52,26 @@ pub fn spawn_session_activity_worker( repo_root: request.repo_root.clone(), entities: request.entities, roots: request.roots, - previous_session_files: request.previous_session_files, + previous_state: request.previous_state.clone(), }; - let result = scan_local_sessions_with_snapshots( + let result = scan_local_sessions_with_state( &scan_request, &spacetop_core::session_activity::StdProcessProbe, std::time::SystemTime::now(), ); - let (result, session_files) = match result { - Ok(scan) => (Ok(scan.report), scan.session_files), - Err(err) => (Err(err), HashMap::new()), + let (result, state, retry_immediately) = match result { + Ok(scan) => (Ok(scan.report), scan.state, false), + Err(err) => { + let retry_immediately = err.retry_immediately(); + (Err(err), request.previous_state, retry_immediately) + } }; let _ = tx.send(SessionActivityWorkerResult { workflow_dir: request.workflow_dir, repo_root: request.repo_root, result, - session_files, + state, + retry_immediately, }); }); rx diff --git a/crates/spacetop/src/app/tests.rs b/crates/spacetop/src/app/tests.rs index b558d02..5a4fd0a 100644 --- a/crates/spacetop/src/app/tests.rs +++ b/crates/spacetop/src/app/tests.rs @@ -2439,7 +2439,8 @@ fn matching_session_activity_result_applies_to_active_workflow() { workflow_dir: workflow_dir.clone(), repo_root: repo_root.clone(), result: Ok(session_report(&workflow_dir, &repo_root, "000")), - session_files: HashMap::new(), + state: Default::default(), + retry_immediately: false, }); assert!( @@ -2461,7 +2462,8 @@ fn stale_session_activity_result_for_other_workflow_is_ignored() { workflow_dir: PathBuf::from("/tmp/other-workflow"), repo_root: repo_root.clone(), result: Ok(session_report(&workflow_dir, &repo_root, "000")), - session_files: HashMap::new(), + state: Default::default(), + retry_immediately: false, }); assert!( @@ -2482,7 +2484,8 @@ fn session_activity_scan_failure_is_non_fatal_and_preserves_last_snapshot() { workflow_dir: workflow_dir.clone(), repo_root: repo_root.clone(), result: Ok(session_report(&workflow_dir, &repo_root, "000")), - session_files: HashMap::new(), + state: Default::default(), + retry_immediately: false, }); app.apply_session_activity_result(SessionActivityWorkerResult { @@ -2491,7 +2494,8 @@ fn session_activity_scan_failure_is_non_fatal_and_preserves_last_snapshot() { result: Err(SessionScanError { message: "fixture scanner failed".to_string(), }), - session_files: HashMap::new(), + state: Default::default(), + retry_immediately: false, }); let overview = app.as_overview().expect("overview"); diff --git a/crates/spacetop/src/lib.rs b/crates/spacetop/src/lib.rs index b9a1bc9..956e624 100644 --- a/crates/spacetop/src/lib.rs +++ b/crates/spacetop/src/lib.rs @@ -3,7 +3,6 @@ pub mod cli; pub mod headless; pub mod ui; -use std::collections::HashMap; use std::io::{self, IsTerminal, Write}; use std::path::{Path, PathBuf}; use std::sync::mpsc::{Receiver, TryRecvError}; @@ -25,7 +24,7 @@ use spacetop_core::config::{self, ConfigLoad, ConfigWarning, SpacetopConfig}; use spacetop_core::discovery; use spacetop_core::editor::{resolve_editor, EditorLauncher, StdEnv, StdLauncher}; use spacetop_core::git_sync::{self, GitRunner, StdGitRunner, SyncOutcome}; -use spacetop_core::session_activity::SessionFileSnapshot; +use spacetop_core::session_activity::SessionScanState; use spacetop_core::session_state; use spacetop_core::watcher::{self, WatcherBackend, WatcherConfig, WorkflowWatcher}; @@ -367,7 +366,7 @@ fn start_history_worker_for(app: &App) -> Option>, rescan_requested: bool, - session_files: HashMap, + scan_state: SessionScanState, last_request_at: Option, } @@ -387,7 +386,7 @@ impl SessionActivityWorkerState { return; } self.receiver = app.session_activity_worker_request().map(|mut request| { - request.previous_session_files = self.session_files.clone(); + request.previous_state = self.scan_state.clone(); app::spawn_session_activity_worker(request) }); self.rescan_requested = false; @@ -432,8 +431,9 @@ fn drain_session_activity_worker(app: &mut App, worker: &mut SessionActivityWork match rx.try_recv() { Ok(result) => { if result.result.is_ok() { - worker.session_files.clone_from(&result.session_files); + worker.scan_state.clone_from(&result.state); } + worker.rescan_requested |= result.retry_immediately; app.apply_session_activity_result(result); clear_worker = true; } diff --git a/crates/spacetop/src/ui/tests.rs b/crates/spacetop/src/ui/tests.rs index 8f307b3..55901ec 100644 --- a/crates/spacetop/src/ui/tests.rs +++ b/crates/spacetop/src/ui/tests.rs @@ -98,7 +98,8 @@ fn app_with_session_attribution( state.apply_session_activity_result(crate::app::SessionActivityWorkerResult { workflow_dir: root.clone(), repo_root: repo_root.clone(), - session_files: std::collections::HashMap::new(), + state: Default::default(), + retry_immediately: false, result: Ok(spacetop_core::domain::SessionScanReport { workflow_dir: root, repo_root, diff --git a/crates/spacetop/src/ui/tests/preview.rs b/crates/spacetop/src/ui/tests/preview.rs index a95fdfe..48058bb 100644 --- a/crates/spacetop/src/ui/tests/preview.rs +++ b/crates/spacetop/src/ui/tests/preview.rs @@ -259,7 +259,7 @@ fn preview_omits_session_metadata_for_unrelated_running_session() { use spacetop_core::session_activity::{ scan_local_sessions_with, ProcessProbe, SessionRoots, SessionScanEntity, SessionScanRequest, }; - use std::collections::{HashMap, HashSet}; + use std::collections::HashSet; use std::fs; use std::path::Path; use std::time::SystemTime; @@ -327,7 +327,7 @@ fn preview_omits_session_metadata_for_unrelated_running_session() { codex: vec![root], claude_code: Vec::new(), }, - previous_session_files: HashMap::new(), + previous_state: Default::default(), }; let report = scan_local_sessions_with( &request, @@ -340,7 +340,8 @@ fn preview_omits_session_metadata_for_unrelated_running_session() { state.apply_session_activity_result(crate::app::SessionActivityWorkerResult { workflow_dir: workflow, repo_root: repo, - session_files: HashMap::new(), + state: Default::default(), + retry_immediately: false, result: Ok(report), }); let mut app = App::from_session(OverviewSession::single(state, true)); diff --git a/crates/spacetop/src/ui/tests/task_list.rs b/crates/spacetop/src/ui/tests/task_list.rs index 080aaf1..5a2c3d2 100644 --- a/crates/spacetop/src/ui/tests/task_list.rs +++ b/crates/spacetop/src/ui/tests/task_list.rs @@ -143,7 +143,7 @@ fn task_row_renders_active_session_marker_from_typed_attribution() { updated_unix: 1_718_000_000, }, ); - let mut terminal = Terminal::new(TestBackend::new(120, 24)).expect("terminal"); + let mut terminal = Terminal::new(TestBackend::new(200, 24)).expect("terminal"); terminal.draw(|frame| render(frame, &app)).expect("render"); let rendered = buffer_text(terminal.backend().buffer()); @@ -161,6 +161,98 @@ fn task_row_renders_active_session_marker_from_typed_attribution() { ); } +#[test] +fn task_row_renders_scanner_replay_then_clears_on_terminal_report() { + use std::fs; + use std::io::Write; + use std::path::Path; + use std::time::SystemTime; + + use spacetop_core::session_activity::{ + scan_local_sessions_with_state, SessionRoots, SessionScanEntity, SessionScanRequest, + StdProcessProbe, + }; + + let mut active = item( + "stable-agent-activity-detection", + "Stable activity task", + "Body", + ); + active.worktree = Some(".worktrees/stable-agent-activity-detection".to_string()); + let mut app = app_with_items(vec![active.clone()]); + let workflow_dir = PathBuf::from("/tmp/spacetop-test"); + let repo_root = app.repo_root().expect("repo root").to_path_buf(); + let temp = tempfile::tempdir().expect("temp"); + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/fixtures/session-activity/codex-v2-worker-open"); + let parent = fs::read_to_string(fixture.join("parent.jsonl")) + .expect("parent") + .replace("/repo", &repo_root.to_string_lossy()); + let child = fs::read_to_string(fixture.join("child.jsonl")) + .expect("child") + .replace("/repo", &repo_root.to_string_lossy()); + fs::write(temp.path().join("parent.jsonl"), parent).expect("parent fixture"); + let child_path = temp.path().join("child.jsonl"); + fs::write(&child_path, child).expect("child fixture"); + let request = SessionScanRequest { + workflow_dir: workflow_dir.clone(), + repo_root: repo_root.clone(), + entities: vec![SessionScanEntity::from(&active)], + roots: SessionRoots { + codex: vec![temp.path().to_path_buf()], + claude_code: Vec::new(), + }, + previous_state: Default::default(), + }; + let running = scan_local_sessions_with_state(&request, &StdProcessProbe, SystemTime::now()) + .expect("running scan"); + app.apply_session_activity_result(crate::app::SessionActivityWorkerResult { + workflow_dir: workflow_dir.clone(), + repo_root: repo_root.clone(), + result: Ok(running.report), + state: running.state.clone(), + retry_immediately: false, + }); + + let mut terminal = Terminal::new(TestBackend::new(200, 24)).expect("terminal"); + terminal.draw(|frame| render(frame, &app)).expect("render"); + let running_buffer = terminal.backend().buffer(); + assert!(find_styled_text(running_buffer, "\u{25CF}", |style| { + style.fg == Some(Color::Green) + })); + assert!(buffer_text(running_buffer).contains("running · worker")); + + let mut append = fs::OpenOptions::new() + .append(true) + .open(&child_path) + .expect("append"); + writeln!( + append, + r#"{{"timestamp":4,"type":"event_msg","payload":{{"type":"task_complete","turn_id":"turn"}}}}"# + ) + .expect("terminal"); + let stopped = scan_local_sessions_with_state( + &SessionScanRequest { + previous_state: running.state, + ..request + }, + &StdProcessProbe, + SystemTime::now(), + ) + .expect("terminal scan"); + app.apply_session_activity_result(crate::app::SessionActivityWorkerResult { + workflow_dir, + repo_root, + result: Ok(stopped.report), + state: stopped.state, + retry_immediately: false, + }); + terminal.draw(|frame| render(frame, &app)).expect("render"); + let stopped_text = buffer_text(terminal.backend().buffer()); + assert!(!stopped_text.contains("running · worker")); + assert!(!stopped_text.contains("\u{25CF} Stable activity task")); +} + #[test] fn task_row_renders_human_gate_with_high_salience_marker() { let app = app_with_session_attribution( diff --git a/docs/superpowers/specs/2026-07-27-spacetop-entity-activity-design.md b/docs/superpowers/specs/2026-07-27-spacetop-entity-activity-design.md index 1f35e73..1313f9f 100644 --- a/docs/superpowers/specs/2026-07-27-spacetop-entity-activity-design.md +++ b/docs/superpowers/specs/2026-07-27-spacetop-entity-activity-design.md @@ -32,17 +32,28 @@ Confidence and a separate handler field are not part of the activity display. Detection parses structured JSON fields and fails closed: - A Codex worker needs canonical child `thread_spawn` metadata with a non-empty - `parent_thread_id`, the exact dispatch assignment, and `task_started`; - matching `task_complete` stops it. + `parent_thread_id`, matching repo/worktree `cwd`, and `task_started`. The + dispatch join may be the legacy exact child assignment or the parent + session's exact `sub_agent_activity(kind=started)` record with matching child + rollout id and agent path; encrypted v2 assignment prose is not evidence. + Matching `task_complete` or an exact parent interruption stops it. - A Claude Code worker needs a canonical parent `Agent` call, correlated - teammate metadata and sidechain acceptance; the matched - `idle_notification` stops it. Correlation is scoped to the exact parent - session directory and tool-use call; reusable worker names never link - activity across parent sessions. + teammate metadata and sidechain acceptance with matching repo/worktree + `cwd`; the matched `idle_notification` stops it. The dispatch basename may + carry the exact parent-session prefix. Current teammate metadata may omit + `agentId`; in that case the sibling sidechain supplies the non-empty id and + its directory supplies the parent session. Correlation remains scoped to the + exact parent directory and tool-use call, with the existing unique + parent/name fail-closed rule when the call id is absent. +- Reusable Claude workers can reopen after an idle transition only when the + correlated parent observes a later teammate-message boundary and the same + sidechain `agentId` emits a subsequent assistant record. Every matching idle + envelope is retained, so repeated handoffs reduce in timestamp order. - FO activity starts only after an exact entity/dispatch-scoped structured tool call. For Codex code-mode `exec`, only command arguments inside nested - `tools.exec_command(...)` calls count; module text and `text(path)` output do - not. The corresponding turn/end-turn closes it. + `tools.exec_command(...)` calls count; direct structured `exec_command` + calls are also executable evidence. Module text and `text(path)` output do + not count. The corresponding turn/end-turn closes it. - A human gate requires an outstanding `request_user_input` or `AskUserQuestion` call scoped to that FO turn, a gate id/header, and both accept and reject option classes. @@ -58,10 +69,20 @@ The existing filesystem watcher and periodic session scan trigger rescans. Session logs and workflow files remain read-only. Fixtures contain only sanitized structural records needed to pin the Codex and Claude Code schemas; prompt and transcript bodies are not exposed in the UI. JSONL artifacts are -streamed record by record without a size cutoff, and each record is projected -to the structural fields needed by the reducer before it is retained. Each -file snapshot carries its safe byte cursor and projected summary: unchanged -files reuse the summary, appends resume at the cursor, truncations rebuild it, -and deletions drop it. A small checkpoint guards cursor reuse when bytes -immediately before the cursor changed, so a large or changed artifact cannot -silently clear real activity. +streamed record by record without a size cutoff and projected directly into +typed, privacy-safe facts. + +`SessionScanState` crosses the background/app boundary. It contains +`SessionFileCursor` values and a `SessionEvidenceStore` keyed by stable runtime +session identity (falling back to a typed source identity until a session id is +available). Unchanged files reuse their cursors, appends resume at the saved +byte offset, and a checkpoint rejects stale cursor reuse. Truncation, rotation, +deletion, or a transient malformed metadata rewrite never deletes previously +observed facts and never synthesizes a stop; only an exact structured terminal +or idle fact closes open activity. + +Each scan inventories `(path, length, modified)` before and after reading. If +the inventory changes, that generation is rejected, the last published report +and scan state remain in place, and the background worker requests an immediate +rescan. This prevents visit order from publishing a parent stop without a +concurrent child restart. diff --git a/tests/fixtures/session-activity/claude-fo-complete/session.jsonl b/tests/fixtures/session-activity/claude-fo-complete/session.jsonl index 4b92605..a35241d 100644 --- a/tests/fixtures/session-activity/claude-fo-complete/session.jsonl +++ b/tests/fixtures/session-activity/claude-fo-complete/session.jsonl @@ -1,2 +1,2 @@ -{"timestamp":"2026-07-27T12:00:00Z","type":"assistant","sessionId":"claude-fo-redacted","isSidechain":false,"message":{"content":[{"type":"tool_use","id":"read-redacted","name":"Read","input":{"file_path":"/repo/docs/state/detect-entity-activity-state.md"}}],"stop_reason":null}} -{"timestamp":"2026-07-27T12:00:01Z","type":"assistant","sessionId":"claude-fo-redacted","isSidechain":false,"message":{"content":[],"stop_reason":"end_turn"}} +{"timestamp":"2026-07-27T12:00:00Z","type":"assistant","sessionId":"claude-fo-redacted","cwd":"/repo","isSidechain":false,"message":{"content":[{"type":"tool_use","id":"read-redacted","name":"Read","input":{"file_path":"/repo/docs/state/detect-entity-activity-state.md"}}],"stop_reason":null}} +{"timestamp":"2026-07-27T12:00:01Z","type":"assistant","sessionId":"claude-fo-redacted","cwd":"/repo","isSidechain":false,"message":{"content":[],"stop_reason":"end_turn"}} diff --git a/tests/fixtures/session-activity/claude-fo-gate/session.jsonl b/tests/fixtures/session-activity/claude-fo-gate/session.jsonl index 6c1241a..9f49d47 100644 --- a/tests/fixtures/session-activity/claude-fo-gate/session.jsonl +++ b/tests/fixtures/session-activity/claude-fo-gate/session.jsonl @@ -1 +1 @@ -{"timestamp":"2026-07-27T12:00:00Z","type":"assistant","sessionId":"claude-fo-redacted","isSidechain":false,"message":{"content":[{"type":"tool_use","id":"read-redacted","name":"Read","input":{"file_path":"/repo/docs/state/detect-entity-activity-state.md"}},{"type":"tool_use","id":"gate-redacted","name":"AskUserQuestion","input":{"questions":[{"header":"Verify gate","question":"Decision?","options":[{"label":"Pass"},{"label":"Reject"}]}]}}],"stop_reason":null}} +{"timestamp":"2026-07-27T12:00:00Z","type":"assistant","sessionId":"claude-fo-redacted","cwd":"/repo","isSidechain":false,"message":{"content":[{"type":"tool_use","id":"read-redacted","name":"Read","input":{"file_path":"/repo/docs/state/detect-entity-activity-state.md"}},{"type":"tool_use","id":"gate-redacted","name":"AskUserQuestion","input":{"questions":[{"header":"Verify gate","question":"Decision?","options":[{"label":"Pass"},{"label":"Reject"}]}]}}],"stop_reason":null}} diff --git a/tests/fixtures/session-activity/claude-modern-cross-parent/different-parent/subagents/worker.jsonl b/tests/fixtures/session-activity/claude-modern-cross-parent/different-parent/subagents/worker.jsonl new file mode 100644 index 0000000..9b350f7 --- /dev/null +++ b/tests/fixtures/session-activity/claude-modern-cross-parent/different-parent/subagents/worker.jsonl @@ -0,0 +1 @@ +{"timestamp":2,"type":"assistant","sessionId":"claude-child","cwd":"/repo/.worktrees/stable-agent-activity-detection","isSidechain":true,"agentId":"claude-worker","message":{"content":[{"type":"text","text":"accepted"}],"stop_reason":null}} diff --git a/tests/fixtures/session-activity/claude-modern-cross-parent/different-parent/subagents/worker.meta.json b/tests/fixtures/session-activity/claude-modern-cross-parent/different-parent/subagents/worker.meta.json new file mode 100644 index 0000000..e0edfcb --- /dev/null +++ b/tests/fixtures/session-activity/claude-modern-cross-parent/different-parent/subagents/worker.meta.json @@ -0,0 +1 @@ +{"taskKind":"in_process_teammate","name":"spacedock-ensign-stable-agent-activity-detection-implement"} diff --git a/tests/fixtures/session-activity/claude-modern-cross-parent/parent.jsonl b/tests/fixtures/session-activity/claude-modern-cross-parent/parent.jsonl new file mode 100644 index 0000000..b3de422 --- /dev/null +++ b/tests/fixtures/session-activity/claude-modern-cross-parent/parent.jsonl @@ -0,0 +1 @@ +{"timestamp":1,"type":"assistant","sessionId":"claude-parent","cwd":"/repo","isSidechain":false,"message":{"content":[{"type":"tool_use","id":"agent-call","name":"Agent","input":{"name":"spacedock-ensign-stable-agent-activity-detection-implement","prompt":"Read /tmp/spacedock-dispatch/claude-parent-spacedock-ensign-stable-agent-activity-detection-implement.md and treat its content as your assignment."}}],"stop_reason":"end_turn"}} diff --git a/tests/fixtures/session-activity/claude-modern-duplicate-call/claude-parent/subagents/worker.jsonl b/tests/fixtures/session-activity/claude-modern-duplicate-call/claude-parent/subagents/worker.jsonl new file mode 100644 index 0000000..9b350f7 --- /dev/null +++ b/tests/fixtures/session-activity/claude-modern-duplicate-call/claude-parent/subagents/worker.jsonl @@ -0,0 +1 @@ +{"timestamp":2,"type":"assistant","sessionId":"claude-child","cwd":"/repo/.worktrees/stable-agent-activity-detection","isSidechain":true,"agentId":"claude-worker","message":{"content":[{"type":"text","text":"accepted"}],"stop_reason":null}} diff --git a/tests/fixtures/session-activity/claude-modern-duplicate-call/claude-parent/subagents/worker.meta.json b/tests/fixtures/session-activity/claude-modern-duplicate-call/claude-parent/subagents/worker.meta.json new file mode 100644 index 0000000..e0edfcb --- /dev/null +++ b/tests/fixtures/session-activity/claude-modern-duplicate-call/claude-parent/subagents/worker.meta.json @@ -0,0 +1 @@ +{"taskKind":"in_process_teammate","name":"spacedock-ensign-stable-agent-activity-detection-implement"} diff --git a/tests/fixtures/session-activity/claude-modern-duplicate-call/parent.jsonl b/tests/fixtures/session-activity/claude-modern-duplicate-call/parent.jsonl new file mode 100644 index 0000000..e587c6f --- /dev/null +++ b/tests/fixtures/session-activity/claude-modern-duplicate-call/parent.jsonl @@ -0,0 +1 @@ +{"timestamp":1,"type":"assistant","sessionId":"claude-parent","cwd":"/repo","isSidechain":false,"message":{"content":[{"type":"tool_use","id":"agent-call-a","name":"Agent","input":{"name":"spacedock-ensign-stable-agent-activity-detection-implement","prompt":"Read /tmp/spacedock-dispatch/claude-parent-spacedock-ensign-stable-agent-activity-detection-implement.md and treat its content as your assignment."}},{"type":"tool_use","id":"agent-call-b","name":"Agent","input":{"name":"spacedock-ensign-stable-agent-activity-detection-implement","prompt":"Read /tmp/spacedock-dispatch/claude-parent-spacedock-ensign-stable-agent-activity-detection-implement.md and treat its content as your assignment."}}],"stop_reason":"end_turn"}} diff --git a/tests/fixtures/session-activity/claude-modern-worker-open/claude-parent/subagents/worker.jsonl b/tests/fixtures/session-activity/claude-modern-worker-open/claude-parent/subagents/worker.jsonl new file mode 100644 index 0000000..9b350f7 --- /dev/null +++ b/tests/fixtures/session-activity/claude-modern-worker-open/claude-parent/subagents/worker.jsonl @@ -0,0 +1 @@ +{"timestamp":2,"type":"assistant","sessionId":"claude-child","cwd":"/repo/.worktrees/stable-agent-activity-detection","isSidechain":true,"agentId":"claude-worker","message":{"content":[{"type":"text","text":"accepted"}],"stop_reason":null}} diff --git a/tests/fixtures/session-activity/claude-modern-worker-open/claude-parent/subagents/worker.meta.json b/tests/fixtures/session-activity/claude-modern-worker-open/claude-parent/subagents/worker.meta.json new file mode 100644 index 0000000..e0edfcb --- /dev/null +++ b/tests/fixtures/session-activity/claude-modern-worker-open/claude-parent/subagents/worker.meta.json @@ -0,0 +1 @@ +{"taskKind":"in_process_teammate","name":"spacedock-ensign-stable-agent-activity-detection-implement"} diff --git a/tests/fixtures/session-activity/claude-modern-worker-open/parent.jsonl b/tests/fixtures/session-activity/claude-modern-worker-open/parent.jsonl new file mode 100644 index 0000000..b3de422 --- /dev/null +++ b/tests/fixtures/session-activity/claude-modern-worker-open/parent.jsonl @@ -0,0 +1 @@ +{"timestamp":1,"type":"assistant","sessionId":"claude-parent","cwd":"/repo","isSidechain":false,"message":{"content":[{"type":"tool_use","id":"agent-call","name":"Agent","input":{"name":"spacedock-ensign-stable-agent-activity-detection-implement","prompt":"Read /tmp/spacedock-dispatch/claude-parent-spacedock-ensign-stable-agent-activity-detection-implement.md and treat its content as your assignment."}}],"stop_reason":"end_turn"}} diff --git a/tests/fixtures/session-activity/claude-modern-wrong-cwd/claude-parent/subagents/worker.jsonl b/tests/fixtures/session-activity/claude-modern-wrong-cwd/claude-parent/subagents/worker.jsonl new file mode 100644 index 0000000..907bff0 --- /dev/null +++ b/tests/fixtures/session-activity/claude-modern-wrong-cwd/claude-parent/subagents/worker.jsonl @@ -0,0 +1 @@ +{"timestamp":2,"type":"assistant","sessionId":"claude-child","cwd":"/different-repo","isSidechain":true,"agentId":"claude-worker","message":{"content":[{"type":"text","text":"accepted"}],"stop_reason":null}} diff --git a/tests/fixtures/session-activity/claude-modern-wrong-cwd/claude-parent/subagents/worker.meta.json b/tests/fixtures/session-activity/claude-modern-wrong-cwd/claude-parent/subagents/worker.meta.json new file mode 100644 index 0000000..e0edfcb --- /dev/null +++ b/tests/fixtures/session-activity/claude-modern-wrong-cwd/claude-parent/subagents/worker.meta.json @@ -0,0 +1 @@ +{"taskKind":"in_process_teammate","name":"spacedock-ensign-stable-agent-activity-detection-implement"} diff --git a/tests/fixtures/session-activity/claude-modern-wrong-cwd/parent.jsonl b/tests/fixtures/session-activity/claude-modern-wrong-cwd/parent.jsonl new file mode 100644 index 0000000..b3de422 --- /dev/null +++ b/tests/fixtures/session-activity/claude-modern-wrong-cwd/parent.jsonl @@ -0,0 +1 @@ +{"timestamp":1,"type":"assistant","sessionId":"claude-parent","cwd":"/repo","isSidechain":false,"message":{"content":[{"type":"tool_use","id":"agent-call","name":"Agent","input":{"name":"spacedock-ensign-stable-agent-activity-detection-implement","prompt":"Read /tmp/spacedock-dispatch/claude-parent-spacedock-ensign-stable-agent-activity-detection-implement.md and treat its content as your assignment."}}],"stop_reason":"end_turn"}} diff --git a/tests/fixtures/session-activity/claude-worker-idle/claude-fo-redacted/subagents/worker.jsonl b/tests/fixtures/session-activity/claude-worker-idle/claude-fo-redacted/subagents/worker.jsonl index 4d1aa3c..8e8b857 100644 --- a/tests/fixtures/session-activity/claude-worker-idle/claude-fo-redacted/subagents/worker.jsonl +++ b/tests/fixtures/session-activity/claude-worker-idle/claude-fo-redacted/subagents/worker.jsonl @@ -1 +1 @@ -{"timestamp":"2026-07-27T11:00:01Z","type":"assistant","sessionId":"claude-child-session-redacted","isSidechain":true,"agentId":"claude-worker-redacted","message":{"content":[{"type":"text","text":"Assignment accepted."}],"stop_reason":null}} +{"timestamp":"2026-07-27T11:00:01Z","type":"assistant","sessionId":"claude-child-session-redacted","cwd":"/repo","isSidechain":true,"agentId":"claude-worker-redacted","message":{"content":[{"type":"text","text":"Assignment accepted."}],"stop_reason":null}} diff --git a/tests/fixtures/session-activity/claude-worker-idle/parent.jsonl b/tests/fixtures/session-activity/claude-worker-idle/parent.jsonl index 3dd4dc0..353f528 100644 --- a/tests/fixtures/session-activity/claude-worker-idle/parent.jsonl +++ b/tests/fixtures/session-activity/claude-worker-idle/parent.jsonl @@ -1,2 +1,2 @@ -{"timestamp":"2026-07-27T11:00:00Z","type":"assistant","sessionId":"claude-fo-redacted","isSidechain":false,"message":{"content":[{"type":"tool_use","id":"agent-call-redacted","name":"Agent","input":{"name":"spacedock-ensign-detect-entity-activity-state-implement","prompt":"Read /tmp/spacedock-dispatch/spacedock-ensign-detect-entity-activity-state-implement.md and treat its content as your assignment."}}],"stop_reason":"end_turn"}} -{"timestamp":"2026-07-27T11:00:03Z","type":"user","sessionId":"claude-fo-redacted","isSidechain":false,"message":{"content":"{\"type\":\"idle_notification\",\"from\":\"spacedock-ensign-detect-entity-activity-state-implement\",\"idleReason\":\"available\"}"}} +{"timestamp":"2026-07-27T11:00:00Z","type":"assistant","sessionId":"claude-fo-redacted","cwd":"/repo","isSidechain":false,"message":{"content":[{"type":"tool_use","id":"agent-call-redacted","name":"Agent","input":{"name":"spacedock-ensign-detect-entity-activity-state-implement","prompt":"Read /tmp/spacedock-dispatch/spacedock-ensign-detect-entity-activity-state-implement.md and treat its content as your assignment."}}],"stop_reason":"end_turn"}} +{"timestamp":"2026-07-27T11:00:03Z","type":"user","sessionId":"claude-fo-redacted","cwd":"/repo","isSidechain":false,"message":{"content":"{\"type\":\"idle_notification\",\"from\":\"spacedock-ensign-detect-entity-activity-state-implement\",\"idleReason\":\"available\"}"}} diff --git a/tests/fixtures/session-activity/claude-worker-open/claude-fo-redacted/subagents/worker.jsonl b/tests/fixtures/session-activity/claude-worker-open/claude-fo-redacted/subagents/worker.jsonl index 4d1aa3c..8e8b857 100644 --- a/tests/fixtures/session-activity/claude-worker-open/claude-fo-redacted/subagents/worker.jsonl +++ b/tests/fixtures/session-activity/claude-worker-open/claude-fo-redacted/subagents/worker.jsonl @@ -1 +1 @@ -{"timestamp":"2026-07-27T11:00:01Z","type":"assistant","sessionId":"claude-child-session-redacted","isSidechain":true,"agentId":"claude-worker-redacted","message":{"content":[{"type":"text","text":"Assignment accepted."}],"stop_reason":null}} +{"timestamp":"2026-07-27T11:00:01Z","type":"assistant","sessionId":"claude-child-session-redacted","cwd":"/repo","isSidechain":true,"agentId":"claude-worker-redacted","message":{"content":[{"type":"text","text":"Assignment accepted."}],"stop_reason":null}} diff --git a/tests/fixtures/session-activity/claude-worker-open/parent.jsonl b/tests/fixtures/session-activity/claude-worker-open/parent.jsonl index 1b0c8a0..408aaed 100644 --- a/tests/fixtures/session-activity/claude-worker-open/parent.jsonl +++ b/tests/fixtures/session-activity/claude-worker-open/parent.jsonl @@ -1 +1 @@ -{"timestamp":"2026-07-27T11:00:00Z","type":"assistant","sessionId":"claude-fo-redacted","isSidechain":false,"message":{"content":[{"type":"tool_use","id":"agent-call-redacted","name":"Agent","input":{"name":"spacedock-ensign-detect-entity-activity-state-implement","prompt":"Read /tmp/spacedock-dispatch/spacedock-ensign-detect-entity-activity-state-implement.md and treat its content as your assignment."}}],"stop_reason":"end_turn"}} +{"timestamp":"2026-07-27T11:00:00Z","type":"assistant","sessionId":"claude-fo-redacted","cwd":"/repo","isSidechain":false,"message":{"content":[{"type":"tool_use","id":"agent-call-redacted","name":"Agent","input":{"name":"spacedock-ensign-detect-entity-activity-state-implement","prompt":"Read /tmp/spacedock-dispatch/spacedock-ensign-detect-entity-activity-state-implement.md and treat its content as your assignment."}}],"stop_reason":"end_turn"}} diff --git a/tests/fixtures/session-activity/codex-fo-exec-command/rollout.jsonl b/tests/fixtures/session-activity/codex-fo-exec-command/rollout.jsonl new file mode 100644 index 0000000..8fd1b73 --- /dev/null +++ b/tests/fixtures/session-activity/codex-fo-exec-command/rollout.jsonl @@ -0,0 +1,3 @@ +{"timestamp":"2026-07-27T10:00:00Z","type":"session_meta","payload":{"id":"codex-fo-exec-command","cwd":"/repo","source":"cli"}} +{"timestamp":"2026-07-27T10:00:01Z","type":"event_msg","payload":{"type":"task_started","turn_id":"fo-turn"}} +{"timestamp":"2026-07-27T10:00:02Z","type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"exec-command","arguments":"{\"cmd\":\"sed -n '1,40p' /repo/docs/state/detect-entity-activity-state.md\"}"}} diff --git a/tests/fixtures/session-activity/codex-v2-worker-open/child.jsonl b/tests/fixtures/session-activity/codex-v2-worker-open/child.jsonl new file mode 100644 index 0000000..bb3916d --- /dev/null +++ b/tests/fixtures/session-activity/codex-v2-worker-open/child.jsonl @@ -0,0 +1,3 @@ +{"timestamp":1,"type":"session_meta","payload":{"id":"codex-child","cwd":"/repo/.worktrees/stable-agent-activity-detection","source":{"subagent":{"thread_spawn":{"agent_path":"/root/spacedock_ensign_stable_agent_activity_detection_implement","parent_thread_id":"codex-parent"}}}}} +{"timestamp":2,"type":"event_msg","payload":{"type":"task_started","turn_id":"turn"}} +{"timestamp":3,"type":"agent_message","payload":{"message":"encrypted-v2-assignment"}} diff --git a/tests/fixtures/session-activity/codex-v2-worker-open/parent.jsonl b/tests/fixtures/session-activity/codex-v2-worker-open/parent.jsonl new file mode 100644 index 0000000..fbf8b69 --- /dev/null +++ b/tests/fixtures/session-activity/codex-v2-worker-open/parent.jsonl @@ -0,0 +1,2 @@ +{"timestamp":0,"type":"session_meta","payload":{"id":"codex-parent","cwd":"/repo","source":"cli"}} +{"timestamp":1,"type":"event_msg","payload":{"type":"sub_agent_activity","kind":"started","agent_thread_id":"codex-child","agent_path":"/root/spacedock_ensign_stable_agent_activity_detection_implement"}} diff --git a/tests/fixtures/session-activity/codex-v2-wrong-cwd/child.jsonl b/tests/fixtures/session-activity/codex-v2-wrong-cwd/child.jsonl new file mode 100644 index 0000000..cd42b9a --- /dev/null +++ b/tests/fixtures/session-activity/codex-v2-wrong-cwd/child.jsonl @@ -0,0 +1,2 @@ +{"timestamp":1,"type":"session_meta","payload":{"id":"codex-child","cwd":"/other","source":{"subagent":{"thread_spawn":{"agent_path":"/root/spacedock_ensign_stable_agent_activity_detection_implement","parent_thread_id":"codex-parent"}}}}} +{"timestamp":2,"type":"event_msg","payload":{"type":"task_started","turn_id":"turn"}} diff --git a/tests/fixtures/session-activity/codex-v2-wrong-cwd/parent.jsonl b/tests/fixtures/session-activity/codex-v2-wrong-cwd/parent.jsonl new file mode 100644 index 0000000..fbf8b69 --- /dev/null +++ b/tests/fixtures/session-activity/codex-v2-wrong-cwd/parent.jsonl @@ -0,0 +1,2 @@ +{"timestamp":0,"type":"session_meta","payload":{"id":"codex-parent","cwd":"/repo","source":"cli"}} +{"timestamp":1,"type":"event_msg","payload":{"type":"sub_agent_activity","kind":"started","agent_thread_id":"codex-child","agent_path":"/root/spacedock_ensign_stable_agent_activity_detection_implement"}} diff --git a/tests/fixtures/session-activity/codex-v2-wrong-parent/child.jsonl b/tests/fixtures/session-activity/codex-v2-wrong-parent/child.jsonl new file mode 100644 index 0000000..3bf412c --- /dev/null +++ b/tests/fixtures/session-activity/codex-v2-wrong-parent/child.jsonl @@ -0,0 +1,2 @@ +{"timestamp":1,"type":"session_meta","payload":{"id":"codex-child","cwd":"/repo/.worktrees/stable-agent-activity-detection","source":{"subagent":{"thread_spawn":{"agent_path":"/root/spacedock_ensign_stable_agent_activity_detection_implement","parent_thread_id":"different-parent"}}}}} +{"timestamp":2,"type":"event_msg","payload":{"type":"task_started","turn_id":"turn"}} diff --git a/tests/fixtures/session-activity/codex-v2-wrong-parent/parent.jsonl b/tests/fixtures/session-activity/codex-v2-wrong-parent/parent.jsonl new file mode 100644 index 0000000..fbf8b69 --- /dev/null +++ b/tests/fixtures/session-activity/codex-v2-wrong-parent/parent.jsonl @@ -0,0 +1,2 @@ +{"timestamp":0,"type":"session_meta","payload":{"id":"codex-parent","cwd":"/repo","source":"cli"}} +{"timestamp":1,"type":"event_msg","payload":{"type":"sub_agent_activity","kind":"started","agent_thread_id":"codex-child","agent_path":"/root/spacedock_ensign_stable_agent_activity_detection_implement"}} diff --git a/tests/fixtures/session-activity/codex-v2-wrong-path/child.jsonl b/tests/fixtures/session-activity/codex-v2-wrong-path/child.jsonl new file mode 100644 index 0000000..100d958 --- /dev/null +++ b/tests/fixtures/session-activity/codex-v2-wrong-path/child.jsonl @@ -0,0 +1,2 @@ +{"timestamp":1,"type":"session_meta","payload":{"id":"codex-child","cwd":"/repo/.worktrees/stable-agent-activity-detection","source":{"subagent":{"thread_spawn":{"agent_path":"/root/spacedock_ensign_stable_agent_activity_detection_implement","parent_thread_id":"codex-parent"}}}}} +{"timestamp":2,"type":"event_msg","payload":{"type":"task_started","turn_id":"turn"}} diff --git a/tests/fixtures/session-activity/codex-v2-wrong-path/parent.jsonl b/tests/fixtures/session-activity/codex-v2-wrong-path/parent.jsonl new file mode 100644 index 0000000..f26e354 --- /dev/null +++ b/tests/fixtures/session-activity/codex-v2-wrong-path/parent.jsonl @@ -0,0 +1,2 @@ +{"timestamp":0,"type":"session_meta","payload":{"id":"codex-parent","cwd":"/repo","source":"cli"}} +{"timestamp":1,"type":"event_msg","payload":{"type":"sub_agent_activity","kind":"started","agent_thread_id":"codex-child","agent_path":"/root/spacedock_ensign_other_task_implement"}} diff --git a/tests/fixtures/session-activity/codex-worker-complete/rollout.jsonl b/tests/fixtures/session-activity/codex-worker-complete/rollout.jsonl index f3afb03..d53ab89 100644 --- a/tests/fixtures/session-activity/codex-worker-complete/rollout.jsonl +++ b/tests/fixtures/session-activity/codex-worker-complete/rollout.jsonl @@ -1,4 +1,4 @@ -{"timestamp":"2026-07-27T10:00:00Z","type":"session_meta","payload":{"id":"codex-worker-redacted","source":{"subagent":{"thread_spawn":{"agent_path":"/root/spacedock_ensign_detect_entity_activity_state_implement","parent_thread_id":"codex-fo-redacted"}}}}} +{"timestamp":"2026-07-27T10:00:00Z","type":"session_meta","payload":{"id":"codex-worker-redacted","cwd":"/repo","source":{"subagent":{"thread_spawn":{"agent_path":"/root/spacedock_ensign_detect_entity_activity_state_implement","parent_thread_id":"codex-fo-redacted"}}}}} {"timestamp":"2026-07-27T10:00:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"Read /tmp/spacedock-dispatch/spacedock-ensign-detect-entity-activity-state-implement.md and treat its content as your assignment."}]}} {"timestamp":"2026-07-27T10:00:02Z","type":"event_msg","payload":{"type":"task_started","turn_id":"worker-turn-redacted"}} {"timestamp":"2026-07-27T10:00:03Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"worker-turn-redacted"}} diff --git a/tests/fixtures/session-activity/codex-worker-open/rollout.jsonl b/tests/fixtures/session-activity/codex-worker-open/rollout.jsonl index 198f228..e2b8ffe 100644 --- a/tests/fixtures/session-activity/codex-worker-open/rollout.jsonl +++ b/tests/fixtures/session-activity/codex-worker-open/rollout.jsonl @@ -1,3 +1,3 @@ -{"timestamp":"2026-07-27T10:00:00Z","type":"session_meta","payload":{"id":"codex-worker-redacted","source":{"subagent":{"thread_spawn":{"agent_path":"/root/spacedock_ensign_detect_entity_activity_state_implement","parent_thread_id":"codex-fo-redacted"}}}}} +{"timestamp":"2026-07-27T10:00:00Z","type":"session_meta","payload":{"id":"codex-worker-redacted","cwd":"/repo","source":{"subagent":{"thread_spawn":{"agent_path":"/root/spacedock_ensign_detect_entity_activity_state_implement","parent_thread_id":"codex-fo-redacted"}}}}} {"timestamp":"2026-07-27T10:00:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"Read /tmp/spacedock-dispatch/spacedock-ensign-detect-entity-activity-state-implement.md and treat its content as your assignment."}]}} {"timestamp":"2026-07-27T10:00:02Z","type":"event_msg","payload":{"type":"task_started","turn_id":"worker-turn-redacted"}} diff --git a/tests/fixtures/session-activity/codex-worker-unlinked/rollout.jsonl b/tests/fixtures/session-activity/codex-worker-unlinked/rollout.jsonl index 0b8d77e..9e98ca3 100644 --- a/tests/fixtures/session-activity/codex-worker-unlinked/rollout.jsonl +++ b/tests/fixtures/session-activity/codex-worker-unlinked/rollout.jsonl @@ -1,3 +1,3 @@ -{"timestamp":"2026-07-27T10:00:00Z","type":"session_meta","payload":{"id":"codex-worker-unlinked-redacted","source":{"subagent":{"thread_spawn":{"agent_path":"/root/spacedock_ensign_detect_entity_activity_state_implement","parent_thread_id":""}}}}} +{"timestamp":"2026-07-27T10:00:00Z","type":"session_meta","payload":{"id":"codex-worker-unlinked-redacted","cwd":"/repo","source":{"subagent":{"thread_spawn":{"agent_path":"/root/spacedock_ensign_detect_entity_activity_state_implement","parent_thread_id":""}}}}} {"timestamp":"2026-07-27T10:00:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"Read /tmp/spacedock-dispatch/spacedock-ensign-detect-entity-activity-state-implement.md and treat its content as your assignment."}]}} {"timestamp":"2026-07-27T10:00:02Z","type":"event_msg","payload":{"type":"task_started","turn_id":"worker-turn-redacted"}}