Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,10 @@ Run from repo root through Nix (do not call bare `bun` on the host). Working dir

### Reading SCE Turso databases

- Use the Turso CLI packaged by this repository, not a host `sqlite3`, a generic nixpkgs Turso package, or another Turso version. `nix run .#turso -- --version` must report the version pinned in `flake.nix` (currently `0.7.0`).
- This repository no longer packages a Turso CLI (removed from `flake.nix` in `1b84a0b1`); there is no `nix run .#turso`. Use a Turso shell (`turso` / `tursodb`) whose `--version` matches the `turso` crate version in `cli/Cargo.lock` (currently `0.8.1`). Do not use a host `sqlite3`, nixpkgs `limbo` (older on-disk format), or nixpkgs `turso-cli` (the Turso Cloud CLI, not a database shell). If no matching shell is available, ask the user before inspecting with anything else.
- Discover the active repository-scoped Agent Trace DB with `nix run .#sce -- doctor --format json` and read `agent_trace_db.path`. Do not guess the repository ID or inspect legacy `<state-root>/sce/agent-trace.db` / `agent-trace-<checkout-id>.db` files; active storage is `<state-root>/sce/repos/<repository-id>/agent-trace.db`.
- Always enable Turso's multiprocess WAL coordination when opening an SCE database that may also be used by hooks or another agent process: `nix run .#turso -- --experimental-multiprocess-wal --readonly "<agent-trace-db-path>"`.
- Prefer `--readonly` for inspection. Add a SQL argument for non-interactive reads, for example: `nix run .#turso -- --experimental-multiprocess-wal --readonly "<agent-trace-db-path>" "PRAGMA quick_check;"`.
- Always enable Turso's multiprocess WAL coordination when opening an SCE database that may also be used by hooks or another agent process: `turso --experimental-multiprocess-wal --readonly "<agent-trace-db-path>"`.
- Prefer `--readonly` for inspection. Add a SQL argument for non-interactive reads, for example: `turso --experimental-multiprocess-wal --readonly "<agent-trace-db-path>" "PRAGMA quick_check;"`. Add `-m list` for pipe-separated output that is easier to post-process.
- Verify the connection before interpreting data: run `PRAGMA quick_check;` (expect `ok`), `PRAGMA journal_mode;` (expect `wal`), and `SELECT id, applied_at FROM __sce_migrations ORDER BY id;`.
- Useful Agent Trace checks include `SELECT COUNT(*) FROM agent_traces;`, `SELECT COUNT(*) FROM messages;`, `SELECT COUNT(*) FROM parts;`, and `SELECT actor_kind, status, COUNT(*) FROM mutation_trace_scopes GROUP BY actor_kind, status;`.
- Concurrent inspection processes may temporarily appear as active OpenCode `bash` mutation scopes because the query command itself is a tracked tool call. After the commands finish, `.git/sce/opencode-mutation-scope-state.json` should normally show `"attempts": []` and a clear recovery phase.
Expand Down
38 changes: 30 additions & 8 deletions cli/src/services/doctor/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::fmt::Write as _;
use std::fs;
use std::path::{Path, PathBuf};

Expand All @@ -11,8 +12,8 @@ use crate::services::lifecycle::{
HealthProblemKind, HealthSeverity, LifecycleProvider, LifecycleProviderId,
};
use crate::services::mutation_trace::runtime::{
inspect_reconciliation_recommendation, run_explicit_reconciliation, ReconciliationFix,
ReconciliationRecommendation,
inspect_reconciliation_recommendation, run_explicit_reconciliation, ReconciliationCounts,
ReconciliationFix, ReconciliationRecommendation,
};
use crate::services::output_format::OutputFormat;
use crate::services::setup;
Expand Down Expand Up @@ -214,20 +215,39 @@ async fn execute_doctor_with_lifecycle_providers(
}
}

