Compare commits

...

10 Commits

37 changed files with 1463 additions and 480 deletions

17
Cargo.lock generated
View File

@ -2130,6 +2130,7 @@ dependencies = [
"protocol", "protocol",
"provider", "provider",
"schemars", "schemars",
"scope-lock",
"serde", "serde",
"serde_json", "serde_json",
"session-store", "session-store",
@ -2746,6 +2747,20 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "scope-lock"
version = "0.1.0"
dependencies = [
"fs4",
"libc",
"manifest",
"serde",
"serde_json",
"session-store",
"tempfile",
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
@ -3574,7 +3589,9 @@ dependencies = [
"manifest", "manifest",
"protocol", "protocol",
"ratatui", "ratatui",
"scope-lock",
"serde_json", "serde_json",
"session-store",
"tokio", "tokio",
"toml", "toml",
"unicode-width", "unicode-width",

View File

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

View File

@ -5,19 +5,17 @@
- [ ] Pod CLI: マニフェスト関連フラグの整理 → [tickets/pod-cli-manifest-flags.md](tickets/pod-cli-manifest-flags.md) - [ ] Pod CLI: マニフェスト関連フラグの整理 → [tickets/pod-cli-manifest-flags.md](tickets/pod-cli-manifest-flags.md)
- [ ] Pod オーケストレーション - [ ] Pod オーケストレーション
- [ ] 動的 Scope 変更 → [tickets/dynamic-scope.md](tickets/dynamic-scope.md) - [ ] 動的 Scope 変更 → [tickets/dynamic-scope.md](tickets/dynamic-scope.md)
- [ ] `scope-lock``pod-registry` リネーム → [tickets/pod-registry-rename.md](tickets/pod-registry-rename.md)
- [ ] ネイティブ GUI クライアント MVP → [tickets/native-gui-mvp.md](tickets/native-gui-mvp.md) - [ ] ネイティブ GUI クライアント MVP → [tickets/native-gui-mvp.md](tickets/native-gui-mvp.md)
- [ ] TUI 拡充 - [ ] TUI 拡充
- [ ] フルスクリーン化によるオーバーホール → [tickets/tui-fullscreen-overhaul.md](tickets/tui-fullscreen-overhaul.md) - [ ] フルスクリーン化によるオーバーホール → [tickets/tui-fullscreen-overhaul.md](tickets/tui-fullscreen-overhaul.md)
- [ ] Run 中の入力キューイング → [tickets/tui-input-queue.md](tickets/tui-input-queue.md) - [ ] Run 中の入力キューイング → [tickets/tui-input-queue.md](tickets/tui-input-queue.md)
- [ ] ユーザーマニフェストのモデル設定 wizard → [tickets/tui-user-model-setup.md](tickets/tui-user-model-setup.md) - [ ] ユーザーマニフェストのモデル設定 wizard → [tickets/tui-user-model-setup.md](tickets/tui-user-model-setup.md)
- [ ] 既存セッションからの Pod 復帰 → [tickets/tui-session-restore.md](tickets/tui-session-restore.md)
- [ ] サブミット入力 - [ ] サブミット入力
- [ ] TUI 補完 + 型付き atom 化 → [tickets/submit-tui-completion.md](tickets/submit-tui-completion.md) - [ ] TUI 補完 + 型付き atom 化 → [tickets/submit-tui-completion.md](tickets/submit-tui-completion.md)
- [ ] セッションログの Segment 保持 → [tickets/session-log-segments.md](tickets/session-log-segments.md) - [ ] セッションログの Segment 保持 → [tickets/session-log-segments.md](tickets/session-log-segments.md)
- [ ] メモリ機構 - [ ] メモリ機構
- [ ] Phase 1 活動抽出 → [tickets/memory-phase1-extract.md](tickets/memory-phase1-extract.md)
- [ ] Phase 2 consolidation → [tickets/memory-phase2-consolidation.md](tickets/memory-phase2-consolidation.md) - [ ] Phase 2 consolidation → [tickets/memory-phase2-consolidation.md](tickets/memory-phase2-consolidation.md)
- [ ] 使用頻度メトリクス + Knowledge 化候補レポート → [tickets/memory-usage-metrics.md](tickets/memory-usage-metrics.md) - [ ] 使用頻度メトリクス + Knowledge 化候補レポート → [tickets/memory-usage-metrics.md](tickets/memory-usage-metrics.md)
- [ ] GC定期再評価 → [tickets/memory-gc.md](tickets/memory-gc.md) - [ ] GC定期再評価 → [tickets/memory-gc.md](tickets/memory-gc.md)
- ワークスペースのメモリーをLintするヘッドレスCLI - ワークスペースのメモリーをLintするヘッドレスCLI
- [ ] Thinking ブロックの TUI 表示 → [tickets/tui-thinking-display.md](tickets/tui-thinking-display.md)

View File

@ -16,11 +16,12 @@ pub use scheme_impl::OpenAIResponsesState;
/// OpenAI Responses scheme 本体。 /// OpenAI Responses scheme 本体。
/// ///
/// `store` / `include_encrypted_content` は scheme 固定の wire 設定で、 /// `store` / `include_encrypted_content` / `send_max_output_tokens` は
/// デフォルトは stateless + ZDR 相当 (`store=false`, `include=[...]`)。 /// scheme 固定の wire 設定で、デフォルトは公式 OpenAI Responses API
/// 将来 ZDR 非対応環境で `store=true` にしたくなった場合に限り override /// 向け (stateless + ZDR + `max_output_tokens` 送出可)。ChatGPT backend
/// する。`ModelCapability` には入れない(これはモデルの能力ではなく、 /// (codex-oauth) のように受理パラメータが subset の経路では provider 層で
/// クライアントの運用方針)。 /// `send_max_output_tokens=false` 等に上書きする。`ModelCapability` には
/// 入れない(モデル能力ではなく wire policy
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct OpenAIResponsesScheme { pub struct OpenAIResponsesScheme {
/// サーバ側に response を保存するか。ZDR/stateless 運用では `false`。 /// サーバ側に response を保存するか。ZDR/stateless 運用では `false`。
@ -28,6 +29,10 @@ pub struct OpenAIResponsesScheme {
/// `include: ["reasoning.encrypted_content"]` を付けるか。 /// `include: ["reasoning.encrypted_content"]` を付けるか。
/// `store=false` で reasoning を使うなら必須。 /// `store=false` で reasoning を使うなら必須。
pub include_encrypted_content: bool, pub include_encrypted_content: bool,
/// `max_output_tokens` を body に載せるか。公式 OpenAI Responses API は
/// 受理するが、ChatGPT backend (codex-oauth) は `Unsupported parameter`
/// で 400 を返すため、その経路では `false` にする。
pub send_max_output_tokens: bool,
} }
impl Default for OpenAIResponsesScheme { impl Default for OpenAIResponsesScheme {
@ -35,12 +40,14 @@ impl Default for OpenAIResponsesScheme {
Self { Self {
store: false, store: false,
include_encrypted_content: true, include_encrypted_content: true,
send_max_output_tokens: true,
} }
} }
} }
impl OpenAIResponsesScheme { impl OpenAIResponsesScheme {
/// デフォルト設定 (`store=false`, `include=["reasoning.encrypted_content"]`)。 /// デフォルト設定 (`store=false`, `include=["reasoning.encrypted_content"]`,
/// `send_max_output_tokens=true`)。
pub fn new() -> Self { pub fn new() -> Self {
Self::default() Self::default()
} }
@ -56,4 +63,10 @@ impl OpenAIResponsesScheme {
self.include_encrypted_content = include; self.include_encrypted_content = include;
self self
} }
/// `max_output_tokens` を body に載せるかを上書き。
pub fn with_send_max_output_tokens(mut self, send: bool) -> Self {
self.send_max_output_tokens = send;
self
}
} }

View File

@ -38,6 +38,9 @@ pub(crate) struct ResponsesRequest {
/// `["reasoning.encrypted_content"]` 等。 /// `["reasoning.encrypted_content"]` 等。
#[serde(skip_serializing_if = "Vec::is_empty")] #[serde(skip_serializing_if = "Vec::is_empty")]
pub include: Vec<&'static str>, pub include: Vec<&'static str>,
/// 公式 OpenAI Responses API では受理されるが、ChatGPT backend
/// (codex-oauth) は 400 で弾く。scheme の `send_max_output_tokens`
/// が `false` のときは `None` のまま送る (skip_serializing_if で除外)。
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<u32>, pub max_output_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@ -195,7 +198,11 @@ impl OpenAIResponsesScheme {
store: self.store, store: self.store,
stream: true, stream: true,
include, include,
max_output_tokens: request.config.max_tokens, max_output_tokens: if self.send_max_output_tokens {
request.config.max_tokens
} else {
None
},
temperature: request.config.temperature, temperature: request.config.temperature,
top_p: request.config.top_p, top_p: request.config.top_p,
} }
@ -444,13 +451,26 @@ mod tests {
} }
#[test] #[test]
fn max_output_tokens_passed_through() { fn max_output_tokens_passed_through_by_default() {
let scheme = OpenAIResponsesScheme::new(); let scheme = OpenAIResponsesScheme::new();
let req = Request::new().user("hi").max_tokens(100); let req = Request::new().user("hi").max_tokens(100);
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning()); let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
assert_eq!(body.max_output_tokens, Some(100)); assert_eq!(body.max_output_tokens, Some(100));
} }
#[test]
fn max_output_tokens_dropped_when_send_disabled() {
let scheme = OpenAIResponsesScheme::new().with_send_max_output_tokens(false);
let req = Request::new().user("hi").max_tokens(100);
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
assert_eq!(body.max_output_tokens, None);
let json = serde_json::to_value(&body).unwrap();
assert!(
json.get("max_output_tokens").is_none(),
"max_output_tokens key must not appear in serialised body, got: {json}"
);
}
#[test] #[test]
fn tool_schema_without_properties_is_normalized() { fn tool_schema_without_properties_is_normalized() {
// schemars は引数なし struct から `type:"object"` だけのスキーマを // schemars は引数なし struct から `type:"object"` だけのスキーマを

View File

@ -3,8 +3,9 @@
use serde_json::Value; use serde_json::Value;
use crate::llm_client::{ use crate::llm_client::{
ClientError, auth::AuthRequirement, capability::ModelCapability, event::Event, scheme::Scheme, ClientError, auth::AuthRequirement, capability::ModelCapability,
types::Request, client::ConfigWarning, event::Event, scheme::Scheme,
types::{Request, RequestConfig},
}; };
use super::OpenAIResponsesScheme; use super::OpenAIResponsesScheme;
@ -51,4 +52,18 @@ impl Scheme for OpenAIResponsesScheme {
fn default_capability(&self) -> ModelCapability { fn default_capability(&self) -> ModelCapability {
super::capability::default_capability() super::capability::default_capability()
} }
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
let mut warnings = Vec::new();
// ChatGPT backend (codex-oauth) は `max_output_tokens` を 400 で弾く。
// scheme 構築時に `send_max_output_tokens=false` で組まれていれば
// body 投影は止まっているので、ユーザの意図が落ちることだけを通知する。
if !self.send_max_output_tokens && config.max_tokens.is_some() {
warnings.push(ConfigWarning::unsupported(
"max_tokens",
"OpenAI Responses (ChatGPT backend)",
));
}
warnings
}
} }

View File

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

View File

