Pod操作ツール修正

This commit is contained in:
Keisuke Hirata 2026-04-19 06:40:45 +09:00
parent 5d63d0f6e2
commit 2af7089396
3 changed files with 120 additions and 10 deletions

View File

@ -17,7 +17,7 @@ use async_trait::async_trait;
use llm_worker::llm_client::types::{ContentPart, Item, Role};
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use protocol::stream::{JsonLineReader, JsonLineWriter};
use protocol::{Event, Method};
use protocol::{ErrorCode, Event, Method};
use serde::Deserialize;
use tokio::net::UnixStream;
@ -45,7 +45,8 @@ struct NameInput {
const SEND_TO_POD_DESCRIPTION: &str =
"Send a text message to a previously spawned Pod. The spawned Pod \
processes it as a user turn. Does not wait for the Pod's response \
processes it as a user turn. Fails if the Pod is already executing a \
turn retry after it finishes. Does not wait for the turn to complete; \
use `ReadPodOutput` to fetch results afterwards.";
#[derive(Debug, Deserialize, schemars::JsonSchema)]
@ -71,9 +72,17 @@ impl Tool for SendToPodTool {
.await
.ok_or_else(|| unknown_pod_err(&input.name))?;
connect_and_send(&record.socket_path, &Method::Run { input: input.message })
send_run_and_confirm(&record.socket_path, input.message)
.await
.map_err(|e| ToolError::ExecutionFailed(format!("send to `{}`: {e}", input.name)))?;
.map_err(|e| match e {
SendRunError::AlreadyRunning => ToolError::ExecutionFailed(format!(
"pod `{}` is already running a turn; wait for it to finish and retry",
input.name
)),
SendRunError::Io(msg) => {
ToolError::ExecutionFailed(format!("send to `{}`: {msg}", input.name))
}
})?;
Ok(ToolOutput {
summary: format!("sent message to `{}`", input.name),
@ -328,6 +337,51 @@ async fn connect_and_send(socket: &Path, method: &Method) -> std::io::Result<()>
Ok(())
}
/// Failure modes distinguished by `SendToPod`.
enum SendRunError {
/// Target Pod responded with `Error { AlreadyRunning }` — the
/// caller can retry once the current turn ends.
AlreadyRunning,
/// Any other failure (connect / write / read / unexpected EOF).
Io(String),
}
/// Write `Method::Run` to the target and read back events until we see
/// either `TurnStart` (accepted) or `Error { AlreadyRunning }`
/// (rejected). Any replayed notifications that precede the response are
/// skipped. Times out per-read so a stuck Pod doesn't hang the tool.
async fn send_run_and_confirm(socket: &Path, input: String) -> Result<(), SendRunError> {
let stream = tokio::time::timeout(SOCKET_OP_TIMEOUT, UnixStream::connect(socket))
.await
.map_err(|_| SendRunError::Io("connect timed out".into()))?
.map_err(|e| SendRunError::Io(format!("connect: {e}")))?;
let (r, w) = stream.into_split();
let mut writer = JsonLineWriter::new(w);
let mut reader = JsonLineReader::new(r);
tokio::time::timeout(SOCKET_OP_TIMEOUT, writer.write(&Method::Run { input }))
.await
.map_err(|_| SendRunError::Io("write timed out".into()))?
.map_err(|e| SendRunError::Io(format!("write: {e}")))?;
loop {
let event = tokio::time::timeout(SOCKET_OP_TIMEOUT, reader.next::<Event>())
.await
.map_err(|_| SendRunError::Io("read timed out".into()))?
.map_err(|e| SendRunError::Io(format!("read: {e}")))?;
match event {
Some(Event::Error {
code: ErrorCode::AlreadyRunning,
..
}) => return Err(SendRunError::AlreadyRunning),
Some(Event::TurnStart { .. }) => return Ok(()),
// Notifications and other pre-turn events are replayed to
// new subscribers; keep reading until the controller's
// response to our `Run` shows up.
Some(_) => continue,
None => return Err(SendRunError::Io("connection closed before response".into())),
}
}
}
/// Connect and ask the Pod for its conversation history. Skips
/// pre-History events (such as buffered notifications replayed to new
/// clients). Returns the raw JSON items as `serde_json::Value` since
@ -384,7 +438,7 @@ fn extract_assistant_text(items: &[serde_json::Value]) -> String {
for part in content {
if let ContentPart::Text { text } = part {
if !out.is_empty() {
out.push('\n');
out.push_str("\n\n");
}
out.push_str(&text);
}

View File

@ -21,7 +21,7 @@ use pod::runtime_dir::{RuntimeDir, SpawnedPodRecord};
use pod::scope_lock::{self, LockFileGuard};
use pod::spawned_pod_registry::SpawnedPodRegistry;
use protocol::stream::{JsonLineReader, JsonLineWriter};
use protocol::{Event, Greeting, Method};
use protocol::{ErrorCode, Event, Greeting, Method};
use serde_json::json;
use tempfile::TempDir;
use tokio::net::UnixListener;
@ -94,6 +94,26 @@ fn accept_one_method(listener: UnixListener) -> JoinHandle<Option<Method>> {
})
}
/// Accept one connection, read one `Method`, then write `response`
/// back. Used by `SendToPod` tests to mock the real controller's
/// `TurnStart` acknowledgement (or its `AlreadyRunning` rejection).
fn accept_method_and_respond(
listener: UnixListener,
response: Event,
) -> JoinHandle<Option<Method>> {
tokio::spawn(async move {
let (stream, _) = listener.accept().await.ok()?;
let (r, w) = stream.into_split();
let mut reader = JsonLineReader::new(r);
let mut writer = JsonLineWriter::new(w);
let method = reader.next::<Method>().await.ok().flatten();
if method.is_some() {
let _ = writer.write(&response).await;
}
method
})
}
/// Pretend to be a spawned Pod that responds to `GetHistory` with a
/// fixed set of items. Accepts connections until the first one that
/// delivers a `GetHistory` method; earlier probes (empty accepts) and
@ -152,7 +172,9 @@ fn assistant(text: &str) -> Item {
async fn send_to_pod_delivers_run_method() {
let (tmp, registry, _rd) = setup_registry().await;
let (socket, listener) = bind_mock_socket(tmp.path(), "child").await;
let received = accept_one_method(listener);
// Mock the controller's accept path: after reading the method,
// ack with `TurnStart` so `SendToPod`'s confirmation loop succeeds.
let received = accept_method_and_respond(listener, Event::TurnStart { turn: 1 });
register_child(&registry, "child", &socket, tmp.path()).await;
let def = send_to_pod_tool(registry);
@ -178,6 +200,37 @@ async fn send_to_pod_errors_on_unknown_pod() {
assert!(err.to_string().contains("no spawned pod"), "{err}");
}
#[tokio::test]
async fn send_to_pod_errors_when_pod_already_running() {
let (tmp, registry, _rd) = setup_registry().await;
let (socket, listener) = bind_mock_socket(tmp.path(), "child").await;
// Respond with the same `Error { AlreadyRunning }` that the real
// controller emits when `Method::Run` arrives during RUNNING.
let received = accept_method_and_respond(
listener,
Event::Error {
code: ErrorCode::AlreadyRunning,
message: "Pod is already executing a turn".into(),
},
);
register_child(&registry, "child", &socket, tmp.path()).await;
let def = send_to_pod_tool(registry);
let (_meta, tool) = def();
let input = json!({ "name": "child", "message": "hi" }).to_string();
let err = tool.execute(&input).await.unwrap_err();
assert!(
err.to_string().contains("already running"),
"expected AlreadyRunning wording: {err}"
);
// Ensure the listener was in fact hit with a Method::Run before the
// rejection path fired — otherwise we'd be asserting on an error
// that came from a connect failure.
let method = received.await.unwrap().expect("expected a method");
assert!(matches!(method, Method::Run { .. }));
}
// ---------------------------------------------------------------------------
// ReadPodOutput
// ---------------------------------------------------------------------------

View File

@ -38,7 +38,7 @@
出力:
- 前回読んだ位置以降の assistant テキスト出力
- 現在の状態(`running` / `idle` / `stopped`
- 現在の到達性(`alive` / `stopped`
内部動作:
- spawn 記録から socket path を引く
@ -53,11 +53,11 @@
- `name`: 対象の Pod
出力:
- 終了確認
- 終了要求を送った旨
- 回収された scope の要約
内部動作:
- socket に接続 → `Method::Shutdown` 送信 → 終了確認受信 → 切断
- socket に接続 → `Method::Shutdown` 送信(応答は待たない)→ 切断
- scope lock file を flock → 対象の allocation 削除 → spawner の deny を解除 → unlock
- spawn 記録から対象を削除
@ -94,3 +94,6 @@
- コールバック通知は `tickets/pod-callback.md`
- Pod ネットワークの GUI / TUI 可視化
- spawner プロセス再起動後の `spawned_pods.json` からの復旧(現状は write-through のみ)
- `ReadPodOutput` カーソルの永続化(インメモリのみ、再起動で 0 に戻る)
- Pod の詳細ステータス(`running` / `idle`)を `Event::History` に含める拡張