Pod操作ツール修正
This commit is contained in:
parent
5d63d0f6e2
commit
2af7089396
|
|
@ -17,7 +17,7 @@ use async_trait::async_trait;
|
||||||
use llm_worker::llm_client::types::{ContentPart, Item, Role};
|
use llm_worker::llm_client::types::{ContentPart, Item, Role};
|
||||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||||
use protocol::stream::{JsonLineReader, JsonLineWriter};
|
use protocol::stream::{JsonLineReader, JsonLineWriter};
|
||||||
use protocol::{Event, Method};
|
use protocol::{ErrorCode, Event, Method};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tokio::net::UnixStream;
|
use tokio::net::UnixStream;
|
||||||
|
|
||||||
|
|
@ -45,7 +45,8 @@ struct NameInput {
|
||||||
|
|
||||||
const SEND_TO_POD_DESCRIPTION: &str =
|
const SEND_TO_POD_DESCRIPTION: &str =
|
||||||
"Send a text message to a previously spawned Pod. The spawned Pod \
|
"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.";
|
use `ReadPodOutput` to fetch results afterwards.";
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
|
@ -71,9 +72,17 @@ impl Tool for SendToPodTool {
|
||||||
.await
|
.await
|
||||||
.ok_or_else(|| unknown_pod_err(&input.name))?;
|
.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
|
.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 {
|
Ok(ToolOutput {
|
||||||
summary: format!("sent message to `{}`", input.name),
|
summary: format!("sent message to `{}`", input.name),
|
||||||
|
|
@ -328,6 +337,51 @@ async fn connect_and_send(socket: &Path, method: &Method) -> std::io::Result<()>
|
||||||
Ok(())
|
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
|
/// Connect and ask the Pod for its conversation history. Skips
|
||||||
/// pre-History events (such as buffered notifications replayed to new
|
/// pre-History events (such as buffered notifications replayed to new
|
||||||
/// clients). Returns the raw JSON items as `serde_json::Value` since
|
/// 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 {
|
for part in content {
|
||||||
if let ContentPart::Text { text } = part {
|
if let ContentPart::Text { text } = part {
|
||||||
if !out.is_empty() {
|
if !out.is_empty() {
|
||||||
out.push('\n');
|
out.push_str("\n\n");
|
||||||
}
|
}
|
||||||
out.push_str(&text);
|
out.push_str(&text);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ use pod::runtime_dir::{RuntimeDir, SpawnedPodRecord};
|
||||||
use pod::scope_lock::{self, LockFileGuard};
|
use pod::scope_lock::{self, LockFileGuard};
|
||||||
use pod::spawned_pod_registry::SpawnedPodRegistry;
|
use pod::spawned_pod_registry::SpawnedPodRegistry;
|
||||||
use protocol::stream::{JsonLineReader, JsonLineWriter};
|
use protocol::stream::{JsonLineReader, JsonLineWriter};
|
||||||
use protocol::{Event, Greeting, Method};
|
use protocol::{ErrorCode, Event, Greeting, Method};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use tokio::net::UnixListener;
|
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
|
/// Pretend to be a spawned Pod that responds to `GetHistory` with a
|
||||||
/// fixed set of items. Accepts connections until the first one that
|
/// fixed set of items. Accepts connections until the first one that
|
||||||
/// delivers a `GetHistory` method; earlier probes (empty accepts) and
|
/// 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() {
|
async fn send_to_pod_delivers_run_method() {
|
||||||
let (tmp, registry, _rd) = setup_registry().await;
|
let (tmp, registry, _rd) = setup_registry().await;
|
||||||
let (socket, listener) = bind_mock_socket(tmp.path(), "child").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(®istry, "child", &socket, tmp.path()).await;
|
register_child(®istry, "child", &socket, tmp.path()).await;
|
||||||
|
|
||||||
let def = send_to_pod_tool(registry);
|
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}");
|
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(®istry, "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
|
// ReadPodOutput
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@
|
||||||
|
|
||||||
出力:
|
出力:
|
||||||
- 前回読んだ位置以降の assistant テキスト出力
|
- 前回読んだ位置以降の assistant テキスト出力
|
||||||
- 現在の状態(`running` / `idle` / `stopped`)
|
- 現在の到達性(`alive` / `stopped`)
|
||||||
|
|
||||||
内部動作:
|
内部動作:
|
||||||
- spawn 記録から socket path を引く
|
- spawn 記録から socket path を引く
|
||||||
|
|
@ -53,11 +53,11 @@
|
||||||
- `name`: 対象の Pod
|
- `name`: 対象の Pod
|
||||||
|
|
||||||
出力:
|
出力:
|
||||||
- 終了確認
|
- 終了要求を送った旨
|
||||||
- 回収された scope の要約
|
- 回収された scope の要約
|
||||||
|
|
||||||
内部動作:
|
内部動作:
|
||||||
- socket に接続 → `Method::Shutdown` 送信 → 終了確認受信 → 切断
|
- socket に接続 → `Method::Shutdown` 送信(応答は待たない)→ 切断
|
||||||
- scope lock file を flock → 対象の allocation 削除 → spawner の deny を解除 → unlock
|
- scope lock file を flock → 対象の allocation 削除 → spawner の deny を解除 → unlock
|
||||||
- spawn 記録から対象を削除
|
- spawn 記録から対象を削除
|
||||||
|
|
||||||
|
|
@ -94,3 +94,6 @@
|
||||||
|
|
||||||
- コールバック通知は `tickets/pod-callback.md`
|
- コールバック通知は `tickets/pod-callback.md`
|
||||||
- Pod ネットワークの GUI / TUI 可視化
|
- Pod ネットワークの GUI / TUI 可視化
|
||||||
|
- spawner プロセス再起動後の `spawned_pods.json` からの復旧(現状は write-through のみ)
|
||||||
|
- `ReadPodOutput` カーソルの永続化(インメモリのみ、再起動で 0 に戻る)
|
||||||
|
- Pod の詳細ステータス(`running` / `idle`)を `Event::History` に含める拡張
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user