@ -4,7 +4,7 @@ use std::process::ExitCode;
use clap::Parser; use clap::Parser;
use manifest::paths; use manifest::paths;
use pod::{Pod, PodController, PodFactory}; use pod::{Pod, PodController, PodFactory};
use session_store::FsStore; use session_store::{FsStore, SessionId};
#[derive(Parser)] #[derive(Parser)]
#[command( #[command(
@ -43,6 +43,14 @@ struct Cli {
/// callbacks upward. Required alongside `--adopt`. /// callbacks upward. Required alongside `--adopt`.
#[arg(long, value_name = "PATH", requires = "adopt")] #[arg(long, value_name = "PATH", requires = "adopt")]
callback: Option<PathBuf>, callback: Option<PathBuf>,
/// Restore a Pod from an existing session. The Pod re-uses the
/// given session id and appends new turns to the same jsonl;
/// concurrent writers are prevented by the `scope.lock` registry.
/// Mutually exclusive with `--adopt` (spawned children always start
/// fresh).
#[arg(long, value_name = "UUID", conflicts_with = "adopt")]
session: Option<SessionId>,
} }
async fn build_factory(cli: &Cli) -> Result<PodFactory, String> { async fn build_factory(cli: &Cli) -> Result<PodFactory, String> {
@ -136,6 +144,14 @@ async fn main() -> ExitCode {
return ExitCode::FAILURE; return ExitCode::FAILURE;
} }
} }
} else if let Some(source_session_id) = cli.session {
match Pod::restore_from_manifest(source_session_id, manifest, store, loader).await {
Ok(p) => p,
Err(e) => {
eprintln!("error: failed to restore pod: {e}");
return ExitCode::FAILURE;
}
}
} else { } else {
match Pod::from_manifest(manifest, store, loader).await { match Pod::from_manifest(manifest, store, loader).await {
Ok(p) => p, Ok(p) => p,

View File

@ -100,10 +100,11 @@ pub struct Pod<C: LlmClient, St: Store> {
/// PodInterceptor installed in `ensure_interceptor_installed`. /// PodInterceptor installed in `ensure_interceptor_installed`.
pending_notifies: NotifyBuffer, pending_notifies: NotifyBuffer,
/// Scope allocation in the machine-wide lock file. `Some` for /// Scope allocation in the machine-wide lock file. `Some` for
/// Pods built via `from_manifest` (production path); `None` for /// Pods built via `from_manifest` / `from_manifest_spawned` /
/// lower-level constructors (`Pod::new`, `Pod::restore`) that /// `restore_from_manifest` (production paths); `None` for the
/// bypass the registry. Kept purely for its `Drop` impl, which /// low-level `Pod::new` constructor used in tests, which bypasses
/// releases the allocation when the Pod is dropped. /// the registry. Kept purely for its `Drop` impl, which releases
/// the allocation when the Pod is dropped.
#[allow(dead_code)] #[allow(dead_code)]
scope_allocation: Option<ScopeAllocationGuard>, scope_allocation: Option<ScopeAllocationGuard>,
/// Socket path of the spawning Pod. `Some` only for Pods built via /// Socket path of the spawning Pod. `Some` only for Pods built via
@ -210,75 +211,11 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
self.inject_resident_knowledge = enabled; self.inject_resident_knowledge = enabled;
} }
/// Restore a Pod from a persisted session.
/// Shared handle to the prompt catalog. Cheap to clone (`Arc`). /// Shared handle to the prompt catalog. Cheap to clone (`Arc`).
pub fn prompts(&self) -> &Arc<PromptCatalog> { pub fn prompts(&self) -> &Arc<PromptCatalog> {
&self.prompts &self.prompts
} }
pub async fn restore(
session_id: SessionId,
manifest: PodManifest,
client: C,
store: St,
pwd: PathBuf,
scope: Scope,
) -> Result<Self, PodError> {
let state = session_store::restore(&store, session_id).await?;
let mut worker = Worker::new(client);
if let Some(ref prompt) = state.system_prompt {
worker.set_system_prompt(prompt);
}
// A leading `Role::System` item can only come from `compact`
// (the Pod's one and only write path that prepends a summary at
// history[0]). Restoring the anchor lets Anthropic re-use a
// stable cache prefix for long-lived restored sessions.
let anchored_on_summary = matches!(
state.history.first(),
Some(Item::Message {
role: llm_worker::Role::System,
..
})
);
worker.set_history(state.history);
worker.set_request_config(state.config);
worker.set_turn_count(state.turn_count);
worker.set_last_run_interrupted(state.last_run_interrupted);
if anchored_on_summary {
worker.set_cache_anchor(Some(0));
}
let prompts = PromptCatalog::builtins_only()?;
let extract_pointer = memory::extract::fold_pointer(&state.extensions);
let mut pod = Self {
manifest,
worker: Some(worker),
store,
session_id,
head_hash: state.head_hash,
pwd,
scope,
hook_builder: HookRegistryBuilder::new(),
interceptor_installed: false,
compact_state: None,
usage_tracker: Arc::new(UsageTracker::new()),
usage_history: Arc::new(Mutex::new(state.usage_history)),
tracker: None,
system_prompt_template: None,
alerter: None,
event_tx: None,
pending_notifies: NotifyBuffer::new(),
scope_allocation: None,
callback_socket: None,
prompts,
inject_resident_knowledge: true,
extract_in_flight: Arc::new(AtomicBool::new(false)),
extract_pointer: Mutex::new(extract_pointer),
};
pod.apply_prune_from_manifest();
Ok(pod)
}
/// The session ID used for persistence. /// The session ID used for persistence.
pub fn session_id(&self) -> SessionId { pub fn session_id(&self) -> SessionId {
self.session_id self.session_id
@ -781,6 +718,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
self.head_hash = Some(hash); self.head_hash = Some(hash);
return Ok(()); return Ok(());
} }
let prev_session_id = self.session_id;
session_store::ensure_head_or_fork( session_store::ensure_head_or_fork(
&self.store, &self.store,
&mut self.session_id, &mut self.session_id,
@ -788,6 +726,13 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
state, state,
) )
.await?; .await?;
// ensure_head_or_fork mints a fresh session_id when it auto-
// forks. Sync that to scope.lock so a concurrent
// restore_from_manifest can't see "no live writer" for the new
// session and grab it.
if self.session_id != prev_session_id && self.scope_allocation.is_some() {
scope_lock::update_session(&self.manifest.pod.name, self.session_id)?;
}
Ok(()) Ok(())
} }
@ -1064,7 +1009,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
let mut summary_worker = Worker::new(summary_client) let mut summary_worker = Worker::new(summary_client)
.system_prompt(summary_system_prompt) .system_prompt(summary_system_prompt)
.temperature(0.0); .temperature(0.0);
summary_worker.set_max_tokens(4096);
// Cumulative input-token meter + interceptor. The meter is bumped // Cumulative input-token meter + interceptor. The meter is bumped
// from the on_usage callback and read on every pre_llm_request. // from the on_usage callback and read on every pre_llm_request.
@ -1220,6 +1164,15 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
// until its first LLM call. // until its first LLM call.
self.session_id = new_session_id; self.session_id = new_session_id;
self.head_hash = Some(new_head_hash); self.head_hash = Some(new_head_hash);
// Keep scope.lock pointing at the live session_id. Without this
// a concurrent `restore_from_manifest(new_session_id)` would
// see no live writer and grab the session this Pod just moved
// into, causing two writers to race on the same jsonl. Skipped
// when no allocation is installed (e.g. compact under
// `Pod::new` in tests).
if self.scope_allocation.is_some() {
scope_lock::update_session(&self.manifest.pod.name, new_session_id)?;
}
let worker = self.worker.as_mut().unwrap(); let worker = self.worker.as_mut().unwrap();
worker.set_history(new_history); worker.set_history(new_history);
// Anchor the prompt cache at the summary item so that Anthropic // Anchor the prompt cache at the summary item so that Anthropic
@ -1413,7 +1366,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
let mut extract_worker = Worker::new(client) let mut extract_worker = Worker::new(client)
.system_prompt(extract::EXTRACT_SYSTEM_PROMPT) .system_prompt(extract::EXTRACT_SYSTEM_PROMPT)
.temperature(0.0); .temperature(0.0);
extract_worker.set_max_tokens(4096);
// Cumulative input-token meter + interceptor (mirror of // Cumulative input-token meter + interceptor (mirror of
// CompactWorkerInterceptor). Aborts the extract worker if its // CompactWorkerInterceptor). Aborts the extract worker if its
@ -1536,15 +1488,18 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
store: St, store: St,
loader: PromptLoader, loader: PromptLoader,
) -> Result<Self, PodError> { ) -> Result<Self, PodError> {
let pwd = current_pwd()?; let common = prepare_pod_common(&manifest, &loader, /* parse_template */ true)?;
let scope = build_scope_with_memory(&manifest, &pwd)?;
if !scope.is_readable(&pwd) { // Session creation is deferred to the first run (see
return Err(PodError::PwdOutsideScope { pwd }); // `ensure_session_head`) so the SessionStart entry can capture
} // the rendered system prompt, not the raw template source. The
// session_id is allocated here so the scope-lock registration
// can record it from the start.
let session_id = session_store::new_session_id();
// Register this Pod in the machine-wide scope-lock registry // Register this Pod in the machine-wide scope-lock registry
// before building anything else, so a spawn that conflicts on // before building anything else, so a spawn that conflicts on
// scope fails fast (and without having paid for client setup). // scope fails fast.
let socket_path = dir::default_base() let socket_path = dir::default_base()
.map_err(ScopeLockError::from)? .map_err(ScopeLockError::from)?
.join(&manifest.pod.name) .join(&manifest.pod.name)
@ -1553,50 +1508,34 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
manifest.pod.name.clone(), manifest.pod.name.clone(),
std::process::id(), std::process::id(),
socket_path, socket_path,
scope.allow_rules(), common.scope.allow_rules(),
session_id,
)?; )?;
let client = provider::build_client(&manifest.model)?; let mut worker = Worker::new(common.client);
let mut worker = Worker::new(client);
apply_worker_manifest(&mut worker, &manifest.worker); apply_worker_manifest(&mut worker, &manifest.worker);
// Resolve the instruction reference and parse the resulting
// template eagerly (syntax check only). Rendering is deferred
// to `ensure_system_prompt_materialized` at first turn so
// runtime values (date, tools, scope summary, ...) can be
// injected.
let system_prompt_template = Some(
SystemPromptTemplate::parse(&manifest.worker.instruction, loader.clone())
.map_err(|source| PodError::InvalidSystemPromptTemplate { source })?,
);
let prompts = PromptCatalog::load(&loader, manifest.pod.prompt_pack.as_deref())?;
// Session creation is deferred to the first run (see
// `ensure_session_head`) so the SessionStart entry can capture
// the rendered system prompt, not the raw template source.
let session_id = session_store::new_session_id();
let mut pod = Self { let mut pod = Self {
manifest, manifest,
worker: Some(worker), worker: Some(worker),
store, store,
session_id, session_id,
head_hash: None, head_hash: None,
pwd, pwd: common.pwd,
scope, scope: common.scope,
hook_builder: HookRegistryBuilder::new(), hook_builder: HookRegistryBuilder::new(),
interceptor_installed: false, interceptor_installed: false,
compact_state: None, compact_state: None,
usage_tracker: Arc::new(UsageTracker::new()), usage_tracker: Arc::new(UsageTracker::new()),
usage_history: Arc::new(Mutex::new(Vec::new())), usage_history: Arc::new(Mutex::new(Vec::new())),
tracker: None, tracker: None,
system_prompt_template, system_prompt_template: common.system_prompt_template,
alerter: None, alerter: None,
event_tx: None, event_tx: None,
pending_notifies: NotifyBuffer::new(), pending_notifies: NotifyBuffer::new(),
scope_allocation: Some(scope_allocation), scope_allocation: Some(scope_allocation),
callback_socket: None, callback_socket: None,
prompts, prompts: common.prompts,
inject_resident_knowledge: true, inject_resident_knowledge: true,
extract_in_flight: Arc::new(AtomicBool::new(false)), extract_in_flight: Arc::new(AtomicBool::new(false)),
extract_pointer: Mutex::new(None), extract_pointer: Mutex::new(None),
@ -1612,57 +1551,43 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
/// [`scope_lock::delegate_scope`], rather than installing a new /// [`scope_lock::delegate_scope`], rather than installing a new
/// top-level entry. `callback_socket` carries the spawner's /// top-level entry. `callback_socket` carries the spawner's
/// Unix-socket path so the spawned Pod can send `Method::Notify` /// Unix-socket path so the spawned Pod can send `Method::Notify`
/// back to the spawner; it is stored but unused in the /// back to the spawner.
/// `spawn-pod-tool` ticket — the receiving side lands in the
/// follow-up `pod-callback` ticket.
pub async fn from_manifest_spawned( pub async fn from_manifest_spawned(
manifest: PodManifest, manifest: PodManifest,
store: St, store: St,
loader: PromptLoader, loader: PromptLoader,
callback_socket: PathBuf, callback_socket: PathBuf,
) -> Result<Self, PodError> { ) -> Result<Self, PodError> {
let pwd = current_pwd()?; let common = prepare_pod_common(&manifest, &loader, /* parse_template */ true)?;
let scope = build_scope_with_memory(&manifest, &pwd)?;
if !scope.is_readable(&pwd) {
return Err(PodError::PwdOutsideScope { pwd });
}
let scope_allocation =
scope_lock::adopt_allocation(manifest.pod.name.clone(), std::process::id())?;
let client = provider::build_client(&manifest.model)?;
let mut worker = Worker::new(client);
apply_worker_manifest(&mut worker, &manifest.worker);
let system_prompt_template = Some(
SystemPromptTemplate::parse(&manifest.worker.instruction, loader.clone())
.map_err(|source| PodError::InvalidSystemPromptTemplate { source })?,
);
let prompts = PromptCatalog::load(&loader, manifest.pod.prompt_pack.as_deref())?;
let session_id = session_store::new_session_id(); let session_id = session_store::new_session_id();
let scope_allocation =
scope_lock::adopt_allocation(manifest.pod.name.clone(), std::process::id(), session_id)?;
let mut worker = Worker::new(common.client);
apply_worker_manifest(&mut worker, &manifest.worker);
let mut pod = Self { let mut pod = Self {
manifest, manifest,
worker: Some(worker), worker: Some(worker),
store, store,
session_id, session_id,
head_hash: None, head_hash: None,
pwd, pwd: common.pwd,
scope, scope: common.scope,
hook_builder: HookRegistryBuilder::new(), hook_builder: HookRegistryBuilder::new(),
interceptor_installed: false, interceptor_installed: false,
compact_state: None, compact_state: None,
usage_tracker: Arc::new(UsageTracker::new()), usage_tracker: Arc::new(UsageTracker::new()),
usage_history: Arc::new(Mutex::new(Vec::new())), usage_history: Arc::new(Mutex::new(Vec::new())),
tracker: None, tracker: None,
system_prompt_template, system_prompt_template: common.system_prompt_template,
alerter: None, alerter: None,
event_tx: None, event_tx: None,
pending_notifies: NotifyBuffer::new(), pending_notifies: NotifyBuffer::new(),
scope_allocation: Some(scope_allocation), scope_allocation: Some(scope_allocation),
callback_socket: Some(callback_socket), callback_socket: Some(callback_socket),
prompts, prompts: common.prompts,
inject_resident_knowledge: true, inject_resident_knowledge: true,
extract_in_flight: Arc::new(AtomicBool::new(false)), extract_in_flight: Arc::new(AtomicBool::new(false)),
extract_pointer: Mutex::new(None), extract_pointer: Mutex::new(None),
@ -1671,6 +1596,113 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
Ok(pod) Ok(pod)
} }
/// Restore a Pod from an existing session log.
///
/// Resolves the manifest cascade exactly like [`Self::from_manifest`]
/// (pwd / scope / scope-lock / client / prompt catalog), seeds a
/// fresh Worker from the source session's `RestoredState`, and
/// reuses the same `session_id` so subsequent turns append to the
/// source jsonl as a continuation of the same conversation.
///
/// Concurrent writers are prevented by the `scope.lock` registry:
/// the registration carries `session_id`, and this constructor
/// refuses to start when `scope_lock::lookup_session` already finds
/// a live Pod writing to `session_id`. So there is no need to fork —
/// resume is "the same session, a different process owning it".
///
/// `system_prompt` is replayed verbatim from the session log —
/// templates are not re-rendered on restore so a long-running
/// session keeps a stable cache prefix even when the manifest's
/// instruction template would render differently today.
pub async fn restore_from_manifest(
session_id: SessionId,
manifest: PodManifest,
store: St,
loader: PromptLoader,
) -> Result<Self, PodError> {
let state = session_store::restore(&store, session_id).await?;
if state.head_hash.is_none() {
return Err(PodError::SessionEmpty { session_id });
}
let common = prepare_pod_common(&manifest, &loader, /* parse_template */ false)?;
// Atomic: register_pod inside install_top_level rejects when
// another live allocation already holds `session_id`. Wrapping
// the lookup + install inside a single `LockFileGuard` is what
// makes "no two live Pods write to the same session log"
// actually structural rather than a hopeful pre-check.
let socket_path = dir::default_base()
.map_err(ScopeLockError::from)?
.join(&manifest.pod.name)
.join("sock");
let scope_allocation = scope_lock::install_top_level(
manifest.pod.name.clone(),
std::process::id(),
socket_path,
common.scope.allow_rules(),
session_id,
)?;
// Build the worker and apply the manifest defaults first, then
// overwrite the pieces the session log is authoritative for.
let mut worker = Worker::new(common.client);
apply_worker_manifest(&mut worker, &manifest.worker);
if let Some(ref prompt) = state.system_prompt {
worker.set_system_prompt(prompt);
}
// A leading `Role::System` item can only come from `compact`
// (the Pod's one and only write path that prepends a summary at
// history[0]). Restoring the anchor lets Anthropic re-use a
// stable cache prefix for long-lived restored sessions.
let anchored_on_summary = matches!(
state.history.first(),
Some(Item::Message {
role: llm_worker::Role::System,
..
})
);
worker.set_history(state.history.clone());
worker.set_request_config(state.config.clone());
worker.set_turn_count(state.turn_count);
worker.set_last_run_interrupted(state.last_run_interrupted);
if anchored_on_summary {
worker.set_cache_anchor(Some(0));
}
let extract_pointer = memory::extract::fold_pointer(&state.extensions);
let mut pod = Self {
manifest,
worker: Some(worker),
store,
session_id,
head_hash: state.head_hash,
pwd: common.pwd,
scope: common.scope,
hook_builder: HookRegistryBuilder::new(),
interceptor_installed: false,
compact_state: None,
usage_tracker: Arc::new(UsageTracker::new()),
usage_history: Arc::new(Mutex::new(state.usage_history)),
tracker: None,
// Restore replays the saved system_prompt verbatim — no
// template re-render on resume.
system_prompt_template: None,
alerter: None,
event_tx: None,
pending_notifies: NotifyBuffer::new(),
scope_allocation: Some(scope_allocation),
callback_socket: None,
prompts: common.prompts,
inject_resident_knowledge: true,
extract_in_flight: Arc::new(AtomicBool::new(false)),
extract_pointer: Mutex::new(extract_pointer),
};
pod.apply_prune_from_manifest();
Ok(pod)
}
/// Convenience: build a Pod from a single-layer TOML manifest string. /// Convenience: build a Pod from a single-layer TOML manifest string.
/// ///
/// Parses the TOML into a [`PodManifestConfig`], converts to a /// Parses the TOML into a [`PodManifestConfig`], converts to a
@ -1864,6 +1896,63 @@ pub enum PodError {
#[error("memory Phase 1 staging write failed: {0}")] #[error("memory Phase 1 staging write failed: {0}")]
ExtractStaging(#[source] memory::extract::StagingError), ExtractStaging(#[source] memory::extract::StagingError),
#[error("session {session_id} has no entries to restore")]
SessionEmpty { session_id: SessionId },
}
/// 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.
struct PodCommon {
pwd: PathBuf,
scope: Scope,
client: Box<dyn LlmClient>,
prompts: Arc<PromptCatalog>,
system_prompt_template: Option<SystemPromptTemplate>,
}
/// Resolve pwd / scope / LLM client / prompt catalog from a validated
/// manifest cascade. Used by `from_manifest`, `from_manifest_spawned`,
/// and `restore_from_manifest` so they share one definition of "what
/// pieces fall out of a manifest".
///
/// `parse_template` controls whether the manifest's instruction is
/// parsed as a system-prompt template. New Pods always parse so the
/// template is rendered at first turn; restored Pods skip parsing
/// because the saved session log replays a previously-rendered
/// `system_prompt` verbatim.
fn prepare_pod_common(
manifest: &PodManifest,
loader: &PromptLoader,
parse_template: bool,
) -> Result<PodCommon, PodError> {
let pwd = current_pwd()?;
let scope = build_scope_with_memory(manifest, &pwd)?;
if !scope.is_readable(&pwd) {
return Err(PodError::PwdOutsideScope { pwd });
}
let client = provider::build_client(&manifest.model)?;
let prompts = PromptCatalog::load(loader, manifest.pod.prompt_pack.as_deref())?;
let system_prompt_template = if parse_template {
Some(
SystemPromptTemplate::parse(&manifest.worker.instruction, loader.clone())
.map_err(|source| PodError::InvalidSystemPromptTemplate { source })?,
)
} else {
None
};
Ok(PodCommon {
pwd,
scope,
client,
prompts,
system_prompt_template,
})
} }
/// Build the Pod's runtime [`Scope`] from the manifest, layering the /// Build the Pod's runtime [`Scope`] from the manifest, layering the

View File

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

View File

@ -441,7 +441,8 @@ fn scope_lock_err_to_tool(e: ScopeLockError) -> ToolError {
ScopeLockError::NotSubset { .. } ScopeLockError::NotSubset { .. }
| ScopeLockError::WriteConflict { .. } | ScopeLockError::WriteConflict { .. }
| ScopeLockError::DuplicatePodName(_) | ScopeLockError::DuplicatePodName(_)
| ScopeLockError::UnknownPod(_) => ToolError::InvalidArgument(e.to_string()), | ScopeLockError::UnknownPod(_)
| ScopeLockError::SessionConflict { .. } => ToolError::InvalidArgument(e.to_string()),
ScopeLockError::Io(_) => ToolError::ExecutionFailed(e.to_string()), ScopeLockError::Io(_) => ToolError::ExecutionFailed(e.to_string()),
} }
} }

View File

@ -351,6 +351,7 @@ async fn stop_pod_sends_shutdown_and_releases_scope() {
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
}], }],
session_store::new_session_id(),
) )
.unwrap(); .unwrap();
scope_lock::delegate_scope( scope_lock::delegate_scope(

View File

@ -358,6 +358,7 @@ async fn shutdown_releases_scope_allocation_when_present() {
std::process::id(), std::process::id(),
"/tmp/kid.sock".into(), "/tmp/kid.sock".into(),
vec![], vec![],
session_store::new_session_id(),
) )
.unwrap(); .unwrap();
std::mem::forget(guard); std::mem::forget(guard);

View File

@ -0,0 +1,86 @@
//! Integration tests for `Pod::restore_from_manifest`'s pre-build
//! validation paths.
//!
//! These cases all return before `prepare_pod_common` runs, so they
//! do not need a real LLM client or scope-lock environment — only the
//! session store needs to be present.
use std::sync::{LazyLock, Mutex};
use pod::{Pod, PodError};
use session_store::{FsStore, SessionId, StoreError};
const MINIMAL_MANIFEST_TOML: &str = r#"
[pod]
name = "restore-test"
pwd = "./"
[model]
scheme = "anthropic"
model_id = "test-model"
[worker]
max_tokens = 100
[[scope.allow]]
target = "./"
permission = "write"
"#;
/// Serialises tests that mutate runtime-dir env vars, mirroring the
/// pattern used by other integration tests in this crate.
static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
#[tokio::test]
async fn restore_from_manifest_rejects_unknown_session() {
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()).await.unwrap();
let manifest = pod::PodManifest::from_toml(MINIMAL_MANIFEST_TOML).unwrap();
// A freshly-minted id with no jsonl file at all → store returns
// NotFound, which `Pod::restore_from_manifest` surfaces verbatim
// as `PodError::Store`.
let unknown = session_store::new_session_id();
let result = Pod::restore_from_manifest(
unknown,
manifest,
store,
pod::PromptLoader::builtins_only(),
)
.await;
match result {
Err(PodError::Store(StoreError::NotFound(id))) => assert_eq!(id, unknown),
Err(other) => panic!("expected Store(NotFound), got {other:?}"),
Ok(_) => panic!("expected unknown session to fail"),
}
}
#[tokio::test]
async fn restore_from_manifest_rejects_empty_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()).await.unwrap();
let manifest = pod::PodManifest::from_toml(MINIMAL_MANIFEST_TOML).unwrap();
// Pre-create an empty `<id>.jsonl` so `read_all` succeeds with no
// entries. `collect_state` returns `head_hash = None`, which
// `restore_from_manifest` rejects with `SessionEmpty` *before* it
// gets as far as building the LLM client — so the test does not
// need credentials or a runtime sandbox.
let id: SessionId = session_store::new_session_id();
let path = store_tmp.path().join(format!("{id}.jsonl"));
std::fs::write(&path, b"").unwrap();
let result =
Pod::restore_from_manifest(id, manifest, store, pod::PromptLoader::builtins_only()).await;
match result {
Err(PodError::SessionEmpty { session_id }) => assert_eq!(session_id, id),
Err(other) => panic!("expected SessionEmpty, got {other:?}"),
Ok(_) => panic!("expected empty session log to fail"),
}
}

