feat: Pos処理の非同期化・Busy状態の削除
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
//! Compact lifecycle `Event` broadcasting.
|
||||
//!
|
||||
//! Covers three paths:
|
||||
//! - `try_post_run_compact` success → `CompactStart + CompactDone`
|
||||
//! - `try_post_run_compact` failure → `CompactStart + CompactFailed`
|
||||
//! - `try_pre_run_compact` success → `CompactStart + CompactDone`
|
||||
//! - `try_pre_run_compact` failure → `CompactStart + CompactFailed`
|
||||
//! - mid-turn `do_compact_and_resume` success → `CompactStart + CompactDone`
|
||||
//! (driven by `compact_request_threshold` → `PreRequestAction::Yield`)
|
||||
|
||||
@@ -96,7 +96,7 @@ fn write_summary_tool_use_events(call_id: &str, text: &str) -> Vec<LlmEvent> {
|
||||
]
|
||||
}
|
||||
|
||||
// A low compact_threshold guarantees `try_post_run_compact` will fire
|
||||
// A low compact_threshold guarantees `try_pre_run_compact` will fire
|
||||
// the first time we check after a run.
|
||||
const POST_RUN_MANIFEST_TOML: &str = r#"
|
||||
[pod]
|
||||
@@ -228,7 +228,7 @@ async fn compact_broadcasts_only_new_system_messages_not_retained_ones() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_run_compact_success_broadcasts_start_and_done() {
|
||||
async fn pre_run_compact_success_broadcasts_start_and_done() {
|
||||
// Responses: (1) first run returns short text, (2) compact worker
|
||||
// emits write_summary then closes (two LLM calls inside the compact
|
||||
// worker: one for write_summary, one that the compact loop consumes
|
||||
@@ -247,7 +247,7 @@ async fn post_run_compact_success_broadcasts_start_and_done() {
|
||||
// Drain run events so only compact events remain in `rx`.
|
||||
let _ = drain(&mut rx);
|
||||
|
||||
pod.try_post_run_compact().await.unwrap();
|
||||
pod.try_pre_run_compact().await;
|
||||
|
||||
let events = drain(&mut rx);
|
||||
let kinds: Vec<&str> = events
|
||||
@@ -412,7 +412,7 @@ async fn compact_resets_extract_pointer_so_phase1_can_fire_again() {
|
||||
|
||||
// Compact runs. Without the fix the in-memory pointer would still
|
||||
// reference the old session's history_len.
|
||||
pod.try_post_run_compact().await.unwrap();
|
||||
pod.try_pre_run_compact().await;
|
||||
assert!(
|
||||
pod.extract_pointer().is_none(),
|
||||
"extract_pointer must be reset to None after compact (matches cold-restore on the new session)"
|
||||
@@ -463,7 +463,7 @@ async fn extract_threshold_zero_is_disabled() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_run_compact_failure_broadcasts_start_and_failed() {
|
||||
async fn pre_run_compact_failure_broadcasts_start_and_failed() {
|
||||
// Only the first run has a response. Compaction will run the
|
||||
// compact worker which immediately exhausts the mock → failure.
|
||||
let client = MockClient::new(vec![single_text_events("hi")]);
|
||||
@@ -476,7 +476,7 @@ async fn post_run_compact_failure_broadcasts_start_and_failed() {
|
||||
let _ = drain(&mut rx);
|
||||
|
||||
// Best-effort: returns Ok(()) even on failure, but emits CompactFailed.
|
||||
pod.try_post_run_compact().await.unwrap();
|
||||
pod.try_pre_run_compact().await;
|
||||
|
||||
let events = drain(&mut rx);
|
||||
let kinds: Vec<&str> = events
|
||||
@@ -497,3 +497,85 @@ async fn post_run_compact_failure_broadcasts_start_and_failed() {
|
||||
"unexpected CompactDone in {kinds:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detached post-run memory jobs (`spawn_post_run_memory_jobs` /
|
||||
// `wait_for_memory_jobs`). Covers the detach round-trip and the structural
|
||||
// invariant that the cloned memory-task Pod shares `SessionHead` with the
|
||||
// source Pod, so that `save_extension` from the background extract does not
|
||||
// leave the next turn's `save_user_input` looking at a stale head_hash.
|
||||
|
||||
const EXTRACT_NO_COMPACT_MANIFEST: &str = r#"
|
||||
[pod]
|
||||
name = "test-pod"
|
||||
pwd = "./"
|
||||
|
||||
[model]
|
||||
scheme = "anthropic"
|
||||
model_id = "test-model"
|
||||
|
||||
[worker]
|
||||
max_tokens = 100
|
||||
|
||||
[memory]
|
||||
extract_threshold = 1
|
||||
|
||||
[[scope.allow]]
|
||||
target = "./"
|
||||
permission = "write"
|
||||
"#;
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_and_wait_drives_extract_to_completion() {
|
||||
let client = MockClient::new(vec![
|
||||
text_events_with_usage("hi", 1000),
|
||||
write_extracted_tool_use_events("ec1"),
|
||||
single_text_events("done"),
|
||||
]);
|
||||
let mut pod = make_pod_with_manifest(EXTRACT_NO_COMPACT_MANIFEST, client).await;
|
||||
|
||||
pod.run_text("first").await.unwrap();
|
||||
assert!(
|
||||
pod.extract_pointer().is_none(),
|
||||
"extract has not run yet — pointer must be None"
|
||||
);
|
||||
|
||||
pod.spawn_post_run_memory_jobs();
|
||||
pod.wait_for_memory_jobs().await;
|
||||
|
||||
assert!(
|
||||
pod.extract_pointer().is_some(),
|
||||
"spawn + wait must complete extract; pointer should be set"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detached_extract_does_not_fork_session_log() {
|
||||
// Source pod and the cloned memory-task pod share `SessionHead` via
|
||||
// `Arc<AsyncMutex<_>>`. The detached extract advances head_hash through
|
||||
// `save_extension`; the next `run` must see that same head_hash so
|
||||
// `ensure_head_or_fork` does not spawn a new session.
|
||||
let client = MockClient::new(vec![
|
||||
text_events_with_usage("hi", 1000),
|
||||
write_extracted_tool_use_events("ec1"),
|
||||
single_text_events("done"),
|
||||
text_events_with_usage("ok", 1000),
|
||||
]);
|
||||
let mut pod = make_pod_with_manifest(EXTRACT_NO_COMPACT_MANIFEST, client).await;
|
||||
|
||||
pod.run_text("first").await.unwrap();
|
||||
let session_before = pod.session_id();
|
||||
|
||||
pod.spawn_post_run_memory_jobs();
|
||||
pod.wait_for_memory_jobs().await;
|
||||
|
||||
pod.run_text("second").await.unwrap();
|
||||
let session_after = pod.session_id();
|
||||
|
||||
assert_eq!(
|
||||
session_before, session_after,
|
||||
"detached extract's save_extension and the next turn's save_user_input \
|
||||
must share head_hash through SessionHead — a fork here means the clone \
|
||||
carried its own head_hash"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ async fn wait_for_status(handle: &PodHandle, status: PodStatus) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_end_enters_busy_until_post_run_finishes_and_broadcasts_status() {
|
||||
async fn run_end_returns_to_idle_without_busy_status() {
|
||||
let client = MockClient::new(simple_text_events());
|
||||
let pod = make_pod(client).await;
|
||||
let handle = spawn_controller(pod).await;
|
||||
@@ -182,7 +182,7 @@ async fn run_end_enters_busy_until_post_run_finishes_and_broadcasts_status() {
|
||||
handle.send(Method::run_text("Hello")).await.unwrap();
|
||||
|
||||
let mut saw_run_end = false;
|
||||
let mut saw_busy_status = false;
|
||||
let mut saw_idle_status = false;
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -191,10 +191,8 @@ async fn run_end_enters_busy_until_post_run_finishes_and_broadcasts_status() {
|
||||
Ok(Event::RunEnd { result: protocol::RunResult::Finished }) => {
|
||||
saw_run_end = true;
|
||||
}
|
||||
Ok(Event::Status {
|
||||
status: PodStatus::Busy,
|
||||
}) if saw_run_end => {
|
||||
saw_busy_status = true;
|
||||
Ok(Event::Status { status: PodStatus::Idle }) if saw_run_end => {
|
||||
saw_idle_status = true;
|
||||
break;
|
||||
}
|
||||
Ok(_) => {}
|
||||
@@ -207,10 +205,10 @@ async fn run_end_enters_busy_until_post_run_finishes_and_broadcasts_status() {
|
||||
|
||||
assert!(saw_run_end, "expected RunEnd::Finished");
|
||||
assert!(
|
||||
saw_busy_status,
|
||||
"expected busy status immediately after RunEnd"
|
||||
saw_idle_status,
|
||||
"expected idle status immediately after RunEnd"
|
||||
);
|
||||
wait_for_status(&handle, PodStatus::Idle).await;
|
||||
assert_eq!(handle.shared_state.get_status(), PodStatus::Idle);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -237,51 +235,6 @@ async fn attach_history_includes_current_status() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pause_while_busy_is_idempotent_not_not_running() {
|
||||
let client = MockClient::new(simple_text_events());
|
||||
let pod = make_pod(client).await;
|
||||
let handle = spawn_controller(pod).await;
|
||||
let mut rx = handle.subscribe();
|
||||
|
||||
handle.send(Method::run_text("Hello")).await.unwrap();
|
||||
|
||||
let mut saw_busy = false;
|
||||
let mut saw_idle = false;
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||
loop {
|
||||
tokio::select! {
|
||||
event = rx.recv() => {
|
||||
match event {
|
||||
Ok(Event::RunEnd { .. }) => {
|
||||
handle.send(Method::Pause).await.unwrap();
|
||||
}
|
||||
Ok(Event::Status { status: PodStatus::Busy }) => {
|
||||
saw_busy = true;
|
||||
}
|
||||
Ok(Event::Status { status: PodStatus::Idle }) if saw_busy => {
|
||||
saw_idle = true;
|
||||
break;
|
||||
}
|
||||
Ok(Event::Error {
|
||||
code: protocol::ErrorCode::NotRunning,
|
||||
..
|
||||
}) if saw_busy && !saw_idle => {
|
||||
panic!("Pause while Busy should be an idempotent no-op");
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
_ = tokio::time::sleep_until(deadline) => break,
|
||||
}
|
||||
}
|
||||
|
||||
assert!(saw_busy, "expected Busy status");
|
||||
assert!(saw_idle, "expected final Idle status");
|
||||
assert_eq!(handle.shared_state.get_status(), PodStatus::Idle);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_state_starts_idle() {
|
||||
let client = MockClient::new(simple_text_events());
|
||||
|
||||
Reference in New Issue
Block a user