feat: add SpawnPod cwd

This commit is contained in:
Keisuke Hirata 2026-06-08 16:23:37 +09:00
parent 15cf4a1332
commit 3dd77079f1
No known key found for this signature in database
6 changed files with 270 additions and 6 deletions

View File

@ -119,7 +119,7 @@ reviewer には coder の実装方針ではなく、この intent packet と dif
- child worktree path / branch
- 対象 ticket path
- intent packet
- Bash は必ず child worktree に `cd` すること
- SpawnPod の `cwd` は child worktree に設定すること(`cwd` は process/tool default cwd であり、scope/authority ではない)
- main workspace の `TODO.md` / `tickets/` / `docs/report/` / `.yoi` は編集しないこと
- child worktree 内の tracked `.yoi` project records は実装対象に必要な branch-local artifacts/dossiers として編集してよいが、`.yoi/memory` や local/runtime/secret-like files は作らないこと
- active orchestration progress と最終 review/approval/close は main workspace の責任として残すこと

View File

@ -116,7 +116,7 @@ fi
## Pod へ渡す scope
Pod を使う場合、Pod の cwd は main workspace のままになる。必ず作業対象が child worktree であることを明示し、Bash 実行時は毎回 `cd <repo>/.worktree/<task-name> && ...` させる
Pod を使う場合、coder Pod の SpawnPod `cwd` は child worktree に設定する。`cwd` は child process/tool default cwd だけを変え、runtime workspace root・project record root・scope/authority は変えないため、下の明示 scope は別途渡す
coder Pod 推奨 scope:

View File