View File

@ -73,6 +73,7 @@ async fn setup_spawner(
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
}], }],
session_store::new_session_id(),
) )
.unwrap(); .unwrap();
// Leak the guard — the spawner allocation needs to outlive the // Leak the guard — the spawner allocation needs to outlive the

View File

@ -142,7 +142,12 @@ fn build_from_config(config: &ModelConfig) -> Result<Box<dyn LlmClient>, Provide
SchemeKind::OpenaiChat => build_transport(OpenAIScheme::new(), config, resolved), SchemeKind::OpenaiChat => build_transport(OpenAIScheme::new(), config, resolved),
SchemeKind::Gemini => build_transport(GeminiScheme::new(), config, resolved), SchemeKind::Gemini => build_transport(GeminiScheme::new(), config, resolved),
SchemeKind::OpenaiResponses => { SchemeKind::OpenaiResponses => {
build_transport(OpenAIResponsesScheme::new(), config, resolved) // ChatGPT backend (codex-oauth) は `max_output_tokens` を
// 400 で弾くため、その経路では送出を止める。
let scheme = OpenAIResponsesScheme::new().with_send_max_output_tokens(
!matches!(config.auth, AuthRef::CodexOAuth),
);
build_transport(scheme, config, resolved)
} }
} }
} }

View File

@ -0,0 +1,17 @@
[package]
name = "scope-lock"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
fs4 = { version = "0.13.1", features = ["sync"] }
libc = "0.2.185"
manifest = { version = "0.1.0", path = "../manifest" }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
session-store = { version = "0.1.0", path = "../session-store" }
thiserror = "2.0"
[dev-dependencies]
tempfile = "3.27.0"

View File

