progress: prepare companion orchestration notifications

This commit is contained in:
Keisuke Hirata 2026-06-11 23:12:14 +09:00
parent e159e9d338
commit 23a5b53807
No known key found for this signature in database
5 changed files with 181 additions and 37 deletions

View File

@ -1,9 +1,11 @@
--- ---
title: 'Orchestrator進捗をAutoKickなしでCompanionへ通知する' title: 'Orchestrator進捗をAutoKickなしでCompanionへ通知する'
state: 'planning' state: 'queued'
created_at: '2026-06-11T08:15:24Z' created_at: '2026-06-11T08:15:24Z'
updated_at: '2026-06-11T08:15:24Z' updated_at: '2026-06-11T10:31:56Z'
assignee: null assignee: null
queued_by: 'workspace-panel'
queued_at: '2026-06-11T10:31:56Z'
--- ---
## 背景 ## 背景

View File

@ -4,4 +4,29 @@
LocalTicketBackend によって作成されました。 LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: intake at: 2026-06-11T10:21:40Z -->
## 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]。
---
<!-- event: state_changed author: intake at: 2026-06-11T10:21:40Z from: planning to: ready reason: intake_ready field: state -->
## State changed
Intake refinement により、AutoKick/re-kick との差分、Companion authority 境界、weak notification semantics、bounded/safe context、validation focus が整理され、Orchestrator が routing できる状態になった。
---
<!-- event: state_changed author: workspace-panel at: 2026-06-11T10:31:56Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
--- ---

View File

