diff --git a/Cargo.lock b/Cargo.lock index 976f57b..411fec8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -269,7 +269,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "autter" -version = "1.7.0" +version = "1.7.1" dependencies = [ "autter", "base64", diff --git a/Cargo.toml b/Cargo.toml index 9ba1036..4492f8f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ resolver = "3" [package] name = "autter" -version = "1.7.0" +version = "1.7.1" edition = "2024" default-run = "autter" diff --git a/INSTALL.md b/INSTALL.md index 4bb413b..f413326 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -55,7 +55,7 @@ New terminals pick it up automatically. Restart your IDE (not just its terminal ```bash autter --version -autter doctor # v1.7.0+ — focused setup validation (exits 1 on failure) +autter doctor # v1.7.1+ — focused setup validation (exits 1 on failure) autter debug # full support dump (always exits 0) ``` diff --git a/src/commands/autter_handlers.rs b/src/commands/autter_handlers.rs index 8c10283..1cf0b3f 100644 --- a/src/commands/autter_handlers.rs +++ b/src/commands/autter_handlers.rs @@ -540,16 +540,44 @@ fn handle_checkpoint(args: &[String]) { ); } + // How long to wait for a freshly-spawned daemon to accept connections when a + // send fails. Paid only when the daemon is down — the exact case that used + // to silently drop a whole session's checkpoints — so a one-off spawn cost + // beats losing the data. + const CHECKPOINT_DAEMON_SPAWN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + let mut sent_count = 0u64; + let mut active_socket = config.control_socket_path.clone(); + let mut tried_spawn = false; for request in requests { let t_send = std::time::Instant::now(); let control_request = ControlRequest::CheckpointRun { request: Box::new(request), }; - let send_result = crate::daemon::send_control_request_fire_and_forget( - &config.control_socket_path, - &control_request, - ); + let mut send_result = + crate::daemon::send_control_request_fire_and_forget(&active_socket, &control_request); + + // A failed send almost always means the daemon is down (crashed, never + // started, or restarting). Rather than dropping the checkpoint — which + // silently loses the session's steps — spawn the daemon and retry once. + // `ensure_daemon_running` is a no-op when the daemon is already up, so a + // transient blip is retried too. Only attempt the spawn once per call. + if send_result.is_err() && !tried_spawn { + tried_spawn = true; + match crate::commands::daemon::ensure_daemon_running(CHECKPOINT_DAEMON_SPAWN_TIMEOUT) { + Ok(spawned) => { + active_socket = spawned.control_socket_path; + send_result = crate::daemon::send_control_request_fire_and_forget( + &active_socket, + &control_request, + ); + } + Err(e) => { + eprintln!("Background worker unavailable, checkpoint dropped: {}", e); + std::process::exit(0); + } + } + } if perf { eprintln!( "[perf] checkpoint: ipc_send={:.1}ms", diff --git a/src/daemon/checkpoint.rs b/src/daemon/checkpoint.rs index d5e1496..c5af5e3 100644 --- a/src/daemon/checkpoint.rs +++ b/src/daemon/checkpoint.rs @@ -45,6 +45,38 @@ impl FileLineStats { } } +/// Serialize the line ranges a single checkpoint (step) touched in one file, as a +/// compact JSON array of `[start, end]` pairs (1-indexed, inclusive), sorted and +/// with overlapping/adjacent ranges merged. A checkpoint's own lines are the +/// attributions whose `author_id` embeds this checkpoint's `trace_id` (AI kinds +/// use `s_::t_`). Returns `None` when nothing is attributable to +/// this step (e.g. plain human saves), so the metric field stays empty. +fn serialize_touched_ranges(entry: &WorkingLogEntry, trace_id: &str) -> Option { + if trace_id.is_empty() { + return None; + } + let mut ranges: Vec<(u32, u32)> = entry + .line_attributions + .iter() + .filter(|la| la.author_id.contains(trace_id)) + .map(|la| (la.start_line, la.end_line)) + .collect(); + if ranges.is_empty() { + return None; + } + ranges.sort_unstable(); + let mut merged: Vec<[u32; 2]> = Vec::with_capacity(ranges.len()); + for (start, end) in ranges { + match merged.last_mut() { + Some(last) if start <= last[1].saturating_add(1) => { + last[1] = last[1].max(end); + } + _ => merged.push([start, end]), + } + } + serde_json::to_string(&merged).ok() +} + /// Latest checkpoint state needed to process a file in the next checkpoint. #[derive(Debug, Clone)] struct PreviousFileState { @@ -389,6 +421,13 @@ fn execute_resolved_checkpoint( if let Some(ek) = edit_kind { values = values.edit_kind(ek); } + // Record the exact line ranges this step touched, so per-session step + // detail survives even when a later checkpoint overwrites these lines + // (the authorship note would drop them). Attributions carrying this + // checkpoint's trace_id are the lines it authored. + if let Some(ranges) = serialize_touched_ranges(entry, &trace_id) { + values = values.line_ranges(ranges); + } let file_attrs = attrs.clone().author(&checkpoint.author); crate::metrics::record(values, file_attrs); diff --git a/src/metrics/events.rs b/src/metrics/events.rs index e9ec20c..b273863 100644 --- a/src/metrics/events.rs +++ b/src/metrics/events.rs @@ -452,6 +452,7 @@ pub mod checkpoint_pos { pub const LINES_DELETED_SLOC: usize = 6; // u32 - for this file pub const TOOL_USE_ID: usize = 7; // String - nullable pub const EDIT_KIND: usize = 8; // String - nullable ("file_edit" | "bash") + pub const LINE_RANGES: usize = 9; // String - nullable; JSON `[[start,end],…]` (1-indexed, inclusive) touched by this step in this file } /// Values for Event ID 4: checkpoint @@ -471,6 +472,7 @@ pub mod checkpoint_pos { /// | 6 | lines_deleted_sloc | u32 | /// | 7 | external_tool_use_id | String (nullable) | /// | 8 | edit_kind | String (nullable) | +/// | 9 | line_ranges | String (nullable) — JSON `[[start,end],…]` touched by this step | #[derive(Debug, Clone, Default)] pub struct CheckpointValues { pub checkpoint_ts: PosField, @@ -482,6 +484,10 @@ pub struct CheckpointValues { pub lines_deleted_sloc: PosField, pub external_tool_use_id: PosField, pub edit_kind: PosField, + /// JSON `[[start,end],…]` (1-indexed, inclusive) of the line ranges this + /// checkpoint/step touched in this file. Empty/absent on older clients and + /// on checkpoints that touched no attributable lines (e.g. human saves). + pub line_ranges: PosField, } impl CheckpointValues { @@ -587,6 +593,17 @@ impl CheckpointValues { self.edit_kind = Some(None); self } + + pub fn line_ranges(mut self, value: impl Into) -> Self { + self.line_ranges = Some(Some(value.into())); + self + } + + #[allow(dead_code)] + pub fn line_ranges_null(mut self) -> Self { + self.line_ranges = Some(None); + self + } } impl PosEncoded for CheckpointValues { @@ -634,6 +651,11 @@ impl PosEncoded for CheckpointValues { checkpoint_pos::EDIT_KIND, string_to_json(&self.edit_kind), ); + sparse_set( + &mut map, + checkpoint_pos::LINE_RANGES, + string_to_json(&self.line_ranges), + ); map } @@ -649,6 +671,7 @@ impl PosEncoded for CheckpointValues { lines_deleted_sloc: sparse_get_u32(arr, checkpoint_pos::LINES_DELETED_SLOC), external_tool_use_id: sparse_get_string(arr, checkpoint_pos::TOOL_USE_ID), edit_kind: sparse_get_string(arr, checkpoint_pos::EDIT_KIND), + line_ranges: sparse_get_string(arr, checkpoint_pos::LINE_RANGES), } } }