@ -21,6 +21,7 @@ use std::path::{Path, PathBuf};
use fs4::fs_std::FileExt; use fs4::fs_std::FileExt;
use manifest::{Permission, ScopeRule, paths}; use manifest::{Permission, ScopeRule, paths};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use session_store::SessionId;
/// On-disk representation of the allocation table. /// On-disk representation of the allocation table.
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
@ -50,6 +51,12 @@ pub struct Allocation {
/// Name of the Pod that delegated scope to this one, or `None` for /// Name of the Pod that delegated scope to this one, or `None` for
/// a top-level Pod started directly by a human. /// a top-level Pod started directly by a human.
pub delegated_from: Option<String>, pub delegated_from: Option<String>,
/// Session ID this Pod is currently writing to. `None` means this
/// is a pre-reservation made by a spawner via [`delegate_scope`]
/// before the child has come up; the child fills it in at
/// [`adopt_allocation`] time.
#[serde(default)]
pub session_id: Option<SessionId>,
} }
impl LockFile { impl LockFile {
@ -60,6 +67,14 @@ impl LockFile {
pub fn find_mut(&mut self, pod_name: &str) -> Option<&mut Allocation> { pub fn find_mut(&mut self, pod_name: &str) -> Option<&mut Allocation> {
self.allocations.iter_mut().find(|a| a.pod_name == pod_name) self.allocations.iter_mut().find(|a| a.pod_name == pod_name)
} }
/// Find the allocation currently writing to `session_id`. Skips
/// pre-reservations whose `session_id` is still `None`.
pub fn find_by_session(&self, session_id: SessionId) -> Option<&Allocation> {
self.allocations
.iter()
.find(|a| a.session_id == Some(session_id))
}
} }
/// Default on-disk path: `<runtime_dir>/scope.lock` resolved via /// Default on-disk path: `<runtime_dir>/scope.lock` resolved via
@ -288,17 +303,29 @@ fn find_conflict_in_subtree(
/// Register a top-level Pod (started directly by a human, no /// Register a top-level Pod (started directly by a human, no
/// delegation parent). Reclaims stale entries before checking /// delegation parent). Reclaims stale entries before checking
/// conflicts so a crashed Pod's allocation doesn't block the new one. /// conflicts so a crashed Pod's allocation doesn't block the new one.
///
/// Rejects when another live allocation is already writing to
/// `session_id`, so two `restore_from_manifest` calls under different
/// `pod_name`s cannot both grab the same session log.
pub fn register_pod( pub fn register_pod(
guard: &mut LockFileGuard, guard: &mut LockFileGuard,
pod_name: String, pod_name: String,
pid: u32, pid: u32,
socket: PathBuf, socket: PathBuf,
scope_allow: Vec<ScopeRule>, scope_allow: Vec<ScopeRule>,
session_id: SessionId,
) -> Result<(), ScopeLockError> { ) -> Result<(), ScopeLockError> {
reclaim_stale(guard); reclaim_stale(guard);
if guard.data().find(&pod_name).is_some() { if guard.data().find(&pod_name).is_some() {
return Err(ScopeLockError::DuplicatePodName(pod_name)); return Err(ScopeLockError::DuplicatePodName(pod_name));
} }
if let Some(existing) = guard.data().find_by_session(session_id) {
return Err(ScopeLockError::SessionConflict {
session_id,
pod_name: existing.pod_name.clone(),
socket: existing.socket.clone(),
});
}
for rule in scope_allow for rule in scope_allow
.iter() .iter()
.filter(|r| r.permission == Permission::Write) .filter(|r| r.permission == Permission::Write)
@ -316,6 +343,7 @@ pub fn register_pod(
socket, socket,
scope_allow, scope_allow,
delegated_from: None, delegated_from: None,
session_id: Some(session_id),
}); });
guard.save()?; guard.save()?;
Ok(()) Ok(())
@ -361,6 +389,9 @@ pub fn delegate_scope(
socket, socket,
scope_allow, scope_allow,
delegated_from: Some(spawner.into()), delegated_from: Some(spawner.into()),
// Pre-reservation. The child fills in its own session_id when
// it calls `adopt_allocation` after the worker is built.
session_id: None,
}); });
guard.save()?; guard.save()?;
Ok(()) Ok(())
@ -483,10 +514,18 @@ pub fn install_top_level(
pid: u32, pid: u32,
socket: PathBuf, socket: PathBuf,
scope_allow: Vec<ScopeRule>, scope_allow: Vec<ScopeRule>,
session_id: SessionId,
) -> Result<ScopeAllocationGuard, ScopeLockError> { ) -> Result<ScopeAllocationGuard, ScopeLockError> {
let lock_path = default_lock_path()?; let lock_path = default_lock_path()?;
let mut guard = LockFileGuard::open(&lock_path)?; let mut guard = LockFileGuard::open(&lock_path)?;
register_pod(&mut guard, pod_name.clone(), pid, socket, scope_allow)?; register_pod(
&mut guard,
pod_name.clone(),
pid,
socket,
scope_allow,
session_id,
)?;
Ok(ScopeAllocationGuard { Ok(ScopeAllocationGuard {
pod_name, pod_name,
lock_path, lock_path,
@ -497,13 +536,15 @@ pub fn install_top_level(
/// a spawning Pod. /// a spawning Pod.
/// ///
/// The spawning flow is two-stage: the spawner calls [`delegate_scope`] /// The spawning flow is two-stage: the spawner calls [`delegate_scope`]
/// (with its own pid as a live placeholder), then exec's the child; the /// (with its own pid as a live placeholder, `session_id = None`), then
/// child, once running, calls this function to rewrite the allocation's /// exec's the child; the child, once running, calls this function to
/// pid to its own and claim the `ScopeAllocationGuard` so the entry is /// rewrite the allocation's pid + session_id to its own and claim the
/// released when the child exits. /// `ScopeAllocationGuard` so the entry is released when the child
/// exits.
pub fn adopt_allocation( pub fn adopt_allocation(
pod_name: String, pod_name: String,
new_pid: u32, new_pid: u32,
session_id: SessionId,
) -> Result<ScopeAllocationGuard, ScopeLockError> { ) -> Result<ScopeAllocationGuard, ScopeLockError> {
let lock_path = default_lock_path()?; let lock_path = default_lock_path()?;
let mut guard = LockFileGuard::open(&lock_path)?; let mut guard = LockFileGuard::open(&lock_path)?;
@ -512,6 +553,7 @@ pub fn adopt_allocation(
.find_mut(&pod_name) .find_mut(&pod_name)
.ok_or_else(|| ScopeLockError::UnknownPod(pod_name.clone()))?; .ok_or_else(|| ScopeLockError::UnknownPod(pod_name.clone()))?;
alloc.pid = new_pid; alloc.pid = new_pid;
alloc.session_id = Some(session_id);
guard.save()?; guard.save()?;
Ok(ScopeAllocationGuard { Ok(ScopeAllocationGuard {
pod_name, pod_name,
@ -519,6 +561,73 @@ pub fn adopt_allocation(
}) })
} }
/// Rewrite the `session_id` recorded for `pod_name` to
/// `new_session_id`.
///
/// The Pod's in-memory `session_id` can change underneath the
/// allocation in two normal places:
///
/// - `Pod::compact` mints a fresh session and swaps it in.
/// - `session_store::ensure_head_or_fork` auto-forks when another
/// writer has advanced the store head behind our back.
///
/// Both paths must call this so subsequent `lookup_session` queries
/// find the live session id, not the old one. Without this update a
/// concurrent `restore_from_manifest(new_id)` would see "no live
/// writer" and proceed to register a competing allocation on the
/// session this Pod just moved into.
///
/// The lock is opened once and the allocation is rewritten inside the
/// guard, so the session_id collision check is atomic with the
/// rewrite.
pub fn update_session(pod_name: &str, new_session_id: SessionId) -> Result<(), ScopeLockError> {
let lock_path = default_lock_path()?;
let mut guard = LockFileGuard::open(&lock_path)?;
if let Some(other) = guard.data().find_by_session(new_session_id) {
if other.pod_name != pod_name {
return Err(ScopeLockError::SessionConflict {
session_id: new_session_id,
pod_name: other.pod_name.clone(),
socket: other.socket.clone(),
});
}
}
let alloc = guard
.data_mut()
.find_mut(pod_name)
.ok_or_else(|| ScopeLockError::UnknownPod(pod_name.into()))?;
alloc.session_id = Some(new_session_id);
guard.save()?;
Ok(())
}
/// Information about a Pod that currently holds an allocation for a
/// given session.
#[derive(Debug, Clone)]
pub struct SessionLockInfo {
pub pod_name: String,
pub socket: PathBuf,
pub pid: u32,
}
/// Open the default lock file, reclaim stale entries, and return the
/// allocation currently writing to `session_id`, if any.
///
/// Used by `Pod::restore_from_manifest` to refuse a resume that would
/// race a live writer on the same source session.
pub fn lookup_session(session_id: SessionId) -> Result<Option<SessionLockInfo>, ScopeLockError> {
let lock_path = default_lock_path()?;
let mut guard = LockFileGuard::open(&lock_path)?;
reclaim_stale(&mut guard);
Ok(guard.data().find_by_session(session_id).map(|a| {
SessionLockInfo {
pod_name: a.pod_name.clone(),
socket: a.socket.clone(),
pid: a.pid,
}
}))
}
/// Errors raised by the mutating scope-lock operations. /// Errors raised by the mutating scope-lock operations.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum ScopeLockError { pub enum ScopeLockError {
@ -535,6 +644,15 @@ pub enum ScopeLockError {
NotSubset { spawner: String, rule: ScopeRule }, NotSubset { spawner: String, rule: ScopeRule },
#[error("pod `{0}` is not registered")] #[error("pod `{0}` is not registered")]
UnknownPod(String), UnknownPod(String),
#[error(
"session {session_id} is already held by pod `{pod_name}` at {}",
.socket.display()
)]
SessionConflict {
session_id: SessionId,
pod_name: String,
socket: PathBuf,
},
} }
#[cfg(test)] #[cfg(test)]
@ -548,6 +666,10 @@ mod tests {
/// harness runs tests on multiple threads inside a single process, /// harness runs tests on multiple threads inside a single process,
/// so env-var writes from one test would otherwise leak into a /// so env-var writes from one test would otherwise leak into a
/// parallel test's `default_lock_path()` lookup. /// parallel test's `default_lock_path()` lookup.
fn sid() -> SessionId {
session_store::new_session_id()
}
static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(())); static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
/// Sandbox `INSOMNIA_RUNTIME_DIR` to a tempdir for the duration of /// Sandbox `INSOMNIA_RUNTIME_DIR` to a tempdir for the duration of
@ -652,6 +774,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("a"), sock("a"),
vec![write_rule("/src", true)], vec![write_rule("/src", true)],
sid(),
) )
.unwrap(); .unwrap();
} }
@ -699,6 +822,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("a"), sock("a"),
vec![write_rule("/src", true)], vec![write_rule("/src", true)],
sid(),
) )
.unwrap(); .unwrap();
let err = register_pod( let err = register_pod(
@ -707,6 +831,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("b"), sock("b"),
vec![write_rule("/src/core", true)], vec![write_rule("/src/core", true)],
sid(),
) )
.unwrap_err(); .unwrap_err();
match err { match err {
@ -726,6 +851,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("a"), sock("a"),
vec![write_rule("/src", true)], vec![write_rule("/src", true)],
sid(),
) )
.unwrap(); .unwrap();
let err = register_pod( let err = register_pod(
@ -734,6 +860,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("a2"), sock("a2"),
vec![write_rule("/docs", true)], vec![write_rule("/docs", true)],
sid(),
) )
.unwrap_err(); .unwrap_err();
assert!(matches!(err, ScopeLockError::DuplicatePodName(ref n) if n == "a")); assert!(matches!(err, ScopeLockError::DuplicatePodName(ref n) if n == "a"));
@ -750,6 +877,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("a"), sock("a"),
vec![write_rule("/src", true)], vec![write_rule("/src", true)],
sid(),
) )
.unwrap(); .unwrap();
let err = delegate_scope( let err = delegate_scope(
@ -775,6 +903,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("a"), sock("a"),
vec![write_rule("/src", true)], vec![write_rule("/src", true)],
sid(),
) )
.unwrap(); .unwrap();
delegate_scope( delegate_scope(
@ -812,6 +941,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("a"), sock("a"),
vec![write_rule("/src", true)], vec![write_rule("/src", true)],
sid(),
) )
.unwrap(); .unwrap();
delegate_scope( delegate_scope(
@ -848,6 +978,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("a"), sock("a"),
vec![write_rule("/src", true)], vec![write_rule("/src", true)],
sid(),
) )
.unwrap(); .unwrap();
delegate_scope( delegate_scope(
@ -886,6 +1017,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("a"), sock("a"),
vec![write_rule("/src", true)], vec![write_rule("/src", true)],
sid(),
) )
.unwrap(); .unwrap();
delegate_scope( delegate_scope(
@ -939,6 +1071,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("a"), sock("a"),
vec![write_rule("/src", true)], vec![write_rule("/src", true)],
sid(),
) )
.unwrap(); .unwrap();
// B only reads under the same tree — allowed. // B only reads under the same tree — allowed.
@ -948,6 +1081,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("b"), sock("b"),
vec![read_rule("/src", true)], vec![read_rule("/src", true)],
sid(),
) )
.unwrap(); .unwrap();
assert_eq!(g.data().allocations.len(), 2); assert_eq!(g.data().allocations.len(), 2);
@ -964,6 +1098,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("a"), sock("a"),
vec![write_rule("/src", true)], vec![write_rule("/src", true)],
sid(),
) )
.unwrap(); .unwrap();
release_pod(&mut g, "a").unwrap(); release_pod(&mut g, "a").unwrap();
@ -973,6 +1108,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("b"), sock("b"),
vec![write_rule("/src", true)], vec![write_rule("/src", true)],
sid(),
) )
.unwrap(); .unwrap();
} }
@ -988,6 +1124,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("a"), sock("a"),
vec![write_rule("/src", true)], vec![write_rule("/src", true)],
sid(),
) )
.unwrap(); .unwrap();
delegate_scope( delegate_scope(
@ -1023,6 +1160,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("a"), sock("a"),
vec![write_rule("/src", true)], vec![write_rule("/src", true)],
sid(),
) )
.unwrap(); .unwrap();
{ {
@ -1047,7 +1185,7 @@ mod tests {
delegate_placeholder(&mut g, "child", std::process::id()); delegate_placeholder(&mut g, "child", std::process::id());
} }
let child_pid = std::process::id().wrapping_add(1); let child_pid = std::process::id().wrapping_add(1);
let guard = adopt_allocation("child".into(), child_pid).unwrap(); let guard = adopt_allocation("child".into(), child_pid, sid()).unwrap();
{ {
let g = LockFileGuard::open(&lock_path).unwrap(); let g = LockFileGuard::open(&lock_path).unwrap();
let alloc = g.data().find("child").unwrap(); let alloc = g.data().find("child").unwrap();
@ -1064,7 +1202,7 @@ mod tests {
fn adopt_allocation_errors_on_unknown_pod() { fn adopt_allocation_errors_on_unknown_pod() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let _sandbox = RuntimeDirSandbox::new(dir.path()); let _sandbox = RuntimeDirSandbox::new(dir.path());
let err = adopt_allocation("ghost".into(), 42).unwrap_err(); let err = adopt_allocation("ghost".into(), 42, sid()).unwrap_err();
assert!(matches!(err, ScopeLockError::UnknownPod(ref n) if n == "ghost")); assert!(matches!(err, ScopeLockError::UnknownPod(ref n) if n == "ghost"));
} }
@ -1078,6 +1216,7 @@ mod tests {
socket: sock(pod_name), socket: sock(pod_name),
scope_allow: vec![write_rule("/tmp/child", true)], scope_allow: vec![write_rule("/tmp/child", true)],
delegated_from: None, delegated_from: None,
session_id: None,
}); });
g.save().unwrap(); g.save().unwrap();
} }
@ -1093,6 +1232,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("a"), sock("a"),
vec![write_rule("/src", true)], vec![write_rule("/src", true)],
sid(),
) )
.unwrap(); .unwrap();
delegate_scope( delegate_scope(
@ -1112,6 +1252,7 @@ mod tests {
std::process::id(), std::process::id(),
sock("x"), sock("x"),
vec![write_rule("/src/core/x", true)], vec![write_rule("/src/core/x", true)],
sid(),
) )
.unwrap_err(); .unwrap_err();
match err { match err {
@ -1119,4 +1260,164 @@ mod tests {
other => panic!("expected WriteConflict, got {other:?}"), other => panic!("expected WriteConflict, got {other:?}"),
} }
} }
#[test]
fn find_by_session_skips_none_placeholders() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("scope.lock");
let mut g = open_empty(&path);
// Pre-reservation: delegate_scope leaves session_id = None
// until adopt_allocation rewrites it. find_by_session must not
// match those placeholders, otherwise a freshly-spawning child
// would shadow itself before it has even chosen a session.
register_pod(
&mut g,
"parent".into(),
std::process::id(),
sock("parent"),
vec![write_rule("/p", true)],
sid(),
)
.unwrap();
delegate_scope(
&mut g,
"parent",
"child".into(),
std::process::id(),
sock("child"),
vec![write_rule("/p/sub", true)],
)
.unwrap();
let target_session = sid();
// The placeholder allocation has session_id = None and must
// not be returned for any lookup.
assert!(g.data().find_by_session(target_session).is_none());
// After adopt-style rewrite, the same allocation is now found.
g.data_mut()
.find_mut("child")
.unwrap()
.session_id = Some(target_session);
let found = g.data().find_by_session(target_session).unwrap();
assert_eq!(found.pod_name, "child");
}
#[test]
fn register_pod_rejects_session_id_collision() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("scope.lock");
let mut g = open_empty(&path);
let shared_session = sid();
register_pod(
&mut g,
"first".into(),
std::process::id(),
sock("first"),
vec![write_rule("/work/a", true)],
shared_session,
)
.unwrap();
// Second registration tries to grab the same session_id under
// a different pod_name. Without the SessionConflict check both
// would succeed and race on the same jsonl.
let err = register_pod(
&mut g,
"second".into(),
std::process::id(),
sock("second"),
vec![write_rule("/work/b", true)],
shared_session,
)
.unwrap_err();
match err {
ScopeLockError::SessionConflict {
session_id,
pod_name,
..
} => {
assert_eq!(session_id, shared_session);
assert_eq!(pod_name, "first");
}
other => panic!("expected SessionConflict, got {other:?}"),
}
}
#[test]
fn lookup_session_returns_live_writer_info() {
let dir = TempDir::new().unwrap();
let _sandbox = RuntimeDirSandbox::new(dir.path());
let s = sid();
let guard = install_top_level(
"live".into(),
std::process::id(),
sock("live"),
vec![write_rule("/work", true)],
s,
)
.unwrap();
let info = lookup_session(s).unwrap().expect("expected live writer");
assert_eq!(info.pod_name, "live");
assert_eq!(info.socket, sock("live"));
drop(guard);
// After the guard's release, the lookup goes back to None.
assert!(lookup_session(s).unwrap().is_none());
}
#[test]
fn update_session_rewrites_allocation_session_id() {
let dir = TempDir::new().unwrap();
let _sandbox = RuntimeDirSandbox::new(dir.path());
let original = sid();
let updated = sid();
let _guard = install_top_level(
"p".into(),
std::process::id(),
sock("p"),
vec![write_rule("/work", true)],
original,
)
.unwrap();
update_session("p", updated).unwrap();
// lookup against the original is now empty, the updated id wins.
assert!(lookup_session(original).unwrap().is_none());
assert_eq!(lookup_session(updated).unwrap().unwrap().pod_name, "p");
}
#[test]
fn update_session_rejects_when_target_already_held() {
let dir = TempDir::new().unwrap();
let _sandbox = RuntimeDirSandbox::new(dir.path());
let s_a = sid();
let s_b = sid();
let _g_a = install_top_level(
"a".into(),
std::process::id(),
sock("a"),
vec![write_rule("/work/a", true)],
s_a,
)
.unwrap();
let _g_b = install_top_level(
"b".into(),
std::process::id(),
sock("b"),
vec![write_rule("/work/b", true)],
s_b,
)
.unwrap();
// `a` cannot adopt b's live session id.
let err = update_session("a", s_b).unwrap_err();
match err {
ScopeLockError::SessionConflict {
pod_name,
session_id,
..
} => {
assert_eq!(pod_name, "b");
assert_eq!(session_id, s_b);
}
other => panic!("expected SessionConflict, got {other:?}"),
}
}
} }

View File

