From 23a5b53807709b55e3b8e7d0412f732e5982a601 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 11 Jun 2026 23:12:14 +0900 Subject: [PATCH] progress: prepare companion orchestration notifications --- .yoi/tickets/00001KTTW04W2/item.md | 6 +- .yoi/tickets/00001KTTW04W2/thread.md | 25 +++++++ crates/manifest/src/profile.rs | 97 +++++++++++++++++++++------- crates/tui/src/multi_pod.rs | 86 +++++++++++++++++++++--- resources/profiles/companion.lua | 4 +- 5 files changed, 181 insertions(+), 37 deletions(-) diff --git a/.yoi/tickets/00001KTTW04W2/item.md b/.yoi/tickets/00001KTTW04W2/item.md index 4ef674f1..3c7db87c 100644 --- a/.yoi/tickets/00001KTTW04W2/item.md +++ b/.yoi/tickets/00001KTTW04W2/item.md @@ -1,9 +1,11 @@ --- title: 'Orchestrator進捗をAutoKickなしでCompanionへ通知する' -state: 'planning' +state: 'queued' created_at: '2026-06-11T08:15:24Z' -updated_at: '2026-06-11T08:15:24Z' +updated_at: '2026-06-11T10:31:56Z' assignee: null +queued_by: 'workspace-panel' +queued_at: '2026-06-11T10:31:56Z' --- ## 背景 diff --git a/.yoi/tickets/00001KTTW04W2/thread.md b/.yoi/tickets/00001KTTW04W2/thread.md index 1681d35b..2f1b559a 100644 --- a/.yoi/tickets/00001KTTW04W2/thread.md +++ b/.yoi/tickets/00001KTTW04W2/thread.md @@ -4,4 +4,29 @@ LocalTicketBackend によって作成されました。 +--- + + + +## Intake summary + +既存 Ticket 00001KTTW04W2 の body/thread/artifacts と関連 Ticket を確認した。これは implementation_ready。範囲は Orchestrator の progress を Companion に read-only weak notification として渡すことであり、AutoKick / re-kick / scheduler ではない。`Method::Notify` に `auto_run: bool` を追加し、Companion progress notice では `auto_run: false` を使う。`auto_run:false` は idle Pod を起こさず NotifyBuffer に積むだけで、live/reachable Companion への best-effort delivery に限定し、missing/stopped Companion の spawn/restore や初期 persistent snapshot は行わない。通知内容は durable/queryable state から bounded に生成し、history に残らない context-only injection、secret/unbounded log、Companion への mutation/spawn/merge authority 付与は禁止。関連の Companion lifecycle/profile policy は closed 済みで、この Ticket は starvation prevention Ticket 00001KTJXS31R とは非重複の follow-up。blocking open questions はない。risk_flags: [notification-semantics, panel-lifecycle, companion-policy, authority-boundary, prompt-context, persistence, sensitive-content]。 + +--- + + + +## State changed + +Intake refinement により、AutoKick/re-kick との差分、Companion authority 境界、weak notification semantics、bounded/safe context、validation focus が整理され、Orchestrator が routing できる状態になった。 + +--- + + + +## State changed + +Ticket を `workspace-panel` が queued にしました。 + + --- diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 6eee56bf..c354c157 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -618,11 +618,11 @@ fn resolve_lua_profile_value( }, model: profile.model.unwrap_or_default(), worker: profile.worker.unwrap_or_default(), - scope: profile_scope_to_config(profile.scope, workspace_base), + scope: profile_scope_to_config(profile.scope, workspace_base)?, delegation_scope: profile_delegation_scope_to_config( profile.delegation_scope, workspace_base, - ), + )?, session: profile.session, permissions: profile.permissions, feature: profile.feature, @@ -699,9 +699,16 @@ struct ProfileConfig { #[derive(Debug, Clone, Deserialize)] #[serde(untagged)] enum ProfileScopeConfig { - Intent { intent: ProfileScopeIntent }, + Table(ProfileScopeTable), String(ProfileScopeIntent), } +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct ProfileScopeTable { + intent: ProfileScopeIntent, + #[serde(default)] + deny_write: Vec, +} #[derive(Debug, Clone, Copy, Deserialize)] #[serde(rename_all = "snake_case")] enum ProfileScopeIntent { @@ -1128,22 +1135,45 @@ fn scope_module(lua: &Lua) -> mlua::Result { let t = lua.create_table()?; t.set( "workspace_write", - lua.create_function(|lua, ()| { - let v = lua.create_table()?; - v.set("intent", "workspace_write")?; - Ok(v) + lua.create_function(|lua, options: LuaValue| { + scope_intent_table(lua, "workspace_write", options) })?, )?; t.set( "workspace_read", - lua.create_function(|lua, ()| { - let v = lua.create_table()?; - v.set("intent", "workspace_read")?; - Ok(v) + lua.create_function(|lua, options: LuaValue| { + scope_intent_table(lua, "workspace_read", options) })?, )?; Ok(t) } +fn scope_intent_table(lua: &Lua, intent: &str, options: LuaValue) -> mlua::Result
{ + let v = lua.create_table()?; + v.set("intent", intent)?; + match options { + LuaValue::Nil => {} + LuaValue::Table(options) => { + for pair in options.pairs::() { + let (key, value) = pair?; + match key.as_str() { + "deny_write" => v.set("deny_write", value)?, + other => { + return Err(mlua::Error::RuntimeError(format!( + "unsupported yoi.scope option `{other}`" + ))); + } + } + } + } + other => { + return Err(mlua::Error::RuntimeError(format!( + "yoi.scope.{intent} options must be a table, got {}", + other.type_name() + ))); + } + } + Ok(v) +} fn validate_module_name(name: &str) -> Result<(), String> { if name.is_empty() { @@ -1250,47 +1280,61 @@ fn reject_absolute_auth_file( fn profile_scope_to_config( scope: Option, workspace_base: &Path, -) -> ScopeConfig { +) -> Result { profile_scope_intent_to_config( scope, workspace_base, Some(ProfileScopeIntent::WorkspaceWrite), + "scope", ) } fn profile_delegation_scope_to_config( scope: Option, workspace_base: &Path, -) -> ScopeConfig { - profile_scope_intent_to_config(scope, workspace_base, None) +) -> Result { + profile_scope_intent_to_config(scope, workspace_base, None, "delegation_scope") } fn profile_scope_intent_to_config( scope: Option, workspace_base: &Path, default_intent: Option, -) -> ScopeConfig { - let intent = match scope { - Some(ProfileScopeConfig::Intent { intent }) | Some(ProfileScopeConfig::String(intent)) => { - Some(intent) - } - None => default_intent, + field: &'static str, +) -> Result { + let (intent, deny_write) = match scope { + Some(ProfileScopeConfig::Table(table)) => (Some(table.intent), table.deny_write), + Some(ProfileScopeConfig::String(intent)) => (Some(intent), Vec::new()), + None => (default_intent, Vec::new()), }; let Some(intent) = intent else { - return ScopeConfig::default(); + return Ok(ScopeConfig::default()); }; let permission = match intent { ProfileScopeIntent::WorkspaceRead => Permission::Read, ProfileScopeIntent::WorkspaceWrite => Permission::Write, }; - ScopeConfig { + let mut deny = Vec::new(); + for path in deny_write { + if path.is_absolute() { + return Err(ProfileError::InvalidProfile(format!( + "field `{field}.deny_write` must be workspace-relative in reusable Profiles" + ))); + } + deny.push(ScopeRule { + target: workspace_base.join(path), + permission: Permission::Write, + recursive: true, + }); + } + Ok(ScopeConfig { allow: vec![ScopeRule { target: workspace_base.to_path_buf(), permission, recursive: true, }], - deny: Vec::new(), - } + deny, + }) } fn profile_compaction_to_partial( value: Option, @@ -1565,7 +1609,10 @@ mod tests { assert!(!companion.feature.task.enabled); assert!(!companion.feature.pods.enabled); assert!(!companion.feature.ticket.enabled); - assert_eq!(companion.scope.allow[0].permission, Permission::Read); + assert_eq!(companion.scope.allow[0].permission, Permission::Write); + assert_eq!(companion.scope.deny.len(), 1); + assert_eq!(companion.scope.deny[0].permission, Permission::Write); + assert_eq!(companion.scope.deny[0].target, tmp.path().join(".worktree")); assert_eq!(companion.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5")); assert!(companion.web.is_some()); diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index 10e481a3..97083175 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -1769,6 +1769,35 @@ fn ensure_orchestration_worktree( }) } +fn prepare_orchestration_worktree_for_restore( + workspace_root: &Path, +) -> Result { + let layout = orchestration_worktree_layout(workspace_root); + if !layout.path.exists() { + return Err(format!( + "orchestration worktree is missing; cannot restore existing Pod state: {}", + layout.path.display() + )); + } + if !layout.path.is_dir() { + return Err(format!( + "orchestration worktree path exists but is not a directory: {}", + layout.path.display() + )); + } + if !git_inside_worktree(&layout.path) { + return Err(format!( + "orchestration worktree path exists but is not a Git worktree: {}", + layout.path.display() + )); + } + validate_existing_orchestration_worktree(workspace_root, &layout)?; + Ok(OrchestrationWorktreeReady { + layout, + status: OrchestrationWorktreeStatus::Reused, + }) +} + fn validate_existing_orchestration_worktree( workspace_root: &Path, layout: &OrchestrationWorktreeLayout, @@ -1964,17 +1993,35 @@ async fn orchestrator_lifecycle( OrchestratorPanelState::new(pod_name, OrchestratorPanelStatus::Live, None), ), OrchestratorLifecyclePlan::Restore => { - match restore_orchestrator_pod(workspace_root, &pod_name, runtime_command.clone()).await - { - Ok(()) => OrchestratorLifecycleReport::with_state(OrchestratorPanelState::new( - pod_name, - OrchestratorPanelStatus::Restored, - Some("restored existing Pod state".to_string()), - )) - .mark_reload(), + match prepare_orchestration_worktree_for_restore(workspace_root) { + Ok(worktree) => { + match restore_orchestrator_pod( + &worktree.layout.path, + &pod_name, + runtime_command.clone(), + ) + .await + { + Ok(()) => { + OrchestratorLifecycleReport::with_state(OrchestratorPanelState::new( + pod_name, + OrchestratorPanelStatus::Restored, + Some(format!( + "restored existing Pod state in orchestration worktree {}", + worktree.layout.path.display() + )), + )) + .mark_reload() + } + Err(error) => OrchestratorLifecycleReport::unavailable( + pod_name, + format!("could not restore workspace Orchestrator: {error}"), + ), + } + } Err(error) => OrchestratorLifecycleReport::unavailable( pod_name, - format!("could not restore workspace Orchestrator: {error}"), + format!("could not prepare orchestration worktree for restore: {error}"), ), } } @@ -3529,6 +3576,27 @@ mod tests { assert!(created.layout.path.join("dirty.txt").exists()); } + #[test] + fn restore_uses_existing_orchestration_worktree_even_when_dirty() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("repo"); + init_test_repo(&root); + let created = ensure_orchestration_worktree(&root).unwrap(); + std::fs::write(created.layout.path.join("orchestrator-notes.txt"), "dirty").unwrap(); + + let restored = prepare_orchestration_worktree_for_restore(&root).unwrap(); + + assert_eq!(restored.status, OrchestrationWorktreeStatus::Reused); + assert_eq!(restored.layout.path, created.layout.path); + assert_ne!(restored.layout.path, root); + assert!( + restored + .layout + .path + .ends_with(".worktree/orchestration/repo-orchestrator") + ); + } + #[test] fn existing_wrong_branch_worktree_is_rejected_without_cleanup() { let temp = TempDir::new().unwrap(); diff --git a/resources/profiles/companion.lua b/resources/profiles/companion.lua index 3497c687..d4cf6091 100644 --- a/resources/profiles/companion.lua +++ b/resources/profiles/companion.lua @@ -2,7 +2,9 @@ return yoi.profile.extend("builtin:default", { slug = "companion", description = "Companion role profile with bundled reusable policy", - scope = yoi.scope.workspace_read(), + scope = yoi.scope.workspace_write({ + deny_write = { ".worktree" }, + }), feature = { task = { enabled = false },