interceptorの修正

This commit is contained in:
Keisuke Hirata 2026-04-18 17:19:59 +09:00
parent 79f342ca60
commit 84a8bd099b
13 changed files with 952 additions and 448 deletions

View File

@ -1,10 +1,11 @@
- [ ] テスト設計 → [tickets/test-design.md](tickets/test-design.md)
- [ ] ツール設計
- [ ] Bash ツール (Permission 層と統合) → [tickets/bash-tool.md](tickets/bash-tool.md)
- [ ] 複数 Pod 間の Scope 排他制御 → [tickets/scope-exclusion.md](tickets/scope-exclusion.md)
- [ ] Compact の改善(要約品質 + 挙動詳細) → [tickets/compact-improvements.md](tickets/compact-improvements.md)
- [ ] Protocol の設計 → [tickets/protocol-design.md](tickets/protocol-design.md)
- [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md)
- [ ] Hook と Interceptor の責務分離 → [tickets/hook-interceptor-separation.md](tickets/hook-interceptor-separation.md)
- [ ] Method::Notify: システム起点のコンテキスト注入 → [tickets/method-notify.md](tickets/method-notify.md)
- [ ] Pod オーケストレーション: LLM によるマルチエージェント分業 → [tickets/pod-orchestration.md](tickets/pod-orchestration.md)
- [ ] ネイティブ GUI クライアント MVP → [tickets/native-gui-mvp.md](tickets/native-gui-mvp.md)
- [ ] TUI 拡充

View File

@ -1,76 +0,0 @@
//! CompactInterceptor — wraps HookInterceptor with urgent compaction check.
//!
//! Decorator that delegates all [`Interceptor`] methods to the inner
//! `HookInterceptor`, then adds a token-count check in `pre_llm_request`.
//! When `last_input_tokens` exceeds the turn threshold, returns
//! `PreRequestAction::Yield` so the Worker exits the turn loop cleanly
//! with `WorkerResult::Yielded` and Pod can perform compaction.
use std::sync::Arc;
use async_trait::async_trait;
use llm_worker::Item;
use llm_worker::interceptor::{
Interceptor, PostToolAction, PreRequestAction, PreToolAction, PromptAction, ToolCallInfo,
ToolResultInfo, TurnEndAction,
};
use tracing::info;
use crate::compact_state::CompactState;
use crate::hook_interceptor::HookInterceptor;
/// Interceptor that wraps HookInterceptor and adds between-turns
/// compaction threshold check.
pub(crate) struct CompactInterceptor {
inner: HookInterceptor,
state: Arc<CompactState>,
}
impl CompactInterceptor {
pub(crate) fn new(inner: HookInterceptor, state: Arc<CompactState>) -> Self {
Self { inner, state }
}
}
#[async_trait]
impl Interceptor for CompactInterceptor {
async fn on_prompt_submit(&self, item: &mut Item) -> PromptAction {
self.inner.on_prompt_submit(item).await
}
async fn pre_llm_request(&self, context: &mut Vec<Item>) -> PreRequestAction {
// Step 1: Delegate to inner hooks first.
let inner_action = self.inner.pre_llm_request(context).await;
if !matches!(inner_action, PreRequestAction::Continue) {
return inner_action;
}
// Step 2: Check between-turns compaction threshold.
if !self.state.is_disabled() && self.state.exceeds_turn() {
info!(
input_tokens = self.state.last_input_tokens(),
threshold = self.state.turn_threshold(),
"Between-turns compaction threshold exceeded, yielding"
);
return PreRequestAction::Yield;
}
PreRequestAction::Continue
}
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
self.inner.pre_tool_call(info).await
}
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction {
self.inner.post_tool_call(info).await
}
async fn on_turn_end(&self, history: &[Item]) -> TurnEndAction {
self.inner.on_turn_end(history).await
}
async fn on_abort(&self, reason: &str) {
self.inner.on_abort(reason).await;
}
}

View File

@ -1,18 +1,95 @@
//! Pod-layer hook infrastructure
//!
//! Provides the `Hook<E>` trait and `HookRegistry` for orchestration hooks
//! that govern control-flow decisions in the Worker execution loop.
//! Hooks are the **public** orchestration extension point. They receive
//! read-only summary information about each event in the Worker
//! execution loop and return a control-flow action
//! (continue / skip / abort / pause).
//!
//! The type system (`HookEventKind` / `Hook<E>`) mirrors the pattern
//! originally in llm-worker, now at the insomnia layer where orchestration
//! concerns belong.
//! Hooks intentionally cannot mutate the Worker's context, history, tool
//! call, or tool result. Internal mechanisms that need such access (e.g.
//! compaction, notification injection, output truncation) implement
//! `llm_worker::Interceptor` directly inside Pod, never via this trait.
//!
//! This separation lets Hooks be exposed safely to user-facing
//! extension surfaces (scripting, plugins) in the future without
//! exposing the underlying mutable state.
use async_trait::async_trait;
use llm_worker::Item;
use llm_worker::tool::ToolOutput;
use llm_worker::interceptor::{
PostToolAction, PreRequestAction, PreToolAction, PromptAction, ToolCallInfo, ToolResultInfo,
TurnEndAction,
PostToolAction, PreRequestAction, PreToolAction, PromptAction, TurnEndAction,
};
use serde_json::Value;
// =============================================================================
// Hook input summary types (read-only)
// =============================================================================
/// Information passed to `OnPromptSubmit` hooks.
pub struct PromptSubmitInfo {
/// Concatenated text content of the user's input message.
pub input_text: String,
/// 0-based turn index this prompt opens.
pub turn_index: usize,
}
/// Information passed to `PreLlmRequest` hooks.
pub struct PreRequestInfo {
/// Number of items currently in the Worker context.
pub item_count: usize,
/// Most recently observed `input_tokens` from the LLM provider.
/// `None` when the Pod has no compaction state attached, or when
/// no LLM call has completed yet.
pub estimated_tokens: Option<u64>,
/// Current turn index (0-based).
pub turn_index: usize,
/// Tool calls already executed in this turn.
pub tool_calls_this_turn: usize,
}
/// Information passed to `PreToolCall` hooks.
pub struct ToolCallSummary {
/// Provider-assigned tool call id.
pub call_id: String,
/// Registered tool name.
pub tool_name: String,
/// Tool arguments as a JSON value (cloned).
///
/// LLM-generated arguments are bounded by max_tokens, so cloning
/// is cheap relative to tool execution. Structural access is
/// required for permission decisions (e.g. inspecting a `path`
/// field), which a stringified preview would not support.
pub arguments: Value,
}
/// Information passed to `PostToolCall` hooks.
pub struct ToolResultSummary {
/// Provider-assigned tool call id this result corresponds to.
pub call_id: String,
/// Registered tool name.
pub tool_name: String,
/// Whether the tool reported an error.
pub is_error: bool,
/// Tool output (`summary` always present, `content` may be `None`).
pub output: ToolOutput,
}
/// Information passed to `OnTurnEnd` hooks.
pub struct TurnEndInfo {
/// Turn that just ended (0-based).
pub turn_index: usize,
/// Tool calls executed in this turn.
pub tool_calls_count: usize,
/// Preview of the assistant's final text in this turn.
/// Truncated at a UTF-8 boundary; empty when no assistant text exists.
pub final_text_preview: String,
}
/// Information passed to `OnAbort` hooks.
pub struct AbortInfo {
/// Reason supplied by the aborter.
pub reason: String,
}
// =============================================================================
// Hook Event Kinds
@ -20,17 +97,15 @@ use llm_worker::interceptor::{
/// Marker trait for hook event kinds.
///
/// Each event kind specifies its input (passed mutably to hooks) and
/// output (the control-flow action returned by hooks).
/// Each event kind specifies its read-only input and the control-flow
/// action returned by hooks.
pub trait HookEventKind: Send + Sync + 'static {
/// Mutable input passed to the hook.
type Input;
/// Read-only input passed to the hook.
type Input: Send + Sync;
/// Control-flow action returned by the hook.
type Output;
}
// --- Event kind markers ---
/// After receiving user input, before adding to history.
pub struct OnPromptSubmit;
/// Before each LLM request.
@ -45,32 +120,32 @@ pub struct OnTurnEnd;
pub struct OnAbort;
impl HookEventKind for OnPromptSubmit {
type Input = Item;
type Input = PromptSubmitInfo;
type Output = PromptAction;
}
impl HookEventKind for PreLlmRequest {
type Input = Vec<Item>;
type Input = PreRequestInfo;
type Output = PreRequestAction;
}
impl HookEventKind for PreToolCall {
type Input = ToolCallInfo;
type Input = ToolCallSummary;
type Output = PreToolAction;
}
impl HookEventKind for PostToolCall {
type Input = ToolResultInfo;
type Input = ToolResultSummary;
type Output = PostToolAction;
}
impl HookEventKind for OnTurnEnd {
type Input = Vec<Item>;
type Input = TurnEndInfo;
type Output = TurnEndAction;
}
impl HookEventKind for OnAbort {
type Input = String;
type Input = AbortInfo;
type Output = ();
}
@ -80,13 +155,13 @@ impl HookEventKind for OnAbort {
/// Async hook for a specific event kind.
///
/// Hooks receive mutable access to the event's input and return a
/// control-flow action. Multiple hooks can be registered per event;
/// they are evaluated in registration order and short-circuit on the
/// first non-Continue result.
/// Hooks receive a shared reference to the event's read-only input
/// and return a control-flow action. Multiple hooks can be registered
/// per event; they are evaluated in registration order and
/// short-circuit on the first non-Continue (or non-Finish) result.
#[async_trait]
pub trait Hook<E: HookEventKind>: Send + Sync {
async fn call(&self, input: &mut E::Input) -> E::Output;
async fn call(&self, input: &E::Input) -> E::Output;
}
// =============================================================================

View File

@ -1,87 +0,0 @@
//! HookInterceptor — bridges Pod-layer hooks to Worker's Interceptor trait.
use std::sync::Arc;
use async_trait::async_trait;
use llm_worker::Item;
use llm_worker::interceptor::{
Interceptor, PostToolAction, PreRequestAction, PreToolAction, PromptAction, ToolCallInfo,
ToolResultInfo, TurnEndAction,
};
use crate::hook::HookRegistry;
/// An `Interceptor` implementation that delegates to a `HookRegistry`.
///
/// Each method iterates the registered hooks in order and short-circuits
/// on the first non-Continue (or non-Finish) result.
pub(crate) struct HookInterceptor {
registry: Arc<HookRegistry>,
}
impl HookInterceptor {
pub(crate) fn new(registry: Arc<HookRegistry>) -> Self {
Self { registry }
}
}
#[async_trait]
impl Interceptor for HookInterceptor {
async fn on_prompt_submit(&self, item: &mut Item) -> PromptAction {
for hook in &self.registry.on_prompt_submit {
let action = hook.call(item).await;
if !matches!(action, PromptAction::Continue) {
return action;
}
}
PromptAction::Continue
}
async fn pre_llm_request(&self, context: &mut Vec<Item>) -> PreRequestAction {
for hook in &self.registry.pre_llm_request {
let action = hook.call(context).await;
if !matches!(action, PreRequestAction::Continue) {
return action;
}
}
PreRequestAction::Continue
}
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
for hook in &self.registry.pre_tool_call {
let action = hook.call(info).await;
if !matches!(action, PreToolAction::Continue) {
return action;
}
}
PreToolAction::Continue
}
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction {
for hook in &self.registry.post_tool_call {
let action = hook.call(info).await;
if !matches!(action, PostToolAction::Continue) {
return action;
}
}
PostToolAction::Continue
}
async fn on_turn_end(&self, history: &[Item]) -> TurnEndAction {
let mut history_vec = history.to_vec();
for hook in &self.registry.on_turn_end {
let action = hook.call(&mut history_vec).await;
if !matches!(action, TurnEndAction::Finish) {
return action;
}
}
TurnEndAction::Finish
}
async fn on_abort(&self, reason: &str) {
let mut reason_string = reason.to_string();
for hook in &self.registry.on_abort {
hook.call(&mut reason_string).await;
}
}
}