@ -40,6 +40,7 @@ pub use session::{
save_run_completed, save_run_errored, save_turn_end, save_usage, save_run_completed, save_run_errored, save_turn_end, save_usage,
}; };
pub use llm_worker::UsageRecord; pub use llm_worker::UsageRecord;
pub use llm_worker::llm_client::types::{ContentPart, Item, Role};
pub use session_log::{ pub use session_log::{
EntryHash, HashedEntry, LogEntry, RestoredState, SessionOrigin, build_chain, collect_state, EntryHash, HashedEntry, LogEntry, RestoredState, SessionOrigin, build_chain, collect_state,
compute_hash, compute_hash,

View File

@ -14,3 +14,5 @@ unicode-width = "0.2.2"
uuid = "1.23" uuid = "1.23"
toml = "1.1.2" toml = "1.1.2"
manifest = { version = "0.1.0", path = "../manifest" } manifest = { version = "0.1.0", path = "../manifest" }
session-store = { version = "0.1.0", path = "../session-store" }
scope-lock = { version = "0.1.0", path = "../scope-lock" }

View File

@ -131,6 +131,9 @@ impl App {
} }
Event::ThinkingDone { text } => { Event::ThinkingDone { text } => {
if let Some(b) = self.last_streaming_thinking_mut() { if let Some(b) = self.last_streaming_thinking_mut() {
// Delta-accumulated text wins. `text` here is the
// Done payload (full body), used only as a fallback
// for providers that don't stream deltas.
if b.text.is_empty() { if b.text.is_empty() {
b.text = text; b.text = text;
} }
@ -325,6 +328,11 @@ impl App {
} }
fn mark_orphan_thinking_incomplete(&mut self) { fn mark_orphan_thinking_incomplete(&mut self) {
// A turn can carry several thinking blocks; we walk all the way
// to `TurnHeader` and convert every still-Streaming one rather
// than breaking on the first Finished hit (which is what the
// tool-call equivalent does, since tool calls finalize in
// submission order).
for b in self.blocks.iter_mut().rev() { for b in self.blocks.iter_mut().rev() {
match b { match b {
Block::Thinking(t) => { Block::Thinking(t) => {

View File

@ -45,9 +45,10 @@ pub struct ThinkingBlock {
} }
pub enum ThinkingState { pub enum ThinkingState {
/// Live block: actively streaming. `started_at` is `None` only for /// Live block: actively streaming. `started_at` powers the
/// blocks materialised from `Event::History`, which never enter the /// `Thinking... (Xs)` live timer. History-restored blocks never
/// streaming state. /// enter this state — they materialise as `Finished { elapsed_secs:
/// None }` since the original duration is not persisted.
Streaming { started_at: Instant }, Streaming { started_at: Instant },
/// Block ended cleanly with `ThinkingDone`. /// Block ended cleanly with `ThinkingDone`.
Finished { elapsed_secs: Option<u64> }, Finished { elapsed_secs: Option<u64> },

View File

@ -3,6 +3,7 @@ mod block;
mod cache; mod cache;
mod client; mod client;
mod input; mod input;
mod picker;
mod scroll; mod scroll;
mod spawn; mod spawn;
mod tool; mod tool;
@ -24,9 +25,11 @@ use crossterm::terminal::{
use protocol::Method; use protocol::Method;
use ratatui::Terminal; use ratatui::Terminal;
use ratatui::backend::CrosstermBackend; use ratatui::backend::CrosstermBackend;
use session_store::SessionId;
use crate::app::App; use crate::app::App;
use crate::client::PodClient; use crate::client::PodClient;
use crate::picker::PickerOutcome;
use crate::spawn::{SpawnOutcome, SpawnReady}; use crate::spawn::{SpawnOutcome, SpawnReady};
fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf { fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf {
@ -47,27 +50,101 @@ enum Mode {
pod_name: String, pod_name: String,
socket_override: Option<PathBuf>, socket_override: Option<PathBuf>,
}, },
/// `tui -r` / `tui --resume`: open the session picker first, then
/// run the same name dialog as Spawn but in resume mode.
Resume,
/// `tui --session <UUID>`: skip the picker, go straight to the
/// resume name dialog with `id` baked in.
ResumeWithSession(SessionId),
} }
fn parse_args() -> Mode { enum ParseError {
Conflict,
InvalidSession(String),
MissingValue(&'static str),
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Conflict => write!(f, "--resume and --session are mutually exclusive"),
Self::InvalidSession(s) => write!(f, "invalid --session UUID: {s}"),
Self::MissingValue(flag) => write!(f, "{flag} requires a value"),
}
}
}
fn parse_args() -> Result<Mode, ParseError> {
let args: Vec<String> = std::env::args().skip(1).collect(); let args: Vec<String> = std::env::args().skip(1).collect();
if args.is_empty() { let mut resume = false;
return Mode::Spawn; let mut session: Option<SessionId> = None;
let mut socket_override: Option<PathBuf> = None;
let mut positional: Option<String> = None;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"-r" | "--resume" => {
resume = true;
i += 1;
}
"--session" => {
let raw = args
.get(i + 1)
.ok_or(ParseError::MissingValue("--session"))?;
session = Some(
raw.parse::<SessionId>()
.map_err(|_| ParseError::InvalidSession(raw.clone()))?,
);
i += 2;
}
"--socket" => {
let raw = args
.get(i + 1)
.ok_or(ParseError::MissingValue("--socket"))?;
socket_override = Some(PathBuf::from(raw));
i += 2;
}
other if positional.is_none() && !other.starts_with('-') => {
positional = Some(other.to_string());
i += 1;
}
_ => {
// Unknown flag or extra positional — keep older
// behaviour of ignoring unknowns rather than aborting.
i += 1;
}
}
} }
let pod_name = args[0].clone();
let socket_override = args if resume && session.is_some() {
.windows(2) return Err(ParseError::Conflict);
.find(|w| w[0] == "--socket")
.map(|w| PathBuf::from(&w[1]));
Mode::Attach {
pod_name,
socket_override,
} }
if let Some(id) = session {
return Ok(Mode::ResumeWithSession(id));
}
if resume {
return Ok(Mode::Resume);
}
if let Some(pod_name) = positional {
return Ok(Mode::Attach {
pod_name,
socket_override,
});
}
Ok(Mode::Spawn)
} }
#[tokio::main] #[tokio::main]
async fn main() -> ExitCode { async fn main() -> ExitCode {
let mode = parse_args(); let mode = match parse_args() {
Ok(m) => m,
Err(e) => {
eprintln!("tui: {e}");
return ExitCode::FAILURE;
}
};
if let Err(e) = enable_raw_mode() { if let Err(e) = enable_raw_mode() {
eprintln!("tui: failed to enter raw mode: {e}"); eprintln!("tui: failed to enter raw mode: {e}");
@ -80,11 +157,13 @@ async fn main() -> ExitCode {
} }
let result = match mode { let result = match mode {
Mode::Spawn => run_spawn().await, Mode::Spawn => run_spawn(None).await,
Mode::Attach { Mode::Attach {
pod_name, pod_name,
socket_override, socket_override,
} => run_attach(pod_name, socket_override).await, } => run_attach(pod_name, socket_override).await,
Mode::Resume => run_resume().await,
Mode::ResumeWithSession(id) => run_spawn(Some(id)).await,
}; };
// Always restore the terminal first so any pending eprintln below // Always restore the terminal first so any pending eprintln below
@ -120,8 +199,19 @@ async fn run_attach(
run(&mut terminal, pod_name, &socket_path, false).await run(&mut terminal, pod_name, &socket_path, false).await
} }
async fn run_spawn() -> Result<(), Box<dyn std::error::Error>> { async fn run_resume() -> Result<(), Box<dyn std::error::Error>> {
let ready = match spawn::run().await? { // Phase 1: pick a session in its own inline viewport, dropping the
// viewport before the name dialog opens so each phase gets fresh
// vertical room.
let id = match picker::run().await? {
PickerOutcome::Picked(id) => id,
PickerOutcome::Cancelled => return Ok(()),
};
run_spawn(Some(id)).await
}
async fn run_spawn(resume_from: Option<SessionId>) -> Result<(), Box<dyn std::error::Error>> {
let ready = match spawn::run(resume_from).await? {
SpawnOutcome::Ready(r) => r, SpawnOutcome::Ready(r) => r,
SpawnOutcome::Cancelled => return Ok(()), SpawnOutcome::Cancelled => return Ok(()),
}; };

327
crates/tui/src/picker.rs Normal file
View File

@ -0,0 +1,327 @@
//! Inline-viewport "pick a session to restore" UX.
//!
//! Reads the most recent sessions from the configured store, lets the
//! user pick one with the arrow keys, and returns the chosen
//! `SessionId`. Closes its inline viewport before returning so the
//! caller can open a fresh viewport for the name dialog.
//!
//! The picker only handles selection. Forking, scope-lock checks, and
//! actual `pod` launch happen later in the resume flow.
use std::io;
use std::time::Duration;
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::{Frame, TerminalOptions, Viewport};
use scope_lock::lookup_session;
use session_store::{
ContentPart, FsStore, HashedEntry, Item, LogEntry, SessionId, Store,
};
const MAX_ROWS: usize = 10;
const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4;
#[derive(Debug)]
pub enum PickerError {
Io(io::Error),
Store(session_store::StoreError),
NoSessions,
}
impl std::fmt::Display for PickerError {
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, "session store error: {e}"),
Self::NoSessions => write!(
f,
"no sessions found — start a fresh pod with `tui` and try again"
),
}
}
}
impl std::error::Error for PickerError {}
impl From<io::Error> for PickerError {
fn from(e: io::Error) -> Self {
Self::Io(e)
}
}
impl From<session_store::StoreError> for PickerError {
fn from(e: session_store::StoreError) -> Self {
Self::Store(e)
}
}
pub enum PickerOutcome {
Picked(SessionId),
Cancelled,
}
/// One row in the picker view. Rendered from the session log so the
/// user can recognise their session at a glance without parsing UUIDs.
struct Row {
id: SessionId,
/// Last user / assistant snippet, or a `[corrupt]` placeholder.
preview: String,
/// `Some(pod_name)` when a live Pod currently holds an allocation
/// for this session in `scope.lock`. Picking such a row launches
/// `pod --session <UUID>` which will fail with `SessionConflict` —
/// the badge warns the user up-front.
live_pod: Option<String>,
}
pub async fn run() -> Result<PickerOutcome, PickerError> {
let store = open_default_store().await?;
let ids = store.list_sessions().await?;
if ids.is_empty() {
return Err(PickerError::NoSessions);
}
let mut rows: Vec<Row> = Vec::with_capacity(MAX_ROWS);
for id in ids.into_iter().take(MAX_ROWS) {
let preview = build_preview(&store, id).await;
// Best-effort live check. A scope.lock I/O hiccup downgrades
// the row to "no badge" rather than killing the picker — the
// user still gets to see the listing.
let live_pod = lookup_session(id).ok().flatten().map(|info| info.pod_name);
rows.push(Row {
id,
preview,
live_pod,
});
}
let mut selected = 0usize;
let mut terminal = make_inline_terminal()?;
loop {
terminal.draw(|f| draw(f, &rows, selected))?;
match poll_event()? {
None => continue,
Some(Action::Up) => {
if selected > 0 {
selected -= 1;
}
}
Some(Action::Down) => {
if selected + 1 < rows.len() {
selected += 1;
}
}
Some(Action::Submit) => {
close_viewport(&mut terminal)?;
return Ok(PickerOutcome::Picked(rows[selected].id));
}
Some(Action::Cancel) => {
close_viewport(&mut terminal)?;
return Ok(PickerOutcome::Cancelled);
}
}
}
}
/// Park the cursor at the very bottom of the picker's inline viewport
/// and emit one newline before dropping the terminal. Without this the
/// inline area is left with the cursor still inside it, so the next
/// `Terminal::with_options(Inline(_))` call (the resume name dialog)
/// computes its own area starting from inside the picker — drawing the
/// new dialog on top of the lower picker rows.
///
/// Setting the cursor to `area.bottom() - 1` and writing `\r\n`
/// scrolls the terminal up exactly one row, so the next inline
/// viewport opens immediately below the picker rather than on top of
/// it.
fn close_viewport(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> io::Result<()> {
let area = terminal.get_frame().area();
let last_row = area.bottom().saturating_sub(1);
terminal.set_cursor_position((0, last_row))?;
use std::io::Write;
let mut out = io::stdout();
out.write_all(b"\r\n")?;
out.flush()?;
Ok(())
}
async fn open_default_store() -> Result<FsStore, PickerError> {
let dir = manifest::paths::sessions_dir().ok_or_else(|| {
PickerError::Io(io::Error::new(
io::ErrorKind::NotFound,
"could not resolve sessions directory \
(set INSOMNIA_HOME, INSOMNIA_DATA_DIR, or HOME)",
))
})?;
Ok(FsStore::new(&dir).await?)
}
async fn build_preview(store: &FsStore, id: SessionId) -> String {
match store.read_all(id).await {
Ok(entries) => last_message_preview(&entries).unwrap_or_else(|| "[empty]".to_string()),
Err(_) => "[corrupt]".to_string(),
}
}
/// Walk the log from the tail looking for the most recent user-message
/// or assistant-message entry, then render its first text fragment in
/// a single line.
fn last_message_preview(entries: &[HashedEntry]) -> Option<String> {
for hashed in entries.iter().rev() {
match &hashed.entry {
LogEntry::UserInput { item, .. } => {
if let Some(text) = first_text(item) {
return Some(format!("user: {}", trim_one_line(&text, 60)));
}
}
LogEntry::AssistantItems { items, .. } => {
if let Some(text) = items.iter().find_map(first_text) {
return Some(format!("assistant: {}", trim_one_line(&text, 60)));
}
}
_ => {}
}
}
None
}
fn first_text(item: &Item) -> Option<String> {
match item {
Item::Message { content, .. } => content.iter().find_map(|p| match p {
ContentPart::Text { text } => Some(text.clone()),
_ => None,
}),
_ => None,
}
}
fn trim_one_line(s: &str, max_chars: usize) -> String {
let collapsed: String = s.chars().map(|c| if c == '\n' { ' ' } else { c }).collect();
if collapsed.chars().count() <= max_chars {
collapsed
} else {
let truncated: String = collapsed.chars().take(max_chars - 1).collect();
format!("{truncated}")
}
}
fn make_inline_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
let backend = CrosstermBackend::new(io::stdout());
Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(VIEWPORT_LINES),
},
)
}
enum Action {
Up,
Down,
Submit,
Cancel,
}
fn poll_event() -> io::Result<Option<Action>> {
if !event::poll(Duration::from_millis(100))? {
return Ok(None);
}
match event::read()? {
TermEvent::Key(k) if k.kind != KeyEventKind::Release => {
let ctrl = k.modifiers.contains(KeyModifiers::CONTROL);
Ok(match k.code {
KeyCode::Up => Some(Action::Up),
KeyCode::Down => Some(Action::Down),
KeyCode::Char('k') if !ctrl => Some(Action::Up),
KeyCode::Char('j') if !ctrl => Some(Action::Down),
KeyCode::Enter => Some(Action::Submit),
KeyCode::Esc => Some(Action::Cancel),
KeyCode::Char('c') if ctrl => Some(Action::Cancel),
_ => None,
})
}
_ => Ok(None),
}
}
fn draw(f: &mut Frame<'_>, rows: &[Row], selected: usize) {
let area = f.area();
let mut constraints: Vec<Constraint> =
Vec::with_capacity(rows.len() + 3);
constraints.push(Constraint::Length(1)); // title
for _ in rows {
constraints.push(Constraint::Length(1));
}
constraints.push(Constraint::Length(1)); // hint
constraints.push(Constraint::Length(1)); // spacer
let layout = Layout::vertical(constraints).split(area);
f.render_widget(
Paragraph::new(Line::from(vec![Span::styled(
"resume pod pick a session",
Style::default().add_modifier(Modifier::BOLD),
)])),
layout[0],
);
for (i, row) in rows.iter().enumerate() {
f.render_widget(
Paragraph::new(row_line(row, i == selected)),
layout[i + 1],
);
}
f.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(" "),
Span::styled("[↑/↓]", Style::default().fg(Color::DarkGray)),
Span::raw(" select "),
Span::styled("[enter]", Style::default().fg(Color::Green)),
Span::raw(" pick "),
Span::styled("[esc]", Style::default().fg(Color::Yellow)),
Span::raw(" cancel"),
])),
layout[rows.len() + 1],
);
}
fn row_line(row: &Row, selected: bool) -> Line<'_> {
let marker = if selected { "" } else { " " };
let id_style = if selected {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Cyan)
};
let preview_style = if selected {
Style::default().fg(Color::White)
} else {
Style::default().fg(Color::DarkGray)
};
let mut spans = vec![
Span::raw(marker),
Span::styled(short_session(row.id), id_style),
Span::raw(" "),
];
if let Some(ref pod_name) = row.live_pod {
spans.push(Span::styled(
format!("[live: {pod_name}] "),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
));
}
spans.push(Span::styled(row.preview.clone(), preview_style));
Line::from(spans)
}
fn short_session(id: SessionId) -> String {
let s = id.to_string();
s.chars().take(8).collect()
}

View File

@ -20,6 +20,7 @@ use std::time::Duration;
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use manifest::{PodManifestConfig, find_project_manifest_from, load_layer, user_manifest_path}; use manifest::{PodManifestConfig, find_project_manifest_from, load_layer, user_manifest_path};
use ratatui::Terminal; use ratatui::Terminal;
use session_store::SessionId;
use ratatui::backend::CrosstermBackend; use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout}; use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
@ -85,7 +86,10 @@ impl From<io::Error> for SpawnError {
type InlineTerminal = Terminal<CrosstermBackend<io::Stdout>>; type InlineTerminal = Terminal<CrosstermBackend<io::Stdout>>;
pub async fn run() -> Result<SpawnOutcome, SpawnError> { /// Source session for a resume run. `None` = fresh spawn (current
/// behaviour); `Some(id)` swaps the dialog into "Resume Pod" mode and
/// passes `--session <id>` to the spawned `pod` child.
pub async fn run(resume_from: Option<SessionId>) -> Result<SpawnOutcome, SpawnError> {
let cwd = std::env::current_dir().map_err(SpawnError::Io)?; let cwd = std::env::current_dir().map_err(SpawnError::Io)?;
// Run the same merge pod itself uses, then read what's missing // Run the same merge pod itself uses, then read what's missing
@ -135,6 +139,7 @@ pub async fn run() -> Result<SpawnOutcome, SpawnError> {
name: default_name, name: default_name,
message: None, message: None,
editing: true, editing: true,
resume_from,
}; };
let mut terminal = make_inline_terminal()?; let mut terminal = make_inline_terminal()?;
@ -266,16 +271,19 @@ async fn wait_for_ready(
let pod_bin = resolve_pod_command(); let pod_bin = resolve_pod_command();
let cwd = std::env::current_dir().map_err(SpawnError::Io)?; let cwd = std::env::current_dir().map_err(SpawnError::Io)?;
let mut child = Command::new(&pod_bin) let mut command = Command::new(&pod_bin);
command
.arg("--overlay") .arg("--overlay")
.arg(overlay_toml) .arg(overlay_toml)
.current_dir(&cwd) .current_dir(&cwd)
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
.kill_on_drop(true) .kill_on_drop(true);
.spawn() if let Some(id) = form.resume_from {
.map_err(SpawnError::PodLaunchFailed)?; command.arg("--session").arg(id.to_string());
}
let mut child = command.spawn().map_err(SpawnError::PodLaunchFailed)?;
let stderr = child let stderr = child
.stderr .stderr
@ -437,6 +445,11 @@ struct Form {
/// cursor stays out so it does not collide with the shell prompt /// cursor stays out so it does not collide with the shell prompt
/// after the inline terminal is dropped. /// after the inline terminal is dropped.
editing: bool, editing: bool,
/// `Some(id)` flips the dialog into "Resume Pod" mode: the title
/// switches, the source session is shown to the user, and the
/// child pod is launched with `--session <id>` so it restores
/// from `id` and appends to the same session log.
resume_from: Option<SessionId>,
} }
impl Form { impl Form {
@ -500,8 +513,12 @@ fn draw_form(f: &mut Frame<'_>, form: &Form) {
]) ])
.split(area); .split(area);
let title_text = match form.resume_from {
Some(id) => format!("resume pod session: {}", short_session(id)),
None => "spawn pod".to_string(),
};
let title = Paragraph::new(Line::from(vec![Span::styled( let title = Paragraph::new(Line::from(vec![Span::styled(
"spawn pod", title_text,
Style::default().add_modifier(Modifier::BOLD), Style::default().add_modifier(Modifier::BOLD),
)])); )]));
f.render_widget(title, layout[0]); f.render_widget(title, layout[0]);
@ -523,6 +540,13 @@ fn draw_form(f: &mut Frame<'_>, form: &Form) {
} }
} }
/// First 8 hex digits of a UUID — short enough to skim, long enough
/// to disambiguate inside a 10-row picker.
pub(crate) fn short_session(id: SessionId) -> String {
let s = id.to_string();
s.chars().take(8).collect()
}
fn name_line(form: &Form) -> Line<'_> { fn name_line(form: &Form) -> Line<'_> {
Line::from(vec![ Line::from(vec![
Span::raw(" "), Span::raw(" "),
@ -600,6 +624,7 @@ mod tests {
name_cursor: name.chars().count(), name_cursor: name.chars().count(),
message: None, message: None,
editing: true, editing: true,
resume_from: None,
} }
} }

View File

@ -188,7 +188,7 @@ scheme 側が吸収する。
| key | 型 | 既定 | 内容 | | key | 型 | 既定 | 内容 |
|---|---|---|---| |---|---|---|---|
| `instruction` | `String` | `$insomnia/default` | システムプロンプト本体として使う prompt asset 参照 | | `instruction` | `String` | `$insomnia/default` | システムプロンプト本体として使う prompt asset 参照 |
| `max_tokens` | `u32` | 未指定 | 1 request の最大出力 token。scheme が provider の該当 wire field に投影 | | `max_tokens` | `u32` | 未指定 | 1 request の最大出力 token。scheme が provider の該当 wire field に投影。scheme ごとのセマンティクス差は `docs/reasoning.md` |
| `max_turns` | `NonZeroU32` | 未指定 | 1 run 内で Worker が進められる最大 turn 数 | | `max_turns` | `NonZeroU32` | 未指定 | 1 run 内で Worker が進められる最大 turn 数 |
| `temperature` | `f32` | 未指定 | sampling temperature | | `temperature` | `f32` | 未指定 | sampling temperature |
| `top_p` | `f32` | 未指定 | nucleus sampling | | `top_p` | `f32` | 未指定 | nucleus sampling |

View File

@ -67,6 +67,21 @@ ref = "gemini/gemini-2.5-pro"
reasoning = -1 reasoning = -1
``` ```
## `max_tokens` との関係
`[worker] max_tokens` は scheme ごとに wire field 名も意味論も異なる。reasoning モデルで併用するときは特に注意:
| Provider / scheme | wire field | `max_tokens` の意味 |
|---|---|---|
| OpenAI Chat (`openai_chat`) | `max_completion_tokens`Ollama 互換は legacy `max_tokens` | reasoning tokens を **含む** 合計上限 |
| OpenAI Responses (`openai_responses`) | `max_output_tokens` | reasoning tokens を **含む** 合計上限 |
| Anthropic (`anthropic`) | `max_tokens`(必須) | thinking tokens を **含む** 合計上限 |
| Gemini (`gemini`) | `generationConfig.maxOutputTokens` | visible のみ。thinking tokens は **別計上** |
OpenAI / Anthropic で `max_tokens` を小さく取りつつ高 effort / 大 budget の reasoning を立てると、reasoning に枠を食われて visible output が空で返ることがある。Gemini は別計上なのでこの事故は起きない。
codex-oauth (ChatGPT backend) 経路では `max_output_tokens``Unsupported parameter` で 400 を返すため、`openai_responses` scheme は `send_max_output_tokens=false` で wire に載せない。manifest に `max_tokens` を書いても黙って落ちるが、scheme の `validate_config``ConfigWarning` を返すので worker 起動時に通知される。
## 範囲外 ## 範囲外
- UI プリセットLow / Medium / High → 各 provider 値)の変換テーブル - UI プリセットLow / Medium / High → 各 provider 値)の変換テーブル

