Compare commits

...

48 Commits

Author SHA1 Message Date
2acc72ca5e
review: request spawnpod profile tests 2026-05-30 14:11:43 +09:00
a22edf160f
close: scope subdelegation control only 2026-05-30 14:04:28 +09:00
39874d92dc
merge: scope subdelegation control only 2026-05-30 14:03:44 +09:00
21c388e0d3
review: scope subdelegation control only 2026-05-30 14:03:44 +09:00
b3fe725742
fix: keep scope subdelegation control-only 2026-05-30 14:01:09 +09:00
40ba8f5d73
close: tui live pending picker 2026-05-30 14:00:58 +09:00
a63a85d667
merge: tui live pending picker 2026-05-30 14:00:32 +09:00
848ca4a59d
review: tui live pending picker 2026-05-30 14:00:32 +09:00
56bb46634c
tui: prioritize live pending pods in picker 2026-05-30 13:57:18 +09:00
08397f3f3b
plan: parallel ticket preflight 2026-05-30 13:54:03 +09:00
e88ecd1903
close: obsolete semantic nix profiles 2026-05-30 12:52:41 +09:00
e9354b38d6
chore: remove stale manifest cascade tests 2026-05-30 12:49:43 +09:00
2533a3d5d3
chore: remove nix mentions from rust 2026-05-30 12:10:37 +09:00
b981bbd87b
chore: remove legacy nix profile resources 2026-05-30 12:05:34 +09:00
1100ded218
close: lua profile authoring 2026-05-30 12:00:02 +09:00
f9043eec7b
merge: lua profile authoring 2026-05-30 11:58:59 +09:00
35ac6cb61f
review: lua profile authoring 2026-05-30 11:58:55 +09:00
6ecb5c4f73
ticket: clarify spawnpod profile inherit 2026-05-30 11:53:19 +09:00
cba46c6a39
feat: add lua profile authoring 2026-05-30 11:45:49 +09:00
36c932a5b3
plan: approve lua profile implementation 2026-05-30 11:26:32 +09:00
20f72214d7
ticket: add lua profile artifacts dir 2026-05-30 11:23:27 +09:00
fac3d8baf0
ticket: add lua profile authoring 2026-05-30 11:23:16 +09:00
8d85a76811
workflow: add ticket preflight gate 2026-05-30 11:16:16 +09:00
ec638af7e6
ticket: refine profile template boundary 2026-05-30 11:03:58 +09:00
ba4d6e3e84
ticket: record profile requirements metadata 2026-05-30 10:39:58 +09:00
6d25dd3c35
ticket: sync profile authoring requirements 2026-05-30 10:39:47 +09:00
cb47d97ccc
ticket: update semantic nix profiles timestamp 2026-05-30 09:45:18 +09:00
b244f1e1d0
plan: semantic nix profiles implementation direction 2026-05-30 09:45:10 +09:00
b676083e7f
close: provider trace spawn preservation 2026-05-30 09:38:34 +09:00
f927b37b83
merge: provider trace spawn preservation 2026-05-30 09:37:46 +09:00
88e43f7402
review: provider trace spawn preservation 2026-05-30 09:37:40 +09:00
23f234d095
fix: preserve spawn event trace setting 2026-05-30 09:30:05 +09:00
31bd50137d
plan: provider trace spawn preservation 2026-05-30 09:24:05 +09:00
f708c1ffbe
close: pod store split 2026-05-30 09:10:53 +09:00
a47f2c4689
fix: preserve terminal turn failures 2026-05-30 09:02:11 +09:00
e37c151f0e
ticket: preserve provider stream trace config 2026-05-30 08:54:39 +09:00
e2cf6ed85f
merge: pod store split 2026-05-30 07:49:07 +09:00
be5bbf3117
review: pod store split 2026-05-30 07:49:04 +09:00
d2e80871ce
fix: reconcile missing delegated children 2026-05-30 07:46:09 +09:00
9db8cdc7f8
plan: semantic nix profiles 2026-05-30 07:37:51 +09:00
e10b4ad4f0
refactor: move scope authority to pod store 2026-05-30 07:36:17 +09:00
520e3f8294
ticket: semantic nix profiles 2026-05-30 07:27:02 +09:00
103ea7bb53
ticket: make pod-store delegation authority 2026-05-30 07:17:02 +09:00
211738132c
refactor: split pod metadata store 2026-05-30 07:16:50 +09:00
f8ece7f55e
ticket: pod-store split direction 2026-05-30 07:01:09 +09:00
1f9a575d07
ticket: split pod metadata storage 2026-05-30 06:31:11 +09:00
c4532e1be7
ticket: session pod state boundary 2026-05-30 05:59:27 +09:00
b2131b64a1
ticket: spawnpod profile selection 2026-05-30 05:56:50 +09:00
96 changed files with 5570 additions and 2790 deletions

View File

@ -2,13 +2,15 @@
description: worktree と sibling の coder / reviewer Pod を使い、下位 orchestrator が複数 ticket の実装・外部レビュー・修正・完了準備を管理する orchestration フロー
model_invokation: true
user_invocable: true
requires: []
requires: ["ticket-preflight-workflow", "worktree-workflow"]
---
# Multi-agent Worktree Workflow
insomnia を insomnia で開発する際の、worktree + coder Pod + 外部 reviewer Pod + orchestrator Pod の標準フロー。これは **最上位 Pod が細かい code review を抱えず、下位 orchestrator が実装と外部レビューの loop を完了状態まで運ぶためのフロー** である。
worktree の機械的作成手順は `$user/worktree-workflow`、ticket 候補選定や方針探索の半自動 loop は `$user/auto-maintain` に分ける。
worktree の機械的作成手順は `$user/worktree-workflow`、実装前の要件同期・反証 preflight は `$user/ticket-preflight-workflow`、ticket 候補選定や方針探索の半自動 loop は `$user/auto-maintain` に分ける。
この Workflow は、対象 ticket が implementation-ready であることを前提にする。設計境界・仕様・authority boundary が未同期の場合は、worktree 作成や coder Pod 起動の前に `ticket-preflight-workflow` を通す。
## 目的
@ -59,8 +61,9 @@ reviewer Pod
- worktree 作成と git 書き込み操作について、人間の許可がある。
- main workspace の unrelated dirty changes を把握している。
- 下位 orchestrator に渡す intent / invariant / non-goals / escalation 条件を短く書ける。
- 設計境界・仕様・authority boundary に不確定要素がある場合、`ticket-preflight-workflow` の結果が ticket thread に記録されている。
設計方針が複数自然に導ける場合、protocol / scope / permission / history persistence に触れる場合、ticket 自体の再定義が必要な場合は、実装委譲前に人間へ戻す。ただし下位 orchestrator に探索だけを委譲することはできる。
設計方針が複数自然に導ける場合、protocol / scope / permission / history persistence に触れる場合、ticket 自体の再定義が必要な場合は、実装委譲前に `ticket-preflight-workflow` を通し、必要なら人間へ戻す。ただし下位 orchestrator に探索だけを委譲することはできる。
## Intent packet
@ -99,6 +102,7 @@ reviewer には coder の実装方針ではなく、この intent packet と dif
- `git status --short --branch`
- 対象 ticket / ticket 群
- 関連 TODO / docs / 既存 worktree
- preflight が必要な ticket では、`ticket-preflight-workflow` の分類・要件同期・critical risks
2. worktree 作成
- `$user/worktree-workflow` に従い `./.worktree/<task-name>` を作る。

View File

@ -0,0 +1,167 @@
---
description: ticket を実装委譲する前に、要件・前提・設計境界・反証観点を同期し、tickets.sh に記録する preflight フロー
model_invokation: true
user_invocable: true
requires: []
---
# Ticket Preflight Workflow
insomnia プロジェクトで ticket を実装に渡す前に、要件・前提・設計境界・反証観点を同期するための Workflow。これは **実装前の gate** であり、worktree 作成や coder / reviewer Pod の起動は `multi-agent-workflow` / `worktree-workflow` 側で扱う。
目的は「ticket があるから実装する」状態を避け、ticket が **実装可能な仕様** なのか、**調査 ticket** なのか、**人間との仕様同期が必要な未決定 ticket** なのかを明確にすることである。
## 適用する場面
以下のいずれかに当てはまる ticket は、実装委譲前にこの Workflow を通す。
- profile / manifest / scope / permission / session / history / pod-store / prompt context など authority boundary に触れる。
- ticket の文面から複数の自然な設計方針が導ける。
- 「どう実装するか」以前に「何を仕様とするか」が曖昧である。
- 既存 implementation plan があるが、抽象化・責務境界・ユーザー体験に疑問がある。
- 過去 decision / memory / docs / ticket thread と矛盾しそうである。
- coder Pod に渡す intent packet を短く書けない。
小さなバグ修正や仕様が明確な局所変更では、この Workflow は省略してよい。ただし省略理由が曖昧な場合は preflight する。
## tickets.sh 運用方針
作業管理の authority は `work-items/``tickets.sh` である。preflight の結果は、口頭の会話だけで終わらせず、ticket の `thread.md` または `item.md` に残す。
- 新規の前提・要件・受け入れ条件は、必要に応じて `item.md` を更新する。
- 調査結果・実装前 plan は `./tickets.sh comment <ticket> --role plan --file <file>` で残す。
- 採用/却下した設計判断、実装停止判断、仕様同期の結論は `--role decision` で残す。
- 実装に入ってよい状態になったら、その根拠を intent packet として ticket thread に残す。
- 仕様が未決定なら、実装 ticket にせず requirements-sync / spike / design ticket として切り分ける。
- ticket の timestamp/frontmatter が更新される場合は、関連変更と一緒に commit する。
- ticket 作成・更新・レビュー・完了は git commit で記録する。push はしない。
## 手順
### 1. 状態確認
- `git status --short --branch`
- 対象 ticket の `item.md` / `thread.md` / artifacts
- 関連 ticket / docs / workflow / Knowledge / memory decision
- 既存 worktree / branch / running Pod の有無
この段階で unrelated dirty changes がある場合は、preflight の記録だけを行うか、人間に確認してから進める。
### 2. ticket の種類を分類する
以下のどれかに分類する。
```text
implementation-ready:
- 要件・受け入れ条件・invariant が明確で、実装方針が一意または十分絞れている。
requirements-sync-needed:
- ticket の目的は見えているが、仕様・用語・責務境界・ユーザー体験の同期が必要。
spike-needed:
- 技術的実現性・依存関係・ライセンス・性能・diagnostics などを先に調べる必要がある。
blocked-needs-human-decision:
- 複数方針があり、AI が勝手に決めると設計境界や product API を固定してしまう。
```
`implementation-ready` 以外は、coder Pod に実装を委譲しない。
### 3. 要件同期
最低限、以下を確認する。
- 完了時に observable に何が変わるか。
- ticket の主語は何か: user-facing behavior / internal architecture / cleanup / investigation。
- 用語が既存設計と一致しているか。
- 何をやらないか。
- 後方互換が必要か、不要な互換層を作ろうとしていないか。
- 既存の authority boundary を変えるか。
- runtime state / persisted state / config / profile / manifest / session log / pod metadata のどれが authority か。
必要なら ticket `item.md` の Background / Requirements / Acceptance criteria を更新する。
### 4. 現行コードと過去判断の map を作る
実装前に、少なくとも関連する current code paths を列挙する。
```text
Current code map:
- file/function: 現在の責務
- file/function: 変更候補
- file/function: 触ってはいけない境界
```
この map は簡潔でよい。目的は coder Pod が blind に探しながら設計を固定するのを防ぐこと。
### 5. 批判的 preflight
実装方針を一度疑う。以下の問いに答える。
- この ticket は本当に実装 ticket か、それとも仕様同期 ticket か。
- 最も自然に見える実装が失敗するとしたらどこか。
- 抽象化に失敗して「別名の同じもの」を作っていないか。
- runtime-bound な値を reusable config に混ぜていないか。
- profile / manifest / scope / session / pod-store などの authority を逆転させていないか。
- user-facing API を安易に public contract 化していないか。
- external dependency / license / portability / packaging の問題はないか。
- reviewer が diff だけ読んでも見落とす設計リスクは何か。
この問いへの答えを `plan` または `decision` として ticket thread に残す。
### 6. intent packet を作る
実装に入ってよい場合だけ、`multi-agent-workflow` に渡す intent packet を作る。
```text
Intent:
- 何を実現するか。
Requirements:
- observable な完了条件。
Invariants:
- 壊してはいけない authority boundary / design boundary。
Non-goals:
- 今回やらないこと。
Escalate if:
- 親/人間に戻す判断条件。
Validation:
- focused test / broader check / doctor / docs 更新。
Current code map:
- 実装対象と触ってはいけない場所。
Critical risks:
- reviewer にも見てほしい失敗パターン。
```
この intent packet が短く書けない場合は、実装委譲せず requirements-sync-needed とする。
## review への引き継ぎ
preflight で出た critical risks は reviewer Pod にも渡す。reviewer は diff だけでなく、ticket の前提・要件・invariant と preflight の反証観点を読む。
reviewer に期待すること:
- 実装が preflight の intent に対応しているか。
- 抽象化失敗や authority boundary 違反がないか。
- preflight で挙げた失敗パターンに落ちていないか。
- validation がリスクに対して十分か。
## 完了条件
この Workflow 自体の完了条件は、次のいずれかである。
- ticket が `implementation-ready` になり、intent packet が thread に記録されている。
- ticket が `requirements-sync-needed` / `spike-needed` / `blocked-needs-human-decision` として整理され、次に人間へ戻す問いまたは follow-up ticket が明確になっている。
- ticket 自体が不要/誤りと判断され、理由が decision として記録されている。
## この Workflow でしないこと
- worktree を作成しない。
- coder Pod に実装を委譲しない。
- merge / close しない。
- 仕様未決定のまま「小さく実装してみる」ことで public API を固定しない。

115
Cargo.lock generated
View File

@ -721,6 +721,17 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "erased-serde"
version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
dependencies = [
"serde",
"serde_core",
"typeid",
]
[[package]]
name = "errno"
version = "0.3.14"
@ -1714,6 +1725,25 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lua-src"
version = "550.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e836dc8ae16806c9bdcf42003a88da27d163433e3f9684c52f0301258004a4fb"
dependencies = [
"cc",
]
[[package]]
name = "luajit-src"
version = "210.6.6+707c12b"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a86cc925d4053d0526ae7f5bc765dbd0d7a5d1a63d43974f4966cb349ca63295"
dependencies = [
"cc",
"which",
]
[[package]]
name = "mac_address"
version = "1.1.8"
@ -1730,6 +1760,7 @@ version = "0.1.0"
dependencies = [
"arc-swap",
"llm-worker",
"mlua",
"protocol",
"serde",
"serde_ignored",
@ -1841,6 +1872,39 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "mlua"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccd36acfa49ce6ee56d1307a061dd302c564eee757e6e4cd67eb4f7204846fab"
dependencies = [
"bstr",
"either",
"erased-serde",
"libc",
"mlua-sys",
"num-traits",
"parking_lot",
"rustc-hash",
"rustversion",
"serde",
"serde-value",
]
[[package]]
name = "mlua-sys"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f1c3a7fc7580227ece249fd90aa2fa3b39eb2b49d3aec5e103b3e85f2c3dfc8"
dependencies = [
"cc",
"cfg-if",
"libc",
"lua-src",
"luajit-src",
"pkg-config",
]
[[package]]
name = "native-tls"
version = "0.2.18"
@ -1997,6 +2061,15 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "ordered-float"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
dependencies = [
"num-traits",
]
[[package]]
name = "ordered-float"
version = "4.6.0"
@ -2166,6 +2239,7 @@ dependencies = [
"memory",
"minijinja",
"pod-registry",
"pod-store",
"protocol",
"provider",
"schemars",
@ -2197,6 +2271,17 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "pod-store"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"session-store",
"tempfile",
"thiserror 2.0.18",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
@ -2864,6 +2949,16 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde-value"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
dependencies = [
"ordered-float 2.10.1",
"serde",
]
[[package]]
name = "serde_core"
version = "1.0.228"
@ -3276,7 +3371,7 @@ dependencies = [
"nix",
"num-derive",
"num-traits",
"ordered-float",
"ordered-float 4.6.0",
"pest",
"pest_derive",
"phf",
@ -3652,6 +3747,7 @@ dependencies = [
"llm-worker",
"manifest",
"pod-registry",
"pod-store",
"protocol",
"pulldown-cmark",
"ratatui",
@ -3666,6 +3762,12 @@ dependencies = [
"uuid",
]
[[package]]
name = "typeid"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "typenum"
version = "1.19.0"
@ -4011,7 +4113,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac"
dependencies = [
"log",
"ordered-float",
"ordered-float 4.6.0",
"strsim",
"thiserror 1.0.69",
"wezterm-dynamic-derive",
@ -4041,6 +4143,15 @@ dependencies = [
"wezterm-dynamic",
]
[[package]]
name = "which"
version = "8.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459"
dependencies = [
"libc",
]
[[package]]
name = "winapi"
version = "0.3.9"

View File

@ -8,6 +8,7 @@ members = [
"crates/session-store",
"crates/manifest",
"crates/pod",
"crates/pod-store",
"crates/protocol",
"crates/provider",
"crates/pod-registry",
@ -32,6 +33,7 @@ manifest = { path = "crates/manifest" }
lint-common = { path = "crates/lint-common" }
memory = { path = "crates/memory" }
pod-registry = { path = "crates/pod-registry" }
pod-store = { path = "crates/pod-store" }
protocol = { path = "crates/protocol" }
provider = { path = "crates/provider" }
session-metrics = { path = "crates/session-metrics" }

View File

@ -27,12 +27,10 @@ pub struct SpawnConfig {
/// (`manifest::paths::pod_runtime_dir`) の解決と、ready 行に乗る
/// 名前との突き合わせに使う。
pub pod_name: String,
/// Optional Nix profile selector. When present the child is launched with
/// Optional profile selector. When present the child is launched with
/// `--profile`; the Pod name is supplied through `--profile-pod-name` so
/// profile evaluation stays separate from `--pod` restore semantics.
pub profile: Option<String>,
/// Optional session-scope snapshot used when restoring by session id.
pub resume_scope: Option<manifest::ScopeConfig>,
/// pod の current_dir。
pub cwd: PathBuf,
/// `Some(id)` のとき `--session <id>` を付与し、当該セッションから
@ -132,12 +130,6 @@ where
.arg(id.to_string())
.arg("--session-pod-name")
.arg(&config.pod_name);
if let Some(scope) = &config.resume_scope {
let scope_json = serde_json::to_string(scope).map_err(|e| {
SpawnError::PodLaunchFailed(io::Error::new(io::ErrorKind::InvalidInput, e))
})?;
command.arg("--resume-scope-json").arg(scope_json);
}
}
let mut child = command.spawn().map_err(SpawnError::PodLaunchFailed)?;

View File

@ -262,13 +262,17 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
}
fn drain_cancel_queue(&mut self) {
use tokio::sync::mpsc::error::TryRecvError;
loop {
match self.cancel_rx.try_recv() {
Ok(()) => continue,
Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => break,
}
}
while self.cancel_rx.try_recv().is_ok() {}
}
/// Discard pending cancellation notifications while the worker is idle.
///
/// Cancellation is a running-turn control signal. Callers that own a higher
/// level run state can use this before starting a new turn so an old idle
/// signal does not poison the next request, while cancellation queued after
/// the run has been accepted remains observable by the turn loop.
pub fn clear_pending_cancel(&mut self) {
self.drain_cancel_queue();
}
fn try_cancelled(&mut self) -> bool {
@ -1058,7 +1062,6 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
/// Internal turn execution logic
async fn run_turn_loop(&mut self) -> Result<WorkerResult, WorkerError> {
self.reset_interruption_state();
self.drain_cancel_queue();
let tool_definitions = self.build_tool_definitions();
info!(
@ -1363,9 +1366,27 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
"elapsed_ms": stream_started.elapsed().as_millis() as u64,
}),
);
match wait_for_first_stream_event(stream, DEFAULT_FIRST_STREAM_EVENT_TIMEOUT)
.await
{
let first_event_result = tokio::select! {
first_event = wait_for_first_stream_event(stream, DEFAULT_FIRST_STREAM_EVENT_TIMEOUT) => first_event,
cancel = self.cancel_rx.recv() => {
if cancel.is_some() {
info!("Cancelled before first stream event");
}
self.emit_lifecycle_trace(
turn,
llm_call,
"stream_first_event_cancelled",
json!({
"attempt": attempt,
"elapsed_ms": stream_started.elapsed().as_millis() as u64,
}),
);
self.timeline.abort_current_block();
self.last_run_interrupted = true;
return Err(WorkerError::Cancelled);
}
};
match first_event_result {
Ok(FirstStreamEvent::Ready(stream)) => return Ok(stream),
Ok(FirstStreamEvent::Empty(stream)) => return Ok(stream),
Err(err) => {
@ -1502,6 +1523,17 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
}
self.emit_stream_event(turn, llm_call, &event);
self.timeline.dispatch(&event);
if let Event::Error(err) = &event {
self.timeline.abort_current_block();
self.timeline.flush_usage();
self.last_run_interrupted = true;
return Err(WorkerError::Client(ClientError::Api {
status: None,
code: err.code.clone(),
message: err.message.clone(),
retry_after: None,
}));
}
}
None => break,
}

View File

@ -7,6 +7,7 @@ license.workspace = true
[dependencies]
arc-swap = "1"
llm-worker = { workspace = true }
mlua = { version = "0.11.4", features = ["lua54", "vendored", "serialize"] }
protocol = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }

View File

@ -1,125 +0,0 @@
//! Cascade-layer collection helpers.
//!
//! Pod manifests are assembled from up to three on-disk layers (see
//! `pod::PodFactory` for the full cascade story):
//!
//! 1. **User manifest** — Pod CLI uses
//! [`crate::paths::user_manifest_path_with_env_override`]
//! 2. **Project manifest** at the closest `.insomnia/manifest.toml`
//! found by walking up from a starting directory (typically `cwd`)
//! 3. **Programmatic overlay** supplied at the call site
//!
//! This module owns the project-layer discovery and the parser glue.
//! User-layer path resolution lives in [`crate::paths`].
//!
//! Cascade *merging* and final validation stay outside this module —
//! that's the data layer's responsibility (`PodManifestConfig::merge`
//! and `PodManifest::try_from`). This module only handles the I/O and
//! path-discovery glue around them.
use std::path::{Path, PathBuf};
use crate::PodManifestConfig;
/// Errors returned when reading a single manifest layer from disk.
#[derive(Debug, thiserror::Error)]
pub enum LayerLoadError {
#[error("failed to read manifest {}: {source}", .path.display())]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse manifest {}: {source}", .path.display())]
Parse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
}
/// Walk up from `start` looking for `.insomnia/manifest.toml`. Returns
/// the closest match, or `None` if none is found before reaching the
/// filesystem root.
pub fn find_project_manifest_from(start: &Path) -> Option<PathBuf> {
let start = start
.canonicalize()
.ok()
.unwrap_or_else(|| start.to_path_buf());
let mut cur: Option<&Path> = Some(start.as_path());
while let Some(dir) = cur {
let candidate = dir.join(".insomnia").join("manifest.toml");
if candidate.is_file() {
return Some(candidate);
}
cur = dir.parent();
}
None
}
/// Read a manifest file from `path` and parse it as a partial
/// [`PodManifestConfig`]. Path resolution against a base directory and
/// merging with other layers are the caller's responsibility.
pub fn load_layer(path: &Path) -> Result<PodManifestConfig, LayerLoadError> {
let toml = std::fs::read_to_string(path).map_err(|source| LayerLoadError::Io {
path: path.to_path_buf(),
source,
})?;
PodManifestConfig::from_toml(&toml).map_err(|source| LayerLoadError::Parse {
path: path.to_path_buf(),
source,
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn find_project_manifest_walks_up() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().canonicalize().unwrap();
let manifest = root.join(".insomnia").join("manifest.toml");
std::fs::create_dir_all(manifest.parent().unwrap()).unwrap();
std::fs::write(&manifest, "").unwrap();
let nested = root.join("a").join("b");
std::fs::create_dir_all(&nested).unwrap();
let found = find_project_manifest_from(&nested).unwrap();
assert_eq!(found, manifest);
}
#[test]
fn find_project_manifest_returns_none_when_absent() {
let tmp = TempDir::new().unwrap();
assert!(find_project_manifest_from(tmp.path()).is_none());
}
#[test]
fn load_layer_round_trips_partial_config() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("manifest.toml");
std::fs::write(
&path,
r#"
[pod]
name = "from-disk"
"#,
)
.unwrap();
let cfg = load_layer(&path).unwrap();
assert_eq!(cfg.pod.name.as_deref(), Some("from-disk"));
}
#[test]
fn load_layer_io_error_carries_path() {
let bogus = PathBuf::from("/definitely/does/not/exist/manifest.toml");
let err = load_layer(&bogus).unwrap_err();
match err {
LayerLoadError::Io { path, .. } => assert_eq!(path, bogus),
_ => panic!("expected Io variant"),
}
}
}

View File

@ -210,9 +210,9 @@ impl PodManifestConfig {
})
}
/// Cascade layer populated with the in-code defaults listed in
/// [`crate::defaults`]. Used by [`PodFactory::resolve`] as the
/// bottom layer, so every per-field default lives at exactly one
/// Base config populated with the in-code defaults listed in
/// [`crate::defaults`]. Profile and one-file Manifest resolvers start
/// from this layer so every per-field default lives at exactly one
/// call site (the `defaults` module).
///
/// `TryFrom<PodManifestConfig>` also reads the same constants as a

View File

@ -1,4 +1,3 @@
mod cascade;
mod config;
pub mod defaults;
mod model;
@ -6,22 +5,19 @@ pub mod paths;
mod profile;
mod scope;
pub use cascade::{LayerLoadError, find_project_manifest_from, load_layer};
pub use config::{
CompactionConfigPartial, FileUploadLimitsPartial, PermissionConfigPartial, PodManifestConfig,
PodMetaConfig, ResolveError, ToolOutputLimitsPartial, WorkerManifestConfig,
PodMetaConfig, ResolveError, SessionConfigPartial, ToolOutputLimitsPartial,
WorkerManifestConfig,
};
pub use model::{
AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind,
};
pub use paths::{
user_manifest_path, user_manifest_path_from_env, user_manifest_path_with_env_override,
user_profiles_path,
};
pub use paths::user_profiles_path;
pub use profile::{
NixProfileResolver, ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata,
ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource, ProfileSelector, ProfileSource,
ResolvedProfile, resolve_profile_artifact,
ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileRegistry,
ProfileRegistryEntry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver,
ProfileSelector, ProfileSource, ResolvedProfile, resolve_profile_artifact,
};
pub use protocol::{Permission, ScopeRule};
pub use scope::{Scope, ScopeError, SharedScope};
@ -73,19 +69,17 @@ pub struct PodManifest {
/// there is no implicit `$config_dir/skills/` or builtin probe.
#[serde(default)]
pub skills: Option<SkillsConfig>,
/// Optional profile provenance for manifests produced by a Nix profile.
/// Optional profile provenance for manifests produced by profile resolution.
/// Stored only after profile resolution so Pod restore can prefer the
/// validated snapshot over ambient manifest cascade state.
/// validated snapshot over current profile files or one-file Manifest input.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<profile::ProfileManifestSnapshot>,
}
/// External Agent Skills (`SKILL.md`) ingest configuration. Skills are
/// loaded *only* from the directories listed here — there is no
/// implicit `$config_dir/skills/` or builtin probe. Cascade-merged
/// across manifest layers, so a user-level manifest can declare a
/// shared skill root once while a project manifest adds its own
/// `.claude/skills/` / `.cursor/skills/` paths on top.
/// implicit `$config_dir/skills/` or builtin probe. Profile and Manifest
/// resolution may compose these entries before validation.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SkillsConfig {
/// Skills *roots*. Children of each root must be individual
@ -395,9 +389,10 @@ pub struct ScopeConfig {
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct SessionConfig {
/// Persist every provider stream event directly to `trace.jsonl` next to the
/// segment log. Intended for debugging stalls between stream requests; off
/// by default because it can be verbose.
/// Persist normalized provider stream events and lifecycle diagnostics to a
/// `.trace.jsonl` sidecar next to the segment log. This is not guaranteed to
/// be a byte-for-byte raw SSE capture. Intended for debugging stalls between
/// stream requests; off by default because it can be verbose.
#[serde(default)]
pub record_event_trace: bool,
}

View File

@ -2,7 +2,7 @@
//!
//! 用途別に三つの base directory を持つ:
//!
//! - **`config_dir`** — 人が手で書く / 編集する設定。`manifest.toml`,
//! - **`config_dir`** — 人が手で書く / 編集する設定。`profiles.toml`,
//! `providers.toml`, `models.toml`, `prompts/`, `prompts.toml` 等
//! - **`data_dir`** — プログラムが書く永続データ。`sessions/` 等
//! - **`runtime_dir`** — 再起動で消えてよいランタイム状態。socket,
@ -23,20 +23,12 @@
//! 解決された各 base が存在するか / ディレクトリかは保証しない —
//! 呼び出し側がファイル操作の前に作成 / 検査する。
use std::ffi::OsString;
use std::path::PathBuf;
/// Environment variable that points at an explicit user manifest.
///
/// Pod CLI treats a non-empty value as an explicit manifest path. Empty values
/// are treated the same as an unset variable, so callers fall back to the
/// auto-discovered user manifest path.
pub const USER_MANIFEST_ENV: &str = "INSOMNIA_USER_MANIFEST";
/// Environment variable that points at installed project resources.
pub const RESOURCE_DIR_ENV: &str = "INSOMNIA_RESOURCE_DIR";
/// 設定ディレクトリ。`manifest.toml`, `providers.toml`, `models.toml`,
/// 設定ディレクトリ。`profiles.toml`, `providers.toml`, `models.toml`,
/// `prompts/` などが置かれる。
pub fn config_dir() -> Option<PathBuf> {
if let Some(p) = env_path("INSOMNIA_CONFIG_DIR") {
@ -80,42 +72,10 @@ pub fn runtime_dir() -> Option<PathBuf> {
// ---- well-known file getters ------------------------------------------------
/// `<config_dir>/manifest.toml` — user manifest の既定位置。
///
/// This deliberately ignores [`USER_MANIFEST_ENV`]. Use
/// [`user_manifest_path_with_env_override`] when mirroring the Pod CLI cascade
/// resolution rules.
pub fn user_manifest_path() -> Option<PathBuf> {
Some(config_dir()?.join("manifest.toml"))
}
/// Resolve an explicit user manifest override from an env value.
///
/// Non-empty values are paths. `None` and empty strings are both treated as no
/// override, matching the Pod CLI's `INSOMNIA_USER_MANIFEST` handling.
pub fn user_manifest_path_from_env(value: Option<OsString>) -> Option<PathBuf> {
value.and_then(|value| {
if value.as_os_str().is_empty() {
None
} else {
Some(PathBuf::from(value))
}
})
}
/// User manifest path using the same env override rule as the Pod CLI cascade.
///
/// A non-empty [`USER_MANIFEST_ENV`] value wins. If the variable is unset or
/// empty, this falls back to [`user_manifest_path`]. The returned path is not
/// guaranteed to exist.
pub fn user_manifest_path_with_env_override() -> Option<PathBuf> {
user_manifest_path_from_env(std::env::var_os(USER_MANIFEST_ENV)).or_else(user_manifest_path)
}
/// `<config_dir>/profiles.toml` — user profile registry/default configuration.
///
/// This is application/profile selection configuration, not a Pod manifest
/// layer. It deliberately ignores [`USER_MANIFEST_ENV`].
/// layer.
pub fn user_profiles_path() -> Option<PathBuf> {
Some(config_dir()?.join("profiles.toml"))
}
@ -125,7 +85,7 @@ pub fn user_prompts_dir() -> Option<PathBuf> {
Some(config_dir()?.join("prompts"))
}
/// Root resource directory used for bundled prompts/Nix support files.
/// Root resource directory used for bundled prompts, profiles, catalogs, and docs.
pub fn resource_dir() -> Option<PathBuf> {
if let Some(p) = env_path(RESOURCE_DIR_ENV) {
return Some(p);
@ -145,10 +105,10 @@ pub fn resource_dir() -> Option<PathBuf> {
)
}
/// Bundled profile registry directory. Missing directories are treated as an
/// empty builtin registry by discovery.
/// Bundled Lua profile registry directory. Missing directories are treated as
/// an empty builtin registry by discovery.
pub fn builtin_profiles_dir() -> Option<PathBuf> {
Some(resource_dir()?.join("nix").join("profiles"))
Some(resource_dir()?.join("profiles"))
}
/// `<config_dir>/prompts.toml` — user prompt pack。
@ -228,7 +188,6 @@ mod tests {
"INSOMNIA_CONFIG_DIR",
"INSOMNIA_DATA_DIR",
"INSOMNIA_RUNTIME_DIR",
"INSOMNIA_USER_MANIFEST",
"INSOMNIA_RESOURCE_DIR",
"INSOMNIA_HOME",
"XDG_CONFIG_HOME",
@ -355,44 +314,9 @@ mod tests {
assert!(runtime_dir().is_none());
}
#[test]
fn user_manifest_env_override_wins_when_non_empty() {
let _g = EnvGuard::new(&[
("HOME", Some("/h")),
("INSOMNIA_USER_MANIFEST", Some("/tmp/user.toml")),
]);
assert_eq!(
user_manifest_path_with_env_override().unwrap(),
PathBuf::from("/tmp/user.toml")
);
}
#[test]
fn empty_user_manifest_env_falls_back_to_default_path() {
let _g = EnvGuard::new(&[("HOME", Some("/h")), ("INSOMNIA_USER_MANIFEST", Some(""))]);
assert_eq!(
user_manifest_path_with_env_override().unwrap(),
PathBuf::from("/h/.config/insomnia/manifest.toml")
);
}
#[test]
fn user_manifest_path_from_env_treats_empty_as_unset() {
assert_eq!(user_manifest_path_from_env(None), None);
assert_eq!(user_manifest_path_from_env(Some(OsString::from(""))), None);
assert_eq!(
user_manifest_path_from_env(Some(OsString::from("/tmp/u.toml"))).unwrap(),
PathBuf::from("/tmp/u.toml")
);
}
#[test]
fn well_known_files_compose_off_base_dirs() {
let _g = EnvGuard::new(&[("INSOMNIA_HOME", Some("/sand"))]);
assert_eq!(
user_manifest_path().unwrap(),
PathBuf::from("/sand/config/manifest.toml")
);
assert_eq!(
user_profiles_path().unwrap(),
PathBuf::from("/sand/config/profiles.toml")

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,8 @@
//! `WorkspaceLayout` carries the workspace root (typically the Pod's
//! pwd). All insomnia-managed content lives under the conventional
//! `<root>/.insomnia/` subdirectory — the same place that holds
//! `manifest.toml` and `prompts/`. The trees inside it:
//! `profiles.toml`, `prompts/`, workflow, knowledge, and generated
//! memory. The trees inside it:
//!
//! - `<root>/.insomnia/workflow/<slug>.md`
//! - `<root>/.insomnia/knowledge/<slug>.md`

View File

@ -45,9 +45,9 @@ pub fn register_pod(
/// and the registration proceeds. The check is structural (deny ⊇
/// competitor.rule), not relational — it does not verify that the
/// competitor actually descends from this Pod's prior delegations.
/// In practice this is safe because the canonical caller is `restore`,
/// which derives `scope_deny` from the session's own snapshot, so any
/// covered competitor is guaranteed to be a descendant of the original
/// In practice this is safe because the canonical restore caller derives
/// `scope_deny` from outstanding `pod-store` child delegations, so any
/// covered competitor is expected to be a descendant of the original
/// allocation. Direct callers must uphold the same invariant.
pub fn register_pod_with_deny(
guard: &mut LockFileGuard,
@ -180,10 +180,11 @@ pub fn release_pod(guard: &mut LockFileGuard, pod_name: &str) -> Result<(), Scop
/// Reclaim a child delegation back into its parent allocation.
///
/// This is idempotent: missing child allocations and missing deny entries are
/// ignored. For each delegated Write rule, at most one exact matching deny rule
/// is removed from the parent's `scope_deny`, preserving any duplicate explicit
/// base deny that was not owned by this child delegation.
/// This is idempotent for missing deny entries. For each delegated Write rule,
/// at most one exact matching deny rule is removed from the parent's `scope_deny`
/// even when the child allocation is already absent; restore reconciliation uses
/// that case when durable Pod-state still records an outstanding delegation but
/// the live lock file no longer has a child allocation.
pub fn reclaim_delegated_scope(
guard: &mut LockFileGuard,
parent: &str,
@ -199,17 +200,13 @@ pub fn reclaim_delegated_scope(
.map(|idx| guard.data().allocations[idx].delegated_from.clone())
.unwrap_or(None);
let child_exists = child_idx.is_some();
if child_exists {
if let Some(parent_alloc) = guard.data_mut().find_mut(parent) {
for rule in delegated_scope
.iter()
.filter(|rule| rule.permission == Permission::Write)
{
if let Some(idx) = parent_alloc.scope_deny.iter().position(|deny| deny == rule) {
parent_alloc.scope_deny.remove(idx);
}
if let Some(parent_alloc) = guard.data_mut().find_mut(parent) {
for rule in delegated_scope
.iter()
.filter(|rule| rule.permission == Permission::Write)
{
if let Some(idx) = parent_alloc.scope_deny.iter().position(|deny| deny == rule) {
parent_alloc.scope_deny.remove(idx);
}
}
}
@ -516,15 +513,43 @@ mod tests {
assert_eq!(a.scope_deny, vec![delegated_rule.clone()]);
assert!(g.data().find("b").is_none());
reclaim_delegated_scope(&mut g, "a", "b", &[delegated_rule.clone()]).unwrap();
reclaim_delegated_scope(&mut g, "a", "b", std::slice::from_ref(&delegated_rule)).unwrap();
let a = g.data().find("a").unwrap();
assert_eq!(
a.scope_deny,
vec![delegated_rule],
"a repeated reclaim with no child allocation must not broaden an explicit duplicate base deny"
assert!(
a.scope_deny.is_empty(),
"a missing child allocation still reclaims one matching parent deny"
);
}
#[test]
fn reclaim_delegated_scope_removes_parent_deny_when_child_allocation_missing() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json");
let mut g = open_empty(&path);
let delegated_rule = write_rule("/src/core", true);
register_pod_with_deny(
&mut g,
"a".into(),
std::process::id(),
sock("a"),
vec![write_rule("/src", true)],
vec![delegated_rule.clone()],
sid(),
)
.unwrap();
reclaim_delegated_scope(
&mut g,
"a",
"missing",
std::slice::from_ref(&delegated_rule),
)
.unwrap();
let a = g.data().find("a").unwrap();
assert!(a.scope_deny.is_empty());
}
#[test]
fn reclaim_stale_reparents_and_removes_dead_entries() {
let dir = TempDir::new().unwrap();

View File

@ -0,0 +1,15 @@
[package]
name = "pod-store"
description = "Durable Pod-name metadata/state persistence"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
session-store = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }

541
crates/pod-store/src/lib.rs Normal file
View File

@ -0,0 +1,541 @@
//! Durable Pod-name metadata/state persistence.
//!
//! This crate owns the name-keyed Pod state surface under a Pod-state root,
//! e.g. `{data_dir}/pods/{pod_name}/metadata.json`. Session JSONL replay stays
//! in `session-store`; Pod metadata may point at a `(SessionId, SegmentId)` but
//! does not own or replay session logs.
//!
//! `resolved_manifest_snapshot` is authority only for Pod-name restore before
//! loading the session log. Existing segment replay still uses `SegmentStart`
//! entries from `session-store`. `spawned_children` is durable current parent
//! Pod state for child registry/reclaim; child lifecycle messages shown to the
//! model remain session JSONL history. Socket and callback paths are last-known
//! runtime hints, not proof of liveness.
use serde::{Deserialize, Serialize};
use session_store::{SegmentId, SessionId};
use std::fs;
use std::path::PathBuf;
/// Errors from Pod metadata persistence.
#[derive(Debug, thiserror::Error)]
pub enum PodStoreError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("serialization error: {0}")]
Serde(#[from] serde_json::Error),
#[error("invalid pod name: {0}")]
InvalidPodName(String),
}
/// Active Session/Segment pointer for a Pod.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PodActiveSegmentRef {
pub session_id: SessionId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub segment_id: Option<SegmentId>,
}
impl PodActiveSegmentRef {
/// Create a reference whose active Segment is not known yet.
pub fn pending_segment(session_id: SessionId) -> Self {
Self {
session_id,
segment_id: None,
}
}
/// Create a fully resolved active Session/Segment reference.
pub fn active_segment(session_id: SessionId, segment_id: SegmentId) -> Self {
Self {
session_id,
segment_id: Some(segment_id),
}
}
}
/// One delegated scope rule for a spawned child, kept local to avoid depending
/// on manifest scope types in durable Pod state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PodSpawnedScopeRule {
pub target: PathBuf,
pub permission: String,
pub recursive: bool,
}
/// One child Pod spawned by this Pod and persisted with the spawner's
/// name-keyed Pod state. Runtime paths are last-known hints only.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PodSpawnedChild {
pub pod_name: String,
pub socket_path: PathBuf,
pub scope_delegated: Vec<PodSpawnedScopeRule>,
pub callback_address: PathBuf,
}
/// One child delegation that has been reclaimed. Kept as durable audit state so
/// restore can distinguish outstanding delegated scope from already-reclaimed
/// child state without consulting session logs.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PodReclaimedChild {
pub pod_name: String,
pub scope_delegated: Vec<PodSpawnedScopeRule>,
}
/// Persistent metadata for a Pod name.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PodMetadata {
pub pod_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active: Option<PodActiveSegmentRef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub spawned_children: Vec<PodSpawnedChild>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reclaimed_children: Vec<PodReclaimedChild>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_manifest_snapshot: Option<serde_json::Value>,
}
impl PodMetadata {
/// Create Pod metadata for `pod_name`.
pub fn new(pod_name: impl Into<String>, active: Option<PodActiveSegmentRef>) -> Self {
Self {
pod_name: pod_name.into(),
active,
spawned_children: Vec::new(),
reclaimed_children: Vec::new(),
resolved_manifest_snapshot: None,
}
}
}
/// Sync persistence backend for Pod metadata.
pub trait PodMetadataStore: Send + Sync {
/// Create or replace metadata for its `pod_name` key.
fn write(&self, metadata: &PodMetadata) -> Result<(), PodStoreError>;
/// Read metadata by Pod name. Returns `None` when no metadata exists.
fn read_by_name(&self, pod_name: &str) -> Result<Option<PodMetadata>, PodStoreError>;
/// List persisted Pod metadata keys.
fn list_names(&self) -> Result<Vec<String>, PodStoreError>;
/// Return the metadata root directory when this backend is path-backed.
fn root_dir(&self) -> Option<PathBuf> {
None
}
/// Delete metadata by Pod name. Missing metadata is a successful no-op.
fn delete_by_name(&self, pod_name: &str) -> Result<(), PodStoreError>;
/// Merge an update into one Pod's metadata, preserving unrelated fields.
fn update_by_name<F>(&self, pod_name: &str, update: F) -> Result<PodMetadata, PodStoreError>
where
F: FnOnce(&mut PodMetadata),
{
let mut metadata = self
.read_by_name(pod_name)?
.unwrap_or_else(|| PodMetadata::new(pod_name, None));
update(&mut metadata);
metadata.pod_name = pod_name.to_string();
self.write(&metadata)?;
Ok(metadata)
}
/// Set the active pointer while preserving spawned children and manifest snapshot.
fn set_active(
&self,
pod_name: &str,
active: Option<PodActiveSegmentRef>,
resolved_manifest_snapshot: Option<serde_json::Value>,
) -> Result<PodMetadata, PodStoreError> {
self.update_by_name(pod_name, |metadata| {
metadata.active = active;
metadata.resolved_manifest_snapshot = resolved_manifest_snapshot;
})
}
/// Set spawned-child registry state while preserving active pointer and manifest snapshot.
fn set_spawned_children(
&self,
pod_name: &str,
children: Vec<PodSpawnedChild>,
) -> Result<PodMetadata, PodStoreError> {
self.update_by_name(pod_name, |metadata| {
metadata.spawned_children = children;
})
}
/// Remove reclaimed child delegations from the outstanding set and record
/// them in durable reclaim history.
fn reclaim_spawned_children(
&self,
pod_name: &str,
reclaimed: Vec<PodReclaimedChild>,
) -> Result<PodMetadata, PodStoreError> {
self.update_by_name(pod_name, |metadata| {
for reclaimed_child in &reclaimed {
metadata
.spawned_children
.retain(|child| child.pod_name != reclaimed_child.pod_name);
}
metadata.reclaimed_children.extend(reclaimed);
})
}
}
/// Filesystem-backed Pod metadata store.
#[derive(Clone)]
pub struct FsPodStore {
root: PathBuf,
}
impl FsPodStore {
/// Create a store rooted at the Pod-state directory, usually `{data_dir}/pods`.
pub fn new(root: impl Into<PathBuf>) -> Result<Self, PodStoreError> {
let root = root.into();
fs::create_dir_all(&root)?;
Ok(Self { root })
}
fn pod_dir(&self, pod_name: &str) -> Result<PathBuf, PodStoreError> {
validate_pod_name(pod_name)?;
Ok(self.root.join(pod_name))
}
fn metadata_path(&self, pod_name: &str) -> Result<PathBuf, PodStoreError> {
Ok(self.pod_dir(pod_name)?.join("metadata.json"))
}
}
impl PodMetadataStore for FsPodStore {
fn write(&self, metadata: &PodMetadata) -> Result<(), PodStoreError> {
let path = self.metadata_path(&metadata.pod_name)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let content = serde_json::to_vec_pretty(metadata)?;
fs::write(path, content)?;
Ok(())
}
fn read_by_name(&self, pod_name: &str) -> Result<Option<PodMetadata>, PodStoreError> {
let path = self.metadata_path(pod_name)?;
let content = match fs::read_to_string(path) {
Ok(content) => content,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(PodStoreError::Io(err)),
};
Ok(Some(serde_json::from_str(&content)?))
}
fn list_names(&self) -> Result<Vec<String>, PodStoreError> {
let mut names = Vec::new();
if !self.root.exists() {
return Ok(names);
}
for entry in fs::read_dir(&self.root)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
if !entry.path().join("metadata.json").exists() {
continue;
}
let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else {
continue;
};
if validate_pod_name(&name).is_ok() {
names.push(name);
}
}
names.sort();
Ok(names)
}
fn root_dir(&self) -> Option<PathBuf> {
Some(self.root.clone())
}
fn delete_by_name(&self, pod_name: &str) -> Result<(), PodStoreError> {
let path = self.metadata_path(pod_name)?;
match fs::remove_file(&path) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(PodStoreError::Io(err)),
}
if let Some(parent) = path.parent() {
let _ = fs::remove_dir(parent);
}
Ok(())
}
}
pub fn validate_pod_name(pod_name: &str) -> Result<(), PodStoreError> {
if pod_name.is_empty()
|| pod_name == "."
|| pod_name == ".."
|| pod_name.contains('/')
|| pod_name.contains('\0')
{
return Err(PodStoreError::InvalidPodName(pod_name.to_string()));
}
Ok(())
}
/// Convenience composition for callers that want one handle carrying separate
/// session-log and Pod-state roots.
#[derive(Clone)]
pub struct CombinedStore<S, P> {
pub session_store: S,
pub pod_store: P,
}
impl<S, P> CombinedStore<S, P> {
pub fn new(session_store: S, pod_store: P) -> Self {
Self {
session_store,
pod_store,
}
}
}
impl<S, P> session_store::Store for CombinedStore<S, P>
where
S: session_store::Store,
P: Send + Sync,
{
fn append(
&self,
session_id: SessionId,
segment_id: SegmentId,
entry: &session_store::LogEntry,
) -> Result<(), session_store::StoreError> {
self.session_store.append(session_id, segment_id, entry)
}
fn read_all(
&self,
session_id: SessionId,
segment_id: SegmentId,
) -> Result<Vec<session_store::LogEntry>, session_store::StoreError> {
self.session_store.read_all(session_id, segment_id)
}
fn list_sessions(&self) -> Result<Vec<SessionId>, session_store::StoreError> {
self.session_store.list_sessions()
}
fn list_segments(
&self,
session_id: SessionId,
) -> Result<Vec<SegmentId>, session_store::StoreError> {
self.session_store.list_segments(session_id)
}
fn lookup_session_of(
&self,
segment_id: SegmentId,
) -> Result<Option<SessionId>, session_store::StoreError> {
self.session_store.lookup_session_of(segment_id)
}
fn create_segment(
&self,
session_id: SessionId,
segment_id: SegmentId,
entries: &[session_store::LogEntry],
) -> Result<(), session_store::StoreError> {
self.session_store
.create_segment(session_id, segment_id, entries)
}
fn exists(
&self,
session_id: SessionId,
segment_id: SegmentId,
) -> Result<bool, session_store::StoreError> {
self.session_store.exists(session_id, segment_id)
}
fn truncate(
&self,
session_id: SessionId,
segment_id: SegmentId,
entries_len: usize,
) -> Result<(), session_store::StoreError> {
self.session_store
.truncate(session_id, segment_id, entries_len)
}
fn read_entry_count(
&self,
session_id: SessionId,
segment_id: SegmentId,
) -> Result<usize, session_store::StoreError> {
self.session_store.read_entry_count(session_id, segment_id)
}
fn append_trace(
&self,
session_id: SessionId,
segment_id: SegmentId,
entry: &session_store::TraceEntry,
) -> Result<(), session_store::StoreError> {
self.session_store
.append_trace(session_id, segment_id, entry)
}
}
impl<S, P> PodMetadataStore for CombinedStore<S, P>
where
S: Send + Sync,
P: PodMetadataStore,
{
fn write(&self, metadata: &PodMetadata) -> Result<(), PodStoreError> {
self.pod_store.write(metadata)
}
fn read_by_name(&self, pod_name: &str) -> Result<Option<PodMetadata>, PodStoreError> {
self.pod_store.read_by_name(pod_name)
}
fn list_names(&self) -> Result<Vec<String>, PodStoreError> {
self.pod_store.list_names()
}
fn root_dir(&self) -> Option<PathBuf> {
self.pod_store.root_dir()
}
fn delete_by_name(&self, pod_name: &str) -> Result<(), PodStoreError> {
self.pod_store.delete_by_name(pod_name)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pod_metadata_manifest_snapshot_roundtrips() {
let mut metadata = PodMetadata::new(
"profile-pod",
Some(PodActiveSegmentRef::pending_segment(
session_store::new_session_id(),
)),
);
metadata.resolved_manifest_snapshot = Some(serde_json::json!({
"pod": { "name": "profile-pod" },
"profile": { "source": { "kind": "path", "path": "/profiles/coder.lua" } }
}));
let json = serde_json::to_string(&metadata).unwrap();
let restored: PodMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(restored, metadata);
}
#[test]
fn fs_store_writes_under_pod_state_root_only() {
let tmp = tempfile::TempDir::new().unwrap();
let session_root = tmp.path().join("sessions");
let pod_root = tmp.path().join("pods");
fs::create_dir_all(&session_root).unwrap();
let store = FsPodStore::new(&pod_root).unwrap();
store
.write(&PodMetadata::new(
"agent",
Some(PodActiveSegmentRef::pending_segment(
session_store::new_session_id(),
)),
))
.unwrap();
assert!(pod_root.join("agent/metadata.json").exists());
assert!(!session_root.join("pods/agent/metadata.json").exists());
}
#[test]
fn active_updates_preserve_children_and_manifest_snapshot() {
let tmp = tempfile::TempDir::new().unwrap();
let store = FsPodStore::new(tmp.path()).unwrap();
let mut metadata = PodMetadata::new("agent", None);
metadata.spawned_children.push(PodSpawnedChild {
pod_name: "child".into(),
socket_path: std::path::Path::new("/tmp/child.sock").into(),
scope_delegated: vec![],
callback_address: std::path::Path::new("/tmp/parent.sock").into(),
});
metadata.resolved_manifest_snapshot = Some(serde_json::json!({"pod":{"name":"agent"}}));
store.write(&metadata).unwrap();
let snapshot = serde_json::json!({"pod":{"name":"updated"}});
store
.set_active(
"agent",
Some(PodActiveSegmentRef::active_segment(
session_store::new_session_id(),
session_store::new_segment_id(),
)),
Some(snapshot.clone()),
)
.unwrap();
let restored = store.read_by_name("agent").unwrap().unwrap();
assert_eq!(restored.spawned_children.len(), 1);
assert_eq!(restored.resolved_manifest_snapshot, Some(snapshot));
}
#[test]
fn child_updates_preserve_active_and_manifest_snapshot() {
let tmp = tempfile::TempDir::new().unwrap();
let store = FsPodStore::new(tmp.path()).unwrap();
let active = PodActiveSegmentRef::active_segment(
session_store::new_session_id(),
session_store::new_segment_id(),
);
let snapshot = serde_json::json!({"pod":{"name":"agent"}});
store
.set_active("agent", Some(active.clone()), Some(snapshot.clone()))
.unwrap();
store
.set_spawned_children(
"agent",
vec![PodSpawnedChild {
pod_name: "child".into(),
socket_path: std::path::Path::new("/tmp/child.sock").into(),
scope_delegated: vec![],
callback_address: std::path::Path::new("/tmp/parent.sock").into(),
}],
)
.unwrap();
let restored = store.read_by_name("agent").unwrap().unwrap();
assert_eq!(restored.active, Some(active));
assert_eq!(restored.resolved_manifest_snapshot, Some(snapshot));
}
#[test]
fn reclaim_children_removes_outstanding_and_records_history() {
let tmp = tempfile::TempDir::new().unwrap();
let store = FsPodStore::new(tmp.path()).unwrap();
let scope = PodSpawnedScopeRule {
target: std::path::Path::new("/tmp/delegated").into(),
permission: "write".into(),
recursive: true,
};
store
.set_spawned_children(
"agent",
vec![PodSpawnedChild {
pod_name: "child".into(),
socket_path: std::path::Path::new("/tmp/child.sock").into(),
scope_delegated: vec![scope.clone()],
callback_address: std::path::Path::new("/tmp/parent.sock").into(),
}],
)
.unwrap();
store
.reclaim_spawned_children(
"agent",
vec![PodReclaimedChild {
pod_name: "child".into(),
scope_delegated: vec![scope.clone()],
}],
)
.unwrap();
let restored = store.read_by_name("agent").unwrap().unwrap();
assert!(restored.spawned_children.is_empty());
assert_eq!(restored.reclaimed_children.len(), 1);
assert_eq!(restored.reclaimed_children[0].scope_delegated, vec![scope]);
}
}

View File

@ -13,6 +13,7 @@ async-trait = { workspace = true }
clap = { version = "4.6.0", features = ["derive"] }
llm-worker = { workspace = true }
session-store = { workspace = true }
pod-store = { workspace = true }
manifest = { workspace = true }
protocol = { workspace = true }
provider = { workspace = true }

View File

@ -12,6 +12,7 @@
//! ```
use pod::{Pod, PodManifest, PodRunResult};
use pod_store::{CombinedStore, FsPodStore};
use session_store::FsStore;
fn manifest_toml(pwd: &std::path::Path) -> String {
@ -48,7 +49,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 2. Create a persistent store (temp dir for demo)
let tmp = tempfile::tempdir()?;
let store = FsStore::new(tmp.path())?;
let store = CombinedStore::new(
FsStore::new(tmp.path().join("sessions"))?,
FsPodStore::new(tmp.path().join("pods"))?,
);
// 3. Build the Pod from the single-layer manifest TOML
let mut pod = Pod::from_manifest_toml(&toml, store).await?;

View File

@ -6,6 +6,7 @@
//! ```
use pod::{Event, Method, PodController};
use pod_store::{CombinedStore, FsPodStore};
use session_store::FsStore;
fn manifest_toml(pwd: &std::path::Path) -> String {
@ -39,7 +40,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let pwd = std::env::current_dir()?;
let toml = manifest_toml(&pwd);
let tmp = tempfile::tempdir()?;
let store = FsStore::new(tmp.path())?;
let store = CombinedStore::new(
FsStore::new(tmp.path().join("sessions"))?,
FsPodStore::new(tmp.path().join("pods"))?,
);
let pod = pod::Pod::from_manifest_toml(&toml, store).await?;
let runtime_tmp = tempfile::tempdir()?;

View File

@ -4,7 +4,8 @@ use std::sync::atomic::Ordering;
use llm_worker::WorkerError;
use llm_worker::llm_client::client::LlmClient;
use session_store::{PodMetadataStore, Store};
use pod_store::PodMetadataStore;
use session_store::Store;
use tokio::sync::{broadcast, mpsc, oneshot};
use crate::discovery::{
@ -161,14 +162,15 @@ impl PodController {
pod.store().clone(),
spawner_name.clone(),
Some(pod.scope().clone()),
Some(pod.scope_change_sink()),
)
.await?;
let reclaimed_unreachable = loaded_registry.reclaimed_unreachable;
let spawned_registry = loaded_registry.registry;
if reclaimed_unreachable {
pod.persist_scope_snapshot()
.map_err(std::io::Error::other)?;
pod.push_notify(
"Restored Pod state contained unreachable delegated child Pods; their delegated write scopes were reclaimed before resume."
.to_string(),
);
}
// Hand the alerter to the Pod so internal operations (compaction,
@ -496,11 +498,11 @@ where
let pwd = pod.pwd().to_path_buf();
let task_store = pod.task_store();
let session_id_for_usage = pod.segment_id().to_string();
let scope_change_sink = pod.scope_change_sink();
let memory_config = pod.manifest().memory.clone();
let web_config = pod.manifest().web.clone();
let spawner_name = pod.manifest().pod.name.clone();
let spawner_model = pod.manifest().model.clone();
let spawner_record_event_trace = pod.manifest().session.record_event_trace;
let pod_store = pod.store().clone();
let self_parent_socket = pod.callback_socket().cloned();
@ -555,8 +557,8 @@ where
spawned_registry.clone(),
self_parent_socket,
spawner_model,
spawner_record_event_trace,
scope_handle,
scope_change_sink,
));
worker.register_tool(send_to_pod_tool(spawned_registry.clone()));
worker.register_tool(read_pod_output_tool(spawned_registry.clone()));
@ -617,6 +619,11 @@ async fn controller_loop<C, St>(
// here so the status flip → drive_turn → finish sequence lives
// in one place, regardless of which Method caused it.
if let Some(run) = pending.take() {
// Cancellation is meaningful only for an accepted running turn. Clear
// idle/stale signals before the status flip; any Cancel/Pause received
// after this point is delivered to the turn and must not be discarded by
// the Worker at run start.
pod.worker_mut().clear_pending_cancel();
set_controller_status(&shared_state, &runtime_dir, &event_tx, PodStatus::Running).await;
let parent_originated = run.is_parent_originated();
let (new_status, shutdown) = match run {
@ -721,7 +728,7 @@ async fn controller_loop<C, St>(
// RUNNING / Paused: the buffer push is the entire
// operation; an in-flight turn (or the next
// Resume/Run) will drain it at its next
// pre_llm_request. IDLE: auto-start a turn so the LLM
// pending_history_appends. IDLE: auto-start a turn so the LLM
// sees the buffered notification(s) without a human
// Run.
if shared_state.get_status() == PodStatus::Idle {
@ -893,11 +900,12 @@ async fn controller_loop<C, St>(
Method::ListCompletions { .. } => {}
Method::PodEvent(event) => {
// Live echo travels through the SystemItem lane: once
// the interceptor drains the notify buffer, the
// typed `SystemItem::PodEvent` lands as a
// For agent-visible PodEvents, live echo travels through the
// SystemItem lane: once the interceptor drains the notify buffer,
// the typed `SystemItem::PodEvent` lands as a
// `LogEntry::SystemItem` entry and the sink forwards it
// to clients as `Event::SystemItem`.
// to clients as `Event::SystemItem`. Control-plane-only
// PodEvents use this same receive path only for side effects.
//
// (1) system side effects — idempotent and tolerant of
// out-of-order delivery (e.g. `TurnEnded` arriving
@ -909,17 +917,19 @@ async fn controller_loop<C, St>(
&self_parent_socket,
)
.await;
// (2) queue the typed event in the notification buffer;
// the next LLM request will inject it as a typed
// `SystemItem::PodEvent` via the interceptor drain.
pod.push_pod_event_notify(event);
// Auto-kick a turn if the Pod is idle so the
// notification is not stranded. Matches the
// `Method::Notify` idle path.
if shared_state.get_status() == PodStatus::Idle {
pending = Some(PendingRun::RunForNotification(
protocol::InvokeKind::PodEvent,
));
// (2) agent-visible events enter the notification/history lane.
// Control-plane-only events (currently ScopeSubDelegated)
// stop after side effects so they do not wake or notify the LLM.
if event.should_notify_agent() {
pod.push_pod_event_notify(event);
// Auto-kick a turn if the Pod is idle so the
// notification is not stranded. Matches the
// `Method::Notify` idle path.
if shared_state.get_status() == PodStatus::Idle {
pending = Some(PendingRun::RunForNotification(
protocol::InvokeKind::PodEvent,
));
}
}
}
}
@ -1065,7 +1075,7 @@ where
}
Some(Method::Notify { message }) => {
// Live echo arrives via `Event::SystemItem` once
// the in-flight turn's next `pre_llm_request`
// the in-flight turn's next `pending_history_appends`
// drains this entry through the interceptor.
notify_buffer.push_notify(message);
}
@ -1086,10 +1096,11 @@ where
// to the next main-loop iteration — drop here
// would lose the event entirely (children fire
// and forget). Apply the side effects inline
// and stage the typed event on the notification
// buffer so the in-flight turn's next
// `pre_llm_request` surfaces it as a typed
// `SystemItem::PodEvent`.
// and, for agent-visible variants, stage the typed
// event on the notification buffer so the in-flight
// turn's next `pending_history_appends` surfaces it
// as a typed `SystemItem::PodEvent`. Control-plane-only
// variants stop after side effects.
let self_parent_socket = parent_socket.cloned();
crate::ipc::event::apply_event_side_effects(
&event,
@ -1098,7 +1109,9 @@ where
&self_parent_socket,
)
.await;
notify_buffer.push_pod_event(event);
if event.should_notify_agent() {
notify_buffer.push_pod_event(event);
}
}
None => {
let _ = cancel_tx.try_send(());
@ -1246,6 +1259,7 @@ fn worker_error_code(e: &PodError) -> ErrorCode {
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::dir::SpawnedPodRecord;
use protocol::PodEvent;
use protocol::stream::{JsonLineReader, JsonLineWriter};
use std::time::Duration;
@ -1483,6 +1497,91 @@ mod tests {
);
}
#[tokio::test]
async fn running_scope_sub_delegated_applies_side_effects_without_notify_buffer() {
let mut env = make_env().await;
env.spawned_registry
.add(SpawnedPodRecord {
pod_name: "child".into(),
socket_path: "/tmp/child.sock".into(),
scope_delegated: vec![],
callback_address: "/tmp/parent.sock".into(),
})
.await
.expect("seed child record");
env._method_tx
.send(Method::PodEvent(PodEvent::ScopeSubDelegated {
parent_pod: "child".into(),
sub_pod: "grandchild".into(),
sub_socket: "/tmp/grandchild.sock".into(),
scope: vec![],
}))
.await
.expect("send pod event");
let pod_future = async {
tokio::time::sleep(Duration::from_millis(50)).await;
Ok::<_, PodError>(PodRunResult::Finished)
};
let (status, shutdown) = drive_turn(
pod_future,
&mut env.method_rx,
&env.event_tx,
&env.cancel_tx,
&env.shared_state,
&env.notify_buffer,
Some(&env.parent_socket_path),
"parent",
&env.spawned_registry,
false,
)
.await;
assert_eq!(status, PodStatus::Idle);
assert!(!shutdown);
assert!(
env.spawned_registry.get("grandchild").await.is_some(),
"ScopeSubDelegated side effects must still register the grandchild"
);
assert!(
env.notify_buffer.is_empty(),
"control-plane-only ScopeSubDelegated must not enter the agent-visible notify buffer"
);
}
#[tokio::test]
async fn running_visible_pod_event_enters_notify_buffer() {
let mut env = make_env().await;
env._method_tx
.send(Method::PodEvent(PodEvent::TurnEnded {
pod_name: "child".into(),
}))
.await
.expect("send pod event");
let pod_future = async {
tokio::time::sleep(Duration::from_millis(50)).await;
Ok::<_, PodError>(PodRunResult::Finished)
};
let (status, shutdown) = drive_turn(
pod_future,
&mut env.method_rx,
&env.event_tx,
&env.cancel_tx,
&env.shared_state,
&env.notify_buffer,
Some(&env.parent_socket_path),
"parent",
&env.spawned_registry,
false,
)
.await;
assert_eq!(status, PodStatus::Idle);
assert!(!shutdown);
assert_eq!(env.notify_buffer.len(), 1);
}
#[tokio::test]
async fn compact_method_is_rejected_while_running() {
let mut env = make_env().await;

View File

@ -15,11 +15,12 @@ use std::time::Duration;
use async_trait::async_trait;
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use pod_store::{PodActiveSegmentRef, PodMetadata, PodMetadataStore};
use protocol::stream::JsonLineReader;
use protocol::{Event, PodStatus};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use session_store::{PodActiveSegmentRef, PodMetadata, PodMetadataStore, SegmentId, SessionId};
use session_store::{SegmentId, SessionId};
use tokio::net::UnixStream;
use tokio::process::Command;
@ -496,8 +497,10 @@ pub enum PodDiscoveryError {
socket_path: PathBuf,
pid: u32,
},
#[error("store error: {0}")]
#[error("session store error: {0}")]
Store(#[from] session_store::StoreError),
#[error("pod store error: {0}")]
PodStore(#[from] pod_store::PodStoreError),
#[error("scope lock error: {0}")]
ScopeLock(#[from] pod_registry::ScopeLockError),
#[error("failed to launch restore process: {0}")]
@ -527,7 +530,7 @@ impl VisibilitySet {
}
async fn summarize_spawned_children(
children: &[session_store::PodSpawnedChild],
children: &[pod_store::PodSpawnedChild],
) -> SpawnedChildrenSummary {
let mut summary = SpawnedChildrenSummary {
count: children.len(),
@ -752,6 +755,7 @@ fn discovery_error_to_tool_error(error: PodDiscoveryError) -> ToolError {
| PodDiscoveryError::NotRestorable { .. } => ToolError::InvalidArgument(error.to_string()),
PodDiscoveryError::LockConflict { .. }
| PodDiscoveryError::Store(_)
| PodDiscoveryError::PodStore(_)
| PodDiscoveryError::ScopeLock(_)
| PodDiscoveryError::RestoreSpawn(_)
| PodDiscoveryError::RestoreExited { .. }
@ -765,11 +769,10 @@ mod tests {
use std::sync::Mutex;
use manifest::{Permission, ScopeRule};
use pod_store::{FsPodStore, PodSpawnedChild, PodSpawnedScopeRule};
use protocol::stream::JsonLineWriter;
use protocol::{Alert, AlertLevel, AlertSource, Greeting};
use session_store::{
FsStore, PodSpawnedChild, PodSpawnedScopeRule, new_segment_id, new_session_id,
};
use session_store::{new_segment_id, new_session_id};
use tempfile::TempDir;
use tokio::net::UnixListener;
@ -788,7 +791,7 @@ mod tests {
std::env::set_var("INSOMNIA_RUNTIME_DIR", &runtime_base);
}
let store = FsStore::new(&store_dir).unwrap();
let store = FsPodStore::new(&store_dir).unwrap();
let session_id = new_session_id();
let active_child_segment = new_segment_id();
let pending_session_id = new_session_id();
@ -806,6 +809,7 @@ mod tests {
child("child-stale", &stale_socket),
child("child-pending", &pending_socket),
],
reclaimed_children: Vec::new(),
resolved_manifest_snapshot: None,
};
store.write(&parent).unwrap();
@ -817,6 +821,7 @@ mod tests {
active_child_segment,
)),
spawned_children: Vec::new(),
reclaimed_children: Vec::new(),
resolved_manifest_snapshot: None,
})
.unwrap();
@ -828,6 +833,7 @@ mod tests {
active_child_segment,
)),
spawned_children: Vec::new(),
reclaimed_children: Vec::new(),
resolved_manifest_snapshot: None,
})
.unwrap();
@ -836,6 +842,7 @@ mod tests {
pod_name: "child-pending".into(),
active: Some(PodActiveSegmentRef::pending_segment(pending_session_id)),
spawned_children: Vec::new(),
reclaimed_children: Vec::new(),
resolved_manifest_snapshot: None,
})
.unwrap();
@ -847,6 +854,7 @@ mod tests {
new_segment_id(),
)),
spawned_children: Vec::new(),
reclaimed_children: Vec::new(),
resolved_manifest_snapshot: None,
})
.unwrap();

View File

@ -1,686 +0,0 @@
//! Builder that assembles a [`PodManifest`] from cascade layers.
//!
//! Layers are merged in order of increasing priority:
//! 1. **Builtin defaults** — in-code defaults, currently empty. Upper
//! layers provide everything; `TryFrom<PodManifestConfig>` fills in
//! per-field defaults (`ToolOutputLimits`, `CompactionConfig`, ...).
//! 2. **User manifest** — `$XDG_CONFIG_HOME/insomnia/manifest.toml`
//! (falling back to `~/.config/insomnia/manifest.toml`).
//! 3. **Project manifest** — closest `.insomnia/manifest.toml` found by
//! walking up from `cwd`.
//! 4. **Programmatic overlay** — inline TOML string or typed
//! [`PodManifestConfig`] supplied by the caller (CLI flags, GUI,
//! spawning Pod, etc.). Highest priority.
//!
//! Path resolution happens **before** merge. Each layer is resolved
//! against its own base directory so that a relative `target = "."`
//! in the project manifest means the project root regardless of how
//! the user or overlay layers lay out their own paths:
//!
//! - user manifest: base = the directory holding the manifest file
//! (which is `manifest::paths::config_dir()` when loaded via the
//! `_auto` variant)
//! - project manifest: base = the **project root** (the parent of
//! `.insomnia/`, not `.insomnia/` itself) so that natural project
//! manifests with `target = "."` cover the whole workspace
//! - overlay: base = the process's `current_dir()` at the time the
//! overlay is installed, since an inline TOML string has no file
//! location of its own
use std::path::{Path, PathBuf};
use manifest::{
LayerLoadError, PodManifest, PodManifestConfig, ResolveError, find_project_manifest_from,
load_layer, paths,
};
use crate::prompt::loader::PromptLoader;
/// Errors raised while building a [`PodManifest`] from cascade layers.
#[derive(Debug, thiserror::Error)]
pub enum FactoryError {
#[error("failed to read manifest {}: {source}", .path.display())]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse manifest {}: {source}", .path.display())]
Parse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("failed to parse overlay TOML: {0}")]
OverlayParse(#[source] toml::de::Error),
#[error("failed to resolve manifest config: {0}")]
Resolve(#[source] ResolveError),
}
impl From<LayerLoadError> for FactoryError {
fn from(e: LayerLoadError) -> Self {
match e {
LayerLoadError::Io { path, source } => Self::Io { path, source },
LayerLoadError::Parse { path, source } => Self::Parse { path, source },
}
}
}
/// Builder that accumulates cascade layers and resolves them to a
/// validated [`PodManifest`].
///
/// Call order does not matter — layers are always merged in the fixed
/// priority order listed at the module level. Calling the same
/// `with_*` method twice overwrites the previous value for that slot.
#[derive(Debug, Default)]
pub struct PodFactory {
/// User layer paired with the directory the manifest lives in
/// (base for resolving its relative paths).
user: Option<(PodManifestConfig, PathBuf)>,
/// Project layer paired with the directory the manifest lives in.
project: Option<(PodManifestConfig, PathBuf)>,
/// Programmatic overlays are resolved against the process's
/// `current_dir()` at the time each call arrives, then merged into
/// this slot. Storing a pre-resolved (absolute-paths) config means
/// later overlay calls from a different cwd still work correctly.
overlay: Option<PodManifestConfig>,
/// Directory holding the user prompts library — co-located with
/// the user manifest when loaded. `<user_manifest_dir>/prompts/`.
user_prompts_dir: Option<PathBuf>,
/// `<project_root>/.insomnia/prompts/` — co-located with the
/// project manifest when loaded.
project_prompts_dir: Option<PathBuf>,
/// `<user_manifest_dir>/prompts.toml`, sibling of the user
/// prompts library. Consumed by the prompt catalog's user layer.
user_pack_file: Option<PathBuf>,
/// `<project_root>/.insomnia/prompts.toml`, sibling of the project
/// prompts library. Consumed by the prompt catalog's workspace layer.
project_pack_file: Option<PathBuf>,
}
impl PodFactory {
pub fn new() -> Self {
Self::default()
}
/// Attempt to load the user manifest from the user's config
/// directory (see [`manifest::paths::config_dir`] for how the path
/// is resolved). If the resolved file does not exist, the call is a
/// no-op — user manifests are optional.
pub fn with_user_manifest_auto(mut self) -> Result<Self, FactoryError> {
let Some(path) = paths::user_manifest_path() else {
return Ok(self);
};
if path.exists() {
let base = manifest_base(&path)?;
self.user = Some((load_layer(&path)?, base.clone()));
self.user_prompts_dir = paths::user_prompts_dir();
self.user_pack_file = paths::user_pack_file();
}
Ok(self)
}
/// Load the user manifest from an explicit path. The file must
/// exist; missing files are an error (unlike the `_auto` variant).
pub fn with_user_manifest(mut self, path: impl AsRef<Path>) -> Result<Self, FactoryError> {
let path = path.as_ref();
let base = manifest_base(path)?;
self.user = Some((load_layer(path)?, base.clone()));
self.user_prompts_dir = Some(base.join("prompts"));
self.user_pack_file = Some(base.join("prompts.toml"));
Ok(self)
}
/// Walk up from `cwd` looking for a `.insomnia/manifest.toml` and
/// load it as the project layer. If no project root is found the
/// call is a no-op.
pub fn with_project_manifest_auto(mut self) -> Result<Self, FactoryError> {
let cwd = std::env::current_dir().map_err(|source| FactoryError::Io {
path: PathBuf::from("."),
source,
})?;
if let Some(path) = find_project_manifest_from(&cwd) {
self.install_project_manifest(&path)?;
}
Ok(self)
}
/// Walk up from `start` looking for a `.insomnia/manifest.toml`.
/// Explicit variant of [`with_project_manifest_auto`] for tests.
pub fn with_project_manifest_from(
mut self,
start: impl AsRef<Path>,
) -> Result<Self, FactoryError> {
if let Some(path) = find_project_manifest_from(start.as_ref()) {
self.install_project_manifest(&path)?;
}
Ok(self)
}
/// Shared setup for `with_project_manifest_auto` / `_from`: record
/// the manifest's project root as the base for relative-path
/// resolution (the parent of `.insomnia/`, not `.insomnia/` itself)
/// so `target = "."` in a project manifest means the project root.
/// `prompts/` still lives inside `.insomnia/`.
fn install_project_manifest(&mut self, path: &Path) -> Result<(), FactoryError> {
let insomnia_dir = manifest_base(path)?;
let project_root = insomnia_dir
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| insomnia_dir.clone());
self.project = Some((load_layer(path)?, project_root));
self.project_prompts_dir = Some(insomnia_dir.join("prompts"));
self.project_pack_file = Some(insomnia_dir.join("prompts.toml"));
Ok(())
}
/// Install a programmatic overlay parsed from a TOML string. Any
/// relative paths in the overlay are resolved against the process's
/// current working directory at the time of this call — an inline
/// TOML string has no file location of its own.
pub fn with_overlay_toml(mut self, toml: &str) -> Result<Self, FactoryError> {
let config = PodManifestConfig::from_toml(toml).map_err(FactoryError::OverlayParse)?;
self.overlay = Some(resolve_and_merge_overlay(self.overlay, config)?);
Ok(self)
}
/// Install a programmatic overlay from an already-parsed config.
/// Behaves like [`Self::with_overlay_toml`] regarding relative paths.
pub fn with_overlay_config(mut self, config: PodManifestConfig) -> Result<Self, FactoryError> {
self.overlay = Some(resolve_and_merge_overlay(self.overlay, config)?);
Ok(self)
}
/// Build a [`PromptLoader`] that reflects the user / project
/// prompt directories registered with this factory (a sibling of
/// each manifest file: `prompts/`). Missing directories are
/// silently skipped.
fn build_prompt_loader(&self) -> PromptLoader {
let user = self
.user_prompts_dir
.as_ref()
.filter(|p| p.is_dir())
.cloned();
let project = self
.project_prompts_dir
.as_ref()
.filter(|p| p.is_dir())
.cloned();
// Pack file filters: `.is_file()` keeps the loader's view
// consistent with the catalog loader, which skips missing packs
// silently. An existing but non-file path (e.g. a directory
// named `prompts.toml`) is also elided here and will surface
// only when a manifest pack explicitly references it.
let user_pack = self
.user_pack_file
.as_ref()
.filter(|p| p.is_file())
.cloned();
let project_pack = self
.project_pack_file
.as_ref()
.filter(|p| p.is_file())
.cloned();
PromptLoader::new(user, project).with_pack_files(user_pack, project_pack)
}
/// Merge all installed layers, convert the result to a validated
/// [`PodManifest`], and return it together with a [`PromptLoader`]
/// that reflects the user / project prompt directories. The loader
/// feeds `{% include "name" %}` references in the Pod's system
/// prompt template.
///
/// Each layer is resolved to absolute paths against its own base
/// (see module docs) **before** merge, so scope rules and
/// `api_key_file` paths from different layers do not accidentally
/// inherit another layer's base.
///
/// The base layer is [`PodManifestConfig::builtin_defaults`] so
/// every per-field default flows through a single source of truth
/// (see [`manifest::defaults`]).
pub fn resolve(self) -> Result<(PodManifest, PromptLoader), FactoryError> {
let loader = self.build_prompt_loader();
let merged = PodManifestConfig::builtin_defaults();
let merged = match self.user {
Some((user, base)) => merged.merge(user.resolve_paths(&base)),
None => merged,
};
let merged = match self.project {
Some((project, base)) => merged.merge(project.resolve_paths(&base)),
None => merged,
};
let merged = match self.overlay {
Some(overlay) => merged.merge(overlay),
None => merged,
};
let manifest = PodManifest::try_from(merged).map_err(FactoryError::Resolve)?;
Ok((manifest, loader))
}
}
fn manifest_base(path: &Path) -> Result<PathBuf, FactoryError> {
let parent = path.parent().ok_or_else(|| FactoryError::Io {
path: path.to_path_buf(),
source: std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"manifest path has no parent directory",
),
})?;
// Absolutise against cwd so later path joins produce absolute
// results regardless of whether the caller passed a relative
// manifest path.
if parent.is_absolute() {
Ok(parent.to_path_buf())
} else {
let cwd = std::env::current_dir().map_err(|source| FactoryError::Io {
path: PathBuf::from("."),
source,
})?;
Ok(cwd.join(parent))
}
}
fn resolve_and_merge_overlay(
existing: Option<PodManifestConfig>,
incoming: PodManifestConfig,
) -> Result<PodManifestConfig, FactoryError> {
let cwd = std::env::current_dir().map_err(|source| FactoryError::Io {
path: PathBuf::from("."),
source,
})?;
let resolved = incoming.resolve_paths(&cwd);
Ok(match existing {
Some(prev) => prev.merge(resolved),
None => resolved,
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn write(path: &Path, contents: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, contents).unwrap();
}
#[test]
fn resolve_overlay_only() {
let tmp = TempDir::new().unwrap();
let pwd = tmp.path().canonicalize().unwrap();
let overlay = format!(
r#"
[pod]
name = "solo"
[model]
scheme = "anthropic"
model_id = "claude-sonnet-4-20250514"
[[scope.allow]]
target = "{pwd}"
permission = "write"
"#,
pwd = pwd.display()
);
let manifest = PodFactory::new()
.with_overlay_toml(&overlay)
.unwrap()
.resolve()
.unwrap();
let manifest = manifest.0;
assert_eq!(manifest.pod.name, "solo");
}
#[test]
fn overlay_stacking_merges_in_place() {
let tmp = TempDir::new().unwrap();
let pwd = tmp.path().canonicalize().unwrap();
let user_cfg = PodManifestConfig::from_toml(&format!(
r#"
[model]
scheme = "anthropic"
model_id = "user-model"
[[scope.allow]]
target = "{pwd}"
permission = "read"
"#,
pwd = pwd.display()
))
.unwrap();
let project_cfg = PodManifestConfig::from_toml(&format!(
r#"
[model]
model_id = "project-model"
[[scope.allow]]
target = "{pwd}"
permission = "write"
"#,
pwd = pwd.display()
))
.unwrap();
let overlay_cfg = PodManifestConfig::from_toml(
r#"
[pod]
name = "overlay-name"
"#,
)
.unwrap();
let (manifest, _loader) = PodFactory::new()
.with_overlay_config(user_cfg)
.unwrap()
.with_overlay_config(project_cfg)
.unwrap()
.with_overlay_config(overlay_cfg)
.unwrap()
.resolve()
.unwrap();
// Note: stacking via with_overlay_config merges into one
// overlay layer so later calls win. This also exercises the
// scope union across layers (two allow rules).
assert_eq!(manifest.pod.name, "overlay-name");
assert_eq!(manifest.model.model_id.as_deref(), Some("project-model"));
assert_eq!(manifest.scope.allow.len(), 2);
}
#[test]
fn cascade_priority_layer_ordering() {
let tmp = TempDir::new().unwrap();
let pwd = tmp.path().canonicalize().unwrap();
// Simulate distinct user / project / overlay layers by using
// the dedicated slots on the factory.
let user = tmp.path().join("user.toml");
write(
&user,
&format!(
r#"
[pod]
name = "from-user"
[model]
scheme = "anthropic"
model_id = "user-model"
[[scope.allow]]
target = "{pwd}"
permission = "write"
"#,
pwd = pwd.display()
),
);
let project_root = tmp.path().join("proj");
let project_manifest = project_root.join(".insomnia").join("manifest.toml");
write(
&project_manifest,
r#"
[model]
model_id = "project-model"
"#,
);
let (manifest, _loader) = PodFactory::new()
.with_user_manifest(&user)
.unwrap()
.with_project_manifest_from(&project_root)
.unwrap()
.resolve()
.unwrap();
// project layer overrides user layer on model.model_id
assert_eq!(manifest.model.model_id.as_deref(), Some("project-model"));
// user layer provides the rest
assert_eq!(manifest.pod.name, "from-user");
}
#[test]
fn project_manifest_walks_up_from_nested_dir() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().canonicalize().unwrap();
let project_manifest = root.join(".insomnia").join("manifest.toml");
write(
&project_manifest,
&format!(
r#"
[pod]
name = "walked-up"
[model]
scheme = "anthropic"
model_id = "claude-sonnet-4-20250514"
[[scope.allow]]
target = "{root}"
permission = "write"
"#,
root = root.display()
),
);
let nested = root.join("a").join("b").join("c");
std::fs::create_dir_all(&nested).unwrap();
let manifest = PodFactory::new()
.with_project_manifest_from(&nested)
.unwrap()
.resolve()
.unwrap();
let manifest = manifest.0;
assert_eq!(manifest.pod.name, "walked-up");
}
#[test]
fn missing_project_root_is_ok() {
let tmp = TempDir::new().unwrap();
let pwd = tmp.path().canonicalize().unwrap();
let overlay = format!(
r#"
[pod]
name = "standalone"
[model]
scheme = "anthropic"
model_id = "m"
[[scope.allow]]
target = "{pwd}"
permission = "write"
"#,
pwd = pwd.display()
);
// The temp dir has no .insomnia/ — walking up should skip the
// project layer silently.
let manifest = PodFactory::new()
.with_project_manifest_from(&pwd)
.unwrap()
.with_overlay_toml(&overlay)
.unwrap()
.resolve()
.unwrap();
let manifest = manifest.0;
assert_eq!(manifest.pod.name, "standalone");
}
#[test]
fn user_manifest_relative_paths_resolve_against_its_directory() {
// user manifest at <tmp>/cfg/manifest.toml with a relative
// scope target `./workspace` must resolve to <tmp>/cfg/workspace.
let tmp = TempDir::new().unwrap();
let root = tmp.path().canonicalize().unwrap();
let cfg_dir = root.join("cfg");
std::fs::create_dir_all(&cfg_dir).unwrap();
let workspace = cfg_dir.join("workspace");
std::fs::create_dir_all(&workspace).unwrap();
let user = cfg_dir.join("manifest.toml");
write(
&user,
r#"
[pod]
name = "rel-user"
[model]
scheme = "anthropic"
model_id = "m"
[[scope.allow]]
target = "./workspace"
permission = "write"
"#,
);
let (manifest, _loader) = PodFactory::new()
.with_user_manifest(&user)
.unwrap()
.resolve()
.unwrap();
assert_eq!(manifest.scope.allow[0].target, workspace);
}
#[test]
fn project_manifest_relative_paths_resolve_against_project_root() {
// `.insomnia/manifest.toml` is the marker for the project, but
// the intuitive base for its relative paths is the project
// root (the parent of `.insomnia/`) — `target = "."` in a
// project manifest should cover the whole workspace, not the
// `.insomnia/` subdir.
let tmp = TempDir::new().unwrap();
let root = tmp.path().canonicalize().unwrap();
let insomnia_dir = root.join(".insomnia");
std::fs::create_dir_all(&insomnia_dir).unwrap();
let project_manifest = insomnia_dir.join("manifest.toml");
write(
&project_manifest,
r#"
[pod]
name = "rel-project"
[model]
scheme = "anthropic"
model_id = "m"
[[scope.allow]]
target = "."
permission = "read"
[[scope.allow]]
target = "src"
permission = "write"
"#,
);
let (manifest, _loader) = PodFactory::new()
.with_project_manifest_from(&root)
.unwrap()
.resolve()
.unwrap();
assert_eq!(manifest.scope.allow[0].target, root);
assert_eq!(manifest.scope.allow[1].target, root.join("src"));
}
#[test]
fn resolve_produces_loader_with_workspace_prompts_dir() {
use crate::prompt::system::{SystemPromptContext, SystemPromptTemplate};
use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
let tmp = TempDir::new().unwrap();
let root = tmp.path().canonicalize().unwrap();
// .insomnia/manifest.toml and .insomnia/prompts/local.md
let manifest_path = root.join(".insomnia").join("manifest.toml");
write(
&manifest_path,
&format!(
r#"
[pod]
name = "factory-pod"
[model]
scheme = "anthropic"
model_id = "m"
[[scope.allow]]
target = "{root}"
permission = "write"
"#,
root = root.display()
),
);
let workspace_prompts_dir = root.join(".insomnia").join("prompts");
std::fs::create_dir_all(&workspace_prompts_dir).unwrap();
std::fs::write(
workspace_prompts_dir.join("local.md"),
"WORKSPACE-BODY from {{ cwd }}",
)
.unwrap();
let (_manifest, loader) = PodFactory::new()
.with_project_manifest_from(&root)
.unwrap()
.resolve()
.unwrap();
// The workspace prompt must be reachable via $workspace/local.
let tmpl = SystemPromptTemplate::parse("$workspace/local", loader).unwrap();
let scope_cfg = ScopeConfig {
allow: vec![ScopeRule {
target: root.clone(),
permission: Permission::Write,
recursive: true,
}],
deny: Vec::new(),
};
let scope = Scope::from_config(&scope_cfg).unwrap();
let catalog = crate::prompt::catalog::PromptCatalog::builtins_only().unwrap();
let ctx = SystemPromptContext {
now: chrono::Utc::now(),
cwd: &root,
language: manifest::defaults::WORKER_LANGUAGE,
scope: &scope,
tool_names: Vec::new(),
agents_md: None,
resident_summary: None,
resident_knowledge: None,
resident_workflows: None,
prompts: &catalog,
};
let rendered = tmpl.render(&ctx).unwrap();
assert!(
rendered.starts_with("WORKSPACE-BODY"),
"expected workspace body, got: {rendered}"
);
}
#[test]
fn resolve_fails_on_missing_required_field() {
let tmp = TempDir::new().unwrap();
let pwd = tmp.path().canonicalize().unwrap();
// pod.name missing — resolver must reject.
let overlay = format!(
r#"
[model]
scheme = "anthropic"
model_id = "m"
[[scope.allow]]
target = "{pwd}"
permission = "write"
"#,
pwd = pwd.display()
);
let err = PodFactory::new()
.with_overlay_toml(&overlay)
.unwrap()
.resolve()
.unwrap_err();
assert!(matches!(err, FactoryError::Resolve(_)));
}
}

View File

@ -6,8 +6,10 @@
//!
//! - **Send** a `Method::PodEvent` to the parent socket, fire-and-forget,
//! logging failures without blocking the child.
//! - **Render** a variant into a human-readable string that the parent's
//! LLM sees via the notification buffer.
//! - **Render** agent-visible variants into human-readable strings for the
//! parent's notification buffer. Control-plane-only variants may still have
//! a renderer for diagnostics, but receive-side classification keeps them
//! out of LLM history/context.
//! - **Apply side effects** on the parent (registry / pod-registry
//! updates) so that the receive path is idempotent and tolerant of
//! out-of-order delivery.
@ -52,11 +54,13 @@ pub fn fire_and_forget(socket: Option<PathBuf>, event: PodEvent) {
});
}
/// Render a variant into the one-line human-readable string that will
/// be injected into the parent's LLM context as a system message.
/// Render a variant into a one-line human-readable string.
///
/// Kept deliberately short — the LLM can always call `ReadPodOutput`
/// to fetch more detail if the event summary is not enough.
/// Only events classified by `PodEvent::should_notify_agent` are injected
/// into the parent's LLM context as system messages; control-plane-only events
/// keep this renderer for diagnostics/tests. Agent-visible summaries are kept
/// deliberately short — the LLM can always call `ReadPodOutput` to fetch more
/// detail if the event summary is not enough.
pub fn render_event(event: &PodEvent) -> String {
match event {
PodEvent::TurnEnded { pod_name } => {

View File

@ -11,8 +11,9 @@
//! persistent history.
//!
//! This is the **single lane** for "system messages produced by Pod
//! state that should land in the next LLM request": Notify, PodEvent,
//! and any future `<system-reminder>` injection all ride this queue.
//! state that should land in the next LLM request": Notify,
//! agent-visible PodEvent variants, and any future `<system-reminder>`
//! injection all ride this queue.
//! Per `tickets/notify-history-persist.md` and `AGENTS.md` (LLM
//! context の加工原則), there is **no** "transient, history-skipping"
//! lane — everything injected into a request is also committed to

View File

@ -11,14 +11,12 @@ pub mod shared_state;
pub mod spawn;
pub mod workflow;
mod factory;
mod interrupt_prep;
mod permission;
mod pod;
pub use compact::token_counter::{EstimateSource, SplitPoint, TokenEstimate};
pub use controller::{PodController, PodHandle, ShutdownReceiver};
pub use factory::{FactoryError, PodFactory};
pub use hook::{Hook, HookEventKind, HookRegistryBuilder};
pub use ipc::alerter::Alerter;
pub use ipc::server::SocketServer;

View File

@ -3,18 +3,19 @@ use std::process::ExitCode;
use clap::Parser;
use manifest::{
NixProfileResolver, PodManifest, PodManifestConfig, ProfileSelector, ScopeConfig, paths,
PodManifest, PodManifestConfig, ProfileResolveOptions, ProfileResolver, ProfileSelector, paths,
};
use pod::{Pod, PodController, PromptLoader};
use session_store::{FsStore, PodMetadataStore, SegmentId, Store};
use pod_store::{CombinedStore, FsPodStore, PodMetadataStore};
use session_store::{FsStore, SegmentId, Store};
#[derive(Debug, Parser)]
#[command(
name = "insomnia-pod",
about = "Spawn a Pod process from a Nix profile or a single manifest file"
about = "Spawn a Pod process from a profile or a single manifest file"
)]
struct Cli {
/// Nix profile to evaluate. Accepts an explicit path, `path:<path>`, a
/// Profile to evaluate. Accepts an explicit path, `path:<path>`, a
/// discovered profile name, `default`, or a source-qualified name such as
/// `project:coder`.
#[arg(
@ -45,10 +46,6 @@ struct Cli {
#[arg(long, value_name = "NAME", requires = "session", hide = true)]
session_pod_name: Option<String>,
/// Internal typed scope snapshot for session restore launched by the TUI.
#[arg(long, value_name = "JSON", requires = "session", hide = true)]
resume_scope_json: Option<String>,
/// Internal resolved manifest config for delegated child Pod spawning.
#[arg(
long,
@ -133,10 +130,6 @@ fn apply_session_restore_overrides(manifest: &mut PodManifest, cli: &Cli) -> Res
if let Some(pod_name) = cli.session_pod_name.as_deref() {
manifest.pod.name = pod_name.to_string();
}
if let Some(scope_json) = cli.resume_scope_json.as_deref() {
manifest.scope = serde_json::from_str::<ScopeConfig>(scope_json)
.map_err(|e| format!("failed to parse --resume-scope-json: {e}"))?;
}
Ok(())
}
@ -154,16 +147,16 @@ fn load_profile(
) -> Result<(PodManifest, PromptLoader), String> {
let cwd = std::env::current_dir()
.map_err(|e| format!("failed to resolve current directory for profile: {e}"))?;
let resolver = NixProfileResolver::new().with_workspace_base(cwd);
let mut resolved = resolver.resolve(selector).map_err(|e| {
let resolver = ProfileResolver::new().with_workspace_base(cwd);
let options = pod_name_override
.map(ProfileResolveOptions::with_pod_name)
.unwrap_or_default();
let resolved = resolver.resolve(selector, options).map_err(|e| {
format!(
"failed to resolve profile {}: {e}",
selector.display_label()
)
})?;
if let Some(pod_name) = pod_name_override {
resolved.manifest.pod.name = pod_name.to_string();
}
Ok((resolved.manifest, PromptLoader::builtins_only()))
}
@ -229,13 +222,28 @@ async fn main() -> ExitCode {
}
},
};
let store = match FsStore::new(&store_dir) {
let session_store = match FsStore::new(&store_dir) {
Ok(s) => s,
Err(e) => {
eprintln!("error: failed to initialize store at {store_dir:?}: {e}");
eprintln!("error: failed to initialize session store at {store_dir:?}: {e}");
return ExitCode::FAILURE;
}
};
let pod_store_dir = match paths::data_dir() {
Some(data_dir) => data_dir.join("pods"),
None => store_dir
.parent()
.map(|parent| parent.join("pods"))
.unwrap_or_else(|| PathBuf::from("pods")),
};
let pod_store = match FsPodStore::new(&pod_store_dir) {
Ok(s) => s,
Err(e) => {
eprintln!("error: failed to initialize pod store at {pod_store_dir:?}: {e}");
return ExitCode::FAILURE;
}
};
let store = CombinedStore::new(session_store, pod_store);
let pod = if cli.adopt {
let callback = match cli.callback.clone() {
@ -436,7 +444,7 @@ permission = "write"
#[test]
fn profile_uses_selected_profile() {
let tmp = TempDir::new().unwrap();
let profile = tmp.path().join("profile.nix");
let profile = tmp.path().join("profile.lua");
let cli = Cli::try_parse_from([
"insomnia-pod",
"--profile",
@ -625,12 +633,12 @@ permission = "write"
fn profile_conflicts_with_manifest_and_restore_modes() {
let segment_id = session_store::new_segment_id().to_string();
for args in [
vec!["insomnia-pod", "--profile", "p.nix", "--manifest", "m.toml"],
vec!["insomnia-pod", "--profile", "p.nix", "--pod", "agent"],
vec!["insomnia-pod", "--profile", "p.lua", "--manifest", "m.toml"],
vec!["insomnia-pod", "--profile", "p.lua", "--pod", "agent"],
vec![
"insomnia-pod",
"--profile",
"p.nix",
"p.lua",
"--session",
&segment_id,
],
@ -651,7 +659,7 @@ permission = "write"
let cli = Cli::try_parse_from([
"insomnia-pod",
"--profile",
"p.nix",
"p.lua",
"--profile-pod-name",
"agent",
])

View File

@ -1,6 +1,7 @@
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use arc_swap::ArcSwap;
use llm_worker::Item;
@ -9,9 +10,12 @@ use llm_worker::llm_client::client::LlmClient;
use llm_worker::llm_client::types::Role;
use llm_worker::state::Mutable;
use llm_worker::{ToolOutputLimits, UsageRecord, Worker, WorkerError, WorkerResult};
use pod_store::{
PodActiveSegmentRef, PodMetadata, PodMetadataStore, PodReclaimedChild, PodSpawnedChild,
PodSpawnedScopeRule, PodStoreError,
};
use session_store::{
LogEntry, PodActiveSegmentRef, PodMetadata, PodMetadataStore, PodScopeSnapshot, SegmentId,
SessionId, Store, StoreError, SystemItem, segment_log, to_logged,
LogEntry, SegmentId, SessionId, Store, StoreError, SystemItem, segment_log, to_logged,
};
use tracing::{info, warn};
@ -43,9 +47,12 @@ use llm_worker::interceptor::PreRequestAction;
use protocol::{
AlertLevel, AlertSource, Event, RewindSummary, RewindTarget, RewindTargetId, Segment,
};
use tokio::net::UnixStream;
use tokio::sync::broadcast;
use tokio::task::JoinHandle;
const RESTORE_RECONCILIATION_REACHABILITY_TIMEOUT: Duration = Duration::from_millis(500);
/// `(SessionId, SegmentId)` pair the Pod is currently writing to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SegmentLocation {
@ -53,18 +60,21 @@ pub struct SegmentLocation {
pub segment_id: SegmentId,
}
type PodMetadataWriter = Arc<dyn Fn(PodMetadata) -> Result<(), StoreError> + Send + Sync>;
type PodMetadataWriter = Arc<dyn Fn(PodMetadata) -> Result<(), PodStoreError> + Send + Sync>;
fn pod_metadata_writer_for_store<St>(store: &St) -> PodMetadataWriter
where
St: PodMetadataStore + Clone + Send + Sync + 'static,
{
let store = store.clone();
Arc::new(move |mut metadata| {
if let Some(existing) = store.read_by_name(&metadata.pod_name)? {
metadata.spawned_children = existing.spawned_children;
}
store.write(&metadata)
Arc::new(move |metadata| {
store
.set_active(
&metadata.pod_name,
metadata.active,
metadata.resolved_manifest_snapshot,
)
.map(|_| ())
})
}
@ -341,10 +351,6 @@ pub struct Pod<C: LlmClient, St: Store> {
/// Workflow descriptions. This is intentionally independent from
/// summary and Knowledge residency: each section has its own gate.
inject_resident_workflows: bool,
/// Latest runtime scope snapshot queued by dynamic scope changes.
/// Drained into the session log before the next turn result is
/// persisted, so resume never silently reclaims delegated writes.
pending_scope_snapshot: Arc<Mutex<Option<PodScopeSnapshot>>>,
/// extract (memory.extract) reentry guard. `true` while an extract
/// worker is running; subsequent triggers are skipped per spec
/// (`docs/plan/memory.md` §Extract 並走防止). `Arc<AtomicBool>` so
@ -450,7 +456,6 @@ impl<C: LlmClient + Clone + 'static, St: Store + Clone + 'static> Pod<C, St> {
inject_resident_summary: self.inject_resident_summary,
inject_resident_knowledge: self.inject_resident_knowledge,
inject_resident_workflows: self.inject_resident_workflows,
pending_scope_snapshot: self.pending_scope_snapshot.clone(),
extract_in_flight: self.extract_in_flight.clone(),
consolidation_in_flight: self.consolidation_in_flight.clone(),
extract_pointer: self.extract_pointer.clone(),
@ -630,7 +635,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
inject_resident_summary: true,
inject_resident_knowledge: true,
inject_resident_workflows: true,
pending_scope_snapshot: Arc::new(Mutex::new(None)),
extract_in_flight: Arc::new(AtomicBool::new(false)),
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
extract_pointer: Arc::new(Mutex::new(None)),
@ -749,30 +753,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
.update(|cur| cur.with_added_deny_rules(revoke.clone()))
}
/// Snapshot the current runtime scope in the session log. The entry
/// is intentionally appended as soon as a session log exists: if the
/// process later exits while children keep their allocations, resume
/// can restore the narrowed scope instead of reclaiming delegated
/// writes.
pub fn persist_scope_snapshot(&mut self) -> Result<(), StoreError> {
if self.segment_state.entries_written() == 0 {
return Ok(());
}
let snapshot = {
let scope = self.scope.snapshot();
PodScopeSnapshot {
allow: scope.allow_rules(),
deny: scope.deny_rules(),
}
};
let payload = serde_json::to_value(&snapshot).expect("PodScopeSnapshot is Serialize");
self.commit_entry(LogEntry::Extension {
ts: segment_log::now_millis(),
domain: session_store::POD_SCOPE_EXTENSION_DOMAIN.into(),
payload,
})
}
/// Append `entry` to the session log AND publish it through the
/// broadcast sink. No user-space serialization is needed across
/// concurrent appenders — the kernel orders `O_APPEND` writes for
@ -792,34 +772,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
self.sink.clone()
}
/// Cloneable callback handed to dynamic-scope tools. It cannot append
/// directly to the async store from a sync tool callback, so it records
/// the latest snapshot and the controller flushes it after the tool
/// turn completes.
pub fn scope_change_sink(&self) -> Arc<dyn Fn(PodScopeSnapshot) + Send + Sync> {
let pending = self.pending_scope_snapshot.clone();
Arc::new(move |snapshot| {
*pending.lock().expect("pending_scope_snapshot poisoned") = Some(snapshot);
})
}
fn flush_pending_scope_snapshot(&mut self) -> Result<(), StoreError> {
let snapshot = self
.pending_scope_snapshot
.lock()
.expect("pending_scope_snapshot poisoned")
.take();
if let Some(snapshot) = snapshot {
let payload = serde_json::to_value(&snapshot).expect("PodScopeSnapshot is Serialize");
self.commit_entry(LogEntry::Extension {
ts: segment_log::now_millis(),
domain: session_store::POD_SCOPE_EXTENSION_DOMAIN.into(),
payload,
})?;
}
Ok(())
}
/// Direct access to the underlying Worker.
pub fn worker(&self) -> &Worker<C, Mutable> {
self.worker.as_ref().expect("worker taken during run")
@ -925,30 +877,32 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
metadata
}
fn write_pod_metadata_pending(&self) -> Result<(), StoreError> {
fn write_pod_metadata_pending(&self) -> Result<(), PodError> {
let Some(writer) = &self.pod_metadata_writer else {
return Ok(());
};
writer(self.pod_metadata(Some(PodActiveSegmentRef::pending_segment(
self.session_id(),
))))
))))?;
Ok(())
}
fn write_pod_metadata_active(&self, loc: SegmentLocation) -> Result<(), StoreError> {
fn write_pod_metadata_active(&self, loc: SegmentLocation) -> Result<(), PodError> {
let Some(writer) = &self.pod_metadata_writer else {
return Ok(());
};
writer(self.pod_metadata(Some(PodActiveSegmentRef::active_segment(
loc.session_id,
loc.segment_id,
))))
))))?;
Ok(())
}
/// Enable name-keyed Pod metadata write-through for Pods built through
/// the low-level constructor. High-level manifest constructors enable it
/// automatically; this hook lets tests and custom embedders opt into the
/// same persistence behavior without changing `Pod::new`'s minimal bounds.
pub fn enable_pod_metadata_write_through(&mut self) -> Result<(), StoreError>
pub fn enable_pod_metadata_write_through(&mut self) -> Result<(), PodError>
where
St: PodMetadataStore + Clone + Send + Sync + 'static,
{
@ -1127,8 +1081,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
}
}
/// Push a `Method::Notify` (or rendered `Method::PodEvent`) entry
/// onto the pending buffer.
/// Push a `Method::Notify` entry onto the pending buffer.
///
/// The notification will be appended to `worker.history` as an
/// `Item::system_message` just before the next LLM request, via
@ -1138,8 +1091,9 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
self.pending_notifies.push_notify(message);
}
/// Push a typed `PodEvent` entry onto the pending buffer.
/// Push an agent-visible typed `PodEvent` entry onto the pending buffer.
///
/// Callers must classify control-plane-only PodEvents before invoking this.
/// Same lifecycle as [`push_notify`](Self::push_notify) but
/// preserves the typed `PodEvent` payload so the IPC layer can
/// emit `SystemItem::PodEvent { event, body }` with structured
@ -2001,7 +1955,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
compacted_from: None,
};
self.commit_entry(initial)?;
self.persist_scope_snapshot()?;
self.write_pod_metadata_active(loc)?;
return Ok(());
}
@ -2296,8 +2249,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
}
}
self.flush_pending_scope_snapshot()?;
let turn_count = self.worker.as_ref().unwrap().turn_count();
self.commit_entry(LogEntry::TurnEnd {
ts: segment_log::now_millis(),
@ -2769,7 +2720,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
.lock()
.expect("usage_history poisoned")
.clear();
self.persist_scope_snapshot()?;
// Reset extract pointer alongside usage_history: the compacted
// session has a fresh log with no `LogEntry::Extension` entries
// yet, so a cold restore here would set extract_pointer to None
@ -3825,7 +3775,6 @@ where
inject_resident_summary: true,
inject_resident_knowledge: true,
inject_resident_workflows: true,
pending_scope_snapshot: Arc::new(Mutex::new(None)),
extract_in_flight: Arc::new(AtomicBool::new(false)),
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
extract_pointer: Arc::new(Mutex::new(None)),
@ -3905,7 +3854,6 @@ where
inject_resident_summary: true,
inject_resident_knowledge: true,
inject_resident_workflows: true,
pending_scope_snapshot: Arc::new(Mutex::new(None)),
extract_in_flight: Arc::new(AtomicBool::new(false)),
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
extract_pointer: Arc::new(Mutex::new(None)),
@ -3963,8 +3911,7 @@ where
/// Restore a Pod from an existing session log.
///
/// Resolves the manifest cascade exactly like [`Self::from_manifest`]
/// (pwd / scope / pod-registry / client / prompt catalog), seeds a
/// Uses the resolved manifest supplied by the caller, seeds a
/// fresh Worker from the source session's `RestoredState`, and
/// reuses the same `segment_id` so subsequent turns append to the
/// source jsonl as a continuation of the same conversation.
@ -3995,19 +3942,13 @@ where
return Err(PodError::SegmentEmpty { segment_id });
}
let mirror_entries: Vec<LogEntry> = raw_entries.clone();
let scope_snapshot = state
.pod_scope
.clone()
.ok_or(PodError::SegmentScopeMissing { segment_id })?;
let scope_config = effective_restore_scope_config(&store, &manifest)?;
let mut common = prepare_pod_common_with_scope(
&manifest,
&loader,
/* parse_template */ false,
ScopeConfig {
allow: scope_snapshot.allow,
deny: scope_snapshot.deny,
},
scope_config,
)?;
let skill_shadows = std::mem::take(&mut common.skill_shadows);
@ -4093,7 +4034,6 @@ where
inject_resident_summary: true,
inject_resident_knowledge: true,
inject_resident_workflows: true,
pending_scope_snapshot: Arc::new(Mutex::new(None)),
extract_in_flight: Arc::new(AtomicBool::new(false)),
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
extract_pointer: Arc::new(Mutex::new(extract_pointer)),
@ -4112,10 +4052,61 @@ where
session_id,
segment_id,
})?;
pod.reconcile_restored_delegations().await?;
drain_skill_shadows(&pod, skill_shadows);
Ok(pod)
}
async fn reconcile_restored_delegations(&mut self) -> Result<(), PodError> {
let pod_name = self.manifest.pod.name.clone();
let Some(metadata) = self.store.read_by_name(&pod_name)? else {
return Ok(());
};
let mut reclaimed = Vec::new();
for child in metadata.spawned_children {
if restored_child_reachable(&child).await {
continue;
}
let delegated_scope = spawned_child_scope_rules(&child);
if !delegated_scope.is_empty() {
let lock_path =
pod_registry::default_registry_path().map_err(ScopeLockError::from)?;
let mut guard =
pod_registry::LockFileGuard::open(&lock_path).map_err(ScopeLockError::from)?;
pod_registry::reclaim_delegated_scope(
&mut guard,
&pod_name,
&child.pod_name,
&delegated_scope,
)?;
let write_rules = delegated_scope
.iter()
.filter(|rule| rule.permission == Permission::Write)
.cloned()
.collect::<Vec<_>>();
self.scope
.update(|current| current.with_removed_deny_rules(write_rules))
.map_err(PodError::Scope)?;
}
reclaimed.push(PodReclaimedChild {
pod_name: child.pod_name,
scope_delegated: child.scope_delegated,
});
}
if reclaimed.is_empty() {
return Ok(());
}
self.store.reclaim_spawned_children(&pod_name, reclaimed)?;
self.push_notify(
"Restored Pod state contained missing or unreachable delegated child Pods; their delegated write scopes were reclaimed before resume."
.to_string(),
);
Ok(())
}
/// Convenience: build a Pod from a single-layer TOML manifest string.
///
/// Parses the TOML into a [`PodManifestConfig`], converts to a
@ -4438,6 +4429,7 @@ fn token_budget_bytes(tokens: u64) -> usize {
pub enum RewindError {
#[error(transparent)]
Store(#[from] StoreError),
#[error("{0}")]
Invalid(String),
}
@ -4546,6 +4538,9 @@ pub enum PodError {
#[error(transparent)]
Store(#[from] StoreError),
#[error(transparent)]
PodStore(#[from] PodStoreError),
#[error(transparent)]
Scope(ScopeError),
@ -4613,11 +4608,6 @@ pub enum PodError {
#[error("session {segment_id} has no entries to restore")]
SegmentEmpty { segment_id: SegmentId },
#[error(
"session {segment_id} has no persisted scope snapshot; refusing resume without explicit scope"
)]
SegmentScopeMissing { segment_id: SegmentId },
#[error("pod metadata for {pod_name} was not found")]
PodMetadataMissing { pod_name: String },
@ -4643,7 +4633,7 @@ pub enum PodError {
/// Bundle of resources that every high-level Pod constructor needs:
/// pwd, scope, an LLM client, the prompt catalog, and (optionally) a
/// parsed system-prompt template. Built once by [`prepare_pod_common`]
/// from the manifest cascade and then split into Pod fields.
/// from the resolved manifest and then split into Pod fields.
struct PodCommon {
pwd: PathBuf,
scope: Scope,
@ -4659,8 +4649,68 @@ struct PodCommon {
skill_shadows: Vec<workflow_crate::ShadowedSkill>,
}
async fn restored_child_reachable(child: &PodSpawnedChild) -> bool {
tokio::time::timeout(
RESTORE_RECONCILIATION_REACHABILITY_TIMEOUT,
UnixStream::connect(&child.socket_path),
)
.await
.map(|result| result.is_ok())
.unwrap_or(false)
}
fn spawned_child_scope_rules(child: &PodSpawnedChild) -> Vec<ScopeRule> {
child
.scope_delegated
.iter()
.filter_map(|rule| delegated_scope_rule_to_scope_rule(rule.clone()))
.collect()
}
fn delegated_scope_rule_to_scope_rule(rule: PodSpawnedScopeRule) -> Option<ScopeRule> {
let permission = match rule.permission.as_str() {
"read" => Permission::Read,
"write" => Permission::Write,
other => {
warn!(permission = %other, "ignoring invalid delegated child scope permission");
return None;
}
};
Some(ScopeRule {
target: rule.target,
permission,
recursive: rule.recursive,
})
}
fn effective_restore_scope_config<St>(
store: &St,
manifest: &PodManifest,
) -> Result<ScopeConfig, PodStoreError>
where
St: PodMetadataStore,
{
let mut scope = manifest.scope.clone();
let Some(metadata) = store.read_by_name(&manifest.pod.name)? else {
return Ok(scope);
};
for child in metadata.spawned_children {
for rule in child.scope_delegated {
if let Some(deny) = delegated_write_rule_to_deny(rule) {
scope.deny.push(deny);
}
}
}
Ok(scope)
}
fn delegated_write_rule_to_deny(rule: PodSpawnedScopeRule) -> Option<ScopeRule> {
let rule = delegated_scope_rule_to_scope_rule(rule)?;
(rule.permission == Permission::Write).then_some(rule)
}
/// Resolve pwd / scope / LLM client / prompt catalog from a validated
/// manifest cascade. Used by `from_manifest`, `from_manifest_spawned`,
/// manifest. Used by `from_manifest`, `from_manifest_spawned`,
/// and `restore_from_manifest` so they share one definition of "what
/// pieces fall out of a manifest".
///

View File

@ -16,10 +16,10 @@
//!
//! 1. **builtin** — `resources/prompts/internal.toml`, baked into the
//! binary. Must cover every [`PodPrompt`] variant (build-time check).
//! 2. **user** — `<user_manifest_dir>/prompts.toml`, auto-discovered by
//! [`PodFactory`]. Optional.
//! 3. **workspace** — `<project>/.insomnia/prompts.toml`, auto-discovered.
//! 2. **user** — `<config_dir>/prompts.toml`, when a caller supplies it.
//! Optional.
//! 3. **workspace** — `<project>/.insomnia/prompts.toml`, when a caller
//! supplies it. Optional.
//! 4. **manifest pack** — `manifest.pod.prompt_pack`, an explicit path
//! per-Pod. Optional.
//!
@ -270,7 +270,7 @@ impl PromptCatalog {
/// - Layer 2 (user): `loader.user_pack_file()` if present.
/// - Layer 3 (workspace): `loader.workspace_pack_file()` if present.
/// - Layer 4 (manifest): `manifest_pack` as an absolute filesystem
/// path (pre-resolved by the manifest cascade).
/// path (pre-resolved by profile/manifest resolution).
pub fn load(
loader: &PromptLoader,
manifest_pack: Option<&Path>,

View File

@ -138,9 +138,8 @@ impl PromptLoader {
}
}
/// Override the auto-discovered pack file paths. Used by
/// [`crate::PodFactory`] to surface `<user_manifest_dir>/prompts.toml`
/// and `<project>/.insomnia/prompts.toml`.
/// Override pack file paths supplied by the caller's profile/manifest
/// resolution context.
pub fn with_pack_files(
mut self,
user_pack_file: Option<PathBuf>,

View File

@ -20,9 +20,8 @@ use std::sync::Arc;
use std::time::Duration;
use manifest::{Permission, ScopeRule, SharedScope};
use session_store::{
PodMetadata, PodMetadataStore, PodScopeSnapshot, PodSpawnedChild, PodSpawnedScopeRule,
StoreError,
use pod_store::{
PodMetadataStore, PodReclaimedChild, PodSpawnedChild, PodSpawnedScopeRule, PodStoreError,
};
use tokio::net::UnixStream;
use tokio::sync::Mutex;
@ -32,7 +31,7 @@ use crate::runtime::dir::{RuntimeDir, SpawnedPodRecord};
use crate::runtime::pod_registry;
type RegistryStateWriter = Arc<dyn Fn(&[SpawnedPodRecord]) -> io::Result<()> + Send + Sync>;
type ScopeChangeSink = Arc<dyn Fn(PodScopeSnapshot) + Send + Sync>;
type RegistryReclaimWriter = Arc<dyn Fn(&SpawnedPodRecord) -> io::Result<()> + Send + Sync>;
const RESTORE_REACHABILITY_TIMEOUT: Duration = Duration::from_millis(500);
@ -41,9 +40,9 @@ pub struct SpawnedPodRegistry {
cursors: Mutex<HashMap<String, usize>>,
runtime_dir: Arc<RuntimeDir>,
state_writer: Option<RegistryStateWriter>,
reclaim_writer: Option<RegistryReclaimWriter>,
parent_name: Option<String>,
parent_scope: Option<SharedScope>,
scope_change_sink: Option<ScopeChangeSink>,
}
pub struct SpawnedPodRegistryLoad {
@ -58,9 +57,9 @@ impl SpawnedPodRegistry {
cursors: Mutex::new(HashMap::new()),
runtime_dir,
state_writer: None,
reclaim_writer: None,
parent_name: None,
parent_scope: None,
scope_change_sink: None,
})
}
@ -77,8 +76,7 @@ impl SpawnedPodRegistry {
St: PodMetadataStore + Clone + Send + Sync + 'static,
{
let loaded =
Self::load_from_pod_state_with_reclaim(runtime_dir, store, pod_name, None, None)
.await?;
Self::load_from_pod_state_with_reclaim(runtime_dir, store, pod_name, None).await?;
Ok(loaded.registry)
}
@ -87,7 +85,6 @@ impl SpawnedPodRegistry {
store: St,
pod_name: String,
parent_scope: Option<SharedScope>,
scope_change_sink: Option<ScopeChangeSink>,
) -> io::Result<SpawnedPodRegistryLoad>
where
St: PodMetadataStore + Clone + Send + Sync + 'static,
@ -99,13 +96,11 @@ impl SpawnedPodRegistry {
.unwrap_or_default();
let mut records = Vec::with_capacity(persisted_children.len());
let mut pruned = false;
let mut pruned_records = Vec::new();
for child in &persisted_children {
let record = match record_from_pod_state(child) {
Ok(record) => record,
Err(err) => {
pruned = true;
warn!(
error = %err,
pod = %child.pod_name,
@ -117,7 +112,6 @@ impl SpawnedPodRegistry {
if is_reachable(&record.socket_path).await {
records.push(record);
} else {
pruned = true;
warn!(
pod = %record.pod_name,
socket = %record.socket_path.display(),
@ -128,20 +122,40 @@ impl SpawnedPodRegistry {
}
runtime_dir.write_spawned_pods(&records).await?;
let state_writer = pod_state_writer(store, pod_name.clone());
// Runtime spawned-pod records are a live registry for ListPods and
// cursor/scope cleanup; durable Pod state remains the discovery source
// for later attach/restore, so do not delete unreachable children from
// Pod state just because their sockets are gone.
if metadata.is_none() || !pruned {
let state_writer = pod_state_writer(store.clone(), pod_name.clone());
let reclaim_writer = pod_state_reclaim_writer(store.clone(), pod_name.clone());
if metadata.is_none() {
state_writer(&records)?;
}
let mut reclaimed_unreachable = false;
if !pruned_records.is_empty() {
let reclaimed = pruned_records
.iter()
.map(|record| PodReclaimedChild {
pod_name: record.pod_name.clone(),
scope_delegated: record
.scope_delegated
.iter()
.map(|rule| PodSpawnedScopeRule {
target: rule.target.clone(),
permission: match rule.permission {
Permission::Read => "read".to_string(),
Permission::Write => "write".to_string(),
},
recursive: rule.recursive,
})
.collect(),
})
.collect();
store
.reclaim_spawned_children(&pod_name, reclaimed)
.map_err(store_error_to_io)?;
reclaimed_unreachable = true;
}
if parent_scope.is_some() {
for record in &pruned_records {
reclaim_record(&pod_name, parent_scope.as_ref(), None, record)?;
reclaimed_unreachable = true;
reclaim_record(&pod_name, parent_scope.as_ref(), record)?;
}
}
@ -151,9 +165,9 @@ impl SpawnedPodRegistry {
cursors: Mutex::new(HashMap::new()),
runtime_dir,
state_writer: Some(state_writer),
reclaim_writer: Some(reclaim_writer),
parent_name: Some(pod_name),
parent_scope,
scope_change_sink,
}),
reclaimed_unreachable,
})
@ -196,6 +210,9 @@ impl SpawnedPodRegistry {
self.cursors.lock().await.remove(pod_name);
if let Some(record) = &removed {
self.reclaim_record(record)?;
if let Some(write_reclaim) = &self.reclaim_writer {
write_reclaim(record)?;
}
}
Ok(removed)
}
@ -205,12 +222,7 @@ impl SpawnedPodRegistry {
release_child_allocation(&record.pod_name)?;
return Ok(());
};
reclaim_record(
parent_name,
self.parent_scope.as_ref(),
self.scope_change_sink.as_ref(),
record,
)
reclaim_record(parent_name, self.parent_scope.as_ref(), record)
}
/// Read-only cursor lookup. Returns 0 when no cursor has been set.
@ -248,10 +260,36 @@ where
})
}
fn pod_state_reclaim_writer<St>(store: St, pod_name: String) -> RegistryReclaimWriter
where
St: PodMetadataStore + Clone + Send + Sync + 'static,
{
Arc::new(move |record| {
let reclaimed = PodReclaimedChild {
pod_name: record.pod_name.clone(),
scope_delegated: record
.scope_delegated
.iter()
.map(|rule| PodSpawnedScopeRule {
target: rule.target.clone(),
permission: match rule.permission {
Permission::Read => "read".to_string(),
Permission::Write => "write".to_string(),
},
recursive: rule.recursive,
})
.collect(),
};
store
.reclaim_spawned_children(&pod_name, vec![reclaimed])
.map(|_| ())
.map_err(store_error_to_io)
})
}
fn reclaim_record(
parent_name: &str,
parent_scope: Option<&SharedScope>,
scope_change_sink: Option<&ScopeChangeSink>,
record: &SpawnedPodRecord,
) -> io::Result<()> {
let write_rules = record
@ -277,13 +315,6 @@ fn reclaim_record(
scope
.update(|current| current.with_removed_deny_rules(write_rules))
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
if let Some(sink) = scope_change_sink {
let snapshot = scope.snapshot();
sink(PodScopeSnapshot {
allow: snapshot.allow_rules(),
deny: snapshot.deny_rules(),
});
}
}
Ok(())
@ -304,18 +335,16 @@ fn write_records_to_pod_state<St>(
store: &St,
pod_name: &str,
records: &[SpawnedPodRecord],
) -> Result<(), StoreError>
) -> Result<(), PodStoreError>
where
St: PodMetadataStore,
{
let mut metadata = store
.read_by_name(pod_name)?
.unwrap_or_else(|| PodMetadata::new(pod_name, None));
metadata.spawned_children = records
let children = records
.iter()
.map(record_to_pod_state)
.collect::<Result<Vec<_>, _>>()?;
store.write(&metadata)
store.set_spawned_children(pod_name, children)?;
Ok(())
}
fn record_to_pod_state(record: &SpawnedPodRecord) -> Result<PodSpawnedChild, serde_json::Error> {
@ -366,7 +395,7 @@ fn record_from_pod_state(child: &PodSpawnedChild) -> Result<SpawnedPodRecord, se
})
}
fn store_error_to_io(error: StoreError) -> io::Error {
fn store_error_to_io(error: PodStoreError) -> io::Error {
io::Error::other(error)
}

View File

@ -15,10 +15,9 @@ use async_trait::async_trait;
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use manifest::{
ModelManifest, Permission, PodManifestConfig, PodMetaConfig, ScopeConfig, ScopeRule,
SharedScope, WorkerManifestConfig,
SessionConfigPartial, SharedScope, WorkerManifestConfig,
};
use serde::Deserialize;
use session_store::PodScopeSnapshot;
use tokio::net::UnixStream;
use tokio::process::Command;
use tokio::time::sleep;
@ -120,6 +119,9 @@ pub struct SpawnPodTool {
/// configuration. Per-spawn override is
/// out of scope here (see `tickets/spawn-inherit-provider.md`).
spawner_model: ModelManifest,
/// Spawner's session diagnostics policy. Preserved for spawned Pods so
/// opt-in provider event traces continue across delegation.
spawner_record_event_trace: bool,
/// Spawner's runtime scope. After a successful spawn, the
/// `Permission::Write` rules in the delegated scope are revoked
/// from the spawner's in-memory view (a `deny(Write, target)` is
@ -128,9 +130,6 @@ pub struct SpawnPodTool {
/// `effective_write` semantics: Write is the only permission
/// tracked across Pods, so revocation only touches Write.
spawner_scope: SharedScope,
/// Called after the spawner scope has been updated so the new
/// effective scope can be persisted to the session log.
scope_changed: Arc<dyn Fn(PodScopeSnapshot) + Send + Sync>,
}
impl SpawnPodTool {
@ -142,8 +141,8 @@ impl SpawnPodTool {
registry: Arc<SpawnedPodRegistry>,
parent_socket: Option<PathBuf>,
spawner_model: ModelManifest,
spawner_record_event_trace: bool,
spawner_scope: SharedScope,
scope_changed: Arc<dyn Fn(PodScopeSnapshot) + Send + Sync>,
) -> Self {
Self {
spawner_name,
@ -153,8 +152,8 @@ impl SpawnPodTool {
registry,
parent_socket,
spawner_model,
spawner_record_event_trace,
spawner_scope,
scope_changed,
}
}
}
@ -213,6 +212,7 @@ impl Tool for SpawnPodTool {
&instruction,
&scope_allow,
&self.spawner_model,
self.spawner_record_event_trace,
) {
Ok(s) => s,
Err(e) => {
@ -250,11 +250,6 @@ impl Tool for SpawnPodTool {
self.spawner_scope
.update(|cur| cur.with_added_deny_rules(revoke_write.clone()))
.map_err(|e| ToolError::ExecutionFailed(format!("revoke spawner scope: {e}")))?;
let current = self.spawner_scope.snapshot();
(self.scope_changed)(PodScopeSnapshot {
allow: current.allow_rules(),
deny: current.deny_rules(),
});
}
let record = SpawnedPodRecord {
@ -395,6 +390,7 @@ fn build_spawn_config_json(
instruction: &str,
scope_allow: &[ScopeRule],
model: &ModelManifest,
record_event_trace: bool,
) -> Result<String, serde_json::Error> {
let config = PodManifestConfig {
pod: PodMetaConfig {
@ -410,6 +406,9 @@ fn build_spawn_config_json(
allow: scope_allow.to_vec(),
deny: Vec::new(),
},
session: record_event_trace.then_some(SessionConfigPartial {
record_event_trace: Some(true),
}),
..Default::default()
};
serde_json::to_string(&config)
@ -495,8 +494,8 @@ pub fn spawn_pod_tool(
registry: Arc<SpawnedPodRegistry>,
parent_socket: Option<PathBuf>,
spawner_model: ModelManifest,
spawner_record_event_trace: bool,
spawner_scope: SharedScope,
scope_changed: Arc<dyn Fn(PodScopeSnapshot) + Send + Sync>,
) -> ToolDefinition {
Arc::new(move || {
let schema = schemars::schema_for!(SpawnPodInput);
@ -512,8 +511,8 @@ pub fn spawn_pod_tool(
registry.clone(),
parent_socket.clone(),
spawner_model.clone(),
spawner_record_event_trace,
spawner_scope.clone(),
scope_changed.clone(),
));
(meta, tool)
})
@ -522,7 +521,7 @@ pub fn spawn_pod_tool(
#[cfg(test)]
mod tests {
use super::*;
use manifest::{AuthRef, SchemeKind};
use manifest::{AuthRef, PodManifest, SchemeKind};
#[test]
fn spawn_config_inherits_inline_spawner_model() {
@ -538,7 +537,7 @@ mod tests {
};
let config_json =
build_spawn_config_json("child", "$insomnia/default", &[], &model).unwrap();
build_spawn_config_json("child", "$insomnia/default", &[], &model, false).unwrap();
let parsed: PodManifestConfig = serde_json::from_str(&config_json).unwrap();
assert_eq!(parsed.model.scheme, Some(SchemeKind::Anthropic));
@ -561,11 +560,51 @@ mod tests {
..Default::default()
};
let config_json =
build_spawn_config_json("child", "$insomnia/default", &[], &model).unwrap();
build_spawn_config_json("child", "$insomnia/default", &[], &model, false).unwrap();
let parsed: PodManifestConfig = serde_json::from_str(&config_json).unwrap();
assert_eq!(
parsed.model.ref_.as_deref(),
Some("anthropic/claude-sonnet-4-6")
);
}
#[test]
fn spawn_config_preserves_record_event_trace_when_enabled() {
let model = ModelManifest {
ref_: Some("anthropic/claude-sonnet-4-6".into()),
..Default::default()
};
let scope = vec![ScopeRule {
target: PathBuf::from("/tmp/child"),
permission: Permission::Read,
recursive: true,
}];
let config_json =
build_spawn_config_json("child", "$insomnia/default", &scope, &model, true).unwrap();
let parsed: PodManifestConfig = serde_json::from_str(&config_json).unwrap();
assert_eq!(
parsed.session.as_ref().and_then(|s| s.record_event_trace),
Some(true)
);
let manifest: PodManifest = PodManifestConfig::builtin_defaults()
.merge(parsed)
.try_into()
.unwrap();
assert!(manifest.session.record_event_trace);
}
#[test]
fn spawn_config_omits_record_event_trace_when_disabled() {
let model = ModelManifest {
ref_: Some("anthropic/claude-sonnet-4-6".into()),
..Default::default()
};
let config_json =
build_spawn_config_json("child", "$insomnia/default", &[], &model, false).unwrap();
let parsed: PodManifestConfig = serde_json::from_str(&config_json).unwrap();
assert!(parsed.session.is_none());
}
}

View File

@ -16,12 +16,15 @@ use llm_worker::Worker;
use llm_worker::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use llm_worker::llm_client::types::Item;
use llm_worker::llm_client::{ClientError, LlmClient, Request};
use pod_store::{CombinedStore, FsPodStore, PodMetadataStore};
use protocol::{Event, Method, RunResult};
use session_store::{FsStore, LogEntry, PodMetadataStore, Store};
use session_store::{FsStore, LogEntry, Store};
use tokio::sync::broadcast;
use pod::{Pod, PodController};
type TestStore = CombinedStore<FsStore, FsPodStore>;
#[derive(Clone)]
struct MockClient {
responses: Arc<Vec<Vec<LlmEvent>>>,
@ -145,11 +148,14 @@ permission = "write"
async fn make_pod_with_manifest(
manifest_toml: &str,
client: MockClient,
) -> Pod<MockClient, FsStore> {
) -> Pod<MockClient, TestStore> {
let manifest = pod::PodManifest::from_toml(manifest_toml).unwrap();
let store_tmp = tempfile::tempdir().unwrap();
let store = FsStore::new(store_tmp.path()).unwrap();
let store = CombinedStore::new(
FsStore::new(store_tmp.path()).unwrap(),
FsPodStore::new(store_tmp.path().join("pods")).unwrap(),
);
std::mem::forget(store_tmp);
let pwd_tmp = tempfile::tempdir().unwrap();
@ -163,7 +169,7 @@ async fn make_pod_with_manifest(
pod
}
async fn make_pod(client: MockClient) -> Pod<MockClient, FsStore> {
async fn make_pod(client: MockClient) -> Pod<MockClient, TestStore> {
make_pod_with_manifest(POST_RUN_MANIFEST_TOML, client).await
}

View File

@ -26,7 +26,10 @@ use llm_worker::llm_client::{ClientError, LlmClient, Request};
use memory::WorkspaceLayout;
use memory::extract::{ExtractedPayload, write_staging};
use memory::schema::SourceRef;
use pod_store::{CombinedStore, FsPodStore};
use session_store::FsStore;
type TestStore = CombinedStore<FsStore, FsPodStore>;
use tokio::sync::broadcast;
use pod::{Event, Pod};
@ -155,11 +158,14 @@ async fn make_pod_with(
manifest_toml: &str,
pwd: std::path::PathBuf,
client: MockClient,
) -> Pod<MockClient, FsStore> {
) -> Pod<MockClient, TestStore> {
let manifest = pod::PodManifest::from_toml(manifest_toml).unwrap();
let store_tmp = tempfile::tempdir().unwrap();
let store = FsStore::new(store_tmp.path()).unwrap();
let store = CombinedStore::new(
FsStore::new(store_tmp.path()).unwrap(),
FsPodStore::new(store_tmp.path().join("pods")).unwrap(),
);
std::mem::forget(store_tmp);
let scope = pod::Scope::writable(&pwd).unwrap();
@ -184,7 +190,7 @@ fn write_n_staging(layout: &WorkspaceLayout, n: usize) -> Vec<uuid::Uuid> {
ids
}
fn attach_event_receiver(pod: &mut Pod<MockClient, FsStore>) -> broadcast::Receiver<Event> {
fn attach_event_receiver(pod: &mut Pod<MockClient, TestStore>) -> broadcast::Receiver<Event> {
let (tx, rx) = broadcast::channel(16);
pod.attach_event_tx(tx);
rx

View File

@ -5,14 +5,17 @@ use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use futures::{Stream, StreamExt};
use llm_worker::Worker;
use llm_worker::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use llm_worker::llm_client::event::{ErrorEvent, Event as LlmEvent, ResponseStatus, StatusEvent};
use llm_worker::llm_client::types::Item;
use llm_worker::llm_client::{ClientError, LlmClient, Request};
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use pod_store::{CombinedStore, FsPodStore};
use session_store::{FsStore, LogEntry};
use pod::{Event, Method, Pod, PodController, PodHandle, PodManifest, PodStatus};
type TestStore = CombinedStore<FsStore, FsPodStore>;
/// Reconstruct a worker-history-like `Vec<Item>` from the live session
/// log mirror held by the Pod's broadcast sink. Replaces the previous
/// `PodSharedState.history()` test helper now that the mirror lives in
@ -152,21 +155,24 @@ target = "./"
permission = "write"
"#;
async fn make_pod(client: MockClient) -> Pod<MockClient, FsStore> {
async fn make_pod(client: MockClient) -> Pod<MockClient, TestStore> {
make_pod_with_pwd(client).await.0
}
async fn make_pod_with_pwd(client: MockClient) -> (Pod<MockClient, FsStore>, std::path::PathBuf) {
async fn make_pod_with_pwd(client: MockClient) -> (Pod<MockClient, TestStore>, std::path::PathBuf) {
make_pod_with_pwd_and_manifest(client, MANIFEST_TOML).await
}
async fn make_pod_with_pwd_and_manifest(
client: MockClient,
manifest_toml: &str,
) -> (Pod<MockClient, FsStore>, std::path::PathBuf) {
) -> (Pod<MockClient, TestStore>, std::path::PathBuf) {
let manifest = PodManifest::from_toml(manifest_toml).unwrap();
let store_tmp = tempfile::tempdir().unwrap();
let store = FsStore::new(store_tmp.path()).unwrap();
let store = CombinedStore::new(
FsStore::new(store_tmp.path()).unwrap(),
FsPodStore::new(store_tmp.path().join("pods")).unwrap(),
);
std::mem::forget(store_tmp);
// Separate tempdir to serve as the Pod's pwd/scope — these tests
@ -184,7 +190,7 @@ async fn make_pod_with_pwd_and_manifest(
(pod, pwd)
}
async fn spawn_controller(pod: Pod<MockClient, FsStore>) -> PodHandle {
async fn spawn_controller(pod: Pod<MockClient, TestStore>) -> PodHandle {
let tmp = tempfile::tempdir().unwrap();
let runtime_base = tmp.path().to_owned();
std::mem::forget(tmp);
@ -248,6 +254,52 @@ async fn run_end_returns_to_idle_without_busy_status() {
assert_eq!(handle.shared_state.get_status(), PodStatus::Idle);
}
#[tokio::test]
async fn provider_stream_error_records_run_errored() {
let client = MockClient::new(vec![LlmEvent::Error(ErrorEvent {
code: Some("context_length_exceeded".into()),
message: "request too large".into(),
})]);
let pod = make_pod(client).await;
let handle = spawn_controller(pod).await;
let mut rx = handle.subscribe();
handle.send(Method::run_text("ping")).await.unwrap();
assert!(
drain_until(&mut rx, std::time::Duration::from_secs(2), |e| matches!(
e,
Event::Error {
code: protocol::ErrorCode::ProviderError,
message,
} if message.contains("context_length_exceeded")
))
.await,
"provider stream error should be surfaced as a live provider error"
);
wait_for_status(&handle, PodStatus::Idle).await;
let (entries, _rx) = handle.sink.subscribe_with_snapshot();
assert!(
entries.iter().any(|entry| matches!(
entry,
LogEntry::RunErrored { message, .. }
if message.contains("context_length_exceeded")
)),
"provider stream error should be persisted as RunErrored"
);
assert!(
!entries.iter().any(|entry| matches!(
entry,
LogEntry::RunCompleted {
result: llm_worker::WorkerResult::Finished,
..
}
)),
"provider stream error must not be recorded as a finished run"
);
}
/// Mid-turn re-attach: a client connecting while the worker is still
/// running observes the in-flight `UserInput` entry in the connect-time
/// `Event::Snapshot`. This is the load-bearing property of the new
@ -919,6 +971,54 @@ async fn pod_event_turn_ended_while_idle_auto_starts_turn_and_injects_system_mes
);
}
#[tokio::test]
async fn pod_event_scope_sub_delegated_while_idle_stays_control_plane_only() {
let client = MockClient::new(simple_text_events());
let client_for_assert = client.clone();
let pod = make_pod(client).await;
let handle = spawn_controller(pod).await;
handle
.send(Method::PodEvent(protocol::PodEvent::ScopeSubDelegated {
parent_pod: "child".into(),
sub_pod: "grandchild".into(),
sub_socket: "/tmp/grandchild.sock".into(),
scope: vec![],
}))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert_eq!(
handle.shared_state.get_status(),
PodStatus::Idle,
"control-plane ScopeSubDelegated must not auto-start the parent LLM"
);
assert!(
client_for_assert.captured_requests().is_empty(),
"ScopeSubDelegated must not issue an LLM request"
);
let (entries, _) = handle.sink.subscribe_with_snapshot();
let saw_scope_event_in_mirror = entries.iter().any(|entry| {
matches!(
entry,
session_store::LogEntry::SystemItem {
item: session_store::SystemItem::PodEvent {
event: protocol::PodEvent::ScopeSubDelegated { .. },
..
},
..
}
)
});
assert!(
!saw_scope_event_in_mirror,
"ScopeSubDelegated must not create an agent-visible SystemItem::PodEvent; mirror = {entries:?}"
);
}
#[tokio::test]
async fn notify_while_running_does_not_emit_already_running_error() {
let client = MockClient::new(simple_text_events());

View File

@ -20,10 +20,11 @@ use pod::spawn::comm_tools::{
list_pods_tool, read_pod_output_tool, send_to_pod_tool, stop_pod_tool,
};
use pod::spawn::registry::SpawnedPodRegistry;
use pod_store::{CombinedStore, FsPodStore, PodMetadataStore};
use protocol::stream::{JsonLineReader, JsonLineWriter};
use protocol::{ErrorCode, Event, Greeting, Method};
use serde_json::json;
use session_store::{FsStore, PodMetadataStore};
use session_store::FsStore;
use tempfile::TempDir;
use tokio::net::UnixListener;
use tokio::sync::mpsc;
@ -385,7 +386,10 @@ async fn stop_pod_sends_shutdown_and_releases_scope() {
let _env = EnvGuard::acquire();
let tmp = TempDir::new().unwrap();
let store_tmp = TempDir::new().unwrap();
let store = FsStore::new(store_tmp.path()).unwrap();
let store = CombinedStore::new(
FsStore::new(store_tmp.path()).unwrap(),
FsPodStore::new(store_tmp.path().join("pods")).unwrap(),
);
let rd = Arc::new(RuntimeDir::create(tmp.path(), "spawner").await.unwrap());
let parent_scope = SharedScope::new(
Scope::writable(tmp.path())
@ -438,7 +442,6 @@ async fn stop_pod_sends_shutdown_and_releases_scope() {
store.clone(),
"spawner".into(),
Some(parent_scope.clone()),
None,
)
.await
.unwrap();
@ -512,7 +515,10 @@ async fn restored_registry_uses_pod_state_without_runtime_file() {
let _env = EnvGuard::acquire();
let runtime_tmp = TempDir::new().unwrap();
let store_tmp = TempDir::new().unwrap();
let store = FsStore::new(store_tmp.path()).unwrap();
let store = CombinedStore::new(
FsStore::new(store_tmp.path()).unwrap(),
FsPodStore::new(store_tmp.path().join("pods")).unwrap(),
);
unsafe {
std::env::set_var("INSOMNIA_RUNTIME_DIR", runtime_tmp.path());
}
@ -573,16 +579,21 @@ async fn restored_registry_uses_pod_state_without_runtime_file() {
.unwrap()
.expect("spawner metadata should remain");
assert!(metadata.spawned_children.is_empty());
assert_eq!(metadata.reclaimed_children.len(), 1);
assert_eq!(metadata.reclaimed_children[0].pod_name, "child");
let runtime_contents = std::fs::read_to_string(rd.path().join("spawned_pods.json")).unwrap();
let runtime_records: Vec<SpawnedPodRecord> = serde_json::from_str(&runtime_contents).unwrap();
assert!(runtime_records.is_empty());
}
#[tokio::test]
async fn load_from_pod_state_prunes_runtime_children_but_preserves_durable_state() {
async fn load_from_pod_state_prunes_runtime_children_and_reclaims_durable_delegation() {
let runtime_tmp = TempDir::new().unwrap();
let store_tmp = TempDir::new().unwrap();
let store = FsStore::new(store_tmp.path()).unwrap();
let store = CombinedStore::new(
FsStore::new(store_tmp.path()).unwrap(),
FsPodStore::new(store_tmp.path().join("pods")).unwrap(),
);
let rd = Arc::new(
RuntimeDir::create(runtime_tmp.path(), "spawner")
.await
@ -615,27 +626,21 @@ async fn load_from_pod_state_prunes_runtime_children_but_preserves_durable_state
.read_by_name("spawner")
.unwrap()
.expect("spawner metadata should be written");
assert_eq!(metadata.spawned_children.len(), 2);
assert!(
metadata
.spawned_children
.iter()
.any(|c| c.pod_name == "alive")
);
assert!(
metadata
.spawned_children
.iter()
.any(|c| c.pod_name == "missing")
);
assert_eq!(metadata.spawned_children.len(), 1);
assert_eq!(metadata.spawned_children[0].pod_name, "alive");
assert_eq!(metadata.reclaimed_children.len(), 1);
assert_eq!(metadata.reclaimed_children[0].pod_name, "missing");
}
#[tokio::test]
async fn load_from_pod_state_reclaims_pruned_child_scope_without_deleting_pod_state() {
async fn load_from_pod_state_reclaims_missing_child_scope_and_records_history() {
let _env = EnvGuard::acquire();
let runtime_tmp = TempDir::new().unwrap();
let store_tmp = TempDir::new().unwrap();
let store = FsStore::new(store_tmp.path()).unwrap();
let store = CombinedStore::new(
FsStore::new(store_tmp.path()).unwrap(),
FsPodStore::new(store_tmp.path().join("pods")).unwrap(),
);
unsafe {
std::env::set_var("INSOMNIA_RUNTIME_DIR", runtime_tmp.path());
}
@ -662,15 +667,6 @@ async fn load_from_pod_state_reclaims_pruned_child_scope_without_deleting_pod_st
session_store::new_segment_id(),
)
.unwrap();
pod_registry::register_pod(
&mut g,
"missing".into(),
std::process::id(),
"/tmp/missing.sock".into(),
vec![missing_rule.clone()],
session_store::new_segment_id(),
)
.unwrap();
}
let parent_scope = SharedScope::new(
@ -696,7 +692,6 @@ async fn load_from_pod_state_reclaims_pruned_child_scope_without_deleting_pod_st
store.clone(),
"spawner".into(),
Some(parent_scope.clone()),
None,
)
.await
.unwrap();
@ -716,8 +711,9 @@ async fn load_from_pod_state_reclaims_pruned_child_scope_without_deleting_pod_st
.read_by_name("spawner")
.unwrap()
.expect("spawner metadata should remain");
assert_eq!(metadata.spawned_children.len(), 1);
assert_eq!(metadata.spawned_children[0].pod_name, "missing");
assert!(metadata.spawned_children.is_empty());
assert_eq!(metadata.reclaimed_children.len(), 1);
assert_eq!(metadata.reclaimed_children[0].pod_name, "missing");
let runtime_contents = std::fs::read_to_string(rd.path().join("spawned_pods.json")).unwrap();
let runtime_records: Vec<SpawnedPodRecord> = serde_json::from_str(&runtime_contents).unwrap();
assert!(runtime_records.is_empty());

View File

@ -8,7 +8,8 @@
use std::sync::{LazyLock, Mutex};
use pod::{Pod, PodError};
use session_store::{FsStore, PodActiveSegmentRef, PodMetadata, PodMetadataStore, StoreError};
use pod_store::{CombinedStore, FsPodStore, PodActiveSegmentRef, PodMetadata, PodMetadataStore};
use session_store::{FsStore, StoreError};
const MINIMAL_MANIFEST_TOML: &str = r#"
[pod]
@ -36,7 +37,10 @@ async fn restore_from_pod_metadata_rejects_missing_metadata() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let store_tmp = tempfile::tempdir().unwrap();
let store = FsStore::new(store_tmp.path()).unwrap();
let store = CombinedStore::new(
FsStore::new(store_tmp.path()).unwrap(),
FsPodStore::new(store_tmp.path().join("pods")).unwrap(),
);
let manifest = pod::PodManifest::from_toml(MINIMAL_MANIFEST_TOML).unwrap();
let result = Pod::restore_from_pod_metadata(
@ -59,7 +63,10 @@ async fn restore_from_pod_metadata_rejects_pending_segment() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let store_tmp = tempfile::tempdir().unwrap();
let store = FsStore::new(store_tmp.path()).unwrap();
let store = CombinedStore::new(
FsStore::new(store_tmp.path()).unwrap(),
FsPodStore::new(store_tmp.path().join("pods")).unwrap(),
);
let manifest = pod::PodManifest::from_toml(MINIMAL_MANIFEST_TOML).unwrap();
let session_id = session_store::new_session_id();
store
@ -95,7 +102,10 @@ async fn restore_from_pod_metadata_resolves_active_pointer_through_session_log()
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let store_tmp = tempfile::tempdir().unwrap();
let store = FsStore::new(store_tmp.path()).unwrap();
let store = CombinedStore::new(
FsStore::new(store_tmp.path()).unwrap(),
FsPodStore::new(store_tmp.path().join("pods")).unwrap(),
);
let manifest = pod::PodManifest::from_toml(MINIMAL_MANIFEST_TOML).unwrap();
let session_id = session_store::new_session_id();
let segment_id = session_store::new_segment_id();
@ -126,7 +136,10 @@ async fn restore_from_manifest_rejects_unknown_segment() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let store_tmp = tempfile::tempdir().unwrap();
let store = FsStore::new(store_tmp.path()).unwrap();
let store = CombinedStore::new(
FsStore::new(store_tmp.path()).unwrap(),
FsPodStore::new(store_tmp.path().join("pods")).unwrap(),
);
let manifest = pod::PodManifest::from_toml(MINIMAL_MANIFEST_TOML).unwrap();
// A freshly-minted id with no jsonl file at all → store returns
@ -155,7 +168,10 @@ async fn restore_from_manifest_rejects_empty_segment_log() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let store_tmp = tempfile::tempdir().unwrap();
let store = FsStore::new(store_tmp.path()).unwrap();
let store = CombinedStore::new(
FsStore::new(store_tmp.path()).unwrap(),
FsPodStore::new(store_tmp.path().join("pods")).unwrap(),
);
let manifest = pod::PodManifest::from_toml(MINIMAL_MANIFEST_TOML).unwrap();
// Pre-create an empty `<sid>/<segid>.jsonl` so `read_all` succeeds
@ -183,36 +199,3 @@ async fn restore_from_manifest_rejects_empty_segment_log() {
Ok(_) => panic!("expected empty segment log to fail"),
}
}
#[tokio::test]
async fn restore_from_manifest_rejects_segment_without_scope_snapshot() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let store_tmp = tempfile::tempdir().unwrap();
let store = FsStore::new(store_tmp.path()).unwrap();
let manifest = pod::PodManifest::from_toml(MINIMAL_MANIFEST_TOML).unwrap();
let sid = session_store::new_session_id();
let segid = session_store::new_segment_id();
let state = session_store::SegmentStartState {
system_prompt: None,
config: &Default::default(),
history: &[],
};
session_store::create_segment_with_ids(&store, sid, segid, state).unwrap();
let result = Pod::restore_from_manifest(
sid,
segid,
manifest,
store,
pod::PromptLoader::builtins_only(),
)
.await;
match result {
Err(PodError::SegmentScopeMissing { segment_id }) => assert_eq!(segment_id, segid),
Err(other) => panic!("expected SegmentScopeMissing, got {other:?}"),
Ok(_) => panic!("expected missing scope snapshot to fail"),
}
}

View File

@ -25,11 +25,14 @@ use llm_worker::Worker;
use llm_worker::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent, UsageEvent};
use llm_worker::llm_client::{ClientError, LlmClient, Request};
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use pod_store::{CombinedStore, FsPodStore};
use session_metrics::{DOMAIN, Metric, metrics_from_extensions};
use session_store::{FsStore, LogEntry, SegmentId, SessionId, Store, StoreError, TraceEntry};
use pod::{Pod, PodManifest};
type TestStore = CombinedStore<FsStore, FsPodStore>;
#[derive(Clone)]
struct MockClient {
responses: Arc<Vec<Vec<LlmEvent>>>,
@ -166,13 +169,16 @@ async fn make_pod(
client: MockClient,
tool_name: &'static str,
) -> (
Pod<MockClient, FsStore>,
Pod<MockClient, TestStore>,
tempfile::TempDir,
tempfile::TempDir,
) {
let manifest = PodManifest::from_toml(&manifest_toml).unwrap();
let store_tmp = tempfile::tempdir().unwrap();
let store = FsStore::new(store_tmp.path()).unwrap();
let store = CombinedStore::new(
FsStore::new(store_tmp.path()).unwrap(),
FsPodStore::new(store_tmp.path().join("pods")).unwrap(),
);
let pwd_tmp = tempfile::tempdir().unwrap();
let pwd = pwd_tmp.path().to_path_buf();
let scope = pod::Scope::writable(&pwd).unwrap();
@ -500,7 +506,10 @@ permission = "write"
let client = MockClient::new(vec![text_response_with_cache("hi", 0, 0)]);
let manifest = PodManifest::from_toml(manifest_toml).unwrap();
let store_tmp = tempfile::tempdir().unwrap();
let store = FsStore::new(store_tmp.path()).unwrap();
let store = CombinedStore::new(
FsStore::new(store_tmp.path()).unwrap(),
FsPodStore::new(store_tmp.path().join("pods")).unwrap(),
);
let pwd_tmp = tempfile::tempdir().unwrap();
let pwd = pwd_tmp.path().to_path_buf();
let scope = pod::Scope::writable(&pwd).unwrap();

View File

@ -126,8 +126,8 @@ fn point_pod_command_at_true() {
}
}
/// `/bin/true` only exists on FHS-compliant systems. On Nix, resolve it
/// via PATH so the tests work regardless of distro.
/// `/bin/true` only exists on FHS-compliant systems. Resolve it via PATH
/// so the tests work regardless of distro.
fn which_true() -> String {
for dir in std::env::var_os("PATH")
.map(|p| std::env::split_paths(&p).collect::<Vec<_>>())
@ -192,8 +192,8 @@ async fn spawn_pod_delegates_scope_and_sends_run() {
registry,
None,
dummy_model(),
false,
spawner_scope.clone(),
std::sync::Arc::new(|_| {}),
);
let (_meta, tool) = def();
@ -281,8 +281,8 @@ async fn spawn_pod_rejects_scope_outside_spawner() {
registry,
None,
dummy_model(),
false,
spawner_scope.clone(),
std::sync::Arc::new(|_| {}),
);
let (_meta, tool) = def();
@ -353,8 +353,8 @@ async fn spawn_pod_rolls_back_reservation_when_socket_never_appears() {
registry,
None,
dummy_model(),
false,
spawner_scope.clone(),
std::sync::Arc::new(|_| {}),
);
let (_meta, tool) = def();

View File

@ -8,10 +8,13 @@ use futures::Stream;
use llm_worker::Worker;
use llm_worker::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use llm_worker::llm_client::{ClientError, LlmClient, Request};
use pod_store::{CombinedStore, FsPodStore};
use session_store::{FsStore, LogEntry, Store};
use pod::{Pod, PodError, PromptLoader, SystemPromptTemplate};
type TestStore = CombinedStore<FsStore, FsPodStore>;
// ---------------------------------------------------------------------------
// Mock LLM Client
// ---------------------------------------------------------------------------
@ -99,11 +102,14 @@ permission = "write"
async fn make_pod_with_body(
body: &str,
client: MockClient,
) -> Result<(Pod<MockClient, FsStore>, PathBuf), PodError> {
) -> Result<(Pod<MockClient, TestStore>, PathBuf), PodError> {
let manifest = pod::PodManifest::from_toml(MINIMAL_MANIFEST_TOML).unwrap();
let store_tmp = tempfile::tempdir().unwrap();
let store = FsStore::new(store_tmp.path()).unwrap();
let store = CombinedStore::new(
FsStore::new(store_tmp.path()).unwrap(),
FsPodStore::new(store_tmp.path().join("pods")).unwrap(),
);
std::mem::forget(store_tmp);
let pwd_tmp = tempfile::tempdir().unwrap();

View File

@ -73,9 +73,10 @@ pub enum Method {
/// Typed lifecycle events sent from a child Pod to its parent.
///
/// Delivered as `Method::PodEvent` over the parent's Unix socket. The
/// parent Controller applies variant-specific side effects (registry /
/// pod-registry updates) and renders a human-readable string that is
/// injected into the parent's LLM context via the notification buffer.
/// parent Controller always applies variant-specific side effects
/// (registry / pod-registry updates). Agent-visible variants are also
/// queued into the notification buffer; control-plane-only variants are
/// not injected into the parent's LLM context.
///
/// Transport is fire-and-forget; receivers must tolerate out-of-order
/// delivery (e.g. `TurnEnded` arriving after `ShutDown` for the same
@ -98,6 +99,9 @@ pub enum PodEvent {
/// Child sub-delegated scope to a grandchild Pod via `SpawnPod`.
///
/// Control-plane only: receivers apply registry side effects and
/// propagate upward, but do not expose this as an agent notification.
///
/// The parent uses this to add the grandchild to its own
/// `spawned_pods.json` so it can manage the grandchild directly
/// even if the intermediate child dies. The parent then re-fires
@ -115,6 +119,22 @@ pub enum PodEvent {
},
}
impl PodEvent {
/// Whether this event should become an agent-visible notification/history item.
///
/// Control-plane-only events still travel over the same wire enum and still
/// run receiver side effects, but they must not wake the parent LLM or enter
/// the notification buffer.
pub fn should_notify_agent(&self) -> bool {
match self {
PodEvent::TurnEnded { .. } | PodEvent::Errored { .. } | PodEvent::ShutDown { .. } => {
true
}
PodEvent::ScopeSubDelegated { .. } => false,
}
}
}
// ---------------------------------------------------------------------------
// Segment — typed pieces of a user submission
// ---------------------------------------------------------------------------
@ -1209,6 +1229,38 @@ mod tests {
));
}
#[test]
fn pod_event_agent_notification_classification() {
assert!(
PodEvent::TurnEnded {
pod_name: "child".into()
}
.should_notify_agent()
);
assert!(
PodEvent::Errored {
pod_name: "child".into(),
message: "boom".into()
}
.should_notify_agent()
);
assert!(
PodEvent::ShutDown {
pod_name: "child".into()
}
.should_notify_agent()
);
assert!(
!PodEvent::ScopeSubDelegated {
parent_pod: "child".into(),
sub_pod: "grandchild".into(),
sub_socket: "/tmp/grandchild.sock".into(),
scope: vec![],
}
.should_notify_agent()
);
}
#[test]
fn method_pod_event_scope_sub_delegated_roundtrip() {
let method = Method::PodEvent(PodEvent::ScopeSubDelegated {

View File

@ -1,7 +1,8 @@
//! Debug-only LLM request/stream trace recording.
//!
//! [`TraceEntry`] captures stream lifecycle markers and raw provider stream
//! events for debugging stalls. Written to a separate `.trace.jsonl` file,
//! [`TraceEntry`] captures stream lifecycle markers and normalized provider
//! stream events for debugging stalls. It is not a byte-for-byte raw SSE
//! capture. Written to a separate `.trace.jsonl` file,
//! completely independent of the segment log used for state restoration.
//!
//! Disabled by default. Enable via `SessionConfig::record_event_trace`.
@ -10,7 +11,7 @@ use llm_worker::llm_client::event::Event;
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// A single trace entry recording either a lifecycle marker or raw stream event.
/// A single trace entry recording either a lifecycle marker or normalized stream event.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TraceEntry {
/// Timestamp in milliseconds since Unix epoch.

View File

@ -3,7 +3,6 @@
//! Layout:
//! - Segment log: `{root}/{session_id}/{segment_id}.jsonl`
//! - Event trace: `{root}/{session_id}/{segment_id}.trace.jsonl`
//! - Pod metadata: `{root}/pods/{pod_name}/metadata.json`
//!
//! The per-Session directory makes `list_segments(session_id)` an O(dir)
//! scan and gives the fork tree a visible grouping in the filesystem.
@ -17,7 +16,6 @@
//! enumerable by the picker.
use crate::event_trace::TraceEntry;
use crate::pod_metadata::{PodMetadata, PodMetadataStore, validate_pod_name};
use crate::segment_log::LogEntry;
use crate::store::{Store, StoreError};
use crate::{SegmentId, SessionId};
@ -57,19 +55,6 @@ impl FsStore {
.join(format!("{segment_id}.trace.jsonl"))
}
fn pods_dir(&self) -> PathBuf {
self.root.join("pods")
}
fn pod_dir(&self, pod_name: &str) -> Result<PathBuf, StoreError> {
validate_pod_name(pod_name)?;
Ok(self.pods_dir().join(pod_name))
}
fn pod_metadata_path(&self, pod_name: &str) -> Result<PathBuf, StoreError> {
Ok(self.pod_dir(pod_name)?.join("metadata.json"))
}
fn append_line(&self, path: &Path, line: &str) -> Result<(), StoreError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
@ -102,70 +87,6 @@ impl FsStore {
}
}
impl PodMetadataStore for FsStore {
fn write(&self, metadata: &PodMetadata) -> Result<(), StoreError> {
let path = self.pod_metadata_path(&metadata.pod_name)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let content = serde_json::to_vec_pretty(metadata)?;
fs::write(path, content)?;
Ok(())
}
fn read_by_name(&self, pod_name: &str) -> Result<Option<PodMetadata>, StoreError> {
let path = self.pod_metadata_path(pod_name)?;
let content = match fs::read_to_string(path) {
Ok(content) => content,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(StoreError::Io(err)),
};
Ok(Some(serde_json::from_str(&content)?))
}
fn list_names(&self) -> Result<Vec<String>, StoreError> {
let dir = self.pods_dir();
let mut names = Vec::new();
if !dir.exists() {
return Ok(names);
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
if !entry.path().join("metadata.json").exists() {
continue;
}
let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else {
continue;
};
if validate_pod_name(&name).is_ok() {
names.push(name);
}
}
names.sort();
Ok(names)
}
fn root_dir(&self) -> Option<PathBuf> {
Some(self.root.clone())
}
fn delete_by_name(&self, pod_name: &str) -> Result<(), StoreError> {
let path = self.pod_metadata_path(pod_name)?;
match fs::remove_file(&path) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(StoreError::Io(err)),
}
if let Some(parent) = path.parent() {
let _ = fs::remove_dir(parent);
}
Ok(())
}
}
impl Store for FsStore {
fn append(
&self,

View File

@ -33,7 +33,6 @@
pub mod event_trace;
pub mod fs_store;
pub mod logged_item;
pub mod pod_metadata;
pub mod segment;
pub mod segment_log;
pub mod store;
@ -44,20 +43,13 @@ pub use fs_store::FsStore;
pub use llm_worker::UsageRecord;
pub use llm_worker::llm_client::types::{ContentPart, Item, Role};
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
pub use pod_metadata::{
PodActiveSegmentRef, PodMetadata, PodMetadataStore, PodSpawnedChild, PodSpawnedScopeRule,
};
pub use segment::{
SegmentStartState, append_entry, append_system_item, classify_history_item,
create_compacted_segment, create_segment, create_segment_with_ids, ensure_head_or_fork, fork,
fork_at, restore, restore_by_segment, save_config_changed, save_delta, save_extension,
save_pod_scope, save_run_completed, save_run_errored, save_turn_end, save_usage,
save_user_input,
};
pub use segment_log::{
LogEntry, POD_SCOPE_EXTENSION_DOMAIN, PodScopeSnapshot, RestoredState, SegmentOrigin,
collect_state,
save_run_completed, save_run_errored, save_turn_end, save_usage, save_user_input,
};
pub use segment_log::{LogEntry, RestoredState, SegmentOrigin, collect_state};
pub use store::{Store, StoreError};
pub use system_item::{SystemItem, SystemReminder, SystemReminderSource, render_pod_event};

View File

@ -1,150 +0,0 @@
//! Pod metadata persistence API.
//!
//! Pod metadata is a lightweight name-keyed pointer to the Session/Segment
//! currently active for a Pod. Conversation content remains in the segment log;
//! this metadata only records references needed by Pod-name resume/attach flows.
use crate::store::StoreError;
use crate::{SegmentId, SessionId};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Active Session/Segment pointer for a Pod.
///
/// `segment_id` is optional so callers can persist a reserved Session before
/// the first Segment ID is known. Once a segment exists, callers should rewrite
/// the metadata with `Some(segment_id)`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PodActiveSegmentRef {
pub session_id: SessionId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub segment_id: Option<SegmentId>,
}
impl PodActiveSegmentRef {
/// Create a reference whose active Segment is not known yet.
pub fn pending_segment(session_id: SessionId) -> Self {
Self {
session_id,
segment_id: None,
}
}
/// Create a fully resolved active Session/Segment reference.
pub fn active_segment(session_id: SessionId, segment_id: SegmentId) -> Self {
Self {
session_id,
segment_id: Some(segment_id),
}
}
}
/// One delegated scope rule for a spawned child, kept local to
/// `session-store` so the persistence crate does not depend on manifest
/// scope types.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PodSpawnedScopeRule {
pub target: PathBuf,
pub permission: String,
pub recursive: bool,
}
/// One child Pod spawned by this Pod and persisted with the spawner's
/// name-keyed Pod state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PodSpawnedChild {
pub pod_name: String,
pub socket_path: PathBuf,
pub scope_delegated: Vec<PodSpawnedScopeRule>,
pub callback_address: PathBuf,
}
/// Persistent metadata for a Pod name.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PodMetadata {
pub pod_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active: Option<PodActiveSegmentRef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub spawned_children: Vec<PodSpawnedChild>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_manifest_snapshot: Option<serde_json::Value>,
}
impl PodMetadata {
/// Create Pod metadata for `pod_name`.
pub fn new(pod_name: impl Into<String>, active: Option<PodActiveSegmentRef>) -> Self {
Self {
pod_name: pod_name.into(),
active,
spawned_children: Vec::new(),
resolved_manifest_snapshot: None,
}
}
}
/// Sync persistence backend for Pod metadata.
///
/// The key is the Pod name. Missing state is not an error: `read_by_name`
/// returns `Ok(None)` for Pods that have never persisted metadata or whose
/// metadata was deleted.
pub trait PodMetadataStore: Send + Sync {
/// Create or replace metadata for its `pod_name` key.
fn write(&self, metadata: &PodMetadata) -> Result<(), StoreError>;
/// Read metadata by Pod name. Returns `None` when no metadata exists.
fn read_by_name(&self, pod_name: &str) -> Result<Option<PodMetadata>, StoreError>;
/// List persisted Pod metadata keys. Implementations return names only;
/// callers can then read each item independently so a corrupt metadata
/// file does not make the whole discovery result fail.
fn list_names(&self) -> Result<Vec<String>, StoreError>;
/// Return the metadata root directory when this backend is path-backed.
fn root_dir(&self) -> Option<PathBuf> {
None
}
/// Delete metadata by Pod name. Missing metadata is a successful no-op.
fn delete_by_name(&self, pod_name: &str) -> Result<(), StoreError>;
}
pub(crate) fn validate_pod_name(pod_name: &str) -> Result<(), StoreError> {
if pod_name.is_empty()
|| pod_name == "."
|| pod_name == ".."
|| pod_name.contains('/')
|| pod_name.contains('\0')
{
return Err(StoreError::InvalidPodName(pod_name.to_string()));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pod_metadata_manifest_snapshot_roundtrips() {
let mut metadata = PodMetadata::new(
"profile-pod",
Some(PodActiveSegmentRef::pending_segment(crate::new_session_id())),
);
metadata.resolved_manifest_snapshot = Some(serde_json::json!({
"pod": { "name": "profile-pod" },
"profile": {
"source": { "kind": "path", "path": "/profiles/coder.nix" }
}
}));
let json = serde_json::to_string(&metadata).unwrap();
let restored: PodMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(restored, metadata);
assert_eq!(
restored.resolved_manifest_snapshot.as_ref().unwrap()["profile"]["source"]["kind"],
"path"
);
}
}

View File

@ -5,7 +5,7 @@
//! functions after state-mutating operations.
use crate::logged_item::{LoggedItem, to_logged};
use crate::segment_log::{self, LogEntry, PodScopeSnapshot, SegmentOrigin};
use crate::segment_log::{self, LogEntry, SegmentOrigin};
use crate::store::{Store, StoreError};
use crate::system_item::SystemItem;
use crate::{SegmentId, SessionId};
@ -385,23 +385,6 @@ pub fn save_extension(
)
}
/// Log the Pod's latest runtime scope snapshot.
pub fn save_pod_scope(
store: &impl Store,
session_id: SessionId,
segment_id: SegmentId,
snapshot: &PodScopeSnapshot,
) -> Result<(), StoreError> {
let payload = serde_json::to_value(snapshot)?;
save_extension(
store,
session_id,
segment_id,
segment_log::POD_SCOPE_EXTENSION_DOMAIN,
payload,
)
}
/// Log a `ConfigChanged` entry.
pub fn save_config_changed(
store: &impl Store,

View File

@ -11,7 +11,7 @@
use llm_worker::llm_client::types::{Item, RequestConfig};
use llm_worker::{UsageRecord, WorkerResult};
use protocol::{InvokeKind, ScopeRule, Segment};
use protocol::{InvokeKind, Segment};
use serde::{Deserialize, Serialize};
use crate::logged_item::LoggedItem;
@ -166,16 +166,6 @@ pub struct SegmentOrigin {
pub at_turn_index: usize,
}
/// Domain used by Pod to persist its latest effective runtime scope.
pub const POD_SCOPE_EXTENSION_DOMAIN: &str = "pod.scope";
/// Payload stored in `LogEntry::Extension { domain: "pod.scope", .. }`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PodScopeSnapshot {
pub allow: Vec<ScopeRule>,
pub deny: Vec<ScopeRule>,
}
/// State collected from log entries.
#[derive(Debug, Clone)]
pub struct RestoredState {
@ -199,9 +189,6 @@ pub struct RestoredState {
/// `LogEntry::Extension` を replay 順に積んだもの。`(domain, payload)`。
/// session-store は domain を不透明扱いし、各ドメインが自前で fold する。
pub extensions: Vec<(String, serde_json::Value)>,
/// Latest runtime scope snapshot persisted by the Pod. `None` means
/// the segment predates scope persistence or the payload was corrupt.
pub pod_scope: Option<PodScopeSnapshot>,
/// User submissions in original typed form, in submit order.
/// One entry per `LogEntry::UserInput`; the K-th entry corresponds to
/// the K-th `Item::user_message` derived during replay (modulo
@ -223,7 +210,6 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
entries_count: 0,
usage_history: Vec::new(),
extensions: Vec::new(),
pod_scope: None,
user_segments: Vec::new(),
};
@ -293,17 +279,6 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
LogEntry::Extension {
domain, payload, ..
} => {
if domain == POD_SCOPE_EXTENSION_DOMAIN {
match serde_json::from_value::<PodScopeSnapshot>(payload.clone()) {
Ok(snapshot) => state.pod_scope = Some(snapshot),
Err(err) => {
tracing::warn!(
error = %err,
"discarding malformed pod.scope snapshot from segment log"
);
}
}
}
state.extensions.push((domain.clone(), payload.clone()));
}
}

View File

@ -29,9 +29,6 @@ pub enum StoreError {
#[error("log corrupted at line {line}: {message}")]
Corrupt { line: usize, message: String },
#[error("invalid pod name: {0}")]
InvalidPodName(String),
}
/// Sync persistence backend for segment logs.

View File

@ -1,8 +1,7 @@
use llm_worker::WorkerResult;
use llm_worker::llm_client::types::{Item, RequestConfig};
use session_store::{
FsStore, LogEntry, PodActiveSegmentRef, PodMetadata, PodMetadataStore, Store, TraceEntry,
collect_state, new_segment_id, new_session_id,
FsStore, LogEntry, Store, TraceEntry, collect_state, new_segment_id, new_session_id,
};
fn nil_session_start(ts: u64, session_id: uuid::Uuid) -> LogEntry {
@ -240,40 +239,3 @@ fn lookup_session_of_finds_owning_session() {
assert_eq!(store.lookup_session_of(segid).unwrap(), Some(sid));
}
#[test]
fn pod_metadata_minimal_crud() {
let dir = tempfile::tempdir().unwrap();
let store = FsStore::new(dir.path()).unwrap();
let pod_name = "worker-a";
let sid = new_session_id();
let segid = new_segment_id();
assert_eq!(store.read_by_name(pod_name).unwrap(), None);
let pending = PodMetadata::new(pod_name, Some(PodActiveSegmentRef::pending_segment(sid)));
store.write(&pending).unwrap();
assert_eq!(store.list_names().unwrap(), vec![pod_name.to_string()]);
assert_eq!(store.read_by_name(pod_name).unwrap(), Some(pending.clone()));
assert!(
dir.path()
.join("pods")
.join(pod_name)
.join("metadata.json")
.exists(),
"Pod metadata must live under <data_dir>/pods/<pod_name>/"
);
let resolved = PodMetadata::new(
pod_name,
Some(PodActiveSegmentRef::active_segment(sid, segid)),
);
store.write(&resolved).unwrap();
assert_eq!(store.read_by_name(pod_name).unwrap(), Some(resolved));
store.delete_by_name(pod_name).unwrap();
assert_eq!(store.read_by_name(pod_name).unwrap(), None);
// Delete is idempotent for missing metadata.
store.delete_by_name(pod_name).unwrap();
}

View File

@ -20,6 +20,7 @@ uuid = { workspace = true }
toml = { workspace = true }
manifest = { workspace = true }
session-store = { workspace = true }
pod-store = { workspace = true }
pod-registry = { workspace = true }
serde = { workspace = true, features = ["derive"] }
pulldown-cmark = { version = "0.13.3", default-features = false }

View File

@ -1181,9 +1181,9 @@ mod tests {
#[test]
fn parse_profile_spawn_mode() {
match parse_args_from(["--profile", "/profiles/coder.nix"]).unwrap() {
match parse_args_from(["--profile", "/profiles/coder.lua"]).unwrap() {
Mode::Spawn { profile } => {
assert_eq!(profile, Some("/profiles/coder.nix".to_string()));
assert_eq!(profile, Some("/profiles/coder.lua".to_string()));
}
_ => panic!("expected Spawn mode"),
}
@ -1196,7 +1196,7 @@ mod tests {
(
vec![
"--profile".to_string(),
"p.nix".to_string(),
"p.lua".to_string(),
"--resume".to_string(),
],
"--profile can only be used for fresh spawn",
@ -1204,7 +1204,7 @@ mod tests {
(
vec![
"--profile".to_string(),
"p.nix".to_string(),
"p.lua".to_string(),
"--session".to_string(),
segment_id,
],
@ -1213,7 +1213,7 @@ mod tests {
(
vec![
"--profile".to_string(),
"p.nix".to_string(),
"p.lua".to_string(),
"--socket".to_string(),
"/tmp/insomnia/sock".to_string(),
],
@ -1222,7 +1222,7 @@ mod tests {
(
vec![
"--profile".to_string(),
"p.nix".to_string(),
"p.lua".to_string(),
"agent".to_string(),
],
"--profile can only be used for fresh spawn",

View File

@ -3,6 +3,7 @@ use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use crossterm::event::{Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, poll, read};
use pod_store::FsPodStore;
use protocol::stream::{JsonLineReader, JsonLineWriter};
use protocol::{ErrorCode, Event, InvokeKind, Method, PodStatus, Segment};
use ratatui::Frame;
@ -199,12 +200,22 @@ fn default_store_dir() -> Result<PathBuf, MultiPodError> {
manifest::paths::sessions_dir().ok_or_else(|| {
MultiPodError::Io(io::Error::new(
io::ErrorKind::NotFound,
"could not resolve sessions directory \
(set INSOMNIA_HOME, INSOMNIA_DATA_DIR, or HOME)",
"could not resolve sessions directory",
))
})
}
fn default_pod_store_dir() -> Result<PathBuf, MultiPodError> {
manifest::paths::data_dir()
.map(|dir| dir.join("pods"))
.ok_or_else(|| {
MultiPodError::Io(io::Error::new(
io::ErrorKind::NotFound,
"could not resolve pod state directory",
))
})
}
#[cfg(test)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SendEligibility {
@ -483,7 +494,8 @@ enum MultiPodAction {
async fn load_pod_list(selected_name: Option<String>) -> Result<PodList, MultiPodError> {
let store_dir = default_store_dir()?;
let store = FsStore::new(&store_dir)?;
let stored = read_stored_pod_infos(&store_dir, &store)?;
let pod_store = FsPodStore::new(default_pod_store_dir()?).map_err(io::Error::other)?;
let stored = read_stored_pod_infos(&store, &pod_store)?;
let live = read_reachable_live_pod_infos(&store)
.await
.unwrap_or_default();

View File

@ -1,7 +1,7 @@
//! Inline-viewport "pick a Pod to attach or restore" UX.
//!
//! Reads live Pod allocations from the runtime registry and stopped Pod state
//! from the session store's name-keyed metadata. Picking a live row attaches to
//! from the pod-store name-keyed metadata. Picking a live row attaches to
//! its socket; picking a stopped row restores via `insomnia-pod --pod <name>`.
use std::io;
@ -9,6 +9,7 @@ use std::path::PathBuf;
use std::time::Duration;
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use pod_store::FsPodStore;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout};
@ -102,7 +103,8 @@ impl PodRowState {
pub async fn run() -> Result<PickerOutcome, PickerError> {
let store_dir = default_store_dir()?;
let store = FsStore::new(&store_dir)?;
let stored_pods = read_stored_pod_infos(&store_dir, &store)?;
let pod_store = FsPodStore::new(default_pod_store_dir()?).map_err(io::Error::other)?;
let stored_pods = read_stored_pod_infos(&store, &pod_store)?;
let live_pods = read_reachable_live_pod_infos(&store)
.await
.unwrap_or_default();
@ -172,6 +174,18 @@ fn default_store_dir() -> Result<PathBuf, PickerError> {
})
}
fn default_pod_store_dir() -> Result<PathBuf, PickerError> {
manifest::paths::data_dir()
.map(|dir| dir.join("pods"))
.ok_or_else(|| {
PickerError::Io(io::Error::new(
io::ErrorKind::NotFound,
"could not resolve pod state directory \
(set INSOMNIA_HOME, INSOMNIA_DATA_DIR, or HOME)",
))
})
}
pub(crate) fn live_socket_for_pod(pod_name: &str) -> Option<PathBuf> {
pod_list_live_socket_for_pod(pod_name)
}
@ -346,4 +360,37 @@ mod tests {
fn picker_title_names_pods_not_sessions() {
assert_eq!(picker_title(), "resume pod pick a pod");
}
#[test]
fn picker_row_shows_live_pending_preview_and_runtime_segment_id() {
let segment_id = session_store::new_segment_id();
let entry = PodList::from_sources(
PodVisibilitySource::ResumePicker,
vec![],
vec![crate::pod_list::LivePodInfo {
pod_name: "pending".to_string(),
socket_path: PathBuf::from("/tmp/pending.sock"),
status: Some(protocol::PodStatus::Idle),
reachable: true,
segment_id: Some(segment_id),
summary: crate::pod_list::PodEntrySummary::default(),
}],
None,
10,
)
.entries
.into_iter()
.next()
.unwrap();
let text = row_line(&entry, false)
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
assert!(text.contains("[live]"));
assert!(text.contains("[live, pending segment]"));
assert!(text.contains(&format!("g:{}", short_id(segment_id))));
}
}

View File

@ -1,14 +1,14 @@
use std::collections::BTreeMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;
use client::PodClient;
use pod_registry::{LockFileGuard, default_registry_path};
use pod_store::{PodActiveSegmentRef, PodMetadata, PodMetadataStore};
use protocol::{Event, PodStatus};
use session_store::{
FsStore, LogEntry, LoggedContentPart, LoggedItem, PodMetadata, SegmentId, SessionId, Store,
FsStore, LogEntry, LoggedContentPart, LoggedItem, SegmentId, SessionId, Store,
};
#[derive(Debug, Clone)]
@ -48,9 +48,9 @@ impl PodList {
entry.finalize();
}
entries.sort_by(|a, b| {
b.summary
.updated_at
.cmp(&a.summary.updated_at)
b.has_reachable_live()
.cmp(&a.has_reachable_live())
.then_with(|| b.summary.updated_at.cmp(&a.summary.updated_at))
.then_with(|| a.name.cmp(&b.name))
});
entries.truncate(max_entries);
@ -164,10 +164,27 @@ impl PodListEntry {
}
fn finalize(&mut self) {
self.fill_live_pending_preview();
self.diagnostics = build_diagnostics(self);
self.actions = build_actions(self);
}
fn has_reachable_live(&self) -> bool {
self.live.as_ref().is_some_and(|live| live.reachable)
}
fn fill_live_pending_preview(&mut self) {
if !self.has_reachable_live() || self.summary.updated_at != 0 {
return;
}
let preview_is_pending = self.summary.preview.as_deref() == Some("[pending segment]");
let preview_is_incomplete = self.summary.preview.is_none() || preview_is_pending;
if preview_is_incomplete && (self.summary.active_segment_id.is_some() || preview_is_pending)
{
self.summary.preview = Some("[live, pending segment]".to_string());
}
}
pub(crate) fn attach_socket_path(&self) -> Option<&Path> {
self.live
.as_ref()
@ -234,27 +251,17 @@ pub(crate) enum PodEntryDiagnosticKind {
}
pub(crate) fn read_stored_pod_infos(
store_dir: &Path,
store: &FsStore,
pod_store: &impl PodMetadataStore,
) -> Result<Vec<StoredPodInfo>, io::Error> {
let pods_dir = store_dir.join("pods");
let mut records = Vec::new();
if !pods_dir.exists() {
return Ok(records);
}
for entry in fs::read_dir(pods_dir)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let pod_name = entry.file_name().to_string_lossy().to_string();
let path = entry.path().join("metadata.json");
let info = match fs::read_to_string(&path) {
Ok(content) => match serde_json::from_str::<PodMetadata>(&content) {
Ok(metadata) => stored_info_from_metadata(store, pod_name, metadata),
Err(e) => corrupt_stored_info(pod_name, e.to_string()),
},
for pod_name in pod_store.list_names().map_err(io::Error::other)? {
let info = match pod_store.read_by_name(&pod_name) {
Ok(Some(metadata)) => stored_info_from_metadata(store, pod_name, metadata),
Ok(None) => corrupt_stored_info(
pod_name,
"metadata disappeared during discovery".to_string(),
),
Err(e) => corrupt_stored_info(pod_name, e.to_string()),
};
records.push(info);
@ -392,10 +399,7 @@ fn summarize_live_pod(store: &FsStore, live: &LivePodInfo) -> PodEntrySummary {
}
}
fn summarize_metadata(
store: &FsStore,
active: Option<&session_store::PodActiveSegmentRef>,
) -> SegmentSummary {
fn summarize_metadata(store: &FsStore, active: Option<&PodActiveSegmentRef>) -> SegmentSummary {
let Some(active) = active else {
return SegmentSummary {
updated_at: 0,
@ -558,7 +562,9 @@ fn trim_one_line(s: &str, max_chars: usize) -> String {
mod tests {
use super::*;
use llm_worker::llm_client::types::RequestConfig;
use session_store::{PodActiveSegmentRef, PodMetadataStore, new_segment_id, new_session_id};
use pod_store::FsPodStore;
use pod_store::{PodActiveSegmentRef, PodMetadataStore};
use session_store::{new_segment_id, new_session_id};
use tempfile::tempdir;
const SOURCE: PodVisibilitySource = PodVisibilitySource::ResumePicker;
@ -604,6 +610,98 @@ mod tests {
assert_eq!(entries[1].name, "older");
}
#[test]
fn reachable_live_rows_sort_before_stopped_rows_before_truncation() {
let stopped = (0..10)
.map(|index| stopped_info_with_updated_at(&format!("stopped-{index}"), 1_000 - index))
.collect::<Vec<_>>();
let live = live_info_with_updated_at("live-pending", PodStatus::Idle, 0);
let entries = PodList::from_sources(SOURCE, stopped, vec![live], None, 10).entries;
assert_eq!(entries.len(), 10);
assert_eq!(entries[0].name, "live-pending");
assert!(entries.iter().all(|entry| entry.name != "stopped-9"));
}
#[test]
fn reachable_live_sort_does_not_promote_unreachable_registry_allocations() {
let mut unreachable = live_info_with_updated_at("unreachable", PodStatus::Idle, 0);
unreachable.reachable = false;
unreachable.status = None;
let entries = PodList::from_sources(
SOURCE,
vec![stopped_info_with_updated_at("stopped", 100)],
vec![unreachable],
None,
10,
)
.entries;
assert_eq!(entries[0].name, "stopped");
assert_eq!(entries[1].name, "unreachable");
}
#[test]
fn live_pending_with_runtime_segment_is_attach_only_and_gets_pending_preview() {
let session_id = new_session_id();
let runtime_segment_id = new_segment_id();
let entry = single_entry(PodList::from_sources(
SOURCE,
vec![pending_metadata_info("pending", session_id)],
vec![live_info_with_segment(
"pending",
PodStatus::Idle,
runtime_segment_id,
)],
None,
10,
));
assert_eq!(entry.name, "pending");
assert_eq!(entry.summary.active_session_id, Some(session_id));
assert_eq!(entry.summary.active_segment_id, Some(runtime_segment_id));
assert_eq!(
entry.summary.preview.as_deref(),
Some("[live, pending segment]")
);
assert!(entry.actions.can_open);
assert!(!entry.actions.can_restore);
assert_eq!(
entry.attach_socket_path(),
Some(Path::new("/tmp/pending.sock"))
);
}
#[test]
fn live_only_runtime_segment_is_attach_only_and_not_restorable() {
let runtime_segment_id = new_segment_id();
let entry = single_entry(PodList::from_sources(
SOURCE,
vec![],
vec![live_info_with_segment(
"runtime-only",
PodStatus::Idle,
runtime_segment_id,
)],
None,
10,
));
assert_eq!(entry.summary.active_segment_id, Some(runtime_segment_id));
assert_eq!(
entry.summary.preview.as_deref(),
Some("[live, pending segment]")
);
assert!(entry.actions.can_open);
assert!(!entry.actions.can_restore);
assert_eq!(
entry.attach_socket_path(),
Some(Path::new("/tmp/runtime-only.sock"))
);
}
#[test]
fn stored_only_row_can_restore_and_open_but_not_direct_send() {
let dir = tempdir().unwrap();
@ -776,11 +874,12 @@ mod tests {
fn read_stored_pod_infos_reports_corrupt_metadata() {
let dir = tempdir().unwrap();
let store = FsStore::new(dir.path()).unwrap();
let pod_store = FsPodStore::new(dir.path().join("pods")).unwrap();
let pod_dir = dir.path().join("pods").join("broken");
fs::create_dir_all(&pod_dir).unwrap();
fs::write(pod_dir.join("metadata.json"), "{not-json").unwrap();
std::fs::create_dir_all(&pod_dir).unwrap();
std::fs::write(pod_dir.join("metadata.json"), "{not-json").unwrap();
let records = read_stored_pod_infos(dir.path(), &store).unwrap();
let records = read_stored_pod_infos(&store, &pod_store).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].pod_name, "broken");
assert!(matches!(
@ -793,16 +892,17 @@ mod tests {
fn read_stored_pod_infos_reads_metadata() {
let dir = tempdir().unwrap();
let store = FsStore::new(dir.path()).unwrap();
let pod_store = FsPodStore::new(dir.path().join("pods")).unwrap();
let session_id = new_session_id();
let segment_id = new_segment_id();
store
pod_store
.write(&PodMetadata::new(
"agent",
Some(PodActiveSegmentRef::active_segment(session_id, segment_id)),
))
.unwrap();
let records = read_stored_pod_infos(dir.path(), &store).unwrap();
let records = read_stored_pod_infos(&store, &pod_store).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].pod_name, "agent");
assert_eq!(records[0].metadata_state, StoredMetadataState::Present);
@ -829,10 +929,42 @@ mod tests {
)
}
fn pending_metadata_info(pod_name: &str, session_id: SessionId) -> StoredPodInfo {
StoredPodInfo {
pod_name: pod_name.to_string(),
metadata_state: StoredMetadataState::Present,
active_session_id: Some(session_id),
active_segment_id: None,
updated_at: 0,
preview: Some("[pending segment]".to_string()),
}
}
fn stopped_info_with_updated_at(pod_name: &str, updated_at: u64) -> StoredPodInfo {
StoredPodInfo {
pod_name: pod_name.to_string(),
metadata_state: StoredMetadataState::Present,
active_session_id: None,
active_segment_id: None,
updated_at,
preview: None,
}
}
fn live_info(pod_name: &str, status: PodStatus) -> LivePodInfo {
live_info_with_updated_at(pod_name, status, 0)
}
fn live_info_with_segment(
pod_name: &str,
status: PodStatus,
segment_id: SegmentId,
) -> LivePodInfo {
let mut info = live_info(pod_name, status);
info.segment_id = Some(segment_id);
info
}
fn live_info_with_updated_at(
pod_name: &str,
status: PodStatus,

View File

@ -17,7 +17,7 @@ use std::time::Duration;
use client::{SpawnConfig, spawn_pod};
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use manifest::{ProfileDiscovery, ScopeConfig};
use manifest::ProfileDiscovery;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout};
@ -42,8 +42,6 @@ pub enum SpawnOutcome {
#[derive(Debug)]
pub enum SpawnError {
Io(io::Error),
Store(session_store::StoreError),
MissingResumeScope { segment_id: SegmentId },
Spawn(client::SpawnError),
}
@ -51,11 +49,6 @@ impl std::fmt::Display for SpawnError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "io error: {e}"),
Self::Store(e) => write!(f, "failed to read session log: {e}"),
Self::MissingResumeScope { segment_id } => write!(
f,
"session {segment_id} has no persisted scope snapshot; refusing resume without explicit scope"
),
Self::Spawn(e) => write!(f, "{e}"),
}
}
@ -69,12 +62,6 @@ impl From<io::Error> for SpawnError {
}
}
impl From<session_store::StoreError> for SpawnError {
fn from(e: session_store::StoreError) -> Self {
Self::Store(e)
}
}
impl From<client::SpawnError> for SpawnError {
fn from(e: client::SpawnError) -> Self {
Self::Spawn(e)
@ -111,7 +98,6 @@ pub async fn run(
editing: true,
resume_from,
resume_by_pod_name: false,
resume_scope: None,
profile_choices,
profile_index,
};
@ -149,10 +135,6 @@ pub async fn run(
}
}
if let Some(id) = form.resume_from {
form.resume_scope = Some(load_resume_scope(id).await?);
}
// Phase 2: launch pod and wait for ready line. Drop the cursor
// out of the name field — subsequent frames are passive status
// updates, not input — so the cursor doesn't end up parked there
@ -305,7 +287,6 @@ fn form_for_pod_name(pod_name: String, defaults: SpawnDefaults) -> Form {
editing: false,
resume_from: None,
resume_by_pod_name: true,
resume_scope: None,
profile_choices: Vec::new(),
profile_index: 0,
}
@ -383,7 +364,6 @@ async fn wait_for_ready(
let config = SpawnConfig {
pod_name: form.name.clone(),
profile: form.selected_profile_selector(),
resume_scope: form.resume_scope.clone(),
cwd: form.cwd.clone(),
resume_from: form.resume_from,
resume_by_pod_name: form.resume_by_pod_name,
@ -399,24 +379,6 @@ async fn wait_for_ready(
})
}
async fn load_resume_scope(segment_id: SegmentId) -> Result<ScopeConfig, SpawnError> {
let store_dir = manifest::paths::sessions_dir().ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"could not resolve sessions directory (set INSOMNIA_HOME, INSOMNIA_DATA_DIR, or HOME)",
)
})?;
let store = session_store::FsStore::new(&store_dir)?;
let state = session_store::restore_by_segment(&store, segment_id)?;
let snapshot = state
.pod_scope
.ok_or(SpawnError::MissingResumeScope { segment_id })?;
Ok(ScopeConfig {
allow: snapshot.allow,
deny: snapshot.deny,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MessageKind {
Info,
@ -453,11 +415,7 @@ struct Form {
/// When true, launch the child with `--pod <name>` so the pod process
/// resolves name-keyed state before falling back to fresh creation.
resume_by_pod_name: bool,
/// Scope snapshot recovered from the source session log. Set only for
/// resume runs and passed through a typed internal restore flag so resume
/// does not silently broaden access.
resume_scope: Option<ScopeConfig>,
/// Optional Nix profile choices passed to `insomnia-pod --profile` for
/// Optional profile choices passed to `insomnia-pod --profile` for
/// fresh spawns. This is not used for resume/attach flows because those must
/// restore Pod state rather than re-evaluate a profile source.
profile_choices: Vec<ProfileChoice>,
@ -616,17 +574,6 @@ fn context_line(form: &Form) -> Line<'_> {
]);
}
if form.resume_scope.is_some() {
return Line::from(vec![
Span::raw(" "),
Span::styled("scope: ", Style::default().fg(Color::DarkGray)),
Span::styled(
"from restored session snapshot",
Style::default().fg(Color::Green),
),
]);
}
match form.scope_origin {
ScopeOrigin::FromProfile => Line::from(vec![
Span::raw(" "),
@ -670,7 +617,6 @@ mod tests {
editing: true,
resume_from: None,
resume_by_pod_name: false,
resume_scope: None,
profile_choices: Vec::new(),
profile_index: 0,
}
@ -691,7 +637,6 @@ mod tests {
assert_eq!(f.name_cursor, "agent".chars().count());
assert_eq!(f.resume_from, None);
assert!(f.resume_by_pod_name);
assert!(f.resume_scope.is_none());
assert!(!f.editing);
assert_eq!(
f.message,
@ -699,28 +644,6 @@ mod tests {
);
}
#[test]
fn resume_scope_snapshot_stays_on_form_for_typed_restore_flag() {
let mut f = form("agent-r");
f.resume_from = Some(session_store::new_segment_id());
f.resume_scope = Some(ScopeConfig {
allow: vec![manifest::ScopeRule {
target: PathBuf::from("/work/example"),
permission: manifest::Permission::Write,
recursive: true,
}],
deny: vec![manifest::ScopeRule {
target: PathBuf::from("/work/example/child"),
permission: manifest::Permission::Write,
recursive: true,
}],
});
let scope = f.resume_scope.as_ref().unwrap();
assert_eq!(scope.allow[0].target, PathBuf::from("/work/example"));
assert_eq!(scope.deny[0].target, PathBuf::from("/work/example/child"));
}
#[test]
fn profile_choices_use_project_registry_default() {
let temp = tempfile::tempdir().unwrap();
@ -732,7 +655,7 @@ mod tests {
r#"
default = "coder"
[profile]
coder = "profiles/coder.nix"
coder = "profiles/coder.lua"
"#,
)
.unwrap();
@ -756,7 +679,7 @@ coder = "profiles/coder.nix"
r#"
default = "coder"
[profile.coder]
path = "profiles/coder.nix"
path = "profiles/coder.lua"
description = "Project coder"
"#,
)

View File

@ -91,7 +91,7 @@ permission = "write"
### Manifest / profile 入力
通常の Pod 起動は Nix profile discovery/default から `PodManifest` を生成する。bundled `builtin:default` が fallback default で、user/project `profiles.toml` は profile registry と default selection だけを担う。user/project `manifest.toml` の ambient cascade は通常起動では使わない。
通常の Pod 起動は Lua profile discovery/default から `PodManifest` を生成する。bundled `builtin:default` が fallback default で、user/project `profiles.toml` は profile registry と default selection だけを担う。user/project `manifest.toml` の ambient cascade は通常起動では使わない。
`insomnia-pod --manifest <PATH>` は explicit one-file compatibility/debug input で、指定 TOML 1 枚だけに builtin defaults を merge し、`PodManifestConfig -> PodManifest` の required validation を通す。

View File

@ -1,47 +1,55 @@
# Manifest profiles
Manifest profiles are the human-authored Nix entrypoint for generating an Insomnia runtime manifest. The Rust side evaluates a selected profile with `nix eval --json --file <path>`, deserializes the resulting JSON artifact, and validates it through the existing `PodManifest` pipeline.
Profiles are reusable Lua-authored recipes for generating an Insomnia runtime manifest. The Rust resolver evaluates a selected `.lua` profile in-process, validates that it is Profile-shaped rather than a complete Manifest, then binds runtime values such as Pod name and concrete scope to produce the persisted `PodManifest` snapshot.
This keeps composition/import/common logic in Nix. Insomnia does not add an implicit profile cascade or merge TOML profile layers into the selected runtime manifest.
Profiles are intentionally not authority-bearing manifests. `pod.name`, concrete `scope.allow` / `scope.deny`, runtime directories, sockets, active session state, and raw secret material do not belong in reusable profiles. Use `--manifest` when you need the explicit low-level complete Manifest escape hatch.
## Minimal profile
```nix
let
insomnia = import ./resources/nix/profile-lib.nix {};
in
insomnia.mkProfile {
name = "coder";
description = "Example coding Pod";
manifest = insomnia.mkManifest {
pod.name = "coder";
model = {
scheme = "anthropic";
model_id = "claude-sonnet-4-20250514";
auth = insomnia.secrets.ref "llm.anthropic.default";
};
scope.allow = [
{ target = "."; permission = "write"; }
];
};
```lua
local profile = require("insomnia.profile")
local models = require("insomnia.models")
local scope = require("insomnia.scope")
return profile {
slug = "coder",
description = "Example coding Pod",
model = models.catalog("codex-oauth/gpt-5.5"),
worker = {
reasoning = "high",
},
scope = scope.workspace_write(),
}
```
Run an explicit path with:
```sh
insomnia-pod --profile ./coder.nix
insomnia-pod --profile ./coder.lua
# or through the TUI fresh-spawn dialog
insomnia --profile ./coder.nix
insomnia --profile ./coder.lua
```
`--profile` accepts an explicit path, `path:<path>`, a discovered profile name, `default`, or a source-qualified name such as `project:coder`, `user:coder`, or `builtin:coder`. Path-like values containing `/`, starting with `.`, or ending in `.nix` preserve the original explicit-path behavior.
`--profile` accepts an explicit path, `path:<path>`, a discovered profile name, `default`, or a source-qualified name such as `project:coder`, `user:coder`, or `builtin:coder`. Path-like values containing `/`, starting with `.`, or ending in `.lua` are explicit paths. ``.nix` paths are no longer supported as profiles and fail with a diagnostic that points users at Lua profiles or `--manifest`.
`--profile` conflicts with `insomnia-pod --manifest` and with restore/session/adopt modes. Use `--profile-pod-name <name>` when a launcher needs a creation-time Pod name override without invoking `--pod` restore semantics. Profile evaluation is a creation-time path; Pod resume restores saved Pod state/resolved snapshots rather than re-evaluating the Nix source.
`--profile` conflicts with `insomnia-pod --manifest` and with restore/session/adopt modes. Use `--profile-pod-name <name>` when a launcher needs a creation-time Pod name override without invoking `--pod` restore semantics. Profile evaluation is a creation-time path; Pod resume restores saved Pod state/resolved snapshots rather than re-evaluating the profile source.
## Controlled Lua environment
Profiles run in a restricted Lua VM. Host virtual modules are available through controlled `require`:
- `require("insomnia")`
- `require("insomnia.profile")`
- `require("insomnia.models")`
- `require("insomnia.compact")`
- `require("insomnia.scope")`
Profile-local modules may be required by dotted names such as `require("shared")` or `require("shared.models")`; those resolve only under the selected profile file's directory. Unsafe/unrestricted Lua facilities such as `os`, `io`, `debug`, `package`, `dofile`, and `loadfile` are unavailable by default.
## Profile discovery
Profile discovery is separate from runtime manifest merging. User/project `profiles.toml` files may declare profile registry metadata, but those files are application/project UX configuration and are not merged into the Nix profile artifact.
Profile discovery is separate from runtime manifest merging. User/project `profiles.toml` files may declare profile registry metadata, but those files are application/project UX configuration and are not merged into the selected profile artifact.
Example project config at `.insomnia/profiles.toml`:
@ -49,15 +57,15 @@ Example project config at `.insomnia/profiles.toml`:
default = "coder"
[profile]
coder = "profiles/coder.nix"
reviewer = "profiles/reviewer.nix"
coder = "profiles/coder.lua"
reviewer = "profiles/reviewer.lua"
```
Table entries can carry descriptions:
```toml
[profile.coder]
path = "profiles/coder.nix"
path = "profiles/coder.lua"
description = "Project coding assistant"
```
@ -77,22 +85,8 @@ The fresh-spawn TUI also uses discovery. The new Pod dialog defaults to the sele
Ambient user/project `manifest.toml` cascade startup has been removed. Normal fresh spawns use profile discovery/default selection, with `profiles.toml` acting only as a profile registry/default selector.
## Artifact contract
## Resolver contract
A profile should evaluate to one of:
- `{ profile = { format = "insomnia.nix-profile.v1"; ... }; manifest = { ... }; }`
- `{ profile = { format = "insomnia.nix-profile.v1"; ... }; config = { ... }; }`
- a raw manifest/config object for debug/test paths.
The resolved artifact is deserialized into the same `PodManifestConfig -> PodManifest` boundary used by direct one-file manifests, so builtin defaults and required-field validation stay shared. Explicit profile paths and user/project registry profile artifacts resolve relative manifest paths against the profile file's directory. Builtin profile artifacts resolve manifest-relative paths against the launch workspace/current directory so the bundled default can grant `scope.allow target = "."` for the workspace rather than for `resources/nix/profiles`.
A Lua profile should return either `profile { ... }` or a plain table containing Profile fields. The resolver converts reusable fields such as `model`, `worker`, `compaction`, `memory`, `web`, `permissions`, `session`, and scope intent into a concrete Manifest. Runtime Pod name and concrete scope authority are supplied by launch context, then the resolved Manifest snapshot is persisted for restore.
Profile and one-file manifest CLI paths currently use builtin prompt assets only. `$insomnia/...` instruction refs work; `$user/...` and `$workspace/...` prompt refs need a future explicit prompt-loader source design instead of reviving ambient manifest discovery.
Secret values must stay as typed references. `resources/nix/profile-lib.nix` emits secret references as JSON like:
```json
{ "kind": "secret_ref", "ref": "llm.anthropic.default" }
```
The encrypted secret store is intentionally not implemented by this profile foundation; attempting to use a `secret_ref` as a live provider credential currently fails with a clear diagnostic at provider construction time.

View File

@ -206,11 +206,11 @@ host_a (spawner) host_b (remote)
# 1. session + profile/manifest input を転送
ssh insomnia@host-b "mkdir -p ~/workspaces/task-123/store"
tar cz session/ | ssh insomnia@host-b "tar xz -C ~/workspaces/task-123/store"
scp profile.nix insomnia@host-b:~/workspaces/task-123/profile.nix
scp profile.lua insomnia@host-b:~/workspaces/task-123/profile.lua
# 2. Pod を起動detach
ssh insomnia@host-b "insomnia-pod --store ~/workspaces/task-123/store \
--profile ~/workspaces/task-123/profile.nix &"
--profile ~/workspaces/task-123/profile.lua &"
# 3. socket を tunnel で引っ張る
ssh -L /tmp/pod-b.sock:/run/insomnia/task-123/pod.sock insomnia@host-b

View File

@ -1,385 +1,189 @@
# Pod Factory: カスケード設定とプロンプト資産
# Pod Factory: Profile resolution and prompt assets
`PodFactory` は、複数の層に分かれた `manifest.toml` とプログラマティック
overlay をマージして、検証済みの `PodManifest``PromptLoader` を生成する
ビルダー。これにより Pod 起動ごとに TOML を手書きする必要がなくなる。
`PodFactory` は、選択された Profile または明示的な one-file Manifest から、検証済みの `PodManifest``PromptLoader` を生成する境界である。通常の fresh spawn は profile discovery/default selection を使い、user/project `manifest.toml` の ambient cascade は使わない。
`PodManifest` は Pod 起動に必要な完全な runtime recipe で、Pod 名、具体的な scope、解決済みパス、model/provider 設定、worker 設定などを含む。Profile はその前段の再利用可能な recipe template であり、Pod 名や concrete scope authority など runtime-bound な値は resolver が起動入力から埋める。
---
## カスケード層
## 起動モード
優先順位が低い順(上位ほど下位を上書き):
| 優先度 | 層 | 位置 | 典型的な内容 |
| モード | 入力 | 用途 | ambient manifest cascade |
|---|---|---|---|
| 1 | ビルトインのデフォルト | `manifest::defaults` モジュールの `pub const` 群を `PodManifestConfig::builtin_defaults()` が cascade 層として注入 | `tool_output.default_max_bytes = 64 KiB`, `file_upload.max_bytes = 256 KiB` など |
| 2 | ユーザー manifest | `<config_dir>/manifest.toml`(解決ルールは `manifest::paths` | プロバイダ指定、デフォルトモデル、常用ツール設定 |
| 3 | プロジェクト manifest | 起動ディレクトリから上方向に探索した最初の `<root>/.insomnia/manifest.toml` | scope、compaction、プロジェクト固有の instruction |
| 4 | プログラマティック overlay | CLI / GUI / 別 Pod からの spawn 等 | `pod.name`、spawn 時の `worker.instruction` のような Pod 固有値 |
| Profile | `--profile <selector>` または省略時 default | 通常の fresh spawn。Lua profile を解決して runtime Manifest を作る | 使わない |
| One-file Manifest | `--manifest <path>` | デバッグ/互換用の完全 Manifest escape hatch | 使わない |
| Restore/attach | `--pod` / `--session` 等 | 保存済み Pod state / session snapshot から再開 | profile を再評価しない |
| SpawnPod internal | hidden `--spawn-config-json` | 親 Pod が子用 config を解決して渡す | 使わない |
デフォルト値はすべて `crates/manifest/src/defaults.rs``pub const` として集約
されており、serde `#[default = "..."]` 経路(`PodManifest` の直接 deserialize
`TryFrom<PodManifestConfig>` 経路cascade 解決)の両方が同じ constants を
参照する。デフォルトを変更するときは `defaults.rs` の 1 行を書き換えるだけで
全経路に反映される。
`profiles.toml` は profile registry/default selection のための UX 設定であり、Pod Manifest 層ではない。Profile の中身へ merge されない。
どの層も TOML スキーマは `PodManifest` と同じ(全フィールド省略可)。
## Profile resolution
## マージセマンティクス
Profile は Lua で書かれる。Rust resolver は selected profile を restricted Lua VM 内で評価し、返り値が Profile-shaped であることを検証してから `PodManifest` に変換する。
| フィールド種別 | 規則 |
```lua
local profile = require("insomnia.profile")
local models = require("insomnia.models")
local scope = require("insomnia.scope")
local compact = require("insomnia.compact")
local model = models.catalog("codex-oauth/gpt-5.5")
return profile {
slug = "coder",
description = "Project coding profile",
model = model,
worker = {
reasoning = "high",
},
compaction = compact.ratio {
threshold = 0.8,
request = 0.9,
},
scope = scope.workspace_write(),
}
```
Profile が表現してよいのは reusable な recipe fields である。
- model selection / provider policy
- worker settings / reasoning
- compaction policy
- memory / web policy
- permissions / tool policy
- session diagnostics policy
- scope intent such as `scope.workspace_write()`
Profile に入れてはいけないもの:
- `pod.name`
- concrete `scope.allow` / `scope.deny`
- runtime directories, sockets, callback addresses
- active/pending/session/pod-store runtime state
- resolved absolute paths that bind the profile to one machine/workspace
- raw resolved secret material
これらが必要な場合は、resolver の runtime input または `--manifest` の explicit Manifest path で扱う。
## Profile selector semantics
`--profile` は以下を受け付ける。
| 形 | 意味 |
|---|---|
| スカラー(`String`, `u32`, `bool` 等) | 上層に値があれば丸ごと置換 |
| `Option<T>` | 上層が `Some` なら置換、`None` なら据え置き |
| 配列スカラー(`worker.stop_sequences` 等) | 上層に値があれば配列ごと置換。追記マージはしない |
| マップ(`tool_output.per_tool` 等) | キー単位でマージ、同一キーは上層優先 |
| `scope.allow` / `scope.deny` | **union**(各層から全部足す)。上位層は `deny` で下位層の `allow` を必ず削れる |
| `permissions.rule` | **union**(下位層の rule → 上位層の rule の順に評価)。`permissions.default_action` は上位層があれば上書き |
| 省略 / `default` | registry default を使う。通常は bundled `builtin:default` |
| `<name>` | unqualified profile name。ambiguous なら fail closed |
| `builtin:<name>` / `user:<name>` / `project:<name>` | source-qualified selector |
| `path:<path>` / `./profile.lua` / `/abs/profile.lua` | 明示 path profile |
各層をマージした結果(`PodManifestConfig`)を `TryFrom<PodManifestConfig>
for PodManifest` が必須フィールド検証と絶対パス検証をかけて `PodManifest`
に変換する。
`.nix` profile files are no longer supported. Reusable profiles are Lua; complete low-level recipes belong behind `--manifest`.
## パス解決
Discovery は bundled builtin profiles、user registry (`<config_dir>/profiles.toml`)、project registry (`<project>/.insomnia/profiles.toml`) を読む。後段の default が前段の default を上書きするため、project default は user/default builtin より優先される。unqualified ambiguous names は source-qualified suggestion を出して失敗する。
manifest 中のパス(`model.auth.file` / `scope.*.target` /
`compaction.model.auth.file`)は相対記述を許容する。相対パスは
**各層のベース基準**で層ごとに絶対化され、そのあとで cascade merge に
かかる。層をまたいだ相対の意味ブレuser 層の `./keys` が project 層の
どこを指すのか曖昧)を避けるための設計。
Example `.insomnia/profiles.toml`:
| 層 | ベース |
```toml
default = "coder"
[profile]
coder = "profiles/coder.lua"
reviewer = "profiles/reviewer.lua"
[profile.orchestrator]
path = "profiles/orchestrator.lua"
description = "Project orchestrator"
```
Relative registry paths are resolved against the `profiles.toml` file that declares them.
## Controlled Lua environment
Profile evaluation runs with controlled host-provided `require`.
Host virtual modules:
- `require("insomnia")`
- `require("insomnia.profile")`
- `require("insomnia.models")`
- `require("insomnia.compact")`
- `require("insomnia.scope")`
Profile-local modules can be reused with dotted names such as `require("shared")` or `require("shared.models")`; they resolve only under the selected profile file's directory. Unsafe/unrestricted Lua facilities such as `os`, `io`, `debug`, unrestricted `package`, `dofile`, `loadfile`, `load`, and `collectgarbage` are unavailable by default.
## One-file Manifest mode
`--manifest <path>` reads exactly one TOML file, resolves relative paths against that file's parent directory, applies builtin defaults, and validates through `PodManifestConfig -> PodManifest`. It does not load user/project `manifest.toml` files and conflicts with `--profile`.
Use `--manifest` only when you need the complete low-level Manifest escape hatch or a focused debug fixture.
## Builtin defaults
Base defaults that are independent of profile choice live in Rust constants under `crates/manifest/src/defaults.rs` and in `PodManifestConfig::builtin_defaults()`. The bundled default role profile lives at `resources/profiles/default.lua` and is discovered as `builtin:default`.
デフォルト値を変更するときは、次のどちらを変更するのかを明確にする。
- all manifests/profiles の baseline default: Rust defaults
- ordinary dogfooding/default role: `resources/profiles/default.lua`
## Path resolution
Profile-local Lua module paths are resolved relative to the selected profile file's directory and are constrained to that directory tree.
Manifest paths in one-file Manifest mode are resolved relative to the manifest file's parent directory before validation. `scope.*.target` and auth file paths must be absolute by the time `PodManifest` is constructed; a remaining relative path indicates a resolver bug and returns `ResolveError::RelativePath`.
Pod cwd is not part of the Profile. It is the process `current_dir()` for manual startup, or the parent-selected `Command::current_dir` for spawned Pods.
## Unknown fields and type errors
Profile validation is stricter than old ambient manifest cascade semantics at the top-level boundary. Complete-Manifest/runtime-authority fields such as `manifest`, `config`, `pod`, and concrete `scope.allow`/`scope.deny` are diagnosed rather than silently treated as reusable profile data.
One-file Manifest deserialization keeps the Manifest compatibility behavior: unknown fields may warn/ignore where the Manifest schema allows it, while type mismatches are hard errors with file/path context.
## Prompt assets
`worker.instruction` refers to a prompt asset used as the main system prompt body. Import-map prefixes:
| Prefix | Resolution |
|---|---|
| user manifest (`<config_dir>/manifest.toml`) | そのファイルの親ディレクトリ |
| project manifest (`<project>/.insomnia/manifest.toml`) | **プロジェクトルート**`.insomnia/` の親)。`target = "."` がワークスペース全体を指すように |
| overlayinline TOML・programmatic | プロセスの `current_dir()` |
Pod の作業ディレクトリは manifest に含まれない。プロセス起動時の
`std::env::current_dir()` がそのまま Pod の pwd となるため、別の作業
ディレクトリで Pod を走らせたい場合は `cd` してから `insomnia-pod` を起動する
(または `SpawnPod` が子に対して行っているように、親プロセス側で
`Command::current_dir` を明示する)。
cascade merge 後の `TryFrom<PodManifestConfig>` では `ensure_absolute`
が不変条件チェックとしてだけ働く。相対パスが残っていれば上流の
resolve 段を取りこぼしている証拠なので `ResolveError::RelativePath`
返す。
## 未知フィールドと型エラー
- **未知フィールド**: `tracing::warn!` を出して無視。将来バージョンアップで読めない
旧設定が出るとユーザー体験が悪いため、`#[serde(deny_unknown_fields)]` は使わない。
- **型ミスマッチ**: `max_tokens = "100"` のような型エラーは hard error として
resolve 失敗させる。ファイルパスと位置情報をエラーメッセージに含める。
---
## manifest.toml 例
### ユーザー層(最小)
`<config_dir>/manifest.toml`:
```toml
[model]
ref = "anthropic/claude-sonnet-4-6"
auth = { kind = "api_key", file = "/home/you/.config/insomnia/keys/anthropic" }
```
`ref = "<provider>/<model_id>"` はプロバイダ / モデルカタログを引く短縮形。
`scheme` / `base_url` / `model_id` は provider 側の宣言から引かれ、`auth` も
カタログの `auth_hint` を起点に解決する。ここでは env 既定(`INSOMNIA_API_KEY_ANTHROPIC`
ではなく file から読みたいので `auth` だけ override している。詳細は
`crates/provider/README.md``resources/{providers,models}/builtin.toml` を参照。
### プロジェクト層(最小)
`<project>/.insomnia/manifest.toml`:
```toml
[[scope.allow]]
target = "/abs/path/to/project"
permission = "write"
[[scope.deny]]
target = "/abs/path/to/project/secrets"
permission = "read"
[compaction]
compact_threshold = 80000
```
### 全オプション例
```toml
[pod]
name = "reviewer"
# Form A: ref のみ(カタログから scheme / base_url / auth_hint / capability を全部引く)
# [model]
# ref = "anthropic/claude-sonnet-4-6"
#
# Form B: ref + 部分 overrideここで示している形
# カタログ起点に個別フィールドだけ上書き。ref 指定時は scheme / model_id / auth は任意 override。
#
# Form C: 完全 inlineカタログ無視。実験用 / カタログに無いモデル)
# [model]
# scheme = "anthropic"
# model_id = "claude-sonnet-4-6"
# auth = { kind = "api_key", file = "..." }
[model]
ref = "anthropic/claude-sonnet-4-6"
base_url = "https://api.anthropic.com"
auth = { kind = "api_key", file = "/home/you/.config/insomnia/keys/anthropic" }
[worker]
instruction = "$user/reviewer"
max_tokens = 4096
max_turns = 50
temperature = 0.3
top_p = 0.9
top_k = 40
stop_sequences = ["\n\n", "</stop>"]
reasoning = "medium" # 文字列 = effort label / 整数 = thinking budget tokens。詳細は docs/reasoning.md
[worker.tool_output]
default_max_bytes = 65536
[worker.tool_output.per_tool]
Read = 131072
Grep = 8192
[worker.file_upload]
max_bytes = 262144
[[scope.allow]]
target = "/abs/path/to/project"
permission = "write"
[[scope.allow]]
target = "/abs/path/to/docs"
permission = "read"
recursive = false
[[scope.deny]]
target = "/abs/path/to/project/secrets"
permission = "write"
[permissions]
default_action = "allow" # allow | deny | ask
[[permissions.rule]]
tool = "Bash"
pattern = "rm *"
action = "deny"
[[permissions.rule]]
tool = "Write"
pattern = "*.env"
action = "deny"
[web]
enabled = true
[web.search]
provider = "brave"
api_key_env = "BRAVE_SEARCH_API_KEY"
timeout_secs = 15
[web.fetch]
timeout_secs = 20
redirect_limit = 5
max_response_bytes = 2097152
max_output_bytes = 65536
[compaction]
prune_protected_tokens = 8000
prune_min_savings = 4096
compact_threshold = 80000
compact_request_threshold = 90000
compact_retained_tokens = 8000
compact_auto_read_budget = 8000
compact_worker_max_input_tokens = 50000
compact_worker_max_turns = 20
[compaction.model]
scheme = "gemini"
model_id = "gemini-2.0-flash"
auth = { kind = "api_key", file = "/home/you/.config/insomnia/keys/gemini" }
```
---
## `[worker]` 設定
`[worker]` は Pod 内の `llm_worker::RequestConfig` とターン制御へ渡す設定を持つ。
Provider ごとの wire 名の違いOpenAI の `max_completion_tokens` /
Responses の `max_output_tokens` / Gemini の `generation_config` など)は
scheme 側が吸収する。
| key | 型 | 既定 | 内容 |
|---|---|---|---|
| `instruction` | `String` | `$insomnia/default` | システムプロンプト本体として使う prompt asset 参照 |
| `max_tokens` | `u32` | 未指定 | 1 request の最大出力 token。scheme が provider の該当 wire field に投影。scheme ごとのセマンティクス差は `docs/reasoning.md` |
| `max_turns` | `NonZeroU32` | 未指定 | 1 run 内で Worker が進められる最大 turn 数 |
| `temperature` | `f32` | 未指定 | sampling temperature |
| `top_p` | `f32` | 未指定 | nucleus sampling |
| `top_k` | `u32` | 未指定 | top-k sampling。未対応 scheme では warning または provider 側挙動に任せる |
| `stop_sequences` | `Vec<String>` | `[]` | stop sequence。cascade では上層指定が配列ごと置換する |
| `reasoning` | `String` または `i32` | 未指定 | reasoning / thinking 制御。詳細は `docs/reasoning.md` |
| `tool_output.default_max_bytes` | `usize` | `65536` | tool result `content` の既定 byte cap |
| `tool_output.per_tool` | `Map<String, usize>` | `{}` | tool 名ごとの byte cap override |
| `file_upload.max_bytes` | `usize` | `262144` | submit 時の FileRef (`@<path>`) upload / attachment の byte cap |
生成設定は provider 別の値域検証を行わない。型が TOML と合わない場合は manifest
parse error になるが、provider が受け付けない値や組み合わせは API 応答で検出する。
## `[web]` 設定
`WebSearch` / `WebFetch` は通常の built-in function tool として登録されるが、manifest で明示的に有効化されるまでネットワークアクセスしない。無効または未設定の場合、tool call は「設定されていない」旨の明示的なエラーを返す。
```toml
[web]
enabled = true
[web.search]
provider = "brave"
api_key_env = "BRAVE_SEARCH_API_KEY" # API key は env 参照に置き、manifest に raw secret を書かない
timeout_secs = 15
[web.fetch]
timeout_secs = 20
redirect_limit = 5
max_response_bytes = 2097152
max_output_bytes = 65536
```
`WebSearch` の最初の provider は Brave Search API`https://api.search.brave.com/res/v1/web/search`)で、入力は `query` と任意の `limit` / `offset`。Brave の制約に合わせて `query` は 400 文字 / 50 words まで、`limit` は 1-20、`offset` は 0-9 に制限される。`timeout_secs` を省略した場合は安全な既定値が使われ、provider response は固定上限内で読み込まれる。
`WebFetch` は http/https URL のみを fetch し、timeout・redirect・response/output byte limit を適用する。localhost / private / link-local などの host/IP は fetch 前と各 redirect で拒否される。テストや明示的に信頼した環境では `[web] allow_private_addresses = true` または `[web.fetch] allow_private_addresses = true` を指定できる。
## `[permissions]` 設定
`[permissions]` が無い場合、ツール permission 層は無効で従来通り実行する。`[permissions]` を書く場合は `default_action = "allow" | "deny" | "ask"` が必須で、`[[permissions.rule]]` は宣言順に最初に一致した rule が採用される。一致しなければ `default_action` を使う。
```toml
[permissions]
default_action = "allow"
[[permissions.rule]]
tool = "Bash"
pattern = "rm *"
action = "deny"
```
`tool` は実行時に登録されているツール名(`Bash`, `Read`, `Write`, `Edit`, `Glob`, `Grep`, `WebSearch`, `WebFetch` 等)に対して大小文字を無視して照合する。`pattern` は built-in tool では主に `command` / `file_path` / `path` / `pattern` / `query` / `url` 引数に対する `*` / `?` ワイルドカードとして評価される。
`allow` は通常実行、`deny` はその tool call を実行せず `is_error = true` の synthetic tool result を履歴へ追加してターンを継続する。`ask` は型として受け付けるが、承認 protocol は未実装のため現在は headless に待機せず fail-closedsynthetic error resultになる。
## instruction とプロンプト資産
### `worker.instruction` フィールド
Pod のシステムプロンプトの**本体**として使うプロンプト資産への参照。
import-map 形式のプレフィックスで指定する:
| プレフィックス | 解決先 |
|---|---|
| `$insomnia` | バイナリ同梱の `resources/prompts/``include_dir!` |
| `$user` | `<config_dir>/prompts/``manifest::paths` で解決) |
| `$insomnia` | bundled `resources/prompts/` (`include_dir!`) |
| `$user` | `<config_dir>/prompts/` |
| `$workspace` | `<project>/.insomnia/prompts/` |
- `.md` 拡張子は省略する(例: `$insomnia/default``resources/prompts/default.md`
- 省略時のデフォルト値は `$insomnia/default``defaults::DEFAULT_INSTRUCTION`
- 指定した prefix の dir に該当ファイルが無ければ **hard error**fallthrough しない)
`.md` extension can be omitted, e.g. `$insomnia/default` resolves to `resources/prompts/default.md`. Missing files are hard errors; prefixes do not fall through.
### ビルトインプロンプト
Profile and one-file Manifest CLI paths currently use builtin prompt assets only for initial loader construction. `$insomnia/...` works; `$user/...` and `$workspace/...` prompt refs need a future explicit prompt-loader source design instead of reviving ambient manifest discovery.
`resources/prompts/` 以下に同梱:
| 名前 | 用途 |
|---|---|
| `default` | デフォルトの instruction 本体。workspace / tool-usage をインクルード |
| `common/workspace` | cwd・日付の注入 |
| `common/tool-usage` | ツール使用の共通ガイダンス |
### `{% include %}` の相対解決
テンプレート内で `{% include "name" %}` のようにプレフィックス無しで書いた場合、
**include を書いたファイル自身のプレフィックスとディレクトリ**からの相対で解決する:
- `$insomnia/default.md` 内の `{% include "common/workspace" %}``$insomnia/common/workspace`
- `$user/custom.md` 内の `{% include "$insomnia/common/tool-usage" %}` → 明示的プレフィックスが優先
### システムプロンプトの最終構造
`instruction` テンプレートのレンダリング結果に、Rust 側で以下の**固定セクション**を付加する。
ユーザーテンプレートからは触れない領域:
```
<instruction のレンダ結果>
---
## Working boundaries
<scope.summary()>
--- ← AGENTS.md が不在なら省略
## Project instructions (AGENTS.md)
<AGENTS.md 本文> ← AGENTS.md が不在なら省略
```
- scope セクションは**必ず**出力される
- AGENTS.md セクションは不在時に区切り `---` ごと省略
---
The rendered instruction body is followed by fixed Rust-provided sections for working boundaries and, when present, `AGENTS.md`. User templates cannot remove the scope section.
## `insomnia-pod` CLI
`insomnia-pod` の通常起動は profile discovery/default から runtime manifest を作る。user/project `manifest.toml` の ambient cascade は通常起動では使わない。
Normal fresh startup uses profile discovery/default selection:
```
insomnia-pod [--profile <selector>] [--profile-pod-name <name>] [-s/--store <path>] [--session <uuid>]
```text
insomnia-pod [--profile <selector>] [--profile-pod-name <name>] [-s/--store <path>]
```
| フラグ | 説明 |
| Flag | Description |
|---|---|
| `--profile <selector>` | builtin/user/project profile registry から Nix profile を選択。省略時は registry default通常は `builtin:default` |
| `--profile-pod-name <name>` | profile 由来 manifest の `pod.name` を fresh spawn 用に上書き |
| `-s, --store <path>` | セッション永続化ディレクトリ(デフォルト: `<data_dir>/sessions/`、`manifest::paths` で解決) |
| `--session <uuid>` | 既存 session id から Pod を復元し、同じ jsonl に後続 turn を追記する |
| `--profile <selector>` | Select a Lua profile. Omitted means registry default, normally `builtin:default` |
| `--profile-pod-name <name>` | Fresh-spawn runtime `pod.name` override for profile resolution |
| `-s, --store <path>` | Session persistence directory, default `<data_dir>/sessions/` |
単一ファイルだけで起動したい場合は `--manifest` を指定する。
Restore/attach uses Pod/session state and does not re-evaluate profile sources.
```
insomnia-pod --manifest <path> [-s/--store <path>] [--session <uuid>]
```text
insomnia-pod --pod <name>
insomnia-pod --session <uuid>
```
`--manifest` は指定 TOML 1 枚だけを読み、builtin defaults を merge したうえで `PodManifestConfig -> PodManifest` の required validation を通す。user / project manifest layer は読まない。`--profile`、`--project` とは併用不可。
Spawn children use hidden `--spawn-config-json`, `--adopt`, and `--callback <path>` flags. These are internal handoff details used by `SpawnPod` after the parent has allocated scope and prepared the child config.
spawn 子 Pod 用の内部フラグとして `--adopt``--callback <path>` がある。これらは `SpawnPod` が scope allocation と親 callback socket を引き継がせるために使うもので、通常の手動起動では使わない。
## Programmatic boundary
Pod の作業ディレクトリは `insomnia-pod` 起動時の cwd が直接使われる。別ディレクトリで
動かしたい場合は `cd <path> && insomnia-pod ...` のように外側で `cd` してから起動する。
引数無しで起動すると、profile registry default通常は bundled `builtin:default`)で起動する。
---
## プログラマティック API
New code should resolve profiles through the profile resolver and then construct Pods from the resulting `PodManifest` and `PromptLoader`. One-file Manifest helpers remain for tests/debugging. Avoid reintroducing user/project manifest cascade APIs as normal startup behavior.
```rust
use pod::{Pod, PodFactory};
let (manifest, loader) = PodFactory::new()
.with_user_manifest_auto()? // manifest::paths から自動読み込み、不在 OK
.with_project_manifest_auto()? // cwd から上方向に .insomnia/ を探索、不在 OK
.with_overlay_toml(overlay)? // programmatic な最上層 overlay
.resolve()?; // -> (PodManifest, PromptLoader)
let (manifest, loader) = resolve_profile_or_manifest(cli_inputs)?;
let pod = Pod::from_manifest(manifest, store, loader).await?;
```
`Pod::from_manifest_toml(toml, store)` は単層 manifest を TOML 文字列で直接投げる
便利関数テスト・デバッグ向け。builtins-only のプロンプトローダで動く。

View File

@ -1,67 +0,0 @@
# Insomnia Nix profile helpers.
#
# A profile file can use:
#
# let insomnia = import ./path/to/profile-lib.nix {};
# in insomnia.mkProfile {
# name = "coder";
# manifest = insomnia.mkManifest { ... };
# }
#
# The output is consumed by `insomnia-pod --profile <path>` via
# `nix eval --json --file <path>`.
{ }:
let
profileFormat = "insomnia.nix-profile.v1";
optional = name: value:
if value == null then {} else { ${name} = value; };
secretRef = ref: {
kind = "secret_ref";
inherit ref;
};
mkManifest = manifest: manifest;
mkProfile =
{ name ? null
, description ? null
, manifest ? null
, config ? null
, ...
}@args:
let
resolvedManifest =
if manifest != null then manifest
else if config != null then config
else removeAttrs args [ "name" "description" "manifest" "config" ];
in
{
profile = ({ format = profileFormat; }
// optional "name" name
// optional "description" description);
manifest = resolvedManifest;
};
semanticPresets = {
# Skeleton for users to extend in their own Nix. Rust does not attach any
# hidden semantic meaning to these helpers; they only generate manifest JSON.
codingAssistant = { modelId ? "claude-sonnet-4-20250514", authRef ? null }:
{
model = {
scheme = "anthropic";
model_id = modelId;
} // (if authRef == null then {} else { auth = secretRef authRef; });
};
};
in
{
inherit profileFormat mkProfile mkManifest semanticPresets;
secrets = {
ref = secretRef;
};
}

View File

@ -1,40 +0,0 @@
let
insomnia = import ../profile-lib.nix {};
in
insomnia.mkProfile {
name = "default";
description = "Bundled default Insomnia coding profile";
manifest = insomnia.mkManifest {
pod.name = "insomnia";
scope.allow = [
{ target = "."; permission = "write"; recursive = true; }
];
session.record_event_trace = true;
worker.reasoning = "high";
model.ref = "codex-oauth/gpt-5.5";
compaction = {
threshold = 200000;
request_threshold = 240000;
worker_context_max_tokens = 100000;
};
memory = {
extract_threshold = 50000;
consolidation_threshold_files = 5;
consolidation_threshold_bytes = 50000;
};
web = {
enabled = true;
search = {
provider = "brave";
api_key_env = "BRAVE_SEARCH_API_KEY";
};
};
};
}

View File

@ -0,0 +1,42 @@
local profile = require("insomnia.profile")
local scope = require("insomnia.scope")
local compact = require("insomnia.compact")
return profile {
slug = "default",
description = "Bundled default Insomnia coding profile",
scope = scope.workspace_write(),
session = {
record_event_trace = true,
},
worker = {
reasoning = "high",
},
model = {
ref = "codex-oauth/gpt-5.5",
},
compaction = compact.tokens {
threshold = 200000,
request_threshold = 240000,
worker_context_max_tokens = 100000,
},
memory = {
extract_threshold = 50000,
consolidation_threshold_files = 5,
consolidation_threshold_bytes = 50000,
},
web = {
enabled = true,
search = {
provider = "brave",
api_key_env = "BRAVE_SEARCH_API_KEY",
},
},
}

View File

@ -2,12 +2,12 @@
id: 20260527-000016-tui-picker-live-pending-pods
slug: tui-picker-live-pending-pods
title: TUI picker: live pending Pod の表示優先と状態補完
status: open
status: closed
kind: task
priority: P2
labels: [migrated]
created_at: 2026-05-27T00:00:16Z
updated_at: 2026-05-27T00:00:16Z
updated_at: 2026-05-30T05:00:56Z
assignee: null
legacy_ticket: tickets/tui-picker-live-pending-pods.md
---

View File

@ -0,0 +1,81 @@
---
id: 20260527-000016-tui-picker-live-pending-pods
slug: tui-picker-live-pending-pods
title: TUI picker: live pending Pod の表示優先と状態補完
status: closed
kind: task
priority: P2
labels: [migrated]
created_at: 2026-05-27T00:00:16Z
updated_at: 2026-05-30T05:00:56Z
assignee: null
legacy_ticket: tickets/tui-picker-live-pending-pods.md
---
## Migration reference
- legacy_ticket: tickets/tui-picker-live-pending-pods.md
- migrated_from: TODO.md / tickets directory migration on 2026-05-27
# TUI picker: live pending Pod の表示優先と状態補完
## 背景
`tui -r` の Pod picker は session store の name-keyed Pod metadata と runtime registry の live allocation を合わせて表示している。しかし、spawned child Pod がまだ最初の user turn / SegmentStart を materialize していない場合、Pod metadata は pending segment のままになり、session log も存在しない。
実例として、`impl-llm-worker-stream-continuation` は live socket と runtime registry 上の segment_id を持っていたが、metadata は以下のように `session_id` のみだった。
```json
{
"pod_name": "impl-llm-worker-stream-continuation",
"active": {
"session_id": "019e5bc6-c3f3-7193-98a1-d64c635f86a1"
}
}
```
一方で runtime 側には segment_id が存在する。
```json
{
"pod_name": "impl-llm-worker-stream-continuation",
"segment_id": "019e5bc6-c3f3-7193-98a1-d6559bdc9cd6",
"state": "idle"
}
```
この状態の Pod は attach 可能だが、session log がないため `updated_at = 0` になり、picker の `updated_at desc` sort と `MAX_ROWS = 10` truncate によって一覧から漏れやすい。
## 方針
Live socket が reachable な Pod は、session log / metadata active segment が未確定でも attach 可能な対象として picker に表示する。restore 可能性と attach 可能性を分け、live pending Pod は restore 不能でも live attach 対象として扱う。
## 要件
- `tui -r` picker は reachable live Pod を stopped Pod より優先して表示する。
- `updated_at = 0` でも live row が `MAX_ROWS` truncate で落ちない。
- sort key は少なくとも live first, updated_at desc, pod_name になる。
- Live Pod の metadata が pending segment の場合でも picker row に表示する。
- preview は `[live, pending segment]` など、人間が状態を理解できる文言にする。
- debug id 表示では runtime registry の segment_id を可能なら表示する。
- Runtime registry / live status に segment_id があり、metadata に segment_id が無い場合、表示上は runtime segment_id を補完できるようにする。
- ただし session log が存在しない限り restore 可能とは扱わない。
- attach は live socket に対して行う。
- Existing stopped / corrupt Pod metadata rows の表示を壊さない。
- `ListVisiblePods` / discovery 側にも同様の pending live 表示不整合がある場合、必要なら後続 ticket に切り出す。
- この ticket の主対象は `tui -r` picker。
## 完了条件
- live pending Pod が `tui -r` に表示される。
- live pending Pod を選択すると live socket に attach する。
- live pending Pod が多数の stopped Pod によって `MAX_ROWS` truncate から漏れない。
- picker の sort / row build の unit test が追加または更新されている。
- `cargo fmt --check``cargo test -p tui picker` あるいは関連 TUI test が通る。
## 範囲外
- pending Pod metadata を runtime segment_id で永続的に書き換えること。
- session log が無い Pod を restore 可能にすること。
- spawned child Pod の first turn / SegmentStart materialization 方針の変更。
- 汎用 spawned Pod panel UI。

View File

@ -0,0 +1,179 @@
<!-- event: migration author: tickets.sh-migration at: 2026-05-27T00:00:16Z -->
## Migrated
Migrated from tickets/tui-picker-live-pending-pods.md. No legacy review file was present at migration time.
---
<!-- event: plan author: hare at: 2026-05-30T04:54:03Z -->
## Plan
## Preflight implementation plan
Classification: implementation-ready.
No blocking preflight gap remains. The product rule is settled: reachable live Pods must be visible/attachable even if durable session-log metadata is incomplete, but missing session logs must not make them restorable.
Implementation detail to preserve:
- Treat “pending live” as a display/model condition, not persisted state.
- Use reachable `LivePodInfo` plus incomplete stored/session summary or runtime-only segment id to improve row order/preview/debug ids.
- Do not mark the Pod restorable unless stored metadata has a usable active segment/session under existing restore rules.
Current code map:
- `crates/tui/src/picker.rs`: picker construction, row rendering, live attach socket override.
- `crates/tui/src/pod_list.rs`: shared model merge/sort/truncation/actions; current sort is updated_at desc only; `merge_live` already supplements segment id from runtime.
- `crates/tui/src/main.rs`: selected live row attaches via socket override before restore fallback.
- `crates/tui/src/multi_pod.rs`: also uses `PodList`, so ordering effects should be checked.
- `crates/pod/src/discovery.rs`: List/Attach/Restore behavior is related but out of scope.
- `crates/pod-registry/src/table.rs`: runtime allocation segment id source.
- `crates/pod-store/src/lib.rs`: pending active segment metadata; do not persist runtime supplementation.
Implementation phases:
1. Change `PodList::from_sources` sorting to reachable-live first, then updated_at desc, then pod_name asc; truncation remains after sorting.
2. Make reachable live pending preview explicit, e.g. `[live, pending segment]`, when durable summary is incomplete.
3. Preserve and test runtime segment id supplementation for display/debug ids only.
4. Add focused `pod_list` tests for live-first-before-truncation, live pending runtime segment attach-only behavior, and live-only runtime segment attach-only behavior.
5. Adjust existing sort/multi-pod tests only as needed.
6. Keep `PodDiscovery::inspect` / `AttachOrRestorePod` behavior out of scope; record follow-up if needed.
Critical risks:
- Live attachability and restoreability must stay separate.
- Do not persist runtime segment supplementation to pod-store.
- Sort must happen before truncation.
- Do not duplicate picker-specific merge/sort logic; fix shared `PodList`.
- Rank reachable live rows, not unreachable registry allocations.
- Preview wording must not imply restoreability.
- Multi-Pod dashboard ordering may change; reviewer should check it remains intended.
Validation plan:
- `cargo test -p tui pod_list`
- `cargo test -p tui picker`
- `cargo test -p tui multi_pod`
- `cargo test -p tui`
- `cargo fmt --check`
---
<!-- event: review author: hare at: 2026-05-30T05:00:32Z status: approve -->
## Review: approve
Approve.
The change correctly moves the live-priority rule into shared `PodList` construction, so both the resume picker and multi-Pod dashboard consume the same merged/sorted model. Reachable live Pods now sort ahead of non-live/unreachable/stopped/corrupt rows before truncation, and live pending rows get display-only runtime segment supplementation plus clearer pending preview text without changing pod-store metadata or restore behavior.
Blocker findings: none.
Requirement coverage:
- Reachable live rows sort before stopped/corrupt/unreachable rows before truncation.
- Sorting remains deterministic inside groups: `updated_at` desc, then pod name asc.
- Live pending/runtime-only rows remain attachable/openable but not restorable.
- Runtime segment id supplementation is display/model-only; no pod-store write path is touched.
- Pending preview uses `[live, pending segment]` and does not imply restoreability.
- Shared `PodList` was fixed rather than duplicating picker-specific logic.
- Unreachable registry allocations are not promoted.
- PodDiscovery / AttachOrRestore behavior was not broadened.
Validation reviewed from coder report:
- `cargo test -p tui pod_list` — passed.
- `cargo test -p tui picker` — passed.
- `cargo test -p tui multi_pod` — passed.
- `cargo test -p tui` — passed.
- `cargo fmt --check` — passed.
Final verdict: approve.
---
<!-- event: close author: hare at: 2026-05-30T05:00:56Z status: closed -->
## Closed
---
id: 20260527-000016-tui-picker-live-pending-pods
slug: tui-picker-live-pending-pods
title: TUI picker: live pending Pod の表示優先と状態補完
status: closed
kind: task
priority: P2
labels: [migrated]
created_at: 2026-05-27T00:00:16Z
updated_at: 2026-05-30T05:00:56Z
assignee: null
legacy_ticket: tickets/tui-picker-live-pending-pods.md
---
## Migration reference
- legacy_ticket: tickets/tui-picker-live-pending-pods.md
- migrated_from: TODO.md / tickets directory migration on 2026-05-27
# TUI picker: live pending Pod の表示優先と状態補完
## 背景
`tui -r` の Pod picker は session store の name-keyed Pod metadata と runtime registry の live allocation を合わせて表示している。しかし、spawned child Pod がまだ最初の user turn / SegmentStart を materialize していない場合、Pod metadata は pending segment のままになり、session log も存在しない。
実例として、`impl-llm-worker-stream-continuation` は live socket と runtime registry 上の segment_id を持っていたが、metadata は以下のように `session_id` のみだった。
```json
{
"pod_name": "impl-llm-worker-stream-continuation",
"active": {
"session_id": "019e5bc6-c3f3-7193-98a1-d64c635f86a1"
}
}
```
一方で runtime 側には segment_id が存在する。
```json
{
"pod_name": "impl-llm-worker-stream-continuation",
"segment_id": "019e5bc6-c3f3-7193-98a1-d6559bdc9cd6",
"state": "idle"
}
```
この状態の Pod は attach 可能だが、session log がないため `updated_at = 0` になり、picker の `updated_at desc` sort と `MAX_ROWS = 10` truncate によって一覧から漏れやすい。
## 方針
Live socket が reachable な Pod は、session log / metadata active segment が未確定でも attach 可能な対象として picker に表示する。restore 可能性と attach 可能性を分け、live pending Pod は restore 不能でも live attach 対象として扱う。
## 要件
- `tui -r` picker は reachable live Pod を stopped Pod より優先して表示する。
- `updated_at = 0` でも live row が `MAX_ROWS` truncate で落ちない。
- sort key は少なくとも live first, updated_at desc, pod_name になる。
- Live Pod の metadata が pending segment の場合でも picker row に表示する。
- preview は `[live, pending segment]` など、人間が状態を理解できる文言にする。
- debug id 表示では runtime registry の segment_id を可能なら表示する。
- Runtime registry / live status に segment_id があり、metadata に segment_id が無い場合、表示上は runtime segment_id を補完できるようにする。
- ただし session log が存在しない限り restore 可能とは扱わない。
- attach は live socket に対して行う。
- Existing stopped / corrupt Pod metadata rows の表示を壊さない。
- `ListVisiblePods` / discovery 側にも同様の pending live 表示不整合がある場合、必要なら後続 ticket に切り出す。
- この ticket の主対象は `tui -r` picker。
## 完了条件
- live pending Pod が `tui -r` に表示される。
- live pending Pod を選択すると live socket に attach する。
- live pending Pod が多数の stopped Pod によって `MAX_ROWS` truncate から漏れない。
- picker の sort / row build の unit test が追加または更新されている。
- `cargo fmt --check``cargo test -p tui picker` あるいは関連 TUI test が通る。
## 範囲外
- pending Pod metadata を runtime segment_id で永続的に書き換えること。
- session log が無い Pod を restore 可能にすること。
- spawned child Pod の first turn / SegmentStart materialization 方針の変更。
- 汎用 spawned Pod panel UI。
---

View File

@ -2,12 +2,12 @@
id: 20260529-163047-pod-event-scope-subdelegation-control-only
slug: pod-event-scope-subdelegation-control-only
title: Keep scope sub-delegation PodEvent out of agent notifications
status: open
status: closed
kind: bug
priority: P2
labels: [pod, events, orchestration, context]
created_at: 2026-05-29T16:30:47Z
updated_at: 2026-05-29T16:30:47Z
updated_at: 2026-05-30T05:04:26Z
assignee: null
legacy_ticket: null
---

View File

@ -0,0 +1,65 @@
---
id: 20260529-163047-pod-event-scope-subdelegation-control-only
slug: pod-event-scope-subdelegation-control-only
title: Keep scope sub-delegation PodEvent out of agent notifications
status: closed
kind: bug
priority: P2
labels: [pod, events, orchestration, context]
created_at: 2026-05-29T16:30:47Z
updated_at: 2026-05-30T05:04:26Z
assignee: null
legacy_ticket: null
---
## Background
Nested Pod orchestration currently emits a visible notification when a child Pod sub-delegates scope to its own child, for example:
```text
pod `orchestrate-nix-manifest-profiles` sub-delegated scope to `manifest-profiles-audit-20260529`
```
This comes from `PodEvent::ScopeSubDelegated`. The event itself is useful as control-plane data: parent Pods need it to update spawned-child registry state, preserve delegated scope ownership, and propagate the child/grandchild relationship upward. However, it does not usually require the parent LLM to take action.
At the moment all `PodEvent` values are pushed into the notification buffer and can trigger `RunForNotification` when the receiving Pod is idle. That makes scope delegation a model-visible semantic notification, adds noise to history/context, and can cause unnecessary auto-kicked LLM turns during nested orchestration.
## Requirements
- Keep `PodEvent::ScopeSubDelegated` as a control-plane event.
- Existing registry side effects must still run.
- Scope ownership/reclaim behavior must not regress.
- Upward propagation to higher-level parents must still happen when needed.
- Do not expose scope sub-delegation as an agent notification.
- Do not push `ScopeSubDelegated` into the Pod notification buffer.
- Do not persist it as model-visible notification history.
- Do not trigger `PendingRun::RunForNotification` solely because scope was sub-delegated.
- Preserve agent-visible notifications for events that need orchestration attention.
- `TurnEnded` should remain agent-visible.
- `Errored` should remain agent-visible.
- `ShutDown` should remain agent-visible unless a later design explicitly separates it.
- Make the event visibility boundary explicit in code.
- Prefer a small helper such as `PodEvent::should_notify_agent()` or an equivalent visibility classification.
- Keep side effects and agent notification decisions separate so future control-plane events do not accidentally become model-visible.
- Keep context/history principles intact.
- Control-plane-only events must not be injected into LLM context without first becoming intentional history content.
- Avoid extra prompt-cache churn and token use for events that are not actionable by the model.
## Suggested implementation notes
Likely areas:
- `crates/protocol/src/lib.rs`: add an explicit visibility/helper on `PodEvent`.
- `crates/pod/src/controller.rs`: after `apply_event_side_effects`, only call `pod.push_pod_event_notify(event)` and set `PendingRun::RunForNotification` when the event is agent-visible.
- `crates/pod/src/ipc/event.rs`: keep `ScopeSubDelegated` side effects unchanged.
- `crates/pod/tests/controller_test.rs`: update/add coverage for control-only scope delegation and agent-visible lifecycle events.
## Acceptance criteria
- `ScopeSubDelegated` still updates/propagates spawned-child registry state exactly as before.
- `ScopeSubDelegated` no longer produces `[Notification] ... sub-delegated scope ...` in the parent Pod's agent-visible output/history.
- `ScopeSubDelegated` does not auto-kick an idle parent Pod into a model run.
- `TurnEnded`, `Errored`, and `ShutDown` still produce agent-visible notifications and can still wake an idle parent when appropriate.
- Tests cover both the control-only `ScopeSubDelegated` path and at least one agent-visible `PodEvent` path.
- `cargo fmt --check`
- Relevant pod/protocol tests pass.

View File

@ -0,0 +1,156 @@
<!-- event: create author: tickets.sh at: 2026-05-29T16:30:47Z -->
## Created
Created by tickets.sh create.
---
<!-- event: plan author: hare at: 2026-05-30T04:54:02Z -->
## Plan
## Preflight implementation plan
Classification: implementation-ready.
No product/API decision is needed before coding. `ScopeSubDelegated` remains a valid typed `PodEvent` for registry side effects and upward propagation, but must not enter the agent-visible notification/history/auto-kick lane.
Current code map:
- `crates/protocol/src/lib.rs`: `PodEvent` variants and protocol roundtrip tests.
- `crates/pod/src/controller.rs`: idle `Method::PodEvent` handler applies side effects, pushes notification, and auto-kicks; running-path handler applies side effects and pushes into `NotifyBuffer`.
- `crates/pod/src/ipc/event.rs`: transport helpers and `apply_event_side_effects`; `ScopeSubDelegated` registers grandchild and re-emits upward.
- `crates/pod/src/ipc/notify_buffer.rs`: agent-visible notification/history lane.
- `crates/pod/src/ipc/interceptor.rs`: drains `NotifyBuffer` into session history/context.
- Existing tests: controller tests for visible `TurnEnded`, pod events tests for `ScopeSubDelegated` registry/re-emission, protocol roundtrips.
Implementation phases:
1. Add `PodEvent::should_notify_agent()` classification in `protocol`: true for `TurnEnded`, `Errored`, `ShutDown`; false for `ScopeSubDelegated`.
2. Gate idle-path notification/auto-kick in `controller.rs`: always apply side effects; only push notify and schedule `RunForNotification` when `should_notify_agent()` is true.
3. Gate running-path notification buffering the same way.
4. Update comments/docs in protocol/controller/notify/event modules to distinguish control-plane side effects from agent-visible notifications.
5. Add focused tests.
Critical risks:
- Never skip `apply_event_side_effects` for `ScopeSubDelegated`.
- Gate both idle and running receive paths.
- Do not change wire serialization or remove the event.
- Do not demote `ShutDown`; it remains agent-visible.
- Do not use rendering availability as the visibility decision.
Validation plan:
- Protocol test for `should_notify_agent` classification.
- Controller test: idle `ScopeSubDelegated` updates side effects as needed but creates no `SystemItem::PodEvent`, no auto-started LLM request, and parent remains idle.
- Keep/verify existing positive `TurnEnded` auto-kick test.
- Existing `pod_events_test` should still pass for registry/re-emission.
- Run `cargo test -p protocol pod_event`, `cargo test -p pod --test pod_events`, focused controller pod-event tests, and `cargo fmt --check`.
---
<!-- event: review author: hare at: 2026-05-30T05:03:44Z status: approve -->
## Review: approve
Approve.
The implementation keeps `PodEvent::ScopeSubDelegated` on the typed IPC/control plane while removing it from the agent-visible notification/history/auto-run lane. The core change is an explicit `PodEvent::should_notify_agent()` classifier used by both controller event receive paths after side effects have already been applied.
Blocker findings: none.
Requirement coverage:
- `ScopeSubDelegated` side effects are still applied in both idle and running paths.
- Upward re-emission remains in `apply_event_side_effects`.
- `ScopeSubDelegated` no longer enters `NotifyBuffer`, does not append `SystemItem::PodEvent`, and does not auto-kick `RunForNotification`.
- `TurnEnded`, `Errored`, and `ShutDown` remain agent-visible.
- Wire serialization/protocol shape is unchanged.
- No new hidden history/context injection path was introduced.
Non-blocking follow-ups:
- Consider making misuse harder later by renaming/gating lower-level `push_pod_event_notify` / `NotifyBuffer::push_pod_event` APIs or adding debug assertions.
- Idle-path test does not directly assert registry side effect, but running-path and pod event side-effect tests cover it and the idle path calls the same side-effect function before gating.
Validation reviewed from coder report:
- `cargo fmt --check` — passed.
- `cargo test -p protocol pod_event` — passed.
- `cargo test -p pod --test pod_events_test` — passed.
- `cargo test -p pod --test controller_test pod_event` — passed.
- focused running-path tests for control-only and visible events — passed.
Final verdict: approve.
---
<!-- event: close author: hare at: 2026-05-30T05:04:26Z status: closed -->
## Closed
---
id: 20260529-163047-pod-event-scope-subdelegation-control-only
slug: pod-event-scope-subdelegation-control-only
title: Keep scope sub-delegation PodEvent out of agent notifications
status: closed
kind: bug
priority: P2
labels: [pod, events, orchestration, context]
created_at: 2026-05-29T16:30:47Z
updated_at: 2026-05-30T05:04:26Z
assignee: null
legacy_ticket: null
---
## Background
Nested Pod orchestration currently emits a visible notification when a child Pod sub-delegates scope to its own child, for example:
```text
pod `orchestrate-nix-manifest-profiles` sub-delegated scope to `manifest-profiles-audit-20260529`
```
This comes from `PodEvent::ScopeSubDelegated`. The event itself is useful as control-plane data: parent Pods need it to update spawned-child registry state, preserve delegated scope ownership, and propagate the child/grandchild relationship upward. However, it does not usually require the parent LLM to take action.
At the moment all `PodEvent` values are pushed into the notification buffer and can trigger `RunForNotification` when the receiving Pod is idle. That makes scope delegation a model-visible semantic notification, adds noise to history/context, and can cause unnecessary auto-kicked LLM turns during nested orchestration.
## Requirements
- Keep `PodEvent::ScopeSubDelegated` as a control-plane event.
- Existing registry side effects must still run.
- Scope ownership/reclaim behavior must not regress.
- Upward propagation to higher-level parents must still happen when needed.
- Do not expose scope sub-delegation as an agent notification.
- Do not push `ScopeSubDelegated` into the Pod notification buffer.
- Do not persist it as model-visible notification history.
- Do not trigger `PendingRun::RunForNotification` solely because scope was sub-delegated.
- Preserve agent-visible notifications for events that need orchestration attention.
- `TurnEnded` should remain agent-visible.
- `Errored` should remain agent-visible.
- `ShutDown` should remain agent-visible unless a later design explicitly separates it.
- Make the event visibility boundary explicit in code.
- Prefer a small helper such as `PodEvent::should_notify_agent()` or an equivalent visibility classification.
- Keep side effects and agent notification decisions separate so future control-plane events do not accidentally become model-visible.
- Keep context/history principles intact.
- Control-plane-only events must not be injected into LLM context without first becoming intentional history content.
- Avoid extra prompt-cache churn and token use for events that are not actionable by the model.
## Suggested implementation notes
Likely areas:
- `crates/protocol/src/lib.rs`: add an explicit visibility/helper on `PodEvent`.
- `crates/pod/src/controller.rs`: after `apply_event_side_effects`, only call `pod.push_pod_event_notify(event)` and set `PendingRun::RunForNotification` when the event is agent-visible.
- `crates/pod/src/ipc/event.rs`: keep `ScopeSubDelegated` side effects unchanged.
- `crates/pod/tests/controller_test.rs`: update/add coverage for control-only scope delegation and agent-visible lifecycle events.
## Acceptance criteria
- `ScopeSubDelegated` still updates/propagates spawned-child registry state exactly as before.
- `ScopeSubDelegated` no longer produces `[Notification] ... sub-delegated scope ...` in the parent Pod's agent-visible output/history.
- `ScopeSubDelegated` does not auto-kick an idle parent Pod into a model run.
- `TurnEnded`, `Errored`, and `ShutDown` still produce agent-visible notifications and can still wake an idle parent when appropriate.
- Tests cover both the control-only `ScopeSubDelegated` path and at least one agent-visible `PodEvent` path.
- `cargo fmt --check`
- Relevant pod/protocol tests pass.
---

View File

@ -0,0 +1,45 @@
# Review R2: session-pod-state-boundary
Verdict: approve
## Conceptual summary
Commit `d2e8087` addresses the two prior blocking issues without reintroducing session-log scope authority. The restore path now reconciles missing/unreachable delegated children inside `Pod::restore_from_manifest` before returning a usable `Pod`, and `pod_registry::reclaim_delegated_scope` now removes the parent's delegated deny layer even when the child allocation is already absent.
## Findings
No blocking issues found in the reviewed delta.
The specific R1 blockers are resolved:
- `crates/pod-registry/src/mutate.rs:181-224` now removes matching `parent_alloc.scope_deny` entries unconditionally for delegated write rules, before optionally removing/reparenting an existing child allocation. This covers the missing-child allocation case and remains idempotent for absent deny entries.
- `crates/pod-registry/src/mutate.rs:524-551` adds direct coverage for reclaiming parent deny when the child allocation is missing.
- `crates/pod/src/pod.rs:4056` calls `pod.reconcile_restored_delegations().await?` from `Pod::restore_from_manifest` before returning the restored `Pod`; `restore_from_pod_metadata` still delegates through `restore_from_manifest`, so name-based restore gets the same enforcement.
- `crates/pod/src/pod.rs:4061-4108` performs reachability checks, reclaims runtime lock state, updates in-memory scope, moves reclaimed children into `pod-store`, and queues a notification via `push_notify` before the restored Pod can be used for a model request.
- Grep review did not find reintroduced `pod.scope` / effective-scope session authority. Remaining `LogEntry::Extension` uses are metrics/memory or generic replay handling, not Pod scope snapshots.
## Non-blocking notes
- `PodController::spawn` still has a secondary `SpawnedPodRegistry::load_from_pod_state_with_reclaim` reconciliation/notification path. With the constructor-level reconciliation, restored Pods should normally arrive there already cleaned up; the remaining path is still useful for registry construction and non-restore cases, but future cleanup could avoid duplicate conceptual ownership.
- As noted in R1, `pod-store::PodMetadataStore::update_by_name` remains read-modify-write internally. That is acceptable for this ticket's filesystem backend and covered field-preservation semantics, but it is not a transactional concurrency primitive.
## Validation run
Inspected the `d2e8087` delta and the current relevant files:
- `crates/pod-registry/src/mutate.rs`
- `crates/pod/src/pod.rs`
- `crates/pod/src/controller.rs`
- `crates/pod/tests/pod_comm_tools_test.rs`
- `crates/pod/tests/restore_test.rs`
Commands run from `/home/hare/Projects/insomnia/.worktree/session-pod-state-boundary`:
```text
cargo test -p pod-registry reclaim_delegated_scope
cargo test -p pod --test pod_comm_tools_test load_from_pod_state_reclaims_missing_child_scope_and_records_history
cargo test -p pod --test restore_test
git diff --check HEAD~3..HEAD
```
All commands passed.

View File

@ -0,0 +1,51 @@
# Review: session-pod-state-boundary
Verdict: blocking
## Conceptual summary
The crate split is mostly in the intended shape: `pod-store` now owns the Pod metadata types, validation, trait, and filesystem-backed store; `session-store` no longer exports Pod metadata; normal construction uses separate session and Pod-state roots; TUI discovery goes through `PodMetadataStore`; and the old `{sessions_root}/pods` layout is not used as an authority.
The late scope-authority change is only partially satisfied. `pod.scope` session-log authority appears removed, and restore derives initial deny rules from `pod-store` `spawned_children`, but the restore reconciliation path has a runtime-state hole for missing children. That violates the ticket's requirement to reclaim delegated scope for missing/stopped/unreachable children before resumed model use.
## Blocking issues
1. **Restore reconciliation leaves stale runtime deny rules when the missing child has no live lock allocation.**
- `crates/pod/src/pod.rs:4597-4616` derives restored parent scope by adding deny rules for every outstanding `metadata.spawned_children` write delegation.
- `crates/pod/src/pod.rs:3960-3967` installs the restored top-level Pod into the runtime registry with those deny rules.
- `crates/pod/src/spawn/registry.rs:131-159` then detects unreachable children and calls `pod_registry::reclaim_delegated_scope(...)` via `reclaim_record(...)`.
- But `crates/pod-registry/src/mutate.rs:202-215` removes the parent's `scope_deny` entries only when the child allocation currently exists (`if child_exists { ... remove parent deny ... }`). If the child is missing from the live lock registry—which is exactly one of the required restore-reconciliation cases—the restored parent allocation keeps the delegated write deny in `pods.json` even though `pod-store` has moved the child to `reclaimed_children` and the in-memory `SharedScope` has removed the deny.
This makes runtime lock state diverge from `pod-store`/in-memory scope after restore. Future spawn/delegation checks that consult the lock allocation will still see the reclaimed write scope as denied. The existing test `load_from_pod_state_reclaims_pruned_child_scope_and_records_history` only covers the case where a stale child allocation exists, so it misses the required "missing child" case. The reclaim operation needs to remove the parent's matching delegated deny independently of whether the child allocation still exists, while still being idempotent.
2. **Restore reconciliation is not structurally part of `Pod::restore_*`; it depends on `PodController::spawn`.**
`Pod::restore_from_manifest` / `Pod::restore_from_pod_metadata` construct a restored Pod with scope derived from outstanding `pod-store` delegations, but the pruning/reclaim and notification are performed later in `PodController::spawn` (`crates/pod/src/controller.rs:160-174`). Normal CLI startup goes through the controller, but the public restore constructors can return a Pod that can be used directly without the required reconciliation notification. If direct use is intentionally unsupported, the API should make that boundary explicit; otherwise the reconciliation should be moved into or made mandatory by the restore path so "before any model request observes resumed state" is enforced by construction.
## Non-blocking notes
- `pod-store::PodMetadataStore::update_by_name` is still implemented as a read-modify-write default method (`crates/pod-store/src/lib.rs:133-145`). The call sites no longer manually preserve unrelated fields, and the field-specific methods are test-covered, so this is acceptable for the current filesystem backend. If concurrent metadata writers become possible, this API will need a stronger atomicity story.
- `pod-store` depends on `session-store` for `SessionId`/`SegmentId`. The ticket allowed a narrow dependency for IDs; the crate docs keep the replay boundary clear.
- `crates/pod/src/main.rs:230-235` derives the Pod-state root from `paths::data_dir()` rather than from custom `--store` when data dir is available. That keeps the new top-level layout, but users of a custom session store do not have a matching custom Pod store flag yet.
## Validation run
Read/inspected:
- Ticket: `work-items/open/20260529-205844-session-pod-state-boundary/item.md`
- Implementation diff/stat for commits `2117381..e10b4ad`
- Key files: `crates/pod-store/src/lib.rs`, `crates/session-store/src/lib.rs`, `crates/session-store/src/fs_store.rs`, `crates/pod/src/pod.rs`, `crates/pod/src/controller.rs`, `crates/pod/src/spawn/registry.rs`, `crates/pod-registry/src/mutate.rs`, `crates/tui/src/pod_list.rs`, `crates/pod/src/main.rs`
Commands run from `/home/hare/Projects/insomnia/.worktree/session-pod-state-boundary`:
```text
cargo test -p pod-store
cargo test -p pod --test pod_comm_tools_test load_from_pod_state_reclaims_pruned_child_scope_and_records_history
cargo test -p session-store
cargo test -p tui pod_list
git diff --check HEAD~2..HEAD
./tickets.sh doctor
```
All commands passed. Full command output was saved by the shell tool at `/run/user/1000/insomnia/review-session-pod-state-boundary/bash-output/bash-uDx0E4.log`.

View File

@ -0,0 +1,104 @@
---
id: 20260529-205844-session-pod-state-boundary
slug: session-pod-state-boundary
title: Split Pod metadata into a dedicated pod-store crate
status: closed
kind: task
priority: P2
labels: [session-store, pod-store, pod, persistence, architecture]
created_at: 2026-05-29T20:58:44Z
updated_at: 2026-05-30T00:10:45Z
assignee: null
legacy_ticket: null
---
## Background
The current persistence design intentionally has two durable surfaces:
- append-only session/segment logs, which are the authority for conversation/history state and segment lineage;
- name-keyed Pod metadata, which is the authority for Pod-name attach/restore pointers and durable spawned-child bookkeeping.
That boundary has become blurry. The `session-store` crate is named and documented primarily as session persistence, but it also owns Pod metadata types, the `PodMetadataStore` trait, validation of Pod names, and the filesystem layout `{sessions_root}/pods/{pod_name}/metadata.json`. In addition, Pod metadata currently stores `spawned_children` and `resolved_manifest_snapshot`, while session logs also store Pod scope snapshots as `LogEntry::Extension` entries. This creates a risk that session-log authority, Pod-state authority, and runtime mirrors drift or become hard to reason about.
This happened because earlier implementation work treated `session-store` as a convenient place for every durable file under the sessions root. That shape should not be extended. The chosen direction for this ticket is to split the durable surfaces into separate crates/APIs: `session-store` remains the session/segment JSONL store, and a new `pod-store` crate owns Pod metadata, Pod-name validation, and the Pod metadata filesystem layout.
## Decisions
- Introduce a dedicated `pod-store` crate for durable Pod metadata/state.
- Move Pod metadata storage from `{sessions_root}/pods/{pod_name}/metadata.json` to a top-level Pod-state root such as `{data_dir}/pods/{pod_name}/metadata.json`.
- Do not provide backward compatibility or migration for the obsolete `{sessions_root}/pods` layout. Existing old-layout Pod metadata may be ignored/lost by this change.
- Redesign the Pod metadata API where needed instead of preserving awkward `session-store`-shaped APIs.
- Keep session logs as the authority for conversation/history replay and for Pod lifecycle notifications actually shown to the model.
- Remove `pod.scope` / effective-scope snapshots from the session-log authority. Parent effective scope during restore should be derived from `pod-store` delegation state, not from a duplicate session extension.
- Keep runtime mirrors such as sockets, lock-file allocations, and `spawned_pods.json` as live runtime views, not durable authority.
Pod metadata may point at a `(SessionId, SegmentId)`, but the session log store must not own Pod metadata types or the Pod metadata filesystem layout. If sharing ID types directly causes an undesirable dependency, introduce a small shared ID module/crate or otherwise keep the dependency narrow; do not let `pod-store` pull in session replay concerns just to name a session pointer.
Observed code points:
- `crates/session-store/src/lib.rs` documents session persistence via append-only JSONL logs, but also exports `pod_metadata` types.
- `crates/session-store/src/fs_store.rs` stores segment logs under `{root}/{session_id}/{segment_id}.jsonl` and Pod metadata under `{root}/pods/{pod_name}/metadata.json` in the same `FsStore`.
- `crates/session-store/src/pod_metadata.rs` says metadata is a lightweight name-keyed pointer, but `PodMetadata` also includes `spawned_children` and `resolved_manifest_snapshot`.
- `crates/pod/src/pod.rs` writes Pod metadata from run/restore/fork/compact paths (`write_pod_metadata_active`, `write_pod_metadata_pending`) and preserves existing `spawned_children` via a read-modify-write helper.
- `crates/pod/src/spawn/registry.rs` treats durable spawned-child state as living in Pod metadata and runtime `spawned_pods.json` as a live mirror, while scope snapshots for resume live in the session log.
- `crates/tui/src/pod_list.rs` reads `{store_dir}/pods/*/metadata.json` directly in some paths rather than using only the `PodMetadataStore` trait.
## Goal
Refactor the architectural boundary between session logs, Pod metadata/state, and runtime mirrors so the storage APIs, crate boundaries, and filesystem layout match their authority boundaries, without changing intended restore/attach semantics for newly written state.
## Desired boundary
The resulting design should make these responsibilities explicit:
- Session log authority:
- conversation history and system prompt replay;
- segment lineage (`forked_from`, `compacted_from`);
- request config / usage / metrics / memory extension records;
- Pod lifecycle notifications and restore/reclaim notices only when they are appended to history as information shown to the model;
- filesystem layout under the session log root, e.g. `{data_dir}/sessions/{session_id}/{segment_id}.jsonl` and associated trace logs.
- Pod metadata authority, owned by `pod-store`:
- Pod-name validation and safe filesystem key rules;
- name-keyed active `(SessionId, SegmentId)` pointer;
- pending/new Pod state if needed before a session segment is materialized;
- resolved manifest snapshot needed for Pod-name restore when the source profile/manifest should not be re-evaluated;
- spawned-child registry state, because it is current parent-Pod state rather than conversation history;
- delegated child scope records and delegation/reclaim history needed to derive parent effective scope during restore;
- restore reconciliation state sufficient to detect children that are missing, stopped, or unreachable and to reclaim their delegated scope before continuing;
- filesystem layout under a Pod-state root, e.g. `{data_dir}/pods/{pod_name}/metadata.json`, not below the session log root.
- Runtime mirrors:
- sockets, lock-file allocations, and `spawned_pods.json` are live runtime views, not durable authority;
- socket paths and callback addresses, if retained in durable metadata, must be documented as last-known runtime hints rather than proof of liveness.
## Acceptance criteria
- Create a new `pod-store` crate and move Pod metadata types/store traits/filesystem implementation into it.
- Remove `pod_metadata` exports and Pod metadata filesystem ownership from `session-store`; update `session-store` crate/module docs so it describes session/segment logs rather than Pod metadata.
- Move the durable Pod metadata layout out of `{sessions_root}/pods/{pod_name}/metadata.json` to a Pod-state root such as `{data_dir}/pods/{pod_name}/metadata.json`.
- Do not implement compatibility fallback or migration for `{sessions_root}/pods`; tests should assert the old path is not read or written as an authority.
- Redesign the Pod metadata API where useful. At minimum, avoid caller-side read-modify-write helpers that can silently drop unrelated fields; provide explicit update/merge operations or otherwise make field-preservation semantics safe and testable.
- Update construction/configuration paths so callers pass distinct roots or distinct store handles for session logs and Pod metadata; sharing the same higher-level data directory is allowed, but the session log store must not own the Pod metadata subdirectory.
- Update `pod`, `tui`, and other callers to depend on/use `pod-store` for Pod metadata instead of importing Pod metadata through `session-store` or reading metadata files directly.
- Remove direct filesystem reads of `pods/*/metadata.json` outside the `pod-store` abstraction, especially in TUI Pod list/discovery paths.
- Document the new boundary in code comments and/or crate/module docs, including why Pod metadata points to session IDs rather than being contained by the session store.
- Clarify the authority of `resolved_manifest_snapshot`: it belongs to Pod-name restore state in `pod-store`; session JSONL `SegmentStart` config/system prompt remain the authority for replaying an existing segment.
- Clarify the authority of `spawned_children`: it belongs to Pod-state/durable child-registry state in `pod-store`; child lifecycle messages shown to the model remain session JSONL history.
- Clarify delegated scope handling: delegated-scope records and delegation/reclaim history live in `pod-store`; parent effective scope during restore is derived from outstanding `pod-store` delegations. Remove the duplicate `pod.scope` session-log extension/typed restore state unless a narrower non-duplicating replacement is proven necessary.
- Add restore reconciliation behavior: when `pod-store` records a delegated child that is missing, stopped, or unreachable at restore time, reclaim the delegated scope in `pod-store`/runtime state and append a system notification to the session history before any model request observes the resumed state.
- Preserve intended durable behavior for newly written state:
- Pod-name restore resolves active metadata from `pod-store` then restores the session log from `session-store`;
- session restore uses session log conversation/history plus `pod-store` delegation state for Pod-scope reconciliation;
- runtime `spawned_pods.json` remains a mirror;
- stopped or unreachable child Pod metadata is not deleted merely because its socket is gone.
- Add focused tests for the split, including active pointer updates preserving spawned children / manifest snapshot, spawned-child updates preserving active pointer / manifest snapshot, and discovery/restore behavior when one durable surface exists without the other.
- Add or update tests that verify Pod metadata is read/written under the new Pod-state root and not under the session log root.
- Run focused validation for `session-store`, `pod-store`, `pod`, and `tui`, plus `./tickets.sh doctor` and `git diff --check`.
- Update any relevant docs or workflow notes if the persistence model changes.
## Non-goals
- Do not redesign the session-log schema unless the split proves it is necessary.
- Do not preserve backward compatibility for obsolete `{sessions_root}/pods` metadata, and do not implement a permanent fallback or migration path.
- Do not change live Pod registry lock semantics except where necessary to align with the clarified durable authority.
- Do not implement broader database storage or transactional storage in this ticket; if the boundary audit reveals a need for transactions, record it as a follow-up unless a minimal update API suffices.

View File

@ -0,0 +1,104 @@
---
id: 20260529-205844-session-pod-state-boundary
slug: session-pod-state-boundary
title: Split Pod metadata into a dedicated pod-store crate
status: closed
kind: task
priority: P2
labels: [session-store, pod-store, pod, persistence, architecture]
created_at: 2026-05-29T20:58:44Z
updated_at: 2026-05-30T00:10:45Z
assignee: null
legacy_ticket: null
---
## Background
The current persistence design intentionally has two durable surfaces:
- append-only session/segment logs, which are the authority for conversation/history state and segment lineage;
- name-keyed Pod metadata, which is the authority for Pod-name attach/restore pointers and durable spawned-child bookkeeping.
That boundary has become blurry. The `session-store` crate is named and documented primarily as session persistence, but it also owns Pod metadata types, the `PodMetadataStore` trait, validation of Pod names, and the filesystem layout `{sessions_root}/pods/{pod_name}/metadata.json`. In addition, Pod metadata currently stores `spawned_children` and `resolved_manifest_snapshot`, while session logs also store Pod scope snapshots as `LogEntry::Extension` entries. This creates a risk that session-log authority, Pod-state authority, and runtime mirrors drift or become hard to reason about.
This happened because earlier implementation work treated `session-store` as a convenient place for every durable file under the sessions root. That shape should not be extended. The chosen direction for this ticket is to split the durable surfaces into separate crates/APIs: `session-store` remains the session/segment JSONL store, and a new `pod-store` crate owns Pod metadata, Pod-name validation, and the Pod metadata filesystem layout.
## Decisions
- Introduce a dedicated `pod-store` crate for durable Pod metadata/state.
- Move Pod metadata storage from `{sessions_root}/pods/{pod_name}/metadata.json` to a top-level Pod-state root such as `{data_dir}/pods/{pod_name}/metadata.json`.
- Do not provide backward compatibility or migration for the obsolete `{sessions_root}/pods` layout. Existing old-layout Pod metadata may be ignored/lost by this change.
- Redesign the Pod metadata API where needed instead of preserving awkward `session-store`-shaped APIs.
- Keep session logs as the authority for conversation/history replay and for Pod lifecycle notifications actually shown to the model.
- Remove `pod.scope` / effective-scope snapshots from the session-log authority. Parent effective scope during restore should be derived from `pod-store` delegation state, not from a duplicate session extension.
- Keep runtime mirrors such as sockets, lock-file allocations, and `spawned_pods.json` as live runtime views, not durable authority.
Pod metadata may point at a `(SessionId, SegmentId)`, but the session log store must not own Pod metadata types or the Pod metadata filesystem layout. If sharing ID types directly causes an undesirable dependency, introduce a small shared ID module/crate or otherwise keep the dependency narrow; do not let `pod-store` pull in session replay concerns just to name a session pointer.
Observed code points:
- `crates/session-store/src/lib.rs` documents session persistence via append-only JSONL logs, but also exports `pod_metadata` types.
- `crates/session-store/src/fs_store.rs` stores segment logs under `{root}/{session_id}/{segment_id}.jsonl` and Pod metadata under `{root}/pods/{pod_name}/metadata.json` in the same `FsStore`.
- `crates/session-store/src/pod_metadata.rs` says metadata is a lightweight name-keyed pointer, but `PodMetadata` also includes `spawned_children` and `resolved_manifest_snapshot`.
- `crates/pod/src/pod.rs` writes Pod metadata from run/restore/fork/compact paths (`write_pod_metadata_active`, `write_pod_metadata_pending`) and preserves existing `spawned_children` via a read-modify-write helper.
- `crates/pod/src/spawn/registry.rs` treats durable spawned-child state as living in Pod metadata and runtime `spawned_pods.json` as a live mirror, while scope snapshots for resume live in the session log.
- `crates/tui/src/pod_list.rs` reads `{store_dir}/pods/*/metadata.json` directly in some paths rather than using only the `PodMetadataStore` trait.
## Goal
Refactor the architectural boundary between session logs, Pod metadata/state, and runtime mirrors so the storage APIs, crate boundaries, and filesystem layout match their authority boundaries, without changing intended restore/attach semantics for newly written state.
## Desired boundary
The resulting design should make these responsibilities explicit:
- Session log authority:
- conversation history and system prompt replay;
- segment lineage (`forked_from`, `compacted_from`);
- request config / usage / metrics / memory extension records;
- Pod lifecycle notifications and restore/reclaim notices only when they are appended to history as information shown to the model;
- filesystem layout under the session log root, e.g. `{data_dir}/sessions/{session_id}/{segment_id}.jsonl` and associated trace logs.
- Pod metadata authority, owned by `pod-store`:
- Pod-name validation and safe filesystem key rules;
- name-keyed active `(SessionId, SegmentId)` pointer;
- pending/new Pod state if needed before a session segment is materialized;
- resolved manifest snapshot needed for Pod-name restore when the source profile/manifest should not be re-evaluated;
- spawned-child registry state, because it is current parent-Pod state rather than conversation history;
- delegated child scope records and delegation/reclaim history needed to derive parent effective scope during restore;
- restore reconciliation state sufficient to detect children that are missing, stopped, or unreachable and to reclaim their delegated scope before continuing;
- filesystem layout under a Pod-state root, e.g. `{data_dir}/pods/{pod_name}/metadata.json`, not below the session log root.
- Runtime mirrors:
- sockets, lock-file allocations, and `spawned_pods.json` are live runtime views, not durable authority;
- socket paths and callback addresses, if retained in durable metadata, must be documented as last-known runtime hints rather than proof of liveness.
## Acceptance criteria
- Create a new `pod-store` crate and move Pod metadata types/store traits/filesystem implementation into it.
- Remove `pod_metadata` exports and Pod metadata filesystem ownership from `session-store`; update `session-store` crate/module docs so it describes session/segment logs rather than Pod metadata.
- Move the durable Pod metadata layout out of `{sessions_root}/pods/{pod_name}/metadata.json` to a Pod-state root such as `{data_dir}/pods/{pod_name}/metadata.json`.
- Do not implement compatibility fallback or migration for `{sessions_root}/pods`; tests should assert the old path is not read or written as an authority.
- Redesign the Pod metadata API where useful. At minimum, avoid caller-side read-modify-write helpers that can silently drop unrelated fields; provide explicit update/merge operations or otherwise make field-preservation semantics safe and testable.
- Update construction/configuration paths so callers pass distinct roots or distinct store handles for session logs and Pod metadata; sharing the same higher-level data directory is allowed, but the session log store must not own the Pod metadata subdirectory.
- Update `pod`, `tui`, and other callers to depend on/use `pod-store` for Pod metadata instead of importing Pod metadata through `session-store` or reading metadata files directly.
- Remove direct filesystem reads of `pods/*/metadata.json` outside the `pod-store` abstraction, especially in TUI Pod list/discovery paths.
- Document the new boundary in code comments and/or crate/module docs, including why Pod metadata points to session IDs rather than being contained by the session store.
- Clarify the authority of `resolved_manifest_snapshot`: it belongs to Pod-name restore state in `pod-store`; session JSONL `SegmentStart` config/system prompt remain the authority for replaying an existing segment.
- Clarify the authority of `spawned_children`: it belongs to Pod-state/durable child-registry state in `pod-store`; child lifecycle messages shown to the model remain session JSONL history.
- Clarify delegated scope handling: delegated-scope records and delegation/reclaim history live in `pod-store`; parent effective scope during restore is derived from outstanding `pod-store` delegations. Remove the duplicate `pod.scope` session-log extension/typed restore state unless a narrower non-duplicating replacement is proven necessary.
- Add restore reconciliation behavior: when `pod-store` records a delegated child that is missing, stopped, or unreachable at restore time, reclaim the delegated scope in `pod-store`/runtime state and append a system notification to the session history before any model request observes the resumed state.
- Preserve intended durable behavior for newly written state:
- Pod-name restore resolves active metadata from `pod-store` then restores the session log from `session-store`;
- session restore uses session log conversation/history plus `pod-store` delegation state for Pod-scope reconciliation;
- runtime `spawned_pods.json` remains a mirror;
- stopped or unreachable child Pod metadata is not deleted merely because its socket is gone.
- Add focused tests for the split, including active pointer updates preserving spawned children / manifest snapshot, spawned-child updates preserving active pointer / manifest snapshot, and discovery/restore behavior when one durable surface exists without the other.
- Add or update tests that verify Pod metadata is read/written under the new Pod-state root and not under the session log root.
- Run focused validation for `session-store`, `pod-store`, `pod`, and `tui`, plus `./tickets.sh doctor` and `git diff --check`.
- Update any relevant docs or workflow notes if the persistence model changes.
## Non-goals
- Do not redesign the session-log schema unless the split proves it is necessary.
- Do not preserve backward compatibility for obsolete `{sessions_root}/pods` metadata, and do not implement a permanent fallback or migration path.
- Do not change live Pod registry lock semantics except where necessary to align with the clarified durable authority.
- Do not implement broader database storage or transactional storage in this ticket; if the boundary audit reveals a need for transactions, record it as a follow-up unless a minimal update API suffices.

View File

@ -0,0 +1,143 @@
<!-- event: create author: tickets.sh at: 2026-05-29T20:58:44Z -->
## Created
Created by tickets.sh create.
---
<!-- event: review author: review-session-pod-state-boundary at: 2026-05-29T23:04:00Z -->
## External review
Initial review found blocking issues in restore reconciliation: missing child allocations left stale runtime deny entries, and reconciliation was not enforced at the public restore boundary. The coder fixed these in commit `d2e8087`; second review approved the implementation.
Artifacts:
- `artifacts/review.md`
- `artifacts/review-r2.md`
---
<!-- event: fix author: insomnia at: 2026-05-30T00:08:00Z -->
## Parent-side validation fix
After merging the approved implementation, post-merge validation failed on `cargo test -p pod --test controller_test empty_turn_pause_rolls_back_and_snapshot_does_not_restore_input`.
The parent took over the stopped/failed handoff and fixed the adjacent turn-control regression directly on main: cancellation received immediately after the controller accepts a run was being lost before the worker reached its first stream event wait, so empty turns could hang instead of rolling back. The fix preserves idle stale-cancel cleanup at the controller boundary and makes first-event waiting cancellation-aware.
While investigating the child Pod's `context_length_exceeded` ping failure, the parent also fixed provider terminal stream errors so `Event::Error` is not only a live TUI event: terminal provider errors now fail the worker turn and persist `RunErrored` instead of allowing an empty `RunCompleted::Finished`.
---
<!-- event: close author: hare at: 2026-05-30T00:10:45Z status: closed -->
## Closed
---
id: 20260529-205844-session-pod-state-boundary
slug: session-pod-state-boundary
title: Split Pod metadata into a dedicated pod-store crate
status: closed
kind: task
priority: P2
labels: [session-store, pod-store, pod, persistence, architecture]
created_at: 2026-05-29T20:58:44Z
updated_at: 2026-05-30T00:10:45Z
assignee: null
legacy_ticket: null
---
## Background
The current persistence design intentionally has two durable surfaces:
- append-only session/segment logs, which are the authority for conversation/history state and segment lineage;
- name-keyed Pod metadata, which is the authority for Pod-name attach/restore pointers and durable spawned-child bookkeeping.
That boundary has become blurry. The `session-store` crate is named and documented primarily as session persistence, but it also owns Pod metadata types, the `PodMetadataStore` trait, validation of Pod names, and the filesystem layout `{sessions_root}/pods/{pod_name}/metadata.json`. In addition, Pod metadata currently stores `spawned_children` and `resolved_manifest_snapshot`, while session logs also store Pod scope snapshots as `LogEntry::Extension` entries. This creates a risk that session-log authority, Pod-state authority, and runtime mirrors drift or become hard to reason about.
This happened because earlier implementation work treated `session-store` as a convenient place for every durable file under the sessions root. That shape should not be extended. The chosen direction for this ticket is to split the durable surfaces into separate crates/APIs: `session-store` remains the session/segment JSONL store, and a new `pod-store` crate owns Pod metadata, Pod-name validation, and the Pod metadata filesystem layout.
## Decisions
- Introduce a dedicated `pod-store` crate for durable Pod metadata/state.
- Move Pod metadata storage from `{sessions_root}/pods/{pod_name}/metadata.json` to a top-level Pod-state root such as `{data_dir}/pods/{pod_name}/metadata.json`.
- Do not provide backward compatibility or migration for the obsolete `{sessions_root}/pods` layout. Existing old-layout Pod metadata may be ignored/lost by this change.
- Redesign the Pod metadata API where needed instead of preserving awkward `session-store`-shaped APIs.
- Keep session logs as the authority for conversation/history replay and for Pod lifecycle notifications actually shown to the model.
- Remove `pod.scope` / effective-scope snapshots from the session-log authority. Parent effective scope during restore should be derived from `pod-store` delegation state, not from a duplicate session extension.
- Keep runtime mirrors such as sockets, lock-file allocations, and `spawned_pods.json` as live runtime views, not durable authority.
Pod metadata may point at a `(SessionId, SegmentId)`, but the session log store must not own Pod metadata types or the Pod metadata filesystem layout. If sharing ID types directly causes an undesirable dependency, introduce a small shared ID module/crate or otherwise keep the dependency narrow; do not let `pod-store` pull in session replay concerns just to name a session pointer.
Observed code points:
- `crates/session-store/src/lib.rs` documents session persistence via append-only JSONL logs, but also exports `pod_metadata` types.
- `crates/session-store/src/fs_store.rs` stores segment logs under `{root}/{session_id}/{segment_id}.jsonl` and Pod metadata under `{root}/pods/{pod_name}/metadata.json` in the same `FsStore`.
- `crates/session-store/src/pod_metadata.rs` says metadata is a lightweight name-keyed pointer, but `PodMetadata` also includes `spawned_children` and `resolved_manifest_snapshot`.
- `crates/pod/src/pod.rs` writes Pod metadata from run/restore/fork/compact paths (`write_pod_metadata_active`, `write_pod_metadata_pending`) and preserves existing `spawned_children` via a read-modify-write helper.
- `crates/pod/src/spawn/registry.rs` treats durable spawned-child state as living in Pod metadata and runtime `spawned_pods.json` as a live mirror, while scope snapshots for resume live in the session log.
- `crates/tui/src/pod_list.rs` reads `{store_dir}/pods/*/metadata.json` directly in some paths rather than using only the `PodMetadataStore` trait.
## Goal
Refactor the architectural boundary between session logs, Pod metadata/state, and runtime mirrors so the storage APIs, crate boundaries, and filesystem layout match their authority boundaries, without changing intended restore/attach semantics for newly written state.
## Desired boundary
The resulting design should make these responsibilities explicit:
- Session log authority:
- conversation history and system prompt replay;
- segment lineage (`forked_from`, `compacted_from`);
- request config / usage / metrics / memory extension records;
- Pod lifecycle notifications and restore/reclaim notices only when they are appended to history as information shown to the model;
- filesystem layout under the session log root, e.g. `{data_dir}/sessions/{session_id}/{segment_id}.jsonl` and associated trace logs.
- Pod metadata authority, owned by `pod-store`:
- Pod-name validation and safe filesystem key rules;
- name-keyed active `(SessionId, SegmentId)` pointer;
- pending/new Pod state if needed before a session segment is materialized;
- resolved manifest snapshot needed for Pod-name restore when the source profile/manifest should not be re-evaluated;
- spawned-child registry state, because it is current parent-Pod state rather than conversation history;
- delegated child scope records and delegation/reclaim history needed to derive parent effective scope during restore;
- restore reconciliation state sufficient to detect children that are missing, stopped, or unreachable and to reclaim their delegated scope before continuing;
- filesystem layout under a Pod-state root, e.g. `{data_dir}/pods/{pod_name}/metadata.json`, not below the session log root.
- Runtime mirrors:
- sockets, lock-file allocations, and `spawned_pods.json` are live runtime views, not durable authority;
- socket paths and callback addresses, if retained in durable metadata, must be documented as last-known runtime hints rather than proof of liveness.
## Acceptance criteria
- Create a new `pod-store` crate and move Pod metadata types/store traits/filesystem implementation into it.
- Remove `pod_metadata` exports and Pod metadata filesystem ownership from `session-store`; update `session-store` crate/module docs so it describes session/segment logs rather than Pod metadata.
- Move the durable Pod metadata layout out of `{sessions_root}/pods/{pod_name}/metadata.json` to a Pod-state root such as `{data_dir}/pods/{pod_name}/metadata.json`.
- Do not implement compatibility fallback or migration for `{sessions_root}/pods`; tests should assert the old path is not read or written as an authority.
- Redesign the Pod metadata API where useful. At minimum, avoid caller-side read-modify-write helpers that can silently drop unrelated fields; provide explicit update/merge operations or otherwise make field-preservation semantics safe and testable.
- Update construction/configuration paths so callers pass distinct roots or distinct store handles for session logs and Pod metadata; sharing the same higher-level data directory is allowed, but the session log store must not own the Pod metadata subdirectory.
- Update `pod`, `tui`, and other callers to depend on/use `pod-store` for Pod metadata instead of importing Pod metadata through `session-store` or reading metadata files directly.
- Remove direct filesystem reads of `pods/*/metadata.json` outside the `pod-store` abstraction, especially in TUI Pod list/discovery paths.
- Document the new boundary in code comments and/or crate/module docs, including why Pod metadata points to session IDs rather than being contained by the session store.
- Clarify the authority of `resolved_manifest_snapshot`: it belongs to Pod-name restore state in `pod-store`; session JSONL `SegmentStart` config/system prompt remain the authority for replaying an existing segment.
- Clarify the authority of `spawned_children`: it belongs to Pod-state/durable child-registry state in `pod-store`; child lifecycle messages shown to the model remain session JSONL history.
- Clarify delegated scope handling: delegated-scope records and delegation/reclaim history live in `pod-store`; parent effective scope during restore is derived from outstanding `pod-store` delegations. Remove the duplicate `pod.scope` session-log extension/typed restore state unless a narrower non-duplicating replacement is proven necessary.
- Add restore reconciliation behavior: when `pod-store` records a delegated child that is missing, stopped, or unreachable at restore time, reclaim the delegated scope in `pod-store`/runtime state and append a system notification to the session history before any model request observes the resumed state.
- Preserve intended durable behavior for newly written state:
- Pod-name restore resolves active metadata from `pod-store` then restores the session log from `session-store`;
- session restore uses session log conversation/history plus `pod-store` delegation state for Pod-scope reconciliation;
- runtime `spawned_pods.json` remains a mirror;
- stopped or unreachable child Pod metadata is not deleted merely because its socket is gone.
- Add focused tests for the split, including active pointer updates preserving spawned children / manifest snapshot, spawned-child updates preserving active pointer / manifest snapshot, and discovery/restore behavior when one durable surface exists without the other.
- Add or update tests that verify Pod metadata is read/written under the new Pod-state root and not under the session log root.
- Run focused validation for `session-store`, `pod-store`, `pod`, and `tui`, plus `./tickets.sh doctor` and `git diff --check`.
- Update any relevant docs or workflow notes if the persistence model changes.
## Non-goals
- Do not redesign the session-log schema unless the split proves it is necessary.
- Do not preserve backward compatibility for obsolete `{sessions_root}/pods` metadata, and do not implement a permanent fallback or migration path.
- Do not change live Pod registry lock semantics except where necessary to align with the clarified durable authority.
- Do not implement broader database storage or transactional storage in this ticket; if the boundary audit reveals a need for transactions, record it as a follow-up unless a minimal update API suffices.
---

View File

@ -0,0 +1,533 @@
# Semantic Nix profiles implementation plan
## 1. Intended model vs current drift
The original profile intent was: a selected profile expresses role/model/tool/context policy, then the resolver turns that policy into a concrete, validated runtime manifest snapshot. The current implementation drifted into asking profile authors to write `PodManifestConfig` in Nix.
Evidence from the closed `manifest-profiles` work:
- The motivating problem was that low-level manifest knobs such as compaction thresholds and pruning sizes are poor authoring UX; profiles should expose high-level intent and presets.
- The intended runtime boundary was still `selected profile + explicit startup inputs => deterministic resolved manifest/config snapshot => Pod runtime`.
- The Nix profile artifact was meant to be portable and standalone, but the design direction also said semantic presets should be preferred for hard-to-tune values.
Current drift:
- `resources/nix/profile-lib.nix` documents `mkProfile { manifest = mkManifest { ... }; }`; `mkManifest` is currently an identity function.
- `resources/nix/profiles/default.nix` is a manifest-shaped blob under `manifest = insomnia.mkManifest { ... }`.
- The builtin default contains `pod.name = "insomnia"`, which is instance identity, not profile policy.
- Reasoning effort appears as `worker.reasoning = "high"` instead of model/quality policy.
- Compaction values are copied as raw manifest constants (`threshold = 200000`, `request_threshold = 240000`, `worker_context_max_tokens = 100000`) instead of being derived from the selected model's effective context window.
- Builtin default resolution currently goes through `NixProfileResolver`, which shells out to `nix eval` even for the normal no-argument startup path.
The fix should introduce a typed semantic profile artifact and a manifestization step. Nix may remain an authoring language for user/project profiles, but `manifest` must become resolver output, not the public authoring API.
## 2. Current code map
### Profile discovery and selection
- `crates/manifest/src/profile.rs`
- `ProfileRegistrySource`: `Builtin | User | Project`.
- `ProfileSelector`: CLI/TUI selector model: explicit path, named source, or default.
- `ProfileSource`: provenance saved into resolved snapshots; currently path or registry path.
- `ProfileRegistryEntry`: discovered entry with source/name/path/description/default flag.
- `ProfileRegistry`: stores entries and one default; implements `default_entry`, `select`, `select_named`, builtin fallback default.
- `ProfileDiscovery::for_cwd()` wires builtin dir, user `profiles.toml`, nearest project `.insomnia/profiles.toml`.
- `discover_profile_dir()` registers every builtin `.nix` file or `profile.nix` directory.
- `load_profile_registry_file()` parses user/project registry TOML; it is selection metadata only, not runtime config.
- `ProfileSelector::parse_cli()` supports `default`, explicit paths, source-qualified selectors, and path-like compatibility.
### Nix eval and artifact parsing
- `crates/manifest/src/profile.rs`
- `NixProfileResolver` always uses `std::process::Command` to execute:
- `nix eval --json --file <absolute-profile-path>`.
- Missing binary returns `ProfileError::NixUnavailable` with a clear diagnostic.
- Nonzero status returns `ProfileError::NixFailed` with stderr.
- JSON stdout is parsed into `serde_json::Value` and passed to `resolve_profile_artifact()`.
- There is no embedded evaluator and no dependency on `rnix`, `nix-compat`, `tvix`, or similar crates in the workspace `Cargo.toml` files.
### Manifest-shaped artifact parsing / manifestization today
- `crates/manifest/src/profile.rs`
- `resolve_profile_artifact(source, base_dir, raw_artifact)` currently treats the evaluated artifact as already manifest-shaped.
- `ProfileEnvelope` only validates optional `profile.format == "insomnia.nix-profile.v1"`.
- `extract_manifest_value()` accepts:
- `{ profile = ..., manifest = { ... } }`,
- `{ profile = ..., config = { ... } }`,
- or a raw manifest object.
- It deserializes the extracted value directly as `PodManifestConfig`.
- It merges `PodManifestConfig::builtin_defaults()`, resolves paths, converts to `PodManifest`, attaches `ProfileManifestSnapshot`, and serializes `manifest_snapshot`.
### Runtime startup path
- `crates/pod/src/main.rs`
- `resolve_manifest()` defaults to `ProfileSelector::Default` when neither `--profile` nor `--manifest` is provided.
- `load_profile()` constructs `NixProfileResolver::new().with_workspace_base(cwd)` and calls `resolve()`.
- `--profile-pod-name` overwrites `resolved.manifest.pod.name` after profile resolution.
- `--manifest` remains a one-file compatibility/debug path.
- `load_spawn_config_json()` is an internal typed adopted-spawn path that deserializes `PodManifestConfig` directly.
- `crates/client/src/spawn.rs`
- `SpawnConfig.profile` is passed to `insomnia-pod --profile`.
- Fresh profile spawns pass `--profile-pod-name <pod_name>` so profile evaluation and pod-name restore semantics remain separate.
- `crates/tui/src/spawn.rs`, `crates/tui/src/main.rs`
- TUI uses profile discovery to populate/cycle the fresh-spawn profile field and passes the chosen selector through the client spawn path.
### Manifest and runtime config types
- `crates/manifest/src/config.rs`
- `PodManifestConfig`: partial manifest/cascade type.
- `PodMetaConfig.name`: currently required by final `TryFrom<PodManifestConfig> for PodManifest`.
- `WorkerManifestConfig.reasoning`: low-level worker request setting.
- `CompactionConfigPartial`: raw numeric compaction fields.
- `TryFrom<PodManifestConfig> for PodManifest` requires `pod.name` and `scope.allow`, fills defaults, validates absolute paths, and materializes `CompactionConfig`.
- `crates/manifest/src/lib.rs`
- `PodManifest`: concrete runtime contract; includes `pod`, `model`, `worker`, `scope`, optional `compaction`, `memory`, `web`, `skills`, and profile provenance.
- `CompactionConfig`: runtime numeric thresholds and budgets.
- `WorkerManifest.reasoning`: copied into `llm_worker::RequestConfig` by pod code.
### Model catalog and context-window plumbing
- `crates/manifest/src/model.rs`
- `ModelManifest`: data representation for `model.ref`, inline model fields, auth, capability, `context_window`, `max_context_window`.
- This crate intentionally does not resolve model refs today.
- `crates/provider/src/catalog.rs`
- Owns builtin provider/model catalog loading from `resources/providers/builtin.toml` and `resources/models/builtin.toml`.
- `resolve_model_manifest()` resolves `ModelManifest` into `ModelConfig` with effective `context_window`.
- Context window resolution order: manifest override > model catalog > provider default > `DEFAULT_CONTEXT_WINDOW`; then clamped by `max_context_window`.
- Builtin `codex-oauth/gpt-5.5` has `context_window = 1000000`, `max_context_window = 272000`, so effective context window is `272000`.
- `crates/provider/src/lib.rs`
- Builds live `LlmClient` from resolved `ModelConfig`.
- `crates/pod/src/controller.rs`
- `build_greeting()` calls `provider::catalog::resolve_model_manifest()` to report effective context window.
- `crates/pod/src/pod.rs`
- `apply_worker_manifest()` maps `WorkerManifest` into `RequestConfig`; `config.reasoning = wm.reasoning.clone()`.
- Compaction/memory worker model overrides are resolved at consumer boundary via `provider::build_client()`.
## 3. Proposed semantic profile schema / API shape
Introduce a new artifact format, e.g. `insomnia.semantic-profile.v1`, whose top-level shape is semantic and does not contain `manifest` or `config`.
Suggested JSON shape after Nix evaluation:
```json
{
"profile": {
"format": "insomnia.semantic-profile.v1",
"name": "default",
"description": "Bundled default Insomnia coding profile"
},
"policy": {
"role": "coder",
"model": {
"ref": "codex-oauth/gpt-5.5",
"quality": "high",
"reasoning": { "effort": "high" }
},
"scope": {
"workspace": { "permission": "write", "recursive": true }
},
"tools": {
"web": { "enabled": true, "search": { "provider": "brave", "api_key_env": "BRAVE_SEARCH_API_KEY" } }
},
"context": {
"compaction": {
"preset": "coding-long-context",
"threshold_ratio": 0.74,
"request_threshold_ratio": 0.88,
"worker_context_ratio": 0.37
}
},
"memory": {
"enabled": true,
"extract_threshold_ratio": 0.18,
"consolidation_threshold_files": 5,
"consolidation_threshold_bytes": 50000
},
"session": { "record_event_trace": true }
}
}
```
The exact field names can change during implementation, but the separation should be strict:
- `profile`: metadata and format only.
- `policy`: semantic profile policy.
- No `pod.name` in `policy`.
- No top-level `manifest` / `config` in semantic v1.
- No `mkManifest` in builtin examples.
- Any raw manifest escape hatch, if kept, should be a separate explicit compatibility/debug resolver path, not `mkProfile`'s normal output.
Suggested Rust types in `crates/manifest/src/profile.rs` or a new `crates/manifest/src/profile/semantic.rs`:
```rust
pub struct SemanticProfileArtifact {
pub profile: ProfileMetadata,
pub policy: SemanticProfilePolicy,
}
pub struct SemanticProfilePolicy {
pub role: Option<ProfileRole>,
pub model: SemanticModelPolicy,
pub scope: SemanticScopePolicy,
pub worker: SemanticWorkerPolicy,
pub tools: SemanticToolPolicy,
pub context: SemanticContextPolicy,
pub memory: Option<SemanticMemoryPolicy>,
pub session: Option<SemanticSessionPolicy>,
pub advanced: Option<AdvancedProfileOverrides>,
}
```
Minimum viable semantic fields for this ticket:
- `model.ref`: catalog ref such as `codex-oauth/gpt-5.5`.
- `model.auth`: optional `AuthRef` override or secret ref, preserving current secret-reference behavior.
- `model.quality`: optional named policy (`low|balanced|high|max`) used to derive default reasoning/output/context behavior.
- `model.reasoning`: either named effort (`low|medium|high|xhigh`) or budget ratio/tokens. It becomes `WorkerManifest.reasoning` only during manifestization.
- `scope.workspace`: common workspace permission policy that maps to `scope.allow target = <workspace-base>`.
- `tools.web`: semantic enablement plus provider-specific search config already supported by `WebConfig`.
- `context.compaction`: ratios/presets against the selected model's effective context window.
- `memory`: either disabled/omitted or semantic thresholds, preferably ratios where they are context-window dependent.
- `session.record_event_trace`: acceptable profile policy because it controls session behavior, not instance identity.
- `advanced.manifest_overrides`: optional, if needed, as an explicitly named escape hatch. Do not expose it in builtin profiles or docs as the normal path.
Suggested Nix library surface:
```nix
insomnia.mkProfile {
name = "default";
description = "Bundled default Insomnia coding profile";
role = "coder";
model = insomnia.models.codexOAuth.gpt55 // {
quality = "high";
reasoning = insomnia.reasoning.effort "high";
};
scope = insomnia.scopes.workspaceWrite;
context = insomnia.context.longCoding;
tools.web = insomnia.web.braveFromEnv "BRAVE_SEARCH_API_KEY";
memory = insomnia.memory.defaultLongContext;
session.recordEventTrace = true;
}
```
`resources/nix/profile-lib.nix` should output semantic JSON only. `mkManifest` should be removed from the public/builtin style. If compatibility is retained temporarily, name it something noisy such as `unsafeRawManifestProfile` and do not use it in builtin docs/tests.
## 4. Manifestization design
### New resolver boundary
Add an explicit manifestization function that receives both the semantic artifact and runtime inputs:
```rust
pub struct ProfileRuntimeInputs {
pub pod_name: String,
pub workspace_base: PathBuf,
}
pub fn manifestize_semantic_profile(
source: ProfileSource,
artifact: SemanticProfileArtifact,
inputs: &ProfileRuntimeInputs,
model_catalogs: &dyn ModelCatalogResolver,
) -> Result<ResolvedProfile, ProfileError>;
```
The resolver output remains a concrete `PodManifest` plus serialized `manifest_snapshot`; session restore should continue using the snapshot rather than re-evaluating the profile.
### Pod name / identity
- `pod.name` must be supplied by runtime inputs, not profile policy.
- `insomnia-pod` should pass the effective fresh pod name into profile resolution, not patch the name afterward.
- For CLI no-argument/`--pod` fresh startup, `cli.pod` or a generated/default instance name remains a startup input.
- For TUI spawn, `client::SpawnConfig.pod_name` becomes `ProfileRuntimeInputs.pod_name` through `--profile-pod-name`.
- Builtin profile artifacts should not contain any `pod` section.
- In resolved manifest snapshots, `pod.name` is present because snapshots are runtime artifacts and are used for restore.
Implementation detail: `TryFrom<PodManifestConfig> for PodManifest` can keep requiring `pod.name`; manifestization should populate `PodManifestConfig.pod.name = Some(inputs.pod_name.clone())` before validation.
### Model and reasoning policy
- Semantic `model.ref` maps to `PodManifestConfig.model.ref_`.
- Auth overrides map to `PodManifestConfig.model.auth`.
- Manifestization resolves the model ref against the model catalog once to obtain effective context window and capability.
- `model.reasoning` / `model.quality` maps to `WorkerManifestConfig.reasoning`:
- If explicit effort is given, produce `ReasoningControl::Effort(...)`.
- If budget ratio is given and model capability supports token budgets, compute budget tokens from context window.
- If no explicit reasoning is given, derive from `quality` and model capability; e.g. high quality on effort-capable models -> `high`, high quality on budget-capable models -> a conservative token budget or leave unset until policy is defined.
- Provider-specific request serialization remains in `llm-worker`; the profile resolver should only produce the existing provider-neutral `ReasoningControl`.
Important dependency issue: `crates/manifest` currently cannot call `provider::catalog` because `provider` depends on `manifest`. To derive compaction inside the profile resolver, move the data-only catalog loading/resolution out of `provider` into `manifest` or a small new crate.
Recommended short-term refactor:
- Move `crates/provider/src/catalog.rs` into `crates/manifest/src/model_catalog.rs` or a new `crates/model-catalog` crate.
- Re-export `ModelConfig`, provider/model entries, `resolve_model_manifest`, and `DEFAULT_CONTEXT_WINDOW` from the new location.
- Update `provider` and `pod::controller::build_greeting()` to use the new location.
- Keep live client construction and auth dereferencing in `provider`.
### Context window and compaction policy
Semantic compaction should be derived from the selected model's effective context window.
Suggested policy type:
```rust
pub struct SemanticCompactionPolicy {
pub enabled: bool,
pub preset: Option<CompactionPreset>,
pub threshold_ratio: Option<f64>,
pub request_threshold_ratio: Option<f64>,
pub worker_context_ratio: Option<f64>,
pub retained_tokens: Option<u64>,
pub final_reserve_ratio: Option<f64>,
pub overview_preset: Option<OverviewPreset>,
pub model: Option<SemanticModelPolicy>,
}
```
Manifestization algorithm:
1. Resolve main model to effective `context_window`.
2. Expand preset defaults into ratios and fixed defaults.
3. Compute raw thresholds:
- `threshold = floor(context_window * threshold_ratio)`.
- `request_threshold = floor(context_window * request_threshold_ratio)`.
- `worker_context_max_tokens = floor(context_window * worker_context_ratio)`.
4. Clamp outputs to safe ranges:
- Ensure `threshold < request_threshold` when both are present; otherwise return a profile validation error instead of silently emitting suspicious config.
- Ensure `request_threshold < context_window` by reserving at least `final_reserve_tokens` or a minimum fixed reserve.
- Keep worker context below the main context window and above a minimum useful budget.
5. Fill `CompactionConfigPartial` with computed numeric fields and existing default-backed fields where appropriate.
6. If `compaction.model` is specified semantically, resolve it separately and derive any compactor-specific worker context budget from that model, not from the main model.
For the current builtin default intent using `codex-oauth/gpt-5.5`, effective context window is `272000`. Ratios close to the existing behavior would be approximately:
- proactive threshold: `200000 / 272000 ~= 0.735`.
- request threshold: `240000 / 272000 ~= 0.882`.
- worker context max: `100000 / 272000 ~= 0.368`.
Those should become preset values (e.g. `longCoding`) rather than magic constants in the Nix profile.
### Scope, tools, memory, session
- `scope.workspace` maps to a `ScopeRule` using `inputs.workspace_base`, not the profile file directory for builtin profiles.
- User/project profile relative paths still resolve against the profile file directory for explicit path fields, but semantic workspace scope should be explicit about using the launch workspace.
- `tools.web` can map directly to existing `WebConfig` because its current fields are already policy-like enough for the minimum implementation.
- `memory.extract_threshold` should either remain explicit for now or gain `extract_threshold_ratio`. If ratio exists, derive from the same effective context window.
- `session.record_event_trace` maps to existing `SessionConfigPartial`.
### Snapshot/provenance
- `ResolvedProfile.manifest_snapshot` remains the validated runtime snapshot.
- Consider replacing or narrowing `raw_artifact` retention for semantic profiles. It can remain for diagnostics in tests, but avoid logging or persisting raw Nix output beyond the validated snapshot.
- `ProfileManifestSnapshot` should continue recording source and profile metadata. For builtin semantic profiles, allow a source that does not imply a Nix path if builtin runtime no longer evals Nix.
## 5. Nix evaluator boundary
### Recommended short-term behavior
Builtin/default profiles should not require the external `nix` command during normal runtime.
Implement this by splitting profile evaluation into source-specific paths:
- Builtin profiles:
- Discovered as builtin profile names for UI/selection.
- Resolved from an in-process semantic definition, not by `nix eval`.
- `resources/nix/profiles/default.nix` can remain as the Nix authoring example/smoke artifact, but normal `ProfileSelector::Default` / `builtin:default` should not invoke it.
- Alternatively, if avoiding duplication is important, generate a checked-in static semantic JSON/TOML artifact from the Nix file at build/release time; runtime should still load the checked-in artifact directly.
- User/project explicit Nix profiles:
- Continue using external `nix eval --json --file <path>` for now.
- Keep diagnostics clear: selecting a Nix-authored user/project profile requires the `nix` command unless/until an embedded evaluator is implemented.
- Add a timeout around `nix eval` as a robustness follow-up or in this ticket if low-risk.
- Explicit non-Nix resolved semantic artifacts:
- Consider supporting `.json` / `.toml` semantic artifacts as test/debug inputs that bypass Nix entirely. This is useful for tests and for users without Nix.
### Embedded evaluator feasibility notes
Current local code has no embedded Nix evaluator dependency. Adding one is not a small drop-in change because the profile examples rely on imports and Nix language evaluation, not just parsing.
Practical options:
- `rnix`-style parser only: not sufficient; it parses Nix syntax but does not evaluate imports/functions/attribute merges.
- `nix-compat` / `tvix` ecosystem: may provide evaluation building blocks, but integrating an evaluator, file imports, builtins policy, purity constraints, and JSON conversion is a separate design task.
- Custom evaluator for a tiny subset: risky unless the supported subset is extremely small; it would create a second Nix-like language and likely fail on normal Nix idioms.
Recommendation: do not attempt embedded Nix evaluation in this ticket. Isolate external evaluation to user/project Nix-authored profiles, make builtin defaults in-process, and document the boundary.
## 6. Step-by-step implementation phases
### Phase 1: Extract model catalog resolution for manifestization
Likely changed files:
- `crates/provider/src/catalog.rs`
- `crates/provider/src/lib.rs`
- `crates/manifest/src/lib.rs`
- `crates/manifest/src/model.rs` or new `crates/manifest/src/model_catalog.rs`
- `crates/pod/src/controller.rs`
Tasks:
1. Move data-only provider/model catalog types and `resolve_model_manifest` into `manifest` or a new no-cycle crate.
2. Keep provider live client construction in `provider`.
3. Update imports in `provider` and `pod`.
4. Preserve current catalog behavior and tests, including context-window clamping.
### Phase 2: Add semantic artifact/types and manifestization
Likely changed files:
- `crates/manifest/src/profile.rs` or new `crates/manifest/src/profile/semantic.rs`
- `crates/manifest/src/config.rs`
- `crates/manifest/src/lib.rs`
Tasks:
1. Add `SEMANTIC_PROFILE_FORMAT_V1 = "insomnia.semantic-profile.v1"`.
2. Add typed semantic policy structs with serde support.
3. Add `ProfileRuntimeInputs`.
4. Add `manifestize_semantic_profile()`:
- fill `pod.name` from runtime inputs;
- map semantic model/ref/auth to `ModelManifest`;
- resolve model context window;
- derive reasoning;
- derive compaction thresholds from ratios/presets;
- map scope/tools/memory/session;
- merge `PodManifestConfig::builtin_defaults()` and validate into `PodManifest`;
- attach profile provenance and serialize snapshot.
5. Make semantic format reject top-level `manifest`/`config`.
6. Keep old v1 manifest-shaped resolver only as an explicit compatibility path if necessary; do not use it for builtin defaults or docs.
### Phase 3: Split builtin profile resolution from external Nix eval
Likely changed files:
- `crates/manifest/src/profile.rs`
- `crates/manifest/src/paths.rs`
- `crates/pod/src/main.rs`
- `crates/tui/src/spawn.rs` if entry metadata needs adjustment
Tasks:
1. Add a builtin profile source representation that can resolve without a path, or mark builtin registry entries with an internal resolver kind.
2. Change `ProfileDiscovery` so builtin `default` is still listed/selectable but does not imply `nix eval`.
3. Add `BuiltinProfileResolver` or `ProfileResolver` enum/trait:
- builtin semantic definitions -> in-process artifact -> manifestization;
- user/project/path `.nix` -> `NixProfileResolver` -> semantic artifact -> manifestization;
- optional `.json`/`.toml` semantic artifact -> parse -> manifestization.
4. Update `load_profile()` to pass the pod name and workspace base into resolution instead of overwriting `manifest.pod.name` afterward.
5. Ensure no-argument `insomnia-pod` and `builtin:default` do not spawn `nix`.
### Phase 4: Replace Nix authoring library and builtin default shape
Likely changed files:
- `resources/nix/profile-lib.nix`
- `resources/nix/profiles/default.nix`
- `docs/manifest-profiles.md`
- `docs/architecture.md`
- `docs/nix.md`
- `docs/pod-factory.md`
Tasks:
1. Rewrite `profile-lib.nix` so `mkProfile` emits `{ profile = { format = "insomnia.semantic-profile.v1"; ... }; policy = ...; }`.
2. Remove `mkManifest` from builtin examples and docs.
3. Provide semantic helper namespaces for model, reasoning, context/compaction, scopes, web, memory, secrets.
4. Rewrite builtin `default.nix` semantically and remove `pod.name`.
5. Update docs to describe semantic profiles and the evaluator boundary.
### Phase 5: Tighten compatibility and diagnostics
Likely changed files:
- `crates/manifest/src/profile.rs`
- `crates/pod/src/main.rs`
- profile-related tests
Tasks:
1. Decide whether old `insomnia.nix-profile.v1` manifest-shaped artifacts are rejected, accepted only via a compatibility flag, or accepted with a deprecation diagnostic.
2. Given the ticket's direction, prefer not preserving awkward authoring compatibility unless the parent explicitly wants a transition period.
3. Improve errors:
- semantic profile contains `pod.name` -> explain pod identity belongs to startup input;
- semantic profile contains raw compaction constants in the wrong place -> explain ratio/preset fields;
- missing `nix` for user/project `.nix` profile -> current diagnostic plus source selector/path;
- builtin profile resolution should never mention missing `nix`.
### Phase 6: Optional robustness follow-ups if still in scope
- Add timeout to external `nix eval`.
- Avoid retaining `ResolvedProfile::raw_artifact` outside debug/test APIs.
- Add explicit `insomnia-pod --profile-artifact <path.json>` if JSON semantic artifacts prove useful.
## 7. Tests and validation commands
### Tests to add/update
`crates/manifest/src/profile.rs` / semantic module:
- Builtin semantic default manifestizes without `pod.name` in the source artifact and with runtime input `pod_name` in the final `PodManifest`.
- Semantic `model.ref = "codex-oauth/gpt-5.5"` uses catalog-clamped context window `272000`.
- Semantic compaction preset/ratios derive expected thresholds from context window.
- Reasoning effort policy maps to `WorkerManifest.reasoning`.
- Semantic artifact containing top-level `manifest`/`config` or semantic `pod.name` is rejected.
- User/project `.nix` missing binary still returns `NixUnavailable`.
- Builtin default resolution with a missing/fake `nix_bin` still succeeds because builtin does not eval Nix.
- Source-qualified/default/ambiguous discovery behavior remains unchanged for user/project entries.
Model catalog tests:
- Moved catalog tests continue to prove provider/model loading, context-window override, clamp, and unknown provider behavior.
- Add a manifestization test using injected in-memory catalogs if the model catalog resolver is trait-based.
Pod/client/TUI tests:
- `insomnia-pod` no-argument default startup path uses profile default and does not require `nix` for builtin.
- `--profile-pod-name` is passed as runtime input, not a post-resolution patch.
- TUI profile picker still lists `builtin:default` and user/project profiles.
- Existing profile selection tests continue to pass.
Docs/Nix smoke:
- A manual or ignored test can run `nix eval --json --file resources/nix/profiles/default.nix` and assert it emits `insomnia.semantic-profile.v1` with `policy`, not `manifest`.
### Focused validation commands
Run after implementation:
```sh
cargo fmt --check
cargo test -p manifest profile -- --nocapture
cargo test -p manifest model -- --nocapture
cargo test -p provider catalog -- --nocapture
cargo test -p pod --bin insomnia-pod profile -- --nocapture
cargo test -p client spawn -- --nocapture
cargo test -p tui spawn -- --nocapture
cargo check -p session-store -p manifest -p provider -p pod -p client -p tui
nix eval --json --file resources/nix/profiles/default.nix
./tickets.sh doctor
git diff --check
```
If the catalog module moves out of `provider`, adjust package/test filters accordingly.
## 8. Risks and open questions
- **Where to house model catalog resolution:** manifestization needs effective context windows, but current catalog resolution lives in `provider`, which depends on `manifest`. The clean short-term answer is to move data-only catalog resolution into `manifest` or a new crate. Parent should choose whether a new crate is worth it; implementation-wise, moving into `manifest` is smaller.
- **Exact semantic field names:** the schema above is intentionally concrete but not final. The important decision is the boundary: semantic `policy` in, `PodManifestConfig` out.
- **Old manifest-shaped Nix artifacts:** preserving them will keep abstraction leaks alive. Recommendation is to reject them for the new semantic format and keep direct `--manifest` as the low-level escape hatch. If migration is required, make it explicit and temporary.
- **Builtin source provenance:** if builtin profiles no longer resolve from a Nix path, `ProfileSource::Registry { path }` may need to become `ProfileSource::Builtin { name }` or make `path` optional. This is a small schema migration for future snapshots.
- **Reasoning defaults by quality:** `quality = "high"` should not blindly force reasoning for every model. It should consult model capability and either choose an effort/budget policy or leave reasoning unset when unsupported.
- **Compaction ratio defaults:** exact preset ratios need product judgment. The current builtin constants imply ratios around 0.735/0.882/0.368 for `gpt-5.5`; using those as the first `longCoding` preset preserves behavior while moving the authoring surface to semantics.
- **External Nix timeout:** current `Command::output()` can hang indefinitely. This is already a known follow-up; include it in this ticket if touching resolver orchestration is easy, otherwise track separately.
- **Docs currently teach the wrong model:** `docs/manifest-profiles.md`, `docs/pod-factory.md`, `docs/architecture.md`, and `docs/nix.md` should be updated in the same implementation so future work does not copy the manifest-shaped API.

View File

@ -0,0 +1,78 @@
---
id: 20260529-222850-semantic-nix-profiles
slug: semantic-nix-profiles
title: Make Nix profiles semantic and manifestize them in the resolver
status: closed
kind: task
priority: P2
labels: [manifest, profiles, nix, architecture]
created_at: 2026-05-29T22:28:50Z
updated_at: 2026-05-30T03:52:39Z
assignee: null
legacy_ticket: null
---
## Background
The closed manifest profiles work item (`work-items/closed/20260527-000022-manifest-profiles`) introduced profile discovery/selection, built-in profile files, and Nix evaluation as the profile artifact path. Its original direction described profiles as role/model/policy selections that resolve into a precise runtime manifest. The current built-in Nix shape has drifted from that intent: it effectively asks authors to write a Manifest-shaped object in Nix.
Current examples include:
- `resources/nix/profile-lib.nix` exposes `mkProfile` and a `mkManifest` helper, where `mkManifest` is currently an identity function.
- `resources/nix/profiles/default.nix` uses `insomnia.mkProfile { manifest = insomnia.mkManifest { ... }; }`.
- The built-in profile currently contains low-level or instance-specific Manifest fields such as `pod.name = "insomnia"`.
- Model policy fields such as high reasoning effort and compaction thresholds are represented as low-level manifest settings rather than being derived from a profile's model/context policy.
- Profile evaluation currently shells out to `nix eval` for Nix artifacts, so the boundary between profile language support and a runtime dependency on the `nix` command is unclear.
The issue is not that profile artifacts are eventually serialized as manifests. The issue is that the authoring API currently exposes the output shape as if the profile itself were a manifest. Profiles should be semantic, and the resolver should perform the manifestization step.
## Goal
Redesign the Nix profile authoring surface so profiles express role/model/tool/context policy at the appropriate level of abstraction, and the manifest resolver turns that semantic profile into the concrete runtime manifest/config used to start a Pod.
This should restore the intended boundary:
- Profile: selectable role/policy/model/context/tool behavior.
- Resolver/manifestization: derives concrete defaults and token thresholds from model metadata and profile policy.
- Runtime manifest/config: low-level concrete values consumed by Pod startup.
## Desired direction
- Remove `mkManifest` from the public/built-in authoring style. A profile should not look like `mkProfile { manifest = mkManifest { ... }; }`.
- Built-in profiles should be written as semantic profiles, not Manifest-shaped blobs.
- Instance-specific values such as `pod.name` must not live in built-in profiles. Pod names come from CLI/TUI/SpawnPod creation, not profile identity.
- Model behavior such as reasoning effort belongs to the profile's model/quality policy and should be manifestized into provider-specific request config by the resolver.
- Compaction and context budgeting should be derived from the selected model's context window and profile policy, e.g. ratios/multipliers/strategy names, rather than hard-coded token thresholds copied into the profile.
- Nix evaluation should be treated as a profile authoring/evaluation mechanism, not as a requirement that the runtime default path always depends on an external `nix` command.
## Acceptance criteria
- Audit the original manifest profiles work item and current implementation to identify where the implementation became Manifest-shaped instead of semantic-profile-shaped.
- Replace the built-in Nix profile shape with a semantic profile API. The exact field names may be redesigned, but they must clearly distinguish profile policy from runtime manifest output.
- Remove `pod.name` and any other instance-specific Pod identity/configuration from built-in profiles.
- Remove `mkManifest` from built-in examples and docs. If a compatibility helper remains internally, it must not be the recommended authoring API and must be justified.
- Implement or adjust the resolver so semantic profile data is manifestized into the concrete runtime manifest/config.
- Ensure model-derived settings are resolved in one place. In particular, reasoning effort and context/compaction policy should be interpreted as profile/model policy and converted into concrete request/compaction settings by the resolver.
- Add or update model catalog/context-window plumbing as needed so compaction thresholds can be derived from the selected model rather than duplicated as raw profile constants.
- Decide and document the Nix evaluator boundary:
- built-in/default profiles must not require the external `nix` command at normal runtime unless that dependency is deliberately accepted and documented;
- user/project Nix profiles may use `nix eval` if no lightweight in-process evaluator is chosen, but diagnostics must clearly state that Nix is required for Nix-authored profiles;
- do not silently make all profile selection depend on a missing or slow `nix` binary.
- Investigate whether a lightweight embedded evaluator is practical enough for the supported Nix subset. If it is not, document why and keep the external-command dependency isolated to Nix-authored profile evaluation.
- Preserve previous profile selection semantics where still relevant: built-in/user/project profile discovery, default profile selection, source-qualified selectors, ambiguity errors, and manifest cascade removal.
- Update docs/tests so the examples present profiles as semantic policy, not manifests written in Nix syntax.
- Add tests covering:
- built-in profile manifestization without `pod.name`;
- semantic compaction policy derived from model context window;
- reasoning/model policy manifestization;
- missing `nix` command behavior for user/project Nix profiles, if external evaluation remains;
- no regression in profile discovery/default/source-qualified selection.
- Run focused validation for the manifest crate and any affected pod/tui/client paths, plus `./tickets.sh doctor` and `git diff --check`.
## Non-goals
- Do not reintroduce manifest cascade or manifest overlay selection as a replacement for profiles.
- Do not turn Nix profiles into a general low-level manifest DSL.
- Do not add profile alias registry complexity back unless a separate ticket re-justifies it.
- Do not implement encrypted secret storage here; keep it in the existing encrypted secrets work item.
- Do not require solving every possible provider-specific model option in this ticket; implement the minimum necessary semantic-to-runtime mapping and leave explicit follow-ups for broader model policy coverage.

View File

@ -0,0 +1,78 @@
---
id: 20260529-222850-semantic-nix-profiles
slug: semantic-nix-profiles
title: Make Nix profiles semantic and manifestize them in the resolver
status: closed
kind: task
priority: P2
labels: [manifest, profiles, nix, architecture]
created_at: 2026-05-29T22:28:50Z
updated_at: 2026-05-30T03:52:39Z
assignee: null
legacy_ticket: null
---
## Background
The closed manifest profiles work item (`work-items/closed/20260527-000022-manifest-profiles`) introduced profile discovery/selection, built-in profile files, and Nix evaluation as the profile artifact path. Its original direction described profiles as role/model/policy selections that resolve into a precise runtime manifest. The current built-in Nix shape has drifted from that intent: it effectively asks authors to write a Manifest-shaped object in Nix.
Current examples include:
- `resources/nix/profile-lib.nix` exposes `mkProfile` and a `mkManifest` helper, where `mkManifest` is currently an identity function.
- `resources/nix/profiles/default.nix` uses `insomnia.mkProfile { manifest = insomnia.mkManifest { ... }; }`.
- The built-in profile currently contains low-level or instance-specific Manifest fields such as `pod.name = "insomnia"`.
- Model policy fields such as high reasoning effort and compaction thresholds are represented as low-level manifest settings rather than being derived from a profile's model/context policy.
- Profile evaluation currently shells out to `nix eval` for Nix artifacts, so the boundary between profile language support and a runtime dependency on the `nix` command is unclear.
The issue is not that profile artifacts are eventually serialized as manifests. The issue is that the authoring API currently exposes the output shape as if the profile itself were a manifest. Profiles should be semantic, and the resolver should perform the manifestization step.
## Goal
Redesign the Nix profile authoring surface so profiles express role/model/tool/context policy at the appropriate level of abstraction, and the manifest resolver turns that semantic profile into the concrete runtime manifest/config used to start a Pod.
This should restore the intended boundary:
- Profile: selectable role/policy/model/context/tool behavior.
- Resolver/manifestization: derives concrete defaults and token thresholds from model metadata and profile policy.
- Runtime manifest/config: low-level concrete values consumed by Pod startup.
## Desired direction
- Remove `mkManifest` from the public/built-in authoring style. A profile should not look like `mkProfile { manifest = mkManifest { ... }; }`.
- Built-in profiles should be written as semantic profiles, not Manifest-shaped blobs.
- Instance-specific values such as `pod.name` must not live in built-in profiles. Pod names come from CLI/TUI/SpawnPod creation, not profile identity.
- Model behavior such as reasoning effort belongs to the profile's model/quality policy and should be manifestized into provider-specific request config by the resolver.
- Compaction and context budgeting should be derived from the selected model's context window and profile policy, e.g. ratios/multipliers/strategy names, rather than hard-coded token thresholds copied into the profile.
- Nix evaluation should be treated as a profile authoring/evaluation mechanism, not as a requirement that the runtime default path always depends on an external `nix` command.
## Acceptance criteria
- Audit the original manifest profiles work item and current implementation to identify where the implementation became Manifest-shaped instead of semantic-profile-shaped.
- Replace the built-in Nix profile shape with a semantic profile API. The exact field names may be redesigned, but they must clearly distinguish profile policy from runtime manifest output.
- Remove `pod.name` and any other instance-specific Pod identity/configuration from built-in profiles.
- Remove `mkManifest` from built-in examples and docs. If a compatibility helper remains internally, it must not be the recommended authoring API and must be justified.
- Implement or adjust the resolver so semantic profile data is manifestized into the concrete runtime manifest/config.
- Ensure model-derived settings are resolved in one place. In particular, reasoning effort and context/compaction policy should be interpreted as profile/model policy and converted into concrete request/compaction settings by the resolver.
- Add or update model catalog/context-window plumbing as needed so compaction thresholds can be derived from the selected model rather than duplicated as raw profile constants.
- Decide and document the Nix evaluator boundary:
- built-in/default profiles must not require the external `nix` command at normal runtime unless that dependency is deliberately accepted and documented;
- user/project Nix profiles may use `nix eval` if no lightweight in-process evaluator is chosen, but diagnostics must clearly state that Nix is required for Nix-authored profiles;
- do not silently make all profile selection depend on a missing or slow `nix` binary.
- Investigate whether a lightweight embedded evaluator is practical enough for the supported Nix subset. If it is not, document why and keep the external-command dependency isolated to Nix-authored profile evaluation.
- Preserve previous profile selection semantics where still relevant: built-in/user/project profile discovery, default profile selection, source-qualified selectors, ambiguity errors, and manifest cascade removal.
- Update docs/tests so the examples present profiles as semantic policy, not manifests written in Nix syntax.
- Add tests covering:
- built-in profile manifestization without `pod.name`;
- semantic compaction policy derived from model context window;
- reasoning/model policy manifestization;
- missing `nix` command behavior for user/project Nix profiles, if external evaluation remains;
- no regression in profile discovery/default/source-qualified selection.
- Run focused validation for the manifest crate and any affected pod/tui/client paths, plus `./tickets.sh doctor` and `git diff --check`.
## Non-goals
- Do not reintroduce manifest cascade or manifest overlay selection as a replacement for profiles.
- Do not turn Nix profiles into a general low-level manifest DSL.
- Do not add profile alias registry complexity back unless a separate ticket re-justifies it.
- Do not implement encrypted secret storage here; keep it in the existing encrypted secrets work item.
- Do not require solving every possible provider-specific model option in this ticket; implement the minimum necessary semantic-to-runtime mapping and leave explicit follow-ups for broader model policy coverage.

View File

@ -0,0 +1,129 @@
<!-- event: create author: hare at: 2026-05-29T22:28:50Z -->
Created as a follow-up to the closed manifest profiles work item after reviewing the original intent and the current built-in Nix profile shape.
---
<!-- event: plan author: planning-pod at: 2026-05-29T22:36:45Z -->
Implementation plan written to `artifacts/implementation-plan.md`. Key recommendation: introduce a typed semantic profile artifact and manifestization step, move/centralize model catalog context-window resolution so compaction can derive from model metadata, and resolve builtin profiles in-process so normal default startup does not require external `nix`.
<!-- event: plan author: hare at: 2026-05-30T00:45:10Z -->
## Plan
## Implementation direction
Use the worktree + sibling coder/reviewer flow. The investigation Pod found the main boundary issue to be concentrated in `crates/manifest/src/profile.rs`: profile artifacts are currently deserialized as `PodManifestConfig`, while built-in Nix files expose a manifest-shaped authoring API.
Decisions for the implementation pass:
- Move data-only model catalog resolution to a cycle-free place owned by `manifest` so profile manifestization can derive context-window-dependent settings. Keep live provider/client construction in `provider`.
- Introduce a semantic profile artifact format such as `insomnia.semantic-profile.v1`; top-level semantic artifacts must use `profile` metadata plus `policy`, not `manifest` / `config`.
- Resolve built-in `default` in-process so the normal default startup path does not require an external `nix` command. User/project/path Nix profiles may still require `nix eval` with clear diagnostics.
- Remove `pod.name` from built-in profiles. Direct no-arg `insomnia-pod` should preserve the current effective default Pod name `insomnia` as runtime input, while `--profile-pod-name` remains the explicit fresh-spawn override.
- Do not preserve manifest-shaped Nix profiles as the normal authoring API. `--manifest` remains the low-level concrete manifest escape hatch.
- Keep the initial semantic policy narrow: explicit model ref, explicit reasoning effort, workspace scope policy, compaction ratios/preset derived from effective context window, memory/session/web policy sufficient to reproduce current builtin behavior.
- Update docs and tests so built-in examples no longer recommend `mkManifest` or raw manifest-shaped profiles.
Implementation should remain narrow: preserve existing profile selection semantics, source-qualified selectors, ambiguity errors, TUI/client profile selection flow, and `SpawnPod.scope` authority.
---
<!-- event: decision author: hare at: 2026-05-30T01:39:47Z -->
## Decision
Implementation is paused after design discussion. The attempted direction of introducing a semantic JSON-profile-to-Manifest projection is not accepted as the specification: it risks preserving nearly the same information as Manifest and therefore failing the intended abstraction.
The current design question has been split into `profile-authoring-requirements-sync`, which records the shared requirements and open questions before choosing Lua/external Nix/another authoring language. Do not proceed with the existing child-worktree implementation until that specification is decided.
---
<!-- event: close author: hare at: 2026-05-30T03:52:39Z status: closed -->
## Closed
---
id: 20260529-222850-semantic-nix-profiles
slug: semantic-nix-profiles
title: Make Nix profiles semantic and manifestize them in the resolver
status: closed
kind: task
priority: P2
labels: [manifest, profiles, nix, architecture]
created_at: 2026-05-29T22:28:50Z
updated_at: 2026-05-30T03:52:39Z
assignee: null
legacy_ticket: null
---
## Background
The closed manifest profiles work item (`work-items/closed/20260527-000022-manifest-profiles`) introduced profile discovery/selection, built-in profile files, and Nix evaluation as the profile artifact path. Its original direction described profiles as role/model/policy selections that resolve into a precise runtime manifest. The current built-in Nix shape has drifted from that intent: it effectively asks authors to write a Manifest-shaped object in Nix.
Current examples include:
- `resources/nix/profile-lib.nix` exposes `mkProfile` and a `mkManifest` helper, where `mkManifest` is currently an identity function.
- `resources/nix/profiles/default.nix` uses `insomnia.mkProfile { manifest = insomnia.mkManifest { ... }; }`.
- The built-in profile currently contains low-level or instance-specific Manifest fields such as `pod.name = "insomnia"`.
- Model policy fields such as high reasoning effort and compaction thresholds are represented as low-level manifest settings rather than being derived from a profile's model/context policy.
- Profile evaluation currently shells out to `nix eval` for Nix artifacts, so the boundary between profile language support and a runtime dependency on the `nix` command is unclear.
The issue is not that profile artifacts are eventually serialized as manifests. The issue is that the authoring API currently exposes the output shape as if the profile itself were a manifest. Profiles should be semantic, and the resolver should perform the manifestization step.
## Goal
Redesign the Nix profile authoring surface so profiles express role/model/tool/context policy at the appropriate level of abstraction, and the manifest resolver turns that semantic profile into the concrete runtime manifest/config used to start a Pod.
This should restore the intended boundary:
- Profile: selectable role/policy/model/context/tool behavior.
- Resolver/manifestization: derives concrete defaults and token thresholds from model metadata and profile policy.
- Runtime manifest/config: low-level concrete values consumed by Pod startup.
## Desired direction
- Remove `mkManifest` from the public/built-in authoring style. A profile should not look like `mkProfile { manifest = mkManifest { ... }; }`.
- Built-in profiles should be written as semantic profiles, not Manifest-shaped blobs.
- Instance-specific values such as `pod.name` must not live in built-in profiles. Pod names come from CLI/TUI/SpawnPod creation, not profile identity.
- Model behavior such as reasoning effort belongs to the profile's model/quality policy and should be manifestized into provider-specific request config by the resolver.
- Compaction and context budgeting should be derived from the selected model's context window and profile policy, e.g. ratios/multipliers/strategy names, rather than hard-coded token thresholds copied into the profile.
- Nix evaluation should be treated as a profile authoring/evaluation mechanism, not as a requirement that the runtime default path always depends on an external `nix` command.
## Acceptance criteria
- Audit the original manifest profiles work item and current implementation to identify where the implementation became Manifest-shaped instead of semantic-profile-shaped.
- Replace the built-in Nix profile shape with a semantic profile API. The exact field names may be redesigned, but they must clearly distinguish profile policy from runtime manifest output.
- Remove `pod.name` and any other instance-specific Pod identity/configuration from built-in profiles.
- Remove `mkManifest` from built-in examples and docs. If a compatibility helper remains internally, it must not be the recommended authoring API and must be justified.
- Implement or adjust the resolver so semantic profile data is manifestized into the concrete runtime manifest/config.
- Ensure model-derived settings are resolved in one place. In particular, reasoning effort and context/compaction policy should be interpreted as profile/model policy and converted into concrete request/compaction settings by the resolver.
- Add or update model catalog/context-window plumbing as needed so compaction thresholds can be derived from the selected model rather than duplicated as raw profile constants.
- Decide and document the Nix evaluator boundary:
- built-in/default profiles must not require the external `nix` command at normal runtime unless that dependency is deliberately accepted and documented;
- user/project Nix profiles may use `nix eval` if no lightweight in-process evaluator is chosen, but diagnostics must clearly state that Nix is required for Nix-authored profiles;
- do not silently make all profile selection depend on a missing or slow `nix` binary.
- Investigate whether a lightweight embedded evaluator is practical enough for the supported Nix subset. If it is not, document why and keep the external-command dependency isolated to Nix-authored profile evaluation.
- Preserve previous profile selection semantics where still relevant: built-in/user/project profile discovery, default profile selection, source-qualified selectors, ambiguity errors, and manifest cascade removal.
- Update docs/tests so the examples present profiles as semantic policy, not manifests written in Nix syntax.
- Add tests covering:
- built-in profile manifestization without `pod.name`;
- semantic compaction policy derived from model context window;
- reasoning/model policy manifestization;
- missing `nix` command behavior for user/project Nix profiles, if external evaluation remains;
- no regression in profile discovery/default/source-qualified selection.
- Run focused validation for the manifest crate and any affected pod/tui/client paths, plus `./tickets.sh doctor` and `git diff --check`.
## Non-goals
- Do not reintroduce manifest cascade or manifest overlay selection as a replacement for profiles.
- Do not turn Nix profiles into a general low-level manifest DSL.
- Do not add profile alias registry complexity back unless a separate ticket re-justifies it.
- Do not implement encrypted secret storage here; keep it in the existing encrypted secrets work item.
- Do not require solving every possible provider-specific model option in this ticket; implement the minimum necessary semantic-to-runtime mapping and leave explicit follow-ups for broader model policy coverage.
---

View File

@ -0,0 +1,41 @@
---
id: 20260529-235408-provider-stream-trace-profile-spawn
slug: provider-stream-trace-profile-spawn
title: Preserve provider stream trace recording across profile-spawned Pods
status: closed
kind: task
priority: P2
labels: [pod, profile, session-trace, debuggability]
created_at: 2026-05-29T23:54:08Z
updated_at: 2026-05-30T00:38:28Z
assignee: null
legacy_ticket: null
---
## Background
`SessionConfig::record_event_trace` controls the debug sidecar `{segment_id}.trace.jsonl`, which records normalized provider stream events and lifecycle markers outside the durable segment log. It is intentionally separate from session replay, but it is important when diagnosing provider failures that are visible only as live events.
During the `session-pod-state-boundary` implementation handoff, a child Pod showed `[ProviderError]` with `error_code: context_length_exceeded` in TUI for a `ping` request. The child's session JSONL had no `run_errored`, and there was no `.trace.jsonl` sidecar. Inspecting the child's runtime manifest showed:
```toml
[session]
record_event_trace = false
```
This suggests the profile/spawn configuration path may have dropped or reset a previously enabled debug trace setting when profiles were introduced or when `SpawnPod` materializes its child manifest. Even if the immediate provider-error persistence bug is fixed separately, disabling the trace sidecar for child Pods makes diagnosis of live-only provider events much harder.
The current trace sidecar is not fully raw SSE; it records parsed `llm_worker::llm_client::event::Event` values plus lifecycle/transport diagnostics. Unknown OpenAI Responses SSE events are surfaced as `Event::UnhandledSse` with bounded previews, while known terminal events such as `response.failed` become `Event::Error`.
## Goal
Make provider stream/event trace configuration survive profile resolution and spawned-Pod manifest construction so debugging settings remain effective for child Pods, and make the resulting behavior explicit.
## Acceptance criteria
- Identify where `session.record_event_trace` is lost or defaulted during profile resolution, manifestization, `SpawnPod`, or hidden spawn-config construction.
- Preserve the intended trace setting for spawned Pods when the parent/profile configuration enables it, unless an explicit child override disables it.
- Add focused tests covering profile-resolved manifests and spawned-Pod manifest construction with `record_event_trace = true`.
- Clarify in docs or comments that `.trace.jsonl` stores normalized stream events/lifecycle diagnostics, not necessarily byte-for-byte raw SSE.
- Do not make event trace globally enabled by default; it remains an opt-in debugging feature.
- Validate with focused manifest/pod tests and `./tickets.sh doctor`.

View File

@ -0,0 +1,41 @@
---
id: 20260529-235408-provider-stream-trace-profile-spawn
slug: provider-stream-trace-profile-spawn
title: Preserve provider stream trace recording across profile-spawned Pods
status: closed
kind: task
priority: P2
labels: [pod, profile, session-trace, debuggability]
created_at: 2026-05-29T23:54:08Z
updated_at: 2026-05-30T00:38:28Z
assignee: null
legacy_ticket: null
---
## Background
`SessionConfig::record_event_trace` controls the debug sidecar `{segment_id}.trace.jsonl`, which records normalized provider stream events and lifecycle markers outside the durable segment log. It is intentionally separate from session replay, but it is important when diagnosing provider failures that are visible only as live events.
During the `session-pod-state-boundary` implementation handoff, a child Pod showed `[ProviderError]` with `error_code: context_length_exceeded` in TUI for a `ping` request. The child's session JSONL had no `run_errored`, and there was no `.trace.jsonl` sidecar. Inspecting the child's runtime manifest showed:
```toml
[session]
record_event_trace = false
```
This suggests the profile/spawn configuration path may have dropped or reset a previously enabled debug trace setting when profiles were introduced or when `SpawnPod` materializes its child manifest. Even if the immediate provider-error persistence bug is fixed separately, disabling the trace sidecar for child Pods makes diagnosis of live-only provider events much harder.
The current trace sidecar is not fully raw SSE; it records parsed `llm_worker::llm_client::event::Event` values plus lifecycle/transport diagnostics. Unknown OpenAI Responses SSE events are surfaced as `Event::UnhandledSse` with bounded previews, while known terminal events such as `response.failed` become `Event::Error`.
## Goal
Make provider stream/event trace configuration survive profile resolution and spawned-Pod manifest construction so debugging settings remain effective for child Pods, and make the resulting behavior explicit.
## Acceptance criteria
- Identify where `session.record_event_trace` is lost or defaulted during profile resolution, manifestization, `SpawnPod`, or hidden spawn-config construction.
- Preserve the intended trace setting for spawned Pods when the parent/profile configuration enables it, unless an explicit child override disables it.
- Add focused tests covering profile-resolved manifests and spawned-Pod manifest construction with `record_event_trace = true`.
- Clarify in docs or comments that `.trace.jsonl` stores normalized stream events/lifecycle diagnostics, not necessarily byte-for-byte raw SSE.
- Do not make event trace globally enabled by default; it remains an opt-in debugging feature.
- Validate with focused manifest/pod tests and `./tickets.sh doctor`.

View File

@ -0,0 +1,102 @@
<!-- event: create author: tickets.sh at: 2026-05-29T23:54:08Z -->
## Created
Created by tickets.sh create.
---
<!-- event: plan author: hare at: 2026-05-30T00:23:45Z -->
## Plan
Use the worktree + sibling coder/reviewer flow for this ticket.
Intent packet summary:
- Preserve `session.record_event_trace` through profile resolution and spawned-Pod manifest construction when the parent/profile enables it.
- Keep event trace opt-in; do not globally enable it by default.
- Treat `.trace.jsonl` as normalized provider stream/lifecycle diagnostics, not byte-for-byte raw SSE.
Implementation direction:
- Trace where `record_event_trace` is carried in profile-resolved manifests and where `SpawnPod` constructs hidden child spawn config.
- Fix the narrow point where the setting is dropped/defaulted.
- Add focused tests for profile/spawn config preserving `record_event_trace = true`.
- Add a small documentation/comment clarification for trace sidecar semantics.
Validation target:
- Focused manifest/pod tests covering the changed path.
- `./tickets.sh doctor` and `git diff --check`.
---
<!-- event: review author: hare at: 2026-05-30T00:37:40Z status: approve -->
## Review: approve
Approve.
The change narrowly fixes the hidden `--spawn-config-json` loss point by carrying the already-resolved spawner `manifest.session.record_event_trace` into `SpawnPodTool` and serializing `[session].record_event_trace = true` only when the parent has opted in. It also adds focused coverage for profile artifact preservation, spawned config preservation, disabled omission, and clarifies trace sidecar semantics as normalized provider/lifecycle diagnostics rather than byte-for-byte raw SSE.
Blocker findings: none.
Validation re-run by reviewer:
- `cargo test -p manifest profile_artifact_preserves_session_record_event_trace` — passed.
- `cargo test -p pod spawn_config` — passed, 4 tests.
- `./tickets.sh doctor` — passed.
- `git diff --check 23f234d^ 23f234d` — passed earlier during inspection.
- Worktree status is clean.
Final verdict: approve.
---
<!-- event: close author: hare at: 2026-05-30T00:38:28Z status: closed -->
## Closed
---
id: 20260529-235408-provider-stream-trace-profile-spawn
slug: provider-stream-trace-profile-spawn
title: Preserve provider stream trace recording across profile-spawned Pods
status: closed
kind: task
priority: P2
labels: [pod, profile, session-trace, debuggability]
created_at: 2026-05-29T23:54:08Z
updated_at: 2026-05-30T00:38:28Z
assignee: null
legacy_ticket: null
---
## Background
`SessionConfig::record_event_trace` controls the debug sidecar `{segment_id}.trace.jsonl`, which records normalized provider stream events and lifecycle markers outside the durable segment log. It is intentionally separate from session replay, but it is important when diagnosing provider failures that are visible only as live events.
During the `session-pod-state-boundary` implementation handoff, a child Pod showed `[ProviderError]` with `error_code: context_length_exceeded` in TUI for a `ping` request. The child's session JSONL had no `run_errored`, and there was no `.trace.jsonl` sidecar. Inspecting the child's runtime manifest showed:
```toml
[session]
record_event_trace = false
```
This suggests the profile/spawn configuration path may have dropped or reset a previously enabled debug trace setting when profiles were introduced or when `SpawnPod` materializes its child manifest. Even if the immediate provider-error persistence bug is fixed separately, disabling the trace sidecar for child Pods makes diagnosis of live-only provider events much harder.
The current trace sidecar is not fully raw SSE; it records parsed `llm_worker::llm_client::event::Event` values plus lifecycle/transport diagnostics. Unknown OpenAI Responses SSE events are surfaced as `Event::UnhandledSse` with bounded previews, while known terminal events such as `response.failed` become `Event::Error`.
## Goal
Make provider stream/event trace configuration survive profile resolution and spawned-Pod manifest construction so debugging settings remain effective for child Pods, and make the resulting behavior explicit.
## Acceptance criteria
- Identify where `session.record_event_trace` is lost or defaulted during profile resolution, manifestization, `SpawnPod`, or hidden spawn-config construction.
- Preserve the intended trace setting for spawned Pods when the parent/profile configuration enables it, unless an explicit child override disables it.
- Add focused tests covering profile-resolved manifests and spawned-Pod manifest construction with `record_event_trace = true`.
- Clarify in docs or comments that `.trace.jsonl` stores normalized stream events/lifecycle diagnostics, not necessarily byte-for-byte raw SSE.
- Do not make event trace globally enabled by default; it remains an opt-in debugging feature.
- Validate with focused manifest/pod tests and `./tickets.sh doctor`.
---

View File

@ -0,0 +1,67 @@
---
id: 20260530-022235-lua-profile-authoring
slug: lua-profile-authoring
title: Implement reusable Lua profile authoring
status: closed
kind: task
priority: P1
labels: [manifest, profiles, lua, architecture]
created_at: 2026-05-30T02:22:35Z
updated_at: 2026-05-30T02:59:55Z
assignee: null
legacy_ticket: null
---
## Background
The previous `semantic-nix-profiles` direction is paused. Nix-specific authoring raised portability, evaluator, and API-injection problems, and the attempted semantic JSON projection risked becoming a near-Manifest copy rather than a useful profile abstraction.
The current direction is to stop making Nix the primary profile authoring layer and implement reusable Profile authoring first. Lua is the current pragmatic candidate because it is embeddable, portable, widely understood, and supports local reuse through host-controlled `require`.
Profile is now interpreted as a reusable manifest-like recipe template: close enough to Manifest to be understandable, but stripped of runtime binding and authority-bearing fields such as Pod identity, concrete delegated scope, resolved paths, secret material, sockets, runtime state, and active session pointers. The resolver combines Profile with runtime inputs and validation to produce the concrete Manifest snapshot.
This ticket should start with an implementation Pod producing a concrete plan. If the plan respects the boundary below, implementation may proceed in the same worktree.
## Requirements
- Add Profile authoring support without using Nix as the primary profile layer.
- Prefer Lua as the authoring surface unless implementation planning reveals a blocker.
- Provide host-controlled import/module loading rather than relying on installed resource paths:
- host-provided modules such as `require("insomnia")` / `require("insomnia.profile")` / `require("insomnia.models")`;
- profile-local reusable modules via controlled local `require`;
- deny or omit unsafe standard libraries such as `os`, `io`, `debug`, and unrestricted `package`.
- Provide a central public Profile constructor/boundary, likely `profile` / `insomnia.profile`, not a public `mkManifest` as the normal authoring API.
- The value returned by a profile file may be collection/table-like and may map closely to a Profile structure, but it is Profile, not complete Manifest.
- Reject or clearly diagnose Manifest-shaped returns that include runtime-only fields such as `pod.name` or concrete authority-bearing `scope.allow`.
- Keep `--manifest` as the explicit low-level complete Manifest escape hatch.
- Preserve existing profile selection semantics where possible: builtin/default selection, source-qualified selectors, registry/file context, and persisted resolved Manifest snapshots.
- Builtin/default startup must not depend on an external evaluator such as `nix`.
- Support model/context-derived compaction through helpers/policies. True Nix-style recursive sets are not required; use Lua locals and helper APIs instead.
- Scope in Profile may express intent/policy, but concrete scope authority remains runtime/delegation controlled.
- Do not change pod-store/session-log authority boundaries.
## Non-goals
- Do not preserve Nix profile authoring as the main path in this ticket.
- Do not implement a Nix-like custom evaluator.
- Do not expose arbitrary complete Manifest construction as the normal profile API.
- Do not add compatibility layers solely to preserve the abandoned semantic JSON projection.
- Do not broaden SpawnPod scope/profile authority.
## Preflight classification
implementation-ready after implementation Pod plan review.
The high-level product direction is now synchronized in `profile-authoring-requirements-sync`: Profile is a reusable manifest-like recipe template, and Lua/controlled `require` is the current candidate. The implementation Pod must still produce a concrete plan covering crate placement, dependency choice, sandboxing, return contract, resolver integration, builtin/default migration, and tests before coding.
## Acceptance criteria
- An implementation plan is recorded in the ticket thread before code changes are accepted.
- The plan explicitly covers dependency choice, sandbox model, module loading, return contract, Profile-to-Manifest resolver integration, and migration/removal of Nix-primary builtin profile usage.
- A child worktree implementation adds reusable Profile authoring support according to the approved plan.
- Builtin/default profile can resolve without external `nix`.
- Lua profile examples can use host-provided `require("insomnia.*")` modules and local reusable modules.
- Unsafe or unrestricted Lua facilities are unavailable or denied by default.
- Runtime-only Manifest fields in returned Profile values produce clear diagnostics.
- Focused tests cover builtin/default resolution, module loading, local reuse, sandbox denial, invalid Manifest-shaped returns, and Profile-to-Manifest conversion.
- Existing relevant manifest/profile selection tests continue to pass or are updated with intentional behavior changes.

View File

@ -0,0 +1,67 @@
---
id: 20260530-022235-lua-profile-authoring
slug: lua-profile-authoring
title: Implement reusable Lua profile authoring
status: closed
kind: task
priority: P1
labels: [manifest, profiles, lua, architecture]
created_at: 2026-05-30T02:22:35Z
updated_at: 2026-05-30T02:59:54Z
assignee: null
legacy_ticket: null
---
## Background
The previous `semantic-nix-profiles` direction is paused. Nix-specific authoring raised portability, evaluator, and API-injection problems, and the attempted semantic JSON projection risked becoming a near-Manifest copy rather than a useful profile abstraction.
The current direction is to stop making Nix the primary profile authoring layer and implement reusable Profile authoring first. Lua is the current pragmatic candidate because it is embeddable, portable, widely understood, and supports local reuse through host-controlled `require`.
Profile is now interpreted as a reusable manifest-like recipe template: close enough to Manifest to be understandable, but stripped of runtime binding and authority-bearing fields such as Pod identity, concrete delegated scope, resolved paths, secret material, sockets, runtime state, and active session pointers. The resolver combines Profile with runtime inputs and validation to produce the concrete Manifest snapshot.
This ticket should start with an implementation Pod producing a concrete plan. If the plan respects the boundary below, implementation may proceed in the same worktree.
## Requirements
- Add Profile authoring support without using Nix as the primary profile layer.
- Prefer Lua as the authoring surface unless implementation planning reveals a blocker.
- Provide host-controlled import/module loading rather than relying on installed resource paths:
- host-provided modules such as `require("insomnia")` / `require("insomnia.profile")` / `require("insomnia.models")`;
- profile-local reusable modules via controlled local `require`;
- deny or omit unsafe standard libraries such as `os`, `io`, `debug`, and unrestricted `package`.
- Provide a central public Profile constructor/boundary, likely `profile` / `insomnia.profile`, not a public `mkManifest` as the normal authoring API.
- The value returned by a profile file may be collection/table-like and may map closely to a Profile structure, but it is Profile, not complete Manifest.
- Reject or clearly diagnose Manifest-shaped returns that include runtime-only fields such as `pod.name` or concrete authority-bearing `scope.allow`.
- Keep `--manifest` as the explicit low-level complete Manifest escape hatch.
- Preserve existing profile selection semantics where possible: builtin/default selection, source-qualified selectors, registry/file context, and persisted resolved Manifest snapshots.
- Builtin/default startup must not depend on an external evaluator such as `nix`.
- Support model/context-derived compaction through helpers/policies. True Nix-style recursive sets are not required; use Lua locals and helper APIs instead.
- Scope in Profile may express intent/policy, but concrete scope authority remains runtime/delegation controlled.
- Do not change pod-store/session-log authority boundaries.
## Non-goals
- Do not preserve Nix profile authoring as the main path in this ticket.
- Do not implement a Nix-like custom evaluator.
- Do not expose arbitrary complete Manifest construction as the normal profile API.
- Do not add compatibility layers solely to preserve the abandoned semantic JSON projection.
- Do not broaden SpawnPod scope/profile authority.
## Preflight classification
implementation-ready after implementation Pod plan review.
The high-level product direction is now synchronized in `profile-authoring-requirements-sync`: Profile is a reusable manifest-like recipe template, and Lua/controlled `require` is the current candidate. The implementation Pod must still produce a concrete plan covering crate placement, dependency choice, sandboxing, return contract, resolver integration, builtin/default migration, and tests before coding.
## Acceptance criteria
- An implementation plan is recorded in the ticket thread before code changes are accepted.
- The plan explicitly covers dependency choice, sandbox model, module loading, return contract, Profile-to-Manifest resolver integration, and migration/removal of Nix-primary builtin profile usage.
- A child worktree implementation adds reusable Profile authoring support according to the approved plan.
- Builtin/default profile can resolve without external `nix`.
- Lua profile examples can use host-provided `require("insomnia.*")` modules and local reusable modules.
- Unsafe or unrestricted Lua facilities are unavailable or denied by default.
- Runtime-only Manifest fields in returned Profile values produce clear diagnostics.
- Focused tests cover builtin/default resolution, module loading, local reuse, sandbox denial, invalid Manifest-shaped returns, and Profile-to-Manifest conversion.
- Existing relevant manifest/profile selection tests continue to pass or are updated with intentional behavior changes.

View File

@ -0,0 +1,165 @@
<!-- event: create author: tickets.sh at: 2026-05-30T02:22:35Z -->
## Created
Created by tickets.sh create.
---
<!-- event: plan author: hare at: 2026-05-30T02:23:16Z -->
## Plan
## Ticket preflight
Classification: implementation-ready after implementation Pod plan review.
Requirements are synchronized enough to ask an implementation Pod for a concrete plan, but not enough to skip planning. The Pod must plan first and wait for orchestrator approval before coding.
Current critical risks:
- Recreating Manifest authoring under another name. Profile may be manifest-like, but runtime-bound and authority-bearing fields (`pod.name`, concrete `scope.allow`, resolved paths, secret material, runtime state) must be rejected or diagnosed.
- Exposing `mkManifest` as the public normal API. The normal boundary should be `profile` / `insomnia.profile`.
- Implementing uncontrolled Lua `require`, standard library access, or installed resource path imports.
- Breaking existing profile selection semantics or resolved Manifest snapshot persistence.
- Letting Profile express capability expansion instead of scope intent/policy checked by runtime/delegation.
Current plan gate:
- Implementation Pod must produce a plan covering dependency/crate placement, sandbox, module loading, return contract, Profile data model, resolver integration, builtin/default migration, diagnostics, and tests.
- If the plan respects the boundary, the orchestrator may authorize implementation in the same Pod/worktree.
---
<!-- event: decision author: hare at: 2026-05-30T02:26:32Z -->
## Decision
## Implementation plan accepted with constraints
The implementation Pod produced a plan for Lua-based reusable Profile authoring. The plan is accepted as the implementation direction, with these constraints:
- Lua is the primary authoring path for this ticket; Nix is not the primary profile layer.
- Do not keep legacy `.nix` profile evaluation just for compatibility if it complicates the design. Prefer removing/disabling Nix profile resolution from the normal profile selector path, while leaving `--manifest` as the explicit low-level escape hatch.
- Builtin/default must resolve from Lua/Profile or Rust in-process data without external `nix`.
- Public authoring boundary is `profile` / `require("insomnia.profile")`, not public `mkManifest`.
- Controlled `require` is part of the implementation: host virtual `insomnia.*` modules and profile-local modules only; no installed resource path imports.
- Profile may be manifest-like, but runtime-bound or authority-bearing fields such as `pod.name`, concrete `scope.allow`/`scope.deny`, resolved paths, sockets, runtime state, and raw secret material must be rejected or clearly diagnosed.
- Scope in Profile is intent/policy only; concrete authority is resolved against runtime/delegation inputs.
- Model/context-derived compaction can use Lua locals and/or helper policy such as `compact.ratio`, not Nix recursive sets.
- Preserve profile selection semantics where still meaningful: default/builtin/user/project/source-qualified/path selectors and persisted resolved Manifest snapshots.
Implementation plan summary:
- Add embedded Lua evaluation in `crates/manifest` using a vendored Lua crate such as `mlua` if dependency/license/build characteristics are acceptable.
- Add a Profile data model that is a reusable manifest-like recipe template and converts into a concrete `PodManifest` only through resolver runtime inputs.
- Add sandboxed Lua evaluation with denied `os`, `io`, `debug`, unrestricted `package`, `dofile`, `loadfile`, and uncontrolled loaders.
- Add host-provided virtual modules such as `insomnia`, `insomnia.profile`, `insomnia.models`, `insomnia.compact`, and `insomnia.scope`.
- Add profile-local controlled `require` with canonical path checks, module cache, and cycle diagnostics.
- Migrate builtin/default from `resources/nix/profiles/default.nix` to a Lua/Profile source or in-process equivalent.
- Add focused tests for builtin/default without external nix, host modules, local require, sandbox denial, invalid Manifest-shaped returns, scope intent resolution, and selector semantics.
---
<!-- event: review author: hare at: 2026-05-30T02:58:55Z status: approve -->
## Review: approve
Approve.
The implementation shifts normal profile authoring from Nix-primary evaluation to embedded Lua/Profile authoring. The public authoring boundary is `profile` / `require("insomnia.profile")`; builtin/default resolves without external `nix`; controlled `require` supports host virtual modules and profile-local modules; unsafe Lua facilities are denied; runtime-bound and authority-bearing Manifest fields are rejected; and `--manifest` remains the explicit low-level escape hatch.
Blocker findings: none.
Non-blocking follow-ups:
- `docs/pod-factory.md` still contains old ambient manifest cascade wording and should be refreshed later.
- Unsupported `.nix` entries in user/project `profiles.toml` can still appear as selectable entries before failing at resolution with the intended diagnostic.
- Builtin profile directory scanning currently treats every top-level `.lua` as a profile, so future helper files should use a convention that avoids accidental selection.
- Nested reused Manifest structs remain more lenient about unknown fields than Profile top-level validation; stronger profile-specific nested diagnostics can be considered later.
SpawnPod integration timeout assessment:
- The failing `spawn_pod_delegates_scope_and_sends_run` timeout appears unrelated to Lua profile authoring. SpawnPod hidden `--spawn-config-json` takes the direct manifest config path before profile/manifest CLI resolution, and does not invoke ProfileResolver/Lua discovery. Track separately if it remains reproducible.
Validation reviewed:
- Coder: `cargo fmt`, `git diff --check`, `cargo test -p manifest`, `cargo test -p client -p tui`, `cargo test -p pod --lib --bins` passed.
- Reviewer: `cargo test -p manifest` passed, 119 tests.
Final verdict: approve.
---
<!-- event: close author: hare at: 2026-05-30T02:59:55Z status: closed -->
## Closed
---
id: 20260530-022235-lua-profile-authoring
slug: lua-profile-authoring
title: Implement reusable Lua profile authoring
status: closed
kind: task
priority: P1
labels: [manifest, profiles, lua, architecture]
created_at: 2026-05-30T02:22:35Z
updated_at: 2026-05-30T02:59:54Z
assignee: null
legacy_ticket: null
---
## Background
The previous `semantic-nix-profiles` direction is paused. Nix-specific authoring raised portability, evaluator, and API-injection problems, and the attempted semantic JSON projection risked becoming a near-Manifest copy rather than a useful profile abstraction.
The current direction is to stop making Nix the primary profile authoring layer and implement reusable Profile authoring first. Lua is the current pragmatic candidate because it is embeddable, portable, widely understood, and supports local reuse through host-controlled `require`.
Profile is now interpreted as a reusable manifest-like recipe template: close enough to Manifest to be understandable, but stripped of runtime binding and authority-bearing fields such as Pod identity, concrete delegated scope, resolved paths, secret material, sockets, runtime state, and active session pointers. The resolver combines Profile with runtime inputs and validation to produce the concrete Manifest snapshot.
This ticket should start with an implementation Pod producing a concrete plan. If the plan respects the boundary below, implementation may proceed in the same worktree.
## Requirements
- Add Profile authoring support without using Nix as the primary profile layer.
- Prefer Lua as the authoring surface unless implementation planning reveals a blocker.
- Provide host-controlled import/module loading rather than relying on installed resource paths:
- host-provided modules such as `require("insomnia")` / `require("insomnia.profile")` / `require("insomnia.models")`;
- profile-local reusable modules via controlled local `require`;
- deny or omit unsafe standard libraries such as `os`, `io`, `debug`, and unrestricted `package`.
- Provide a central public Profile constructor/boundary, likely `profile` / `insomnia.profile`, not a public `mkManifest` as the normal authoring API.
- The value returned by a profile file may be collection/table-like and may map closely to a Profile structure, but it is Profile, not complete Manifest.
- Reject or clearly diagnose Manifest-shaped returns that include runtime-only fields such as `pod.name` or concrete authority-bearing `scope.allow`.
- Keep `--manifest` as the explicit low-level complete Manifest escape hatch.
- Preserve existing profile selection semantics where possible: builtin/default selection, source-qualified selectors, registry/file context, and persisted resolved Manifest snapshots.
- Builtin/default startup must not depend on an external evaluator such as `nix`.
- Support model/context-derived compaction through helpers/policies. True Nix-style recursive sets are not required; use Lua locals and helper APIs instead.
- Scope in Profile may express intent/policy, but concrete scope authority remains runtime/delegation controlled.
- Do not change pod-store/session-log authority boundaries.
## Non-goals
- Do not preserve Nix profile authoring as the main path in this ticket.
- Do not implement a Nix-like custom evaluator.
- Do not expose arbitrary complete Manifest construction as the normal profile API.
- Do not add compatibility layers solely to preserve the abandoned semantic JSON projection.
- Do not broaden SpawnPod scope/profile authority.
## Preflight classification
implementation-ready after implementation Pod plan review.
The high-level product direction is now synchronized in `profile-authoring-requirements-sync`: Profile is a reusable manifest-like recipe template, and Lua/controlled `require` is the current candidate. The implementation Pod must still produce a concrete plan covering crate placement, dependency choice, sandboxing, return contract, resolver integration, builtin/default migration, and tests before coding.
## Acceptance criteria
- An implementation plan is recorded in the ticket thread before code changes are accepted.
- The plan explicitly covers dependency choice, sandbox model, module loading, return contract, Profile-to-Manifest resolver integration, and migration/removal of Nix-primary builtin profile usage.
- A child worktree implementation adds reusable Profile authoring support according to the approved plan.
- Builtin/default profile can resolve without external `nix`.
- Lua profile examples can use host-provided `require("insomnia.*")` modules and local reusable modules.
- Unsafe or unrestricted Lua facilities are unavailable or denied by default.
- Runtime-only Manifest fields in returned Profile values produce clear diagnostics.
- Focused tests cover builtin/default resolution, module loading, local reuse, sandbox denial, invalid Manifest-shaped returns, and Profile-to-Manifest conversion.
- Existing relevant manifest/profile selection tests continue to pass or are updated with intentional behavior changes.
---

View File

@ -1,7 +0,0 @@
<!-- event: migration author: tickets.sh-migration at: 2026-05-27T00:00:16Z -->
## Migrated
Migrated from tickets/tui-picker-live-pending-pods.md. No legacy review file was present at migration time.
---

View File

@ -1,7 +0,0 @@
<!-- event: create author: tickets.sh at: 2026-05-29T16:30:47Z -->
## Created
Created by tickets.sh create.
---

View File

@ -0,0 +1,88 @@
---
id: 20260529-205540-spawnpod-profile-tool-description
slug: spawnpod-profile-tool-description
title: SpawnPod profile selection and templated tool description
status: open
kind: feature
priority: P2
labels: [pod, manifest, tools, workflow]
created_at: 2026-05-29T20:55:40Z
updated_at: 2026-05-30T05:11:43Z
assignee: null
legacy_ticket: null
---
## Background
`SpawnPod` is becoming the main mechanism for hierarchical orchestration. The workflow model now distinguishes role-specific child Pods: lower orchestrators, coder Pods, and external reviewer Pods. These roles should be selectable by profile, but the current `SpawnPod` tool has no profile field and no LLM-visible route to discover profile selectors.
A separate `ListProfiles` tool would expose mostly static or semi-static affordance information as another model action. The desired design is instead to make profile choices part of the `SpawnPod` tool's own guidance: render the available profile selectors into the tool description, and repeat the same selector list in invalid/ambiguous/no-default diagnostics.
Current implementation notes:
- Tool metadata is defined by `llm_worker::tool::ToolMeta`; `ToolDefinition` factories return `(ToolMeta, Arc<dyn Tool>)` and `ToolServerHandle::flush_pending()` materializes them before request construction.
- Tool descriptions are currently hard-coded strings/doc comments in each tool factory. `crates/pod/src/spawn/tool.rs` has a static `DESCRIPTION` and `SpawnPodInput` only contains `name`, `instruction`, `task`, and `scope`.
- `SpawnPod` currently builds an internal `PodManifestConfig` directly from selected pieces of the parent resolved manifest plus tool input, then launches the child with hidden `--spawn-config-json`. It copies the parent model and session trace flag, applies optional instruction override, and uses the delegated scope from the tool call. It does not use profile resolution and it does not copy the parent resolved manifest wholesale.
- Prompt assets are centralized under `resources/prompts`; Pod-owned prompt injection strings are represented by `PodPrompt` and rendered by `PromptCatalog` through minijinja. `resources/prompts/internal.toml` has build-time coverage against `PodPrompt` variants.
- Profile discovery/resolution already exists in `manifest`, though the concrete profile authoring layer is being revised away from Nix-primary semantics. SpawnPod profile selection should use the same effective profile registry/default semantics as the normal launcher path once that resolver is available.
## Requirements
- Add an optional `profile` field to `SpawnPodInput`.
- `SpawnPod.profile` accepts three conceptual selector classes:
- omitted or `"default"`: resolve the effective child default profile;
- `"inherit"`: derive child config from the spawner's resolved Manifest, extracting only reusable profile-like fields;
- `<slug>` / source-qualified selectors such as `builtin:<slug>`, `user:<slug>`, `project:<slug>`: resolve a discovered role profile.
- `inherit` is distinct from reusing the profile source that created the parent. It means extracting reusable configuration from the parent resolved Manifest. Re-evaluating or reusing the parent's original Profile source is a separate concept and is not required here.
- Make `SpawnPod`'s LLM-facing tool description include the currently discoverable profile selectors, the effective default profile, and the special `inherit` selector.
- Do not add a separate `ListProfiles` tool for this feature.
- The profile list in the tool description must come from the same builtin/user/project profile discovery rules used by the profile launcher path.
- If profile discovery fails, the tool should still be registered with a clear diagnostic in its description rather than making Pod startup fail solely because the description could not list profiles.
- If `profile` is omitted, `SpawnPod` resolves the effective default profile. With the builtin default profile present, ordinary omission should keep working.
- If `profile` is invalid, ambiguous, unsupported, or omitted while no default can be resolved, the tool error must include a compact available-profile list, source-qualified suggestions, and mention `inherit` where appropriate.
- `SpawnPod.profile` should initially accept registry/default selectors and `inherit` only: `default`, `inherit`, `builtin:<name>`, `user:<name>`, `project:<name>`, and unqualified names only when unambiguous. Raw path selectors, `path:<path>`, relative paths, absolute paths, and `.nix`/`.lua` path-like values are rejected for the tool path.
- `SpawnPod.scope` remains the only delegated capability. A profile selected through `SpawnPod`, including `inherit`, must not be able to expand `scope.allow` beyond the explicit tool argument.
- `SpawnPod.name` always overrides any resolved/derived `pod.name` in the child manifest.
- `SpawnPod.instruction`, if present, is a typed override for the selected profile's or inherited config's `worker.instruction` only; it must not replace the whole profile/config.
- Profile-selected spawn should preserve profile-owned role configuration such as model, worker settings, tools/memory/web policy, and prompt pack where applicable, subject to the explicit `name` and `scope` overrides.
- `inherit` should preserve reusable parent-owned configuration such as model, worker settings, compaction, tools/memory/web policy, prompt pack, and session diagnostics where applicable, subject to the explicit `name`, `scope`, and optional `instruction` overrides.
- `inherit` must not preserve runtime-bound or authority-bearing parent fields: parent `pod.name`, concrete parent `scope.allow`/`scope.deny`, resolved runtime paths, sockets, session/pod-store state, spawned-child registry state, callback addresses, or raw resolved secret material.
- Existing hidden `--spawn-config-json` remains the internal launch handoff. `SpawnPod` resolves/merges selected profile data in the parent process and passes the resulting manifest config/snapshot to the child; it should not simply exec `insomnia-pod --profile`.
- Documentation/workflows should show `SpawnPod(profile = "project:coder")`, `SpawnPod(profile = "project:reviewer")`, optionally `project:orchestrator`, and `SpawnPod(profile = "inherit")` as explicit choices while keeping scope authority separate from profile role selection.
## Tool description templating direction
Use the existing prompt infrastructure rather than scattering another large hard-coded string.
Acceptable implementation shape:
- Add a Pod-owned prompt/catalog entry for the `SpawnPod` tool description, e.g. `spawn_pod_tool_description`, with minijinja variables such as `available_profiles`, `default_profile`, `special_selectors`, and `profile_diagnostic`.
- Render this prompt when registering `SpawnPod` in `register_pod_tools`, using the Pod cwd as the profile discovery base.
- Keep the rendered description as `ToolMeta.description`; the tool metadata still remains session-scoped after registration.
- The same formatter used for the description should be reusable by error diagnostics so invalid profile errors repeat the available selectors.
If a more general tool-description catalog is introduced, keep the initial scope narrow: it must support `SpawnPod` without forcing every built-in tool to migrate in the same ticket.
## Acceptance criteria
- `SpawnPod` schema exposes optional `profile` with clear field docs for `default`, `inherit`, and profile slug/source-qualified selectors.
- `SpawnPod` tool description includes a compact available-profile block, default-profile guidance, and the `inherit` special selector.
- `SpawnPod` invalid/ambiguous/no-default profile errors include the same compact selector list and tell the model to use a listed selector or `inherit`.
- `SpawnPod(profile = "project:reviewer")` resolves the project reviewer profile and applies its role config while replacing `pod.name` and `scope.allow` with the explicit `SpawnPod` values.
- `SpawnPod` with omitted profile resolves the effective default profile.
- `SpawnPod(profile = "inherit")` derives child config from the parent resolved Manifest's reusable fields while replacing `pod.name`, `scope.allow`, and optional `worker.instruction` with the explicit SpawnPod values.
- `SpawnPod(profile = "./reviewer.lua")`, `SpawnPod(profile = "path:./reviewer.lua")`, legacy `.nix` path-like selectors, and absolute profile paths are rejected with an explanation that the tool accepts `default`, `inherit`, or registry selectors only.
- Ambiguous unqualified profile names fail closed with source-qualified suggestions.
- A profile's or inherited config's `scope.allow` cannot grant access not present in `SpawnPod.scope`.
- Existing `SpawnPod` behavior that matters for lifecycle remains intact: registry reservation/rollback, scope revocation from the spawner, callback wiring, child socket wait, and initial `Method::Run` confirmation.
- Unit/integration tests cover description rendering, selector formatting, omitted default resolution, `inherit`, invalid selector diagnostics, profile config application, explicit instruction override, and scope authority replacement.
- Documentation and `.insomnia/workflow/` references explain profile-based coder/reviewer/orchestrator spawning without introducing a `ListProfiles` tool.
## Non-goals
- Do not introduce an LLM-callable `ListProfiles` tool.
- Do not enable arbitrary profile path evaluation through `SpawnPod`.
- Do not confuse `inherit` with reusing the parent's original Profile source. `inherit` is derived from the parent resolved Manifest; parent Profile source reuse can be a later explicit feature if needed.
- Do not revive manifest cascade or generic overlay layers.
- Do not redesign prompt-loader source selection for `$user` / `$workspace` profile prompt refs in this ticket unless it is required to keep current behavior correct.
- Do not implement encrypted secret storage; profiles may still contain unresolved typed secret refs as currently documented.

View File

@ -0,0 +1,99 @@
<!-- event: create author: tickets.sh at: 2026-05-29T20:55:40Z -->
## Created
Created by tickets.sh create.
---
<!-- event: decision author: hare at: 2026-05-30T02:53:19Z -->
## Decision
Clarified selector semantics:
- `default` / omitted means resolve the effective child default profile.
- `<slug>` / source-qualified selectors mean resolve a discovered role profile.
- `inherit` means derive reusable child config from the spawner's resolved Manifest.
`inherit` is explicitly not the same as reusing the Profile source that created the parent. It extracts reusable fields from the parent resolved Manifest and still replaces runtime-bound/authority fields such as `pod.name` and concrete `scope.allow` with the SpawnPod inputs. Reusing the parent's original Profile source can be considered later as a separate feature if needed.
---
<!-- event: plan author: hare at: 2026-05-30T04:54:02Z -->
## Plan
## Preflight implementation plan
Classification: implementation-ready.
No product/API decision is needed before coding. The ticket already fixes the important semantics: omitted/default uses the effective child default profile, `inherit` derives reusable config from the spawner's resolved Manifest, named/source-qualified selectors resolve discovered profiles, path selectors are rejected, and `SpawnPod.scope` remains the only delegated capability.
Important implementation notes:
- Do not rely on process `current_dir()` for SpawnPod profile discovery. Use the Pod cwd (`spawner_pwd`) explicitly by adding/exposing a resolver helper that resolves from a registry discovered for that cwd.
- Resolve profiles and build child config before pod-registry reservation where possible, so invalid profile selectors do not mutate registry/scope.
- `inherit` means derive from the parent resolved Manifest, not from the parent's original Profile source.
- Path-like values, `path:<...>`, `.lua`/legacy suffix selectors, and absolute/relative paths must fail closed in `SpawnPod.profile`.
- Existing hidden `--spawn-config-json` remains the internal handoff; do not exec child with `--profile`.
- Existing prompt-loader source limitations are out of scope; preserve current behavior.
Current code map:
- `crates/pod/src/spawn/tool.rs`: `SpawnPodInput`, static description, spawn lifecycle, `build_spawn_config_json`.
- `crates/pod/src/controller.rs`: `register_pod_tools`, currently snapshots parent model/trace and registers spawn tools.
- `crates/manifest/src/profile.rs`: `ProfileDiscovery`, `ProfileRegistry`, `ProfileSelector`, `ProfileResolver`.
- `crates/manifest/src/config.rs`: `PodManifestConfig`, merge/resolve/defaults.
- `crates/pod/src/main.rs`: hidden `--spawn-config-json` loading takes precedence and uses builtins-only prompt loader.
- `crates/pod/src/prompt/catalog.rs` and `resources/prompts/internal.toml`: central prompt catalog for templated tool description.
Implementation phases:
1. Add manifest profile resolver helper for registry/cwd-explicit selection.
2. Add `SpawnPodInput.profile` and a SpawnPod-specific selector parser for `default`, `inherit`, and registry selectors only.
3. Add shared available-profile formatter for tool description and error diagnostics.
4. Move SpawnPod tool description into prompt catalog/minijinja and render it during tool registration; discovery failures should render diagnostics, not fail Pod startup.
5. Build child `PodManifestConfig` from selected profile Manifest or inherited parent Manifest, replacing `pod.name`, replacing `scope.allow`, clearing `scope.deny`, and optionally overriding only `worker.instruction`.
6. Preserve existing lifecycle: registry reservation/rollback, scope revocation, spawned registry write, callback wiring, child socket wait, initial `Method::Run` confirmation.
7. Update docs/workflows with `project:coder`, `project:reviewer`, optional `project:orchestrator`, and `inherit` examples.
Critical risks:
- Do not merge profile/inherited scope with explicit SpawnPod scope; explicit scope replaces capability.
- Do not call CLI-style profile parser in a way that allows path profiles through SpawnPod.
- Description and diagnostic profile lists should share formatting.
- Prompt catalog key coverage is build-time enforced.
Validation plan:
- Unit tests for selector parsing, formatter, config builder override/replacement behavior.
- Manifest tests for cwd/registry-explicit resolver helper.
- Prompt catalog rendering test.
- SpawnPod integration tests for omitted default, inherit, project profile, invalid selector pre-reservation failure, ambiguity suggestions, and scope replacement.
- Run `cargo test -p manifest profile`, `cargo test -p pod spawn_pod`, relevant prompt catalog tests, `cargo fmt --check`, and `./tickets.sh doctor`.
---
<!-- event: review author: hare at: 2026-05-30T05:11:43Z status: request_changes -->
## Review: request changes
Request changes.
The implementation direction appears sound, and the reviewer did not find a concrete authority-expansion or lifecycle regression. However, the work item acceptance criteria require focused tests for the new SpawnPod profile semantics, and the submitted tests mostly cover selector parsing plus existing lifecycle tests forced to `profile = "inherit"`.
Required fixes:
- Add SpawnPod default-profile coverage proving omitted `profile` resolves the effective registry default.
- Add a source-qualified profile coverage case, e.g. `project:reviewer`, proving role config from the selected profile reaches the generated `--spawn-config-json`.
- Add `inherit` config coverage proving reusable parent fields are copied while `pod.name`, `scope.allow`, and `scope.deny` are replaced.
- Add explicit `instruction` override coverage proving only `worker.instruction` changes.
- Add invalid / ambiguous / no-default diagnostics coverage proving the available-selector block appears.
- Add profile scope replacement coverage proving profile/inherited scope cannot expand delegated scope.
Non-blocking follow-ups:
- Available profile list currently emits source-qualified selectors only; future refinement may mention unqualified names when unambiguous.
- Workflow examples can later be updated to use explicit `project:coder` / `project:reviewer` selectors.
Validation note:
- `cargo test -p pod spawn_profile --no-default-features` currently only proves parser behavior, not profile resolution or child config construction.
---

View File

@ -0,0 +1,112 @@
---
id: 20260530-013904-profile-authoring-requirements-sync
slug: profile-authoring-requirements-sync
title: Sync profile authoring requirements before choosing the language
status: open
kind: task
priority: P2
labels: [manifest, profiles, architecture]
created_at: 2026-05-30T01:39:04Z
updated_at: 2026-05-30T02:03:58Z
assignee: null
legacy_ticket: null
---
## Background
The `semantic-nix-profiles` implementation direction exposed a deeper product/design issue: choosing a profile authoring language before agreeing on the profile boundary risks recreating the same problem in another syntax. The current Nix-shaped implementation drifted toward manifest-shaped authoring, while the desired authoring experience is portable, reusable, and abstracted through system-provided APIs/functions/modules.
Recent discussion rejected the earlier assumption that a separate semantic JSON projection must be the central abstraction. If that projection preserves almost the same information as Manifest, it may be a failed abstraction. A Nix-like custom subset is also risky because users must learn deviations from real Nix and trust a new evaluator. Snix is technically relevant but currently carries licensing/distribution concerns for direct embedding. Lua is under consideration as a pragmatic embeddable language with long-standing implementation experience, but the profile specification is intentionally not fully decided yet.
The current working interpretation is that Profile is not necessarily a radically higher-level object than Manifest. Instead, Profile may be a reusable, manifest-like recipe template: close enough to Manifest to be understandable, but stripped of Pod identity, environment-specific resolution, and capability-authority fields. The key boundary is not information reduction for its own sake; it is separating reusable recipe content from runtime binding.
This ticket is only for synchronizing requirements and open questions before committing to the exact language/API. It should not implement the profile language.
## Current shared requirements
- Manifest is the complete Pod runtime recipe and remains resolver output / persisted snapshot authority.
- Profile is a reusable manifest-like recipe template, not necessarily a separate semantic-only projection.
- Profile authoring must not become a complete low-level Manifest DSL in another syntax: runtime-only and environment-bound fields must stay out of reusable profiles.
- `--manifest` remains the explicit low-level concrete manifest escape hatch.
- Profile identity is minimal: slug/name and description may be the only direct profile metadata, and may also be supplied by registry/file context where appropriate.
- Instance-specific runtime values such as `pod.name` do not belong in reusable profiles; Pod names come from CLI/TUI/SpawnPod/runtime inputs.
- Scope authority must not move into profiles. `SpawnPod.scope` / explicit delegation remains authoritative for capability expansion.
- Profile may express scope intent/policy, such as workspace read/write, but resolver/runtime must check it against launch workspace and delegated permissions before producing concrete `scope.allow`.
- Environment-specific or resolved values such as absolute paths, runtime directories, sockets, active/pending state, and secret material do not belong in profiles.
- Model, worker/reasoning, context, compaction, memory, web, tool behavior, and related policy may exist in Profile as reusable recipe fields.
- Model/context-derived numeric settings, especially compaction thresholds, should preferably be expressible through helpers/policies such as ratios derived from model metadata, instead of forcing copied raw constants into user profiles.
- User-authored profiles should support practical reuse/composition of partial definitions.
- System-provided APIs should be injected or loaded through a stable mechanism, not by asking users to import files from Insomnia's installed resource layout.
- If Lua is chosen, controlled `require` is a likely core primitive:
- `require("insomnia")` / `require("insomnia.*")` for host-provided virtual modules;
- local/profile-directory modules for user reuse;
- no dependency on installed resource paths.
- If Lua is chosen, the central public constructor should likely be `profile` or `insomnia.profile`, not `mkManifest`. A lower-level `mkManifest` may exist internally or as an advanced escape hatch only if explicitly justified.
- A Lua profile file may return a table/collection that maps closely to a Profile structure. That structure may resemble Manifest minus runtime binding fields.
- A returned table must not be blindly accepted as complete Manifest config. Manifest-shaped returns containing runtime-only fields such as `pod.name` should be diagnosed or routed to `--manifest` instead.
- Nix-style recursive sets are desirable for some authoring patterns, but Lua cannot provide true lazy recursive attrsets. The likely substitute is local bindings plus helper APIs, e.g. load a model first, then derive compaction from its context window or use resolver-side ratio helpers.
- Built-in/default startup should not silently depend on a missing/slow external evaluator unless that dependency is deliberately accepted and documented.
## Current working Lua sketch, not final specification
```lua
local profile = require("insomnia.profile")
local models = require("insomnia.models")
local compact = require("insomnia.compact")
local scope = require("insomnia.scope")
local model = models.catalog("codex-oauth/gpt-5.5")
return profile {
slug = "default",
description = "Default coding profile",
model = model,
worker = {
reasoning = "high",
},
compaction = compact.ratio {
threshold = 0.8,
request = 0.9,
worker = 0.36,
},
scope = scope.workspace_write(),
}
```
This sketch intentionally treats the returned value as Profile, not Manifest. The resolver combines it with runtime Pod name, workspace, delegated scope, path resolution, model catalog data, defaults, and validation to produce a concrete Manifest.
## Open specification questions
- Which authoring language should be supported first: Lua, external Nix wrapper, Starlark/Jsonnet/Nickel/Rhai, or another option?
- If Lua is chosen, which Rust embedding crate/features are acceptable, and how should sandboxing be configured?
- What is the exact module loading model?
- host-provided `require("insomnia")` / `require("insomnia.*")`;
- profile-local reusable modules;
- denied standard libraries such as `os`, `io`, `debug`, `package`;
- module cache scope and path traversal behavior.
- What exact return contract should profile files use?
- `return require("insomnia.profile") { ... }`;
- `return { slug = ..., description = ..., model = ..., compaction = ... }`;
- explicit profile constructor vs plain table;
- how invalid Manifest-shaped returns are diagnosed.
- What is the minimal stable Insomnia profile API surface?
- profile constructors;
- model catalog access;
- context/compaction helpers;
- scope-intent helpers;
- memory/web/tool policy helpers;
- extension/merge helpers;
- optional presets.
- How much direct manifest-like field access should Profile expose versus requiring helper constructors?
- How are profile metadata and registry metadata reconciled when both exist?
- How should existing Nix profile support be handled: keep as advanced/external evaluator path, deprecate, or replace?
- What compatibility/migration story is acceptable for current built-in Nix profile files and existing user profiles?
- What validation and diagnostics are required before any implementation is merged?
## Acceptance criteria
- Capture the agreed requirements above in the work item and keep them synchronized with the design discussion.
- Record the unresolved language/API/sandbox/return-contract questions without prematurely deciding the full specification.
- Ensure `semantic-nix-profiles` implementation work does not proceed as if semantic JSON projection were the accepted design.
- Once the specification is decided, either update this ticket into the implementation ticket or create a follow-up implementation ticket with a clear intent packet.

View File

@ -0,0 +1,18 @@
<!-- event: create author: tickets.sh at: 2026-05-30T01:39:04Z -->
## Created
Created by tickets.sh create.
---
<!-- event: decision author: hare at: 2026-05-30T02:03:58Z -->
## Decision
Updated the requirements to reflect the current interpretation: Profile is a reusable manifest-like recipe template, not necessarily a separate semantic-only projection. The boundary is that runtime-bound and authority-bearing fields (`pod.name`, concrete `scope.allow`, resolved paths, secret material, runtime state) stay out of Profile, while reusable recipe fields such as model, worker/reasoning, compaction, memory, web, tool policy, and scope intent may remain close to Manifest shape.
Lua-specific notes now record controlled `require` and a public `profile` constructor as likely core primitives, while keeping the exact language/API/return contract open.
---