fn reconciliation_completed_text(counts: &ReconciliationCounts) -> String {
let mut text = format!(
"Snapshot ref reconciliation completed: deleted {}, retained {}, locally required {}.",
counts.deleted, counts.retained, counts.local_required
);
if counts.pruned_events > 0 {
let _ = write!(
text,
" Pruned {} mutation event(s) whose snapshot trees are absent from this repository.",
counts.pruned_events
);
}
if counts.repinned > 0 {
let _ = write!(
text,
" Re-pinned {} snapshot tree(s) that were missing pins.",
counts.repinned
);
}
text
}

fn reconciliation_fix_result(fix: &ReconciliationFix) -> DoctorFixResultRecord {
let (outcome, detail) = match fix {
ReconciliationFix::Completed(counts) => (
FixResult::Fixed,
format!(
"Snapshot ref reconciliation completed: deleted {}, retained {}, locally required {}.",
counts.deleted, counts.retained, counts.local_required
),
reconciliation_completed_text(counts),
),
ReconciliationFix::CompletedStatePersistFailed { counts, warning } => (
FixResult::Fixed,
format!(
"Snapshot ref reconciliation completed: deleted {}, retained {}, locally required {}. Warning: {warning}",
counts.deleted, counts.retained, counts.local_required
"{} Warning: {warning}",
reconciliation_completed_text(counts)
),
),
ReconciliationFix::Failed {
Expand Down Expand Up @@ -564,6 +584,8 @@ mod reconciliation_fix_tests {
deleted: 3,
retained: 2,
local_required: 1,
repinned: 0,
pruned_events: 0,
};

fn not_applied() -> StateWarning {
Expand Down
8 changes: 6 additions & 2 deletions cli/src/services/doctor/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1028,6 +1028,8 @@ fn reconciliation_counts_json(counts: &ReconciliationCounts) -> serde_json::Valu
"deleted": counts.deleted,
"retained": counts.retained,
"local_required": counts.local_required,
"repinned": counts.repinned,
"pruned_events": counts.pruned_events,
})
}

Expand All @@ -1053,6 +1055,8 @@ mod reconciliation_render_tests {
deleted: 3,
retained: 2,
local_required: 1,
repinned: 0,
pruned_events: 0,
};

fn recommendation(last_attempt_age_ms: Option<i64>) -> ReconciliationRecommendation {
Expand Down Expand Up @@ -1087,7 +1091,7 @@ mod reconciliation_render_tests {

#[test]
fn json_ref_reconciliation_fix_results_match_contract() {
let counts = json!({ "deleted": 3, "retained": 2, "local_required": 1 });
let counts = json!({ "deleted": 3, "retained": 2, "local_required": 1, "repinned": 0, "pruned_events": 0 });
let cases = [
(
"completed",
Expand Down Expand Up @@ -1153,7 +1157,7 @@ mod reconciliation_render_tests {

#[test]
fn json_completed_state_persist_failed_results_match_contract() {
let counts = json!({ "deleted": 3, "retained": 2, "local_required": 1 });
let counts = json!({ "deleted": 3, "retained": 2, "local_required": 1, "repinned": 0, "pruned_events": 0 });
let cases = [
(
"completed + not applied",
Expand Down
2 changes: 1 addition & 1 deletion cli/src/services/mutation_trace/runtime/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ where
fn protected_worktree_failure(error: ProtectedWorktreeError) -> CoordinateError {
match error {
ProtectedWorktreeError::GitDirResolution(source)
| ProtectedWorktreeError::CheckoutIdentity(source) => CoordinateError::Other(source),
| ProtectedWorktreeError::WorktreeIdentity(source) => CoordinateError::Other(source),
ProtectedWorktreeError::LockAcquisition(source) => {
CoordinateError::LockAcquisition(anyhow::Error::new(source))
}
Expand Down
122 changes: 122 additions & 0 deletions cli/src/services/mutation_trace/runtime/git_snapshot.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
use std::collections::BTreeSet;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Stdio;

use anyhow::{anyhow, bail, Context, Result};
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use uuid::Uuid;

Expand Down Expand Up @@ -102,6 +104,84 @@ impl GitSnapshotService {
Ok(())
}

pub async fn pin_trees(
&self,
lease: WorktreeLockLease,
worktree_id: &WorktreeId,
trees: &[TreeId],
) -> Result<()> {
if trees.is_empty() {
return Ok(());
}

let mut stdin_payload = String::new();
for tree in trees {
stdin_payload.push_str("create ");
stdin_payload.push_str(&pin_ref_name(worktree_id, tree));
stdin_payload.push(' ');
stdin_payload.push_str(&tree.0);
stdin_payload.push('\n');
}

self.run_ref_mutation_inner(
lease,
vec![
"update-ref".to_string(),
"--no-deref".to_string(),
"--stdin".to_string(),
],
Some(stdin_payload.into_bytes()),
|| {},
)
.await?;
Ok(())
}

pub async fn absent_trees(&self, trees: &[TreeId]) -> Result<BTreeSet<TreeId>> {
if trees.is_empty() {
return Ok(BTreeSet::new());
}

let mut stdin_payload = String::new();
for tree in trees {
stdin_payload.push_str(&tree.0);
stdin_payload.push('\n');
}
let raw = self
.run_git_with_stdin(&["cat-file", "--batch-check"], stdin_payload.into_bytes())
.await?;

let lines: Vec<&str> = raw.lines().collect();
if lines.len() != trees.len() {
bail!(
"git cat-file --batch-check answered {} line(s) for {} tree(s)",
lines.len(),
trees.len()
);
}

let mut absent = BTreeSet::new();
for (tree, line) in trees.iter().zip(lines) {
let mut fields = line.split(' ');
let object_name = fields.next().unwrap_or_default();
let object_type = fields.next().unwrap_or_default();
if object_name != tree.0 {
bail!(
"git cat-file --batch-check answered '{line}' for tree {}",
tree.0
);
}
match object_type {
"missing" => {
absent.insert(tree.clone());
}
"tree" => {}
other => bail!("object {} is a {other}, not a tree", tree.0),
}
}
Ok(absent)
}

pub async fn diff_trees(&self, before: &TreeId, after: &TreeId) -> Result<String> {
self.run_git(
&[
Expand Down Expand Up @@ -336,6 +416,48 @@ impl GitSnapshotService {
String::from_utf8(output.stdout)
.with_context(|| format!("git {args:?} emitted invalid UTF-8"))
}

async fn run_git_with_stdin(&self, args: &[&str], stdin_payload: Vec<u8>) -> Result<String> {
let mut child = self
.cancellable_git_command(args, None)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| {
format!(
"Failed to run git command {:?} in '{}'",
args,
self.repository_root.display()
)
})?;

let mut stdin = child
.stdin
.take()
.ok_or_else(|| anyhow!("git {args:?} stdin was not captured"))?;
let writer = tokio::spawn(async move {
stdin.write_all(&stdin_payload).await?;
stdin.shutdown().await
});

let output = child
.wait_with_output()
.await
.with_context(|| format!("Failed to wait for git command {args:?}"))?;
writer
.await
.map_err(|source| anyhow!("git {args:?} stdin writer failed: {source}"))?
.with_context(|| format!("Failed to write git command {args:?} stdin"))?;

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(anyhow!("git {args:?} failed: {stderr}"));
}

String::from_utf8(output.stdout)
.with_context(|| format!("git {args:?} emitted invalid UTF-8"))
}
}

#[derive(Clone, Debug, Eq, PartialEq)]
Expand Down
6 changes: 3 additions & 3 deletions cli/src/services/mutation_trace/runtime/protected_worktree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ pub enum ProtectedWorktreeError {
operation: ExternalTaintOperation,
source: anyhow::Error,
},
CheckoutIdentity(anyhow::Error),
WorktreeIdentity(anyhow::Error),
MarkerWorkerFailed(tokio::task::JoinError),
}

Expand All @@ -46,7 +46,7 @@ impl std::fmt::Display for ProtectedWorktreeError {
),
ProtectedWorktreeError::LockAcquisition(source) => write!(f, "{source}"),
ProtectedWorktreeError::GitDirResolution(source)
| ProtectedWorktreeError::CheckoutIdentity(source) => write!(f, "{source}"),
| ProtectedWorktreeError::WorktreeIdentity(source) => write!(f, "{source}"),
}
}
}
Expand Down Expand Up @@ -127,7 +127,7 @@ impl ProtectedWorktree {

let worktree_id = resolve_worktree_id(repository_root)
.await
.map_err(ProtectedWorktreeError::CheckoutIdentity)?;
.map_err(ProtectedWorktreeError::WorktreeIdentity)?;

Ok(Self {
marker,
Expand Down
17 changes: 16 additions & 1 deletion cli/src/services/mutation_trace/runtime/ref_doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ pub(crate) struct ReconciliationCounts {
pub(crate) deleted: usize,
pub(crate) retained: usize,
pub(crate) local_required: usize,
pub(crate) repinned: usize,
pub(crate) pruned_events: usize,
}

const NOT_APPLIED_MESSAGE: &str = "Maintenance-state update was not applied.";
Expand Down Expand Up @@ -192,14 +194,16 @@ fn counts(report: ReconciliationReport) -> ReconciliationCounts {
deleted: report.deleted,
retained: report.retained,
local_required: report.local_required,
repinned: report.repinned,
pruned_events: report.pruned_events,
}
}

fn failure_kind(error: &ReconcileError) -> &'static str {
match error {
ReconcileError::GitDir(_) => "git_dir_unavailable",
ReconcileError::Lock(_) => "lock_unavailable",
ReconcileError::CheckoutIdentity(_) => "worktree_identity_unavailable",
ReconcileError::WorktreeIdentity(_) => "worktree_identity_unavailable",
ReconcileError::AgentTraceDbUnavailable(source) => {
match source.downcast_ref::<ExistingRepositoryDbError>() {
Some(ExistingRepositoryDbError::Missing { .. }) => "agent_trace_db_missing",
Expand All @@ -221,6 +225,11 @@ fn failure_kind(error: &ReconcileError) -> &'static str {
ReconcileError::MalformedPin { .. } => "malformed_ref",
ReconcileError::DurableRoots(_) => "durable_roots_unavailable",
ReconcileError::MissingRequiredPins { .. } => "missing_required_pins",
ReconcileError::ObjectInventory(_) => "object_inventory_failed",
ReconcileError::UnrecoverableCursor { .. } => "unrecoverable_cursor",
ReconcileError::PruneTransaction(_) => "prune_transaction_failed",
ReconcileError::PruneConflict => "prune_conflict",
ReconcileError::RepinTransaction(_) => "repin_transaction_failed",
ReconcileError::DeleteTransaction(_) => "delete_transaction_failed",
}
}
Expand Down Expand Up @@ -305,6 +314,8 @@ mod tests {
local_required: 1,
retained: 2,
deleted: 3,
repinned: 0,
pruned_events: 0,
}
}

Expand Down Expand Up @@ -375,13 +386,17 @@ mod tests {
local_required: 1,
retained: 2,
deleted: 3,
repinned: 4,
pruned_events: 5,
};
assert_eq!(
fix_from_outcome(ExplicitOutcome::Completed(report)),
ReconciliationFix::Completed(ReconciliationCounts {
deleted: 3,
retained: 2,
local_required: 1,
repinned: 4,
pruned_events: 5,
})
);
assert_eq!(
Expand Down
Loading
Loading