View File

@ -0,0 +1,44 @@
# Anthropic Messages API: `max_tokens` パラメータ仕様
Source: https://platform.claude.com/docs/en/api/messages
Retrieved: 2026-04-28
---
## 1. パラメータ名
`max_tokens`
`max_output_tokens` ではない。
## 2. 必須か任意か
**必須 (required)**。
`POST /v1/messages` のボディパラメータとして必須指定。
insomnia の現在の実装(`max_tokens: u32`、未指定時 4096 にフォールバック)は仕様と合致している。
## 3. 型・範囲
- 型: integer (number)
- 意味: 生成を停止する前に出力できるトークンの上限。モデルはこの値に達する前に停止することもある(上限の指定であり、保証値ではない)
- モデルごとに最大値が異なる:
- Claude Opus 4.6 / 4.7: 最大 128k トークン
- Claude Sonnet 4.6 / Haiku 4.5: 最大 64k トークン
- Message Batches API + beta ヘッダ `output-300k-2026-03-24`: 最大 300k トークンOpus 4.7, 4.6, Sonnet 4.6
## 4. Extended Thinking との組み合わせ制約
Source: https://platform.claude.com/docs/en/build-with-claude/extended-thinking
- `thinking.budget_tokens` は必ず `max_tokens` **未満** でなければならない
- thinking トークンは `max_tokens` の上限に含まれてカウントされる
- `budget_tokens` の最小値は **1,024 トークン**
- 例外: ツールを伴う interleaved thinking では `budget_tokens``max_tokens` を超えることが許容される(予算がコンテキストウィンドウ全体に対して適用されるため)
- `max_tokens` が 21,333 を超える場合はストリーミングが必須
- Claude Opus 4.6 / Sonnet 4.6 以降では `budget_tokens` は非推奨になり、代わりに `effort` パラメータによる adaptive thinking が推奨されている
## 5. ドキュメント URL
- Messages API リファレンス: https://platform.claude.com/docs/en/api/messages
- Extended Thinking ガイド: https://platform.claude.com/docs/en/build-with-claude/extended-thinking

View File

@ -0,0 +1,45 @@
# Google Gemini API: `maxOutputTokens` パラメータ仕様
Source: https://ai.google.dev/api/generate-content
Source (thinking): https://ai.google.dev/gemini-api/docs/thinking
Source (Gemini 2.5 Flash): https://ai.google.dev/gemini-api/docs/models/gemini-2.5-flash
Source (Gemini 2.5 Pro): https://ai.google.dev/gemini-api/docs/models/gemini-2.5-pro
Retrieved: 2026-04-28
---
## 1. パラメータ名と位置
`generationConfig.maxOutputTokens`
リクエストボディのトップレベルではなく、`generationConfig` オブジェクト内に配置する。
SDK では `GenerateContentConfig(max_output_tokens=...)` として渡す。
## 2. 必須 / 任意
**任意 (optional)**。省略時はモデルのデフォルト上限が適用される。
## 3. 型と範囲
- 型: `integer`
- モデル別の最大値:
- `gemini-2.5-flash`: 最大 **65,536** トークン
- `gemini-2.5-pro`: 最大 **65,536** トークン
- 最小値の公式明記はないが、正の整数を指定する。
## 4. thinking トークンとの関係
- `maxOutputTokens` が制限するのは**最終レスポンスの出力トークン数のみ**。thinking トークンは `usageMetadata.thoughtsTokenCount` として別途計上され、`maxOutputTokens` のカウントには含まれない。
- thinking トークンの制御には `generationConfig.thinkingConfig.thinkingBudget` を用いる。
- Gemini 2.5 Flash / Pro: `128`〜`32768` トークン、`0` で thinking 無効化(モデルによる)、`-1` で動的
- 課金は「output tokens + thinking tokens」の合算。
- `maxOutputTokens``thinkingBudget` は独立したパラメータであり、両方を同時に指定できる。
> **注意**: 2025年10月時点で `gemini-2.5-flash` において `max_output_tokens` が無視されるバグが報告されており、Google 側が修正をロールアウトした経緯がある。最新モデルで想定通りに機能するか実測で確認することを推奨。
## 5. ドキュメント URL
- API リファレンス (GenerationConfig): https://ai.google.dev/api/generate-content#v1beta.GenerationConfig
- Thinking ガイド: https://ai.google.dev/gemini-api/docs/thinking
- Gemini 2.5 Flash モデル仕様: https://ai.google.dev/gemini-api/docs/models/gemini-2.5-flash
- Gemini 2.5 Pro モデル仕様: https://ai.google.dev/gemini-api/docs/models/gemini-2.5-pro

View File