@ -618,11 +618,11 @@ fn resolve_lua_profile_value(
}, },
model: profile.model.unwrap_or_default(), model: profile.model.unwrap_or_default(),
worker: profile.worker.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( delegation_scope: profile_delegation_scope_to_config(
profile.delegation_scope, profile.delegation_scope,
workspace_base, workspace_base,
), )?,
session: profile.session, session: profile.session,
permissions: profile.permissions, permissions: profile.permissions,
feature: profile.feature, feature: profile.feature,
@ -699,9 +699,16 @@ struct ProfileConfig {
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(untagged)] #[serde(untagged)]
enum ProfileScopeConfig { enum ProfileScopeConfig {
Intent { intent: ProfileScopeIntent }, Table(ProfileScopeTable),
String(ProfileScopeIntent), String(ProfileScopeIntent),
} }
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
struct ProfileScopeTable {
intent: ProfileScopeIntent,
#[serde(default)]
deny_write: Vec<PathBuf>,
}
#[derive(Debug, Clone, Copy, Deserialize)] #[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
enum ProfileScopeIntent { enum ProfileScopeIntent {
@ -1128,22 +1135,45 @@ fn scope_module(lua: &Lua) -> mlua::Result<Table> {
let t = lua.create_table()?; let t = lua.create_table()?;
t.set( t.set(
"workspace_write", "workspace_write",
lua.create_function(|lua, ()| { lua.create_function(|lua, options: LuaValue| {
let v = lua.create_table()?; scope_intent_table(lua, "workspace_write", options)
v.set("intent", "workspace_write")?;
Ok(v)
})?, })?,
)?; )?;
t.set( t.set(
"workspace_read", "workspace_read",
lua.create_function(|lua, ()| { lua.create_function(|lua, options: LuaValue| {
let v = lua.create_table()?; scope_intent_table(lua, "workspace_read", options)
v.set("intent", "workspace_read")?;
Ok(v)
})?, })?,
)?; )?;
Ok(t) Ok(t)
} }
fn scope_intent_table(lua: &Lua, intent: &str, options: LuaValue) -> mlua::Result<Table> {
let v = lua.create_table()?;
v.set("intent", intent)?;
match options {
LuaValue::Nil => {}
LuaValue::Table(options) => {
for pair in options.pairs::<String, LuaValue>() {
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> { fn validate_module_name(name: &str) -> Result<(), String> {
if name.is_empty() { if name.is_empty() {
@ -1250,47 +1280,61 @@ fn reject_absolute_auth_file(
fn profile_scope_to_config( fn profile_scope_to_config(
scope: Option<ProfileScopeConfig>, scope: Option<ProfileScopeConfig>,
workspace_base: &Path, workspace_base: &Path,
) -> ScopeConfig { ) -> Result<ScopeConfig, ProfileError> {
profile_scope_intent_to_config( profile_scope_intent_to_config(
scope, scope,
workspace_base, workspace_base,
Some(ProfileScopeIntent::WorkspaceWrite), Some(ProfileScopeIntent::WorkspaceWrite),
"scope",
) )
} }
fn profile_delegation_scope_to_config( fn profile_delegation_scope_to_config(
scope: Option<ProfileScopeConfig>, scope: Option<ProfileScopeConfig>,
workspace_base: &Path, workspace_base: &Path,
) -> ScopeConfig { ) -> Result<ScopeConfig, ProfileError> {
profile_scope_intent_to_config(scope, workspace_base, None) profile_scope_intent_to_config(scope, workspace_base, None, "delegation_scope")
} }
fn profile_scope_intent_to_config( fn profile_scope_intent_to_config(
scope: Option<ProfileScopeConfig>, scope: Option<ProfileScopeConfig>,
workspace_base: &Path, workspace_base: &Path,
default_intent: Option<ProfileScopeIntent>, default_intent: Option<ProfileScopeIntent>,
) -> ScopeConfig { field: &'static str,
let intent = match scope { ) -> Result<ScopeConfig, ProfileError> {
Some(ProfileScopeConfig::Intent { intent }) | Some(ProfileScopeConfig::String(intent)) => { let (intent, deny_write) = match scope {
Some(intent) Some(ProfileScopeConfig::Table(table)) => (Some(table.intent), table.deny_write),
} Some(ProfileScopeConfig::String(intent)) => (Some(intent), Vec::new()),
None => default_intent, None => (default_intent, Vec::new()),
}; };
let Some(intent) = intent else { let Some(intent) = intent else {
return ScopeConfig::default(); return Ok(ScopeConfig::default());
}; };
let permission = match intent { let permission = match intent {
ProfileScopeIntent::WorkspaceRead => Permission::Read, ProfileScopeIntent::WorkspaceRead => Permission::Read,
ProfileScopeIntent::WorkspaceWrite => Permission::Write, 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 { allow: vec![ScopeRule {
target: workspace_base.to_path_buf(), target: workspace_base.to_path_buf(),
permission, permission,
recursive: true, recursive: true,
}], }],
deny: Vec::new(), deny,
} })
} }
fn profile_compaction_to_partial( fn profile_compaction_to_partial(
value: Option<serde_json::Value>, value: Option<serde_json::Value>,
@ -1565,7 +1609,10 @@ mod tests {
assert!(!companion.feature.task.enabled); assert!(!companion.feature.task.enabled);
assert!(!companion.feature.pods.enabled); assert!(!companion.feature.pods.enabled);
assert!(!companion.feature.ticket.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_eq!(companion.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
assert!(companion.web.is_some()); assert!(companion.web.is_some());

View File

@ -1769,6 +1769,35 @@ fn ensure_orchestration_worktree(
}) })
} }
fn prepare_orchestration_worktree_for_restore(
workspace_root: &Path,
) -> Result<OrchestrationWorktreeReady, String> {
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( fn validate_existing_orchestration_worktree(
workspace_root: &Path, workspace_root: &Path,
layout: &OrchestrationWorktreeLayout, layout: &OrchestrationWorktreeLayout,
@ -1964,20 +1993,38 @@ async fn orchestrator_lifecycle(
OrchestratorPanelState::new(pod_name, OrchestratorPanelStatus::Live, None), OrchestratorPanelState::new(pod_name, OrchestratorPanelStatus::Live, None),
), ),
OrchestratorLifecyclePlan::Restore => { OrchestratorLifecyclePlan::Restore => {
match restore_orchestrator_pod(workspace_root, &pod_name, runtime_command.clone()).await 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( Ok(()) => {
OrchestratorLifecycleReport::with_state(OrchestratorPanelState::new(
pod_name, pod_name,
OrchestratorPanelStatus::Restored, OrchestratorPanelStatus::Restored,
Some("restored existing Pod state".to_string()), Some(format!(
"restored existing Pod state in orchestration worktree {}",
worktree.layout.path.display()
)),
)) ))
.mark_reload(), .mark_reload()
}
Err(error) => OrchestratorLifecycleReport::unavailable( Err(error) => OrchestratorLifecycleReport::unavailable(
pod_name, pod_name,
format!("could not restore workspace Orchestrator: {error}"), format!("could not restore workspace Orchestrator: {error}"),
), ),
} }
} }
Err(error) => OrchestratorLifecycleReport::unavailable(
pod_name,
format!("could not prepare orchestration worktree for restore: {error}"),
),
}
}
OrchestratorLifecyclePlan::Spawn => match ensure_orchestration_worktree(workspace_root) { OrchestratorLifecyclePlan::Spawn => match ensure_orchestration_worktree(workspace_root) {
Ok(worktree) => { Ok(worktree) => {
let worktree_note = match worktree.status { let worktree_note = match worktree.status {
@ -3529,6 +3576,27 @@ mod tests {
assert!(created.layout.path.join("dirty.txt").exists()); 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] #[test]
fn existing_wrong_branch_worktree_is_rejected_without_cleanup() { fn existing_wrong_branch_worktree_is_rejected_without_cleanup() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();

View File

@ -2,7 +2,9 @@ return yoi.profile.extend("builtin:default", {
slug = "companion", slug = "companion",
description = "Companion role profile with bundled reusable policy", description = "Companion role profile with bundled reusable policy",
scope = yoi.scope.workspace_read(), scope = yoi.scope.workspace_write({
deny_write = { ".worktree" },
}),
feature = { feature = {
task = { enabled = false }, task = { enabled = false },