Compare commits

...

2 Commits

Author SHA1 Message Date
5fa060a748 pod-registryのモジュール分割 2026-04-29 20:14:34 +09:00
71434b9d8b scope-lock -> pod-registry 2026-04-29 20:01:32 +09:00
29 changed files with 1604 additions and 1522 deletions

32
Cargo.lock generated
View File

@ -2127,10 +2127,10 @@ dependencies = [
"manifest",
"memory",
"minijinja",
"pod-registry",
"protocol",
"provider",
"schemars",
"scope-lock",
"serde",
"serde_json",
"session-store",
@ -2142,6 +2142,20 @@ dependencies = [
"tracing",
]
[[package]]
name = "pod-registry"
version = "0.1.0"
dependencies = [
"fs4",
"libc",
"manifest",
"serde",
"serde_json",
"session-store",
"tempfile",
"thiserror 2.0.18",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
@ -2747,20 +2761,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "scope-lock"
version = "0.1.0"
dependencies = [
"fs4",
"libc",
"manifest",
"serde",
"serde_json",
"session-store",
"tempfile",
"thiserror 2.0.18",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@ -3587,9 +3587,9 @@ version = "0.1.0"
dependencies = [
"crossterm 0.28.1",
"manifest",
"pod-registry",
"protocol",
"ratatui",
"scope-lock",
"serde_json",
"session-store",
"tokio",

View File

@ -9,7 +9,7 @@ members = [
"crates/pod",
"crates/protocol",
"crates/provider",
"crates/scope-lock",
"crates/pod-registry",
"crates/tools",
"crates/tui", "crates/memory",
]

View File

@ -6,7 +6,7 @@
//! `providers.toml`, `models.toml`, `prompts/`, `prompts.toml` 等
//! - **`data_dir`** — プログラムが書く永続データ。`sessions/` 等
//! - **`runtime_dir`** — 再起動で消えてよいランタイム状態。socket,
//! `scope.lock`, `pid` ファイル等
//! `pods.json`, `pid` ファイル等
//!
//! ## 解決順 (優先順位高 → 低)
//!
@ -52,7 +52,7 @@ pub fn data_dir() -> Option<PathBuf> {
Some(env_path("HOME")?.join(".insomnia"))
}
/// ランタイムディレクトリ。socket, `scope.lock`, Pod ごとの `pid` /
/// ランタイムディレクトリ。socket, `pods.json`, Pod ごとの `pid` /
/// `status.json` 等が置かれる。再起動で消えて構わない。
pub fn runtime_dir() -> Option<PathBuf> {
if let Some(p) = env_path("INSOMNIA_RUNTIME_DIR") {
@ -95,9 +95,9 @@ pub fn sessions_dir() -> Option<PathBuf> {
Some(data_dir()?.join("sessions"))
}
/// `<runtime_dir>/scope.lock` — machine-wide scope allocation registry。
pub fn scope_lock_path() -> Option<PathBuf> {
Some(runtime_dir()?.join("scope.lock"))
/// `<runtime_dir>/pods.json` — machine-wide Pod allocation registry。
pub fn pod_registry_path() -> Option<PathBuf> {
Some(runtime_dir()?.join("pods.json"))
}
/// `<runtime_dir>/<pod_name>/` — Pod ごとのランタイムディレクトリ。
@ -302,8 +302,8 @@ mod tests {
);
assert_eq!(sessions_dir().unwrap(), PathBuf::from("/sand/sessions"));
assert_eq!(
scope_lock_path().unwrap(),
PathBuf::from("/sand/run/scope.lock")
pod_registry_path().unwrap(),
PathBuf::from("/sand/run/pods.json")
);
assert_eq!(
pod_runtime_dir("foo").unwrap(),

View File

@ -142,7 +142,7 @@ impl Scope {
/// Allow rules with their targets resolved to absolute paths.
///
/// Used by the scope-lock registry, where every Pod's allocation
/// Used by the pod-registry, where every Pod's allocation
/// must be expressed in absolute terms so prefix comparisons are
/// meaningful across processes.
pub fn allow_rules(&self) -> Vec<ScopeRule> {

View File

@ -1,5 +1,5 @@
[package]
name = "scope-lock"
name = "pod-registry"
version = "0.1.0"
edition.workspace = true
license.workspace = true

View File

@ -0,0 +1,203 @@
//! Pure functions that decide whether scope rules collide.
//!
//! These helpers are read-only over [`LockFile`]; they never touch the
//! file or the lock itself. The mutating operations in [`crate::mutate`]
//! call them under the [`crate::LockFileGuard`].
use manifest::{Permission, ScopeRule};
use crate::table::{Allocation, LockFile};
/// Whether `a` and `b` claim any overlapping concrete path.
///
/// Recursive rules cover `target/**`; non-recursive rules cover the
/// target itself and its direct children. The four cases enumerate
/// when those coverage sets intersect.
pub(crate) fn rules_overlap(a: &ScopeRule, b: &ScopeRule) -> bool {
match (a.recursive, b.recursive) {
(true, true) => a.target.starts_with(&b.target) || b.target.starts_with(&a.target),
(true, false) => {
// a covers a.target/**; b covers {b.target, b.target/*}.
b.target.starts_with(&a.target) || a.target.parent() == Some(b.target.as_path())
}
(false, true) => {
a.target.starts_with(&b.target) || b.target.parent() == Some(a.target.as_path())
}
(false, false) => {
a.target == b.target
|| a.target.parent() == Some(b.target.as_path())
|| b.target.parent() == Some(a.target.as_path())
}
}
}
/// Does `cover` fully contain `inner`'s claimed paths?
fn covers_fully(cover: &ScopeRule, inner: &ScopeRule) -> bool {
if cover.permission < inner.permission {
return false;
}
if cover.recursive {
inner.target.starts_with(&cover.target)
} else {
inner.target == cover.target && !inner.recursive
}
}
/// Check whether `rule` is contained in `parent`'s effective write
/// scope: its allow set covers `rule`, and no child of `parent` has
/// already taken a piece that would overlap `rule`.
pub fn is_within_effective_write(lock: &LockFile, parent: &str, rule: &ScopeRule) -> bool {
let Some(alloc) = lock.find(parent) else {
return false;
};
if rule.permission != Permission::Write {
return alloc.scope_allow.iter().any(|r| covers_fully(r, rule));
}
let covered = alloc
.scope_allow
.iter()
.filter(|r| r.permission == Permission::Write)
.any(|r| covers_fully(r, rule));
if !covered {
return false;
}
let child_conflict = lock
.allocations
.iter()
.filter(|a| a.delegated_from.as_deref() == Some(parent))
.flat_map(|a| a.scope_allow.iter())
.filter(|r| r.permission == Permission::Write)
.any(|r| rules_overlap(r, rule));
!child_conflict
}
/// Find the Pod that actually owns a write scope overlapping `rule`.
///
/// Walks the delegation tree: if an allocation overlaps `rule`, we
/// descend into its children and return the deepest overlapping node
/// as the true owner. `exempt` names a Pod whose ownership is
/// permitted (used during delegation: the spawner itself is allowed
/// to still own the rule's region because it is handing it down).
pub fn find_conflict_owner(
lock: &LockFile,
rule: &ScopeRule,
exempt: Option<&str>,
) -> Option<String> {
if rule.permission != Permission::Write {
return None;
}
for alloc in lock
.allocations
.iter()
.filter(|a| a.delegated_from.is_none())
{
if let Some(owner) = find_conflict_in_subtree(lock, alloc, rule) {
if Some(owner.as_str()) == exempt {
continue;
}
return Some(owner);
}
}
None
}
fn find_conflict_in_subtree(
lock: &LockFile,
alloc: &Allocation,
rule: &ScopeRule,
) -> Option<String> {
let overlaps_here = alloc
.scope_allow
.iter()
.filter(|r| r.permission == Permission::Write)
.any(|r| rules_overlap(r, rule));
if !overlaps_here {
return None;
}
for child in lock
.allocations
.iter()
.filter(|a| a.delegated_from.as_deref() == Some(alloc.pod_name.as_str()))
{
if let Some(owner) = find_conflict_in_subtree(lock, child, rule) {
return Some(owner);
}
}
Some(alloc.pod_name.clone())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::*;
use crate::{ScopeLockError, delegate_scope, register_pod};
use tempfile::TempDir;
#[test]
fn rules_overlap_prefix_relation() {
assert!(rules_overlap(
&write_rule("/src", true),
&write_rule("/src/core", true)
));
assert!(rules_overlap(
&write_rule("/src/core", true),
&write_rule("/src", true),
));
assert!(!rules_overlap(
&write_rule("/src", true),
&write_rule("/docs", true),
));
}
#[test]
fn rules_overlap_non_recursive() {
assert!(!rules_overlap(
&write_rule("/src", false),
&write_rule("/src/a/b", true),
));
assert!(rules_overlap(
&write_rule("/src", false),
&write_rule("/src/child", false),
));
}
#[test]
fn conflict_detection_descends_to_real_owner() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json");
let mut g = open_empty(&path);
register_pod(
&mut g,
"a".into(),
std::process::id(),
sock("a"),
vec![write_rule("/src", true)],
sid(),
)
.unwrap();
delegate_scope(
&mut g,
"a",
"b".into(),
std::process::id(),
sock("b"),
vec![write_rule("/src/core", true)],
)
.unwrap();
// A different top-level Pod trying to register /src/core/x
// should be blamed on B (deepest owner), not A.
let err = register_pod(
&mut g,
"x".into(),
std::process::id(),
sock("x"),
vec![write_rule("/src/core/x", true)],
sid(),
)
.unwrap_err();
match err {
ScopeLockError::WriteConflict { competitor, .. } => assert_eq!(competitor, "b"),
other => panic!("expected WriteConflict, got {other:?}"),
}
}
}

View File

@ -0,0 +1,34 @@
//! Error type for mutating pod-registry operations.
use std::io;
use std::path::PathBuf;
use manifest::ScopeRule;
use session_store::SessionId;
/// Errors raised by the mutating pod-registry operations.
#[derive(Debug, thiserror::Error)]
pub enum ScopeLockError {
#[error("I/O error on pods.json: {0}")]
Io(#[from] io::Error),
#[error("pod name `{0}` is already registered")]
DuplicatePodName(String),
#[error("requested scope `{}` conflicts with pod `{competitor}`", .rule.target.display())]
WriteConflict { competitor: String, rule: ScopeRule },
#[error(
"requested scope `{}` is not within spawner `{spawner}`'s effective scope",
.rule.target.display()
)]
NotSubset { spawner: String, rule: ScopeRule },
#[error("pod `{0}` is not registered")]
UnknownPod(String),
#[error(
"session {session_id} is already held by pod `{pod_name}` at {}",
.socket.display()
)]
SessionConflict {
session_id: SessionId,
pod_name: String,
socket: PathBuf,
},
}

View File

@ -0,0 +1,32 @@
//! Machine-wide Pod allocation registry.
//!
//! A single JSON file at `<runtime_dir>/pods.json` records every live
//! Pod's allocation (see [`manifest::paths::pod_registry_path`] for
//! how the path is resolved). File-level `flock(2)` serialises access
//! across processes so spawn sequences from unrelated Pods can't race.
//!
//! Each Pod, when starting, acquires the lock, reclaims stale entries
//! (Pods whose PID has died), checks that its requested write scope
//! does not overlap any other allocation's effective write scope, and
//! registers itself. When it exits normally, it removes its entry and
//! returns delegated scope to its `delegated_from` parent. Crash
//! recovery rides on the next Pod that opens the file — no background
//! reaper.
mod conflict;
mod error;
mod lifecycle;
mod mutate;
mod table;
#[cfg(test)]
mod test_util;
pub use conflict::{find_conflict_owner, is_within_effective_write};
pub use error::ScopeLockError;
pub use lifecycle::{
ScopeAllocationGuard, SessionLockInfo, adopt_allocation, install_top_level, lookup_session,
update_session,
};
pub use mutate::{delegate_scope, reclaim_stale, reclaim_stale_with, register_pod, release_pod};
pub use table::{Allocation, LockFile, LockFileGuard, default_registry_path};

View File

@ -0,0 +1,317 @@
//! Owned-allocation guards and the high-level entry points that open
//! the default registry path, mutate it, and return a guard that cleans
//! up on drop.
use std::path::{Path, PathBuf};
use manifest::ScopeRule;
use session_store::SessionId;
use crate::error::ScopeLockError;
use crate::mutate::{register_pod, release_pod};
use crate::table::{LockFileGuard, default_registry_path};
/// Owned allocation: on drop, opens the lock file and releases this
/// Pod's entry. The guard keeps only the name + lock-file path; it
/// does not hold the `flock` for the Pod's lifetime.
#[derive(Debug)]
pub struct ScopeAllocationGuard {
pod_name: String,
lock_path: PathBuf,
}
impl ScopeAllocationGuard {
pub fn pod_name(&self) -> &str {
&self.pod_name
}
pub fn lock_path(&self) -> &Path {
&self.lock_path
}
}
impl Drop for ScopeAllocationGuard {
fn drop(&mut self) {
if let Ok(mut guard) = LockFileGuard::open(&self.lock_path) {
let _ = release_pod(&mut guard, &self.pod_name);
}
}
}
/// Open the default lock file, register a top-level Pod, and return a
/// guard that will release the allocation on drop.
pub fn install_top_level(
pod_name: String,
pid: u32,
socket: PathBuf,
scope_allow: Vec<ScopeRule>,
session_id: SessionId,
) -> Result<ScopeAllocationGuard, ScopeLockError> {
let lock_path = default_registry_path()?;
let mut guard = LockFileGuard::open(&lock_path)?;
register_pod(
&mut guard,
pod_name.clone(),
pid,
socket,
scope_allow,
session_id,
)?;
Ok(ScopeAllocationGuard {
pod_name,
lock_path,
})
}
/// Take ownership of an existing allocation that was pre-registered by
/// a spawning Pod.
///
/// The spawning flow is two-stage: the spawner calls
/// [`crate::delegate_scope`] (with its own pid as a live placeholder,
/// `session_id = None`), then exec's the child; the child, once
/// running, calls this function to rewrite the allocation's pid +
/// session_id to its own and claim the [`ScopeAllocationGuard`] so
/// the entry is released when the child exits.
pub fn adopt_allocation(
pod_name: String,
new_pid: u32,
session_id: SessionId,
) -> Result<ScopeAllocationGuard, ScopeLockError> {
let lock_path = default_registry_path()?;
let mut guard = LockFileGuard::open(&lock_path)?;
let alloc = guard
.data_mut()
.find_mut(&pod_name)
.ok_or_else(|| ScopeLockError::UnknownPod(pod_name.clone()))?;
alloc.pid = new_pid;
alloc.session_id = Some(session_id);
guard.save()?;
Ok(ScopeAllocationGuard {
pod_name,
lock_path,
})
}
/// Rewrite the `session_id` recorded for `pod_name` to
/// `new_session_id`.
///
/// The Pod's in-memory `session_id` can change underneath the
/// allocation in two normal places:
///
/// - `Pod::compact` mints a fresh session and swaps it in.
/// - `session_store::ensure_head_or_fork` auto-forks when another
/// writer has advanced the store head behind our back.
///
/// Both paths must call this so subsequent [`lookup_session`] queries
/// find the live session id, not the old one. Without this update a
/// concurrent `restore_from_manifest(new_id)` would see "no live
/// writer" and proceed to register a competing allocation on the
/// session this Pod just moved into.
///
/// The lock is opened once and the allocation is rewritten inside the
/// guard, so the session_id collision check is atomic with the
/// rewrite.
pub fn update_session(pod_name: &str, new_session_id: SessionId) -> Result<(), ScopeLockError> {
let lock_path = default_registry_path()?;
let mut guard = LockFileGuard::open(&lock_path)?;
if let Some(other) = guard.data().find_by_session(new_session_id) {
if other.pod_name != pod_name {
return Err(ScopeLockError::SessionConflict {
session_id: new_session_id,
pod_name: other.pod_name.clone(),
socket: other.socket.clone(),
});
}
}
let alloc = guard
.data_mut()
.find_mut(pod_name)
.ok_or_else(|| ScopeLockError::UnknownPod(pod_name.into()))?;
alloc.session_id = Some(new_session_id);
guard.save()?;
Ok(())
}
/// Information about a Pod that currently holds an allocation for a
/// given session.
#[derive(Debug, Clone)]
pub struct SessionLockInfo {
pub pod_name: String,
pub socket: PathBuf,
pub pid: u32,
}
/// Open the default lock file, reclaim stale entries, and return the
/// allocation currently writing to `session_id`, if any.
///
/// Used by `Pod::restore_from_manifest` to refuse a resume that would
/// race a live writer on the same source session.
pub fn lookup_session(session_id: SessionId) -> Result<Option<SessionLockInfo>, ScopeLockError> {
let lock_path = default_registry_path()?;
let mut guard = LockFileGuard::open(&lock_path)?;
crate::mutate::reclaim_stale(&mut guard);
Ok(guard.data().find_by_session(session_id).map(|a| {
SessionLockInfo {
pod_name: a.pod_name.clone(),
socket: a.socket.clone(),
pid: a.pid,
}
}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::table::Allocation;
use crate::test_util::*;
use tempfile::TempDir;
/// Mimic what the spawner does before the child comes up: push an
/// allocation for the child carrying the spawner's (live) pid as a
/// placeholder. Exists only in tests.
fn delegate_placeholder(g: &mut LockFileGuard, pod_name: &str, placeholder_pid: u32) {
g.data_mut().allocations.push(Allocation {
pod_name: pod_name.to_string(),
pid: placeholder_pid,
socket: sock(pod_name),
scope_allow: vec![write_rule("/tmp/child", true)],
delegated_from: None,
session_id: None,
});
g.save().unwrap();
}
#[test]
fn scope_allocation_guard_releases_on_drop() {
let dir = TempDir::new().unwrap();
let _sandbox = RuntimeDirSandbox::new(dir.path());
let lock_path = dir.path().join("pods.json");
let guard = install_top_level(
"a".into(),
std::process::id(),
sock("a"),
vec![write_rule("/src", true)],
sid(),
)
.unwrap();
{
let g = LockFileGuard::open(&lock_path).unwrap();
assert!(g.data().find("a").is_some());
}
drop(guard);
{
let g = LockFileGuard::open(&lock_path).unwrap();
assert!(g.data().find("a").is_none());
}
}
#[test]
fn adopt_allocation_rewrites_pid_and_releases_on_drop() {
let dir = TempDir::new().unwrap();
let _sandbox = RuntimeDirSandbox::new(dir.path());
let lock_path = dir.path().join("pods.json");
// Pre-register an allocation under spawner's pid, as delegate_scope would.
{
let mut g = LockFileGuard::open(&lock_path).unwrap();
delegate_placeholder(&mut g, "child", std::process::id());
}
let child_pid = std::process::id().wrapping_add(1);
let guard = adopt_allocation("child".into(), child_pid, sid()).unwrap();
{
let g = LockFileGuard::open(&lock_path).unwrap();
let alloc = g.data().find("child").unwrap();
assert_eq!(alloc.pid, child_pid);
}
drop(guard);
{
let g = LockFileGuard::open(&lock_path).unwrap();
assert!(g.data().find("child").is_none());
}
}
#[test]
fn adopt_allocation_errors_on_unknown_pod() {
let dir = TempDir::new().unwrap();
let _sandbox = RuntimeDirSandbox::new(dir.path());
let err = adopt_allocation("ghost".into(), 42, sid()).unwrap_err();
assert!(matches!(err, ScopeLockError::UnknownPod(ref n) if n == "ghost"));
}
#[test]
fn lookup_session_returns_live_writer_info() {
let dir = TempDir::new().unwrap();
let _sandbox = RuntimeDirSandbox::new(dir.path());
let s = sid();
let guard = install_top_level(
"live".into(),
std::process::id(),
sock("live"),
vec![write_rule("/work", true)],
s,
)
.unwrap();
let info = lookup_session(s).unwrap().expect("expected live writer");
assert_eq!(info.pod_name, "live");
assert_eq!(info.socket, sock("live"));
drop(guard);
// After the guard's release, the lookup goes back to None.
assert!(lookup_session(s).unwrap().is_none());
}
#[test]
fn update_session_rewrites_allocation_session_id() {
let dir = TempDir::new().unwrap();
let _sandbox = RuntimeDirSandbox::new(dir.path());
let original = sid();
let updated = sid();
let _guard = install_top_level(
"p".into(),
std::process::id(),
sock("p"),
vec![write_rule("/work", true)],
original,
)
.unwrap();
update_session("p", updated).unwrap();
// lookup against the original is now empty, the updated id wins.
assert!(lookup_session(original).unwrap().is_none());
assert_eq!(lookup_session(updated).unwrap().unwrap().pod_name, "p");
}
#[test]
fn update_session_rejects_when_target_already_held() {
let dir = TempDir::new().unwrap();
let _sandbox = RuntimeDirSandbox::new(dir.path());
let s_a = sid();
let s_b = sid();
let _g_a = install_top_level(
"a".into(),
std::process::id(),
sock("a"),
vec![write_rule("/work/a", true)],
s_a,
)
.unwrap();
let _g_b = install_top_level(
"b".into(),
std::process::id(),
sock("b"),
vec![write_rule("/work/b", true)],
s_b,
)
.unwrap();
// `a` cannot adopt b's live session id.
let err = update_session("a", s_b).unwrap_err();
match err {
ScopeLockError::SessionConflict {
pod_name,
session_id,
..
} => {
assert_eq!(pod_name, "b");
assert_eq!(session_id, s_b);
}
other => panic!("expected SessionConflict, got {other:?}"),
}
}
}

View File

@ -0,0 +1,567 @@
//! Mutating operations over the allocation table. All of these expect
//! the caller to hold a [`LockFileGuard`] for the registry's lock file.
use std::io;
use std::path::PathBuf;
use manifest::{Permission, ScopeRule};
use session_store::SessionId;
use crate::conflict::{find_conflict_owner, is_within_effective_write};
use crate::error::ScopeLockError;
use crate::table::{Allocation, LockFileGuard};
/// Register a top-level Pod (started directly by a human, no
/// delegation parent). Reclaims stale entries before checking
/// conflicts so a crashed Pod's allocation doesn't block the new one.
///
/// Rejects when another live allocation is already writing to
/// `session_id`, so two `restore_from_manifest` calls under different
/// `pod_name`s cannot both grab the same session log.
pub fn register_pod(
guard: &mut LockFileGuard,
pod_name: String,
pid: u32,
socket: PathBuf,
scope_allow: Vec<ScopeRule>,
session_id: SessionId,
) -> Result<(), ScopeLockError> {
reclaim_stale(guard);
if guard.data().find(&pod_name).is_some() {
return Err(ScopeLockError::DuplicatePodName(pod_name));
}
if let Some(existing) = guard.data().find_by_session(session_id) {
return Err(ScopeLockError::SessionConflict {
session_id,
pod_name: existing.pod_name.clone(),
socket: existing.socket.clone(),
});
}
for rule in scope_allow
.iter()
.filter(|r| r.permission == Permission::Write)
{
if let Some(competitor) = find_conflict_owner(guard.data(), rule, None) {
return Err(ScopeLockError::WriteConflict {
competitor,
rule: rule.clone(),
});
}
}
guard.data_mut().allocations.push(Allocation {
pod_name,
pid,
socket,
scope_allow,
delegated_from: None,
session_id: Some(session_id),
});
guard.save()?;
Ok(())
}
/// Register a spawned Pod whose scope is delegated from `spawner`.
/// The requested scope must be within `spawner`'s effective write
/// scope; overlap with any Pod other than `spawner` is a conflict.
pub fn delegate_scope(
guard: &mut LockFileGuard,
spawner: &str,
spawned: String,
pid: u32,
socket: PathBuf,
scope_allow: Vec<ScopeRule>,
) -> Result<(), ScopeLockError> {
reclaim_stale(guard);
if guard.data().find(&spawned).is_some() {
return Err(ScopeLockError::DuplicatePodName(spawned));
}
if guard.data().find(spawner).is_none() {
return Err(ScopeLockError::UnknownPod(spawner.into()));
}
for rule in &scope_allow {
if !is_within_effective_write(guard.data(), spawner, rule) {
return Err(ScopeLockError::NotSubset {
spawner: spawner.into(),
rule: rule.clone(),
});
}
if rule.permission == Permission::Write {
if let Some(competitor) = find_conflict_owner(guard.data(), rule, Some(spawner)) {
return Err(ScopeLockError::WriteConflict {
competitor,
rule: rule.clone(),
});
}
}
}
guard.data_mut().allocations.push(Allocation {
pod_name: spawned,
pid,
socket,
scope_allow,
delegated_from: Some(spawner.into()),
// Pre-reservation. The child fills in its own session_id when
// it calls `adopt_allocation` after the worker is built.
session_id: None,
});
guard.save()?;
Ok(())
}
/// Remove a Pod's allocation. Surviving children are reparented to
/// the removed Pod's own `delegated_from`, so the delegation tree
/// stays connected.
pub fn release_pod(guard: &mut LockFileGuard, pod_name: &str) -> Result<(), ScopeLockError> {
let idx = guard
.data()
.allocations
.iter()
.position(|a| a.pod_name == pod_name);
let Some(idx) = idx else {
return Err(ScopeLockError::UnknownPod(pod_name.into()));
};
let removed = guard.data().allocations[idx].clone();
for alloc in guard.data_mut().allocations.iter_mut() {
if alloc.delegated_from.as_deref() == Some(pod_name) {
alloc.delegated_from.clone_from(&removed.delegated_from);
}
}
guard.data_mut().allocations.remove(idx);
guard.save()?;
Ok(())
}
/// Remove allocations whose PID is dead, reparenting children to the
/// dead Pod's `delegated_from`. Idempotent and best-effort — I/O
/// errors on save are swallowed so a crashed Pod's entry never blocks
/// forward progress.
pub fn reclaim_stale(guard: &mut LockFileGuard) {
reclaim_stale_with(guard, pid_alive);
}
/// Test seam: stale reclaim with a caller-supplied liveness probe.
pub fn reclaim_stale_with(guard: &mut LockFileGuard, mut is_alive: impl FnMut(u32) -> bool) {
let dead: Vec<String> = guard
.data()
.allocations
.iter()
.filter(|a| !is_alive(a.pid))
.map(|a| a.pod_name.clone())
.collect();
if dead.is_empty() {
return;
}
for name in &dead {
let Some(idx) = guard
.data()
.allocations
.iter()
.position(|a| a.pod_name == *name)
else {
continue;
};
let removed = guard.data().allocations[idx].clone();
for alloc in guard.data_mut().allocations.iter_mut() {
if alloc.delegated_from.as_deref() == Some(name.as_str()) {
alloc.delegated_from.clone_from(&removed.delegated_from);
}
}
guard.data_mut().allocations.remove(idx);
}
let _ = guard.save();
}
/// `kill(pid, 0)` — returns true if the process exists (even when we
/// don't own it), false only on ESRCH.
fn pid_alive(pid: u32) -> bool {
if pid == 0 {
return false;
}
let ret = unsafe { libc::kill(pid as libc::pid_t, 0) };
if ret == 0 {
return true;
}
io::Error::last_os_error()
.raw_os_error()
.map(|e| e != libc::ESRCH)
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::is_within_effective_write;
use crate::test_util::*;
use tempfile::TempDir;
#[test]
fn register_detects_write_conflict() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json");
let mut g = open_empty(&path);
register_pod(
&mut g,
"a".into(),
std::process::id(),
sock("a"),
vec![write_rule("/src", true)],
sid(),
)
.unwrap();
let err = register_pod(
&mut g,
"b".into(),
std::process::id(),
sock("b"),
vec![write_rule("/src/core", true)],
sid(),
)
.unwrap_err();
match err {
ScopeLockError::WriteConflict { competitor, .. } => assert_eq!(competitor, "a"),
other => panic!("expected WriteConflict, got {other:?}"),
}
}
#[test]
fn duplicate_pod_name_rejected() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json");
let mut g = open_empty(&path);
register_pod(
&mut g,
"a".into(),
std::process::id(),
sock("a"),
vec![write_rule("/src", true)],
sid(),
)
.unwrap();
let err = register_pod(
&mut g,
"a".into(),
std::process::id(),
sock("a2"),
vec![write_rule("/docs", true)],
sid(),
)
.unwrap_err();
assert!(matches!(err, ScopeLockError::DuplicatePodName(ref n) if n == "a"));
}
#[test]
fn delegate_must_be_subset() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json");
let mut g = open_empty(&path);
register_pod(
&mut g,
"a".into(),
std::process::id(),
sock("a"),
vec![write_rule("/src", true)],
sid(),
)
.unwrap();
let err = delegate_scope(
&mut g,
"a",
"b".into(),
std::process::id(),
sock("b"),
vec![write_rule("/docs", true)],
)
.unwrap_err();
assert!(matches!(err, ScopeLockError::NotSubset { .. }));
}
#[test]
fn delegate_succeeds_within_parent_scope() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json");
let mut g = open_empty(&path);
register_pod(
&mut g,
"a".into(),
std::process::id(),
sock("a"),
vec![write_rule("/src", true)],
sid(),
)
.unwrap();
delegate_scope(
&mut g,
"a",
"b".into(),
std::process::id(),
sock("b"),
vec![write_rule("/src/core", true)],
)
.unwrap();
assert_eq!(g.data().allocations.len(), 2);
// A's effective write no longer covers /src/core because B has it.
assert!(!is_within_effective_write(
g.data(),
"a",
&write_rule("/src/core", true)
));
// A still covers its own uninvolved areas.
assert!(is_within_effective_write(
g.data(),
"a",
&write_rule("/src/other", true)
));
}
#[test]
fn delegate_rejects_sibling_overlap() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json");
let mut g = open_empty(&path);
register_pod(
&mut g,
"a".into(),
std::process::id(),
sock("a"),
vec![write_rule("/src", true)],
sid(),
)
.unwrap();
delegate_scope(
&mut g,
"a",
"b".into(),
std::process::id(),
sock("b"),
vec![write_rule("/src/core", true)],
)
.unwrap();
// Sibling C from A tries to take /src/core/sub — already under B's scope.
let err = delegate_scope(
&mut g,
"a",
"c".into(),
std::process::id(),
sock("c"),
vec![write_rule("/src/core/sub", true)],
)
.unwrap_err();
// NotSubset fires first because /src/core is no longer in A's effective.
assert!(matches!(err, ScopeLockError::NotSubset { .. }));
}
#[test]
fn release_reparents_children() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json");
let mut g = open_empty(&path);
register_pod(
&mut g,
"a".into(),
std::process::id(),
sock("a"),
vec![write_rule("/src", true)],
sid(),
)
.unwrap();
delegate_scope(
&mut g,
"a",
"b".into(),
std::process::id(),
sock("b"),
vec![write_rule("/src/core", true)],
)
.unwrap();
delegate_scope(
&mut g,
"b",
"d".into(),
std::process::id(),
sock("d"),
vec![write_rule("/src/core/x", true)],
)
.unwrap();
release_pod(&mut g, "b").unwrap();
// D should now list A as its delegated_from.
let d = g.data().find("d").unwrap();
assert_eq!(d.delegated_from.as_deref(), Some("a"));
assert!(g.data().find("b").is_none());
}
#[test]
fn reclaim_stale_reparents_and_removes_dead_entries() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json");
let mut g = open_empty(&path);
register_pod(
&mut g,
"a".into(),
std::process::id(),
sock("a"),
vec![write_rule("/src", true)],
sid(),
)
.unwrap();
delegate_scope(
&mut g,
"a",
"b".into(),
std::process::id(),
sock("b"),
vec![write_rule("/src/core", true)],
)
.unwrap();
delegate_scope(
&mut g,
"b",
"d".into(),
std::process::id(),
sock("d"),
vec![write_rule("/src/core/x", true)],
)
.unwrap();
// Simulate B crashing by rewriting its pid to one the probe
// will treat as dead.
let fake_dead_pid: u32 = 0xffff_fff0;
for alloc in g.data_mut().allocations.iter_mut() {
if alloc.pod_name == "b" {
alloc.pid = fake_dead_pid;
}
}
reclaim_stale_with(&mut g, |pid| pid != fake_dead_pid);
assert!(g.data().find("b").is_none());
let d = g.data().find("d").unwrap();
assert_eq!(d.delegated_from.as_deref(), Some("a"));
}
#[test]
fn read_rules_do_not_conflict_with_write() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json");
let mut g = open_empty(&path);
register_pod(
&mut g,
"a".into(),
std::process::id(),
sock("a"),
vec![write_rule("/src", true)],
sid(),
)
.unwrap();
// B only reads under the same tree — allowed.
register_pod(
&mut g,
"b".into(),
std::process::id(),
sock("b"),
vec![read_rule("/src", true)],
sid(),
)
.unwrap();
assert_eq!(g.data().allocations.len(), 2);
}
#[test]
fn releasing_pod_reopens_scope_for_fresh_registration() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json");
let mut g = open_empty(&path);
register_pod(
&mut g,
"a".into(),
std::process::id(),
sock("a"),
vec![write_rule("/src", true)],
sid(),
)
.unwrap();
release_pod(&mut g, "a").unwrap();
register_pod(
&mut g,
"b".into(),
std::process::id(),
sock("b"),
vec![write_rule("/src", true)],
sid(),
)
.unwrap();
}
#[test]
fn delegated_scope_returns_to_parent_on_release() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json");
let mut g = open_empty(&path);
register_pod(
&mut g,
"a".into(),
std::process::id(),
sock("a"),
vec![write_rule("/src", true)],
sid(),
)
.unwrap();
delegate_scope(
&mut g,
"a",
"b".into(),
std::process::id(),
sock("b"),
vec![write_rule("/src/core", true)],
)
.unwrap();
assert!(!is_within_effective_write(
g.data(),
"a",
&write_rule("/src/core", true)
));
release_pod(&mut g, "b").unwrap();
// /src/core is back in A's effective write scope.
assert!(is_within_effective_write(
g.data(),
"a",
&write_rule("/src/core", true)
));
}
#[test]
fn register_pod_rejects_session_id_collision() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json");
let mut g = open_empty(&path);
let shared_session = sid();
register_pod(
&mut g,
"first".into(),
std::process::id(),
sock("first"),
vec![write_rule("/work/a", true)],
shared_session,
)
.unwrap();
// Second registration tries to grab the same session_id under
// a different pod_name. Without the SessionConflict check both
// would succeed and race on the same jsonl.
let err = register_pod(
&mut g,
"second".into(),
std::process::id(),
sock("second"),
vec![write_rule("/work/b", true)],
shared_session,
)
.unwrap_err();
match err {
ScopeLockError::SessionConflict {
session_id,
pod_name,
..
} => {
assert_eq!(session_id, shared_session);
assert_eq!(pod_name, "first");
}
other => panic!("expected SessionConflict, got {other:?}"),
}
}
}