@ -0,0 +1,51 @@
# OpenAI Chat Completions API — 出力トークン数制御パラメータ仕様
- **Source**: https://platform.openai.com/docs/api-reference/chat/create
- **Supplementary**: https://learn.microsoft.com/en-us/azure/foundry/openai/how-to/reasoning
- **Retrieved**: 2026-04-28
---
## 1. `max_tokens``max_completion_tokens` の関係
| パラメータ | 状態 | 対応モデル |
|---|---|---|
| `max_tokens` | **Deprecated** | GPT-3.5, GPT-4 系など旧モデルでは動作する |
| `max_completion_tokens` | 現行・推奨 | 全モデル(旧モデルにも後方互換あり) |
- `max_tokens` は o1 系以降では **受け付けられない**(エラーまたは無視)。
- 変更の背景: 旧来の `max_tokens` は「返却トークン = 生成トークン = 課金トークン」を前提にしていた。o1 系で推論トークンreasoning tokensが導入されたことでこの前提が崩れ、新パラメータが設計された。
- `max_completion_tokens` は旧モデルでも機能するため、**新規実装では `max_completion_tokens` を使うべき**。
## 2. 必須か任意か
- **任意optional**
- 指定しない場合はモデルのコンテキスト上限まで生成する(デフォルト: `null`)。
## 3. 型と範囲
- **型**: `integer | null`
- **範囲**: `1` 以上、モデルのコンテキストウィンドウの残りトークン数以下。上限値はモデルごとに異なり、ドキュメント上に固定の最大値は明示されていない。
- `null` を渡すと制限なし(モデル上限に従う)。
## 4. Reasoning モデルでの reasoning tokens のカウント
- `max_completion_tokens` の上限には **reasoning tokens推論トークンを含む**
- reasoning tokens: モデルが内部で生成するが API レスポンスには含まれない隠しトークン。
- 課金対象は reasoning tokens + visible output tokens の合計。
- レスポンスの `usage.completion_tokens_details.reasoning_tokens` で内訳を確認できる。
- したがって、`max_completion_tokens = 5000` と設定しても、推論に多くのトークンを使った場合、目に見える出力は 5000 より少なくなる。
## 5. Ollama の OpenAI compat API での扱い(補助情報)
- Ollama の `/v1/chat/completions` は現時点で **`max_tokens` のみを公式サポート**している(内部的に `num_predict` にマッピング)。
- `max_completion_tokens` サポートは Issue #7125 / PR #14464 で議論中だが、2026-04-28 時点では公式ドキュメント上に記載なし。
- **Ollama に対しては `max_tokens` を使う**のが安全な選択。ただし将来的に `max_completion_tokens` に移行される見込み。
## 6. ドキュメント URL
- [OpenAI Chat Completions API Reference](https://platform.openai.com/docs/api-reference/chat/create)
- [Azure OpenAI Reasoning Models (GPT-5, o3, o1)](https://learn.microsoft.com/en-us/azure/foundry/openai/how-to/reasoning)
- [Ollama OpenAI Compatibility](https://docs.ollama.com/api/openai-compatibility)
- [Ollama Issue #7125 — max_completion_tokens support](https://github.com/ollama/ollama/issues/7125)
- [OpenAI Community — Why max_tokens changed to max_completion_tokens](https://community.openai.com/t/why-was-max-tokens-changed-to-max-completion-tokens/938077)

View File

@ -0,0 +1,60 @@
# OpenAI Responses API — `max_output_tokens` Parameter
- **Source**: https://platform.openai.com/docs/api-reference/responses/create
- **Retrieved**: 2026-04-28
---
## 1. パラメータ名
`max_output_tokens` — 正しい。Chat Completions API の `max_tokens` / `max_completion_tokens` とは別物。
## 2. 必須 / 任意
**任意 (optional)**。省略時のデフォルトは `inf`(モデルが許容する最大出力トークン数)。
## 3. 型と範囲
| 項目 | 値 |
|---|---|
| 型 | `integer` または文字列 `"inf"` |
| 最小値 | `1` |
| 最大値 | モデルごとの最大出力トークン数(例: gpt-4.1 系は 32,768 |
| デフォルト | `inf` |
上限に達した場合、レスポンスの `status``"incomplete"` になり、`incomplete_details.reason` が `"max_output_tokens"` にセットされる。
## 4. Reasoning tokens との関係 / Reasoning モデルとの組合せ制約
`max_output_tokens`**reasoning tokens を含む** 合計生成トークン数の上限として機能する。
公式ガイド (https://platform.openai.com/docs/guides/reasoning) には以下の記述がある:
> "You can limit the total number of tokens the model generates (including both reasoning and final output tokens) by using the max_output_tokens parameter."
**実用上の注意点:**
- モデルが内部思考に多数の reasoning tokens を消費した後に上限に達すると、visible output が一切返らずに打ち切られる場合がある。
- コスト制御目的には `reasoning.effort` (`"low"` など) の使用が推奨される。`max_output_tokens` はあくまで暴走抑止のガードとして位置づける。
- o シリーズなど reasoning モデルでは `reasoning.max_tokens` (別パラメータ) で reasoning 専用の上限を設定できる場合もある。
## 5. ChatGPT backend (`https://chatgpt.com/backend-api/codex/responses`) における取り扱い
このエンドポイントは公式 Responses API のサブセットのみをサポートする非公式 backend であり、`max_output_tokens` を **サポートしないパラメータとして 400 エラーで拒否する**
LiteLLM の調査 (https://github.com/BerriAI/litellm/issues/21193) によれば、ChatGPT Codex backend が受け付けるパラメータは以下に限られる:
```
model, input, instructions, stream, store, include,
tools, tool_choice, reasoning, previous_response_id, truncation
```
`max_output_tokens`, `max_tokens`, `max_completion_tokens`, `temperature`, `user`, `metadata`, `context_management` はすべて拒否される。
Codex CLI 自身も `config.toml``model_max_output_tokens` を API リクエストに載せない実装になっており (https://github.com/openai/codex/issues/4138)、これはバグではなく ChatGPT backend の制約に対する回避策と解釈できる。
## 6. ドキュメント URL
- 公式 API リファレンス: https://platform.openai.com/docs/api-reference/responses/create
- Reasoning ガイド: https://platform.openai.com/docs/guides/reasoning
- Codex CLI issue (max_output_tokens 未送信): https://github.com/openai/codex/issues/4138
- LiteLLM issue (ChatGPT backend 拒否パラメータ一覧): https://github.com/BerriAI/litellm/issues/21193
- OpenAI Community (reasoning tokens 上限): https://community.openai.com/t/limiting-maximum-number-of-reasoning-tokens/1285430

View File

@ -1,5 +1,7 @@
You are here as an agent of the "insomnia system". You are here as an agent of the "insomnia system".
Stay precise, edit code directly when asked, and avoid speculative refactoring.
{% include "common/workspace" %} {% include "common/workspace" %}
{% include "common/tool-usage" %} {% include "common/tool-usage" %}

View File

@ -1,78 +0,0 @@
# メモリ機構: Phase 1 活動抽出
## 背景
`docs/plan/memory.md` §Phase 1 の実装。activity tokens の累積閾値で発火し、前回 Phase 1 以降の session log 範囲から「起きたこと」を 4 種の活動ログ候補として抽出、`memory/_staging/<id>.json` に書き出す。Knowledge 化や summary rewrite は Phase 2 に委ねる。
Pod を立てずに既存 compact と同じ Worker spawn 機構を再利用する。raw session log は `session-store` で保持されており、ここから range を切り出して入力に使う。
## 要件
### Trigger
- activity tokens 累積閾値(設定ファイルで tune。input tokens cumulative since last pointer を使う
- tool call カウントは不採用(ツールカスタマイズ非依存・大小重みづけのため)
- 発火点は Pod の post-run hook で、**compact より前** に走らせるcompact は history を組み替えるため、extract の入力範囲を安定させたい)
### 実行主体と入出力
- 既存 compact の Worker spawn 機構を再利用、Pod は立てない
- 入力: 前回 Phase 1 以降の session log 範囲
- 出力 JSON schema: `decisions`, `discussions`, `attempts`, `requests` の候補配列。抽出対象なしは空配列
- 出力に自由文の補足説明を入れさせないschema 準拠のみ)
### 処理境界の pointer 永続化
- pointer は session log に書き、寿命を session と揃える
- session-store のドメイン純度を保つため、汎用拡張点 `LogEntry::Extension { domain: String, payload: serde_json::Value }` を **本チケットで session-store に新設**し、`domain = "memory.extract"` で payload に `{ processed_through_entry: usize, staging_id: String }` を載せる
- `RestoredState` には `extensions: Vec<(String, serde_json::Value)>` 形で raw 集積し、memory crate 側が `domain` で fold して最新 pointer を取り出すsession-store は memory のことを知らない)
### 並走防止 (Phase 1 同士)
- Pod 上の `extract_in_flight: AtomicBool` で in-flight 中の新規 trigger を skip
- 完了時点で閾値再評価し、超過していれば直ちに次回を発火(新 pointer 以降の最大範囲を回収)
- pending 状態は別途保持しない(完了時の再評価で coalesce 相当が自然に成立)
- Phase 2 の進行状況ファイルとは別物(こちらは別チケット範囲外)
### 書き込み
- 書き込み先: `memory/_staging/<id>.json`1 件 1 ファイル、UUIDv7 可)
- pod 側ラッパーが `source: { session_id, range: [start_entry, end_entry] }` を**機械付与**して LLM 出力と wrap
- LLM に source を推論させない
### モデル
- 設定 key `memory.extract_model`(軽量だが文脈理解できる中堅クラス想定)
- 副次設定: `memory.extract_threshold`input tokens 累積閾値、未設定で disable、`memory.extract_worker_max_input_tokens`extract worker 自身の input cap
### prompt
- prompt 要件は `docs/plan/memory-prompts.md` §Phase 1: 活動抽出 prompt に従う
## 範囲外
- Phase 2 による staging の消費・クリーンアップ(別チケット)
- staging の cleanup 戦略の詳細Phase 2 で完了時に消す、実行中追加分は残す、という契約だけ本チケットで守る)
- compact Worker spawn 機構自体の拡張(既存をそのまま使う。共通化が必要になったら別途)
- Phase 2 並走防止ファイル(別チケット)
## 完了条件
- Pod 稼働中に閾値超過で Phase 1 が発火し、`memory/_staging/<id>.json` にファイルができる
- ファイルは schema に準拠、`source` が機械付与されている
- 抽出対象なしのときは空配列として書き出される(または発火そのものを skip、どちらでもよい
- session 側の処理済み pointer が更新され、次回 Phase 1 は続きから走る
- 既存 compact の動作に回帰がない
## 参照
- `docs/plan/memory.md` §Phase 1: 活動抽出 / §ファイル形式staging
- `docs/plan/memory-prompts.md` §共通原則 / §Phase 1: 活動抽出 prompt
- 既存 `session-store` クレートsession log range 取得)
- 既存 compact の Worker spawn 経路
## Review
- 状態: Request changes → 対応済み (2026-04-28、Blocking fix + 回帰テスト追加)
- レビュー詳細: [./memory-phase1-extract.review.md](./memory-phase1-extract.review.md)
- 日付: 2026-04-28

View File

@ -1,60 +0,0 @@
# Review: メモリ機構 Phase 1 活動抽出
## 前提・要件の確認
- **Trigger (input tokens 累積閾値、tool call カウント不採用)**: `Pod::cumulative_input_tokens_since` (`crates/pod/src/pod.rs:1254`) で `usage_history``input_total_tokens` を集計し、`memory.extract_threshold` 未設定時は no-op (`pod.rs:1280`)。tool call は不参照で要件適合。閾値の単位については後述の Non-blocking 参照
- **Trigger (compact より前)**: Controller の post-run ブロック 4 箇所すべてで `try_post_run_extract``try_post_run_compact` の順に呼ばれる (`crates/pod/src/controller.rs:335,393,448,545`)。要件どおり
- **実行主体 (compact と同じ Worker spawn 機構を再利用、Pod は立てない)**: `run_extract_once``llm_worker::Worker` を直接組んで `system_prompt`/`temperature`/`max_tokens`/usage callback/interceptor を貼る構成 (`pod.rs:1378-1404`)。compact 経路 (`pod.rs:1046-1077`) と同じ素の Worker spawn パターン。Pod は立てていない
- **入出力 (前回 Phase 1 以降の session log 範囲)**: `processed_through_history_len..current_history_len``Worker::history()` から切り出し (`pod.rs:1369`)、`source.range = [start_entry, end_entry]` は session-store の entry index で機械付与 (`pod.rs:1417-1420`)
- **出力 schema (4 種候補配列、空配列許容、自由文不可)**: `ExtractedPayload` (`crates/memory/src/extract/payload.rs:13`) が `decisions/discussions/attempts/requests` を保持し全 default 空。EXTRACT_SYSTEM_PROMPT (`crates/memory/src/extract/prompt.rs:7`) は schema 遵守と空配列許容、自由文禁止を明示。`write_extracted` ツール 1 本のみ提供で「ツールで提出して終わる」枠組みに閉じている
- **pointer 永続化 (session-store 拡張点 `LogEntry::Extension`)**: `LogEntry::Extension { ts, domain, payload }` を新設 (`crates/session-store/src/session_log.rs:175`)、`RestoredState.extensions: Vec<(String, Value)>` を追加 (`session_log.rs:223`)、`save_extension` ヘルパー (`crates/session-store/src/session.rs:333`) を export。session-store 側に `"memory.extract"` 定数も `payload` 構造の知識も持たせていない (replay は順番に積むだけ)。fold 責務は `memory::extract::fold_pointer` (`crates/memory/src/extract/pointer.rs:26`) に閉じている。ドメイン純度の要件を満たす
- **並走防止 (`extract_in_flight: AtomicBool`、完了時再評価ループ)**: `Pod::extract_in_flight: Arc<AtomicBool>` (`pod.rs:131`) を `compare_exchange` でガード (`pod.rs:1287`)、完了時は `loop` で再評価 (`pod.rs:1289-1314`)。`pending` 状態は持たず、完了時に閾値が落ちていれば自然に Skipped で抜けるため coalesce 相当が成立。要件どおり
- **書き込み (`memory/_staging/<id>.json`、source 機械付与、UUIDv7 可)**: `write_staging` (`crates/memory/src/extract/staging.rs:40`) が UUIDv7 ファイル名で `StagingRecord { source, payload }` を pretty JSON 書き出し。`source` は Pod 側で `session_id` + `range` を付与し、LLM には推論させない (system prompt にも明示)
- **モデル設定 (`memory.extract_model` + 副次設定)**: `MemoryConfig``extract_model` / `extract_threshold` / `extract_worker_max_input_tokens` の 3 つを追加 (`crates/manifest/src/lib.rs:75-86`)、cascade merge も対称 (`crates/manifest/src/config.rs:215-219`)、`extract_worker_max_input_tokens` の default 定数も追加 (`crates/manifest/src/defaults.rs:50`)。`extract_model` 未設定時は main client の `clone_boxed()` にフォールバック (`pod.rs:1238-1247`)
- **prompt 要件 (`docs/plan/memory-prompts.md` §Phase 1)**: `EXTRACT_SYSTEM_PROMPT` は §共通原則 (source 推論禁止、空出力許容) と §Phase 1 (派生物作成禁止、4 種限定、自由文禁止、shallow 除外) を網羅。`#共通原則` の「既存 docs と重複保存しない」も "Do not duplicate content already captured by static project docs" として反映済み
## 完了条件の確認
- 閾値超過で発火し staging file 作成: `run_extract_once` の最後で `write_staging` → pointer save。空 payload でも pointer は前進し、staging 書き込みのみ skip という分岐が pointer.rs:19 のコメントと整合
- schema 準拠 + `source` 機械付与: `WriteExtractedTool``from_str::<ExtractedPayload>` で受け、Pod 側で `SourceRef` を wrap (`pod.rs:1417-1422`)。LLM 側に source は渡らない
- 抽出対象なしは空配列 / skip どちらでも可: 現実装は空配列でも skip (空時は staging file を作らず、pointer だけ前進)。仕様上どちらでも良いとある通り
- session 側 pointer 更新で続きから走る: `save_extension``EXTRACT_DOMAIN` 永続化 + 同期して in-memory `extract_pointer` 更新 (`pod.rs:1442-1445`)。restore 時は `fold_pointer` で最新値を取り出す (`pod.rs:254`)
- 既存 compact の動作に回帰なし: pod 141 / session-store 16 / memory 82 / manifest 75 全 pass、compact のテストは無改変
## アーキテクチャ・スコープ
- **session-store ドメイン純度**: `LogEntry::Extension` は domain 文字列を不透明に扱い、payload は `serde_json::Value` のまま積むだけ。`"memory.extract"` 定数も `ExtractPointerPayload` の構造も memory crate に閉じている。session-store 単体テスト (`session_log.rs:644-700`) も memory ドメインを知らない汎用テストになっており、設計通りの分離が取れている
- **memory crate 内のモジュール分割**: `extract/{mod,input,payload,pointer,prompt,staging,tool}.rs` で関心ごとに 1 ファイルずつ切れている。`mod.rs` の re-export と doc が綺麗。`pod.rs` に直書きせず memory 側に責務が寄っており、好ましい
- **Pod の責務**: trigger 判定・worker spawn・source 機械付与・pointer 永続化のみ。`memory::extract::*` を呼ぶだけで自分は schema/prompt/tool を知らない。範囲外Phase 2 / staging cleanup / compact spawn 機構の共通化 / Phase 2 並走防止ファイル)には手を出していない
- **compact との並列性**: `MemoryExtractWorkerInterceptor``CompactWorkerInterceptor` のミラー実装。共通化していないが、ticket §範囲外で「compact Worker spawn 機構自体の拡張」を明確に除外しているので OK。ただし将来 3 個目が出るなら共通化候補
- **manifest 設定の cascade**: `MemoryConfig::merge_with_upper` に追加 3 フィールド全てを忘れず追記済み (`config.rs:215-219`)、defaults.rs に定数化、lib.rs に `Option` の意図 doc を追加とパターン遵守
- **依存追加**: `memory/Cargo.toml``uuid = { version = "1.23.1", features = ["v7", "serde"] }` 追加。バージョンとフォーマットは `cargo add` の出力体裁
## 指摘事項
### Blocking
- **Compact 後に extract_pointer が陳腐化して Phase 1 が止まる**`crates/pod/src/pod.rs:1180-1216``compact()``session_id` を新しいコンパクト後セッションへ swap し `usage_history``clear()` しているが、`extract_pointer` (in-memory) は古いセッションの `processed_through_history_len`(典型的には 50+)を保持したまま。次回以降 `cumulative_input_tokens_since(processed_history_len)` の filter `r.history_len > history_len_pointer` は新しいセッションの低い `history_len` をすべて除外し、永久に 0 を返す。結果としてプロセス継続中に compact が走ると、その後 Phase 1 は二度と発火しない。fix は `compact()` の swap 直後(`usage_history.clear()` の隣)で `*self.extract_pointer.lock() = None` を行うのが最小。新セッション側の log には Extension entry がまだ無いので、cold restore でも `fold_pointer = None` になっており、cold restore と挙動を一致させられる
- **対応済み (2026-04-28)**: `Pod::compact()` (`crates/pod/src/pod.rs:1217-1226`) で `usage_history.clear()` の直後に `*self.extract_pointer.lock() = None` を実行。意図と cold restore 一致を doc コメントに明記。
- **完了条件「session 側の処理済み pointer が更新され、次回 Phase 1 は続きから走る」が compact を挟むと崩れる** — 上記 Blocking の直接の帰結。テストでは現れていないが、compact + extract の組合せシナリオを想定したテストがあれば検出できる
- **対応済み (2026-04-28)**: `Pod::extract_pointer() -> Option<ExtractPointerPayload>` accessor を追加し、回帰テスト `compact_resets_extract_pointer_so_phase1_can_fire_again` (`crates/pod/tests/compact_events_test.rs:319-381`) を追加。extract → compact の連続シナリオで pointer が None に戻ることを検証。
### Non-blocking / Follow-up
- **閾値の単位cumulative `input_total_tokens` の解釈)**`UsageRecord.input_total_tokens` は「そのリクエスト送信時のプロンプト全長」(`session-store/src/session_log.rs:147-149`) で増分ではない。pointer 以降の records を素直に sum すると、長い 1 turn 内の連続 LLM call では各 prompt が前 prompt の super-set なので合計は実トークン消費の数倍に膨らむ。ticket / 設計の「cumulative input tokens since last pointer」をどう取るかは複数解釈あるが、現実装は最もリベラル=最も発火しやすい)解釈になっており、頻繁発火を許容する仕様前提と整合はする。今後 threshold の運用値を tune するときに「3 turn で hit する」程度の感覚と乖離するので、doc に "billed input cumulative" と明記するか、`input_total_tokens - cache_read` の差分版や、最後の record だけを見る (= 現プロンプト長) など別解釈に切り替えるか、いずれ運用観察で決める
- **対応済み (2026-04-28)**: `Pod::tokens_added_since(history_len_pointer)``Pod::total_tokens_at(now) - Pod::total_tokens_at(pointer)` の差分計算に切り替え (`crates/pod/src/pod.rs:1284-1294`)。compact 側と同じ accounting (measured/interpolated/extrapolated) に乗るので、threshold 値は「pointer 以降に追加されたプロンプト全長」と素直に解釈できる。`Pod::total_tokens_at(history_len)` を `compact::token_counter` に追加 (`pub` API、将来別チケットで llm-worker に下ろす予定)。
- **`build_extract_input` が ToolCall 引数と ToolResult 本文を落とす** — `crates/memory/src/extract/input.rs:40-45`。compact 用 `build_summary_prompt` のミラーだが、Phase 1 の `attempts` 抽出は「何を試したか」が tool 引数書き換えた箇所、実行コマンドに集中することが多く、tool 名だけだと "ran read_file" レベルの粒度になる。tool 結果 summary は残るので致命ではないが、Phase 1 prompt の `attempts.action` 品質を観察してから decide。共通ヘルパー化したいなら memory crate へ持って行く整理も将来検討
- **空 payload で pointer だけ前進する設計の妥当性**`pod.rs:1414-1424``payload.is_empty()` なら staging を作らず pointer だけ進める。spec §完了条件の「空配列で書き出す または skip、どちらでもよい」に合致。一方 `LogEntry::Extension` は payload を増やし続けるので、空 turn が連続すると Extension entry が積み上がる。session 寿命なので Phase 1 の頻度なら許容範囲だが、log size 監視のときに見える要素として把握しておく
- **`write_extracted` が呼ばれずに worker が終了したケースの取り扱い** — `pod.rs:1406-1412` で warn を出して空 payload 扱い、pointer は前進。再実行で同 range を再抽出できないので、ここで pointer を前進させる選択は LLM 不安定時に情報を取りこぼす可能性がある一方、無限ループ防止という意味では合理的。仕様上は "skip でも空配列でもよい" なので問題ないが、運用で `write_extracted` 呼び忘れが頻発するなら spec 変更pointer 据え置きで再 triggerが選択肢
- **Controller 側の `if let Err(e)` 分岐は到達不能**`try_post_run_extract` は最後に internal `self.alert` した上で常に `Ok(())` を返すので、controller の `if let Err(e) = pod.try_post_run_extract()` は dead branch。`try_post_run_compact` も同様で先例を踏襲しているだけだが、両方とも内部 alert だけに統一するか、controller-only alert にするかの整理は別 PR で
### Nits
- `pointer.rs:80-90` の malformed entry テスト: コメント「壊れた最新を黙って無視すると意図しない再抽出を招く」は妥当な保守姿勢。ただし複数 Extension entries がある中で**たまたま最新だけ malformed** の場合は古い良 entry に fallback したほうが安全とも言える。現状で良いが、Phase 2 の stagings 消費と接続するときに再度 visit
- `tool.rs:42-48``call_count` は debug 用のみで Pod 側からは未使用。残しておくならいずれ logging で警告に繋ぐ
- `pod.rs:1339` 直前の comment「`history_len_pointer == 0` means everything so far」は「history_len > 0 のすべての record」という意味で正しいが、説明が `cumulative_input_tokens_since(0)` と読むと若干誤読しやすい。doc に「pointer 0 = 未抽出」と書き換えると親切
- `MEMORY_EXTRACT_WORKER_MAX_INPUT_TOKENS = 30_000` の default 値はコメントに根拠を 1 行付記しても良い (compact が 50_000 なので、抽出は context 圧縮後の slice であってより小さくて足りる、という意図か)
## 判断
**Request changes → 対応済み (2026-04-28)** — Blocking 指摘事項 (compact 後の extract_pointer リセット漏れ) は `crates/pod/src/pod.rs:1217-1226` で fix し、`crates/pod/tests/compact_events_test.rs:319-381` に回帰テストを追加して再発防止を担保した。workspace 全テスト pass。Non-blocking / Nits は仕様準拠と保守姿勢の範囲で別チケット / 運用観察に委ねる方針で完了とできる。

View File

@ -0,0 +1,35 @@
# `scope-lock``pod-registry` リネーム
## 背景
`crates/scope-lock` は元々 `crates/pod/src/runtime/scope_lock.rs` にあった機能を、TUI picker から live セッション判定 (`lookup_session`) のために参照したいという理由で独立クレートに切り出したもの。実体は「マシン全体で生きている Pod の allocation テーブル」であり、scope 衝突判定はその一機能にすぎないsession_id 衝突防止・delegation tree の reparent・stale 回収などは scope と独立に効いている)。
加えて、永続化先のファイル名 `scope.lock` も実態と合っていない:
- 中身は `flock(2)` で保護された JSON ステート。`Cargo.lock` のようなバージョン固定ファイルではない
- `flock` は read-modify-write のトランザクションを直列化するためだけに保持しており、Pod の生存期間ずっとロックを保持しているわけでもない
- 拡張子で `.lock` を名乗っていることで「触るな」という誤った印象を与える
## ゴール
クレート名とファイル名を実態に合わせる:
- crate `scope-lock``pod-registry`
- ファイル `<runtime_dir>/scope.lock``<runtime_dir>/pods.json`
- `manifest::paths::scope_lock_path``manifest::paths::pod_registry_path`
- インポート `scope_lock::...` / `crate::runtime::scope_lock::...``pod_registry::...`
- `crates/pod/src/runtime/mod.rs``pub use ::scope_lock``pub use ::pod_registry`
- ドキュメンテーション・コメント中の "scope.lock" / "scope-lock registry" の言い換え
## 範囲外
- 内部型名のリネーム(`LockFile` / `LockFileGuard` / `ScopeAllocationGuard` / `ScopeLockError` 等)。これらは内部 API でリネームしても挙動は変わらず、必要が生じた時に別途整理する。
- `ScopeRule` / `scope_allow` / `delegate_scope` 等、scope そのものを扱うシンボル名は据え置き(クレートが何かではなく、何を扱うかなので)。
- 既存の `scope.lock` ファイルが残っている環境での互換性dev 期間中の互換不要、必要なら手で消す)。
## 完了条件
- `crates/pod-registry` として独立クレートが存在し、`pod`/`tui` の依存もこの名前を指している
- `<runtime_dir>/pods.json` が新しいレジストリの保存先として使われる
- 既存テストpod / pod-registry / tui 関連)がすべて緑
- ドキュメンテーション・チケット本文中の参照(`tickets/dynamic-scope.md` 等)が新名に揃っている

