Skip to content
Draft
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
148 changes: 132 additions & 16 deletions crates/skilllite-assistant/src-tauri/src/life_pulse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,24 +161,48 @@ fn check_schedule_due(workspace: &std::path::Path) -> bool {

// ─── Subprocess helpers ─────────────────────────────────────────────────────

fn build_life_pulse_command(
skilllite_path: &std::path::Path,
workspace: &str,
env_pairs: &[(String, String)],
args: &[&str],
) -> Command {
let root = skilllite_bridge::find_project_root(workspace);
let mut cmd = Command::new(skilllite_path);
crate::windows_spawn::hide_child_console(&mut cmd);
cmd.args(args)
.envs(env_pairs.iter().map(|(k, v)| (k.as_str(), v.as_str())))
.current_dir(&root)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
if std::path::Path::new(workspace).is_absolute() {
cmd.env(
skilllite_bridge::local::env_keys::paths::SKILLLITE_WORKSPACE,
workspace,
);
}
cmd
}

fn spawn_growth(
skilllite_path: &std::path::Path,
workspace: &str,
env_pairs: &[(String, String)],
running: Arc<AtomicBool>,
app: tauri::AppHandle,
) {
let path = skilllite_path.to_path_buf();
let workspace = workspace.to_string();
let env: Vec<(String, String)> = env_pairs.to_vec();
std::thread::spawn(move || {
emit(&app, "growth-started", None);
let mut growth_cmd = Command::new(&path);
crate::windows_spawn::hide_child_console(&mut growth_cmd);
let result = growth_cmd
.args(["evolution", "run"])
.envs(env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.status();
let result = build_life_pulse_command(
&path,
&workspace,
&env,
&["evolution", "run", "--workspace", &workspace],
)
.status();
running.store(false, Ordering::SeqCst);
match result {
Ok(s) if s.success() => emit(&app, "growth-done", None),
Expand All @@ -194,22 +218,23 @@ fn spawn_growth(

fn spawn_rhythm(
skilllite_path: &std::path::Path,
workspace: &str,
env_pairs: &[(String, String)],
running: Arc<AtomicBool>,
app: tauri::AppHandle,
) {
let path = skilllite_path.to_path_buf();
let workspace = workspace.to_string();
let env: Vec<(String, String)> = env_pairs.to_vec();
std::thread::spawn(move || {
emit(&app, "rhythm-started", None);
let mut rhythm_cmd = Command::new(&path);
crate::windows_spawn::hide_child_console(&mut rhythm_cmd);
let result = rhythm_cmd
.args(["schedule", "tick"])
.envs(env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.status();
let result = build_life_pulse_command(
&path,
&workspace,
&env,
&["schedule", "tick", "--workspace", &workspace],
)
.status();
running.store(false, Ordering::SeqCst);
match result {
Ok(s) if s.success() => emit(&app, "rhythm-done", None),
Expand Down Expand Up @@ -281,6 +306,7 @@ pub fn start(state: LifePulseState, skilllite_path: PathBuf, app: tauri::AppHand
s.growth_running.store(true, Ordering::SeqCst);
spawn_growth(
&skilllite_path,
&workspace,
&child_env,
s.growth_running.clone(),
app.clone(),
Expand All @@ -293,6 +319,7 @@ pub fn start(state: LifePulseState, skilllite_path: PathBuf, app: tauri::AppHand
s.rhythm_running.store(true, Ordering::SeqCst);
spawn_rhythm(
&skilllite_path,
&workspace,
&child_env,
s.rhythm_running.clone(),
app.clone(),
Expand Down Expand Up @@ -326,3 +353,92 @@ pub fn start(state: LifePulseState, skilllite_path: PathBuf, app: tauri::AppHand
pub fn stop(state: &LifePulseState) {
state.alive.store(false, Ordering::SeqCst);
}

#[cfg(test)]
mod tests {
use super::*;

fn temp_workspace(name: &str) -> PathBuf {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("duration")
.as_nanos();
let dir = std::env::temp_dir().join(format!(
"skilllite_life_pulse_{}_{}_{}",
std::process::id(),
unique,
name
));
std::fs::create_dir_all(&dir).expect("workspace");
dir
}

fn args(cmd: &Command) -> Vec<String> {
cmd.get_args()
.map(|arg| arg.to_string_lossy().to_string())
.collect()
}

#[test]
fn growth_command_targets_life_pulse_workspace() {
let workspace = temp_workspace("growth");
let workspace_str = workspace.to_string_lossy().to_string();
let env = vec![("SKILLLITE_API_KEY".to_string(), "test-key".to_string())];
let cmd = build_life_pulse_command(
std::path::Path::new("skilllite"),
&workspace_str,
&env,
&["evolution", "run", "--workspace", &workspace_str],
);

let expected_workspace = workspace.canonicalize().expect("canonical workspace");
assert_eq!(
cmd.get_current_dir().expect("current dir"),
expected_workspace.as_path()
);
assert_eq!(
args(&cmd),
vec![
"evolution".to_string(),
"run".to_string(),
"--workspace".to_string(),
workspace_str.clone()
]
);
assert!(cmd.get_envs().any(|(key, value)| {
key == skilllite_bridge::local::env_keys::paths::SKILLLITE_WORKSPACE
&& value == Some(std::ffi::OsStr::new(&workspace_str))
}));

let _ = std::fs::remove_dir_all(workspace);
}

#[test]
fn rhythm_command_targets_life_pulse_workspace() {
let workspace = temp_workspace("rhythm");
let workspace_str = workspace.to_string_lossy().to_string();
let cmd = build_life_pulse_command(
std::path::Path::new("skilllite"),
&workspace_str,
&[],
&["schedule", "tick", "--workspace", &workspace_str],
);

let expected_workspace = workspace.canonicalize().expect("canonical workspace");
assert_eq!(
cmd.get_current_dir().expect("current dir"),
expected_workspace.as_path()
);
assert_eq!(
args(&cmd),
vec![
"schedule".to_string(),
"tick".to_string(),
"--workspace".to_string(),
workspace_str
]
);

let _ = std::fs::remove_dir_all(workspace);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,33 @@ use crate::skilllite_bridge::chat::ChatConfigOverrides;

use super::status::load_evolution_status;

fn now_unix() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}

pub(crate) fn next_periodic_anchor(
current: Option<i64>,
now: i64,
growth_tick_would_be_due: bool,
arm_periodic: bool,
) -> Option<i64> {
if current.is_none() || (growth_tick_would_be_due && arm_periodic) {
Some(now)
} else {
current
}
}

pub fn evolution_growth_due(
workspace: &str,
last_periodic_spawn_unix: &Mutex<Option<i64>>,
cfg: Option<&ChatConfigOverrides>,
skilllite_path: &Path,
) -> bool {
let now = now_unix();
let anchor = last_periodic_spawn_unix
.lock()
.ok()
Expand All @@ -30,6 +51,17 @@ pub fn evolution_growth_due(
let Some(a9) = status.a9 else {
return false;
};
let next_anchor = next_periodic_anchor(
anchor,
now,
a9.growth_tick_would_be_due,
a9.arm_periodic,
);
if next_anchor != anchor {
if let Ok(mut guard) = last_periodic_spawn_unix.lock() {
*guard = next_anchor;
}
}
if !a9.growth_tick_would_be_due {
return false;
}
Expand All @@ -38,3 +70,29 @@ pub fn evolution_growth_due(
}
true
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn periodic_anchor_initializes_on_first_successful_check() {
assert_eq!(next_periodic_anchor(None, 100, false, false), Some(100));
}

#[test]
fn periodic_anchor_advances_when_periodic_arm_fires() {
assert_eq!(
next_periodic_anchor(Some(10), 100, true, true),
Some(100)
);
}

#[test]
fn periodic_anchor_does_not_advance_for_signal_only_due() {
assert_eq!(
next_periodic_anchor(Some(10), 100, true, false),
Some(10)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ pub use chat::{
pub use followup_suggestions::followup_chat_suggestions;
pub use integrations::*;
pub use llm_routing_error::{classify_llm_routing_error_message, LlmInvokeResult};
pub(crate) use paths::load_dotenv_for_child;
pub(crate) use paths::{find_project_root, load_dotenv_for_child};
pub use paths::{
default_writable_workspace_dir, ensure_skilllite_version, resolve_skilllite_path_app,
MIN_SKILLLITE_VERSION,
Expand Down
36 changes: 36 additions & 0 deletions tasks/TASK-2026-069-daily-critical-bug-sweep/CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Technical Context

## Current State

- Relevant crates/files: `crates/skilllite-assistant/src-tauri/src/life_pulse.rs`, `crates/skilllite-assistant/src-tauri/src/skilllite_bridge/integrations/evolution_ui/growth.rs`, `crates/skilllite-assistant/src-tauri/src/skilllite_bridge/mod.rs`, `skilllite/src/cli.rs`, `crates/skilllite-commands/src/evolution.rs`, `crates/skilllite-commands/src/schedule.rs`.
- Current behavior: current branch `cursor/critical-bug-investigation-d75f` contains a fix for a recent desktop Life Pulse regression introduced by the CLI-only bridge refactor.

## Architecture Fit

- Layer boundaries involved: desktop assistant bridge spawning CLI subprocesses; engine CLI remains the execution boundary.
- Interfaces to preserve: Life Pulse still delegates heavy work to `skilllite` CLI; no direct engine dependency is reintroduced.

## Dependency and Compatibility

- New dependencies: none planned.
- Backward compatibility notes: existing Life Pulse UI and CLI arguments are preserved; background subprocesses now receive the same workspace contract as manual desktop actions.

## Design Decisions

- Decision: perform a review-only audit first and defer implementation until a concrete critical trigger scenario is established.
- Rationale: the automation explicitly requires a high confidence bar and no PR for doubtful findings.
- Alternatives considered: proactively patch suspicious code based on pattern matching.
- Why rejected: it risks false positives and unnecessary behavior drift.
- Decision: fix Life Pulse by adding a small command builder that sets `current_dir`, `--workspace`, and absolute `SKILLLITE_WORKSPACE` consistently for background growth/rhythm subprocesses.
- Rationale: manual desktop actions already use this workspace contract, and the bug was caused by background subprocesses omitting it.
- Alternatives considered: call the in-process engine directly from the desktop bridge.
- Why rejected: it would reverse the CLI-only bridge direction from the recent refactor.
- Decision: update the desktop periodic anchor when the first successful check initializes it and when the periodic arm fires.
- Rationale: this restores the mutation that `growth_due` performed before the read-only CLI status path was introduced.
- Alternatives considered: only update after the subprocess exits successfully.
- Why rejected: the original semantics advance the periodic anchor when the periodic arm fires, even if a periodic-only preflight later skips due to no proposals.

## Open Questions

- [x] Which recent commits contain high-blast-radius behavioral changes?
- [x] Does any suspicious change have a concrete trigger scenario with critical impact?
38 changes: 38 additions & 0 deletions tasks/TASK-2026-069-daily-critical-bug-sweep/PRD.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# PRD

## Background

This task is a scheduled high-severity bug sweep. The expected outcome most days is a concise verified report that no critical bugs were found. If a real critical bug is found, the task shifts to a minimal fix with regression coverage.

## Objective

Inspect recent commits for concrete, triggerable critical correctness bugs and either fix a confirmed issue or report that no critical bugs were found.

## Functional Requirements

- FR-1: Review recent commit metadata and changed files before selecting suspicious code paths.
- FR-2: Trace selected behavioral changes through callers and downstream effects.
- FR-3: Only implement a fix when the bug has a plausible concrete trigger and high-severity impact.
- FR-4: Post the outcome to Slack.

## Non-Functional Requirements

- Security: preserve sandbox, authorization, and execution gating invariants; do not relax protections without explicit evidence and tests.
- Performance: do not run unnecessary broad or long-running verification when no code changed.
- Compatibility: avoid behavior changes unless required for a confirmed critical fix.

## Constraints

- Technical: use repository specs as the execution baseline; keep any fix minimal and localized.
- Timeline: no calendar estimate; complete within the automation run if feasible.

## Success Metrics

- Metric: verified outcome.
- Baseline: recent commits have not been independently audited in this run.
- Target: confirmed critical bug fixed with validation, or no critical bug surfaced and no PR opened.

## Rollout

- Rollout plan: if a fix is committed, push the branch and open a PR through automation tooling.
- Rollback plan: if no fix is made, no rollout is required.
Loading
Loading