View File

@ -6,11 +6,10 @@ pub mod shared_state;
pub mod socket_server;
mod agents_md;
mod compact_interceptor;
mod compact_state;
mod factory;
mod hook_interceptor;
mod pod;
mod pod_interceptor;
mod prompt_loader;
mod prune;
mod system_prompt;

View File

@ -14,14 +14,13 @@ use tracing::{info, warn};
use manifest::{PodManifest, PodManifestConfig, ResolveError, Scope, ScopeError, WorkerManifest};
use crate::agents_md::read_agents_md;
use crate::compact_interceptor::CompactInterceptor;
use crate::compact_state::CompactState;
use crate::hook::{
Hook, HookRegistryBuilder, OnAbort, OnPromptSubmit, OnTurnEnd, PostToolCall, PreLlmRequest,
PreToolCall,
PreRequestInfo, PreToolCall,
};
use crate::hook_interceptor::HookInterceptor;
use crate::notifier::Notifier;
use crate::pod_interceptor::PodInterceptor;
use crate::prompt_loader::PromptLoader;
use crate::system_prompt::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
use crate::usage_tracker::UsageTracker;
@ -38,8 +37,8 @@ struct UsageTrackingHook {
#[async_trait]
impl Hook<PreLlmRequest> for UsageTrackingHook {
async fn call(&self, context: &mut Vec<Item>) -> PreRequestAction {
self.tracker.note_request(context.len());
async fn call(&self, info: &PreRequestInfo) -> PreRequestAction {
self.tracker.note_request(info.item_count);
PreRequestAction::Continue
}
}
@ -346,16 +345,15 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
/// `on_usage` callback to track `input_tokens`.
fn ensure_interceptor_installed(&mut self) {
if !self.interceptor_installed {
// Pre-LLM-request hook: capture history.len() into the
// UsageTracker so the upcoming on_usage callback can pair
// it with the measured input_tokens.
// Pre-LLM-request hook: record the item count at send time
// so the on_usage callback can pair it with the measured
// input_tokens.
self.hook_builder.add_pre_llm_request(UsageTrackingHook {
tracker: self.usage_tracker.clone(),
});
let builder = std::mem::take(&mut self.hook_builder);
let registry = Arc::new(builder.build());
let hook_interceptor = HookInterceptor::new(registry);
let compact_threshold = self
.manifest
@ -363,12 +361,9 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
.as_ref()
.and_then(|c| c.compact_threshold);
// Usage tracking via on_usage callback. Independent of
// compact_threshold so that LlmUsage entries are persisted
// unconditionally.
let tracker_for_usage = self.usage_tracker.clone();
if let Some(threshold) = compact_threshold {
let compact_state = if let Some(threshold) = compact_threshold {
let retained = self
.manifest
.compaction
@ -377,9 +372,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
.unwrap_or(2);
let state = Arc::new(CompactState::new(threshold, retained));
// Combined on_usage: feed both the legacy compact threshold
// tracker and the new UsageTracker.
let state_for_usage = state.clone();
self.worker_mut().on_usage(move |event| {
if let Some(tokens) = event.input_tokens {
@ -387,17 +379,17 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
}
tracker_for_usage.record_usage(event);
});
let interceptor = CompactInterceptor::new(hook_interceptor, state.clone());
self.worker_mut().set_interceptor(interceptor);
self.compact_state = Some(state);
self.compact_state = Some(state.clone());
Some(state)
} else {
self.worker_mut().on_usage(move |event| {
tracker_for_usage.record_usage(event);
});
self.worker_mut().set_interceptor(hook_interceptor);
}
None
};
let interceptor = PodInterceptor::new(registry, compact_state);
self.worker_mut().set_interceptor(interceptor);
self.interceptor_installed = true;
}
}

View File

@ -0,0 +1,294 @@
//! Pod-owned `Interceptor` implementation.
//!
//! Bridges Pod's internal mechanisms (compaction trigger today;
//! notification injection / output truncation in the future) and the
//! public `HookRegistry`. Internal mechanisms run first and have full
//! mutable access via the `Interceptor` trait. Hooks then receive
//! read-only summary information and only return control-flow
//! decisions (continue / skip / abort / pause).
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use async_trait::async_trait;
use llm_worker::Item;
use llm_worker::interceptor::{
Interceptor, PostToolAction, PreRequestAction, PreToolAction, PromptAction, ToolCallInfo,
ToolResultInfo, TurnEndAction,
};
use llm_worker::tool::ToolOutput;
use tracing::info;
use crate::compact_state::CompactState;
use crate::hook::{
AbortInfo, HookRegistry, PreRequestInfo, PromptSubmitInfo, ToolCallSummary, ToolResultSummary,
TurnEndInfo,
};
/// Maximum number of bytes copied into `TurnEndInfo::final_text_preview`.
const FINAL_TEXT_PREVIEW_LIMIT: usize = 512;
pub(crate) struct PodInterceptor {
registry: Arc<HookRegistry>,
compact_state: Option<Arc<CompactState>>,
/// Next turn index assigned by `on_prompt_submit`.
next_turn_index: AtomicUsize,
/// Tool calls observed in the current turn (reset on each new prompt).
tool_calls_this_turn: AtomicUsize,
}
impl PodInterceptor {
pub(crate) fn new(
registry: Arc<HookRegistry>,
compact_state: Option<Arc<CompactState>>,
) -> Self {
Self {
registry,
compact_state,
next_turn_index: AtomicUsize::new(0),
tool_calls_this_turn: AtomicUsize::new(0),
}
}
fn current_turn_index(&self) -> usize {
self.next_turn_index
.load(Ordering::Relaxed)
.saturating_sub(1)
}
}
#[async_trait]
impl Interceptor for PodInterceptor {
async fn on_prompt_submit(&self, item: &mut Item) -> PromptAction {
let turn_index = self.next_turn_index.fetch_add(1, Ordering::Relaxed);
self.tool_calls_this_turn.store(0, Ordering::Relaxed);
let info = PromptSubmitInfo {
input_text: extract_message_text(item).unwrap_or_default(),
turn_index,
};
for hook in &self.registry.on_prompt_submit {
let action = hook.call(&info).await;
if !matches!(action, PromptAction::Continue) {
return action;
}
}
PromptAction::Continue
}
async fn pre_llm_request(&self, context: &mut Vec<Item>) -> PreRequestAction {
// Internal mechanism: between-turns compaction trigger.
if let Some(state) = self.compact_state.as_ref() {
if !state.is_disabled() && state.exceeds_turn() {
info!(
input_tokens = state.last_input_tokens(),
threshold = state.turn_threshold(),
"Between-turns compaction threshold exceeded, yielding"
);
return PreRequestAction::Yield;
}
}
let info = PreRequestInfo {
item_count: context.len(),
estimated_tokens: self.compact_state.as_ref().map(|s| s.last_input_tokens()),
turn_index: self.current_turn_index(),
tool_calls_this_turn: self.tool_calls_this_turn.load(Ordering::Relaxed),
};
for hook in &self.registry.pre_llm_request {
let action = hook.call(&info).await;
if !matches!(action, PreRequestAction::Continue) {
return action;
}
}
PreRequestAction::Continue
}
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
let summary = ToolCallSummary {
call_id: info.call.id.clone(),
tool_name: info.call.name.clone(),
arguments: info.call.input.clone(),
};
for hook in &self.registry.pre_tool_call {
let action = hook.call(&summary).await;
if !matches!(action, PreToolAction::Continue) {
return action;
}
}
self.tool_calls_this_turn.fetch_add(1, Ordering::Relaxed);
PreToolAction::Continue
}
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction {
let summary = ToolResultSummary {
call_id: info.result.tool_use_id.clone(),
tool_name: info.call.name.clone(),
is_error: info.result.is_error,
output: ToolOutput {
summary: info.result.summary.clone(),
content: info.result.content.clone(),
},
};
for hook in &self.registry.post_tool_call {
let action = hook.call(&summary).await;
if !matches!(action, PostToolAction::Continue) {
return action;
}
}
PostToolAction::Continue
}
async fn on_turn_end(&self, history: &[Item]) -> TurnEndAction {
let final_text_preview = history
.iter()
.rev()
.find(|i| i.is_assistant_message())
.and_then(extract_message_text)
.map(|t| preview(&t, FINAL_TEXT_PREVIEW_LIMIT))
.unwrap_or_default();
let info = TurnEndInfo {
turn_index: self.current_turn_index(),
tool_calls_count: self.tool_calls_this_turn.load(Ordering::Relaxed),
final_text_preview,
};
for hook in &self.registry.on_turn_end {
let action = hook.call(&info).await;
if !matches!(action, TurnEndAction::Finish) {
return action;
}
}
TurnEndAction::Finish
}
async fn on_abort(&self, reason: &str) {
let info = AbortInfo {
reason: reason.to_string(),
};
for hook in &self.registry.on_abort {
hook.call(&info).await;
}
}
}
fn extract_message_text(item: &Item) -> Option<String> {
match item {
Item::Message { content, .. } => Some(
content
.iter()
.map(|p| p.as_text())
.collect::<Vec<_>>()
.join(""),
),
_ => None,
}
}
fn preview(text: &str, limit: usize) -> String {
if text.len() <= limit {
return text.to_string();
}
let mut end = limit;
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
text[..end].to_string()
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicBool, AtomicUsize};
use super::*;
use crate::hook::{Hook, HookRegistryBuilder, PreLlmRequest};
struct CountingHook(Arc<AtomicUsize>);
#[async_trait]
impl Hook<PreLlmRequest> for CountingHook {
async fn call(&self, _info: &PreRequestInfo) -> PreRequestAction {
self.0.fetch_add(1, Ordering::Relaxed);
PreRequestAction::Continue
}
}
fn registry_with_pre_llm_hook(counter: Arc<AtomicUsize>) -> Arc<HookRegistry> {
let mut builder = HookRegistryBuilder::new();
builder.add_pre_llm_request(CountingHook(counter));
Arc::new(builder.build())
}
#[tokio::test]
async fn pre_llm_request_yields_and_skips_hooks_when_compact_threshold_exceeded() {
let count = Arc::new(AtomicUsize::new(0));
let registry = registry_with_pre_llm_hook(count.clone());
let state = Arc::new(CompactState::new(100, 2));
state.update_input_tokens(200); // exceeds turn threshold
let interceptor = PodInterceptor::new(registry, Some(state));
let mut ctx: Vec<Item> = vec![Item::user_message("hi")];
let action = interceptor.pre_llm_request(&mut ctx).await;
assert!(matches!(action, PreRequestAction::Yield));
// Hook must not run when an internal mechanism short-circuits first.
assert_eq!(count.load(Ordering::Relaxed), 0);
}
#[tokio::test]
async fn pre_llm_request_runs_hooks_when_under_threshold() {
let count = Arc::new(AtomicUsize::new(0));
let registry = registry_with_pre_llm_hook(count.clone());
let state = Arc::new(CompactState::new(100, 2));
// last_input_tokens stays at 0, well below threshold.
let interceptor = PodInterceptor::new(registry, Some(state));
let mut ctx: Vec<Item> = vec![Item::user_message("hi")];
let action = interceptor.pre_llm_request(&mut ctx).await;
assert!(matches!(action, PreRequestAction::Continue));
assert_eq!(count.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn pre_llm_request_runs_hooks_when_no_compact_state() {
let count = Arc::new(AtomicUsize::new(0));
let registry = registry_with_pre_llm_hook(count.clone());
let interceptor = PodInterceptor::new(registry, None);
let mut ctx: Vec<Item> = Vec::new();
let action = interceptor.pre_llm_request(&mut ctx).await;
assert!(matches!(action, PreRequestAction::Continue));
assert_eq!(count.load(Ordering::Relaxed), 1);
}
struct AbortingHook(Arc<AtomicBool>);
#[async_trait]
impl Hook<PreLlmRequest> for AbortingHook {
async fn call(&self, _info: &PreRequestInfo) -> PreRequestAction {
self.0.store(true, Ordering::Relaxed);
PreRequestAction::Cancel("nope".into())
}
}
#[tokio::test]
async fn pre_llm_request_short_circuits_on_first_non_continue() {
let first_called = Arc::new(AtomicBool::new(false));
let second_count = Arc::new(AtomicUsize::new(0));
let mut builder = HookRegistryBuilder::new();
builder.add_pre_llm_request(AbortingHook(first_called.clone()));
builder.add_pre_llm_request(CountingHook(second_count.clone()));
let registry = Arc::new(builder.build());
let interceptor = PodInterceptor::new(registry, None);
let mut ctx: Vec<Item> = Vec::new();
let action = interceptor.pre_llm_request(&mut ctx).await;
assert!(matches!(action, PreRequestAction::Cancel(_)));
assert!(first_called.load(Ordering::Relaxed));
assert_eq!(second_count.load(Ordering::Relaxed), 0);
}
}

View File

@ -1,5 +1,7 @@
{% include "common/workspace" %}
You are here as an agent of the "insomnia system".
Stay precise, edit code directly when asked, and avoid speculative refactoring. Explain what you changed in one short paragraph at the end of each turn.
{% include "common/workspace" %}
{% include "common/tool-usage" %}

View File

@ -0,0 +1,154 @@
# Hook と Interceptor の責務分離
## レビュー状態
初回レビュー実施済み。[hook-interceptor-separation.review.md](hook-interceptor-separation.review.md) を参照。
要件全項目達成。**無条件で受け入れ可**。
## 背景
Worker の実行ループへの介入手段として `Interceptor` trait (llm-worker crate) と `Hook` trait (pod crate) が存在するが、現状では Hook が Interceptor と同じ権限を持っており、責務の分離が成立していない。
### 現状の構造
```
Worker
└── Interceptor trait (llm-worker)
↑ implements
HookInterceptor (pod)
└── HookRegistry
└── Vec<Box<dyn Hook<E>>>
```
- `Interceptor``&mut Vec<Item>` (context)、`&mut ToolResultInfo` (tool result) 等を直接受け取る。context の書き換え、history への干渉、tool result の改変が自由にできる
- `HookInterceptor``Interceptor` を実装し、各メソッドで Hook を順に呼ぶブリッジ
- `Hook<E>``call(&self, input: &mut E::Input)` が受け取る `E::Input``Vec<Item>``ToolCallInfo` **そのもの**
- **結果として Hook からも Interceptor と同じ全権限で context を操作できてしまっている**
### 問題
- Hook を将来スクリプト言語Rhai, Wasm 等)に公開したとき、ユーザースクリプトが context を自由に書き換えられてしまう
- 内部機構compaction トリガー、notification 注入、tool output truncationと外部公開 hook が同じ権限レベルで動いており、何が安全に公開できるかが型で表現されていない
- Interceptor のドキュメントには「upper layers (e.g. Pod) implement」とあるが、Pod が直接実装するのではなく Hook 経由で間接実装しているため、設計意図と実態がずれている
## ゴール
Interceptor と Hook の責務を明確に分離し、型レベルで権限の境界を表現する。
## 方針
### Interceptor内部実装用
- **llm-worker crate に留まる**。Pod / Worker の内部機構が直接実装する
- context / history の**直接操作が可能**`&mut Vec<Item>` 等を受け取る)
- 外部に公開しない。`pub(crate)` または Pod crate 内でのみ利用
- 用途:
- compaction トリガー(`PreLlmRequest` で context サイズを見て `Yield` を返す)
- notification 注入(`PreLlmRequest` で pending notifications を context に flush
- tool output truncation`PostToolCall` で content を書き換え)
- 将来の内部機構
### Hook公開 API 用)
- **Pod crate で定義**。将来スクリプト言語やプラグインに公開する前提
- イベントの**観測**と**制御フロー判断**continue / skip / abort / pauseのみ
- context の直接操作は**できない**。受け取るのは:
- **読み取り専用の情報**ツール名、引数の概要、ターン番号、etc.
- **制御フロー判断の返却値**continue / skip / abort
- 安全に sandbox 可能
### 実行順序
同じ決定点(例: pre_tool_callで Interceptor と Hook の両方が登録されている場合:
```
[Interceptor: 内部機構の処理context 操作等)]
[Hook: 外部の判断observe + continue/skip/abort]
Worker が次のステップに進む
```
Interceptor が先に走って context を整え、Hook はその結果を観測する。Hook の判断が Interceptor の操作を覆すことはないabort 等で中断は可能)。
## 必要な変更
### Hook の Input 型を制限する
現状の `Hook<PreLlmRequest>` の Input は `Vec<Item>` だが、これを read-only のサマリ型に変更:
```rust
// Before: Hook が context を直接操作できる
impl HookEventKind for PreLlmRequest {
type Input = Vec<Item>; // &mut Vec<Item> が渡される
type Output = PreRequestAction;
}
// After: Hook は read-only の情報だけ受け取る
impl HookEventKind for PreLlmRequest {
type Input = PreRequestInfo; // context のサマリitem 数、token 推定等)
type Output = PreRequestAction;
}
```
同様に各イベントの Input を「観測に必要な最小限の read-only 情報」に絞る。
### `ToolCallInfo` / `ToolResultInfo` の分離
現状は Interceptor と Hook で同じ `ToolCallInfo` を共有している。分離:
- **Interceptor 用**: `ToolCallInfo { call: &mut ToolCall, meta: ToolMeta, tool: Arc<dyn Tool> }` — call の書き換え可能
- **Hook 用**: `ToolCallSummary { name: String, arguments_preview: String }` — 読み取りのみ、tool instance へのアクセスなし
### HookInterceptor の解体
現在の `HookInterceptor`Hook を Interceptor にブリッジする層)は不要になる。代わりに:
- Worker が Interceptor を呼ぶ(内部機構の処理)
- Worker が Hook を呼ぶ(外部判断の問い合わせ)
- これらは Worker の実行ループ内で**別々のステップ**として呼ばれる
Worker が Hook を直接知るか、Pod 層が Worker に Hook callback を注入するかは設計時に判断。
### llm-worker 側の変更
- `Interceptor` trait はそのまま(内部実装用として健全)
- Worker の実行ループに「Hook 呼び出しポイント」を追加Interceptor とは別系統)
- Hook の呼び出しインターフェースは `Fn` ベースのコールバックか、新しい trait か
### pod 側の変更
- `Hook<E>``E::Input` を read-only サマリ型に置き換え
- `HookInterceptor` を削除
- 内部機構compaction 等)は Interceptor を直接実装する形に移行
- HookRegistry は外部公開 API として残り、将来のスクリプト公開の基盤になる
## 設計で決めること
- **Hook の Input 型の具体設計**: 各イベントで何を read-only として渡すか(ツール名? 引数の先頭 N 文字? token 数?
- **Worker が Hook をどう呼ぶか**: Interceptor と同じ trait 方式か、コールバック登録方式か、別の trait か
- **Interceptor の複数登録**: 現状 Worker は1つの Interceptor しか持てない。内部機構が複数compaction + notification + truncationになると、Chain of Responsibility 的に複数の Interceptor を合成する仕組みが要る
- **Hook の実行順序保証**: 複数の Hook が登録されている場合の評価順序と short-circuit 規則(現行と同じ「登録順 + 最初の non-Continue で打ち切り」を維持するか)
- **既存の Pod 層 hook ユーザーの移行**: compact_interceptor 等が現在 HookInterceptor 経由で動いている場合の移行パス
## 完了条件
- Interceptor は context / history の直接操作が可能な内部 API として存続し、compaction / notification 注入 / tool output truncation 等が Interceptor を直接利用する
- Hook は read-only のサマリ情報のみを受け取り、制御フロー判断continue / skip / abort / pauseを返す公開 API になる
- Hook から `&mut Vec<Item>``&mut ToolCallInfo` 等の context 直接操作パスが**型レベルで存在しない**
- HookInterceptor ブリッジは削除される
- Worker の実行ループ内で Interceptor → Hook の順序で呼ばれ、それぞれの責務が分離されている
- 既存の Pod 層 hookcompaction トリガー等)が Interceptor 直接実装に移行し、動作が壊れていない
- 単体テストで Hook が context を操作できないことが検証される
## 他チケットとの関係
- **tickets/method-notify.md**: notification の context 注入は Interceptor の責務。Hook ではない
- **tickets/pod-orchestration.md**: spawned Pod のツール実行制御permissionは Hook として公開しうる
- **tickets/permission-extension-point.md**: パーミッション制御は Hook の代表的ユースケース。ツール実行の approve/deny を外部から制御する
- **tickets/bash-tool.md**: Bash ツールの Permission 層は Hook (pre_tool_call) で実装される想定
## 範囲外
- **スクリプト言語バインディングの実装**: Hook を Rhai / Wasm に公開する仕組み自体は別チケット。本チケットは公開可能な型境界を作るところまで
- **Hook の動的ロード / アンロード**: 起動時に登録して freeze する現行モデルを維持。動的な Hook 管理は別チケット

