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

View File

@ -9,6 +9,7 @@ members = [
"crates/pod",
"crates/protocol",
"crates/provider",
"crates/scope-lock",
"crates/tools",
"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 オーケストレーション
- [ ] 動的 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)
- [ ] TUI 拡充
- [ ] フルスクリーン化によるオーバーホール → [tickets/tui-fullscreen-overhaul.md](tickets/tui-fullscreen-overhaul.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)
- [ ] 既存セッションからの Pod 復帰 → [tickets/tui-session-restore.md](tickets/tui-session-restore.md)
- [ ] サブミット入力
- [ ] TUI 補完 + 型付き atom 化 → [tickets/submit-tui-completion.md](tickets/submit-tui-completion.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)
- [ ] 使用頻度メトリクス + Knowledge 化候補レポート → [tickets/memory-usage-metrics.md](tickets/memory-usage-metrics.md)
- [ ] GC定期再評価 → [tickets/memory-gc.md](tickets/memory-gc.md)
- ワークスペースのメモリーを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 本体。
///
/// `store` / `include_encrypted_content` は scheme 固定の wire 設定で、
/// デフォルトは stateless + ZDR 相当 (`store=false`, `include=[...]`)。
/// 将来 ZDR 非対応環境で `store=true` にしたくなった場合に限り override
/// する。`ModelCapability` には入れない(これはモデルの能力ではなく、
/// クライアントの運用方針)。
/// `store` / `include_encrypted_content` / `send_max_output_tokens` は
/// scheme 固定の wire 設定で、デフォルトは公式 OpenAI Responses API
/// 向け (stateless + ZDR + `max_output_tokens` 送出可)。ChatGPT backend
/// (codex-oauth) のように受理パラメータが subset の経路では provider 層で
/// `send_max_output_tokens=false` 等に上書きする。`ModelCapability` には
/// 入れない(モデル能力ではなく wire policy
#[derive(Debug, Clone)]
pub struct OpenAIResponsesScheme {
/// サーバ側に response を保存するか。ZDR/stateless 運用では `false`。
@ -28,6 +29,10 @@ pub struct OpenAIResponsesScheme {
/// `include: ["reasoning.encrypted_content"]` を付けるか。
/// `store=false` で reasoning を使うなら必須。
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 {
@ -35,12 +40,14 @@ impl Default for OpenAIResponsesScheme {
Self {
store: false,
include_encrypted_content: true,
send_max_output_tokens: true,
}
}
}
impl OpenAIResponsesScheme {
/// デフォルト設定 (`store=false`, `include=["reasoning.encrypted_content"]`)。
/// デフォルト設定 (`store=false`, `include=["reasoning.encrypted_content"]`,
/// `send_max_output_tokens=true`)。
pub fn new() -> Self {
Self::default()
}
@ -56,4 +63,10 @@ impl OpenAIResponsesScheme {
self.include_encrypted_content = include;
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"]` 等。
#[serde(skip_serializing_if = "Vec::is_empty")]
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")]
pub max_output_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
@ -195,7 +198,11 @@ impl OpenAIResponsesScheme {
store: self.store,
stream: true,
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,
top_p: request.config.top_p,
}
@ -444,13 +451,26 @@ mod tests {
}
#[test]
fn max_output_tokens_passed_through() {
fn max_output_tokens_passed_through_by_default() {
let scheme = OpenAIResponsesScheme::new();
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, 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]
fn tool_schema_without_properties_is_normalized() {
// schemars は引数なし struct から `type:"object"` だけのスキーマを

View File

@ -3,8 +3,9 @@
use serde_json::Value;
use crate::llm_client::{
ClientError, auth::AuthRequirement, capability::ModelCapability, event::Event, scheme::Scheme,
types::Request,
ClientError, auth::AuthRequirement, capability::ModelCapability,
client::ConfigWarning, event::Event, scheme::Scheme,
types::{Request, RequestConfig},
};
use super::OpenAIResponsesScheme;
@ -51,4 +52,18 @@ impl Scheme for OpenAIResponsesScheme {
fn default_capability(&self) -> ModelCapability {
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" }
protocol = { version = "0.1.0", path = "../protocol" }
provider = { version = "0.1.0", path = "../provider" }
scope-lock = { version = "0.1.0", path = "../scope-lock" }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
thiserror = "2.0"

View File

@ -4,7 +4,7 @@ use std::process::ExitCode;
use clap::Parser;
use manifest::paths;
use pod::{Pod, PodController, PodFactory};
use session_store::FsStore;
use session_store::{FsStore, SessionId};
#[derive(Parser)]
#[command(
@ -43,6 +43,14 @@ struct Cli {
/// callbacks upward. Required alongside `--adopt`.
#[arg(long, value_name = "PATH", requires = "adopt")]
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> {
@ -136,6 +144,14 @@ async fn main() -> ExitCode {
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 {
match Pod::from_manifest(manifest, store, loader).await {
Ok(p) => p,

View File

@ -100,10 +100,11 @@ pub struct Pod<C: LlmClient, St: Store> {
/// PodInterceptor installed in `ensure_interceptor_installed`.
pending_notifies: NotifyBuffer,
/// Scope allocation in the machine-wide lock file. `Some` for
/// Pods built via `from_manifest` (production path); `None` for
/// lower-level constructors (`Pod::new`, `Pod::restore`) that
/// bypass the registry. Kept purely for its `Drop` impl, which
/// releases the allocation when the Pod is dropped.
/// Pods built via `from_manifest` / `from_manifest_spawned` /
/// `restore_from_manifest` (production paths); `None` for the
/// low-level `Pod::new` constructor used in tests, which bypasses
/// the registry. Kept purely for its `Drop` impl, which releases
/// the allocation when the Pod is dropped.
#[allow(dead_code)]
scope_allocation: Option<ScopeAllocationGuard>,
/// 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;
}
/// Restore a Pod from a persisted session.
/// Shared handle to the prompt catalog. Cheap to clone (`Arc`).
pub fn prompts(&self) -> &Arc<PromptCatalog> {
&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.
pub fn session_id(&self) -> SessionId {
self.session_id
@ -781,6 +718,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
self.head_hash = Some(hash);
return Ok(());
}
let prev_session_id = self.session_id;
session_store::ensure_head_or_fork(
&self.store,
&mut self.session_id,
@ -788,6 +726,13 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
state,
)
.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(())
}
@ -1064,7 +1009,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
let mut summary_worker = Worker::new(summary_client)
.system_prompt(summary_system_prompt)
.temperature(0.0);
summary_worker.set_max_tokens(4096);
// Cumulative input-token meter + interceptor. The meter is bumped
// 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.
self.session_id = new_session_id;
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();
worker.set_history(new_history);
// 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)
.system_prompt(extract::EXTRACT_SYSTEM_PROMPT)
.temperature(0.0);
extract_worker.set_max_tokens(4096);
// Cumulative input-token meter + interceptor (mirror of
// CompactWorkerInterceptor). Aborts the extract worker if its
@ -1536,15 +1488,18 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
store: St,
loader: PromptLoader,
) -> Result<Self, PodError> {
let pwd = current_pwd()?;
let scope = build_scope_with_memory(&manifest, &pwd)?;
if !scope.is_readable(&pwd) {
return Err(PodError::PwdOutsideScope { pwd });
}
let common = prepare_pod_common(&manifest, &loader, /* parse_template */ true)?;
// Session creation is deferred to the first run (see
// `ensure_session_head`) so the SessionStart entry can capture
// the rendered system prompt, not the raw template source. The
// session_id is allocated here so the scope-lock registration
// can record it from the start.
let session_id = session_store::new_session_id();
// Register this Pod in the machine-wide scope-lock registry
// 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()
.map_err(ScopeLockError::from)?
.join(&manifest.pod.name)
@ -1553,50 +1508,34 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
manifest.pod.name.clone(),
std::process::id(),
socket_path,
scope.allow_rules(),
common.scope.allow_rules(),
session_id,
)?;
let client = provider::build_client(&manifest.model)?;
let mut worker = Worker::new(client);
let mut worker = Worker::new(common.client);
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 {
manifest,
worker: Some(worker),
store,
session_id,
head_hash: None,
pwd,
scope,
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(Vec::new())),
tracker: None,
system_prompt_template,
system_prompt_template: common.system_prompt_template,
alerter: None,
event_tx: None,
pending_notifies: NotifyBuffer::new(),
scope_allocation: Some(scope_allocation),
callback_socket: None,
prompts,
prompts: common.prompts,
inject_resident_knowledge: true,
extract_in_flight: Arc::new(AtomicBool::new(false)),
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
/// top-level entry. `callback_socket` carries the spawner's
/// Unix-socket path so the spawned Pod can send `Method::Notify`
/// back to the spawner; it is stored but unused in the
/// `spawn-pod-tool` ticket — the receiving side lands in the
/// follow-up `pod-callback` ticket.
/// back to the spawner.
pub async fn from_manifest_spawned(
manifest: PodManifest,
store: St,
loader: PromptLoader,
callback_socket: PathBuf,
) -> Result<Self, PodError> {
let pwd = current_pwd()?;
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 common = prepare_pod_common(&manifest, &loader, /* parse_template */ true)?;
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 {
manifest,
worker: Some(worker),
store,
session_id,
head_hash: None,
pwd,
scope,
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(Vec::new())),
tracker: None,
system_prompt_template,
system_prompt_template: common.system_prompt_template,
alerter: None,
event_tx: None,
pending_notifies: NotifyBuffer::new(),
scope_allocation: Some(scope_allocation),
callback_socket: Some(callback_socket),
prompts,
prompts: common.prompts,
inject_resident_knowledge: true,
extract_in_flight: Arc::new(AtomicBool::new(false)),
extract_pointer: Mutex::new(None),
@ -1671,6 +1596,113 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
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.
///
/// Parses the TOML into a [`PodManifestConfig`], converts to a
@ -1864,6 +1896,63 @@ pub enum PodError {
#[error("memory Phase 1 staging write failed: {0}")]
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

View File

@ -1,2 +1,2 @@
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::WriteConflict { .. }
| 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()),
}
}

View File

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

View File

@ -358,6 +358,7 @@ async fn shutdown_releases_scope_allocation_when_present() {
std::process::id(),
"/tmp/kid.sock".into(),
vec![],
session_store::new_session_id(),
)
.unwrap();
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,
recursive: true,
}],
session_store::new_session_id(),
)
.unwrap();
// 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::Gemini => build_transport(GeminiScheme::new(), config, resolved),
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 manifest::{Permission, ScopeRule, paths};
use serde::{Deserialize, Serialize};
use session_store::SessionId;
/// On-disk representation of the allocation table.
#[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
/// a top-level Pod started directly by a human.
pub delegated_from: Option<String>,
/// Session ID this Pod is currently writing to. `None` means this
/// is a pre-reservation made by a spawner via [`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 {
@ -60,6 +67,14 @@ impl LockFile {
pub fn find_mut(&mut self, pod_name: &str) -> Option<&mut Allocation> {
self.allocations.iter_mut().find(|a| a.pod_name == pod_name)
}
/// Find the allocation currently writing to `session_id`. Skips
/// pre-reservations whose `session_id` is still `None`.
pub fn find_by_session(&self, session_id: SessionId) -> Option<&Allocation> {
self.allocations
.iter()
.find(|a| a.session_id == Some(session_id))
}
}
/// Default on-disk path: `<runtime_dir>/scope.lock` resolved via
@ -288,17 +303,29 @@ fn find_conflict_in_subtree(
/// Register a top-level Pod (started directly by a human, no
/// delegation parent). Reclaims stale entries before checking
/// conflicts so a crashed Pod's allocation doesn't block the new one.
///
/// Rejects when another live allocation is already writing to
/// `session_id`, so two `restore_from_manifest` calls under different
/// `pod_name`s cannot both grab the same session log.
pub fn register_pod(
guard: &mut LockFileGuard,
pod_name: String,
pid: u32,
socket: PathBuf,
scope_allow: Vec<ScopeRule>,
session_id: SessionId,
) -> Result<(), ScopeLockError> {
reclaim_stale(guard);
if guard.data().find(&pod_name).is_some() {
return Err(ScopeLockError::DuplicatePodName(pod_name));
}
if let Some(existing) = guard.data().find_by_session(session_id) {
return Err(ScopeLockError::SessionConflict {
session_id,
pod_name: existing.pod_name.clone(),
socket: existing.socket.clone(),
});
}
for rule in scope_allow
.iter()
.filter(|r| r.permission == Permission::Write)
@ -316,6 +343,7 @@ pub fn register_pod(
socket,
scope_allow,
delegated_from: None,
session_id: Some(session_id),
});
guard.save()?;
Ok(())
@ -361,6 +389,9 @@ pub fn delegate_scope(
socket,
scope_allow,
delegated_from: Some(spawner.into()),
// Pre-reservation. The child fills in its own session_id when
// it calls `adopt_allocation` after the worker is built.
session_id: None,
});
guard.save()?;
Ok(())
@ -483,10 +514,18 @@ pub fn install_top_level(
pid: u32,
socket: PathBuf,
scope_allow: Vec<ScopeRule>,
session_id: SessionId,
) -> Result<ScopeAllocationGuard, ScopeLockError> {
let lock_path = default_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 {
pod_name,
lock_path,
@ -497,13 +536,15 @@ pub fn install_top_level(
/// a spawning Pod.
///
/// 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
/// child, once running, calls this function to rewrite the allocation's
/// pid to its own and claim the `ScopeAllocationGuard` so the entry is
/// released when the child exits.
/// (with its own pid as a live placeholder, `session_id = None`), then
/// exec's the child; the child, once running, calls this function to
/// rewrite the allocation's pid + session_id to its own and claim the
/// `ScopeAllocationGuard` so the entry is released when the child
/// exits.
pub fn adopt_allocation(
pod_name: String,
new_pid: u32,
session_id: SessionId,
) -> Result<ScopeAllocationGuard, ScopeLockError> {
let lock_path = default_lock_path()?;
let mut guard = LockFileGuard::open(&lock_path)?;
@ -512,6 +553,7 @@ pub fn adopt_allocation(
.find_mut(&pod_name)
.ok_or_else(|| ScopeLockError::UnknownPod(pod_name.clone()))?;
alloc.pid = new_pid;
alloc.session_id = Some(session_id);
guard.save()?;
Ok(ScopeAllocationGuard {
pod_name,
@ -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.
#[derive(Debug, thiserror::Error)]
pub enum ScopeLockError {
@ -535,6 +644,15 @@ pub enum ScopeLockError {
NotSubset { spawner: String, rule: ScopeRule },
#[error("pod `{0}` is not registered")]
UnknownPod(String),
#[error(
"session {session_id} is already held by pod `{pod_name}` at {}",
.socket.display()
)]
SessionConflict {
session_id: SessionId,
pod_name: String,
socket: PathBuf,
},
}
#[cfg(test)]
@ -548,6 +666,10 @@ mod tests {
/// harness runs tests on multiple threads inside a single process,
/// so env-var writes from one test would otherwise leak into a
/// parallel test's `default_lock_path()` lookup.
fn sid() -> SessionId {
session_store::new_session_id()
}
static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
/// Sandbox `INSOMNIA_RUNTIME_DIR` to a tempdir for the duration of
@ -652,6 +774,7 @@ mod tests {
std::process::id(),
sock("a"),
vec![write_rule("/src", true)],
sid(),
)
.unwrap();
}
@ -699,6 +822,7 @@ mod tests {
std::process::id(),
sock("a"),
vec![write_rule("/src", true)],
sid(),
)
.unwrap();
let err = register_pod(
@ -707,6 +831,7 @@ mod tests {
std::process::id(),
sock("b"),
vec![write_rule("/src/core", true)],
sid(),
)
.unwrap_err();
match err {
@ -726,6 +851,7 @@ mod tests {
std::process::id(),
sock("a"),
vec![write_rule("/src", true)],
sid(),
)
.unwrap();
let err = register_pod(
@ -734,6 +860,7 @@ mod tests {
std::process::id(),
sock("a2"),
vec![write_rule("/docs", true)],
sid(),
)
.unwrap_err();
assert!(matches!(err, ScopeLockError::DuplicatePodName(ref n) if n == "a"));
@ -750,6 +877,7 @@ mod tests {
std::process::id(),
sock("a"),
vec![write_rule("/src", true)],
sid(),
)
.unwrap();
let err = delegate_scope(
@ -775,6 +903,7 @@ mod tests {
std::process::id(),
sock("a"),
vec![write_rule("/src", true)],
sid(),
)
.unwrap();
delegate_scope(
@ -812,6 +941,7 @@ mod tests {
std::process::id(),
sock("a"),
vec![write_rule("/src", true)],
sid(),
)
.unwrap();
delegate_scope(
@ -848,6 +978,7 @@ mod tests {
std::process::id(),
sock("a"),
vec![write_rule("/src", true)],
sid(),
)
.unwrap();
delegate_scope(
@ -886,6 +1017,7 @@ mod tests {
std::process::id(),
sock("a"),
vec![write_rule("/src", true)],
sid(),
)
.unwrap();
delegate_scope(
@ -939,6 +1071,7 @@ mod tests {
std::process::id(),
sock("a"),
vec![write_rule("/src", true)],
sid(),
)
.unwrap();
// B only reads under the same tree — allowed.
@ -948,6 +1081,7 @@ mod tests {
std::process::id(),
sock("b"),
vec![read_rule("/src", true)],
sid(),
)
.unwrap();
assert_eq!(g.data().allocations.len(), 2);
@ -964,6 +1098,7 @@ mod tests {
std::process::id(),
sock("a"),
vec![write_rule("/src", true)],
sid(),
)
.unwrap();
release_pod(&mut g, "a").unwrap();
@ -973,6 +1108,7 @@ mod tests {
std::process::id(),
sock("b"),
vec![write_rule("/src", true)],
sid(),
)
.unwrap();
}
@ -988,6 +1124,7 @@ mod tests {
std::process::id(),
sock("a"),
vec![write_rule("/src", true)],
sid(),
)
.unwrap();
delegate_scope(
@ -1023,6 +1160,7 @@ mod tests {
std::process::id(),
sock("a"),
vec![write_rule("/src", true)],
sid(),
)
.unwrap();
{
@ -1047,7 +1185,7 @@ mod tests {
delegate_placeholder(&mut g, "child", std::process::id());
}
let child_pid = std::process::id().wrapping_add(1);
let guard = adopt_allocation("child".into(), child_pid).unwrap();
let guard = adopt_allocation("child".into(), child_pid, sid()).unwrap();
{
let g = LockFileGuard::open(&lock_path).unwrap();
let alloc = g.data().find("child").unwrap();
@ -1064,7 +1202,7 @@ mod tests {
fn adopt_allocation_errors_on_unknown_pod() {
let dir = TempDir::new().unwrap();
let _sandbox = RuntimeDirSandbox::new(dir.path());
let err = adopt_allocation("ghost".into(), 42).unwrap_err();
let err = adopt_allocation("ghost".into(), 42, sid()).unwrap_err();
assert!(matches!(err, ScopeLockError::UnknownPod(ref n) if n == "ghost"));
}
@ -1078,6 +1216,7 @@ mod tests {
socket: sock(pod_name),
scope_allow: vec![write_rule("/tmp/child", true)],
delegated_from: None,
session_id: None,
});
g.save().unwrap();
}
@ -1093,6 +1232,7 @@ mod tests {
std::process::id(),
sock("a"),
vec![write_rule("/src", true)],
sid(),
)
.unwrap();
delegate_scope(
@ -1112,6 +1252,7 @@ mod tests {
std::process::id(),
sock("x"),
vec![write_rule("/src/core/x", true)],
sid(),
)
.unwrap_err();
match err {
@ -1119,4 +1260,164 @@ mod tests {
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,
};
pub use llm_worker::UsageRecord;
pub use llm_worker::llm_client::types::{ContentPart, Item, Role};
pub use session_log::{
EntryHash, HashedEntry, LogEntry, RestoredState, SessionOrigin, build_chain, collect_state,
compute_hash,

View File

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

View File

@ -131,6 +131,9 @@ impl App {
}
Event::ThinkingDone { text } => {
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() {
b.text = text;
}
@ -325,6 +328,11 @@ impl App {
}
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() {
match b {
Block::Thinking(t) => {

View File

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

View File

@ -3,6 +3,7 @@ mod block;
mod cache;
mod client;
mod input;
mod picker;
mod scroll;
mod spawn;
mod tool;
@ -24,9 +25,11 @@ use crossterm::terminal::{
use protocol::Method;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use session_store::SessionId;
use crate::app::App;
use crate::client::PodClient;
use crate::picker::PickerOutcome;
use crate::spawn::{SpawnOutcome, SpawnReady};
fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf {
@ -47,27 +50,101 @@ enum Mode {
pod_name: String,
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();
if args.is_empty() {
return Mode::Spawn;
let mut resume = false;
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
.windows(2)
.find(|w| w[0] == "--socket")
.map(|w| PathBuf::from(&w[1]));
Mode::Attach {
pod_name,
socket_override,
if resume && session.is_some() {
return Err(ParseError::Conflict);
}
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]
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() {
eprintln!("tui: failed to enter raw mode: {e}");
@ -80,11 +157,13 @@ async fn main() -> ExitCode {
}
let result = match mode {
Mode::Spawn => run_spawn().await,
Mode::Spawn => run_spawn(None).await,
Mode::Attach {
pod_name,
socket_override,
} => 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
@ -120,8 +199,19 @@ async fn run_attach(
run(&mut terminal, pod_name, &socket_path, false).await
}
async fn run_spawn() -> Result<(), Box<dyn std::error::Error>> {
let ready = match spawn::run().await? {
async fn run_resume() -> Result<(), Box<dyn std::error::Error>> {
// 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::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 manifest::{PodManifestConfig, find_project_manifest_from, load_layer, user_manifest_path};
use ratatui::Terminal;
use session_store::SessionId;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Color, Modifier, Style};
@ -85,7 +86,10 @@ impl From<io::Error> for SpawnError {
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)?;
// 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,
message: None,
editing: true,
resume_from,
};
let mut terminal = make_inline_terminal()?;
@ -266,16 +271,19 @@ async fn wait_for_ready(
let pod_bin = resolve_pod_command();
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_toml)
.current_dir(&cwd)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(SpawnError::PodLaunchFailed)?;
.kill_on_drop(true);
if let Some(id) = form.resume_from {
command.arg("--session").arg(id.to_string());
}
let mut child = command.spawn().map_err(SpawnError::PodLaunchFailed)?;
let stderr = child
.stderr
@ -437,6 +445,11 @@ struct Form {
/// cursor stays out so it does not collide with the shell prompt
/// after the inline terminal is dropped.
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 {
@ -500,8 +513,12 @@ fn draw_form(f: &mut Frame<'_>, form: &Form) {
])
.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(
"spawn pod",
title_text,
Style::default().add_modifier(Modifier::BOLD),
)]));
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<'_> {
Line::from(vec![
Span::raw(" "),
@ -600,6 +624,7 @@ mod tests {
name_cursor: name.chars().count(),
message: None,
editing: true,
resume_from: None,
}
}

View File

@ -188,7 +188,7 @@ scheme 側が吸収する。
| key | 型 | 既定 | 内容 |
|---|---|---|---|
| `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 数 |
| `temperature` | `f32` | 未指定 | sampling temperature |
| `top_p` | `f32` | 未指定 | nucleus sampling |

View File

@ -67,6 +67,21 @@ ref = "gemini/gemini-2.5-pro"
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 値)の変換テーブル

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".
Stay precise, edit code directly when asked, and avoid speculative refactoring.
{% include "common/workspace" %}
{% 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 表示が壊れない