View File

@ -0,0 +1,259 @@
//! On-disk allocation table and the `flock`-protected guard.
use std::fs::{DirBuilder, File, OpenOptions};
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
use std::path::{Path, PathBuf};
use fs4::fs_std::FileExt;
use manifest::{ScopeRule, paths};
use serde::{Deserialize, Serialize};
use session_store::SessionId;
/// On-disk representation of the allocation table.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LockFile {
#[serde(default)]
pub allocations: Vec<Allocation>,
}
/// One Pod's scope allocation.
///
/// `scope_allow` is the full set of allow rules the Pod was granted.
/// Portions delegated out to child Pods are **not** subtracted in
/// storage — the effective write scope is derived on the fly by
/// removing rules owned by any Pod whose `delegated_from` points to
/// this one. Keeping the raw allow set makes reparenting (stale
/// reclaim) trivial.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Allocation {
/// Pod name — also the identity used throughout orchestration.
pub pod_name: String,
/// Owning process. Checked with `kill(pid, 0)` for stale detection.
pub pid: u32,
/// Pod's Unix socket path.
pub socket: PathBuf,
/// Allow rules granted to this Pod (write + read).
pub scope_allow: Vec<ScopeRule>,
/// Name of the Pod that delegated scope to this one, or `None` for
/// a top-level Pod started directly by a human.
pub delegated_from: Option<String>,
/// Session ID this Pod is currently writing to. `None` means this
/// is a pre-reservation made by a spawner via [`crate::delegate_scope`]
/// before the child has come up; the child fills it in at
/// [`crate::adopt_allocation`] time.
#[serde(default)]
pub session_id: Option<SessionId>,
}
impl LockFile {
pub fn find(&self, pod_name: &str) -> Option<&Allocation> {
self.allocations.iter().find(|a| a.pod_name == pod_name)
}
pub fn find_mut(&mut self, pod_name: &str) -> Option<&mut Allocation> {
self.allocations.iter_mut().find(|a| a.pod_name == pod_name)
}
/// Find the allocation currently writing to `session_id`. Skips
/// pre-reservations whose `session_id` is still `None`.
pub fn find_by_session(&self, session_id: SessionId) -> Option<&Allocation> {
self.allocations
.iter()
.find(|a| a.session_id == Some(session_id))
}
}
/// Default on-disk path: `<runtime_dir>/pods.json` resolved via
/// [`manifest::paths::pod_registry_path`]. Tests should point this
/// elsewhere by setting `INSOMNIA_HOME` or `INSOMNIA_RUNTIME_DIR` to a
/// tempdir.
pub fn default_registry_path() -> io::Result<PathBuf> {
paths::pod_registry_path().ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"could not resolve pods.json path (no INSOMNIA_HOME / \
INSOMNIA_RUNTIME_DIR / XDG_RUNTIME_DIR / HOME)",
)
})
}
/// RAII guard over an exclusively-locked lock file.
///
/// The file is kept open for the lifetime of the guard; `flock(LOCK_EX)`
/// is released automatically on drop. Mutations go through
/// [`LockFileGuard::data_mut`] and are committed with
/// [`LockFileGuard::save`] before dropping — callers who mutate but
/// never call `save` leave the table unchanged, which is the right
/// behaviour for error paths.
pub struct LockFileGuard {
file: File,
data: LockFile,
}
impl LockFileGuard {
/// Open the lock file at `path` (creating it + parent dirs if
/// needed), acquire an exclusive `flock`, then parse the contents.
///
/// An empty file is treated as an empty allocation table.
///
/// File is created with mode `0600` and its parent directory with
/// mode `0700` so no other user on the machine can read the
/// allocation table. Existing files/directories are left alone.
pub fn open(path: &Path) -> io::Result<Self> {
if let Some(parent) = path.parent() {
DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(parent)?;
}
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.open(path)?;
FileExt::lock_exclusive(&file)?;
let mut this = Self {
file,
data: LockFile::default(),
};
this.reload()?;
Ok(this)
}
fn reload(&mut self) -> io::Result<()> {
self.file.seek(SeekFrom::Start(0))?;
let mut buf = String::new();
self.file.read_to_string(&mut buf)?;
self.data = if buf.trim().is_empty() {
LockFile::default()
} else {
serde_json::from_str(&buf).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("pods.json parse error: {e}"),
)
})?
};
Ok(())
}
pub fn data(&self) -> &LockFile {
&self.data
}
pub fn data_mut(&mut self) -> &mut LockFile {
&mut self.data
}
/// Serialise `self.data` back to the file (truncate + rewrite).
pub fn save(&mut self) -> io::Result<()> {
let json = serde_json::to_vec_pretty(&self.data).map_err(io::Error::other)?;
self.file.seek(SeekFrom::Start(0))?;
self.file.set_len(0)?;
self.file.write_all(&json)?;
self.file.sync_data()?;
Ok(())
}
}
impl Drop for LockFileGuard {
fn drop(&mut self) {
let _ = FileExt::unlock(&self.file);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::register_pod;
use crate::test_util::*;
use tempfile::TempDir;
#[test]
fn open_creates_empty_lock_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json");
let guard = LockFileGuard::open(&path).unwrap();
assert!(guard.data().allocations.is_empty());
assert!(path.exists());
}
#[test]
fn open_creates_file_with_owner_only_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let parent = dir.path().join("insomnia");
let path = parent.join("pods.json");
let _guard = LockFileGuard::open(&path).unwrap();
let file_mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(file_mode, 0o600, "file mode = {file_mode:o}");
let dir_mode = std::fs::metadata(&parent).unwrap().permissions().mode() & 0o777;
assert_eq!(dir_mode, 0o700, "dir mode = {dir_mode:o}");
}
#[test]
fn save_and_reopen_roundtrip() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json");
{
let mut g = open_empty(&path);
register_pod(
&mut g,
"a".into(),
std::process::id(),
sock("a"),
vec![write_rule("/src", true)],
sid(),
)
.unwrap();
}
let guard = LockFileGuard::open(&path).unwrap();
assert_eq!(guard.data().allocations.len(), 1);
assert_eq!(guard.data().allocations[0].pod_name, "a");
}
#[test]
fn find_by_session_skips_none_placeholders() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json");
let mut g = open_empty(&path);
// Pre-reservation: delegate_scope leaves session_id = None
// until adopt_allocation rewrites it. find_by_session must not
// match those placeholders, otherwise a freshly-spawning child
// would shadow itself before it has even chosen a session.
register_pod(
&mut g,
"parent".into(),
std::process::id(),
sock("parent"),
vec![write_rule("/p", true)],
sid(),
)
.unwrap();
crate::delegate_scope(
&mut g,
"parent",
"child".into(),
std::process::id(),
sock("child"),
vec![write_rule("/p/sub", true)],
)
.unwrap();
let target_session = sid();
// The placeholder allocation has session_id = None and must
// not be returned for any lookup.
assert!(g.data().find_by_session(target_session).is_none());
// After adopt-style rewrite, the same allocation is now found.
g.data_mut()
.find_mut("child")
.unwrap()
.session_id = Some(target_session);
let found = g.data().find_by_session(target_session).unwrap();
assert_eq!(found.pod_name, "child");
}
}