View File

@ -0,0 +1,72 @@
# レビュー: Hook と Interceptor の責務分離
対象差分: `crates/pod/src/{hook,pod,lib,pod_interceptor}.rs`、削除: `compact_interceptor.rs` / `hook_interceptor.rs`staged、未コミット
## 要件達成状況
| 要件 | 状態 |
|---|---|
| Hook の Input が read-only サマリ型になる | ✅ `PromptSubmitInfo` / `PreRequestInfo` / `ToolCallSummary` / `ToolResultSummary` / `TurnEndInfo` / `AbortInfo` を新設。`Hook::call(&self, input: &E::Input)` で `&mut` が消えた |
| Hook から context / history の直接操作パスが型レベルで存在しない | ✅ `Vec<Item>` / `ToolCallInfo` / `ToolResultInfo` が Hook の Input から消え、サマリ型に置換 |
| Interceptor は context / history の直接操作が可能なまま | ✅ `PodInterceptor``Interceptor` を実装し `&mut Vec<Item>` / `&mut ToolCallInfo` 等を受け取る |
| Interceptor → Hook の実行順序(内部が先、公開が後) | ✅ `PodInterceptor` の各メソッドが: 内部ロジックcompaction check 等)→ サマリ構築 → Hook 呼び出し の順 |
| HookInterceptor ブリッジの削除 | ✅ `hook_interceptor.rs` deleted |
| CompactInterceptor の統合 | ✅ `compact_interceptor.rs` deleted。compaction check は `PodInterceptor::pre_llm_request` に統合 |
| 既存の compaction 動作が壊れない | ✅ `compact_state``PodInterceptor` に渡し、`exceeds_turn()` で Yield する同じロジック |
| 単体テストで Hook が context を操作できないことが検証される | ✅ テストの Hook が `&PreRequestInfo`read-onlyを受け取る形で書かれている。`&mut` パスは型レベルで不可能 |
## アーキテクチャ
### 良い点
**`PodInterceptor` が single composite interceptor として全責務を統合**:
- compaction check (内部) → サマリ構築 → Hook 呼び出しを1つの `pre_llm_request` 内で制御
- Worker 側は単一の `set_interceptor` で完結(`Vec<Box<dyn Interceptor>>` 不要)
- `compact_interceptor.rs` の decorator パターンinner HookInterceptor をラップ)が消え、フラットな構造に
**Hook の `call` シグネチャが `&self, input: &E::Input` に**:
- `&mut E::Input``&E::Input` への変更で、Hook 実装者が context を書き換える経路が型レベルで消えた
- 将来スクリプトに公開するとき、`&T` だけ渡せば良いsandbox の粒度が明確)
**サマリ型の設計**:
- `ToolCallSummary::arguments``serde_json::Value` clone — 構造的アクセスが可能で permission 判断に使える
- `ToolResultSummary::output``ToolOutput` clone — summary + content 両方にアクセス可能
- `PreRequestInfo` が item_count / estimated_tokens / turn_index / tool_calls_this_turn を集約 — compaction 判断に必要十分
- `TurnEndInfo::final_text_preview` が 512 byte 制限 + UTF-8 boundary — Pod orchestration で spawned Pod の結果要約に使える
**ターン追跡の統合**:
- `next_turn_index` / `tool_calls_this_turn``PodInterceptor` 内の `AtomicUsize` で管理
- `on_prompt_submit` でリセット、`pre_tool_call` でインクリメント
- Hook が受け取るサマリにこの情報が反映される
### テスト
4 ケース:
- `pre_llm_request_yields_and_skips_hooks_when_compact_threshold_exceeded` — 内部機構compactionが Hook より先に short-circuit することを検証
- `pre_llm_request_runs_hooks_when_under_threshold` — 通常時は Hook が走ることを検証
- `pre_llm_request_runs_hooks_when_no_compact_state` — compaction 無効時も Hook が走ることを検証
- `pre_llm_request_short_circuits_on_first_non_continue` — 複数 Hook の短絡評価を検証
特に 1 つ目が**設計の核心Interceptor が先、Hook が後。内部が short-circuit したら Hook は走らない)**を lock-in している。
## 指摘事項
### 1. 🟢 `pod.rs``ensure_interceptor_installed` が少しすっきりした
旧: `CompactInterceptor::new(hook_interceptor, state)` で decorator をネスト → 新: `PodInterceptor::new(registry, compact_state)` でフラットに構築。`compact_state` の有無で分岐していた `set_interceptor` 呼び出しが1箇所に統合。
### 2. 🟢 `HookEventKind::Input``Send + Sync` バウンドが追加
```rust
type Input: Send + Sync;
```
`&E::Input` を async メソッドで受け取るために必要。正しい追加。
### 3. 🟢 `extract_message_text` / `preview` がユーティリティとして分離
`PodInterceptor` 内の private function として切り出し。`preview` は UTF-8 boundary を考慮した切断で、`truncate_content`tool output truncationと同じパターン。
## 結論
**無条件で受け入れ可**。チケットの要件を完全に達成。特に「型レベルで Hook から context 操作が不可能」という核心的な保証が `&E::Input` (not `&mut`) + サマリ型で実現されている。テストも設計意図を正確にカバー。