@ -549,7 +549,7 @@ fn append_orchestrator_agent_routing_guidance(out: &mut String) {
out.push_str("- Create worktrees or spawn coder/reviewer Pods only after `workflow_state = inprogress` is already recorded and accepted. If the Ticket is still queued and unblocked, record `queued -> inprogress` before any worktree/SpawnPod side effect.\n");
out.push_str("- Use `worktree-workflow` for the mechanical worktree plan: create `.worktree/<task-name>`, keep tracked `.yoi` project records visible in the child worktree, exclude `.yoi/memory` plus local/runtime/log/lock/secret-like `.yoi` paths, and keep active orchestration progress plus final review/approval/close in the main workspace unless explicitly designed otherwise.\n");
out.push_str("- Use `multi-agent-workflow` for the sibling loop: coder and reviewer are siblings under this Orchestrator; coder gets narrow write scope to the child worktree; reviewer is read-only by default.\n");
out.push_str("- Give the coder an intent packet that distinguishes binding decisions/invariants, implementation latitude, escalation conditions, child worktree/branch, validation commands, and report expectations; require Bash commands to `cd` into the child worktree, prohibit editing main-workspace `.yoi`/Ticket/workflow/docs records, and prohibit creating generated memory/local/runtime/secret-like files in the child worktree.\n");
out.push_str("- Give the coder an intent packet that distinguishes binding decisions/invariants, implementation latitude, escalation conditions, child worktree/branch, validation commands, and report expectations; set SpawnPod `cwd` to the child worktree while delegating explicit scope separately, prohibit editing main-workspace `.yoi`/Ticket/workflow/docs records, and prohibit creating generated memory/local/runtime/secret-like files in the child worktree.\n");
out.push_str("- Give the reviewer the recorded Ticket intent, binding decisions/invariants, implementation latitude, acceptance criteria, explicit escalation conditions, diff/commits, validation evidence, and blocker/non-blocker criteria; reviewer judgment is against recorded requirements and decisions, not unrecorded preferred tactics. Keep branch-local reviewer verdicts in the review report or merge-ready dossier rather than recording them as final main-branch Ticket approval.\n");
out.push_str("- Ticket thread progress may record worktree plan, coder delegated/completed/blocked, reviewer delegated, blocker/fix-loop summaries, and merge-ready dossier pointer; do not merge, close, or record final main approval in this routing/branch-review phase.\n");
out.push_str("- Stop at a merge-ready dossier for `orchestrator-merge-completion` containing Ticket id/slug, branch/worktree, commits, intent/invariant check, implementation summary, coder/reviewer Pods, blockers fixed or rejected findings with reasons, validation performed, residual risks, dirty state, and parent/human decision needs if any.\n");

View File

@ -18,7 +18,7 @@ use manifest::{
CompactionConfigPartial, DelegationScope, FileUploadLimitsPartial, Permission,
PermissionConfigPartial, PodManifest, PodManifestConfig, PodMetaConfig, ProfileDiscovery,
ProfileError, ProfileRegistry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver,
ProfileSelector, ScopeConfig, ScopeRule, SessionConfigPartial, SharedScope,
ProfileSelector, Scope, ScopeConfig, ScopeRule, SessionConfigPartial, SharedScope,
ToolOutputLimitsPartial, WorkerManifestConfig,
};
use serde::Deserialize;
@ -52,6 +52,11 @@ struct SpawnPodInput {
/// Instruction-file reference (e.g. `$yoi/default`, `$user/my-agent`).
#[serde(default)]
instruction: Option<String>,
/// Child process/tool working directory. This is not the runtime workspace
/// root and grants no filesystem authority. When omitted, the spawned Pod
/// starts in the spawner's current working directory.
#[serde(default)]
cwd: Option<PathBuf>,
/// First message sent to the spawned Pod via `Method::Run`.
task: String,
/// Allow rules delegated to the spawned Pod. Must be a subset of the
@ -304,6 +309,7 @@ impl Tool for SpawnPodTool {
let scope_allow = parse_scope(&input.scope)?;
self.validate_delegation_scope(&scope_allow)?;
let child_cwd = validate_spawn_cwd(input.cwd.as_deref(), &scope_allow, &self.spawner_pwd)?;
let spawn_selector =
parse_spawn_profile_selector(input.profile.as_deref()).map_err(|msg| {
@ -349,7 +355,12 @@ impl Tool for SpawnPodTool {
// entry on exit.
let start_outcome = self
.exec_child(&input.name, &spawn_config_json, &predicted_socket)
.exec_child(
&input.name,
&spawn_config_json,
&predicted_socket,
&child_cwd,
)
.await;
if let Err(e) = start_outcome {
self.release_reservation(&lock_path, &input.name);
@ -422,6 +433,7 @@ impl SpawnPodTool {
pod_name: &str,
spawn_config_json: &str,
predicted_socket: &Path,
child_cwd: &Path,
) -> Result<(), ToolError> {
let runtime_command = match &self.runtime_command {
Some(command) => command.clone(),
@ -458,7 +470,7 @@ impl SpawnPodTool {
.arg(&self.callback_socket)
.arg("--spawn-config-json")
.arg(spawn_config_json)
.current_dir(&self.spawner_pwd)
.current_dir(child_cwd)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::from(stderr_file))
@ -531,6 +543,60 @@ fn parse_scope(rules: &[ScopeRuleInput]) -> Result<Vec<ScopeRule>, ToolError> {
.collect()
}
fn validate_spawn_cwd(
cwd: Option<&Path>,
scope_allow: &[ScopeRule],
default_cwd: &Path,
) -> Result<PathBuf, ToolError> {
let Some(cwd) = cwd else {
return Ok(default_cwd.to_path_buf());
};
if !cwd.is_absolute() {
return Err(ToolError::InvalidArgument(format!(
"SpawnPod.cwd must be absolute: {}",
cwd.display()
)));
}
let metadata = std::fs::metadata(cwd).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
ToolError::InvalidArgument(format!("SpawnPod.cwd does not exist: {}", cwd.display()))
} else {
ToolError::InvalidArgument(format!(
"SpawnPod.cwd is not usable: {}: {e}",
cwd.display()
))
}
})?;
if !metadata.is_dir() {
return Err(ToolError::InvalidArgument(format!(
"SpawnPod.cwd must be a directory: {}",
cwd.display()
)));
}
let canonical = std::fs::canonicalize(cwd).map_err(|e| {
ToolError::InvalidArgument(format!(
"SpawnPod.cwd is not usable: {}: {e}",
cwd.display()
))
})?;
let child_scope = Scope::from_config(&ScopeConfig {
allow: scope_allow.to_vec(),
deny: Vec::new(),
})
.map_err(|e| {
ToolError::InvalidArgument(format!(
"requested child scope cannot validate SpawnPod.cwd: {e}"
))
})?;
if !child_scope.is_readable(&canonical) {
return Err(ToolError::InvalidArgument(format!(
"SpawnPod.cwd {} is outside the child's delegated readable scope; cwd grants no authority, so add an explicit read or write scope rule covering it",
cwd.display()
)));
}
Ok(canonical)
}
/// Serialise the internal manifest config that gets handed to the child
/// Pod runtime process via the hidden `--spawn-config-json` flag.
/// `PodManifestConfig`'s `Serialize` impl is the single source of truth for the
@ -912,6 +978,63 @@ mod tests {
}
}
#[test]
fn spawn_pod_input_schema_includes_optional_cwd() {
let schema = serde_json::to_value(schemars::schema_for!(SpawnPodInput)).unwrap();
let properties = schema
.get("properties")
.and_then(serde_json::Value::as_object)
.expect("schema properties");
assert!(properties.contains_key("cwd"), "schema: {schema}");
let required = schema
.get("required")
.and_then(serde_json::Value::as_array)
.expect("schema required list");
assert!(
!required.iter().any(|value| value.as_str() == Some("cwd")),
"cwd must remain optional: {schema}"
);
}
#[test]
fn spawn_pod_validate_cwd_requires_absolute_existing_directory_in_child_scope() {
let root = TempDir::new().unwrap();
let child_cwd = root.path().join("child");
std::fs::create_dir(&child_cwd).unwrap();
let file_path = root.path().join("file.txt");
std::fs::write(&file_path, "not a dir").unwrap();
let outside = TempDir::new().unwrap();
let missing = root.path().join("missing");
let rules = vec![abs_rule(root.path(), Permission::Write)];
assert_eq!(
validate_spawn_cwd(None, &rules, root.path()).unwrap(),
root.path()
);
assert_eq!(
validate_spawn_cwd(Some(&child_cwd), &rules, root.path()).unwrap(),
std::fs::canonicalize(&child_cwd).unwrap()
);
for (cwd, expected) in [
(Path::new("relative"), "must be absolute"),
(missing.as_path(), "does not exist"),
(file_path.as_path(), "must be a directory"),
(
outside.path(),
"outside the child's delegated readable scope",
),
] {
let err = validate_spawn_cwd(Some(cwd), &rules, root.path()).unwrap_err();
match err {
ToolError::InvalidArgument(message) => {
assert!(message.contains(expected), "{message}")
}
other => panic!("expected InvalidArgument, got {other:?}"),
}
}
}
fn parent_manifest(root: &Path, deny: Option<&Path>) -> PodManifest {
PodManifestConfig {
pod: PodMetaConfig {

View File

@ -145,6 +145,30 @@ fn mock_runtime_command() -> PodRuntimeCommand {
PodRuntimeCommand::new(which_true(), Vec::new())
}
fn cwd_recording_runtime_command(script_path: &Path, output_path: &Path) -> PodRuntimeCommand {
std::fs::write(script_path, "pwd > \"$1\"\n").unwrap();
PodRuntimeCommand::new(
which_sh(),
vec![
script_path.as_os_str().to_os_string(),
output_path.as_os_str().to_os_string(),
],
)
}
async fn read_recorded_pwd(output_path: &Path) -> String {
for _ in 0..50 {
if let Ok(content) = std::fs::read_to_string(output_path) {
return content.trim_end().to_string();
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
panic!(
"runtime command did not record pwd at {}",
output_path.display()
);
}
/// `/bin/true` only exists on FHS-compliant systems. Resolve it via PATH
/// so the tests work regardless of distro.
fn which_true() -> String {
@ -160,6 +184,19 @@ fn which_true() -> String {
"/bin/true".into()
}
fn which_sh() -> String {
for dir in std::env::var_os("PATH")
.map(|p| std::env::split_paths(&p).collect::<Vec<_>>())
.unwrap_or_default()
{
let candidate = dir.join("sh");
if candidate.is_file() {
return candidate.to_string_lossy().into_owned();
}
}
"/bin/sh".into()
}
/// Tests don't exercise the model — they intercept the spawned
/// child via a mock socket — but `spawn_pod_tool` needs a value to
/// embed in the overlay TOML. Any well-formed `ModelManifest` works.
@ -231,6 +268,108 @@ fn clear_env() {
}
}
#[tokio::test]
async fn spawn_pod_runs_child_process_in_provided_cwd() {
let _env = EnvGuard::acquire();
let allow_root = TempDir::new().unwrap();
let child_cwd = allow_root.path().join("child-cwd");
std::fs::create_dir(&child_cwd).unwrap();
let script = allow_root.path().join("record-pwd.sh");
let output_path = allow_root.path().join("pwd.txt");
let (_tmp, runtime_base, spawner_socket, spawner_rd) =
setup_spawner("root", allow_root.path()).await;
let (_predicted_socket, listener) = bind_mock_pod_socket(&runtime_base, "child-cwd").await;
let received = accept_one_method(listener);
let registry = SpawnedPodRegistry::new(spawner_rd);
let def = spawn_pod_tool_with_runtime_command(
"root".into(),
spawner_socket,
runtime_base,
allow_root.path().to_path_buf(),
registry,
None,
dummy_manifest(allow_root.path()),
shared_scope_for(allow_root.path()),
builtin_prompts(),
cwd_recording_runtime_command(&script, &output_path),
);
let (_meta, tool) = def();
let input = json!({
"name": "child-cwd",
"task": "hello",
"profile": "inherit",
"cwd": child_cwd.to_str().unwrap(),
"scope": [{
"target": allow_root.path().to_str().unwrap(),
"permission": "write"
}]
})
.to_string();
tool.execute(&input).await.unwrap();
assert!(matches!(received.await.unwrap(), Some(Method::Run { .. })));
assert_eq!(
read_recorded_pwd(&output_path).await,
child_cwd.to_str().unwrap()
);
clear_env();
}
#[tokio::test]
async fn spawn_pod_omitted_cwd_preserves_spawner_pwd() {
let _env = EnvGuard::acquire();
let allow_root = TempDir::new().unwrap();
let script = allow_root.path().join("record-pwd.sh");
let output_path = allow_root.path().join("pwd.txt");
let (_tmp, runtime_base, spawner_socket, spawner_rd) =
setup_spawner("root", allow_root.path()).await;
let (_predicted_socket, listener) =
bind_mock_pod_socket(&runtime_base, "child-default-cwd").await;
let received = accept_one_method(listener);
let registry = SpawnedPodRegistry::new(spawner_rd);
let def = spawn_pod_tool_with_runtime_command(
"root".into(),
spawner_socket,
runtime_base,
allow_root.path().to_path_buf(),
registry,
None,
dummy_manifest(allow_root.path()),
shared_scope_for(allow_root.path()),
builtin_prompts(),
cwd_recording_runtime_command(&script, &output_path),
);
let (_meta, tool) = def();
let input = json!({
"name": "child-default-cwd",
"task": "hello",
"profile": "inherit",
"scope": [{
"target": allow_root.path().to_str().unwrap(),
"permission": "write"
}]
})
.to_string();
tool.execute(&input).await.unwrap();
assert!(matches!(received.await.unwrap(), Some(Method::Run { .. })));
assert_eq!(
read_recorded_pwd(&output_path).await,
allow_root.path().to_str().unwrap()
);
clear_env();
}
#[tokio::test]
async fn spawn_pod_delegates_scope_and_sends_run() {
let _env = EnvGuard::acquire();

View File

@ -71,6 +71,8 @@ pod_orchestration_guidance_section = "{% include \"$yoi/common/pod-orchestration
spawn_pod_tool_description = """\
Spawn a new Pod process to work on a delegated task. The spawner's write scope is reduced by the scope passed here; the spawned Pod receives its own socket and starts running `task` immediately. The spawned Pod outlives the spawner's current turn and can be contacted again through its socket path.
Optional `cwd`: when provided, it is the child process/tool default working directory only. It must be an absolute existing directory covered by the child's delegated readable scope, and it does not change workspace/Profile/memory/Ticket roots or grant authority.
Profile selection: `profile` may be omitted or set to `default` to use the effective child default profile, set to `inherit` to derive reusable child configuration from this Pod, or set to one of the registry selectors below. Raw/path profile selectors are not accepted by SpawnPod. `scope` is always the only delegated filesystem capability; profile scope is replaced by the explicit SpawnPod scope.
Default profile: {{ default_profile }}