Compare commits
2 Commits
288e2239d4
...
6ebd10a006
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ebd10a006 | |||
| d8d802d120 |
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,3 +2,4 @@
|
||||||
.direnv
|
.direnv
|
||||||
*.local*
|
*.local*
|
||||||
.env
|
.env
|
||||||
|
.worktree
|
||||||
|
|
|
||||||
2
TODO.md
2
TODO.md
|
|
@ -2,8 +2,10 @@
|
||||||
- [ ] 内部 Worker / 内部 Pod の Workflow 化 → [tickets/internal-worker-workflow.md](tickets/internal-worker-workflow.md)
|
- [ ] 内部 Worker / 内部 Pod の Workflow 化 → [tickets/internal-worker-workflow.md](tickets/internal-worker-workflow.md)
|
||||||
- [ ] Agent Skills を Workflow として ingest → [tickets/agent-skills.md](tickets/agent-skills.md)
|
- [ ] Agent Skills を Workflow として ingest → [tickets/agent-skills.md](tickets/agent-skills.md)
|
||||||
- [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md)
|
- [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md)
|
||||||
|
- [ ] Resume 時の Scope claim の改善 → [tickets/resume-scope-claim.md](tickets/resume-scope-claim.md)
|
||||||
- [ ] Pod CLI: マニフェスト関連フラグの整理 → [tickets/pod-cli-manifest-flags.md](tickets/pod-cli-manifest-flags.md)
|
- [ ] Pod CLI: マニフェスト関連フラグの整理 → [tickets/pod-cli-manifest-flags.md](tickets/pod-cli-manifest-flags.md)
|
||||||
- [ ] OpenAI Responses: sampling パラメータの取り扱い → [tickets/responses-sampling-params.md](tickets/responses-sampling-params.md)
|
- [ ] OpenAI Responses: sampling パラメータの取り扱い → [tickets/responses-sampling-params.md](tickets/responses-sampling-params.md)
|
||||||
|
- [ ] OpenAI Responses: prompt_cache_key 送出 → [tickets/responses-prompt-cache-key.md](tickets/responses-prompt-cache-key.md)
|
||||||
- [ ] llm-worker のエラー耐性
|
- [ ] llm-worker のエラー耐性
|
||||||
- [ ] HTTP transient リトライ → [tickets/llm-worker-transient-retry.md](tickets/llm-worker-transient-retry.md)
|
- [ ] HTTP transient リトライ → [tickets/llm-worker-transient-retry.md](tickets/llm-worker-transient-retry.md)
|
||||||
- [ ] ストリーム途中失敗時の継続 → [tickets/llm-worker-stream-continuation.md](tickets/llm-worker-stream-continuation.md)
|
- [ ] ストリーム途中失敗時の継続 → [tickets/llm-worker-stream-continuation.md](tickets/llm-worker-stream-continuation.md)
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,15 @@ struct ResponsesUsage {
|
||||||
output_tokens: Option<u64>,
|
output_tokens: Option<u64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
total_tokens: Option<u64>,
|
total_tokens: Option<u64>,
|
||||||
|
/// `input_tokens` の内訳。`cached_tokens` がプロンプトキャッシュヒット分。
|
||||||
|
#[serde(default)]
|
||||||
|
input_tokens_details: Option<InputTokensDetails>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct InputTokensDetails {
|
||||||
|
#[serde(default)]
|
||||||
|
cached_tokens: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -270,7 +279,10 @@ pub(crate) fn parse_sse(
|
||||||
total_tokens: usage.total_tokens.or_else(|| {
|
total_tokens: usage.total_tokens.or_else(|| {
|
||||||
Some(usage.input_tokens.unwrap_or(0) + usage.output_tokens.unwrap_or(0))
|
Some(usage.input_tokens.unwrap_or(0) + usage.output_tokens.unwrap_or(0))
|
||||||
}),
|
}),
|
||||||
cache_read_input_tokens: None,
|
cache_read_input_tokens: usage
|
||||||
|
.input_tokens_details
|
||||||
|
.and_then(|d| d.cached_tokens),
|
||||||
|
// Responses API は cache 書き込みを別計上しない(input_tokens に含まれる)
|
||||||
cache_creation_input_tokens: None,
|
cache_creation_input_tokens: None,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
@ -554,9 +566,31 @@ mod tests {
|
||||||
assert_eq!(u.input_tokens, Some(10));
|
assert_eq!(u.input_tokens, Some(10));
|
||||||
assert_eq!(u.output_tokens, Some(20));
|
assert_eq!(u.output_tokens, Some(20));
|
||||||
assert_eq!(u.total_tokens, Some(30));
|
assert_eq!(u.total_tokens, Some(30));
|
||||||
|
assert_eq!(u.cache_read_input_tokens, None);
|
||||||
|
assert_eq!(u.cache_creation_input_tokens, None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completed_extracts_cached_tokens_from_input_tokens_details() {
|
||||||
|
let data = r#"{"response":{"usage":{
|
||||||
|
"input_tokens":12345,
|
||||||
|
"input_tokens_details":{"cached_tokens":11000},
|
||||||
|
"output_tokens":50,
|
||||||
|
"total_tokens":12395
|
||||||
|
}}}"#;
|
||||||
|
let (events, _) = run("response.completed", data);
|
||||||
|
let Event::Usage(u) = &events[0] else {
|
||||||
|
panic!("expected usage")
|
||||||
|
};
|
||||||
|
assert_eq!(u.input_tokens, Some(12345));
|
||||||
|
assert_eq!(u.output_tokens, Some(50));
|
||||||
|
assert_eq!(u.total_tokens, Some(12395));
|
||||||
|
assert_eq!(u.cache_read_input_tokens, Some(11000));
|
||||||
|
// OpenAI Responses は cache 書き込みを別計上しない
|
||||||
|
assert_eq!(u.cache_creation_input_tokens, None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn text_stream_start_delta_stop() {
|
fn text_stream_start_delta_stop() {
|
||||||
let mut state = OpenAIResponsesState::default();
|
let mut state = OpenAIResponsesState::default();
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,11 @@ pub(crate) struct ResponsesRequest {
|
||||||
pub temperature: Option<f32>,
|
pub temperature: Option<f32>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub top_p: Option<f32>,
|
pub top_p: Option<f32>,
|
||||||
|
/// 会話単位の安定キー。ChatGPT backend (codex-oauth) は明示キーが
|
||||||
|
/// 無いとプロンプトキャッシュがほぼ効かない。pod 側は `SessionId`
|
||||||
|
/// を渡す。`Request::cache_key` が `None` のときはキー自体を送らない。
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub prompt_cache_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// reasoning 制御。
|
/// reasoning 制御。
|
||||||
|
|
@ -220,6 +225,7 @@ impl OpenAIResponsesScheme {
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
},
|
},
|
||||||
|
prompt_cache_key: request.cache_key.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -531,6 +537,29 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_cache_key_passed_through_when_set() {
|
||||||
|
let scheme = OpenAIResponsesScheme::new();
|
||||||
|
let req = Request::new().user("hi").cache_key("session-abc");
|
||||||
|
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||||
|
assert_eq!(body.prompt_cache_key.as_deref(), Some("session-abc"));
|
||||||
|
let json = serde_json::to_value(&body).unwrap();
|
||||||
|
assert_eq!(json["prompt_cache_key"], "session-abc");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_cache_key_omitted_when_none() {
|
||||||
|
let scheme = OpenAIResponsesScheme::new();
|
||||||
|
let req = Request::new().user("hi");
|
||||||
|
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||||
|
assert!(body.prompt_cache_key.is_none());
|
||||||
|
let json = serde_json::to_value(&body).unwrap();
|
||||||
|
assert!(
|
||||||
|
json.get("prompt_cache_key").is_none(),
|
||||||
|
"prompt_cache_key key must not appear in serialised body, got: {json}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_schema_without_properties_is_normalized() {
|
fn tool_schema_without_properties_is_normalized() {
|
||||||
// schemars は引数なし struct から `type:"object"` だけのスキーマを
|
// schemars は引数なし struct から `type:"object"` だけのスキーマを
|
||||||
|
|
|
||||||
|
|
@ -455,6 +455,14 @@ pub struct Request {
|
||||||
/// (Anthropic today) can place a long-lived cache breakpoint there.
|
/// (Anthropic today) can place a long-lived cache breakpoint there.
|
||||||
/// Providers without prompt caching ignore the field.
|
/// Providers without prompt caching ignore the field.
|
||||||
pub cache_anchor: Option<usize>,
|
pub cache_anchor: Option<usize>,
|
||||||
|
/// 会話単位の安定キー。`prompt_cache_key` として送られる
|
||||||
|
/// (OpenAI Responses)。ChatGPT backend (codex-oauth) は明示キーが
|
||||||
|
/// 無いと org/project ハッシュ衝突でプロンプトキャッシュが
|
||||||
|
/// ほぼヒットしないため、pod 側で `SessionId` を渡す運用を想定。
|
||||||
|
/// `cache_anchor` と違い名前空間キーであり、`prefix anchor` とは
|
||||||
|
/// 別の概念。`cache_anchor` を読まない provider と同じく、
|
||||||
|
/// `prompt_cache_key` を持たない provider は無視する。
|
||||||
|
pub cache_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Request {
|
impl Request {
|
||||||
|
|
@ -534,6 +542,14 @@ impl Request {
|
||||||
self.config.stop_sequences.push(sequence.into());
|
self.config.stop_sequences.push(sequence.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the conversation cache key.
|
||||||
|
///
|
||||||
|
/// 詳細は [`Request::cache_key`] のフィールドコメント参照。
|
||||||
|
pub fn cache_key(mut self, key: impl Into<String>) -> Self {
|
||||||
|
self.cache_key = Some(key.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,10 @@ pub struct Worker<C: LlmClient, S: WorkerState = Mutable> {
|
||||||
/// Index of the last stable cache prefix item, set by higher layers.
|
/// Index of the last stable cache prefix item, set by higher layers.
|
||||||
/// Plumbed into [`Request::cache_anchor`] at request build time.
|
/// Plumbed into [`Request::cache_anchor`] at request build time.
|
||||||
cache_anchor: Option<usize>,
|
cache_anchor: Option<usize>,
|
||||||
|
/// Conversation-scoped cache key, set by higher layers. Plumbed into
|
||||||
|
/// [`Request::cache_key`] at request build time. Pod 側では
|
||||||
|
/// `SessionId` を渡す。
|
||||||
|
cache_key: Option<String>,
|
||||||
/// State marker
|
/// State marker
|
||||||
_state: PhantomData<S>,
|
_state: PhantomData<S>,
|
||||||
}
|
}
|
||||||
|
|
@ -392,6 +396,14 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
|
||||||
self.cache_anchor = anchor;
|
self.cache_anchor = anchor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the conversation-scoped cache key. Plumbed into each outgoing
|
||||||
|
/// [`Request`] via [`Request::cache_key`] — caching-aware providers
|
||||||
|
/// that scope cache by an explicit key (OpenAI Responses) read it as
|
||||||
|
/// `prompt_cache_key`. Pass `None` to clear.
|
||||||
|
pub fn set_cache_key(&mut self, key: Option<String>) {
|
||||||
|
self.cache_key = key;
|
||||||
|
}
|
||||||
|
|
||||||
/// Get a mutable reference to the timeline (for additional handler registration)
|
/// Get a mutable reference to the timeline (for additional handler registration)
|
||||||
pub fn timeline_mut(&mut self) -> &mut Timeline {
|
pub fn timeline_mut(&mut self) -> &mut Timeline {
|
||||||
&mut self.timeline
|
&mut self.timeline
|
||||||
|
|
@ -585,6 +597,7 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
|
||||||
// if the prune projection trimmed items from the head — keep it
|
// if the prune projection trimmed items from the head — keep it
|
||||||
// in range).
|
// in range).
|
||||||
request.cache_anchor = self.cache_anchor.filter(|&anchor| anchor < context.len());
|
request.cache_anchor = self.cache_anchor.filter(|&anchor| anchor < context.len());
|
||||||
|
request.cache_key = self.cache_key.clone();
|
||||||
|
|
||||||
request
|
request
|
||||||
}
|
}
|
||||||
|
|
@ -1065,6 +1078,7 @@ impl<C: LlmClient> Worker<C, Mutable> {
|
||||||
prune_config: None,
|
prune_config: None,
|
||||||
savings_estimator: None,
|
savings_estimator: None,
|
||||||
cache_anchor: None,
|
cache_anchor: None,
|
||||||
|
cache_key: None,
|
||||||
_state: PhantomData,
|
_state: PhantomData,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1321,6 +1335,7 @@ impl<C: LlmClient> Worker<C, Mutable> {
|
||||||
prune_config: self.prune_config,
|
prune_config: self.prune_config,
|
||||||
savings_estimator: self.savings_estimator,
|
savings_estimator: self.savings_estimator,
|
||||||
cache_anchor: self.cache_anchor,
|
cache_anchor: self.cache_anchor,
|
||||||
|
cache_key: self.cache_key,
|
||||||
_state: PhantomData,
|
_state: PhantomData,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1400,6 +1415,7 @@ impl<C: LlmClient> Worker<C, Locked> {
|
||||||
prune_config: self.prune_config,
|
prune_config: self.prune_config,
|
||||||
savings_estimator: self.savings_estimator,
|
savings_estimator: self.savings_estimator,
|
||||||
cache_anchor: self.cache_anchor,
|
cache_anchor: self.cache_anchor,
|
||||||
|
cache_key: self.cache_key,
|
||||||
_state: PhantomData,
|
_state: PhantomData,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1195,6 +1195,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
.compact_system()
|
.compact_system()
|
||||||
.map_err(PodError::PromptCatalog)?;
|
.map_err(PodError::PromptCatalog)?;
|
||||||
let mut summary_worker = Worker::new(summary_client).system_prompt(summary_system_prompt);
|
let mut summary_worker = Worker::new(summary_client).system_prompt(summary_system_prompt);
|
||||||
|
summary_worker.set_cache_key(Some(self.session_id.to_string()));
|
||||||
|
|
||||||
// Cumulative input-token meter + interceptor. The meter is bumped
|
// Cumulative input-token meter + interceptor. The meter is bumped
|
||||||
// from the on_usage callback and read on every pre_llm_request.
|
// from the on_usage callback and read on every pre_llm_request.
|
||||||
|
|
@ -1356,6 +1357,10 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
// can place a durable `cache_control` breakpoint there — our
|
// can place a durable `cache_control` breakpoint there — our
|
||||||
// compact layout guarantees history[0] is the summary.
|
// compact layout guarantees history[0] is the summary.
|
||||||
worker.set_cache_anchor(Some(0));
|
worker.set_cache_anchor(Some(0));
|
||||||
|
// Re-key the OpenAI Responses prompt cache namespace to the new
|
||||||
|
// session_id so post-compact turns share a key with extract /
|
||||||
|
// consolidate workers running in the same session.
|
||||||
|
worker.set_cache_key(Some(new_session_id.to_string()));
|
||||||
self.usage_history
|
self.usage_history
|
||||||
.lock()
|
.lock()
|
||||||
.expect("usage_history poisoned")
|
.expect("usage_history poisoned")
|
||||||
|
|
@ -1545,6 +1550,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
|
|
||||||
let client = self.build_extractor_client(memory_cfg)?;
|
let client = self.build_extractor_client(memory_cfg)?;
|
||||||
let mut extract_worker = Worker::new(client).system_prompt(extract::EXTRACT_SYSTEM_PROMPT);
|
let mut extract_worker = Worker::new(client).system_prompt(extract::EXTRACT_SYSTEM_PROMPT);
|
||||||
|
extract_worker.set_cache_key(Some(self.session_id.to_string()));
|
||||||
|
|
||||||
// Cumulative input-token meter + interceptor (mirror of
|
// Cumulative input-token meter + interceptor (mirror of
|
||||||
// CompactWorkerInterceptor). Aborts the extract worker if its
|
// CompactWorkerInterceptor). Aborts the extract worker if its
|
||||||
|
|
@ -1741,6 +1747,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
};
|
};
|
||||||
let mut worker =
|
let mut worker =
|
||||||
Worker::new(client).system_prompt(consolidate::CONSOLIDATION_SYSTEM_PROMPT);
|
Worker::new(client).system_prompt(consolidate::CONSOLIDATION_SYSTEM_PROMPT);
|
||||||
|
worker.set_cache_key(Some(self.session_id.to_string()));
|
||||||
|
|
||||||
let input_so_far = Arc::new(std::sync::atomic::AtomicU64::new(0));
|
let input_so_far = Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||||
{
|
{
|
||||||
|
|
@ -1905,6 +1912,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||||
|
|
||||||
let mut worker = Worker::new(common.client);
|
let mut worker = Worker::new(common.client);
|
||||||
apply_worker_manifest(&mut worker, &manifest.worker);
|
apply_worker_manifest(&mut worker, &manifest.worker);
|
||||||
|
worker.set_cache_key(Some(session_id.to_string()));
|
||||||
|
|
||||||
let mut pod = Self {
|
let mut pod = Self {
|
||||||
manifest,
|
manifest,
|
||||||
|
|
@ -1965,6 +1973,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||||
|
|
||||||
let mut worker = Worker::new(common.client);
|
let mut worker = Worker::new(common.client);
|
||||||
apply_worker_manifest(&mut worker, &manifest.worker);
|
apply_worker_manifest(&mut worker, &manifest.worker);
|
||||||
|
worker.set_cache_key(Some(session_id.to_string()));
|
||||||
|
|
||||||
let mut pod = Self {
|
let mut pod = Self {
|
||||||
manifest,
|
manifest,
|
||||||
|
|
@ -2052,6 +2061,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||||
// overwrite the pieces the session log is authoritative for.
|
// overwrite the pieces the session log is authoritative for.
|
||||||
let mut worker = Worker::new(common.client);
|
let mut worker = Worker::new(common.client);
|
||||||
apply_worker_manifest(&mut worker, &manifest.worker);
|
apply_worker_manifest(&mut worker, &manifest.worker);
|
||||||
|
worker.set_cache_key(Some(session_id.to_string()));
|
||||||
if let Some(ref prompt) = state.system_prompt {
|
if let Some(ref prompt) = state.system_prompt {
|
||||||
worker.set_system_prompt(prompt);
|
worker.set_system_prompt(prompt);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -268,12 +268,11 @@ async fn wait_for_ready(
|
||||||
form: &mut Form,
|
form: &mut Form,
|
||||||
overlay_toml: &str,
|
overlay_toml: &str,
|
||||||
) -> Result<SpawnReady, SpawnError> {
|
) -> Result<SpawnReady, SpawnError> {
|
||||||
let (pod_bin, pod_args) = resolve_pod_command();
|
let pod_bin = resolve_pod_command();
|
||||||
let cwd = std::env::current_dir().map_err(SpawnError::Io)?;
|
let cwd = std::env::current_dir().map_err(SpawnError::Io)?;
|
||||||
|
|
||||||
let mut command = Command::new(&pod_bin);
|
let mut command = Command::new(&pod_bin);
|
||||||
command
|
command
|
||||||
.args(&pod_args)
|
|
||||||
.arg("--overlay")
|
.arg("--overlay")
|
||||||
.arg(overlay_toml)
|
.arg(overlay_toml)
|
||||||
.current_dir(&cwd)
|
.current_dir(&cwd)
|
||||||
|
|
@ -375,28 +374,21 @@ fn build_overlay_toml(form: &Form) -> String {
|
||||||
toml::to_string(&toml::Value::Table(root)).expect("overlay serialisation cannot fail")
|
toml::to_string(&toml::Value::Table(root)).expect("overlay serialisation cannot fail")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves the program (and any leading args) used to launch a child Pod.
|
/// Resolves the binary used to launch a child Pod. Must point at a
|
||||||
|
/// `pod`-compatible executable — the parent reads the child's stderr
|
||||||
|
/// directly looking for `INSOMNIA-READY`, so any wrapper that emits
|
||||||
|
/// extra lines on stderr will pollute that handshake.
|
||||||
///
|
///
|
||||||
/// `INSOMNIA_POD_COMMAND` is split on whitespace so devshells can point it
|
/// `INSOMNIA_POD_COMMAND` overrides the lookup (used by tests to inject
|
||||||
/// at e.g. `cargo run -p pod --quiet --`; the first token is the program
|
/// a mock binary). Otherwise we defer to `PATH` — missing binary
|
||||||
/// and the rest are prepended before `--overlay` and friends.
|
/// surfaces as the spawn `io::Error`.
|
||||||
fn resolve_pod_command() -> (PathBuf, Vec<String>) {
|
fn resolve_pod_command() -> PathBuf {
|
||||||
if let Ok(cmd) = std::env::var("INSOMNIA_POD_COMMAND") {
|
if let Ok(cmd) = std::env::var("INSOMNIA_POD_COMMAND") {
|
||||||
let mut tokens = cmd.split_whitespace();
|
if !cmd.is_empty() {
|
||||||
if let Some(program) = tokens.next() {
|
return PathBuf::from(cmd);
|
||||||
let args = tokens.map(str::to_owned).collect();
|
|
||||||
return (PathBuf::from(program), args);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Ok(exe) = std::env::current_exe() {
|
PathBuf::from("pod")
|
||||||
if let Some(dir) = exe.parent() {
|
|
||||||
let candidate = dir.join("pod");
|
|
||||||
if candidate.is_file() {
|
|
||||||
return (candidate, Vec::new());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(PathBuf::from("pod"), Vec::new())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct StderrTail {
|
struct StderrTail {
|
||||||
|
|
|
||||||
21
devshell.nix
21
devshell.nix
|
|
@ -1,4 +1,23 @@
|
||||||
{ pkgs }:
|
{ pkgs }:
|
||||||
|
let
|
||||||
|
# Dev-only wrapper. tui の spawn 経路は `pod` バイナリを直に exec し、
|
||||||
|
# stderr の `INSOMNIA-READY` 行で握手するので、cargo の進捗や rustc の
|
||||||
|
# warning が混ざると tail に余計な行が積もり本当のエラーが押し出される。
|
||||||
|
# ここで一度ビルドを切り離し、成功時はビルド出力を一切捨てて素のバイナリ
|
||||||
|
# を exec、失敗時のみ build log を stderr に流して exit する。
|
||||||
|
pod-dev = pkgs.writeShellScriptBin "pod" ''
|
||||||
|
set -u
|
||||||
|
buildlog=$(mktemp)
|
||||||
|
trap 'rm -f "$buildlog"' EXIT
|
||||||
|
if ! cargo build --quiet -p pod 2>"$buildlog"; then
|
||||||
|
cat "$buildlog" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
manifest=$(cargo locate-project --workspace --message-format plain 2>/dev/null)
|
||||||
|
target_dir=''${CARGO_TARGET_DIR:-$(dirname "$manifest")/target}
|
||||||
|
exec "$target_dir/debug/pod" "$@"
|
||||||
|
'';
|
||||||
|
in
|
||||||
pkgs.mkShell {
|
pkgs.mkShell {
|
||||||
packages = with pkgs; [
|
packages = with pkgs; [
|
||||||
nixfmt
|
nixfmt
|
||||||
|
|
@ -6,12 +25,12 @@ pkgs.mkShell {
|
||||||
git
|
git
|
||||||
rustc
|
rustc
|
||||||
cargo
|
cargo
|
||||||
|
pod-dev
|
||||||
];
|
];
|
||||||
buildInputs = with pkgs; [
|
buildInputs = with pkgs; [
|
||||||
pkg-config
|
pkg-config
|
||||||
openssl
|
openssl
|
||||||
];
|
];
|
||||||
INSOMNIA_POD_COMMAND = "cargo run -p pod --quiet --";
|
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
echo "dev-shell-loaded"
|
echo "dev-shell-loaded"
|
||||||
'';
|
'';
|
||||||
|
|
|
||||||
91
docs/research/openai_responses_prompt_cache_key.md
Normal file
91
docs/research/openai_responses_prompt_cache_key.md
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
# OpenAI Responses API — `prompt_cache_key` Parameter
|
||||||
|
|
||||||
|
- **Source**: https://platform.openai.com/docs/api-reference/responses/create
|
||||||
|
- **Retrieved**: 2026-05-02
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. パラメータ名
|
||||||
|
|
||||||
|
`prompt_cache_key` — Responses API のリクエスト body に載せる任意の文字列キー。
|
||||||
|
プロンプトキャッシュのスコープを明示する。
|
||||||
|
|
||||||
|
## 2. 必須 / 任意
|
||||||
|
|
||||||
|
**任意 (optional)**。
|
||||||
|
|
||||||
|
公式 OpenAI Responses API では省略しても automatic prefix matching が
|
||||||
|
走るためキャッシュは効く。一方 ChatGPT backend (codex-oauth /
|
||||||
|
`https://chatgpt.com/backend-api/codex/responses`) では **明示キーが
|
||||||
|
無いと事実上ヒットしない**(後述)。
|
||||||
|
|
||||||
|
## 3. ChatGPT backend (`codex-oauth`) でのキャッシュ挙動
|
||||||
|
|
||||||
|
実観測(`019de419-...` セッション、171 turn / 累計入力 22.2M token)で
|
||||||
|
`cache_read_tokens` が全 turn 0 だった。最新セッション (`019de48f-...`,
|
||||||
|
5 turn) でも 0。
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
1. ChatGPT backend のプロンプトキャッシュは org / project 単位で
|
||||||
|
ハッシュ衝突する設計
|
||||||
|
2. `prompt_cache_key` を送らないと、複数 conversation のリクエストが
|
||||||
|
同じハッシュ空間に積み上がり、prefix が他 conversation で
|
||||||
|
上書きされてヒット率が落ちる
|
||||||
|
3. Codex CLI の実装はこれを認識しており、conversation_id を毎リクエスト
|
||||||
|
送って自分専用の名前空間にキャッシュさせている:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// codex-rs/core/src/client.rs:853
|
||||||
|
let prompt_cache_key = Some(self.client.state.conversation_id.to_string());
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 公式 OpenAI API での挙動
|
||||||
|
|
||||||
|
公式エンドポイント (`https://api.openai.com/v1/responses`) では、
|
||||||
|
明示キーが無くても automatic prefix matching が走る。明示キーを
|
||||||
|
送ることで、複数 client / 複数 organization が同じ prefix を共有する
|
||||||
|
シナリオ(マルチテナント等)で意図しないヒット混線を避ける用途
|
||||||
|
で使う。少なくとも害は無いので両 backend で同じ値を送って良い。
|
||||||
|
|
||||||
|
## 5. insomnia での運用
|
||||||
|
|
||||||
|
- `Request::cache_key: Option<String>` を provider-agnostic な
|
||||||
|
キャッシュヒントとして持つ。`cache_anchor` (Anthropic 用 prefix
|
||||||
|
index) と並立する別概念。
|
||||||
|
- `OpenAIResponsesScheme::build_request` で
|
||||||
|
`request.cache_key.clone()` を `prompt_cache_key` に投影。
|
||||||
|
`None` のときは body にキー自体が載らない
|
||||||
|
(`#[serde(skip_serializing_if = "Option::is_none")]`)。
|
||||||
|
- pod 側は LLM 呼び出し時に `SessionId.to_string()` を渡す。
|
||||||
|
主 Run / compactor / extract / consolidate worker のすべてが
|
||||||
|
同じ `session_id` を使うので、pod 内の派生 worker が prefix を
|
||||||
|
共有しているところでヒットが期待できる。
|
||||||
|
- 他 scheme (`anthropic`, `gemini`, `openai_chat`) は
|
||||||
|
`Request::cache_key` を未参照のまま無視する。
|
||||||
|
|
||||||
|
## 6. Fork との関係
|
||||||
|
|
||||||
|
`session-store::fork` / `fork_at` はいずれも新 `SessionId` を発行する。
|
||||||
|
新 fork = 新 cache_key とする(素直に `SessionId.to_string()` を渡す)。
|
||||||
|
|
||||||
|
fork 直後の cache 明示ヒットは失われるが、OpenAI Responses は
|
||||||
|
automatic prefix matching も走るため完全に冷えるわけではない。
|
||||||
|
fork 越しに親の cache_key を継承して明示ヒットも残す最適化は
|
||||||
|
別チケット扱い。
|
||||||
|
|
||||||
|
## 7. Compaction との関係
|
||||||
|
|
||||||
|
compaction は session_id を入れ替える (`create_compacted_session`)。
|
||||||
|
compact 直後に worker の `cache_key` も新 session_id で更新するため、
|
||||||
|
post-compact turn は extract / consolidate worker と同じ namespace で
|
||||||
|
動く。compact 自体は prefix を大幅に書き換えるので、明示キー継続の
|
||||||
|
有無に関わらずヒット率は元から低い。
|
||||||
|
|
||||||
|
## 8. ドキュメント URL
|
||||||
|
|
||||||
|
- 公式 API リファレンス: https://platform.openai.com/docs/api-reference/responses/create
|
||||||
|
- Codex CLI 実装 (conversation_id を prompt_cache_key に渡す):
|
||||||
|
https://github.com/openai/codex/blob/main/codex-rs/core/src/client.rs
|
||||||
|
- ChatGPT backend のサポートパラメータ (LiteLLM issue):
|
||||||
|
https://github.com/BerriAI/litellm/issues/21193
|
||||||
118
tickets/responses-prompt-cache-key.md
Normal file
118
tickets/responses-prompt-cache-key.md
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
# OpenAI Responses: prompt_cache_key 送出によるキャッシュ有効化
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
codex-oauth 経由 (ChatGPT backend) の OpenAI Responses API において、
|
||||||
|
プロンプトキャッシュが事実上効いていない。実セッションログ
|
||||||
|
(`019de419-...`, 171 turn / 累計入力 22.2M token) で `cache_read_tokens`
|
||||||
|
が全 turn 0、最新セッション (`019de48f-...`, 5 turn) でも 0。
|
||||||
|
|
||||||
|
直近のパース修正 (`events.rs` の `ResponsesUsage` に
|
||||||
|
`input_tokens_details.cached_tokens` を追加) で計測経路は復旧したが、
|
||||||
|
それでも 0 が観測される → **server 側でそもそもキャッシュが効いていない**。
|
||||||
|
|
||||||
|
原因は codex-rs の実装で確定:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// codex-rs/core/src/client.rs:853
|
||||||
|
let prompt_cache_key = Some(self.client.state.conversation_id.to_string());
|
||||||
|
```
|
||||||
|
|
||||||
|
ChatGPT backend では `prompt_cache_key` をリクエストに含めないと
|
||||||
|
プロンプトキャッシュが期待通りに動かない (org/project ハッシュが
|
||||||
|
別 conversation と衝突しやすく、ヒット率が著しく落ちる)。Codex は
|
||||||
|
conversation 単位の安定キーを毎リクエスト付けて、その名前空間内で
|
||||||
|
prefix をキャッシュさせている。
|
||||||
|
|
||||||
|
insomnia 側の `ResponsesRequest` には `prompt_cache_key` フィールドが
|
||||||
|
存在せず、`Request` 構造体にも会話/セッション単位の安定キー概念が無い。
|
||||||
|
このため codex-oauth で長尺の Run を走らせると毎 turn 全 prefix を
|
||||||
|
従量課金している。
|
||||||
|
|
||||||
|
## 方針
|
||||||
|
|
||||||
|
`Request` に provider-agnostic な `cache_key: Option<String>` を
|
||||||
|
足し、`OpenAIResponsesScheme` がそれを `prompt_cache_key` として
|
||||||
|
送る。pod 側は LLM 呼び出し時に `SessionId` をキーとして渡す。
|
||||||
|
他 scheme (Anthropic / Gemini / OpenAI Chat / Ollama) はフィールドを
|
||||||
|
無視する。既存の `cache_anchor` (Anthropic 用 prefix anchor) と
|
||||||
|
同じ「キャッシュヒントを Request に載せ、効く provider だけ拾う」
|
||||||
|
規約に揃える。
|
||||||
|
|
||||||
|
### Fork との関係
|
||||||
|
|
||||||
|
`session-store::fork` / `fork_at` はいずれも新 `SessionId` を発行する。
|
||||||
|
本チケットでは **新 fork = 新 cache_key** とする (素直に
|
||||||
|
`SessionId.to_string()` を渡す)。fork 直後の cache 明示ヒットは失われる
|
||||||
|
が、OpenAI Responses は automatic prefix matching も走るため完全に
|
||||||
|
冷えるわけではない。fork 越しに親の cache_key を継承して明示ヒットも
|
||||||
|
残す最適化は別チケットで検討する (本ticketの範囲外)。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
### llm-worker 側
|
||||||
|
|
||||||
|
- `Request` に `cache_key: Option<String>` を追加 (`types.rs:442`
|
||||||
|
の `cache_anchor` の隣)。doc コメントで「会話単位の安定キー。
|
||||||
|
prompt_cache_key として送られる (OpenAI Responses)。
|
||||||
|
prefix anchor を持たない provider は無視」を明記
|
||||||
|
- ビルダ `Request::cache_key(impl Into<String>)` を追加
|
||||||
|
- `OpenAIResponsesScheme::build_request` で `request.cache_key.clone()`
|
||||||
|
を `ResponsesRequest::prompt_cache_key` にセット
|
||||||
|
- `ResponsesRequest` に `prompt_cache_key: Option<String>` を追加
|
||||||
|
(`#[serde(skip_serializing_if = "Option::is_none")]`)
|
||||||
|
- 他 scheme (`anthropic`, `gemini`, `openai_chat`) は touch しない
|
||||||
|
(Request の新フィールドを未参照のまま残す)
|
||||||
|
|
||||||
|
### pod 側
|
||||||
|
|
||||||
|
- LLM クライアントに渡す `Request` を組み立てる箇所で
|
||||||
|
`cache_key(session_id.to_string())` を入れる。少なくとも以下:
|
||||||
|
- 主 Run の LLM 呼び出し (`pod.rs` の Run / Worker 経路)
|
||||||
|
- compactor worker
|
||||||
|
- memory extract worker
|
||||||
|
- `SessionId` は `SharedState::session_id` から取得できる
|
||||||
|
(`shared_state.rs:21`)
|
||||||
|
- compactor / extract のように pod の中で派生する worker でも
|
||||||
|
同じ `session_id` を使う。これにより pod 内のすべての LLM
|
||||||
|
呼び出しが同一 cache_key 名前空間で動き、prefix が共有される
|
||||||
|
ところでヒットが期待できる
|
||||||
|
|
||||||
|
### docs
|
||||||
|
|
||||||
|
- `docs/research/` 配下に `openai_responses_prompt_cache_key.md`
|
||||||
|
(仮) を追加し、「ChatGPT backend では prompt_cache_key 必須」
|
||||||
|
「codex-rs の挙動」「insomnia での Fork 方針」を残す。
|
||||||
|
既存の `openai_responses_max_output_tokens.md` と並びで置く
|
||||||
|
|
||||||
|
## 完了条件
|
||||||
|
|
||||||
|
- `Request::cache_key("abc")` で組んだリクエストが、
|
||||||
|
`OpenAIResponsesScheme::build_request` で
|
||||||
|
`prompt_cache_key: "abc"` を含む body を生成する (unit test)
|
||||||
|
- `cache_key = None` のときは body に `prompt_cache_key` キーが
|
||||||
|
載らない (`skip_serializing_if`) (unit test)
|
||||||
|
- pod の Run で codex-oauth + Responses を使ったとき、2 turn 目
|
||||||
|
以降の `cache_read_tokens` が 0 でない (実セッションログで確認)
|
||||||
|
- `cargo check` / `cargo test` が `llm-worker`, `provider`, `pod`
|
||||||
|
で通る
|
||||||
|
|
||||||
|
## 範囲外
|
||||||
|
|
||||||
|
- Fork 越しのキャッシュ継承 (`forked_from` を辿って root の
|
||||||
|
cache_key を継承する最適化)。別チケット
|
||||||
|
- 公式 OpenAI Responses API (非 ChatGPT backend) での
|
||||||
|
`prompt_cache_key` 必要性検証。少なくとも害は無いので両経路で
|
||||||
|
同じ値を送って良い
|
||||||
|
- compaction で prefix が大きく書き換わる経路の cache_key 戦略
|
||||||
|
(compaction 後は prefix がほぼ別物なので、ヒット率を最大化する
|
||||||
|
なら compaction 直後だけ別 key にする手もあるが、まずは単純に
|
||||||
|
session_id 一本で動かす)
|
||||||
|
- `cache_anchor` (Anthropic 用) と `cache_key` (Responses 用) の
|
||||||
|
統合。両者は別概念 (前者は prefix の境界 index、後者は
|
||||||
|
名前空間キー) なので並立させる
|
||||||
|
|
||||||
|
## Review
|
||||||
|
- 状態: Approve
|
||||||
|
- レビュー詳細: [./responses-prompt-cache-key.review.md](./responses-prompt-cache-key.review.md)
|
||||||
|
- 日付: 2026-05-02
|
||||||
63
tickets/responses-prompt-cache-key.review.md
Normal file
63
tickets/responses-prompt-cache-key.review.md
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
# Review: OpenAI Responses prompt_cache_key 送出によるキャッシュ有効化
|
||||||
|
|
||||||
|
## 前提・要件の確認
|
||||||
|
|
||||||
|
### llm-worker 側
|
||||||
|
- `Request` に `cache_key: Option<String>` を追加し doc を整備:
|
||||||
|
`crates/llm-worker/src/llm_client/types.rs:458-465`。
|
||||||
|
`cache_anchor` の直下、要件通りの位置。doc にも「会話単位の安定キー」「`prompt_cache_key` として送られる」「持たない provider は無視」が明記されている。
|
||||||
|
- `Request::cache_key(impl Into<String>)` builder:
|
||||||
|
`crates/llm-worker/src/llm_client/types.rs:546-552`。要件通り。
|
||||||
|
- `OpenAIResponsesScheme::build_request` で `request.cache_key.clone()` を `ResponsesRequest::prompt_cache_key` に投影:
|
||||||
|
`crates/llm-worker/src/llm_client/scheme/openai_responses/request.rs:228`。
|
||||||
|
- `ResponsesRequest::prompt_cache_key: Option<String>` を `#[serde(skip_serializing_if = "Option::is_none")]` 付きで追加:
|
||||||
|
同 `request.rs:51-57`。
|
||||||
|
- 他 scheme (`anthropic`, `gemini`, `openai_chat`) は touch されておらず、`Request::cache_key` を未参照のまま無視している(grep 確認済)。要件通り。
|
||||||
|
|
||||||
|
### pod 側
|
||||||
|
- 主 Worker の構築 3 経路すべてで `set_cache_key(Some(session_id.to_string()))` を実施:
|
||||||
|
- `from_manifest`: `crates/pod/src/pod.rs:1915`
|
||||||
|
- `from_manifest_spawned`: 同 `:1976`
|
||||||
|
- `restore_from_manifest`: 同 `:2064`
|
||||||
|
- compactor (`summary_worker`) と memory extract (`extract_worker`) も session_id でキー付け済み:
|
||||||
|
`pod.rs:1198`, `pod.rs:1553`。要件で明示された 3 経路(主 Run / compactor / extract)すべてカバーされている。
|
||||||
|
- `Worker::set_cache_key` の追加と、`build_request` 時の `request.cache_key = self.cache_key.clone()` 投影、`lock()`/`unlock()` 越しの引き継ぎ:
|
||||||
|
`crates/llm-worker/src/worker.rs:193, 399-405, 600, 1338, 1418`。状態遷移で落ちないことが確認できる。
|
||||||
|
|
||||||
|
### docs
|
||||||
|
- `docs/research/openai_responses_prompt_cache_key.md` を新規作成。
|
||||||
|
「ChatGPT backend では prompt_cache_key 必須」「codex-rs の挙動」「insomnia での Fork / Compaction 方針」「公式 API での挙動」「URL」がカバーされている。`openai_responses_max_output_tokens.md` と同じ並び。要件通り。
|
||||||
|
|
||||||
|
### 完了条件
|
||||||
|
- ユニットテスト 2 件:
|
||||||
|
`prompt_cache_key_passed_through_when_set` / `prompt_cache_key_omitted_when_none`
|
||||||
|
(`crates/llm-worker/src/llm_client/scheme/openai_responses/request.rs:540-560`)。
|
||||||
|
body にキーが乗る/省略される、両方を JSON 値で確認している。完了条件を満たす。
|
||||||
|
- `cargo check --workspace` 通過確認済(手元再実行で確認)。
|
||||||
|
- `cargo test -p llm-worker --lib` 121 件パス、`-p provider --lib` 41 件、`-p pod --lib` 133 件パスを確認。
|
||||||
|
- 「pod の Run で codex-oauth + Responses を使ったとき、2 turn 目以降の `cache_read_tokens` が 0 でない」については実セッションログ要観測(実装上は events.rs のパース修正で `cache_read_input_tokens` を埋める経路が出来ており、`prompt_cache_key` も乗ることがテストで確認できているので、計測経路としては揃っている)。これはコード単体では検証できない要件で、実走行に委ねるのが妥当。
|
||||||
|
|
||||||
|
## アーキテクチャ・スコープ
|
||||||
|
|
||||||
|
- llm-worker は provider-agnostic な `cache_key` を持ち、scheme ごとの解釈は scheme 配下で完結。`cache_anchor` (Anthropic 用 prefix index) と並立して別概念として扱う方針が doc とコードの両方で明確。低レベル基盤を歪めず、`cache_anchor` の規約パターンに素直に乗っている。
|
||||||
|
- 他 scheme (`anthropic`, `gemini`, `openai_chat`) はフィールドを未参照のまま残しており、不要な実装拡散がない。
|
||||||
|
- 範囲外項目への踏み込み確認:
|
||||||
|
- **memory consolidate worker への適用** (`pod.rs:1750`): ticket の要件節で明示されているのは Run/compactor/extract の 3 件だが、同節末尾に「compactor / extract のように pod の中で派生する worker でも 同じ session_id を使う。これにより pod 内のすべての LLM 呼び出しが同一 cache_key 名前空間で動き、prefix が共有されるところでヒットが期待できる」というポリシーが明記されている。consolidate も pod 内派生 worker なので、明示列挙されていなくとも policy の対象に当然含まれる解釈で妥当。漏れて consolidate だけ別 namespace になる方が不自然。
|
||||||
|
- **compact 後の re-key** (`pod.rs:1363`): compact 中に `self.session_id = new_session_id` (1333) で session_id 自体が入れ替わる。worker 側のキャッシュキーを古い session_id のまま放置すると、post-compact turn と extract/consolidate (これらは `self.session_id` = 新) で namespace が分裂する。範囲外の「compaction の cache_key 戦略」は「明示キーを別系統に切り替える等の最適化」を指しており、ここは「session_id 一本で動かす」という ticket 末尾の方針 (110行) を素直に維持しているだけ。むしろ re-key しない方がポリシー違反になる。
|
||||||
|
- Worker の構築・状態遷移箇所すべてで `cache_key` をハンドリング (`Worker::new` 初期化、`lock`/`unlock` 引き継ぎ) しており、後段で見落としによる空キー問題が起きない。
|
||||||
|
- `events.rs` の `input_tokens_details.cached_tokens` 取り込みは ticket 本文では「直近のパース修正で復旧した」前提として記述されているが、実際には未コミットだった分が今回まとめて入っている。これは本 ticket 完了の前提として必要な計測経路であり (= cache 効果が実環境で確認可能になる)、ticket の精神を満たすために必要。範囲外項目ではない。
|
||||||
|
|
||||||
|
## 指摘事項
|
||||||
|
|
||||||
|
### Blocking
|
||||||
|
なし。
|
||||||
|
|
||||||
|
### Non-blocking / Follow-up
|
||||||
|
- 完了条件の最後(codex-oauth で実走行して `cache_read_tokens` が 0 でないことを実セッションログで確認)は実装変更だけでは取り込めない。ticket クローズ前に 1 セッション流して `cache_read_input_tokens` がログに出ることを確認してほしい。
|
||||||
|
- ticket 本文では `cache_anchor` の隣としていたが、`Request::cache_anchor` フィールドの doc コメントが英語、`cache_key` は日本語になっている。プロジェクト方針として混在は許容されているように見えるが、両者揃える価値はある。優先度は低い。
|
||||||
|
|
||||||
|
### Nits
|
||||||
|
- `docs/research/openai_responses_prompt_cache_key.md:79-83`「Compaction との関係」セクションは ticket 範囲外項目を補足する形になっているが、現時点の実装方針 (post-compact で new session_id に re-key) と完全整合しているので有用。残してよい。
|
||||||
|
|
||||||
|
## 判断
|
||||||
|
**Approve** — ticket の前提・要件・完了条件はコード上満たされており、ticket 範囲外と明記された fork 越し継承や compaction 戦略への踏み込みも無い。consolidate worker への展開と post-compact re-key は ticket の policy 文 (「pod 内のすべての LLM 呼び出しが同一 cache_key 名前空間で動き」「session_id 一本で動かす」) に沿った最小拡張で、コードベースを歪めていない。残るは実セッションでの `cache_read_tokens > 0` 観測のみで、これは実走確認に委ねるのが妥当。
|
||||||
40
tickets/resume-scope-claim.md
Normal file
40
tickets/resume-scope-claim.md
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# Resume 時の Scope Claim の改善
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
`tickets/dynamic-scope.md` で in-process Scope の縮小(SpawnPod による委譲時の Write revoke)と pod-registry 上の delegation 記録が揃った。これにより「セッション中に scope が縮む」状態を Pod / registry の双方が一貫して表現できる。
|
||||||
|
|
||||||
|
一方で `tui -r` 経由の resume は、`crates/tui/src/spawn.rs` の `build_overlay_toml` を通じて fresh spawn と同じロジックで overlay を合成する。manifest cascade に scope 宣言が無い場合、cwd 直下に `write` 再帰の rule を毎回付ける挙動。
|
||||||
|
|
||||||
|
このため次のような衝突が起きる:
|
||||||
|
|
||||||
|
- セッション S が稼働中に SpawnPod で子 C を作り、cwd 配下のサブパスを委譲した
|
||||||
|
- 親が exit、子 C は registry 上にエントリが残存(あるいはまだ稼働中)
|
||||||
|
- ユーザーが S を resume しようとすると、新しい Pod が cwd 全体に `write` を claim → 委譲された部分と overlap して registry が拒否
|
||||||
|
|
||||||
|
resume の意図は「過去のセッションの続きを取る」であって「過去の effective scope より広い範囲を新たに掴み直す」ではない。現状は後者になっており、過去に手放した scope を resume が勝手に取り戻そうとする形になっている。
|
||||||
|
|
||||||
|
## ゴール
|
||||||
|
|
||||||
|
セッション resume 時に claim する scope が、当該セッションが最後に持っていた effective scope に揃う。委譲済み・他 Pod が保持中の部分は claim 対象から外れ、resume された Pod は当時と同じ範囲だけで動作する。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
- resume 時の overlay 合成は cwd 盲信ではなく、当該セッションが過去に持っていた scope を反映する。情報源は session log / registry / その他のいずれでも良いが、何らかの永続情報から復元できること
|
||||||
|
- 過去の scope 情報が取得できないセッション(旧形式 / 破損)は、明示的なエラーで止めるか、ユーザーに確認させてから fresh claim にフォールバックする(黙って広げない)
|
||||||
|
- claim 試行が registry の既存 allocation と衝突した場合、エラーメッセージで衝突相手の Pod 名 と target rule の双方が伝わる(現状は Pod 名のみ)
|
||||||
|
- 委譲済みエントリ(`delegated_from` を持つ allocation)が同じセッションの委譲チェーンに属する場合、resume はその範囲を claim せずに進行する
|
||||||
|
|
||||||
|
## 完了条件
|
||||||
|
|
||||||
|
- 「親 Pod がセッション中に SpawnPod を実行 → 子に委譲 → 親 exit → 親セッションを resume」のフローが、既存子 allocation を残したまま衝突なしで成功する
|
||||||
|
- 既存の無関係な Pod と衝突するケースは、衝突 rule と相手 Pod 名を含む明確なエラーで失敗する
|
||||||
|
- 単体テスト or 統合テストで上記 2 ケースが検証される
|
||||||
|
- 既存の fresh spawn (resume なし) の挙動には変化なし
|
||||||
|
|
||||||
|
## 範囲外
|
||||||
|
|
||||||
|
- 過去スコープの永続化スキーマを新規導入するかの判断は実装時に決める(session log の既存フィールドで足りるなら追加しない)
|
||||||
|
- 自動的に既存 Pod を kill / reclaim して claim を通す挙動
|
||||||
|
- protocol 経由の外部からの GrantScope / RevokeScope(`tickets/dynamic-scope.md` の範囲外宣言を継承)
|
||||||
|
- registry 側のエラー型の全面再設計(rule 情報を含めるための最小限の拡張のみで足りる想定)
|
||||||
Loading…
Reference in New Issue
Block a user