116
tickets/method-notify.md Normal file
View File

@ -0,0 +1,116 @@
# Method::Notify: システム起点のコンテキスト注入と自動ターン開始
## 背景
現状の Pod の実行サイクルは `Method::Run { input }` が唯一のターン開始手段であり、これは人間(または外部クライアント)が明示的にテキストを送ることを前提としている。
Pod オーケストレーション(`tickets/pod-orchestration.md`)では、子 Pod が親 Pod にコールバックで通知を送り、親 Pod が**人間の入力を待たずに**その通知を処理して次のアクションを起こす必要がある。現状のアーキテクチャにはこの経路が無い。
また、RUNNING 中の Pod に対しても、リクエストの合間に情報を注入して LLM に認知させたいケースがある(子 Pod からの非同期通知など)。現状は RUNNING 中にコンテキストを追加する手段が無い。
## ゴール
`Method::Notify` を新設し、Pod の状態IDLE / RUNNINGに応じて適切にコンテキストへの注入とターン制御を行う。
## 仕様
### `Method::Notify { source: String, message: String }`
protocol に追加する新しい Method。`Run` とは異なり、ユーザーメッセージではなく**システム通知**としてコンテキストに注入される。
### Pod が IDLE のとき
1. 通知メッセージを system message としてコンテキストに注入
2. **自動でターンを開始**する(人間の入力を待たない)
3. LLM は通知を見て次のアクションを判断する
### Pod が RUNNING のとき
1. 通知メッセージを**次の LLM リクエストの直前**に注入するtool call の応答を送った後、次のリクエストを組み立てる前)
2. ターンは中断しない。LLM は現在のタスクを続行しつつ、次のレスポンスで通知を認知する
3. LLM は今やっていることを優先し、切りの良いタイミングで通知に対処するかを自分で判断する
### 注入されるメッセージのフォーマット
通知は素のテキストではなく、以下の構造で注入される:
```
[Notification from {source}]
{message}
This is a notification, not a blocking request. If you are in the middle of a task, continue your current work and address this at a natural stopping point.
```
- `[Notification]` prefix で LLM にこれが通知であることを明示
- 「ブロッカーではないので直ちに対処しなくてよい」という指示を付与
- LLM が通知を見て即座にタスクを放棄する(指示追従性の暴走)を防ぐ
### 複数通知のバッファリング
- RUNNING 中に複数の `Notify` が到着した場合、バッファに溜めて次の LLM リクエスト直前にまとめて注入する
- 個別の `[Notification]` ブロックとして並べる1つにマージしない
- IDLE 中に複数到着した場合、1つの system message にまとめて注入し、ターンを1回だけ開始する
## 実装に必要な変更
### protocol crate
- `Method::Notify { source: String, message: String }``Method` enum に追加
- 対応する `Event` の追加が必要かは設計時に判断
### Worker
- RUNNING 中に外部からメッセージを注入する仕組みが必要
- 現状の Worker は turn 実行中にコンテキストの追加手段を持たない
- tool call → tool result → **notification 注入** → 次の LLM リクエスト、というフローを追加
- 注入ポイントは `execute_tools` 完了後、次の LLM リクエスト組み立て前
### Controller
- `Method::Notify` のハンドリングを追加
- IDLE 時: 通知を注入 → 内部的に `run()` を開始(`Method::Run` と似た経路だがメッセージ種別が異なる)
- RUNNING 時: Worker の notification buffer に push
### Pod
- notification buffer を保持するフィールドを追加
- `ensure_system_prompt_materialized` 的な「ターン開始前に notification を flush する」フックが要るかもしれない
## `Method::Run` との対比
| | `Method::Run` | `Method::Notify` |
|---|---|---|
| 対象状態 | IDLE のみRUNNING 中は AlreadyRunning エラー) | IDLE でも RUNNING でも受け付ける |
| コンテキスト上の見え方 | user message | system message`[Notification]` prefix 付き) |
| ターン制御 | 新ターンを開始 | IDLE: 自動でターン開始。RUNNING: 現ターンに注入 |
| LLM の期待挙動 | 指示に従って即座に行動 | 現タスク優先、切りの良いタイミングで対処を判断 |
| 送信元 | 人間 / クライアント | システム / 子 Pod のコールバック / Hook |
## 設計で決めること
- **notification の注入はどの message type で行うか**: 既存の `Role::User` / `Role::Assistant` とは別の `Role::System` (mid-conversation) を追加するか、`Role::User` に `[Notification]` prefix を付けて実質的に区別するか
- **IDLE 時の自動ターン開始と人間の `Run` の競合**: 通知が到着して自動ターンが始まった直後に人間が `Run` を送った場合の挙動
- **notification buffer のサイズ上限**: RUNNING が長時間の場合にバッファが無制限に溜まるリスク
- **通知メッセージの prefix / suffix テンプレートの置き場**: ハードコードか、instruction 側でカスタマイズ可能にするか
- **Event の対応**: `Event::NotificationInjected` のような確認イベントを返すか
## 完了条件
- `Method::Notify` が protocol に追加され、Controller が IDLE / RUNNING で適切にハンドリングする
- IDLE 時: 通知が system message として注入され、自動でターンが開始される
- RUNNING 時: 通知が次の LLM リクエスト直前に注入され、LLM が認知できる
- 複数通知のバッファリングが動作するRUNNING 中に溜まった通知がまとめて注入される)
- `[Notification]` prefix と non-blocking 指示が付与される
- 単体テストで IDLE / RUNNING 両パスが検証される
## 他チケットとの関係
- **tickets/pod-orchestration.md**: 本チケットの主要消費者。子 Pod の `Notify` ツール → 親の callback → 親の `Method::Notify` というフローでオーケストレーションの非同期通知が成立する
- **tickets/protocol-design.md**: protocol への Method 追加。既存の Method 設計パターンRun / Resume / Cancel / Shutdownと整合させる
- **tickets/compact-improvements.md**: notification がコンテキストに蓄積した場合の compaction 挙動は別途検討
## 範囲外
- **通知の routing / addressing**: 誰が誰に通知を送るかはオーケストレーション側の責務。本チケットは「Pod が Notify を受けたときの挙動」だけを扱う
- **通知の優先度 / フィルタリング**: 全通知を等しく注入する。重要度に応じた選別は LLM に任せる
- **通知の永続化**: 通知は session-store に永続化しない。コンテキスト上の message としてのみ存在する