View File

@ -0,0 +1,97 @@
//! Shared test helpers for the pod-registry crate.
//!
//! Visible to all `#[cfg(test)]` modules under `crate::test_util::*`.
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex, MutexGuard};
use manifest::{Permission, ScopeRule};
use session_store::SessionId;
use crate::table::LockFileGuard;
pub(crate) fn sid() -> SessionId {
session_store::new_session_id()
}
/// Serialises tests that mutate runtime-dir env vars. The test
/// harness runs tests on multiple threads inside a single process,
/// so env-var writes from one test would otherwise leak into a
/// parallel test's `default_registry_path()` lookup.
pub(crate) static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
/// Sandbox `INSOMNIA_RUNTIME_DIR` to a tempdir for the duration of
/// a test; restore the previous value (and any `INSOMNIA_HOME` /
/// `XDG_RUNTIME_DIR` that would otherwise outrank it) on drop.
pub(crate) struct RuntimeDirSandbox {
prev_runtime: Option<String>,
prev_home: Option<String>,
prev_xdg: Option<String>,
_guard: MutexGuard<'static, ()>,
}
impl RuntimeDirSandbox {
pub(crate) fn new(dir: &Path) -> Self {
let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let prev_runtime = std::env::var("INSOMNIA_RUNTIME_DIR").ok();
let prev_home = std::env::var("INSOMNIA_HOME").ok();
let prev_xdg = std::env::var("XDG_RUNTIME_DIR").ok();
// SAFETY: ENV_LOCK serialises env writes across this test
// module; other modules that touch env vars rely on their
// own lock or `serial_test`.
unsafe {
std::env::remove_var("INSOMNIA_HOME");
std::env::remove_var("XDG_RUNTIME_DIR");
std::env::set_var("INSOMNIA_RUNTIME_DIR", dir);
}
Self {
prev_runtime,
prev_home,
prev_xdg,
_guard: guard,
}
}
}
impl Drop for RuntimeDirSandbox {
fn drop(&mut self) {
unsafe {
match &self.prev_runtime {
Some(v) => std::env::set_var("INSOMNIA_RUNTIME_DIR", v),
None => std::env::remove_var("INSOMNIA_RUNTIME_DIR"),
}
match &self.prev_home {
Some(v) => std::env::set_var("INSOMNIA_HOME", v),
None => std::env::remove_var("INSOMNIA_HOME"),
}
match &self.prev_xdg {
Some(v) => std::env::set_var("XDG_RUNTIME_DIR", v),
None => std::env::remove_var("XDG_RUNTIME_DIR"),
}
}
}
}
pub(crate) fn write_rule(path: &str, recursive: bool) -> ScopeRule {
ScopeRule {
target: PathBuf::from(path),
permission: Permission::Write,
recursive,
}
}
pub(crate) fn read_rule(path: &str, recursive: bool) -> ScopeRule {
ScopeRule {
target: PathBuf::from(path),
permission: Permission::Read,
recursive,
}
}
pub(crate) fn sock(name: &str) -> PathBuf {
PathBuf::from(format!("/tmp/{name}.sock"))
}
pub(crate) fn open_empty(path: &Path) -> LockFileGuard {
LockFileGuard::open(path).unwrap()
}