View File

@ -1,79 +0,0 @@
# TUI: 既存セッションからの Pod 復帰
## 背景
`session-store` は JSONL ログから Worker 状態を復元でき、Pod 側にも `Pod::restore(session_id, ...)` が存在する。一方で、現在の実行経路は新規 Pod 起動 (`Pod::from_manifest`) と生存中 Pod への attach / `Paused` 状態の `Resume` に限られており、停止済み Pod を既存 `SessionId` から起動するユーザー向け導線がない。
TUI には既に新規 Pod 起動用の spawn UI があるため、同じような選択 UI で既存 session を一覧し、選択した session を復元した Pod を起動して attach できるようにする。
## 要件
### 起動導線
- TUI の既存挙動は維持する:
- `tui`(引数なし): 新規 Pod spawn。現在と同じ name 入力ダイアログから始める
- `tui <pod-name>`: 生存中 Pod への attach
- 既存 session 復帰用に `tui -r` / `tui --resume` を追加する
- `--resume` はユーザー向けの「過去 session から復帰」入口であり、protocol の `Method::Resume`Paused turn の続行)とは別概念として扱う
- `--resume` 指定時のみ、現在の name 入力ダイアログの前段に session 選択プロンプトを表示する
- session id を直接指定するショートカットとして `tui --session <UUID>` を追加する
- `--session` は session picker をスキップし、指定 session を復元対象にした name 入力ダイアログから始める
- `--resume``--session` は併用不可
- 直接起動用に、Pod CLI に session id を指定して復元起動するフラグを追加する(`pod --session <UUID>`
- TUI の `--resume` / `--session` 復帰フローは最終的にこの Pod CLI 復元起動経路を使う
### セッション一覧
- `manifest::paths::sessions_dir()` または既存の `--store` 相当設定で解決される session store を読み、既存 session を新しい順に一覧表示する
- 一覧には少なくとも以下を表示する:
- `SessionId`
- 最終更新時刻、または store が提供できる同等の並び順情報
- 履歴の簡易プレビュー(最後の user / assistant メッセージ等、取得できる範囲でよい)
- session log が壊れている、復元不能、または現在のバージョンで読めない場合は、その行を復帰不可として表示するか、エラー表示してスキップする
- session が 1 件もない場合は、新規 Pod 起動へ戻れる導線を出す
### 復元 Pod の構築
- 選択した `SessionId` を使って `Pod::restore` 経由で Pod を構築する
- manifest / scope / tool 登録 / prompt loader は、通常の新規 Pod 起動と同じ現在の cascade 解決結果を使う
- Worker の会話履歴・system prompt・request config・turn count・usage history 等は session log 由来の状態を使う
- 復元起動時、runtime の `history.json` / `status.json` / `Event::History` で TUI が初期履歴を正しく再構築できる
- 復元された session が interrupted / paused 相当の状態を持つ場合、起動直後に `Resume` 可能な状態として扱う。通常終了済みなら `Idle` として新しい入力を受け付ける
### 二重起動の扱い
- 既に生きている Pod が同じ session を持っている場合は、新規復元起動せず既存 Pod への attach を促す
- 少なくとも、同じ session id に対する複数 Pod の同時書き込みが発生しないようにする
- runtime dir / status.json から検出できる範囲でよいが、検出不能な場合のエラーメッセージは明示する
### UI / 操作
- `tui -r` / `tui --resume` では、name 入力の前に session picker を表示する
- session picker は上下キーで session を選択し、Enter で決定、Esc / Ctrl-C でキャンセルできる
- session が多い場合でも使えるよう、最低限のスクロールを備える
- session 決定後は既存の name 入力ダイアログを再利用する
- 入力する name は、復元された session を載せる新しい Pod 実行インスタンス名runtime dir / socket 名)
- default name は現行と同じ cwd 由来でよい
- 表示上は `Resume Pod` / `session: <short-id>` のように、新規 spawn ではなく復帰であることを明示する
- `tui --session <UUID>` では session picker を省略し、指定 session を対象にした name 入力ダイアログから始める
- 将来的な検索フィルタ追加を妨げない state 構造にするが、本チケットでは必須にしない
- 復帰に失敗した場合、inline / alt-screen 内にエラーを表示し、一覧へ戻るか終了できる
## 完了条件
- `pod --session <UUID>` で既存 session から Pod を起動できる
- `tui -r` / `tui --resume` で既存 session 一覧を表示し、選択した session を復元対象にできる
- `tui --session <UUID>` で session picker を経由せず、指定 session の復帰 name 入力へ進める
- 復帰フローでは session 選択後または `--session` 指定後に name 入力ダイアログが表示され、その name の Pod として起動・attach できる
- 復元直後の TUI に過去履歴が表示される
- 復元後に新しい入力を送ると、既存履歴に続く turn として動作し、session log に追記される
- interrupted / paused 状態の session では、復元直後に Resume 導線が動作する
- 同一 session の生存中 Pod がある場合は二重起動せず attach または明示的なエラーになる
## 範囲外
- session log の全文検索 UI
- compact 前後の session chain を 1 つの論理スレッドとして束ねる UI
- 過去 session の編集・削除・名前付け
- spawn された子 Pod / scope delegation ツリー全体の復元
- 別マシンから転送された session store の import UI

View File

@ -1,89 +0,0 @@
# Thinking ブロックの TUI 表示
## 背景
Reasoningextended thinking系の対応は llm-worker レイヤまで降りている:
- Anthropic の `thinking`、OpenAI Responses の `reasoning_text` / `reasoning_summary_text`、Gemini の thinking はいずれも `DeltaContent::Thinking(String)` に正規化され、Timeline 層には `ThinkingBlockKind` の Start / Delta / Stop が流れている
- history 上は `Item::Reasoning { text, summary, encrypted_content, ... }` として保持され、session-store にも persist されている
ところが上位層への通り道が無い:
- `Worker` の closure 公開 API には `on_thinking_block` が無い(`on_text_block` / `on_tool_use_block` のみ)
- `protocol::Event` に Thinking 系イベントが無い
- pod controller でブリッジしていない
- TUI に Thinking 用ブロックが無い
結果として、provider が thinking 平文を返していても TUI からは「無音で時間が過ぎる」状態になる。実行中であることが見えず、終わった後に「どれくらい考えていたか」も残らない。
provider ごとに本文を流せるかは異なる:
- **Anthropic**: extended thinking は平文で流れる
- **OpenAI Responses**: モデル / 設定によって本文(`reasoning_text`)が流れない場合がある。`reasoning_summary` は流れることがある
- **Gemini**: thinking は流れるが provider 設定依存
「平文があれば流す。無くても thinking 中であることは見せる」というのが基本方針。
## 方針
- llm-worker → protocol → pod controller → TUI に Thinking の通り道を作る
- worker は `DeltaContent::Thinking` 由来の本文をそのまま渡す。本文を出さない provider のときは Delta が来ないだけで Start / Done は届く
- TUI は実行中とその後の両方を残す:
- **実行中**: `Thinking... (Xs)` ヘッダ + 本文があれば直近 1 行のライブ表示
- **終了後**: `Thought for Xs` として履歴に残す。`detail` モードでは累積本文を展開
- token 数表記は当面入れない(`UsageEvent` に reasoning 分離が無く、別チケットで `Usage` を拡張するまで保留)
## 要件
### Worker API
- `Worker::on_thinking_block(setup)``on_text_block` と対称に追加。setup は per-block で 1 回呼ばれ、`block.on_delta(|text|)` / `block.on_stop(|full_text|)` を登録できる
### Protocol
- `Event::ThinkingStart`、`Event::ThinkingDelta { text }`、`Event::ThinkingDone { text }` を追加(`text` には完成形を載せる、`TextDone` と同じ流儀)
- 本文を返さない provider では Delta が 0 件のまま Start → Done が届く(破綻しない)
- 1 turn に複数の thinking block が来る可能性があるprovider 都合)。各ブロックは独立して扱う
### Pod Controller
- `worker.on_thinking_block` で上記 3 イベントに変換して `event_tx` に流す
### TUI
- 新ブロック種別 `Block::Thinking` を持つ
- 実行中は以下のように表示:
```
Thinking... (10s)
<累積本文の末尾を 1 行に切り詰めたもの>
```
- 終了後は `Thought for 12s` を残す。`detail` モードでは本文をそのまま展開して読める
- `overview` モードは 1 行(例: `Thought for 12s`
- 経過時間表示のため、Thinking ブロックが実行中の間は再描画が定期的に走る必要がある(粒度は 1Hz 程度で十分)
- ライブ 1 行の選び方は「累積テキストの最後の改行以降を取り、表示幅で切り詰める」を MVP とする
- 同一 turn 内の複数 thinking block は別ブロックとして表示される
### イベント欠落耐性
- `ThinkingDone` が来ないまま `TurnEnd` が来た場合、対応する `Block::Thinking` は経過時間を凍結した状態で履歴に残す。`ToolCall` 側の `Incomplete` と同じ思想
### History 再生
- `Event::History``Item::Reasoning { text, ... }``Block::Thinking { text, finished: true }` として復元する(経過時間は持たないので `Thought` のみで時間表示は省く)
## 範囲外
- `UsageEvent` の reasoning_tokens 分離。Anthropic は `output_tokens` に thinking を含み、OpenAI Responses は `output_tokens_details.reasoning_tokens` を別途返すが、現状の `UsageEvent` ではそれを分離していない。本チケットでは token 数表示そのものを行わない
- Anthropic Redacted Thinking暗号化 blobの表示。現状 plaintext 経路のみ流れる
- Thinking 本文の Markdown レンダリング / シンタックスハイライト
- thinking を context から prune する話(別軸)
## 完了条件
- Anthropic で extended thinking を有効にした session で「Thinking... (Xs) + 本文 1 行」が live で見え、終了後に `Thought for Xs` として履歴に残る
- 本文を流さない provider 設定でも `Thinking...` ヘッダが表示され、終了後に `Thought for ...` が残る
- `detail` モードで thinking 本文が全行展開できる
- 同一 turn に複数 thinking block が来てもそれぞれ独立に表示される
- `Event::History` 再生で過去の thinking が `Block::Thinking { finished: true }` として復元される
- `ThinkingDone` 欠落でも panic せず、Incomplete 相当の表示で残る
- 既存のテキスト / ツール / notification / compact 表示が壊れない