huskies: merge 1236 story Ask "what happened with X" and get a paged, subject-scoped history

This commit is contained in:
Huskies Agent
2026-07-20 14:44:32 +00:00
parent 30ca3ad463
commit c9804ecfdf
18 changed files with 911 additions and 25 deletions
@@ -170,6 +170,14 @@ pub(in crate::agents::pool) async fn run_server_owned_completion(
"[agents] Server-owned completion for '{story_id}:{agent_name}': gates_passed={gates_passed}"
);
crate::history::record_agent_run(
story_id,
agent_name,
session_id.as_deref(),
gates_passed,
&gate_output,
);
// Notify chat transports of the agent completion result.
let _ = watcher_tx.send(WatcherEvent::AgentCompleted {
story_id: story_id.to_string(),
+189
View File
@@ -0,0 +1,189 @@
//! Rotated log sink for the chat bot's PTY output.
//!
//! The chat bot runs Claude Code CLI in a PTY (see
//! [`crate::llm::providers::claude_code`]) and previously logged every raw
//! PTY line — spawn commands, reader-thread lifecycle, and truncated
//! passthrough of each NDJSON line — via [`crate::slog!`], which meant this
//! high-volume, low-signal output shared the bounded operational ring buffer
//! and `server.log` with everything else, displacing genuinely operational
//! lines. This sink gives that PTY output its own daily-rotated file
//! (`chatbot-YYYY-MM-DD.log`) instead.
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
/// Number of daily log files to keep on disk before pruning older ones.
const KEEP_DAYS: u64 = 7;
/// Internal state for the on-disk log: directory and last-written date.
struct ChatBotLogState {
dir: Option<PathBuf>,
/// `YYYY-MM-DD` of the last written entry — used to detect day rollover.
last_date: String,
}
/// Daily-rotated on-disk sink for the chat bot's PTY output.
pub struct ChatBotLog {
state: Mutex<ChatBotLogState>,
}
impl ChatBotLog {
fn new() -> Self {
Self {
state: Mutex::new(ChatBotLogState {
dir: None,
last_date: String::new(),
}),
}
}
/// Set the directory for daily-rotated chat bot log files.
///
/// Files are written as `chatbot-YYYY-MM-DD.log` inside `dir`. Files
/// older than [`KEEP_DAYS`] are pruned immediately and again on each day
/// rollover. Call once at startup after the project root is known.
pub fn set_log_dir(&self, dir: PathBuf) {
prune_old_logs(&dir, KEEP_DAYS);
if let Ok(mut state) = self.state.lock() {
state.dir = Some(dir);
}
}
/// Append a line to today's chat bot log file, prefixed with an ISO 8601
/// UTC timestamp. No-ops silently until [`set_log_dir`] has been called.
pub fn push_line(&self, message: &str) {
let (log_path, prune_dir) = match self.state.lock() {
Ok(mut state) => {
if let Some(dir) = state.dir.clone() {
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
let path = dir.join(format!("chatbot-{today}.log"));
let maybe_prune = if state.last_date != today {
state.last_date = today;
Some(dir)
} else {
None
};
(Some(path), maybe_prune)
} else {
(None, None)
}
}
Err(_) => (None, None),
};
if let Some(ref dir) = prune_dir {
prune_old_logs(dir, KEEP_DAYS);
}
if let Some(ref path) = log_path {
let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(file, "{timestamp} [pty-debug] {message}");
}
}
}
}
static GLOBAL: OnceLock<ChatBotLog> = OnceLock::new();
/// Access the process-wide chat bot PTY log sink.
pub fn global() -> &'static ChatBotLog {
GLOBAL.get_or_init(ChatBotLog::new)
}
/// Delete daily `chatbot-*.log` files older than `keep_days` from `dir`.
fn prune_old_logs(dir: &Path, keep_days: u64) {
let cutoff = chrono::Utc::now()
.checked_sub_signed(chrono::Duration::days(keep_days as i64))
.map(|t| t.format("%Y-%m-%d").to_string())
.unwrap_or_default();
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
// Match "chatbot-YYYY-MM-DD.log"
if name.starts_with("chatbot-") && name.ends_with(".log") && name.len() == 22 {
// SAFETY: "chatbot-" is 8 ASCII bytes, ".log" is 4, total 22 chars
// means the middle 10 bytes are the date "YYYY-MM-DD" — all
// ASCII, safe to slice.
if let Some(date_part) = name.get(8..18)
&& date_part < cutoff.as_str()
{
let _ = std::fs::remove_file(&path);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fresh_sink() -> ChatBotLog {
ChatBotLog::new()
}
#[test]
fn push_line_before_set_log_dir_is_a_noop() {
let sink = fresh_sink();
// Must not panic when no directory has been configured yet.
sink.push_line("hello");
}
#[test]
fn push_line_writes_to_rotated_file() {
let tmp = std::env::temp_dir().join(format!(
"huskies_chatbot_log_test_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
std::fs::create_dir_all(&tmp).unwrap();
let sink = fresh_sink();
sink.set_log_dir(tmp.clone());
sink.push_line("raw line: {\"type\":\"assistant\"}");
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
let path = tmp.join(format!("chatbot-{today}.log"));
let contents = std::fs::read_to_string(&path).unwrap();
let _ = std::fs::remove_dir_all(&tmp);
assert!(contents.contains("[pty-debug]"));
assert!(contents.contains("raw line: {\"type\":\"assistant\"}"));
}
#[test]
fn does_not_write_into_shared_ring_buffer() {
let tmp = std::env::temp_dir().join(format!(
"huskies_chatbot_log_isolation_test_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
std::fs::create_dir_all(&tmp).unwrap();
let sink = fresh_sink();
sink.set_log_dir(tmp.clone());
let marker = "chatbot_isolation_marker_9f31a";
sink.push_line(marker);
let _ = std::fs::remove_dir_all(&tmp);
let ring_hits = crate::log_buffer::global().get_recent(1000, Some(marker), None);
assert!(
ring_hits.is_empty(),
"chat bot PTY output must not land in the shared server log ring buffer"
);
}
}
@@ -0,0 +1,153 @@
//! Read/write helpers for the `history_log` append-only list in the CRDT document.
//!
//! Every chat turn, agent run, and pipeline transition is appended as a
//! [`HistoryEntryCrdt`][super::super::types::HistoryEntryCrdt] entry, scoped
//! to a subject (story, sled, or project). Entries are never updated or
//! tombstoned — the list is strictly grow-only, mirroring `event_log.rs`.
use bft_json_crdt::json_crdt::{JsonValue, *};
use bft_json_crdt::op::ROOT_ID;
use serde_json::json;
use super::super::state::{apply_and_persist, get_crdt};
use super::super::types::HistoryEntryCrdt;
/// Raw history entry extracted from the CRDT document.
pub struct HistoryEntryRaw {
/// Monotonic sequence number for the recording sled (0-based).
pub event_seq: u64,
/// Hex-encoded Ed25519 public key of the sled that wrote this entry.
pub sled_id: String,
/// Unix timestamp (seconds) when the entry was recorded.
pub timestamp: f64,
/// Subject kind: `"story"`, `"sled"`, or `"project"`.
pub subject_type: String,
/// Subject identifier.
pub subject_id: String,
/// Entry kind: `"pipeline_transition"`, `"chat_turn"`, or `"agent_run"`.
pub kind: String,
/// Short human-readable summary.
pub summary: String,
/// JSON-encoded full payload.
pub detail: String,
}
/// Append a new history entry to the CRDT, computing the monotonic `event_seq`
/// atomically while the CRDT lock is held. No-ops silently when the CRDT is
/// not yet initialised.
#[allow(clippy::too_many_arguments)]
pub fn append_history_entry(
sled_id: &str,
timestamp: f64,
subject_type: &str,
subject_id: &str,
kind: &str,
summary: &str,
detail: &str,
) {
let Some(state_mutex) = get_crdt() else {
return;
};
let Ok(mut state) = state_mutex.lock() else {
return;
};
// Count existing entries for this sled while holding the lock so the seq
// is computed and used in the same critical section — no TOCTOU gap.
let event_seq = state
.crdt
.doc
.history_log
.iter()
.filter(|e| matches!(e.sled_id.view(), JsonValue::String(s) if s == sled_id))
.count() as f64;
// Append after the last existing entry so the list stays in insertion order.
let total_len = state.crdt.doc.history_log.view().len();
let after = if total_len > 0 {
super::list_id_at(&state.crdt.doc.history_log, total_len - 1).unwrap_or(ROOT_ID)
} else {
ROOT_ID
};
let entry: JsonValue = json!({
"event_seq": event_seq,
"sled_id": sled_id,
"timestamp": timestamp,
"subject_type": subject_type,
"subject_id": subject_id,
"kind": kind,
"summary": summary,
"detail": detail,
})
.into();
apply_and_persist(&mut state, |s| s.crdt.doc.history_log.insert(after, entry));
}
/// Read all history entries from the CRDT document.
///
/// Entries with a missing or empty `sled_id` are silently skipped. Order
/// reflects CRDT insertion order (RGA list semantics) — callers that need a
/// deterministic global order should sort by `(timestamp, sled_id, event_seq)`.
pub fn read_all_history_entries() -> Vec<HistoryEntryRaw> {
let Some(state_mutex) = get_crdt() else {
return Vec::new();
};
let Ok(state) = state_mutex.lock() else {
return Vec::new();
};
state
.crdt
.doc
.history_log
.iter()
.filter_map(extract_entry)
.collect()
}
/// Convert a CRDT history entry to its read-side representation.
fn extract_entry(e: &HistoryEntryCrdt) -> Option<HistoryEntryRaw> {
let event_seq = match e.event_seq.view() {
JsonValue::Number(n) => n as u64,
_ => return None,
};
let sled_id = match e.sled_id.view() {
JsonValue::String(s) if !s.is_empty() => s,
_ => return None,
};
let timestamp = match e.timestamp.view() {
JsonValue::Number(n) => n,
_ => 0.0,
};
let subject_type = match e.subject_type.view() {
JsonValue::String(s) => s,
_ => String::new(),
};
let subject_id = match e.subject_id.view() {
JsonValue::String(s) => s,
_ => String::new(),
};
let kind = match e.kind.view() {
JsonValue::String(s) => s,
_ => String::new(),
};
let summary = match e.summary.view() {
JsonValue::String(s) => s,
_ => String::new(),
};
let detail = match e.detail.view() {
JsonValue::String(s) => s,
_ => String::new(),
};
Some(HistoryEntryRaw {
event_seq,
sled_id,
timestamp,
subject_type,
subject_id,
kind,
summary,
detail,
})
}
+2
View File
@@ -16,6 +16,7 @@ mod active_agents;
mod agent_throttle;
mod event_log;
mod gateway_projects;
mod history_log;
mod llm_sessions;
mod merge_jobs;
mod test_jobs;
@@ -37,6 +38,7 @@ pub use event_log::{
pub use gateway_projects::{
delete_gateway_project, read_all_gateway_projects, read_gateway_project, write_gateway_project,
};
pub use history_log::{HistoryEntryRaw, append_history_entry, read_all_history_entries};
pub use llm_sessions::{assemble_and_advance_session, read_llm_session, write_llm_session};
pub use merge_jobs::{delete_merge_job, read_all_merge_jobs, read_merge_job, write_merge_job};
pub use test_jobs::{delete_test_job, read_all_test_jobs, read_test_job, write_test_job};
+9 -8
View File
@@ -28,14 +28,15 @@ mod write;
pub use gateway_config::{read_gateway_active_project, write_gateway_active_project};
pub use lww_maps::{
EventLogEntryRaw, GAP_PIPELINE_EVENT, append_event_log_entry, append_gap_log_entry,
assemble_and_advance_session, delete_active_agent, delete_agent_throttle,
delete_gateway_project, delete_merge_job, delete_test_job, delete_token_usage,
read_active_agent, read_agent_throttle, read_all_active_agents, read_all_agent_throttles,
read_all_event_log_entries, read_all_gateway_projects, read_all_merge_jobs, read_all_test_jobs,
read_all_token_usage, read_gateway_project, read_llm_session, read_merge_job, read_test_job,
read_token_usage, write_active_agent, write_agent_throttle, write_gateway_project,
write_llm_session, write_merge_job, write_test_job, write_token_usage,
EventLogEntryRaw, GAP_PIPELINE_EVENT, HistoryEntryRaw, append_event_log_entry,
append_gap_log_entry, append_history_entry, assemble_and_advance_session, delete_active_agent,
delete_agent_throttle, delete_gateway_project, delete_merge_job, delete_test_job,
delete_token_usage, read_active_agent, read_agent_throttle, read_all_active_agents,
read_all_agent_throttles, read_all_event_log_entries, read_all_gateway_projects,
read_all_history_entries, read_all_merge_jobs, read_all_test_jobs, read_all_token_usage,
read_gateway_project, read_llm_session, read_merge_job, read_test_job, read_token_usage,
write_active_agent, write_agent_throttle, write_gateway_project, write_llm_session,
write_merge_job, write_test_job, write_token_usage,
};
pub use ops::{all_ops_json, apply_remote_op, ops_since, our_vector_clock, subscribe_ops};
pub use presence::{
+31
View File
@@ -51,6 +51,9 @@ pub struct PipelineDoc {
pub event_log: ListCrdt<EventLogEntryCrdt>,
/// Per-session LLM context state (high-water marks for event log injection).
pub llm_sessions: ListCrdt<LlmSessionCrdt>,
/// Append-only, subject-scoped log of chat turns, agent runs, and
/// pipeline transitions, persisted as CRDT ops (story 1236).
pub history_log: ListCrdt<HistoryEntryCrdt>,
}
/// CRDT entry representing a single persisted pipeline stage-transition event.
@@ -79,6 +82,34 @@ pub struct EventLogEntryCrdt {
pub pipeline_event: LwwRegisterCrdt<String>,
}
/// CRDT entry representing a single persisted history entry — a chat turn,
/// agent run, or pipeline transition — scoped to a subject (story, sled, or
/// project) for the `get_history` / `get_history_entry` MCP tools (story 1236).
///
/// Entries are append-only, mirroring [`EventLogEntryCrdt`]'s per-sled
/// monotonic `event_seq` scheme so `"{sled_id}:{event_seq}"` is a stable,
/// re-readable pagination cursor and payload ref.
#[add_crdt_fields]
#[derive(Clone, CrdtNode, Debug, Serialize, Deserialize)]
pub struct HistoryEntryCrdt {
/// Monotonic sequence number for this sled (0, 1, 2, …).
pub event_seq: LwwRegisterCrdt<f64>,
/// Hex-encoded Ed25519 public key of the sled that recorded this entry.
pub sled_id: LwwRegisterCrdt<String>,
/// Unix timestamp (seconds) when the entry was recorded.
pub timestamp: LwwRegisterCrdt<f64>,
/// Subject kind: `"story"`, `"sled"`, or `"project"`.
pub subject_type: LwwRegisterCrdt<String>,
/// Subject identifier (story ID, sled hex ID, or project name/persona).
pub subject_id: LwwRegisterCrdt<String>,
/// Entry kind: `"pipeline_transition"`, `"chat_turn"`, or `"agent_run"`.
pub kind: LwwRegisterCrdt<String>,
/// Short human-readable summary shown in a paged listing.
pub summary: LwwRegisterCrdt<String>,
/// JSON-encoded full payload returned by `get_history_entry`.
pub detail: LwwRegisterCrdt<String>,
}
/// CRDT entry tracking an LLM session's event-log injection state.
///
/// Each session (keyed by `session_id`, typically a Matrix room ID) records the
+357
View File
@@ -0,0 +1,357 @@
//! History — subject-scoped, cursor-paged timeline over chat turns, agent
//! runs, and pipeline transitions (story 1236).
//!
//! Every entry is persisted to the CRDT `history_log` list (see
//! [`crate::crdt_state`]) so it survives restarts and replicates across
//! sleds. Callers query via [`get_history`], which returns short typed
//! summaries plus an opaque `ref` string; the full payload for a single
//! entry is fetched on demand via [`get_history_entry`].
use chrono::Utc;
/// One entry in a paged history listing: a short typed summary plus a `ref`
/// that [`get_history_entry`] can resolve to the full payload.
pub struct HistoryEntry {
pub kind: String,
pub subject_type: String,
pub subject_id: String,
pub at: chrono::DateTime<Utc>,
pub summary: String,
/// Opaque cursor-safe reference: `"{sled_id}:{event_seq}"`.
pub entry_ref: String,
}
/// A page of history entries plus an opaque cursor for the next page.
pub struct HistoryPage {
pub entries: Vec<HistoryEntry>,
pub next_cursor: Option<String>,
}
/// Sort key used both for global ordering and for the pagination cursor.
fn sort_key(e: &crate::crdt_state::HistoryEntryRaw) -> (i64, String, u64) {
(e.timestamp as i64, e.sled_id.clone(), e.event_seq)
}
fn encode_cursor(key: &(i64, String, u64)) -> String {
format!("{}:{}:{}", key.0, key.1, key.2)
}
fn decode_cursor(cursor: &str) -> Option<(i64, String, u64)> {
let mut parts = cursor.splitn(3, ':');
let ts: i64 = parts.next()?.parse().ok()?;
let sled_id = parts.next()?.to_string();
let seq: u64 = parts.next()?.parse().ok()?;
Some((ts, sled_id, seq))
}
/// Query a time-ordered, paged history for a subject.
///
/// `since`/`until` are inclusive Unix-second bounds (`None` = unbounded).
/// `cursor` resumes after the last entry returned by a previous call;
/// `limit` is clamped to `[1, 500]`.
pub fn get_history(
subject_type: &str,
subject_id: &str,
since: Option<i64>,
until: Option<i64>,
cursor: Option<&str>,
limit: usize,
) -> HistoryPage {
let limit = limit.clamp(1, 500);
let after_key = cursor.and_then(decode_cursor);
let mut matching: Vec<crate::crdt_state::HistoryEntryRaw> =
crate::crdt_state::read_all_history_entries()
.into_iter()
.filter(|e| subject_matches(e, subject_type, subject_id))
.filter(|e| since.is_none_or(|s| e.timestamp as i64 >= s))
.filter(|e| until.is_none_or(|u| e.timestamp as i64 <= u))
.collect();
matching.sort_by_key(sort_key);
let start = match after_key {
Some(after) => matching
.iter()
.position(|e| sort_key(e) > after)
.unwrap_or(matching.len()),
None => 0,
};
let page: Vec<_> = matching[start..].iter().take(limit).collect();
let next_cursor = if start + page.len() < matching.len() {
page.last().map(|e| encode_cursor(&sort_key(e)))
} else {
None
};
let entries = page
.into_iter()
.map(|e| HistoryEntry {
kind: e.kind.clone(),
subject_type: e.subject_type.clone(),
subject_id: e.subject_id.clone(),
at: chrono::DateTime::from_timestamp(e.timestamp as i64, 0).unwrap_or_default(),
summary: e.summary.clone(),
entry_ref: format!("{}:{}", e.sled_id, e.event_seq),
})
.collect();
HistoryPage {
entries,
next_cursor,
}
}
/// Return true when `entry` belongs to the requested subject.
///
/// `"sled"` subjects match on the recording sled's own ID (who did it);
/// `"story"` subjects match on the entry's declared subject dimension (what
/// it happened to). A `"project"` query returns every entry recorded by this
/// server instance, since each huskies server is scoped to a single project.
fn subject_matches(
e: &crate::crdt_state::HistoryEntryRaw,
subject_type: &str,
subject_id: &str,
) -> bool {
match subject_type {
"sled" | "robot" => e.sled_id == subject_id,
"project" => true,
_ => e.subject_type == subject_type && e.subject_id == subject_id,
}
}
/// Resolve a `ref` string returned by [`get_history`] into the full payload
/// for that entry. Returns `None` when the ref does not resolve to any known
/// entry.
pub fn get_history_entry(entry_ref: &str) -> Option<String> {
let (sled_id, seq_str) = entry_ref.split_once(':')?;
let seq: u64 = seq_str.parse().ok()?;
crate::crdt_state::read_all_history_entries()
.into_iter()
.find(|e| e.sled_id == sled_id && e.event_seq == seq)
.map(|e| e.detail)
}
/// Record a pipeline stage transition into the unified history log.
///
/// Called from a dedicated broadcast subscriber (see
/// [`spawn_history_subscriber`]) so it runs independently of
/// [`crate::event_log`]'s own transition log.
pub(crate) fn record_pipeline_transition(fired: &crate::pipeline_state::TransitionFired) {
let sled_id = crate::crdt_state::our_node_id().unwrap_or_default();
let timestamp = fired.at.timestamp() as f64;
let from_stage = crate::pipeline_state::stage_label(&fired.before);
let to_stage = crate::pipeline_state::stage_label(&fired.after);
let pipeline_event = crate::pipeline_state::event_label(&fired.event);
let summary = format!(
"{} moved {from_stage} -> {to_stage} ({pipeline_event})",
fired.story_id.0
);
let detail = serde_json::json!({
"story_id": fired.story_id.0,
"from_stage": from_stage,
"to_stage": to_stage,
"pipeline_event": pipeline_event,
})
.to_string();
crate::crdt_state::append_history_entry(
&sled_id,
timestamp,
"story",
&fired.story_id.0,
"pipeline_transition",
&summary,
&detail,
);
}
/// Record a completed chat turn into the unified history log.
///
/// `subject_id` is the persona/session the turn belongs to (e.g. `"timmy"`).
pub(crate) fn record_chat_turn(subject_id: &str, user_message: &str, assistant_reply: &str) {
let sled_id = crate::crdt_state::our_node_id().unwrap_or_default();
let timestamp = Utc::now().timestamp() as f64;
let summary = truncate(user_message, 120);
let detail = serde_json::json!({
"user": user_message,
"assistant": assistant_reply,
})
.to_string();
crate::crdt_state::append_history_entry(
&sled_id,
timestamp,
"project",
subject_id,
"chat_turn",
&summary,
&detail,
);
}
/// Record a completed agent run into the unified history log.
///
/// The gate output is truncated to keep the CRDT entry bounded; the full
/// transcript remains on disk under `.huskies/logs/{story_id}/` for deeper
/// inspection via `get_agent_output`.
pub(crate) fn record_agent_run(
story_id: &str,
agent_name: &str,
session_id: Option<&str>,
gates_passed: bool,
gate_output: &str,
) {
let sled_id = crate::crdt_state::our_node_id().unwrap_or_default();
let timestamp = Utc::now().timestamp() as f64;
let outcome = if gates_passed { "passed" } else { "failed" };
let summary = format!(
"{agent_name} run on {story_id} {outcome}: {}",
truncate(gate_output, 100)
);
let detail = serde_json::json!({
"story_id": story_id,
"agent_name": agent_name,
"session_id": session_id,
"gates_passed": gates_passed,
"gate_output": truncate(gate_output, 4000),
})
.to_string();
crate::crdt_state::append_history_entry(
&sled_id,
timestamp,
"story",
story_id,
"agent_run",
&summary,
&detail,
);
}
fn truncate(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
s.to_string()
} else {
let truncated: String = s.chars().take(max_chars).collect();
format!("{truncated}")
}
}
/// Spawn a background task that persists every `TransitionFired` event to the
/// unified history log, independently of [`crate::event_log`]'s subscriber.
pub fn spawn_history_subscriber() {
let mut rx = crate::pipeline_state::subscribe_transitions();
tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(fired) => record_pipeline_transition(&fired),
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn record_and_query_pipeline_transition() {
crate::crdt_state::init_for_test();
let fired = crate::pipeline_state::TransitionFired {
story_id: crate::pipeline_state::StoryId("42_story_test".to_string()),
before: crate::pipeline_state::Stage::Backlog,
after: crate::pipeline_state::Stage::Coding {
claim: None,
plan: crate::pipeline_state::PlanState::Missing,
retries: 0,
},
event: crate::pipeline_state::PipelineEvent::DepsMet,
at: chrono::Utc::now(),
};
record_pipeline_transition(&fired);
let page = get_history("story", "42_story_test", None, None, None, 50);
assert_eq!(page.entries.len(), 1);
assert_eq!(page.entries[0].kind, "pipeline_transition");
assert!(page.entries[0].summary.contains("42_story_test"));
let full = get_history_entry(&page.entries[0].entry_ref).unwrap();
assert!(full.contains("DepsMet"));
}
#[test]
fn record_and_query_chat_turn() {
crate::crdt_state::init_for_test();
record_chat_turn("timmy", "what happened with 42?", "here's the history");
let page = get_history("project", "timmy", None, None, None, 50);
assert_eq!(page.entries.len(), 1);
assert_eq!(page.entries[0].kind, "chat_turn");
let full = get_history_entry(&page.entries[0].entry_ref).unwrap();
assert!(full.contains("here's the history"));
}
#[test]
fn record_and_query_agent_run() {
crate::crdt_state::init_for_test();
record_agent_run(
"42_story_test",
"coder-1",
Some("sess-1"),
true,
"all gates passed",
);
let page = get_history("story", "42_story_test", None, None, None, 50);
assert_eq!(page.entries.len(), 1);
assert_eq!(page.entries[0].kind, "agent_run");
}
#[test]
fn pagination_cursor_advances() {
crate::crdt_state::init_for_test();
for i in 0..5 {
record_chat_turn("timmy", &format!("msg {i}"), &format!("reply {i}"));
}
let page1 = get_history("project", "timmy", None, None, None, 2);
assert_eq!(page1.entries.len(), 2);
assert!(page1.next_cursor.is_some());
let page2 = get_history(
"project",
"timmy",
None,
None,
page1.next_cursor.as_deref(),
2,
);
assert_eq!(page2.entries.len(), 2);
let page3 = get_history(
"project",
"timmy",
None,
None,
page2.next_cursor.as_deref(),
2,
);
assert_eq!(page3.entries.len(), 1);
assert!(page3.next_cursor.is_none());
}
#[test]
fn sled_subject_matches_recording_sled() {
crate::crdt_state::init_for_test();
record_chat_turn("timmy", "hi", "hello");
let sled_id = crate::crdt_state::our_node_id().unwrap_or_default();
let page = get_history("sled", &sled_id, None, None, None, 50);
assert_eq!(page.entries.len(), 1);
}
}
+5 -2
View File
@@ -3,8 +3,8 @@
use serde_json::Value;
use super::{
agent_tools, diagnostics, git_tools, merge_tools, qa_tools, shell_tools, status_tools,
story_tools, timer_tools, trigger_tools, wizard_tools,
agent_tools, diagnostics, git_tools, history_tools, merge_tools, qa_tools, shell_tools,
status_tools, story_tools, timer_tools, trigger_tools, wizard_tools,
};
use crate::http::context::AppContext;
@@ -91,6 +91,9 @@ pub async fn dispatch_tool_call(
"get_token_usage" => diagnostics::tool_get_token_usage(&args, ctx),
// Chat turn telemetry (story 1209)
"chat_telemetry" => diagnostics::tool_chat_telemetry(&args),
// Subject-scoped history (story 1236)
"get_history" => history_tools::tool_get_history(&args),
"get_history_entry" => history_tools::tool_get_history_entry(&args),
// Delete story
"delete_story" => story_tools::tool_delete_story(&args, ctx).await,
// Purge story (CRDT tombstone — story 521)
+66
View File
@@ -0,0 +1,66 @@
//! MCP tools for subject-scoped history queries (story 1236).
//!
//! `get_history` returns a time-ordered, cursor-paged list of short typed
//! summaries for a subject (story, sled, or project); `get_history_entry`
//! resolves the `ref` from one of those summaries into its full payload.
use serde_json::{Value, json};
/// MCP tool: return a paged, subject-scoped history listing.
pub(crate) fn tool_get_history(args: &Value) -> Result<String, String> {
let subject_type = args
.get("subject_type")
.and_then(|v| v.as_str())
.ok_or("subject_type is required (one of: story, robot, project)")?;
let subject_id = args
.get("subject_id")
.and_then(|v| v.as_str())
.ok_or("subject_id is required")?;
if !matches!(subject_type, "story" | "sled" | "robot" | "project") {
return Err(format!(
"subject_type must be one of: story, robot, project (got '{subject_type}')"
));
}
let since = args.get("since").and_then(|v| v.as_i64());
let until = args.get("until").and_then(|v| v.as_i64());
let cursor = args.get("cursor").and_then(|v| v.as_str());
let limit = args
.get("limit")
.and_then(|v| v.as_u64())
.map(|n| n as usize)
.unwrap_or(50);
let page = crate::history::get_history(subject_type, subject_id, since, until, cursor, limit);
let entries: Vec<Value> = page
.entries
.iter()
.map(|e| {
json!({
"ref": e.entry_ref,
"kind": e.kind,
"subject_type": e.subject_type,
"subject_id": e.subject_id,
"at": e.at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
"summary": e.summary,
})
})
.collect();
serde_json::to_string_pretty(&json!({
"entries": entries,
"next_cursor": page.next_cursor,
}))
.map_err(|e| format!("Serialization error: {e}"))
}
/// MCP tool: resolve a `ref` from `get_history` into its full payload.
pub(crate) fn tool_get_history_entry(args: &Value) -> Result<String, String> {
let entry_ref = args
.get("ref")
.and_then(|v| v.as_str())
.ok_or("ref is required (from a get_history entry)")?;
crate::history::get_history_entry(entry_ref)
.ok_or_else(|| format!("No history entry found for ref '{entry_ref}'"))
}
+2
View File
@@ -12,6 +12,8 @@ pub mod diagnostics;
pub mod dispatch;
/// MCP tools for git operations scoped to agent worktrees.
pub mod git_tools;
/// MCP tools for subject-scoped history queries (`get_history`, `get_history_entry`).
pub mod history_tools;
/// MCP tools for merge status and merge-to-master operations.
pub mod merge_tools;
/// Task-local progress emitter used to deliver `notifications/progress`
+3 -1
View File
@@ -121,7 +121,9 @@ mod tests {
assert!(names.contains(&"gc"));
assert!(names.contains(&"chat_telemetry"));
assert!(names.contains(&"ask_question"));
assert_eq!(tools.len(), 89);
assert!(names.contains(&"get_history"));
assert!(names.contains(&"get_history_entry"));
assert_eq!(tools.len(), 91);
}
#[test]
@@ -120,6 +120,54 @@ pub(super) fn system_tools() -> Vec<Value> {
}
}
}),
json!({
"name": "get_history",
"description": "Return a time-ordered, cursor-paged history for a subject (story, robot, or project) over an optional time range. Each entry is a short typed summary (chat_turn, agent_run, or pipeline_transition) plus a 'ref' string; fetch the full payload for one entry with get_history_entry.",
"inputSchema": {
"type": "object",
"properties": {
"subject_type": {
"type": "string",
"description": "One of: story, robot, project"
},
"subject_id": {
"type": "string",
"description": "Story ID, robot (sled) hex ID, or project/persona name"
},
"since": {
"type": "integer",
"description": "Optional Unix-second lower bound (inclusive)"
},
"until": {
"type": "integer",
"description": "Optional Unix-second upper bound (inclusive)"
},
"cursor": {
"type": "string",
"description": "Opaque cursor from a previous page's next_cursor to resume from"
},
"limit": {
"type": "integer",
"description": "Maximum number of entries to return (default 50, max 500)"
}
},
"required": ["subject_type", "subject_id"]
}
}),
json!({
"name": "get_history_entry",
"description": "Resolve a 'ref' string returned by get_history into its full payload.",
"inputSchema": {
"type": "object",
"properties": {
"ref": {
"type": "string",
"description": "The 'ref' value from a get_history entry"
}
},
"required": ["ref"]
}
}),
json!({
"name": "run_command",
"description": "Execute a shell command in an agent's worktree directory. The working_dir must be inside .huskies/worktrees/. Returns stdout, stderr, exit_code, and timed_out. Supports SSE streaming (send Accept: text/event-stream) for long-running commands. Dangerous commands (rm -rf /, sudo, etc.) are blocked.",
+9
View File
@@ -257,6 +257,15 @@ where
} else {
result.extend(cc_messages);
}
let assistant_reply = result
.iter()
.rev()
.find(|m| m.role == Role::Assistant)
.map(|m| m.content.as_str())
.unwrap_or_default();
crate::history::record_chat_turn(persona, &user_message, assistant_reply);
on_update(&result);
return Ok(ChatResult {
messages: result,
@@ -8,7 +8,6 @@ mod tests;
use super::parse::{parse_assistant_message, parse_tool_results};
use crate::agents::TokenUsage;
use crate::llm::types::Message;
use crate::slog;
use std::sync::atomic::{AtomicBool, Ordering};
use stream::handle_stream_event;
@@ -36,7 +35,7 @@ pub(super) fn process_json_event(
// Capture session_id from the first event that carries it
if let Some(tx) = sid_tx.take() {
if let Some(sid) = json.get("session_id").and_then(|s| s.as_str()) {
slog!("[pty-debug] CAPTURED session_id: {}", sid);
crate::chatbot_log::global().push_line(&format!("CAPTURED session_id: {sid}"));
let _ = tx.send(sid.to_string());
} else {
*sid_tx = Some(tx);
@@ -45,7 +44,7 @@ pub(super) fn process_json_event(
// Detect authentication_failed at the top level of any event.
if json.get("error").and_then(|e| e.as_str()) == Some("authentication_failed") {
slog!("[pty-debug] Detected authentication_failed error");
crate::chatbot_log::global().push_line("Detected authentication_failed error");
auth_failed.store(true, Ordering::Relaxed);
}
+16 -11
View File
@@ -180,7 +180,8 @@ impl ClaudeCodeProvider {
}
let captured_session_id = sid_rx.await.ok();
slog!("[pty-debug] RECEIVED session_id: {:?}", captured_session_id);
crate::chatbot_log::global()
.push_line(&format!("RECEIVED session_id: {captured_session_id:?}"));
let usage = usage_rx.await.ok();
let structured_messages: Vec<Message> = msg_rx.try_iter().collect();
@@ -308,21 +309,22 @@ fn run_pty_session(
// Allow nested spawning when the server itself runs inside Claude Code
cmd.env("CLAUDECODE", "");
slog!(
"[pty-debug] Spawning: claude -p \"{}\" {} {} --output-format stream-json --verbose --include-partial-messages --permission-prompt-tool mcp__huskies__prompt_permission",
crate::chatbot_log::global().push_line(&format!(
"Spawning: claude -p \"{}\" {} {} --output-format stream-json --verbose --include-partial-messages --permission-prompt-tool mcp__huskies__prompt_permission",
user_message,
resume_session_id
.map(|s| format!("--resume {s}"))
.unwrap_or_default(),
model.map(|m| format!("--model {m}")).unwrap_or_default()
);
));
let mut child = pair
.slave
.spawn_command(cmd)
.map_err(|e| format!("Failed to spawn claude: {e}"))?;
slog!("[pty-debug] Process spawned, pid: {:?}", child.process_id());
crate::chatbot_log::global()
.push_line(&format!("Process spawned, pid: {:?}", child.process_id()));
drop(pair.slave);
let reader = pair
@@ -339,23 +341,23 @@ fn run_pty_session(
let reader_handle = std::thread::spawn(move || {
let buf_reader = BufReader::new(reader);
slog!("[pty-debug] Reader thread started");
crate::chatbot_log::global().push_line("Reader thread started");
for line in buf_reader.lines() {
match line {
Ok(l) => {
slog!("[pty-debug] raw line: {}", l);
crate::chatbot_log::global().push_line(&format!("raw line: {l}"));
if line_tx.send(Some(l)).is_err() {
break;
}
}
Err(e) => {
slog!("[pty-debug] read error: {e}");
crate::chatbot_log::global().push_line(&format!("read error: {e}"));
let _ = line_tx.send(None);
break;
}
}
}
slog!("[pty-debug] Reader thread done");
crate::chatbot_log::global().push_line("Reader thread done");
let _ = line_tx.send(None);
});
@@ -382,7 +384,8 @@ fn run_pty_session(
while !trimmed.is_char_boundary(end) {
end -= 1;
}
slog!("[pty-debug] processing: {}...", &trimmed[..end]);
crate::chatbot_log::global()
.push_line(&format!("processing: {}...", &trimmed[..end]));
// Try to parse as JSON
if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed)
@@ -477,7 +480,9 @@ fn run_pty_session(
&& let Some(ref status) = exit_status
&& !status.success()
{
slog!("[pty-debug] Claude Code exited with non-zero status: {status}");
crate::chatbot_log::global().push_line(&format!(
"Claude Code exited with non-zero status: {status}"
));
return Err(format!("Claude Code crashed (exit status: {status})"));
}
+6
View File
@@ -10,6 +10,9 @@ mod agent_log;
mod agent_mode;
mod agents;
mod chat;
/// Chat bot log — daily-rotated sink for the chat bot's PTY output, kept
/// separate from the shared operational ring buffer and `server.log`.
pub mod chatbot_log;
#[cfg(test)]
mod ci_publish_artifact;
mod config;
@@ -27,6 +30,9 @@ pub(crate) mod event_log;
/// Gateway mode — multi-project reverse proxy that fronts multiple project containers.
pub mod gateway;
mod gateway_relay;
/// History — subject-scoped, cursor-paged timeline over chat turns, agent
/// runs, and pipeline transitions.
pub(crate) mod history;
mod http;
mod io;
mod llm;
+1
View File
@@ -228,6 +228,7 @@ pub(crate) async fn init_subsystems(app_state: &Arc<SessionState>, cwd: &Path, i
if let Some(ref root) = *app_state.project_root.lock().unwrap() {
let log_dir = root.join(".huskies").join("logs");
let _ = std::fs::create_dir_all(&log_dir);
crate::chatbot_log::global().set_log_dir(log_dir.clone());
log_buffer::global().set_log_dir(log_dir);
}
+4
View File
@@ -42,6 +42,10 @@ pub(crate) fn spawn_event_bridges(
// the history survives rebuild_and_restart and replicates across nodes.
crate::event_log::spawn_event_log_subscriber();
// Unified history subscriber: persist every transition into the
// subject-scoped history log alongside chat turns and agent runs (story 1236).
crate::history::spawn_history_subscriber();
// CRDT → watcher bridge: translate CRDT stage-transition events into
// WatcherEvent::WorkItem so downstream consumers (WebSocket, auto-assign)
// see a uniform stream regardless of whether the event originated from the