View File

@ -12,7 +12,7 @@ session-store = { version = "0.1.0", path = "../session-store" }
manifest = { version = "0.1.0", path = "../manifest" }
protocol = { version = "0.1.0", path = "../protocol" }
provider = { version = "0.1.0", path = "../provider" }
scope-lock = { version = "0.1.0", path = "../scope-lock" }
pod-registry = { version = "0.1.0", path = "../pod-registry" }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
thiserror = "2.0"

View File

@ -8,7 +8,7 @@
//! logging failures without blocking the child.
//! - **Render** a variant into a human-readable string that the parent's
//! LLM sees via the notification buffer.
//! - **Apply side effects** on the parent (registry / scope-lock
//! - **Apply side effects** on the parent (registry / pod-registry
//! updates) so that the receive path is idempotent and tolerant of
//! out-of-order delivery.
//!
@ -27,7 +27,7 @@ use std::sync::Arc;
use protocol::{Method, PodEvent, ScopeRule};
use crate::runtime::dir::SpawnedPodRecord;
use crate::runtime::scope_lock::{self, ScopeLockError};
use crate::runtime::pod_registry::{self, ScopeLockError};
use crate::spawn::comm_tools::connect_and_send;
use crate::spawn::registry::SpawnedPodRegistry;
@ -146,21 +146,21 @@ pub async fn apply_event_side_effects(
}
fn release_scope_silently(pod_name: &str) {
let lock_path = match scope_lock::default_lock_path() {
let lock_path = match pod_registry::default_registry_path() {
Ok(p) => p,
Err(e) => {
tracing::warn!(error = %e, "default_lock_path failed");
tracing::warn!(error = %e, "default_registry_path failed");
return;
}
};
let mut guard = match scope_lock::LockFileGuard::open(&lock_path) {
let mut guard = match pod_registry::LockFileGuard::open(&lock_path) {
Ok(g) => g,
Err(e) => {
tracing::warn!(error = %e, "LockFileGuard open failed");
return;
}
};
match scope_lock::release_pod(&mut guard, pod_name) {
match pod_registry::release_pod(&mut guard, pod_name) {
Ok(()) => {}
Err(ScopeLockError::UnknownPod(_)) => {}
Err(e) => tracing::warn!(error = ?e, pod = %pod_name, "release_pod failed"),

View File

@ -46,7 +46,7 @@ struct Cli {
/// Restore a Pod from an existing session. The Pod re-uses the
/// given session id and appends new turns to the same jsonl;
/// concurrent writers are prevented by the `scope.lock` registry.
/// concurrent writers are prevented by the pod-registry.
/// Mutually exclusive with `--adopt` (spawned children always start
/// fresh).
#[arg(long, value_name = "UUID", conflicts_with = "adopt")]

View File

@ -26,7 +26,7 @@ use crate::prompt::catalog::{CatalogError, PromptCatalog};
use crate::prompt::loader::PromptLoader;
use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
use crate::runtime::dir;
use crate::runtime::scope_lock::{self, ScopeAllocationGuard, ScopeLockError};
use crate::runtime::pod_registry::{self, ScopeAllocationGuard, ScopeLockError};
use async_trait::async_trait;
use llm_worker::interceptor::PreRequestAction;
use protocol::{AlertLevel, AlertSource, Event, Segment};
@ -727,11 +727,11 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
)
.await?;
// ensure_head_or_fork mints a fresh session_id when it auto-
// forks. Sync that to scope.lock so a concurrent
// forks. Sync that to pods.json so a concurrent
// restore_from_manifest can't see "no live writer" for the new
// session and grab it.
if self.session_id != prev_session_id && self.scope_allocation.is_some() {
scope_lock::update_session(&self.manifest.pod.name, self.session_id)?;
pod_registry::update_session(&self.manifest.pod.name, self.session_id)?;
}
Ok(())
}
@ -1164,14 +1164,14 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
// until its first LLM call.
self.session_id = new_session_id;
self.head_hash = Some(new_head_hash);
// Keep scope.lock pointing at the live session_id. Without this
// Keep pods.json pointing at the live session_id. Without this
// a concurrent `restore_from_manifest(new_session_id)` would
// see no live writer and grab the session this Pod just moved
// into, causing two writers to race on the same jsonl. Skipped
// when no allocation is installed (e.g. compact under
// `Pod::new` in tests).
if self.scope_allocation.is_some() {
scope_lock::update_session(&self.manifest.pod.name, new_session_id)?;
pod_registry::update_session(&self.manifest.pod.name, new_session_id)?;
}
let worker = self.worker.as_mut().unwrap();
worker.set_history(new_history);
@ -1493,18 +1493,18 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
// Session creation is deferred to the first run (see
// `ensure_session_head`) so the SessionStart entry can capture
// the rendered system prompt, not the raw template source. The
// session_id is allocated here so the scope-lock registration
// session_id is allocated here so the pod-registry registration
// can record it from the start.
let session_id = session_store::new_session_id();
// Register this Pod in the machine-wide scope-lock registry
// Register this Pod in the machine-wide pod-registry
// before building anything else, so a spawn that conflicts on
// scope fails fast.
let socket_path = dir::default_base()
.map_err(ScopeLockError::from)?
.join(&manifest.pod.name)
.join("sock");
let scope_allocation = scope_lock::install_top_level(
let scope_allocation = pod_registry::install_top_level(
manifest.pod.name.clone(),
std::process::id(),
socket_path,
@ -1548,7 +1548,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
///
/// Behaves like [`Pod::from_manifest`] but claims the scope
/// allocation that the spawner pre-registered via
/// [`scope_lock::delegate_scope`], rather than installing a new
/// [`pod_registry::delegate_scope`], rather than installing a new
/// top-level entry. `callback_socket` carries the spawner's
/// Unix-socket path so the spawned Pod can send `Method::Notify`
/// back to the spawner.
@ -1562,7 +1562,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
let session_id = session_store::new_session_id();
let scope_allocation =
scope_lock::adopt_allocation(manifest.pod.name.clone(), std::process::id(), session_id)?;
pod_registry::adopt_allocation(manifest.pod.name.clone(), std::process::id(), session_id)?;
let mut worker = Worker::new(common.client);
apply_worker_manifest(&mut worker, &manifest.worker);
@ -1599,14 +1599,14 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
/// Restore a Pod from an existing session log.
///
/// Resolves the manifest cascade exactly like [`Self::from_manifest`]
/// (pwd / scope / scope-lock / client / prompt catalog), seeds a
/// (pwd / scope / pod-registry / client / prompt catalog), seeds a
/// fresh Worker from the source session's `RestoredState`, and
/// reuses the same `session_id` so subsequent turns append to the
/// source jsonl as a continuation of the same conversation.
///
/// Concurrent writers are prevented by the `scope.lock` registry:
/// Concurrent writers are prevented by the pod-registry:
/// the registration carries `session_id`, and this constructor
/// refuses to start when `scope_lock::lookup_session` already finds
/// refuses to start when `pod_registry::lookup_session` already finds
/// a live Pod writing to `session_id`. So there is no need to fork —
/// resume is "the same session, a different process owning it".
///
@ -1636,7 +1636,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
.map_err(ScopeLockError::from)?
.join(&manifest.pod.name)
.join("sock");
let scope_allocation = scope_lock::install_top_level(
let scope_allocation = pod_registry::install_top_level(
manifest.pod.name.clone(),
std::process::id(),
socket_path,

View File

@ -11,7 +11,7 @@ use crate::shared_state::PodSharedState;
///
/// Written by the spawner after a successful `SpawnPod` tool call so
/// `ListPods` (future ticket) and a restored spawner can enumerate
/// their live children without re-querying `scope.lock`.
/// their live children without re-querying `pods.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpawnedPodRecord {
/// Spawned Pod's identity.

View File

@ -1,2 +1,2 @@
pub mod dir;
pub use ::scope_lock;
pub use ::pod_registry;

View File

@ -22,7 +22,7 @@ use serde::Deserialize;
use tokio::net::UnixStream;
use crate::runtime::dir::SpawnedPodRecord;
use crate::runtime::scope_lock::{self, LockFileGuard};
use crate::runtime::pod_registry::{self, LockFileGuard};
use crate::spawn::registry::SpawnedPodRegistry;
/// Timeout applied to each socket-level operation — connect, write,
@ -283,10 +283,10 @@ impl Tool for ListPodsTool {
// allocation table doesn't keep growing indefinitely when
// children crash without a clean exit path.
if !stale_names.is_empty() {
if let Ok(lock_path) = scope_lock::default_lock_path()
if let Ok(lock_path) = pod_registry::default_registry_path()
&& let Ok(mut guard) = LockFileGuard::open(&lock_path)
{
scope_lock::reclaim_stale(&mut guard);
pod_registry::reclaim_stale(&mut guard);
}
}
@ -475,11 +475,11 @@ fn summarize_scope(record: &SpawnedPodRecord) -> String {
/// effects (Method::Shutdown was sent), and stale-reclaim will clean
/// up whatever we couldn't.
fn release_scope(pod_name: &str) {
let Ok(lock_path) = scope_lock::default_lock_path() else {
let Ok(lock_path) = pod_registry::default_registry_path() else {
return;
};
let Ok(mut guard) = LockFileGuard::open(&lock_path) else {
return;
};
let _ = scope_lock::release_pod(&mut guard, pod_name);
let _ = pod_registry::release_pod(&mut guard, pod_name);
}

View File

@ -1,9 +1,9 @@
//! `SpawnPod` tool — launch a new Pod process as a child of this one.
//!
//! Wires scope-lock delegation, overlay-TOML construction, subprocess
//! Wires pod-registry delegation, overlay-TOML construction, subprocess
//! launch, and socket handoff into a single `Tool` implementation. When
//! the LLM calls `SpawnPod`, a fresh `pod` binary is exec'd in its own
//! process group, the scope lock is updated atomically, and the child's
//! process group, the pod-registry is updated atomically, and the child's
//! first turn is kicked off by handing its socket a `Method::Run`.
use std::path::{Path, PathBuf};
@ -26,7 +26,7 @@ use tokio::time::sleep;
use crate::ipc::event;
use crate::runtime::dir::SpawnedPodRecord;
use crate::runtime::scope_lock::{self, LockFileGuard, ScopeLockError};
use crate::runtime::pod_registry::{self, LockFileGuard, ScopeLockError};
use crate::spawn::registry::SpawnedPodRegistry;
use protocol::PodEvent;
@ -93,7 +93,7 @@ impl From<PermissionInput> for Permission {
/// controller once per Pod lifetime.
pub struct SpawnPodTool {
/// Spawner's own pod name — becomes the spawned Pod's
/// `delegated_from` in the scope-lock registry.
/// `delegated_from` in the pod-registry.
spawner_name: String,
/// Path to the spawner's Unix socket. Handed to the child via
/// `--callback` so its `PodEvent` callbacks have somewhere to land.
@ -167,7 +167,7 @@ impl Tool for SpawnPodTool {
.unwrap_or_else(|| DEFAULT_INSTRUCTION.to_string());
let predicted_socket = self.runtime_base.join(&input.name).join("sock");
let lock_path = scope_lock::default_lock_path()
let lock_path = pod_registry::default_registry_path()
.map_err(|e| ToolError::ExecutionFailed(format!("scope lock path: {e}")))?;
// Reserve the allocation up front. Spawner's pid is a live
@ -175,7 +175,7 @@ impl Tool for SpawnPodTool {
{
let mut guard = LockFileGuard::open(&lock_path)
.map_err(|e| ToolError::ExecutionFailed(format!("scope lock open: {e}")))?;
scope_lock::delegate_scope(
pod_registry::delegate_scope(
&mut guard,
&self.spawner_name,
input.name.clone(),
@ -183,7 +183,7 @@ impl Tool for SpawnPodTool {
predicted_socket.clone(),
scope_allow.clone(),
)
.map_err(scope_lock_err_to_tool)?;
.map_err(pod_registry_err_to_tool)?;
}
// `start_outcome` covers steps that happen before the child is
@ -312,7 +312,7 @@ impl SpawnPodTool {
fn release_reservation(&self, lock_path: &Path, pod_name: &str) {
if let Ok(mut g) = LockFileGuard::open(lock_path) {
let _ = scope_lock::release_pod(&mut g, pod_name);
let _ = pod_registry::release_pod(&mut g, pod_name);
}
}
}
@ -436,7 +436,7 @@ async fn send_run(socket: &Path, task: &str) -> Result<(), ToolError> {
Ok(())
}
fn scope_lock_err_to_tool(e: ScopeLockError) -> ToolError {
fn pod_registry_err_to_tool(e: ScopeLockError) -> ToolError {
match e {
ScopeLockError::NotSubset { .. }
| ScopeLockError::WriteConflict { .. }

View File

@ -15,7 +15,7 @@ use llm_worker::llm_client::types::{ContentPart, Item, Role};
use llm_worker::tool::ToolOutput;
use manifest::{Permission, ScopeRule};
use pod::runtime::dir::{RuntimeDir, SpawnedPodRecord};
use pod::runtime::scope_lock::{self, LockFileGuard};
use pod::runtime::pod_registry::{self, LockFileGuard};
use pod::spawn::comm_tools::{
list_pods_tool, read_pod_output_tool, send_to_pod_tool, stop_pod_tool,
};
@ -86,7 +86,7 @@ async fn setup_registry() -> (TempDir, Arc<SpawnedPodRegistry>, Arc<RuntimeDir>)
/// Register a fake spawned-child record pointing at a given socket
/// path, with a trivial write-scope for `scope_path`. Does not touch
/// scope.lock.
/// pods.json.
async fn register_child(
registry: &SpawnedPodRegistry,
name: &str,
@ -334,14 +334,14 @@ async fn stop_pod_sends_shutdown_and_releases_scope() {
unsafe {
std::env::set_var("INSOMNIA_RUNTIME_DIR", tmp.path());
}
let lock_path = tmp.path().join("scope.lock");
let lock_path = tmp.path().join("pods.json");
// Seed scope.lock with a top-level `spawner` allocation plus a
// Seed pods.json with a top-level `spawner` allocation plus a
// delegated `child` allocation — mimics what SpawnPod would have
// done so StopPod has something to release.
{
let mut g = LockFileGuard::open(&lock_path).unwrap();
scope_lock::register_pod(
pod_registry::register_pod(
&mut g,
"spawner".into(),
std::process::id(),
@ -354,7 +354,7 @@ async fn stop_pod_sends_shutdown_and_releases_scope() {
session_store::new_session_id(),
)
.unwrap();
scope_lock::delegate_scope(
pod_registry::delegate_scope(
&mut g,
"spawner",
"child".into(),

View File

@ -11,7 +11,7 @@ use std::time::Duration;
use pod::ipc::event::{apply_event_side_effects, fire_and_forget, render_event};
use pod::runtime::dir::{RuntimeDir, SpawnedPodRecord};
use pod::runtime::scope_lock::{self, LockFileGuard};
use pod::runtime::pod_registry::{self, LockFileGuard};
use pod::spawn::registry::SpawnedPodRegistry;
use protocol::stream::JsonLineReader;
use protocol::{Method, Permission, PodEvent, ScopeRule};
@ -62,8 +62,8 @@ impl Drop for EnvGuard {
}
}
/// Point `INSOMNIA_RUNTIME_DIR` at `dir`. The scope-lock then lives at
/// `<dir>/scope.lock` and Pod runtime sub-dirs at `<dir>/{pod_name}/`.
/// Point `INSOMNIA_RUNTIME_DIR` at `dir`. The pod-registry then lives at
/// `<dir>/pods.json` and Pod runtime sub-dirs at `<dir>/{pod_name}/`.
fn set_runtime_dir(dir: &std::path::Path) {
unsafe {
std::env::set_var("INSOMNIA_RUNTIME_DIR", dir);
@ -348,12 +348,12 @@ async fn apply_turn_ended_and_errored_are_system_noops() {
async fn shutdown_releases_scope_allocation_when_present() {
let _env = EnvGuard::acquire();
let scope_dir = TempDir::new().unwrap();
let lock_path = scope_dir.path().join("scope.lock");
let lock_path = scope_dir.path().join("pods.json");
set_runtime_dir(scope_dir.path());
// Install a top-level allocation for "kid" so ShutDown has
// something to release.
let guard = scope_lock::install_top_level(
let guard = pod_registry::install_top_level(
"kid".into(),
std::process::id(),
"/tmp/kid.sock".into(),

View File

@ -2,7 +2,7 @@
//! validation paths.
//!
//! These cases all return before `prepare_pod_common` runs, so they
//! do not need a real LLM client or scope-lock environment — only the
//! do not need a real LLM client or pod-registry environment — only the
//! session store needs to be present.
use std::sync::{LazyLock, Mutex};

View File

@ -1,6 +1,6 @@
//! Integration tests for the `SpawnPod` tool.
//!
//! These tests exercise the tool's scope-lock delegation, subprocess
//! These tests exercise the tool's pod-registry delegation, subprocess
//! launch, socket handoff, and `spawned_pods.json` write without relying
//! on the real `pod` binary. `INSOMNIA_POD_COMMAND` is pointed at
//! `/bin/true` (which exits immediately) while a test-owned Unix
@ -13,7 +13,7 @@ use std::sync::{LazyLock, Mutex};
use llm_worker::tool::{ToolError, ToolOutput};
use manifest::{AuthRef, ModelManifest, Permission, SchemeKind, ScopeRule};
use pod::runtime::dir::{RuntimeDir, SpawnedPodRecord};
use pod::runtime::scope_lock::{self, LockFileGuard};
use pod::runtime::pod_registry::{self, LockFileGuard};
use pod::spawn::registry::SpawnedPodRegistry;
use pod::spawn::tool::spawn_pod_tool;
use protocol::Method;
@ -40,7 +40,7 @@ impl EnvGuard {
}
/// Set up a tempdir, point `INSOMNIA_RUNTIME_DIR` at it (so
/// `scope.lock` and per-Pod runtime subdirs both land in the
/// `pods.json` and per-Pod runtime subdirs both land in the
/// sandbox), and install a live top-level "spawner" allocation so the
/// tool has something to delegate from. Returns the tempdir (keeps it
/// alive for the test's lifetime), runtime base, spawner socket, and
@ -64,7 +64,7 @@ async fn setup_spawner(
.unwrap();
let spawner_socket = spawner_rd.socket_path();
let _guard = scope_lock::install_top_level(
let _guard = pod_registry::install_top_level(
spawner_name.into(),
std::process::id(),
spawner_socket.clone(),
@ -207,8 +207,8 @@ async fn spawn_pod_delegates_scope_and_sends_run() {
other => panic!("expected Run, got {other:?}"),
}
// Verify scope_lock has the child allocation under `root`.
let lock_path = scope_lock::default_lock_path().unwrap();
// Verify pod_registry has the child allocation under `root`.
let lock_path = pod_registry::default_registry_path().unwrap();
let guard = LockFileGuard::open(&lock_path).unwrap();
let child = guard
.data()
@ -273,7 +273,7 @@ async fn spawn_pod_rejects_scope_outside_spawner() {
}
// The spawner's allocation is unchanged; no "child" appeared.
let lock_path = scope_lock::default_lock_path().unwrap();
let lock_path = pod_registry::default_registry_path().unwrap();
let guard = LockFileGuard::open(&lock_path).unwrap();
assert!(guard.data().find("child").is_none());
@ -334,7 +334,7 @@ async fn spawn_pod_rolls_back_reservation_when_socket_never_appears() {
}
// Rollback assertion: the reserved "ghost" allocation is gone.
let lock_path = scope_lock::default_lock_path().unwrap();
let lock_path = pod_registry::default_registry_path().unwrap();
let guard = LockFileGuard::open(&lock_path).unwrap();
assert!(
guard.data().find("ghost").is_none(),

View File

@ -39,7 +39,7 @@ pub enum Method {
///
/// Delivered as `Method::PodEvent` over the parent's Unix socket. The
/// parent Controller applies variant-specific side effects (registry /
/// scope-lock updates) and renders a human-readable string that is
/// pod-registry updates) and renders a human-readable string that is
/// injected into the parent's LLM context via the notification buffer.
///
/// Transport is fire-and-forget; receivers must tolerate out-of-order

File diff suppressed because it is too large Load Diff

View File

@ -15,4 +15,4 @@ uuid = "1.23"
toml = "1.1.2"
manifest = { version = "0.1.0", path = "../manifest" }
session-store = { version = "0.1.0", path = "../session-store" }
scope-lock = { version = "0.1.0", path = "../scope-lock" }
pod-registry = { version = "0.1.0", path = "../pod-registry" }

View File

@ -5,7 +5,7 @@
//! `SessionId`. Closes its inline viewport before returning so the
//! caller can open a fresh viewport for the name dialog.
//!
//! The picker only handles selection. Forking, scope-lock checks, and
//! The picker only handles selection. Forking, pod-registry checks, and
//! actual `pod` launch happen later in the resume flow.
use std::io;
@ -19,7 +19,7 @@ use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::{Frame, TerminalOptions, Viewport};
use scope_lock::lookup_session;
use pod_registry::lookup_session;
use session_store::{
ContentPart, FsStore, HashedEntry, Item, LogEntry, SessionId, Store,
};
@ -73,7 +73,7 @@ struct Row {
/// Last user / assistant snippet, or a `[corrupt]` placeholder.
preview: String,
/// `Some(pod_name)` when a live Pod currently holds an allocation
/// for this session in `scope.lock`. Picking such a row launches
/// for this session in `pods.json`. Picking such a row launches
/// `pod --session <UUID>` which will fail with `SessionConflict` —
/// the badge warns the user up-front.
live_pod: Option<String>,
@ -88,7 +88,7 @@ pub async fn run() -> Result<PickerOutcome, PickerError> {
let mut rows: Vec<Row> = Vec::with_capacity(MAX_ROWS);
for id in ids.into_iter().take(MAX_ROWS) {
let preview = build_preview(&store, id).await;
// Best-effort live check. A scope.lock I/O hiccup downgrades
// Best-effort live check. A pods.json I/O hiccup downgrades
// the row to "no badge" rather than killing the picker — the
// user still gets to see the listing.
let live_pod = lookup_session(id).ok().flatten().map(|info| info.pod_name);

View File

@ -2,9 +2,9 @@
## 背景
現状の Pod の `Scope``Pod::from_manifest` 時に1回構築され、以後 immutable。scope lock file (`tickets/scope-lock.md`) は登録・削除・衝突チェックを任意のタイミングで行えるため lock file 側の制約はないが、Pod 内部の `Scope``ScopedFs` が起動時に固定されているため、実行中に scope を追加・縮小することができない。
現状の Pod の `Scope``Pod::from_manifest` 時に1回構築され、以後 immutable。pod-registry (`crates/pod-registry`) は登録・削除・衝突チェックを任意のタイミングで行えるためレジストリ側の制約はないが、Pod 内部の `Scope``ScopedFs` が起動時に固定されているため、実行中に scope を追加・縮小することができない。
オーケストレーションでは SpawnPod による scope 分譲で effective scope が縮小するが、これは lock file 上の記録にとどまり、Pod 側の `ScopedFs` は元の scope のまま動作している(ツール実行時に lock file と照合していない)。
オーケストレーションでは SpawnPod による scope 分譲で effective scope が縮小するが、これは pod-registry 上の記録にとどまり、Pod 側の `ScopedFs` は元の scope のまま動作している(ツール実行時に pod-registry と照合していない)。
また将来、外部から Pod に scope を動的に付与するケース(人間が「このディレクトリも触っていいよ」と追加する、別 Pod が scope を委譲してくる等)にも対応したい。
@ -18,9 +18,9 @@ Pod の実行中に scope を追加・縮小でき、変更が即座にツール
- `Scope``Arc<RwLock<Scope>>`(または同等の共有可変参照)にする
- `ScopedFs``Scope` の共有参照を持ち、ツール実行時に最新の scope を参照する
- scope 変更メソッド: `pod.update_scope(new_scope_config)`lock file 更新 + `Scope` 再構築 + `ScopedFs` に反映
- scope 変更メソッド: `pod.update_scope(new_scope_config)`pod-registry 更新 + `Scope` 再構築 + `ScopedFs` に反映
### scope lock file との連携
### pod-registry との連携
- scope 追加時: `flock → 衝突チェック → 追加分を登録 → unlock → Pod 内 Scope 再構築`
- scope 縮小時(分譲): `flock → 分譲を記録 → unlock → Pod 内 Scope 再構築`
@ -36,13 +36,9 @@ Pod の実行中に scope を追加・縮小でき、変更が即座にツール
- Pod の実行中に scope を追加でき、追加後のツール実行が新しい scope を反映する
- Pod の実行中に scope を縮小でき、縮小後のツール実行が制限を反映する
- scope 変更が lock file と Pod 内 Scope の両方に整合的に反映される
- scope 変更が pod-registry と Pod 内 Scope の両方に整合的に反映される
- 単体テストで動的追加・縮小後の permission チェックが検証される
## 依存
- `tickets/scope-lock.md`: lock file 基盤
## 範囲外
- protocol 経由の外部からの scope 付与 / 剥奪(必要になったら追加)