progress: prepare companion orchestration notifications
This commit is contained in:
parent
e159e9d338
commit
23a5b53807
|
|
@ -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'
|
||||||
---
|
---
|
||||||
|
|
||||||
## 背景
|
## 背景
|
||||||
|
|
|
||||||
|
|
@ -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 にしました。
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user