View File

@ -14,60 +14,152 @@ Pod の LLM が、ツール呼び出しを通じて別 Pod のライフサイク
```
Human (GUI / TUI) ─── Pod A (orchestrator)
├── socket ──→ Pod B (researcher)
├── socket ──→ Pod C (coder)
└── socket ──→ Pod D (reviewer)
└── socket ──→ Pod E (tester)
├── callback addr ──→ Pod B (researcher)
├── callback addr ──→ Pod C (coder)
└── callback addr ──→ Pod D (reviewer)
└── callback addr ──→ Pod E (tester)
```
### プロセス独立
- spawn された Pod は **完全に独立したプロセス** として動作する。OS レベルの親子関係subprocessは持たない
- spawner が落ちても、spawned Pod は**続行**する。再接続も可能
- spawner と spawned Pod の関係は「socket client として接続している」だけ。関係性はプロセスではなく**ソケット接続**で表現される
- spawner が落ちても、spawned Pod は**続行**する
- すべての Pod は同格。「誰が spawn したか」は Pod の runtime 属性であり、プロセスの従属関係ではない
- Pod の発見には**レジストリ**runtime_dir 内の socket パスを列挙、または明示的な登録)が必要
### Scope の分譲
### Pod の発見と知識
- Pod は**自分が spawn した相手**と**親から明示的に教えてもらった相手**しか知らない
- runtime_dir のスキャンや共有レジストリによる探索は行わない(セキュリティリスク)
- 親抜きで会話すべき Pod があるなら、それは親が明示的に紹介する
- Pod が知っている Pod のリスト = spawn 記録 + 紹介された相手の記録
### 接続モデル: 常時接続なし
- Pod 間の接続は**すべて一時的**。常時接続は張らない
- 操作(メッセージ送信・出力読み取り・停止)は**都度接続の request-response**:
- ローカル: unix socket に接続 → request → response → 切断
- リモート: SSH 経由で接続 → request → response → 切断
- 非同期通知は**コールバック**方式: spawned Pod がイベント発生時に spawner のアドレスに一発接続して通知し、即切断webhook と同じモデル)
- 接続は常に**送信側が開始**する
## Scope の分譲と排他制御
### 原則
- Pod A が Pod B を spawn するとき、A は自身の scope の一部を B に**譲渡**する
- 譲渡した scope 領域は A の effective scope から **deny** されるA は自分が譲った部分にアクセスできなくなる)
- これにより**構造的に排他制御が保証**される:同一パスに対して write 権限を持つ Pod は常に高々1つ
- Pod B が終了すると、譲渡された scope は A に**返却**されるA の deny が解除される)
- Pod B が scope の一部をさらに Pod D に分譲した場合、B の終了時に D が保持している分は D にそのまま残るD は独立して生きているため。D が終了するまで、その scope 領域は誰にも返却されない
- 譲渡した scope 領域は A の effective scope から deny されるA は自分が譲った部分にアクセスできなくなる)
- **write scope の排他制御が保証**される:同一パスに対して write 権限を持つ Pod は常に高々1つ
- read は衝突しない(複数 Pod が同じパスを同時に read 可能)
### `tickets/scope-exclusion.md` との関係
### Scope lock file
scope 分譲により Pod 間の write 排他制御は spawn 時に構造的に保証される。`tickets/scope-exclusion.md` が扱おうとしていた「複数 Pod が同一パスに同時 write する問題」は、**本チケットの scope 分譲モデルで吸収される**。
scope の割り当てをマシン上の**単一の lock file** で一元管理する。spawn 系譜を持たない Pod 同士(人間が独立に起動した Pod 等)でも write 衝突を検出できる
ただし、分譲ではなく**共有 read** (複数 Pod が同じパスを同時に read する) は引き続き許可される。read 同士は衝突しない。
置き場: `$XDG_RUNTIME_DIR/insomnia/scope.lock`
内容:
```json
{
"allocations": [
{
"pod_id": "abc123",
"pid": 12345,
"socket": "/run/insomnia/.../pod-a.sock",
"scope_allow": ["/project/src:write:recursive"],
"delegated_from": null
},
{
"pod_id": "def456",
"pid": 12346,
"socket": "/run/insomnia/.../pod-b.sock",
"scope_allow": ["/project/src/core:write:recursive"],
"delegated_from": "abc123"
}
]
}
```
操作は `flock(2)` による advisory lock で排他アクセスする:
| タイミング | 動作 |
|---|---|
| **Pod 起動** | lock → stale 検出PID 死活)→ 自動回収 → write 衝突チェック → 自分の scope を登録 → unlock |
| **scope 分譲 (SpawnPod)** | lock → spawner の allocation に deny 追記 → 新 Pod の allocation を追加(`delegated_from` に spawner→ unlock |
| **Pod 正常終了** | lock → 自分の allocation を削除 → `delegated_from` が自分の子が残っていなければ親の deny を解除 → unlock |
| **stale 検出** | `kill(pid, 0)` で生存確認。死んでいたら allocation を削除し、scope を `delegated_from` の親に返却 |
### stale の自動回収
Pod がクラッシュ(正常終了せず PID が消えたした場合、lock file にエントリが残る。**次に lock file を開いた Pod が stale を検出し自動回収する**:
- Pod B (pid=200, dead): `/project/src` write, delegated_from=A
- Pod D (pid=300, alive): `/project/src/core` write, delegated_from=B
→ B が死んでいる:
1. B の scope `/project/src` のうち D が持つ `/project/src/core` を除いた部分を A に返却
2. B のエントリを削除
3. D の `delegated_from` を A に付け替え
能動的なコールバックが届かなくても、いつか誰かが lock file を開けば回収が走る。
### Effective scope の導出
Pod の effective scope は lock file から導出される:
```
effective_scope = 自分の allocation - Σ(delegated_from が自分を指す子の allocation)
```
Pod は lock file を読むことで自分の effective scope を知れる。ただし常時読むとパフォーマンスに影響するため、キャッシュして scope 変動のコールバック通知で更新するのが実用的。
### 返却
- Pod B が終了すると、scope は `delegated_from` が指す spawner (A) に自動返却される
- 返却先は常に `delegated_from`。プールへの返却や他者への再分配はない
### 又貸し
- Pod B が `/src/core` を Pod D に又貸しする場合、lock file 上で:
- B の allocation から `/src/core` を除外
- D の allocation を追加(`delegated_from=B`
- B は spawner (A) にコールバックで通知:「`/src/core` を D に委譲した」
- A は D の存在と D の socket address をこの通知で知る(= 親からの紹介)
- B が終了したとき: lock file の stale 回収で D の `delegated_from` が A に付け替わり、D が保持しない残りの scope が A に戻る
### 観測可能性
- `insomnia scope list` 的なコマンドで lock file を読めば、マシン上の全 Pod の scope 割り当てを一覧できる
- 人間が「今どの Pod がどこを write 占有しているか」を確認する手段になる
- 衝突で Pod 起動が拒否されたとき、競合相手の pod_id を lock file から取得してエラーメッセージに含める
## LLM に公開するツール群
Pod の LLM が使えるツールとして以下を設計する。いずれも通常の Tool trait 実装で、Worker に登録される。
Pod の LLM が使えるツールとして以下を設計する。すべて**都度接続の request-response** で動作する。
### `SpawnPod`
新しい Pod を起動し、scope の一部を譲渡する。
入力:
- `name`: spawned Pod の識別名spawner のスコープ内で一意)
- `instruction`: spawned Pod の instruction ファイル参照(省略時は `$insomnia/default`
- `task`: spawned Pod への最初のメッセージspawn 後に即座に run される)
- `scope`: 譲渡する scope 定義allow / deny ルール)。**spawner の現在の effective scope のサブセットでなければならない**。バリデーションで超過分は拒否
- `name`: spawned Pod の識別名
- `instruction`: instruction ファイル参照(省略時は `$insomnia/default`
- `task`: 最初のメッセージspawn 後に即座に run される)
- `scope`: 譲渡する scope 定義。spawner の effective scope のサブセットでなければならない
- `address`: (自動注入) spawner の callback address
出力:
- spawned Pod の識別子(`pod_id`)と接続状態
- spawned Pod の `pod_id` と接続先 address
内部動作:
- spawner の effective scope から、譲渡する scope 領域を deny に追加spawner 側の scope を縮小)
- PodFactory のカスケードuser manifest + project manifestに加え、spawner からの overlay譲渡 scope + instruction + provider 等)を重ねて spawned Pod の PodManifest を構築
- 独立プロセスとして Pod を起動、socket 確立
- scope lock file を flock → write 衝突チェック → spawner の allocation に deny 追記 + 新 Pod の allocation を登録 → unlock
- PodFactory のカスケードに spawner からの overlay を重ねて PodManifest を構築
- 独立プロセスとして Pod を起動
- spawner の callback address を spawned Pod に渡す
- `task``Method::Run` で送信
### `SendToPod`
spawned Pod にメッセージを送る。
既知の Pod にメッセージを送る(都度接続)
入力:
- `pod_id`: 対象の Pod
@ -78,11 +170,11 @@ spawned Pod にメッセージを送る。
### `ReadPodOutput`
spawned Pod の最新の出力を読む
既知の Pod の最新の出力を読む(都度接続)
入力:
- `pod_id`: 対象の Pod
- `since`: 前回読んだ時点からの差分のみ取得するオプション(省略時は全出力)
- `since`: 前回読んだ時点からの差分のみ取得するオプション
出力:
- 対象 Pod の assistant 応答テキスト(最新ターン or 差分)
@ -90,7 +182,7 @@ spawned Pod の最新の出力を読む。
### `StopPod`
spawned Pod を終了させ、譲渡した scope を回収する。
既知の Pod を終了させ、譲渡した scope を回収する(都度接続)
入力:
- `pod_id`: 対象の Pod
@ -99,72 +191,50 @@ spawned Pod を終了させ、譲渡した scope を回収する。
- 終了確認
- 回収された scope の要約
内部動作:
- 対象 Pod に graceful shutdown を要求
- 対象 Pod が保持していた scope のうち、さらに下流に分譲されていない分を spawner に返却
- spawner の deny リストから返却分を解除
### `ListPods`
spawner が spawn した Pod の一覧とそれぞれの状態を返す。
自分が知っている Pod の一覧と状態を返す。
入力: なし
出力:
- spawned Pod の `pod_id`、`name`、`status`running / idle / stopped、譲渡中の scope 要約、最終応答の要約
- Pod の `pod_id`、`name`、`status`running / idle / stopped、譲渡中の scope 要約、最終応答の要約
## 非同期通知
内部動作:
- spawn 記録を元にリストを構築
- 各 Pod に都度接続して health check接続できなければ stopped 扱い)
- Hook で定期的に自動実行することも可能
spawned Pod が出力を生成したとき、spawner の LLM にそれを伝える仕組みが必要。ただし LLM は「ターン」の単位で動くため、イベントストリームをリアルタイムに割り込ませるのは不自然。
## 非同期通知: コールバック方式
### 方針: ターン間フックで集約通知
### 仕組み
- spawner のターンが終了した後、次のターンの開始前に**フック**が走り、spawned Pod から届いているイベントを集約する
- 集約された通知は次のターンの**先頭に system message として注入**される(例: `[pod "researcher"] completed: found 3 relevant files`
- spawn 時に spawner が**自分の callback address** を spawned Pod に渡す
- ローカル: unix socket path
- リモート: `insomnia@host:pod-name` 形式の SSH address
- spawned Pod がイベント発生時に、spawner の callback address に**一発接続して通知を送り、即切断**する
- spawner が落ちていたら callback が失敗するだけspawned Pod は続行する)
### 通知の種類
- **ターン完了**: spawned Pod の1ターンが終わったspawner は `ReadPodOutput` で内容を取りに行く)
- **scope 又貸し**: spawned Pod が自身の scope の一部をさらに別の Pod に委譲したspawner の scope 記録を更新する材料)
- **エラー**: spawned Pod でエラーが発生
- **終了**: spawned Pod が停止したscope 返却のトリガー)
### 通知は「シグナル」のみ
通知にはイベントの種類と最小限のメタデータpod_id、scope 変動等のみを含める。応答テキスト全体のような大きなデータは含まない。spawner が内容を知りたければ `ReadPodOutput` で取りに行く。
### 通知の LLM への伝達
- spawner が受け取ったコールバック通知はバッファに溜められる
- spawner の次のターン開始前に、Hook が溜まった通知を集約してターンの先頭に system message として注入する
- 通知が無い場合は何もしない
### 通知の粒度
### ポーリングでも代替可能
- **ターン完了通知**: spawned Pod の1ターンが終わったとき
- **エラー通知**: spawned Pod でエラーが発生したとき
- **終了通知**: spawned Pod が停止したときscope 返却が発生する)
ツール出力の内容自体は `ReadPodOutput` で明示的に取りに行くモデル。通知は「何か変化があった」というシグナルのみで、内容全体は含まないspawner のコンテキストを圧迫しない)。
## Scope の分譲と回収の詳細
### 分譲時
1. spawner が `SpawnPod``scope` を指定
2. システムが検証: 指定された scope が spawner の **現在の effective scope のサブセット**であること
3. 検証通過後、spawner の effective scope から譲渡分を deny として差し引く
4. spawned Pod は譲渡された scope を自身の allow として持って起動
### 回収時StopPod または spawned Pod の自発的終了)
1. 終了する Pod が保持している effective scope を確認
2. そのうち、さらに下流に分譲中の scope は除外(下流 Pod が生きている間は返却されない)
3. 残りの scope を spawner の deny から解除し、spawner の effective scope に復帰
### 分譲チェーンのケース
Pod A が Pod B に `/src` を分譲 → Pod B が Pod D に `/src/core` を分譲:
- A の scope: `/src` は deny
- B の scope: `/src` を持つが `/src/core` は denyD に分譲済み)
- D の scope: `/src/core`
B が終了すると:
- `/src` のうち D に分譲中の `/src/core` は返却されないD が生きている)
- `/src` から `/src/core` を除いた部分が A に返却される
- A の scope: `/src``/src/core` 以外が復帰。`/src/core` は引き続き denyD が保持中)
- D が終了すると `/src/core` が A に返却される
### runtime scope state
- Pod の scope は manifest の static 定義だけでは決まらない。分譲と回収による**動的な state** が加わる
- この state を管理するレイヤーが必要Pod 内のメモリ状態 + 永続化の選択肢)
- spawner がクラッシュして復帰した場合、分譲中の scope を復元する必要がある → scope ledger の永続化が事実上必須
コールバックが届かなくても、spawner は `ListPods` のポーリングで状態を拾える。コールバックは「早く気付くための最適化」であって唯一の手段ではない。
## Pod の設定
@ -172,84 +242,77 @@ B が終了すると:
- spawned Pod の `instruction``SpawnPod` の引数で明示指定
- 省略時は `$insomnia/default`
- spawner の instruction を引き継ぐケースはまれ(分業目的の spawn なので、spawned Pod には固有の役割がある)
- spawner の instruction を引き継ぐケースはまれ(分業目的の spawn なので固有の役割がある)
### provider
- API キーと provider 設定は通常ユーザー manifest / プロジェクト manifest 由来なので、spawned Pod は PodFactory 経由で同じカスケードから取得する
- spawned Pod に異なるモデルを使わせたい場合は overlay で上書きする(例: 調査用 Pod には小さいモデル、コード生成にはフルモデル)
- API キーと provider 設定は PodFactory のカスケードuser / project manifestから取得
- spawned Pod に異なるモデルを使わせたい場合は overlay で上書き
### Pod 起動コマンド
- 環境変数またはユーザー設定で上書き可能
- デフォルトは `pod`PATH 上の `pod` バイナリ)
- リモートの場合は SSH 越しに実行
## 人間向けの監視
### 各 Pod は独立して観測可能
### 各 Pod に個別接続
- すべての Pod は通常の socket サーバーを持つ。人間は GUI / TUI で任意の Pod に接続して会話を閲覧・介入できる
- Pod 間に主従関係が無いので、人間はどの Pod にも同格にアクセスでき
- すべての Pod は通常の socket サーバーを持つ。人間は GUI / TUI で Pod に接続して会話を閲覧・介入できる
- ただし、Pod の存在を知る手段は**spawn 記録(+ 紹介)のみ**。人間もオーケストレーター Pod 経由か、事前に Pod の address を知っている必要があ
### Pod ネットワークの可視化
- GUI / TUI が Pod の一覧を表示spawn 関係をグラフまたはリストで表現)
- 各ノードに statusrunning / idle / stopped、保持中の scope 要約、最終ターンの要約を表示
- ノードをクリック/選択すると、その Pod の会話ビューに切り替わる
- GUI / TUI がオーケストレーター Pod の spawn 記録を読んで Pod リストを表示
- 各エントリに status、scope 要約、最終応答の要約を表示
- エントリを選択すると、その Pod に接続して会話ビューに切り替わる
### 介入
- 人間は任意の Pod に直接メッセージを送れる(通常のクライアントとして接続)
- 人間は既知の Pod に直接メッセージを送れる(通常のクライアントとして都度接続)
- 人間が Pod を直接 stop できるscope は spawner に返却される)
- spawner の LLM は spawned Pod に人間が介入したことを(次の通知で)知る
## Pod の発見と登録
プロセスが独立しているため、起動中の Pod を**発見**する仕組みが要る。
候補:
- **A. runtime_dir convention**: 各 Pod が socket path を `runtime_dir/<pod_name>.sock` に作る。ディレクトリを列挙すれば一覧が取れる(現行の仕組みの延長)
- **B. 明示的なレジストリ**: Pod 起動時に共有レジストリ(ファイルまたは IPCに登録。終了時に削除
- **C. daemon**: 軽量な daemon プロセスがレジストリ役を担う(`crates/daemon` は現在空)
現時点では A が最もシンプル。daemon が必要になるのは「リモート Pod」や「Pod の自動再起動」が必要になってから。
## 設計で決めること
- **Pod の起動方式**: 独立プロセスとして `pod` バイナリを起動する形で確定。起動方法の詳細fork? 直接 exec? nix-shell 経由?
- **scope ledger の永続化方式**: ファイルベース / session-store / 別の永続ストア
- **scope の分譲粒度**: パス単位で分譲するか、permission レベルread / writeでも分譲できるか例: write だけ譲って read は共有する等)
- **通知の注入方式**: system message として注入するか、専用の `Event` としてターン開始前に Worker に流すか
- **通知のバッファリング**: spawner の1ターンが数分かかる場合、その間のイベントをどこに溜めるか
- **Pod の発見**: A (runtime_dir) / B (レジストリファイル) / C (daemon)
- **callback の protocol**: 通知メッセージの具体的なフォーマット。既存の protocol crate (`Event` enum) を拡張するか、別の軽量フォーマットにするか
- **scope の分譲粒度**: パス単位で分譲するか、permission レベルwrite だけ渡して read は共有等)でも制御できるか
- **通知のバッファリング**: spawner のターン実行中に溜まるコールバック通知をどこに保持するか
- **リソース制限**: 最大 spawned Pod 数、ネスト深さの上限
- **ツール名**: `SpawnPod` / `SendToPod` / `ReadPodOutput` / `StopPod` / `ListPods` の命名はそのままで良いか
- **ツール名**: `SpawnPod` / `SendToPod` / `ReadPodOutput` / `StopPod` / `ListPods` の命名
- **Pod ネットワークの可視化**: GUI / TUI どちらに先行実装するか
- **spawner 復帰時の再接続**: scope ledger を読んで spawned Pod のソケットに再接続する手順
- **分譲チェーンの depth limit**: 再帰的 spawn の深さ上限を設けるか
- **spawner 復帰時の再接続**: spawn 記録を読んで spawned Pod に再接続する手順。callback address の再登録
- **リモート spawn**: SSH-only モデルの詳細(`docs/network-peering.md` を参照)
## 完了条件
- Pod の LLM が `SpawnPod` ツールで別 Pod を起動でき、spawned Pod が独立プロセスとして動作する
- spawn 時に scope が分譲され、spawner の effective scope が縮小されることを検証できる
- `SendToPod` で spawned Pod にメッセージを送り、`ReadPodOutput` で応答を読み取れる
- `StopPod` で spawned Pod を graceful に停止でき、scope が spawner に返却される
- `ListPods` で spawned Pod の状態と scope 要約を一覧できる
- spawned Pod のターン完了・エラー・停止が spawner に非同期通知され、次のターン開始時に LLM に伝わる
- spawner が停止しても spawned Pod が続行し、spawner 復帰時に再接続できる
- 人間が GUI または TUI で Pod ネットワークを閲覧でき、任意の Pod に接続して会話を見られる
- spawned Pod がさらに別の Pod を spawn する再帰的なネットワークが動作する
- scope 分譲チェーンの返却(中間ノードの終了時に部分返却)が正しく動作する
- spawn 時に scope lock file に allocation が記録され、spawner の effective scope が<><E3818C><EFBFBD>小される
- 同一パスへの write 衝突が lock file で検出され、Pod 起動が拒否される(競合相手の pod_id がエラーに含まれる)
- stale エントリPID が死亡が自動回収され、scope が `delegated_from` の親に戻る
- `SendToPod` / `ReadPodOutput` が都度接続の request-response で動作する
- `StopPod` で spawned Pod を graceful に停止でき、lock file から allocation が削除されて scope が返却される
- `ListPods` で既知の Pod の状態を一覧でき、health check が機能する
- spawned Pod のターン完了・エラー・停止がコールバックで spawner に通知され、`Method::Notify` 経由で LLM に伝わる
- spawner が停止しても spawned Pod が続行する
- 又貸しが lock file に記録され、コールバック通知により spawner が孫 Pod の存在と scope を把握できる
- `insomnia scope list` 相当のコマンドで lock file を読み、マシン上の全 Pod の scope 割り当てを一覧できる
- 人間が GUI または TUI で Pod リストを閲覧でき、任意の既知 Pod に接続して会話を見られる
## 他チケットとの関係
- **tickets/scope-exclusion.md**: scope 分譲モデルにより**本チケットに吸収される**。write 排他制御は分譲時に構造的に保証されるため、別の排他制御メカニズムは不要。scope-exclusion.md は本チケットの scope 設計がカバーする
- **tickets/protocol-design.md**: Protocol に Pod 間通知のイベントspawned Pod の状態変化通知など)を追加する必要があるかもしれない。ただし各 Pod は通常の socket サーバーなので、protocol 拡張なしで「複数 socket に繋ぐクライアント」として実装できる可能性もあ
- **tickets/native-gui-mvp.md**: Pod ネットワーク可視化は GUI 側の拡張。MVP scope には含まれていないが、本チケットの監視要件は GUI の次フェーズに自然に接続する
- **tickets/tui-pod-spawn-ui.md**: TUI からの Pod spawn UI。本チケットは LLM からの spawn だが、spawn のインフラPodFactory + プロセス起動)は共通
- **tickets/tui-pod-shutdown.md**: Pod ネットワークの shutdown 戦略にも影響。Pod を停止したとき scope がどこに返るか
- **tickets/permission-extension-point.md**: spawned Pod のツール実行パーミッションを spawner が制御する可能性
- **tickets/method-notify.md**: コールバック通知を親 Pod に注入する仕組み。オーケストレーションの非同期通知に必須
- **tickets/protocol-design.md**: コールバック通知の protocol 拡張が必要になりう
- **tickets/native-gui-mvp.md**: Pod リスト可視化は GUI 側の拡張
- **tickets/tui-pod-spawn-ui.md**: TUI からの Pod spawn UI。spawn のインフラは共通
- **tickets/permission-extension-point.md**: spawned Pod のツール実行パーミッションを spawner が制御<E588B6><E5BEA1>る可能性
- **scope-exclusion (削除済み)**: scope lock file による write 排他で吸収された
## 範囲外
- **Pod 間の直接メッセージパッシング**: Pod 同士が spawner を介さずに直接通信する仕組み。すべてのやり取りは spawnerオーケストレーターを介す
- **共有メモリ / 共有状態**: Pod 間のデータ共有はツール経由のテキストメッセージのみ。ファイルシステムを介した暗黙の共有は scope が許す範囲で起きうるが、それは Pod の既存動作の延長であり、分譲により write 衝突は構造的に排除される
- **自動スケーリング / 負荷分散**: Pod の spawn 先は常にローカルマシン。リモート実行やクラウド分散は扱わない
- **課金・トークン予算管理**: spawned Pod が消費するトークンの予算制御や spawner への請求集約は扱わない
- **人間がネットワークの構造を変更する操作**Pod の spawn 元替え、ネットワークの組み替えなど)
- **daemon の導入**: Pod の発見は runtime_dir convention または軽量レジストリで対応。daemon は必要が明確になってから
- **Pod 間の直接メッセージパッシング**: すべてのやり取りは spawner を介するか、spawner が明示的に紹介した相手とのみ行う
- **常時接続**: Pod 間の接続はすべて一時的(都度接続 or コールバック一発)
- **runtime_dir のスキャンによる Pod 探索**: セキュリティリスクのため行わない。Pod の存在は spawn 記録 + 明示的な紹介 + lock file のみで把握
- **自動スケーリング / 負荷分散 / リモート実行**: ローカルの単一マシン上での動作に集中。リモート spawn は `docs/network-peering.md` を参照
- **課金・トークン予算管理**
- **環境再現git clone, コンテナ構築等)**: insomnia の責務外

