warn/errorのTUIへの通知ルート
This commit is contained in:
parent
0c29de1b10
commit
faa8eb5793
13
README.md
13
README.md
|
|
@ -3,16 +3,3 @@
|
||||||
insomnia(i6a)は不休のエージェントループを回すためのエージェントプラットフォーム。
|
insomnia(i6a)は不休のエージェントループを回すためのエージェントプラットフォーム。
|
||||||
|
|
||||||
ワークフローを統括し、四六時中電力を消費し、イテレーションします。
|
ワークフローを統括し、四六時中電力を消費し、イテレーションします。
|
||||||
|
|
||||||
## Crates
|
|
||||||
|
|
||||||
| クレート | 概要 |
|
|
||||||
|---|---|
|
|
||||||
| `insomnia` | トップレベルアプリケーション(未実装) |
|
|
||||||
| `llm-worker` | 自律的なLLMシステムを構築するためのライブラリ |
|
|
||||||
| `llm-worker-macros` | `llm-worker`用の手続きマクロ (`#[tool_registry]`, `#[tool]`) |
|
|
||||||
|
|
||||||
## ドキュメント
|
|
||||||
|
|
||||||
- [要件](crates/llm-worker/docs/requirements.md) — llm-workerに求める性能 (R1-R4)
|
|
||||||
- [アーキテクチャ](crates/llm-worker/docs/architecture.md) — 3層構成とモジュール配置
|
|
||||||
|
|
|
||||||
1
TODO.md
1
TODO.md
|
|
@ -6,6 +6,7 @@
|
||||||
- [ ] Protocol の設計 → [tickets/protocol-design.md](tickets/protocol-design.md)
|
- [ ] Protocol の設計 → [tickets/protocol-design.md](tickets/protocol-design.md)
|
||||||
- [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md)
|
- [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md)
|
||||||
- [ ] ネイティブ GUI クライアント MVP → [tickets/native-gui-mvp.md](tickets/native-gui-mvp.md)
|
- [ ] ネイティブ GUI クライアント MVP → [tickets/native-gui-mvp.md](tickets/native-gui-mvp.md)
|
||||||
|
- [ ] Pod Factory: カスケード設定とプロンプト資産 → [tickets/pod-factory.md](tickets/pod-factory.md)
|
||||||
- [ ] TUI 拡充
|
- [ ] TUI 拡充
|
||||||
- [ ] 通知チャネル (Warn/Error 可視化) → [tickets/tui-notification-channel.md](tickets/tui-notification-channel.md)
|
- [ ] 通知チャネル (Warn/Error 可視化) → [tickets/tui-notification-channel.md](tickets/tui-notification-channel.md)
|
||||||
- [ ] Pod の明示的 shutdown → [tickets/tui-pod-shutdown.md](tickets/tui-pod-shutdown.md)
|
- [ ] Pod の明示的 shutdown → [tickets/tui-pod-shutdown.md](tickets/tui-pod-shutdown.md)
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,11 @@ pub struct Worker<C: LlmClient, S: WorkerState = Mutable> {
|
||||||
turn_start_cbs: Vec<Box<dyn Fn(usize) + Send + Sync>>,
|
turn_start_cbs: Vec<Box<dyn Fn(usize) + Send + Sync>>,
|
||||||
/// Turn-end callbacks
|
/// Turn-end callbacks
|
||||||
turn_end_cbs: Vec<Box<dyn Fn(usize) + Send + Sync>>,
|
turn_end_cbs: Vec<Box<dyn Fn(usize) + Send + Sync>>,
|
||||||
|
/// Non-fatal warning callbacks. Invoked when the Worker wants to
|
||||||
|
/// surface an advisory message to the upper layer (e.g. Pod) so it
|
||||||
|
/// can be forwarded to the user — distinct from `tracing::warn!`,
|
||||||
|
/// which is for developer-facing logs.
|
||||||
|
warning_cbs: Vec<Box<dyn Fn(&str) + Send + Sync>>,
|
||||||
/// Request configuration (max_tokens, temperature, etc.)
|
/// Request configuration (max_tokens, temperature, etc.)
|
||||||
request_config: RequestConfig,
|
request_config: RequestConfig,
|
||||||
/// Whether the previous run was interrupted
|
/// Whether the previous run was interrupted
|
||||||
|
|
@ -274,6 +279,23 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
|
||||||
self.turn_start_cbs.push(Box::new(callback));
|
self.turn_start_cbs.push(Box::new(callback));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Register a non-fatal warning callback.
|
||||||
|
///
|
||||||
|
/// The callback is invoked with a short human-readable message
|
||||||
|
/// whenever the Worker encounters a condition that should be
|
||||||
|
/// surfaced to a human (e.g. tool output byte-cap truncation).
|
||||||
|
/// This channel is separate from `tracing::warn!`, which remains
|
||||||
|
/// in place for developer logs.
|
||||||
|
pub fn on_warning(&mut self, callback: impl Fn(&str) + Send + Sync + 'static) {
|
||||||
|
self.warning_cbs.push(Box::new(callback));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_warning(&self, message: &str) {
|
||||||
|
for cb in &self.warning_cbs {
|
||||||
|
cb(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Register a turn-end callback (receives 0-based turn number).
|
/// Register a turn-end callback (receives 0-based turn number).
|
||||||
pub fn on_turn_end(&mut self, callback: impl Fn(usize) + Send + Sync + 'static) {
|
pub fn on_turn_end(&mut self, callback: impl Fn(usize) + Send + Sync + 'static) {
|
||||||
self.turn_end_cbs.push(Box::new(callback));
|
self.turn_end_cbs.push(Box::new(callback));
|
||||||
|
|
@ -696,6 +718,13 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
|
||||||
limit_bytes = limit,
|
limit_bytes = limit,
|
||||||
"Tool output exceeded byte limit and was truncated"
|
"Tool output exceeded byte limit and was truncated"
|
||||||
);
|
);
|
||||||
|
self.emit_warning(&format!(
|
||||||
|
"tool `{}` output truncated from {} to {} bytes (limit {})",
|
||||||
|
tool_call.name,
|
||||||
|
before,
|
||||||
|
content.len(),
|
||||||
|
limit
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -962,6 +991,7 @@ impl<C: LlmClient> Worker<C, Mutable> {
|
||||||
max_turns: None,
|
max_turns: None,
|
||||||
turn_start_cbs: Vec::new(),
|
turn_start_cbs: Vec::new(),
|
||||||
turn_end_cbs: Vec::new(),
|
turn_end_cbs: Vec::new(),
|
||||||
|
warning_cbs: Vec::new(),
|
||||||
request_config: RequestConfig::default(),
|
request_config: RequestConfig::default(),
|
||||||
last_run_interrupted: false,
|
last_run_interrupted: false,
|
||||||
cancel_tx,
|
cancel_tx,
|
||||||
|
|
@ -1214,6 +1244,7 @@ impl<C: LlmClient> Worker<C, Mutable> {
|
||||||
max_turns: self.max_turns,
|
max_turns: self.max_turns,
|
||||||
turn_start_cbs: self.turn_start_cbs,
|
turn_start_cbs: self.turn_start_cbs,
|
||||||
turn_end_cbs: self.turn_end_cbs,
|
turn_end_cbs: self.turn_end_cbs,
|
||||||
|
warning_cbs: self.warning_cbs,
|
||||||
request_config: self.request_config,
|
request_config: self.request_config,
|
||||||
last_run_interrupted: self.last_run_interrupted,
|
last_run_interrupted: self.last_run_interrupted,
|
||||||
|
|
||||||
|
|
@ -1286,6 +1317,7 @@ impl<C: LlmClient> Worker<C, Locked> {
|
||||||
max_turns: self.max_turns,
|
max_turns: self.max_turns,
|
||||||
turn_start_cbs: self.turn_start_cbs,
|
turn_start_cbs: self.turn_start_cbs,
|
||||||
turn_end_cbs: self.turn_end_cbs,
|
turn_end_cbs: self.turn_end_cbs,
|
||||||
|
warning_cbs: self.warning_cbs,
|
||||||
request_config: self.request_config,
|
request_config: self.request_config,
|
||||||
last_run_interrupted: self.last_run_interrupted,
|
last_run_interrupted: self.last_run_interrupted,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,21 +18,43 @@ pub(crate) const AGENTS_MD_LIMIT: usize = 64 * 1024;
|
||||||
|
|
||||||
const TRUNCATION_NOTICE: &str = "\n\n[truncated: AGENTS.md exceeded 64KB limit]";
|
const TRUNCATION_NOTICE: &str = "\n\n[truncated: AGENTS.md exceeded 64KB limit]";
|
||||||
|
|
||||||
/// Read `AGENTS.md` from `cwd` if present. Returns `None` for "absent or
|
/// Outcome of an `AGENTS.md` ingestion attempt.
|
||||||
/// unreadable"; all non-fatal problems are logged via `tracing::warn!`.
|
|
||||||
///
|
///
|
||||||
/// - Absent: `None`, no warn.
|
/// `body` carries the text that should be handed to the template
|
||||||
/// - Over limit: first 64KB (UTF-8 char boundary) + truncation notice, warn.
|
/// engine (if any); `warnings` are short human-readable messages that
|
||||||
/// - Non-UTF-8 or I/O error: `None`, warn.
|
/// Pod forwards to the user-facing notification channel. The caller
|
||||||
pub(crate) fn read_agents_md(cwd: &Path) -> Option<String> {
|
/// also gets `tracing::warn!` lines for the developer log.
|
||||||
|
pub(crate) struct AgentsMdResult {
|
||||||
|
pub body: Option<String>,
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read `AGENTS.md` from `cwd` if present. All non-fatal problems are
|
||||||
|
/// both logged via `tracing::warn!` (developer-facing) and surfaced
|
||||||
|
/// via `AgentsMdResult::warnings` (user-facing).
|
||||||
|
///
|
||||||
|
/// - Absent: `body = None`, no warning.
|
||||||
|
/// - Over limit: first 64KB (UTF-8 char boundary) + truncation notice, warning.
|
||||||
|
/// - Non-UTF-8 or I/O error: `body = None`, warning.
|
||||||
|
pub(crate) fn read_agents_md(cwd: &Path) -> AgentsMdResult {
|
||||||
let path = cwd.join("AGENTS.md");
|
let path = cwd.join("AGENTS.md");
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
|
||||||
let file = match File::open(&path) {
|
let file = match File::open(&path) {
|
||||||
Ok(f) => f,
|
Ok(f) => f,
|
||||||
Err(e) if e.kind() == ErrorKind::NotFound => return None,
|
Err(e) if e.kind() == ErrorKind::NotFound => {
|
||||||
|
return AgentsMdResult {
|
||||||
|
body: None,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(path = %path.display(), error = %e, "failed to open AGENTS.md");
|
warn!(path = %path.display(), error = %e, "failed to open AGENTS.md");
|
||||||
return None;
|
warnings.push(format!("failed to open AGENTS.md ({}): {}", path.display(), e));
|
||||||
|
return AgentsMdResult {
|
||||||
|
body: None,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -42,7 +64,11 @@ pub(crate) fn read_agents_md(cwd: &Path) -> Option<String> {
|
||||||
let read_limit = (AGENTS_MD_LIMIT as u64) + 1;
|
let read_limit = (AGENTS_MD_LIMIT as u64) + 1;
|
||||||
if let Err(e) = file.take(read_limit).read_to_end(&mut buf) {
|
if let Err(e) = file.take(read_limit).read_to_end(&mut buf) {
|
||||||
warn!(path = %path.display(), error = %e, "failed to read AGENTS.md");
|
warn!(path = %path.display(), error = %e, "failed to read AGENTS.md");
|
||||||
return None;
|
warnings.push(format!("failed to read AGENTS.md ({}): {}", path.display(), e));
|
||||||
|
return AgentsMdResult {
|
||||||
|
body: None,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let truncated = buf.len() > AGENTS_MD_LIMIT;
|
let truncated = buf.len() > AGENTS_MD_LIMIT;
|
||||||
|
|
@ -69,7 +95,15 @@ pub(crate) fn read_agents_md(cwd: &Path) -> Option<String> {
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(path = %path.display(), error = %e, "AGENTS.md is not valid UTF-8");
|
warn!(path = %path.display(), error = %e, "AGENTS.md is not valid UTF-8");
|
||||||
return None;
|
warnings.push(format!(
|
||||||
|
"AGENTS.md ({}) is not valid UTF-8: {}",
|
||||||
|
path.display(),
|
||||||
|
e
|
||||||
|
));
|
||||||
|
return AgentsMdResult {
|
||||||
|
body: None,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -80,10 +114,18 @@ pub(crate) fn read_agents_md(cwd: &Path) -> Option<String> {
|
||||||
limit = AGENTS_MD_LIMIT,
|
limit = AGENTS_MD_LIMIT,
|
||||||
"AGENTS.md exceeded size limit; truncating"
|
"AGENTS.md exceeded size limit; truncating"
|
||||||
);
|
);
|
||||||
|
warnings.push(format!(
|
||||||
|
"AGENTS.md ({}) exceeded {} bytes; the tail was truncated",
|
||||||
|
path.display(),
|
||||||
|
AGENTS_MD_LIMIT
|
||||||
|
));
|
||||||
text.push_str(TRUNCATION_NOTICE);
|
text.push_str(TRUNCATION_NOTICE);
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(text)
|
AgentsMdResult {
|
||||||
|
body: Some(text),
|
||||||
|
warnings,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -95,17 +137,16 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn absent_file_returns_none() {
|
fn absent_file_returns_none() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
assert!(read_agents_md(dir.path()).is_none());
|
assert!(read_agents_md(dir.path()).body.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reads_small_file_verbatim() {
|
fn reads_small_file_verbatim() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
fs::write(dir.path().join("AGENTS.md"), "# hello\nworld").unwrap();
|
fs::write(dir.path().join("AGENTS.md"), "# hello\nworld").unwrap();
|
||||||
assert_eq!(
|
let result = read_agents_md(dir.path());
|
||||||
read_agents_md(dir.path()).as_deref(),
|
assert_eq!(result.body.as_deref(), Some("# hello\nworld"));
|
||||||
Some("# hello\nworld"),
|
assert!(result.warnings.is_empty());
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -114,11 +155,13 @@ mod tests {
|
||||||
let body = "a".repeat(AGENTS_MD_LIMIT + 1024);
|
let body = "a".repeat(AGENTS_MD_LIMIT + 1024);
|
||||||
fs::write(dir.path().join("AGENTS.md"), &body).unwrap();
|
fs::write(dir.path().join("AGENTS.md"), &body).unwrap();
|
||||||
|
|
||||||
let got = read_agents_md(dir.path()).expect("some");
|
let result = read_agents_md(dir.path());
|
||||||
|
let got = result.body.expect("some");
|
||||||
assert!(got.ends_with(TRUNCATION_NOTICE));
|
assert!(got.ends_with(TRUNCATION_NOTICE));
|
||||||
let prefix = got.strip_suffix(TRUNCATION_NOTICE).unwrap();
|
let prefix = got.strip_suffix(TRUNCATION_NOTICE).unwrap();
|
||||||
assert_eq!(prefix.len(), AGENTS_MD_LIMIT);
|
assert_eq!(prefix.len(), AGENTS_MD_LIMIT);
|
||||||
assert!(prefix.chars().all(|c| c == 'a'));
|
assert!(prefix.chars().all(|c| c == 'a'));
|
||||||
|
assert_eq!(result.warnings.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -127,9 +170,11 @@ mod tests {
|
||||||
let body = "a".repeat(AGENTS_MD_LIMIT);
|
let body = "a".repeat(AGENTS_MD_LIMIT);
|
||||||
fs::write(dir.path().join("AGENTS.md"), &body).unwrap();
|
fs::write(dir.path().join("AGENTS.md"), &body).unwrap();
|
||||||
|
|
||||||
let got = read_agents_md(dir.path()).expect("some");
|
let result = read_agents_md(dir.path());
|
||||||
|
let got = result.body.expect("some");
|
||||||
assert_eq!(got.len(), AGENTS_MD_LIMIT);
|
assert_eq!(got.len(), AGENTS_MD_LIMIT);
|
||||||
assert!(!got.contains("truncated"));
|
assert!(!got.contains("truncated"));
|
||||||
|
assert!(result.warnings.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -142,12 +187,23 @@ mod tests {
|
||||||
body.push_str(&"b".repeat(128));
|
body.push_str(&"b".repeat(128));
|
||||||
fs::write(dir.path().join("AGENTS.md"), &body).unwrap();
|
fs::write(dir.path().join("AGENTS.md"), &body).unwrap();
|
||||||
|
|
||||||
let got = read_agents_md(dir.path()).expect("some");
|
let result = read_agents_md(dir.path());
|
||||||
|
let got = result.body.expect("some");
|
||||||
assert!(got.ends_with(TRUNCATION_NOTICE));
|
assert!(got.ends_with(TRUNCATION_NOTICE));
|
||||||
let prefix = got.strip_suffix(TRUNCATION_NOTICE).unwrap();
|
let prefix = got.strip_suffix(TRUNCATION_NOTICE).unwrap();
|
||||||
// The partial 'あ' must have been dropped, leaving only the ASCII prefix.
|
// The partial 'あ' must have been dropped, leaving only the ASCII prefix.
|
||||||
assert_eq!(prefix.len(), AGENTS_MD_LIMIT - 1);
|
assert_eq!(prefix.len(), AGENTS_MD_LIMIT - 1);
|
||||||
assert!(prefix.chars().all(|c| c == 'a'));
|
assert!(prefix.chars().all(|c| c == 'a'));
|
||||||
|
assert_eq!(result.warnings.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_utf8_surfaces_warning() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
fs::write(dir.path().join("AGENTS.md"), [0xff, 0xfe, 0xfd]).unwrap();
|
||||||
|
let result = read_agents_md(dir.path());
|
||||||
|
assert!(result.body.is_none());
|
||||||
|
assert_eq!(result.warnings.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -159,7 +215,7 @@ mod tests {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let body = vec![0xffu8; AGENTS_MD_LIMIT + 1024];
|
let body = vec![0xffu8; AGENTS_MD_LIMIT + 1024];
|
||||||
fs::write(dir.path().join("AGENTS.md"), body).unwrap();
|
fs::write(dir.path().join("AGENTS.md"), body).unwrap();
|
||||||
assert!(read_agents_md(dir.path()).is_none());
|
assert!(read_agents_md(dir.path()).body.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -167,6 +223,6 @@ mod tests {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
// Invalid UTF-8 start byte.
|
// Invalid UTF-8 start byte.
|
||||||
fs::write(dir.path().join("AGENTS.md"), [0xff, 0xfe, 0xfd]).unwrap();
|
fs::write(dir.path().join("AGENTS.md"), [0xff, 0xfe, 0xfd]).unwrap();
|
||||||
assert!(read_agents_md(dir.path()).is_none());
|
assert!(read_agents_md(dir.path()).body.is_none());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@ use llm_worker::llm_client::client::LlmClient;
|
||||||
use session_store::Store;
|
use session_store::Store;
|
||||||
use tokio::sync::{broadcast, mpsc};
|
use tokio::sync::{broadcast, mpsc};
|
||||||
|
|
||||||
|
use crate::notifier::Notifier;
|
||||||
use crate::pod::{Pod, PodError, PodRunResult};
|
use crate::pod::{Pod, PodError, PodRunResult};
|
||||||
use crate::runtime_dir::RuntimeDir;
|
use crate::runtime_dir::RuntimeDir;
|
||||||
use crate::shared_state::{PodSharedState, PodStatus};
|
use crate::shared_state::{PodSharedState, PodStatus};
|
||||||
use crate::socket_server::SocketServer;
|
use crate::socket_server::SocketServer;
|
||||||
use protocol::{ErrorCode, Event, Method, RunResult, TurnResult};
|
use protocol::{ErrorCode, Event, Method, NotificationLevel, NotificationSource, RunResult, TurnResult};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// PodHandle — client-facing, Clone-able
|
// PodHandle — client-facing, Clone-able
|
||||||
|
|
@ -22,6 +23,7 @@ pub struct PodHandle {
|
||||||
event_tx: broadcast::Sender<Event>,
|
event_tx: broadcast::Sender<Event>,
|
||||||
pub shared_state: Arc<PodSharedState>,
|
pub shared_state: Arc<PodSharedState>,
|
||||||
pub runtime_dir: Arc<RuntimeDir>,
|
pub runtime_dir: Arc<RuntimeDir>,
|
||||||
|
pub notifier: Notifier,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PodHandle {
|
impl PodHandle {
|
||||||
|
|
@ -37,6 +39,11 @@ impl PodHandle {
|
||||||
pub fn send_event(&self, event: Event) -> Result<usize, broadcast::error::SendError<Event>> {
|
pub fn send_event(&self, event: Event) -> Result<usize, broadcast::error::SendError<Event>> {
|
||||||
self.event_tx.send(event)
|
self.event_tx.send(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Emit a user-facing notification. Thin wrapper over `Notifier::notify`.
|
||||||
|
pub fn notify(&self, level: NotificationLevel, source: NotificationSource, message: String) {
|
||||||
|
self.notifier.notify(level, source, message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -56,6 +63,7 @@ impl PodController {
|
||||||
{
|
{
|
||||||
let (method_tx, mut method_rx) = mpsc::channel::<Method>(32);
|
let (method_tx, mut method_rx) = mpsc::channel::<Method>(32);
|
||||||
let (event_tx, _) = broadcast::channel::<Event>(256);
|
let (event_tx, _) = broadcast::channel::<Event>(256);
|
||||||
|
let notifier = Notifier::new(event_tx.clone());
|
||||||
|
|
||||||
let manifest_toml = toml::to_string_pretty(pod.manifest()).unwrap_or_default();
|
let manifest_toml = toml::to_string_pretty(pod.manifest()).unwrap_or_default();
|
||||||
let greeting = build_greeting(&pod);
|
let greeting = build_greeting(&pod);
|
||||||
|
|
@ -78,8 +86,14 @@ impl PodController {
|
||||||
event_tx: event_tx.clone(),
|
event_tx: event_tx.clone(),
|
||||||
shared_state: shared_state.clone(),
|
shared_state: shared_state.clone(),
|
||||||
runtime_dir: runtime_dir.clone(),
|
runtime_dir: runtime_dir.clone(),
|
||||||
|
notifier: notifier.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Hand the notifier to the Pod so internal operations (compaction,
|
||||||
|
// AGENTS.md ingestion during the first turn) can emit user-facing
|
||||||
|
// notifications on the same channel.
|
||||||
|
pod.attach_notifier(notifier.clone());
|
||||||
|
|
||||||
// Start socket server (lives as a background task, cleaned up on drop via RuntimeDir)
|
// Start socket server (lives as a background task, cleaned up on drop via RuntimeDir)
|
||||||
let _socket_server = SocketServer::start(&handle).await?;
|
let _socket_server = SocketServer::start(&handle).await?;
|
||||||
// Keep the server alive by moving it into the controller task
|
// Keep the server alive by moving it into the controller task
|
||||||
|
|
@ -163,6 +177,15 @@ impl PodController {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let notifier_for_worker = notifier.clone();
|
||||||
|
worker.on_warning(move |message| {
|
||||||
|
notifier_for_worker.notify(
|
||||||
|
NotificationLevel::Warn,
|
||||||
|
NotificationSource::Worker,
|
||||||
|
message.to_owned(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// Register the builtin file-manipulation tools (Read / Write /
|
// Register the builtin file-manipulation tools (Read / Write /
|
||||||
// Edit / Glob / Grep). `ScopedFs` carries the pod-lifetime
|
// Edit / Glob / Grep). `ScopedFs` carries the pod-lifetime
|
||||||
// scope/pwd; `Tracker` is session-scoped — a fresh instance per
|
// scope/pwd; `Tracker` is session-scoped — a fresh instance per
|
||||||
|
|
@ -215,6 +238,11 @@ impl PodController {
|
||||||
if new_status == PodStatus::Idle {
|
if new_status == PodStatus::Idle {
|
||||||
if let Err(e) = pod.try_post_run_compact().await {
|
if let Err(e) = pod.try_post_run_compact().await {
|
||||||
tracing::warn!(error = %e, "Post-run compaction error");
|
tracing::warn!(error = %e, "Post-run compaction error");
|
||||||
|
notifier.notify(
|
||||||
|
NotificationLevel::Warn,
|
||||||
|
NotificationSource::Compactor,
|
||||||
|
format!("post-run compaction error: {e}"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -249,6 +277,11 @@ impl PodController {
|
||||||
if new_status == PodStatus::Idle {
|
if new_status == PodStatus::Idle {
|
||||||
if let Err(e) = pod.try_post_run_compact().await {
|
if let Err(e) = pod.try_post_run_compact().await {
|
||||||
tracing::warn!(error = %e, "Post-run compaction error");
|
tracing::warn!(error = %e, "Post-run compaction error");
|
||||||
|
notifier.notify(
|
||||||
|
NotificationLevel::Warn,
|
||||||
|
NotificationSource::Compactor,
|
||||||
|
format!("post-run compaction error: {e}"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
pub mod controller;
|
pub mod controller;
|
||||||
pub mod hook;
|
pub mod hook;
|
||||||
|
pub mod notifier;
|
||||||
pub mod runtime_dir;
|
pub mod runtime_dir;
|
||||||
pub mod shared_state;
|
pub mod shared_state;
|
||||||
pub mod socket_server;
|
pub mod socket_server;
|
||||||
|
|
@ -17,6 +18,7 @@ mod usage_tracker;
|
||||||
pub use token_counter::{EstimateSource, SplitPoint, TokenEstimate};
|
pub use token_counter::{EstimateSource, SplitPoint, TokenEstimate};
|
||||||
|
|
||||||
pub use controller::{PodController, PodHandle};
|
pub use controller::{PodController, PodHandle};
|
||||||
|
pub use notifier::Notifier;
|
||||||
pub use hook::{Hook, HookEventKind, HookRegistryBuilder};
|
pub use hook::{Hook, HookEventKind, HookRegistryBuilder};
|
||||||
pub use manifest::{PodManifest, ProviderConfig, ProviderKind, Scope};
|
pub use manifest::{PodManifest, ProviderConfig, ProviderKind, Scope};
|
||||||
pub use pod::{Pod, PodError, PodRunResult, apply_worker_manifest};
|
pub use pod::{Pod, PodError, PodRunResult, apply_worker_manifest};
|
||||||
|
|
|
||||||
191
crates/pod/src/notifier.rs
Normal file
191
crates/pod/src/notifier.rs
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
//! User-facing notification channel for Pod → client.
|
||||||
|
//!
|
||||||
|
//! Separate from `tracing` (which is for developer logs). Notifications
|
||||||
|
//! are short human-readable messages the Pod layer wants a client to
|
||||||
|
//! see — for example "compaction failed", "tool output truncated".
|
||||||
|
//!
|
||||||
|
//! Each notification is broadcast on the shared `Event` channel and
|
||||||
|
//! also appended to an in-memory buffer so that clients connecting
|
||||||
|
//! after the fact still see everything emitted during the session.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
|
use protocol::{Event, Notification, NotificationLevel, NotificationSource};
|
||||||
|
|
||||||
|
/// Upper bound on buffered notifications. When exceeded, the oldest
|
||||||
|
/// entries are discarded so a long-running session cannot leak
|
||||||
|
/// memory through a pathological loop of recurring notifications
|
||||||
|
/// (e.g. compaction failing every turn).
|
||||||
|
const MAX_BUFFERED_NOTIFICATIONS: usize = 512;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Notifier {
|
||||||
|
inner: Arc<Inner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Inner {
|
||||||
|
event_tx: broadcast::Sender<Event>,
|
||||||
|
buffer: Mutex<VecDeque<Notification>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Notifier {
|
||||||
|
pub fn new(event_tx: broadcast::Sender<Event>) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(Inner {
|
||||||
|
event_tx,
|
||||||
|
buffer: Mutex::new(VecDeque::with_capacity(MAX_BUFFERED_NOTIFICATIONS)),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record and broadcast a notification.
|
||||||
|
///
|
||||||
|
/// The broadcast may have no subscribers (e.g. during Pod
|
||||||
|
/// construction before any client has connected); the buffer
|
||||||
|
/// guarantees the message is still delivered once a client
|
||||||
|
/// attaches.
|
||||||
|
///
|
||||||
|
/// The buffer mutex is held across `broadcast::send` to make
|
||||||
|
/// `subscribe_with_snapshot` race-free — a client that snapshots
|
||||||
|
/// the buffer while holding the same lock sees every notification
|
||||||
|
/// exactly once: older ones from the snapshot, newer ones from
|
||||||
|
/// the freshly-subscribed receiver.
|
||||||
|
pub fn notify(&self, level: NotificationLevel, source: NotificationSource, message: String) {
|
||||||
|
let notification = Notification {
|
||||||
|
level,
|
||||||
|
source,
|
||||||
|
message,
|
||||||
|
timestamp_ms: now_ms(),
|
||||||
|
};
|
||||||
|
if let Ok(mut buf) = self.inner.buffer.lock() {
|
||||||
|
if buf.len() >= MAX_BUFFERED_NOTIFICATIONS {
|
||||||
|
buf.pop_front();
|
||||||
|
}
|
||||||
|
buf.push_back(notification.clone());
|
||||||
|
let _ = self
|
||||||
|
.inner
|
||||||
|
.event_tx
|
||||||
|
.send(Event::Notification(notification));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribe and atomically snapshot the current buffer.
|
||||||
|
///
|
||||||
|
/// The returned snapshot contains notifications emitted before
|
||||||
|
/// this call; the receiver will deliver notifications emitted
|
||||||
|
/// after. A notification cannot appear in both.
|
||||||
|
pub fn subscribe_with_snapshot(&self) -> (Vec<Notification>, broadcast::Receiver<Event>) {
|
||||||
|
let buf = self
|
||||||
|
.inner
|
||||||
|
.buffer
|
||||||
|
.lock()
|
||||||
|
.expect("notifier buffer mutex poisoned");
|
||||||
|
let rx = self.inner.event_tx.subscribe();
|
||||||
|
let snapshot: Vec<Notification> = buf.iter().cloned().collect();
|
||||||
|
(snapshot, rx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_ms() -> i64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis() as i64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn notify_broadcasts_to_existing_subscriber() {
|
||||||
|
let (tx, _keep) = broadcast::channel::<Event>(8);
|
||||||
|
let notifier = Notifier::new(tx);
|
||||||
|
let (_snapshot, mut rx) = notifier.subscribe_with_snapshot();
|
||||||
|
|
||||||
|
notifier.notify(
|
||||||
|
NotificationLevel::Warn,
|
||||||
|
NotificationSource::Compactor,
|
||||||
|
"test message".into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
match rx.try_recv() {
|
||||||
|
Ok(Event::Notification(n)) => assert_eq!(n.message, "test message"),
|
||||||
|
other => panic!("unexpected event: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn late_subscriber_sees_earlier_notifications_via_snapshot() {
|
||||||
|
let (tx, _keep) = broadcast::channel::<Event>(8);
|
||||||
|
let notifier = Notifier::new(tx);
|
||||||
|
|
||||||
|
notifier.notify(
|
||||||
|
NotificationLevel::Error,
|
||||||
|
NotificationSource::Pod,
|
||||||
|
"first".into(),
|
||||||
|
);
|
||||||
|
notifier.notify(
|
||||||
|
NotificationLevel::Warn,
|
||||||
|
NotificationSource::AgentsMd,
|
||||||
|
"second".into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let (snapshot, mut rx) = notifier.subscribe_with_snapshot();
|
||||||
|
assert_eq!(snapshot.len(), 2);
|
||||||
|
assert_eq!(snapshot[0].message, "first");
|
||||||
|
assert_eq!(snapshot[1].message, "second");
|
||||||
|
assert!(rx.try_recv().is_err()); // nothing pending on the receiver
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn buffer_discards_oldest_past_cap() {
|
||||||
|
let (tx, _keep) = broadcast::channel::<Event>(1024);
|
||||||
|
let notifier = Notifier::new(tx);
|
||||||
|
|
||||||
|
for i in 0..(MAX_BUFFERED_NOTIFICATIONS + 50) {
|
||||||
|
notifier.notify(
|
||||||
|
NotificationLevel::Warn,
|
||||||
|
NotificationSource::Worker,
|
||||||
|
format!("msg-{i}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (snapshot, _rx) = notifier.subscribe_with_snapshot();
|
||||||
|
assert_eq!(snapshot.len(), MAX_BUFFERED_NOTIFICATIONS);
|
||||||
|
// First 50 were evicted; the oldest remaining is msg-50.
|
||||||
|
assert_eq!(snapshot.first().unwrap().message, "msg-50");
|
||||||
|
let last = format!("msg-{}", MAX_BUFFERED_NOTIFICATIONS + 49);
|
||||||
|
assert_eq!(snapshot.last().unwrap().message, last);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subscribe_snapshot_and_live_do_not_overlap() {
|
||||||
|
let (tx, _keep) = broadcast::channel::<Event>(8);
|
||||||
|
let notifier = Notifier::new(tx);
|
||||||
|
|
||||||
|
notifier.notify(
|
||||||
|
NotificationLevel::Warn,
|
||||||
|
NotificationSource::Worker,
|
||||||
|
"historic".into(),
|
||||||
|
);
|
||||||
|
let (snapshot, mut rx) = notifier.subscribe_with_snapshot();
|
||||||
|
notifier.notify(
|
||||||
|
NotificationLevel::Error,
|
||||||
|
NotificationSource::Worker,
|
||||||
|
"live".into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(snapshot.len(), 1);
|
||||||
|
assert_eq!(snapshot[0].message, "historic");
|
||||||
|
match rx.try_recv() {
|
||||||
|
Ok(Event::Notification(n)) => assert_eq!(n.message, "live"),
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
}
|
||||||
|
assert!(rx.try_recv().is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,8 +21,10 @@ use crate::hook::{
|
||||||
PreToolCall,
|
PreToolCall,
|
||||||
};
|
};
|
||||||
use crate::hook_interceptor::HookInterceptor;
|
use crate::hook_interceptor::HookInterceptor;
|
||||||
|
use crate::notifier::Notifier;
|
||||||
use crate::system_prompt::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
use crate::system_prompt::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
||||||
use crate::usage_tracker::UsageTracker;
|
use crate::usage_tracker::UsageTracker;
|
||||||
|
use protocol::{NotificationLevel, NotificationSource};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use llm_worker::interceptor::PreRequestAction;
|
use llm_worker::interceptor::PreRequestAction;
|
||||||
|
|
||||||
|
|
@ -97,6 +99,9 @@ pub struct Pod<C: LlmClient, St: Store> {
|
||||||
/// `Some` until `ensure_system_prompt_materialized` renders it once,
|
/// `Some` until `ensure_system_prompt_materialized` renders it once,
|
||||||
/// then `None` forever — including after compaction.
|
/// then `None` forever — including after compaction.
|
||||||
system_prompt_template: Option<SystemPromptTemplate>,
|
system_prompt_template: Option<SystemPromptTemplate>,
|
||||||
|
/// User-facing notification sink attached by the Controller at
|
||||||
|
/// spawn time. `None` in tests / direct `Pod::new` usage.
|
||||||
|
notifier: Option<Notifier>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<C: LlmClient, St: Store> Pod<C, St> {
|
impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
|
|
@ -137,6 +142,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
usage_history: Arc::new(Mutex::new(Vec::<UsageRecord>::new())),
|
usage_history: Arc::new(Mutex::new(Vec::<UsageRecord>::new())),
|
||||||
tracker: None,
|
tracker: None,
|
||||||
system_prompt_template: None,
|
system_prompt_template: None,
|
||||||
|
notifier: None,
|
||||||
};
|
};
|
||||||
pod.apply_prune_from_manifest();
|
pod.apply_prune_from_manifest();
|
||||||
Ok(pod)
|
Ok(pod)
|
||||||
|
|
@ -185,6 +191,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
usage_history: Arc::new(Mutex::new(state.usage_history)),
|
usage_history: Arc::new(Mutex::new(state.usage_history)),
|
||||||
tracker: None,
|
tracker: None,
|
||||||
system_prompt_template: None,
|
system_prompt_template: None,
|
||||||
|
notifier: None,
|
||||||
};
|
};
|
||||||
pod.apply_prune_from_manifest();
|
pod.apply_prune_from_manifest();
|
||||||
Ok(pod)
|
Ok(pod)
|
||||||
|
|
@ -275,6 +282,21 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
self.tracker.as_ref()
|
self.tracker.as_ref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attach a user-facing notification sink.
|
||||||
|
///
|
||||||
|
/// Called by the Controller immediately after spawning so that
|
||||||
|
/// Pod-internal operations (compaction failures, AGENTS.md
|
||||||
|
/// ingestion warnings) can surface messages to connected clients.
|
||||||
|
pub fn attach_notifier(&mut self, notifier: Notifier) {
|
||||||
|
self.notifier = Some(notifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn notify(&self, level: NotificationLevel, source: NotificationSource, message: String) {
|
||||||
|
if let Some(n) = self.notifier.as_ref() {
|
||||||
|
n.notify(level, source, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Hook registration ---
|
// --- Hook registration ---
|
||||||
|
|
||||||
fn assert_hooks_open(&self) {
|
fn assert_hooks_open(&self) {
|
||||||
|
|
@ -392,6 +414,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
let Some(template) = self.system_prompt_template.take() else {
|
let Some(template) = self.system_prompt_template.take() else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
let notifier = self.notifier.clone();
|
||||||
let worker = self.worker.as_mut().expect("worker present");
|
let worker = self.worker.as_mut().expect("worker present");
|
||||||
// Materialise any pending tool factories so the template sees the
|
// Materialise any pending tool factories so the template sees the
|
||||||
// full list of tool names. Redundant with the flush inside
|
// full list of tool names. Redundant with the flush inside
|
||||||
|
|
@ -404,7 +427,17 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
.map(|d| d.name)
|
.map(|d| d.name)
|
||||||
.collect();
|
.collect();
|
||||||
let mut files = std::collections::BTreeMap::new();
|
let mut files = std::collections::BTreeMap::new();
|
||||||
if let Some(body) = read_agents_md(&self.pwd) {
|
let agents_md = read_agents_md(&self.pwd);
|
||||||
|
for warning in agents_md.warnings {
|
||||||
|
if let Some(n) = notifier.as_ref() {
|
||||||
|
n.notify(
|
||||||
|
NotificationLevel::Warn,
|
||||||
|
NotificationSource::AgentsMd,
|
||||||
|
warning,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(body) = agents_md.body {
|
||||||
files.insert("agents_md".to_string(), body);
|
files.insert("agents_md".to_string(), body);
|
||||||
}
|
}
|
||||||
let ctx = SystemPromptContext {
|
let ctx = SystemPromptContext {
|
||||||
|
|
@ -553,6 +586,11 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(error = %e, "Compaction failed during run");
|
warn!(error = %e, "Compaction failed during run");
|
||||||
|
self.notify(
|
||||||
|
NotificationLevel::Error,
|
||||||
|
NotificationSource::Compactor,
|
||||||
|
format!("mid-run compaction failed: {e}"),
|
||||||
|
);
|
||||||
if let Some(ref state) = self.compact_state {
|
if let Some(ref state) = self.compact_state {
|
||||||
state.record_compact_failure();
|
state.record_compact_failure();
|
||||||
}
|
}
|
||||||
|
|
@ -583,6 +621,11 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(error = %e, "Proactive post-run compaction failed");
|
warn!(error = %e, "Proactive post-run compaction failed");
|
||||||
|
self.notify(
|
||||||
|
NotificationLevel::Warn,
|
||||||
|
NotificationSource::Compactor,
|
||||||
|
format!("post-run compaction failed: {e}"),
|
||||||
|
);
|
||||||
state.record_compact_failure();
|
state.record_compact_failure();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -830,6 +873,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||||
usage_history: Arc::new(Mutex::new(Vec::new())),
|
usage_history: Arc::new(Mutex::new(Vec::new())),
|
||||||
tracker: None,
|
tracker: None,
|
||||||
system_prompt_template,
|
system_prompt_template,
|
||||||
|
notifier: None,
|
||||||
};
|
};
|
||||||
pod.apply_prune_from_manifest();
|
pod.apply_prune_from_manifest();
|
||||||
Ok(pod)
|
Ok(pod)
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,21 @@ async fn handle_connection(stream: tokio::net::UnixStream, handle: PodHandle) {
|
||||||
let (reader, writer) = stream.into_split();
|
let (reader, writer) = stream.into_split();
|
||||||
let mut reader = JsonLineReader::new(reader);
|
let mut reader = JsonLineReader::new(reader);
|
||||||
let mut writer = JsonLineWriter::new(writer);
|
let mut writer = JsonLineWriter::new(writer);
|
||||||
let mut rx = handle.subscribe();
|
|
||||||
|
// Atomically subscribe and snapshot buffered notifications so that
|
||||||
|
// warnings emitted before this client connected are replayed
|
||||||
|
// exactly once — they appear in the snapshot, and any notification
|
||||||
|
// arriving afterwards reaches us through `rx`.
|
||||||
|
let (notification_snapshot, mut rx) = handle.notifier.subscribe_with_snapshot();
|
||||||
|
for notification in notification_snapshot {
|
||||||
|
if writer
|
||||||
|
.write(&Event::Notification(notification))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,38 @@ pub enum Event {
|
||||||
items: Vec<serde_json::Value>,
|
items: Vec<serde_json::Value>,
|
||||||
greeting: Greeting,
|
greeting: Greeting,
|
||||||
},
|
},
|
||||||
|
Notification(Notification),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User-facing notification emitted from the Pod layer.
|
||||||
|
///
|
||||||
|
/// This is a separate channel from `tracing` (developer logs): entries
|
||||||
|
/// here are assembled explicitly by the Pod when a condition should be
|
||||||
|
/// surfaced to the person driving the client. Keep messages short and
|
||||||
|
/// human-readable.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Notification {
|
||||||
|
pub level: NotificationLevel,
|
||||||
|
pub source: NotificationSource,
|
||||||
|
pub message: String,
|
||||||
|
/// Milliseconds since the Unix epoch.
|
||||||
|
pub timestamp_ms: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum NotificationLevel {
|
||||||
|
Warn,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum NotificationSource {
|
||||||
|
Pod,
|
||||||
|
Worker,
|
||||||
|
Compactor,
|
||||||
|
AgentsMd,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pod self-description rendered by the TUI when a session starts empty.
|
/// Pod self-description rendered by the TUI when a session starts empty.
|
||||||
|
|
@ -187,6 +219,23 @@ mod tests {
|
||||||
assert_eq!(parsed["data"]["greeting"]["tools"][0], "Read");
|
assert_eq!(parsed["data"]["greeting"]["tools"][0], "Read");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn event_notification_format() {
|
||||||
|
let event = Event::Notification(Notification {
|
||||||
|
level: NotificationLevel::Warn,
|
||||||
|
source: NotificationSource::Compactor,
|
||||||
|
message: "compaction failed".into(),
|
||||||
|
timestamp_ms: 1_700_000_000_000,
|
||||||
|
});
|
||||||
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed["event"], "notification");
|
||||||
|
assert_eq!(parsed["data"]["level"], "warn");
|
||||||
|
assert_eq!(parsed["data"]["source"], "compactor");
|
||||||
|
assert_eq!(parsed["data"]["message"], "compaction failed");
|
||||||
|
assert_eq!(parsed["data"]["timestamp_ms"], 1_700_000_000_000i64);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn event_error_format() {
|
fn event_error_format() {
|
||||||
let event = Event::Error {
|
let event = Event::Error {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use protocol::{Event, Greeting, Method};
|
use protocol::{Event, Greeting, Method, NotificationLevel, NotificationSource};
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
pub pod_name: String,
|
pub pod_name: String,
|
||||||
|
|
@ -35,6 +35,10 @@ pub enum MessageKind {
|
||||||
Tool,
|
Tool,
|
||||||
Error,
|
Error,
|
||||||
TurnStats,
|
TurnStats,
|
||||||
|
/// Pod → user notification, Warn level.
|
||||||
|
NoticeWarn,
|
||||||
|
/// Pod → user notification, Error level.
|
||||||
|
NoticeError,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
|
|
@ -166,6 +170,21 @@ impl App {
|
||||||
self.current_tool = None;
|
self.current_tool = None;
|
||||||
}
|
}
|
||||||
Event::ToolCallArgsDelta { .. } => {}
|
Event::ToolCallArgsDelta { .. } => {}
|
||||||
|
Event::Notification(notification) => {
|
||||||
|
let kind = match notification.level {
|
||||||
|
NotificationLevel::Warn => MessageKind::NoticeWarn,
|
||||||
|
NotificationLevel::Error => MessageKind::NoticeError,
|
||||||
|
};
|
||||||
|
let prefix = match notification.level {
|
||||||
|
NotificationLevel::Warn => "[notice]",
|
||||||
|
NotificationLevel::Error => "[notice error]",
|
||||||
|
};
|
||||||
|
let source = notification_source_label(notification.source);
|
||||||
|
self.output_queue.push(OutputItem::Padded(
|
||||||
|
kind,
|
||||||
|
format!("{prefix} {source}: {}", notification.message),
|
||||||
|
));
|
||||||
|
}
|
||||||
Event::History { items, greeting } => {
|
Event::History { items, greeting } => {
|
||||||
self.restore_history(&items);
|
self.restore_history(&items);
|
||||||
if self.turn_index == 0 {
|
if self.turn_index == 0 {
|
||||||
|
|
@ -298,6 +317,15 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn notification_source_label(source: NotificationSource) -> &'static str {
|
||||||
|
match source {
|
||||||
|
NotificationSource::Pod => "pod",
|
||||||
|
NotificationSource::Worker => "worker",
|
||||||
|
NotificationSource::Compactor => "compactor",
|
||||||
|
NotificationSource::AgentsMd => "AGENTS.md",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn fmt_tokens(n: u64) -> String {
|
pub fn fmt_tokens(n: u64) -> String {
|
||||||
if n >= 1_000_000 {
|
if n >= 1_000_000 {
|
||||||
format!("{:.1}M", n as f64 / 1_000_000.0)
|
format!("{:.1}M", n as f64 / 1_000_000.0)
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,14 @@ pub fn kind_style(kind: &MessageKind) -> Style {
|
||||||
MessageKind::Tool => Style::default().fg(Color::Cyan),
|
MessageKind::Tool => Style::default().fg(Color::Cyan),
|
||||||
MessageKind::Error => Style::default().fg(Color::Red),
|
MessageKind::Error => Style::default().fg(Color::Red),
|
||||||
MessageKind::TurnStats => Style::default().fg(Color::DarkGray),
|
MessageKind::TurnStats => Style::default().fg(Color::DarkGray),
|
||||||
|
MessageKind::NoticeWarn => Style::default()
|
||||||
|
.fg(Color::Black)
|
||||||
|
.bg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
MessageKind::NoticeError => Style::default()
|
||||||
|
.fg(Color::White)
|
||||||
|
.bg(Color::Red)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
140
tickets/pod-factory.md
Normal file
140
tickets/pod-factory.md
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
# Pod Factory: 設定カスケードとプロンプト資産による Pod 自動生成
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
現状、Pod を起動するには `test_pod.local.toml` のような完全な `PodManifest` TOML を手書きする必要がある。1 人のユーザーが1 つのエージェントを試験運用するには十分だが、Insomnia が狙う「複数のエージェントが独立プロセスとして spawn されて自律的に動く」世界観では、**Pod のライフサイクル全体が自動化可能でなければならない**。そのためには、Pod の**作成自体**も自動化可能である必要がある。
|
||||||
|
|
||||||
|
手書きマニフェストには以下の問題がある:
|
||||||
|
|
||||||
|
- 1 Pod = 1 ファイルで、Pod を動的に増やす用途にスケールしない
|
||||||
|
- 設定項目が多く(`worker.*` / `provider.*` / `scope.*` / `compaction.*` / `tool_output.*` 等)、毎回コピペしてわずかな差分だけ書き換える苦行になる
|
||||||
|
- system_prompt を TOML 文字列に埋め込む形はプロンプト資産の再利用性が低い
|
||||||
|
- Pod の起動条件の**共通部分**(プロバイダ・モデル・デフォルトツール設定など)は本来一度書けば良いのに、毎回書かされる
|
||||||
|
|
||||||
|
## ゴール
|
||||||
|
|
||||||
|
Pod 作成を「**最終的に `PodManifest` を1 つ構築する問題**」として定式化し、その `PodManifest` を**カスケード + 差分上書き**で組み立てる基盤を提供する。手書きが必要な TOML は「ユーザー / プロジェクト単位の**デフォルト上書き**」だけに縮退させ、個別の Pod 起動ごとに人間が TOML を触らない状態を目指す。
|
||||||
|
|
||||||
|
プロンプトは手書きマニフェストに文字列を埋め込む方式をやめ、**テンプレート資産ライブラリ**として参照可能にする。
|
||||||
|
|
||||||
|
## 方針
|
||||||
|
|
||||||
|
### 同じ型で、層で上書きする
|
||||||
|
|
||||||
|
- **解決後の型は現行の `manifest::PodManifest` のまま**。Pod 側の契約(`Pod::from_manifest`)は変更しない。
|
||||||
|
- カスケードは `PodManifest` より上の「部分的な `PodManifest` を層ごとに保持し、順番にマージして最終形を作る」層として設計する。
|
||||||
|
- 各層は**部分形**を持てる(全フィールドを埋める必要はない)。存在するフィールドだけが下層を上書きする。
|
||||||
|
|
||||||
|
### カスケードの層
|
||||||
|
|
||||||
|
優先順位が低い方から高い方へ:
|
||||||
|
|
||||||
|
1. **ビルトインのデフォルト**: コードに焼き込んだ基本値(現在 `PodManifest` 各フィールドの `#[serde(default)]` や `Default` 実装に散っているものを集約)
|
||||||
|
2. **ユーザー設定**: `~/.config/insomnia/config.toml` など。ユーザー個人のプロバイダ指定・デフォルトモデル・常用ツール設定等を書く
|
||||||
|
3. **プロジェクト設定**: プロジェクト直下の `.insomnia/config.toml` など。プロジェクト固有の scope・compaction 設定・system_prompt のベース等を書く
|
||||||
|
4. **プログラマティック上書き**: Pod 生成を呼ぶコード(GUI / CLI / 別 Pod からの spawn 等)が渡す `PodManifestOverlay` 的な部分形。ここで `pod.name` や `pod.pwd` のような**その Pod に固有の値**を与える
|
||||||
|
|
||||||
|
各層とも人間が書くときは `PodManifest` と同じ TOML スキーマで書く(サブセット可)。
|
||||||
|
|
||||||
|
### マージのセマンティクス
|
||||||
|
|
||||||
|
- **スカラー** (`String`, `u32`, `bool` 等): 上層が存在すれば丸ごと置換
|
||||||
|
- **Option 型**: 上層が `Some` なら置換、`None` なら据え置き
|
||||||
|
- **マップ** (例: `tool_output.per_tool`): キー単位でマージ、同一キーは上層優先
|
||||||
|
- **リスト** (例: `scope.allow` / `scope.deny`): **原則置換**(append にすると下層の意図しないルールが漏れる危険)。ただし append したいケースはあるので、設計時に decoration の形式(例: `scope.allow_extra` など)を別途検討
|
||||||
|
- 未知フィールドは manifest エラーにせずログ警告
|
||||||
|
|
||||||
|
### プロンプト資産ライブラリ
|
||||||
|
|
||||||
|
- プロンプトは TOML 文字列ではなく**ファイルとして管理**する。
|
||||||
|
- 検索パスはカスケードと対応した3層:
|
||||||
|
1. **ビルトイン**: バイナリに同梱されたデフォルトプロンプト(`coder` / `reviewer` / `planner` 等、設計時に選定)
|
||||||
|
2. **ユーザー**: `~/.config/insomnia/prompts/*.md` 等
|
||||||
|
3. **プロジェクト**: `.insomnia/prompts/*.md` 等
|
||||||
|
- 同名があれば**上層が優先**。
|
||||||
|
- 既存の `SystemPromptTemplate`(minijinja ベース)のローダを拡張し、テンプレート内から他のプロンプトを `{% include "coder" %}` / `{% import "planner" as p %}` のように参照できるようにする。
|
||||||
|
- 層の異なる同名プロンプトを合成するための include 先解決は上記優先順位に従う。
|
||||||
|
|
||||||
|
### 設定値のテンプレート参照は扱わない
|
||||||
|
|
||||||
|
`worker.max_tokens = "{{ env.INSOMNIA_MAX_TOKENS }}"` のような**設定値の中でテンプレートを展開する機能は本チケットの範囲外**とする。テンプレートエンジンはプロンプト本文の組み立てだけに限定する。設定値の動的化が必要になった時点で別チケットで検討する。
|
||||||
|
|
||||||
|
### プログラマティック Pod 作成 API
|
||||||
|
|
||||||
|
- `Pod::from_manifest(path)` の隣に、カスケード解決を経由する生成経路を追加する。イメージ:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 層を明示的に指定して最終形を得る
|
||||||
|
let manifest = PodFactory::new()
|
||||||
|
.with_user_config(user_config_path)? // absent OK
|
||||||
|
.with_project_config(project_root)? // absent OK
|
||||||
|
.with_overlay(overlay_toml_or_struct) // programmatic
|
||||||
|
.resolve()?; // -> PodManifest
|
||||||
|
Pod::from_manifest(manifest, store).await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
- 細かい形状は設計時に詰める(builder 型 vs 関数型、`overlay` を TOML 文字列か型付きか等)。
|
||||||
|
- CLI からは `insomnia spawn --overlay '...'` 相当で同じ経路を叩く想定。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
### カスケード基盤
|
||||||
|
|
||||||
|
- ユーザー設定・プロジェクト設定・プログラマティック overlay を順に重ねた結果が `PodManifest` として取れる。
|
||||||
|
- 各層が部分形を許容する(`pod.pwd` だけ書いてあっても良い等)。
|
||||||
|
- マージセマンティクスが**フィールドごとに定義**され、テストされる(スカラー / Option / マップ / リスト)。
|
||||||
|
- 層が全て空でも**ビルトインデフォルト単体で有効な manifest にならない**(少なくとも `pod.pwd` と `provider` と `scope.allow` は上位層で与える必要がある)。その旨を resolve 時のエラーで明示する。
|
||||||
|
|
||||||
|
### プロンプト資産ライブラリ
|
||||||
|
|
||||||
|
- 3層の検索パスでプロンプトファイルを解決できる。
|
||||||
|
- 同名プロンプトは上層優先で解決される。
|
||||||
|
- `SystemPromptTemplate` の minijinja `Environment` にカスタムローダを仕込み、`{% include "name" %}` / `{% import "name" as x %}` で資産を参照できる。
|
||||||
|
- プロンプト資産自体もテンプレートとして評価され、現行の `SystemPromptContext`(`now` / `cwd` / `scope` / `tools` / `files` 等)と同じ変数が見える。
|
||||||
|
|
||||||
|
### プログラマティック Pod 作成
|
||||||
|
|
||||||
|
- 既存の `Pod::from_manifest` を壊さず、追加経路として `PodFactory` 系の API を提供する。
|
||||||
|
- TUI / GUI / daemon 等の上位クライアントが、TOML ファイルパスではなく**オーバーレイ + 設定ディレクトリパス**を渡すだけで Pod を起動できる。
|
||||||
|
|
||||||
|
### ドキュメント
|
||||||
|
|
||||||
|
- カスケード層の優先順位・マージ規則を `docs/` にまとめる。
|
||||||
|
- ユーザー設定 / プロジェクト設定ファイルの**最小例**と**全オプション例**を残す。
|
||||||
|
|
||||||
|
## 設計で決めること
|
||||||
|
|
||||||
|
- **ユーザー設定のパス**: `~/.config/insomnia/config.toml` か、XDG 非準拠のパスも許容するか。環境変数で上書きできるか。
|
||||||
|
- **プロジェクト設定の場所**: プロジェクトルートの `.insomnia/config.toml` か、別の命名か。サブディレクトリから起動したときの discovery(上位ディレクトリ探索)の挙動。
|
||||||
|
- **プロジェクトルートの判定**: 明示指定 vs `.git` や `.insomnia/` で自動検出
|
||||||
|
- **preset の概念を入れるか**: 名前付きの overlay セット(例: `insomnia spawn coder`)を導入するか。導入する場合、preset はユーザー設定内に `[preset.coder]` として持つか、個別ファイル `~/.config/insomnia/presets/coder.toml` として持つか
|
||||||
|
- **リストフィールドのマージ方針**: 置換 only にするか、append 用の別フィールド (`scope.allow_extra` 等) を用意するか
|
||||||
|
- **ビルトインプロンプトの初期ラインナップ**: どの役割をデフォルトで同梱するか、どこに置くか(`crates/pod/assets/prompts/*.md` を `include_str!` で埋め込む等)
|
||||||
|
- **プロンプト資産のファイル形式**: `.md` か `.txt` か、拡張子省略可能にするか、フロントマター(YAML/TOML)で引数デフォルトを持たせるか
|
||||||
|
- **プロンプト include 時の context 伝搬**: 親テンプレートの変数を include 先でも見えるようにするか、明示的に `with` で渡させるか
|
||||||
|
- **エラー戦略**: 上層で書かれた未知フィールドや型ミスマッチをどこまで寛容に扱うか
|
||||||
|
- **既存の `Pod::from_manifest(path)` とのインターフェース整理**: 廃止するか、内部的に PodFactory に委譲するか
|
||||||
|
- **CLI コマンド名**: `insomnia spawn` / `insomnia pod new` / その他
|
||||||
|
|
||||||
|
## 完了条件
|
||||||
|
|
||||||
|
- `PodManifest` の最終形を層のマージで構築する `PodFactory`(または同等の仕組み)が実装され、マージセマンティクスの単体テストが通る。
|
||||||
|
- ユーザー設定・プロジェクト設定・プログラマティック overlay のすべての層を使う end-to-end テストで、Pod が TOML ファイルパスを一切渡さずに起動できる。
|
||||||
|
- プロンプト資産ライブラリを経由して system_prompt が組み立てられ、`{% include "ビルトイン名" %}` で同梱プロンプトを参照できることをテストで確認できる。
|
||||||
|
- ユーザー設定ファイル / プロジェクト設定ファイルのドキュメントが `docs/` に存在する。
|
||||||
|
- 既存の `Pod::from_manifest(path)` 経路が動き続ける(回帰させない)。
|
||||||
|
|
||||||
|
## 他チケットとの関係
|
||||||
|
|
||||||
|
- `tickets/native-gui-mvp.md`: 現状「manifest ファイルを選ぶ UI」を含むが、本チケット完了後はその UI が「preset 選択 + overlay 入力」に置き換わる想定。native-gui-mvp 実装時に本チケットの API を使うか、先に文字列パス渡しで済ませて後から差し替えるかは別途判断
|
||||||
|
- `tickets/tui-pod-spawn-ui.md`: 同上。Pod spawn UI は本チケットが提供する API の上に構築される
|
||||||
|
- `tickets/protocol-design.md`: Pod ↔ Client protocol 自体は変わらない。spawn 要求を protocol に載せるかどうかは protocol-design 側で検討
|
||||||
|
- `docs/system-prompt-template.md` / `crates/pod/src/system_prompt.rs`: プロンプト資産ライブラリはこの minijinja 基盤の拡張として実装される
|
||||||
|
|
||||||
|
## 範囲外
|
||||||
|
|
||||||
|
- **設定値の中のテンプレート展開**(`max_tokens = "{{ env.X }}"` のような動的値)。プロンプト本文のテンプレート展開のみを扱う
|
||||||
|
- **GUI 内での設定ファイル編集 UI**。編集は人間がエディタで TOML を書くだけ(あくまで「Pod 生成時に手書きしない」ことを目指す)
|
||||||
|
- **チーム共有・同期**。ユーザー設定とプロジェクト設定は各自・各リポジトリ単位で管理される
|
||||||
|
- **秘密情報管理**(API キー等)。既存の `api_key_file` 方式を維持する
|
||||||
|
- **設定値の型バリデーション強化**(JSON Schema など)。現行の serde ベースで十分な範囲に留める
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
# TUI 通知チャネル: Warn/Error をユーザーに可視化
|
# TUI 通知チャネル: Warn/Error をユーザーに可視化
|
||||||
|
|
||||||
|
## レビュー状態
|
||||||
|
|
||||||
|
初回レビュー実施済み。[tui-notification-channel.review.md](tui-notification-channel.review.md) を参照。
|
||||||
|
コア要件は達成。残る指摘は (1) `Notifier::buffer` の無制限成長、(2) TUI 側の表示強度(履歴行に紛れるか別立てで見落とさない位置に出すか)の 2 点で、いずれも user 判断待ち。
|
||||||
|
|
||||||
## 背景
|
## 背景
|
||||||
|
|
||||||
Pod/Worker 層は現在、通知すべき事象(compaction 失敗、AGENTS.md 読み取り失敗、ツール出力の切り詰め、将来追加される様々な前処理エラー等)をすべて `tracing::warn!` で出している。TUI はこのログを受け取る仕組みを持たないため、**ユーザーは何も気づかないまま Pod が縮退動作している状態**になりうる。
|
Pod/Worker 層は現在、通知すべき事象(compaction 失敗、AGENTS.md 読み取り失敗、ツール出力の切り詰め、将来追加される様々な前処理エラー等)をすべて `tracing::warn!` で出している。TUI はこのログを受け取る仕組みを持たないため、**ユーザーは何も気づかないまま Pod が縮退動作している状態**になりうる。
|
||||||
|
|
|
||||||
107
tickets/tui-notification-channel.review.md
Normal file
107
tickets/tui-notification-channel.review.md
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
# レビュー: TUI 通知チャネル
|
||||||
|
|
||||||
|
対象差分: `crates/pod/src/{notifier,pod,controller,agents_md,lib,socket_server}.rs`, `crates/llm-worker/src/worker.rs`, `crates/protocol/src/lib.rs`, `crates/tui/src/{app,ui}.rs`(いずれも未コミット)
|
||||||
|
|
||||||
|
## 要件達成状況
|
||||||
|
|
||||||
|
| 要件 | 状態 |
|
||||||
|
|---|---|
|
||||||
|
| Pod 層が型付き通知を発行する API を持つ | ✅ `Notifier::notify(level, source, message)`、`PodHandle::notify` / `Pod::notify` ラッパ |
|
||||||
|
| `tracing` とは別系統 | ✅ 既存 `tracing::warn!` は並存、通知は `Notifier` 経由のみ |
|
||||||
|
| 構造化型(level / source / message / timestamp) | ✅ `protocol::Notification` に全4項目。timestamp は unix ms の i64 |
|
||||||
|
| レベルは Warn / Error(Info は設計判断) | ✅ Info は除外(設計判断として妥当) |
|
||||||
|
| 発生源の列挙(Pod / Worker / Compactor / Tool 境界等) | ✅ `NotificationSource::{Pod, Worker, Compactor, AgentsMd}`。Tool 境界は Worker に集約 |
|
||||||
|
| TUI が受信して表示する | 🟡 表示はする(`MessageKind::NoticeWarn/Error` + 色 + bold)が、**ticket が想定した「一時表示・通知ペイン」レベルには到達していない**。後述 |
|
||||||
|
| Error と Warn の視覚的区別 | ✅ Yellow bold / Red bold + `[notice]` / `[notice error]` prefix |
|
||||||
|
| 通知履歴が見られる | 🟡 `output_queue` → scrollback に残るのみ。専用ペインでの閲覧は無い |
|
||||||
|
| 新着通知の一時表示 | 🟡 普通のメッセージ行として積まれるだけ、トースト / ステータスバー常駐は無し |
|
||||||
|
| 複数通知の重ね合わせ | 🟡 単純 append、特段の配慮は無し |
|
||||||
|
| 既存 `tracing::warn!` の置換(compaction 失敗) | ✅ `pod.rs` の mid-run / post-run 両経路で notify。並行して tracing も残る |
|
||||||
|
| 既存 `tracing::warn!` の置換(ツール出力切り詰め) | ✅ `worker.rs` が `on_warning` コールバック経由で Controller → Notifier に流す |
|
||||||
|
| 既存 `tracing::warn!` の置換(AGENTS.md) | ✅ `read_agents_md` が `AgentsMdResult { body, warnings }` を返し、`pod.rs` で notify |
|
||||||
|
|
||||||
|
**コア要件は達成**。「TUI の表示方法」は設計議論を含む項目で、ticket の "設計で決めること" に「トースト / ステータスバー / 通知ペイン / その組合せ」と列挙されていた部分を、実装者は**「履歴内の区別された行」に絞った**と読める。これは最小実装としては筋が通るが、ticket 本文の「見落としにくい位置に一時的に表示」という表現とは**乖離**があり、判断を user に返すべき。
|
||||||
|
|
||||||
|
## アーキテクチャ統合
|
||||||
|
|
||||||
|
### 良い点
|
||||||
|
|
||||||
|
- **`Notifier` の race-free 購読**: `subscribe_with_snapshot` が `buffer` の mutex を保持したまま `event_tx.subscribe()` を呼び、snapshot をクローンしてから返すことで、「通知が snapshot と live の両方に現れる」「どちらからも漏れる」の両方を同時に防いでいる。`notifier.rs` のテスト `subscribe_snapshot_and_live_do_not_overlap` で設計意図を lock-in している。late subscriber 対応は本チケットの肝であり、最もよく出来ている部分。
|
||||||
|
- **層の分離**: Worker は `on_warning(Box<dyn Fn(&str) + Send + Sync>)` というタイプ消去されたコールバックを受ける形にし、`protocol::Notification*` 型に依存していない。Controller 側で closure に notifier をキャプチャして橋渡しする。Worker が protocol に依存せずに済んでおり、llm-worker は低レベル基盤のままという方針(memory)とも整合。
|
||||||
|
- **`read_agents_md` の pure 分離**: 以前は `Option<String>` + 直接 `tracing::warn!` だったが、`AgentsMdResult { body, warnings }` を返す形に変更され、呼び出し側が副作用(notify)を担当する。pure function + 副作用集約の分離として綺麗。
|
||||||
|
- **既存 broadcast channel への相乗り**: `Event::Notification(Notification)` という新バリアントとして既存の `broadcast::Sender<Event>` に載せたことで、新しい通信経路を作らずに済んでいる。`socket_server` 側の配線変更も `subscribe_with_snapshot` の snapshot を prelude として書き出し、以降は既存 loop に合流する、という自然な形。
|
||||||
|
- **`Pod::attach_notifier` パターン**: Controller が Pod の construction 後に notifier を差し込む(`Pod::new` 自体は notifier を持たない)ため、Pod 直接 new の tests でも Notifier が不要。`None` のときは `Pod::notify` が no-op になる。
|
||||||
|
|
||||||
|
### 懸念 1: 🟡 `Notifier::buffer` が無制限に伸びる
|
||||||
|
|
||||||
|
```rust
|
||||||
|
struct Inner {
|
||||||
|
event_tx: broadcast::Sender<Event>,
|
||||||
|
buffer: Mutex<Vec<Notification>>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
セッションが長寿命かつ通知が頻発するケース(compaction 失敗がループするとか、ツール出力切り詰めが毎ターン起きるとか)で、`buffer` は単調増加する。新規クライアントが接続した瞬間に `subscribe_with_snapshot` が全部クローンするので、**1 接続あたりのメモリ消費と初期送信コストも比例して増える**。
|
||||||
|
|
||||||
|
ticket には明示的な上限要件は無いが、実運用での**メモリ健全性**として上限を設けるべき。たとえば:
|
||||||
|
|
||||||
|
- 上限件数 (例: 256 件) でリング化(最古から落とす)
|
||||||
|
- 時間窓(直近 N 分)
|
||||||
|
- 落とした件数だけ "[N older notifications elided]" の synthetic notification を先頭に置く
|
||||||
|
|
||||||
|
**判断**: 要検討。後続チケットとして切るか、本チケットの範囲で追補するか。
|
||||||
|
|
||||||
|
### 懸念 2: 🟡 TUI 側の表示が ticket 要件の minimum interpretation
|
||||||
|
|
||||||
|
ticket 要件:
|
||||||
|
> - 新着通知はユーザーが見落としにくい位置に**一時的**に表示される(トースト / ステータスバー等)
|
||||||
|
> - 履歴が見られる(キーバインドで通知ペインを開ける等)
|
||||||
|
|
||||||
|
実装:
|
||||||
|
- `MessageKind::NoticeWarn/Error` として通常のメッセージ行と同じ `output_queue` に積む
|
||||||
|
- Yellow/Red の bold + `[notice]` prefix で視覚的に区別
|
||||||
|
- トースト・ステータスバー常駐・通知ペインは無し
|
||||||
|
|
||||||
|
ユーザーが会話に集中していてスクロールが別位置にあった場合、新着通知は画面外に流れて気付けない可能性が高い。
|
||||||
|
|
||||||
|
一方、ticket の "設計で決めること" には 4 択が並んでおり、実装者が minimum viable として「履歴に色付きで積む」を選んだという解釈もできる。
|
||||||
|
|
||||||
|
**判断**: user が「これで良い」と言えば OK。「やはり見落とす」となれば別タスクとして追加実装(status 行 1 段拡張でラスト通知を pin する、など)が必要。
|
||||||
|
|
||||||
|
### 懸念 3: 🟢 `NotificationSource` に Pod 名が入らない
|
||||||
|
|
||||||
|
現状 `NotificationSource` はカテゴリだけ持ち、Pod 名や具体的な tool 名は `message` 本文に埋め込む設計。単一 Pod 前提ではこれで十分。将来 `tickets/native-gui-mvp.md` で GUI が複数 Pod を spawn するようになると「どの Pod の通知か」がメッセージパースでしか分からなくなる。
|
||||||
|
|
||||||
|
**判断**: 不問。複数 Pod 対応のチケット(将来)で `Notification` に Pod 名フィールドを追加するのが自然。現時点で先回りする必要は無い。
|
||||||
|
|
||||||
|
### 懸念 4: 🟢 `PodHandle::subscribe` の用途
|
||||||
|
|
||||||
|
以前は `handle.subscribe()` を直接呼んで broadcast::Receiver を得ていたところが、今は `handle.notifier.subscribe_with_snapshot()` 経由になった。`PodHandle::subscribe` 自体はまだ存在しており、socket_server 以外に呼び出し元があるかは未確認。無ければ将来削除可能。
|
||||||
|
|
||||||
|
**判断**: 不問。クリーンアップは別途。
|
||||||
|
|
||||||
|
## 完了条件照合
|
||||||
|
|
||||||
|
- [x] Pod 層が型付き通知を発行する API を持つ
|
||||||
|
- [x] TUI がその通知を受信して表示する
|
||||||
|
- [~] 履歴保存 — 一応 scrollback / `Notifier::buffer` には残るが、ticket 本文の「セッション単位で履歴を見られる」は minimal な達成
|
||||||
|
- [~] 手動テストで TUI 画面上に現れることの確認 — コードレベルでは通るが、実機確認の記述は無い(これは受け入れテストの話)
|
||||||
|
|
||||||
|
## テスト
|
||||||
|
|
||||||
|
- `notifier.rs` の 3 ケース(broadcast / late subscriber snapshot / snapshot-live non-overlap)は設計要点を的確にカバー
|
||||||
|
- `protocol/lib.rs` で `event_notification_format` が JSON 表現を lock-in
|
||||||
|
- `agents_md.rs` のテストは `AgentsMdResult` 変更に追従し、`non_utf8_surfaces_warning` が新規に追加されて warning 経路をカバー
|
||||||
|
- Controller / Pod / socket_server レベルの integration test は無し。`Notifier` の単体テストで十分と割り切った判断と見える
|
||||||
|
|
||||||
|
## 結論
|
||||||
|
|
||||||
|
**コア要件は達成、アーキテクチャは筋が良い**。特に race-free subscribe と層の分離は見事。
|
||||||
|
|
||||||
|
残る指摘:
|
||||||
|
|
||||||
|
1. 🟡 **buffer 無制限**: 実運用でメモリ単調増加の可能性。上限の検討要
|
||||||
|
2. 🟡 **TUI 表示の強度**: ticket 文面からは「通知は見落とされないべき」と読めるが、実装は履歴行に紛れる最小形。user 判断待ち
|
||||||
|
3. 🟢 **Pod 名フィールド** / 🟢 **`PodHandle::subscribe` の残存**: 不問
|
||||||
|
|
||||||
|
**受け入れ可否**: 指摘1・2について user 判断。採否次第で「このまま受け入れ」「buffer 上限だけ追補」「TUI 表示も強化」の 3 パスに分岐する。
|
||||||
Loading…
Reference in New Issue
Block a user