项目文件夹

文件
David Whatley 5f97b2416e fix(swarm,tasks): direct-exec teammate spawn, bypass shell on Windows (#230)
Teammate spawn (`agent` tool, `task_create`) failed on Windows with exit
127 and "command not found" against a backslash-mangled Python path,
even though the same `bash -lc "..."` command worked interactively.

Root cause: `subprocess_backend.spawn` built a single shell command
string with a `KEY='val' python ...` env-prefix, then routed through
`bash -lc` via `asyncio.create_subprocess_exec`. Two failures stacked:

  1. The env-prefix string used Python `repr()` for values, and Git
     Bash's `-lc` argument parser consumed backslashes inside the
     quoted segments as escape sequences (`\U` -> `U`).
  2. Even with proper `shlex.quote` of every command part — keeping
     backslashes intact — Git Bash launched via
     `asyncio.create_subprocess_exec` could not exec a Windows-pathed
     Python interpreter, returning `command not found` for the same
     argv that interactive bash exec'd fine.

Fix: don't use a shell at all for teammate spawn.

  * Add `argv: list[str] | None` to `TaskRecord` plus matching kwargs
    on `BackgroundTaskManager.create_shell_task` /
    `create_agent_task`. Either `command` (shell) or `argv` (direct
    exec) must be supplied; both is rejected.
  * `_start_process` runs the argv path via
    `asyncio.create_subprocess_exec(*argv, env=...)` directly, with no
    bash wrapper.
  * `subprocess_backend.spawn` builds an argv list (via
    `get_teammate_command()` plus inherited CLI flags) and hands it to
    `create_agent_task(argv=..., env=...)`. Inherited env vars now
    flow through `env=` rather than being embedded in the command
    string.
  * The legacy shell-evaluated `command=` path is preserved verbatim
    for callers (e.g. `BashTool`) that legitimately want shell
    semantics — only teammate spawn migrates to direct exec.

Tests: 5 new unit tests in `tests/test_swarm/test_subprocess_backend.py`
and 3 in `tests/test_tasks/test_manager.py` lock in the contract — env
plumbed via `env=` kwarg, argv list preserves Windows backslashed
paths, command/argv mutual exclusion, direct-exec round-trip.

Validated end-to-end on Windows 11 against a real CEO/Lead workflow:
CEO successfully calls `agent` tool, Lead spawns at depth=1 and
issues 29 tool calls. Same path was producing exit-127 task logs
before this change.
2026-05-04 16:21:40 -05:00

234 行
8.2 KiB
Python

"""Tests for background task management."""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
import pytest
from openharness.tasks.manager import BackgroundTaskManager, _encode_task_worker_payload
@pytest.mark.asyncio
async def test_create_shell_task_and_read_output(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
manager = BackgroundTaskManager()
task = await manager.create_shell_task(
command="printf 'hello task'",
description="hello",
cwd=tmp_path,
)
await asyncio.wait_for(manager._waiters[task.id], timeout=5) # type: ignore[attr-defined]
updated = manager.get_task(task.id)
assert updated is not None
assert updated.status == "completed"
assert "hello task" in manager.read_task_output(task.id)
@pytest.mark.asyncio
async def test_create_agent_task_with_command_override_and_write(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
manager = BackgroundTaskManager()
task = await manager.create_agent_task(
prompt="first",
description="agent",
cwd=tmp_path,
command="while read line; do echo \"got:$line\"; break; done",
)
await asyncio.wait_for(manager._waiters[task.id], timeout=5) # type: ignore[attr-defined]
assert "got:first" in manager.read_task_output(task.id)
@pytest.mark.asyncio
async def test_create_agent_task_preserves_multiline_prompt(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
manager = BackgroundTaskManager()
task = await manager.create_agent_task(
prompt="line 1\nline 2\nline 3",
description="agent",
cwd=tmp_path,
command=(
"python -u -c \"import sys, json; "
"print(json.loads(sys.stdin.readline())['text'].replace(chr(10), '|'))\""
),
)
await asyncio.wait_for(manager._waiters[task.id], timeout=5) # type: ignore[attr-defined]
assert "line 1|line 2|line 3" in manager.read_task_output(task.id)
@pytest.mark.asyncio
async def test_write_to_stopped_agent_task_restarts_process(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
manager = BackgroundTaskManager()
task = await manager.create_agent_task(
prompt="ready",
description="agent",
cwd=tmp_path,
command="while read line; do echo \"got:$line\"; break; done",
)
await asyncio.wait_for(manager._waiters[task.id], timeout=5) # type: ignore[attr-defined]
await manager.write_to_task(task.id, "follow-up")
await asyncio.wait_for(manager._waiters[task.id], timeout=5) # type: ignore[attr-defined]
output = manager.read_task_output(task.id)
assert "got:ready" in output
assert "[OpenHarness] Agent task restarted; prior interactive context was not preserved." in output
assert "got:follow-up" in output
updated = manager.get_task(task.id)
assert updated is not None
assert updated.metadata["restart_count"] == "1"
assert updated.metadata["status_note"] == "Task restarted; prior interactive context was not preserved."
@pytest.mark.asyncio
async def test_create_shell_task_stores_env_on_record(tmp_path: Path, monkeypatch):
"""``env=`` passed to ``create_shell_task`` must land on the
``TaskRecord.env`` field so ``_start_process`` can forward it to the
subprocess. Plumbing this dict instead of baking ``KEY=val`` into the
command string is the fix for #230."""
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
manager = BackgroundTaskManager()
task = await manager.create_shell_task(
command="printf 'noop'",
description="env test",
cwd=tmp_path,
env={"MY_OH_TEST_VAR": "hello-230"},
)
await asyncio.wait_for(manager._waiters[task.id], timeout=5) # type: ignore[attr-defined]
stored = manager.get_task(task.id)
assert stored is not None
assert stored.env == {"MY_OH_TEST_VAR": "hello-230"}
@pytest.mark.asyncio
async def test_create_shell_task_argv_path_bypasses_shell(tmp_path: Path, monkeypatch):
"""The argv path runs the executable directly via
``asyncio.create_subprocess_exec(*argv)`` with no shell. This is the
fix for #230: bash on Windows cannot exec Windows-pathed binaries
when launched via ``create_subprocess_exec``, so teammate spawn must
not route through a shell."""
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
manager = BackgroundTaskManager()
import sys
task = await manager.create_shell_task(
argv=[sys.executable, "-c", "print('argv-route-ok')"],
description="argv direct exec",
cwd=tmp_path,
)
await asyncio.wait_for(manager._waiters[task.id], timeout=10) # type: ignore[attr-defined]
output = manager.read_task_output(task.id)
assert "argv-route-ok" in output
stored = manager.get_task(task.id)
assert stored is not None
assert stored.argv is not None
assert stored.command is None
@pytest.mark.asyncio
async def test_create_shell_task_rejects_both_command_and_argv(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
manager = BackgroundTaskManager()
with pytest.raises(ValueError, match="only one"):
await manager.create_shell_task(
command="printf hi",
argv=["echo", "hi"],
description="both",
cwd=tmp_path,
)
@pytest.mark.asyncio
async def test_create_shell_task_rejects_neither_command_nor_argv(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
manager = BackgroundTaskManager()
with pytest.raises(ValueError, match="either command or argv"):
await manager.create_shell_task(
description="empty",
cwd=tmp_path,
)
@pytest.mark.asyncio
async def test_start_process_forwards_env_to_subprocess(tmp_path: Path, monkeypatch):
"""End-to-end: env vars passed via ``create_shell_task(env=...)`` must be
visible to the spawned subprocess. The subprocess prints the value, and
we read it back from the task output."""
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
manager = BackgroundTaskManager()
task = await manager.create_shell_task(
command='printf "value=%s" "$MY_OH_TEST_VAR"',
description="env passthrough",
cwd=tmp_path,
env={"MY_OH_TEST_VAR": "spawn-230"},
)
await asyncio.wait_for(manager._waiters[task.id], timeout=5) # type: ignore[attr-defined]
output = manager.read_task_output(task.id)
assert "value=spawn-230" in output
def test_encode_task_worker_payload_wraps_multiline_text() -> None:
payload = _encode_task_worker_payload("alpha\nbeta\n")
assert json.loads(payload.decode("utf-8")) == {"text": "alpha\nbeta"}
def test_encode_task_worker_payload_preserves_structured_messages() -> None:
raw = '{"text":"follow up","from":"coordinator"}'
payload = _encode_task_worker_payload(raw)
assert payload.decode("utf-8") == raw + "\n"
@pytest.mark.asyncio
async def test_stop_task(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
manager = BackgroundTaskManager()
task = await manager.create_shell_task(
command="sleep 30",
description="sleeper",
cwd=tmp_path,
)
await manager.stop_task(task.id)
updated = manager.get_task(task.id)
assert updated is not None
assert updated.status == "killed"
@pytest.mark.asyncio
async def test_completion_listener_fires_when_task_finishes(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
manager = BackgroundTaskManager()
seen: list[tuple[str, str, int | None]] = []
done = asyncio.Event()
async def _listener(task):
seen.append((task.id, task.status, task.return_code))
done.set()
manager.register_completion_listener(_listener)
task = await manager.create_shell_task(
command="printf 'done'",
description="listener",
cwd=tmp_path,
)
await asyncio.wait_for(done.wait(), timeout=5)
assert seen == [(task.id, "completed", 0)]