View File

@ -1,101 +0,0 @@
# 複数 Pod 間の Scope 排他制御
## 背景
[scope-redesign.md](scope-redesign.md) で Scope は「allow / deny の領域リスト + permission レベル」に再設計された。
これにより複数 Pod が同じファイルツリーに対して異なる権限を宣言する状況が日常化する。
現状、複数 Pod が同じ pwd や重なる allow を持っても何のチェックも警告もない。
両者が同じファイルに `write` を持っていた場合、ツール経由で同時に書き換えて
内容を破壊しうる。Git のコミット粒度より細かい競合は履歴からも復元しづらい。
Pod を「並行作業の単位」として扱う以上、scope の重なりは設計時に検知・制御
できる必要がある。
## 要件
- **R1: write の重複を検出する**
同じファイルが 2 つ以上の Pod から `write` 権限で見えている状態を作らせない。
片方の Pod が起動中なら、もう片方の起動を拒否する(または昇格を拒否する)。
- **R2: read は共有可**
`read` のみの重なりは許す。複数の閲覧者は問題ない。
- **R3: deny は重なりに影響しない**
`deny` で write が落とされている path は、その Pod にとって write を持たないと
みなして他 Pod との重複判定を行う。
- **R4: 解放の確実性**
Pod の異常終了で排他が永久に残らないこと。lock の所有者プロセスが死んだら
自動解放されるか、stale lock として検知できる。
- **R5: 観測可能性**
ユーザーが「今どの Pod がどこを write 占有しているか」を見られる。
競合で起動が拒否されたとき、競合相手をエラーメッセージで示す。
## 設計上の論点
### 排他の粒度
選択肢:
- **A. ファイル単位** — 厳密だが lock 数が膨大になる
- **B. allow rule 単位** — 宣言された target ごとに lock。粒度が荒いが宣言と一致して直感的
- **C. パス prefix の最小被覆** — write 領域を最小の prefix に縮約して lock。中間
おすすめは **B**。Pod の宣言と lock の単位が一致する方が説明しやすく、
ユーザーが「この Pod は src を握っている」と理解しやすい。
### lock の保管場所
選択肢:
- **A. lock file**`~/.insomnia/locks/<hash>.lock` 等。各 Pod プロセスが直接握る
- **B. 中央レジストリプロセス** — 常駐デーモンが scope の貸し借りを管理
- **C. session-store の拡張** — 既存の永続化基盤に lock テーブルを足す
おすすめは **A**。デーモンを増やしたくないし、Pod は短命〜中時間生存の想定なので
ファイルロック (`flock(2)` / `fcntl`) で十分。stale lock 検知は PID 死活で行う。
### 競合発生時の挙動
選択肢:
- **A. 起動失敗 (fail-fast)** — エラーで起動拒否、ユーザーが手で解決
- **B. 待機 (block)** — 競合相手が解放するまで起動を待つ
- **C. 自動降格** — 競合する write を read に降格して起動
おすすめは **A**。並行作業の単位として Pod を起動するなら、競合は意図しない
状況であることが多い。明示エラーで気づける方が安全。`--wait` フラグや `--read-only`
フラグで B / C を選べるようにするのは将来拡張で十分。
### 取得タイミング
- Pod 起動時に scope 全体を一括取得し、終了時に解放
- 起動中に scope を変えることは現状想定しないmanifest 編集 → 再起動)
### resume との関係
`Pod::restore` でも同じ scope が要求される。前回終了時に解放されているはずなので
取得が成功するのが正常。失敗する場合は別 Pod が起動している = ユーザーが意図的に
2 つ動かそうとしている。fail-fast に乗せる。
## 影響範囲(想定)
実装時に詰める。現時点での見立て:
- 新規モジュール `crates/pod/src/scope_lock.rs`(または独立クレート `scope-lock`
- lock の取得 / 解放
- stale lock 検知PID 確認)
- 競合情報の収集(誰がどの allow を握っているか)
- `Pod::new` / `Pod::restore` の前段で lock 取得
- `Pod` の Drop / 明示解放で lock 返却
- `Controller` 層でのエラー伝搬とユーザー向けメッセージ
- CLI に「現在のロック一覧」を見るコマンド(観測性 R5
## 非ゴール
- **同一 Pod 内の並行性制御** — 1 Pod 内のツール並行実行は本 ticket では扱わない
- **ネットワーク越しの排他** — ローカルファイルロックのみ。複数マシンで同じワーキングコピーを共有する想定はしない
- **read-write lock の細かな格上げ/格下げ** — 取得時に確定、起動中の変更はしない
## 依存
- [scope-redesign.md](scope-redesign.md) — allow / deny / permission レベルの構造が前提