pycastle 0.1.3.10.dev0__tar.gz → 0.1.3.11.dev0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/CONTEXT.md +12 -10
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/PKG-INFO +1 -1
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/agent_runner.py +151 -151
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/config/loader.py +1 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/defaults/config.py +1 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/iteration/__init__.py +44 -46
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/iteration/_deps.py +2 -2
- pycastle-0.1.3.11.dev0/src/pycastle/iteration/_phase_row.py +30 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/iteration/_utils.py +4 -3
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/iteration/merge.py +133 -114
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/iteration/preflight.py +1 -1
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/orchestrator.py +2 -2
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/rich_status_display.py +20 -31
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/services/git_service.py +7 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/status_display.py +42 -52
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/worktree.py +15 -4
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle.egg-info/PKG-INFO +1 -1
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle.egg-info/SOURCES.txt +2 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/test_agent_runner.py +1 -1
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/test_config_new.py +15 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/test_git_service.py +49 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/test_iteration.py +407 -18
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/test_merge.py +202 -20
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/test_orchestrator.py +16 -0
- pycastle-0.1.3.11.dev0/tests/test_phase_row.py +50 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/test_plain_status_display.py +108 -29
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/test_status_display.py +147 -47
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/test_worktree.py +47 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/.github/workflows/publish.yml +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/.gitignore +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/.python-version +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/CLAUDE.md +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/LICENSE +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/README.md +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/docs/adr/0001-runtime-dependency-installation.md +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/docs/agents/domain.md +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/docs/agents/issue-tracker.md +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/docs/agents/triage-labels.md +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/pyproject.toml +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/setup.cfg +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/__init__.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/__pycache__/__init__.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/__pycache__/_types.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/__pycache__/agent_output_protocol.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/__pycache__/agent_result.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/__pycache__/agent_runner.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/__pycache__/build_command.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/__pycache__/container_runner.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/__pycache__/errors.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/__pycache__/init_command.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/__pycache__/labels.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/__pycache__/main.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/__pycache__/orchestrator.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/__pycache__/prompt_pipeline.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/__pycache__/prompt_utils.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/__pycache__/rich_status_display.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/__pycache__/stream_parser.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/__pycache__/worktree.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/_types.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/agent_output_protocol.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/agent_result.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/build_command.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/config/__init__.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/config/__pycache__/__init__.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/config/__pycache__/loader.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/config/__pycache__/validator.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/config/validator.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/container_runner.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/defaults/.gitignore +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/defaults/Dockerfile +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/defaults/prompts/coding-standards/deep-modules.md +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/defaults/prompts/coding-standards/interfaces.md +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/defaults/prompts/coding-standards/mocking.md +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/defaults/prompts/coding-standards/refactoring.md +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/defaults/prompts/coding-standards/tests.md +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/defaults/prompts/implement-prompt.md +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/defaults/prompts/merge-prompt.md +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/defaults/prompts/plan-prompt.md +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/defaults/prompts/preflight-issue.md +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/defaults/prompts/review-prompt.md +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/errors.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/init_command.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/iteration/__pycache__/__init__.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/iteration/__pycache__/_deps.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/iteration/__pycache__/_utils.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/iteration/__pycache__/implement.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/iteration/__pycache__/merge.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/iteration/__pycache__/planning.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/iteration/__pycache__/preflight.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/iteration/implement.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/iteration/planning.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/labels.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/main.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/prompt_pipeline.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/prompt_utils.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/services/__init__.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/services/__pycache__/__init__.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/services/__pycache__/_base.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/services/__pycache__/claude_service.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/services/__pycache__/docker_service.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/services/__pycache__/git_service.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/services/__pycache__/github_service.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/services/_base.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/services/claude_service.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/services/docker_service.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle/services/github_service.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle.egg-info/dependency_links.txt +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle.egg-info/entry_points.txt +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle.egg-info/requires.txt +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/src/pycastle.egg-info/top_level.txt +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__init__.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/__init__.cpython-311.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/conftest.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/test_agent_output_protocol.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/test_agent_result.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/test_agent_runner.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/test_build_command.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/test_claude_service.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/test_config_new.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/test_container_runner.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/test_default_prompts.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/test_deps.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/test_docker_service.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/test_errors.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/test_git_service.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/test_github_service.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/test_implement.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/test_init_command.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/test_integration.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/test_iteration.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/test_labels.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/test_main.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/test_merge.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/test_orchestrator.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/test_plan.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/test_planning.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/test_preflight.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/test_prompt_pipeline.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/test_prompt_utils.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/test_status_display.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/test_stream_parser.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/test_subprocess_service.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/__pycache__/test_worktree.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/conftest.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/test_agent_output_protocol.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/test_agent_result.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/test_build_command.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/test_claude_service.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/test_container_runner.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/test_default_prompts.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/test_deps.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/test_docker_service.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/test_errors.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/test_github_service.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/test_implement.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/test_init_command.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/test_integration.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/test_labels.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/test_main.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/test_plan.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/test_planning.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/test_preflight.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/test_prompt_pipeline.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/test_prompt_utils.py +0 -0
- {pycastle-0.1.3.10.dev0 → pycastle-0.1.3.11.dev0}/tests/test_subprocess_service.py +0 -0
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
| **CLI default** | The behavior when no `--model` or `--effort` flag is injected — triggered by an empty string in STAGE_OVERRIDES | default model, unset |
|
|
38
38
|
| **validate_config** | Public function in the config validator module; takes a `Config` and a `ClaudeService`, resolves model shorthands to full model IDs, validates all stage overrides, and returns a new immutable `Config`; raises `ConfigValidationError` on any invalid entry | config validation, startup check |
|
|
39
39
|
| **ConfigValidationError** | Error raised by validate_config when a model shorthand or effort level is unrecognised; includes the invalid value, closest valid suggestion, and full list of valid options | validation error, config error |
|
|
40
|
+
| **auto_push** | Boolean config entry (default `True`) that controls whether `merge_phase` pushes local main to the remote after any merges produce commits; set to `False` to disable automatic pushing | push_after_merge, AUTO_PUSH |
|
|
40
41
|
|
|
41
42
|
## GitHub Integration
|
|
42
43
|
|
|
@@ -91,7 +92,7 @@
|
|
|
91
92
|
| **merge-sandbox worktree** | A temporary named-branch worktree (`pycastle/merge-sandbox`) created by `merge_phase` from HEAD after clean merges complete; the Merger runs inside it to resolve conflicting branches; always removed in a `try/finally` by `merge_phase` regardless of state; on success `merge_phase` fast-forwards `main` from the branch before cleanup; located at `.pycastle/.worktrees/merge-sandbox` | merger worktree, conflict worktree |
|
|
92
93
|
| **branch** | A git branch name assigned to an issue inside the plan; follows the pattern `pycastle/issue-<n>-<slug>` | feature branch, issue branch |
|
|
93
94
|
| **orphan worktree** | A worktree directory under `.pycastle/.worktrees/` no longer registered in git, typically left by a crashed agent run | stale worktree, leftover worktree |
|
|
94
|
-
| **orphan sweep** | Startup operation that cross-references `.pycastle/.worktrees/` against `git worktree list --porcelain
|
|
95
|
+
| **orphan sweep** | Startup operation that cross-references `.pycastle/.worktrees/` against `git worktree list --porcelain`, deletes unregistered directories, and removes the `.worktrees` parent directory if no active children remain | worktree cleanup, stale cleanup |
|
|
95
96
|
| **collision detection** | Mechanism that prevents two parallel agents from simultaneously creating worktrees for the same branch, implemented as a per-branch async lock | — |
|
|
96
97
|
|
|
97
98
|
## Prompts
|
|
@@ -162,8 +163,8 @@
|
|
|
162
163
|
| **new-branch path** | The `git worktree add -b <branch> <path> <safe-SHA>` form used when the branch does not yet exist; always branched from the pinned safe SHA rather than HEAD | — |
|
|
163
164
|
| **existing-branch path** | The `git worktree add <path> <branch>` form used when the branch already exists | — |
|
|
164
165
|
| **worktree contents check** | Guard step run after `git worktree add` that verifies `pyproject.toml` or `requirements.txt` is present; fails with the worktree path and directory listing if absent | checkout guard, file check |
|
|
165
|
-
| **`detached_worktree`** | Async context manager in `worktree.py` that creates a detached checkout at a given SHA, yields the path, and guarantees removal in `__aexit__` regardless of outcome; used by `planning_phase` and `preflight_phase` for their sandbox worktrees | managed_worktree |
|
|
166
|
-
| **`branch_worktree`** | Async context manager in `worktree.py` that creates a named-branch worktree at a given SHA, yields the path, and on exit removes the worktree
|
|
166
|
+
| **`detached_worktree`** | Async context manager in `worktree.py` that creates a detached checkout at a given SHA, yields the path, and guarantees removal in `__aexit__` regardless of outcome; also removes the `.worktrees` parent directory if no other worktrees remain after cleanup; used by `planning_phase` and `preflight_phase` for their sandbox worktrees | managed_worktree |
|
|
167
|
+
| **`branch_worktree`** | Async context manager in `worktree.py` that creates a named-branch worktree at a given SHA, yields the path, and on exit removes the worktree, optionally deletes the branch, and removes the `.worktrees` parent directory if no other worktrees remain; used by `merge_phase` for the merge-sandbox worktree | managed_worktree |
|
|
167
168
|
| **`_agent_worktree`** | Async context manager in `implement.py` that owns the full Implementer and Reviewer worktree lifecycle; accepts a branch name, SHA, `CancellationToken`, and `Deps`; on entry creates the worktree and gitdir overlay; on exit conditionally removes the worktree based on `token.wants_worktree_preserved` and working-tree cleanliness, and always removes the gitdir overlay; used by `run_issue` twice per issue — once for the Implementer (new-branch path) and once for the Reviewer (existing-branch path); defined in `implement.py` not `worktree.py` because its cleanup policy depends on agent-lifecycle state (`CancellationToken`) rather than being unconditional | managed_worktree |
|
|
168
169
|
| **`worktree_name_for_branch`** | Function in `worktree.py` that derives a short directory name from a branch string: extracts `issue-N` from `pycastle/issue-N-slug` or falls back to a sanitised slug; single authoritative definition replacing duplicated regex in `agent_runner` and `merge_phase` | — |
|
|
169
170
|
| **`worktree_path`** | Function in `worktree.py` that constructs the host filesystem path for a named worktree at `<repo_root>/<pycastle_dir>/.worktrees/<name>`; single authoritative path expression replacing duplication across all phase modules | — |
|
|
@@ -193,11 +194,12 @@
|
|
|
193
194
|
| **GithubService** | Service that encapsulates `gh` CLI calls for GitHub issue operations: closing issues, querying parent issues, listing open sub-issues, and reading issue labels | GitHub wrapper, gh provider |
|
|
194
195
|
| **Logger** | Injectable abstraction that owns all structured log output for one iteration; exposes named channels (`log_error`, `log_agent_output`) each writing to a dedicated file under `logs/`; injected via `Deps` so tests never touch the filesystem | log writer, output handler |
|
|
195
196
|
| **RecordingLogger** | Test double for `Logger` that records every call in memory; tests assert on recorded calls rather than capturing stderr or reading log files | mock logger, spy logger |
|
|
196
|
-
| **StatusDisplay** | Injectable abstraction that owns the live terminal status panel and all formatted terminal output; exposes `register(caller, startup_message="started", work_body="")`, `update_phase`, `reset_idle_timer`, `remove(caller, shutdown_message="finished", shutdown_style="success")`, and `print(caller, message, style=None)` methods; backed by a `rich` `Live` display in production and a `PlainStatusDisplay` in tests; injected via `Deps` as a separate concern from `Logger`; defined in `status_display` module | terminal display, status bar |
|
|
197
|
-
| **caller** | The identity string passed as the first argument to `StatusDisplay.register`, `remove`, and `print`; rendered as a `[Caller]` prefix on every terminal output line; empty string `""` is the anonymous caller — no brackets are printed and the message is output as-is; a blank line is inserted before any output call (`register`, `remove`, or `print`) when the caller differs from the previous one,
|
|
198
|
-
| **work_body** | The caller-constructed string passed as the third argument to `register`; displayed in the body column
|
|
199
|
-
| **PlainStatusDisplay** | Plain-terminal adapter for `StatusDisplay` defined in `status_display` module; panel methods (`update_phase`, `reset_idle_timer`) are no-ops; `register` and `remove` print their startup/shutdown messages; `print(caller, message, style=None)` formats output as `[Caller] message` with no ANSI colour codes, no bold, and style ignored; used in tests so assertions can match the full formatted line | NullStatusDisplay |
|
|
200
|
-
| **
|
|
197
|
+
| **StatusDisplay** | Injectable abstraction that owns the live terminal status panel and all formatted terminal output; exposes `register(caller, startup_message="started", work_body="", initial_phase="Setup")`, `update_phase`, `reset_idle_timer`, `remove(caller, shutdown_message="finished", shutdown_style="success")`, and `print(caller, message, style=None)` methods; `shutdown_message` and `message` may contain `\n` — each line is emitted separately with the `[Caller]` prefix and the same style applied to every line; `shutdown_style` accepts `"success"` (green), `"error"` (red), or `"warning"` (yellow); backed by a `rich` `Live` display in production and a `PlainStatusDisplay` in tests; injected via `Deps` as a separate concern from `Logger`; defined in `status_display` module | terminal display, status bar |
|
|
198
|
+
| **caller** | The identity string passed as the first argument to `StatusDisplay.register`, `remove`, and `print`; rendered as a `[Caller]` prefix on every terminal output line; empty string `""` is the anonymous caller — no brackets are printed and the message is output as-is; a blank line is inserted before any output call (`register`, `remove`, or `print`) when the caller differs from the previous one, unconditionally when the caller is `""` (anonymous outputs always stand alone), or before the very first output call (when no previous call has occurred); canonical callers — phase rows: `"Preflight"`, `"Plan"`, `"Implement"`, `"Merge"`; agents: `"Preflight Agent"`, `"Plan Agent"`, `"Implement Agent #N"`, `"Review Agent #N"`, `"Merge Agent"` | source, label |
|
|
199
|
+
| **work_body** | The caller-constructed string passed as the third argument to `register`; applies to agent rows only; displayed in the body column during the Work phase; empty string for agent rows that do not reach Work; unused by phase rows (which use `initial_phase` for their fixed body label) | — |
|
|
200
|
+
| **PlainStatusDisplay** | Plain-terminal adapter for `StatusDisplay` defined in `status_display` module; panel methods (`update_phase`, `reset_idle_timer`) are no-ops; `register` and `remove` print their startup/shutdown messages; `print(caller, message, style=None)` formats output as `[Caller] message` with no ANSI colour codes, no bold, and style ignored; multi-line messages are split and each line prefixed with `[Caller]`; used in tests so assertions can match the full formatted line | NullStatusDisplay |
|
|
201
|
+
| **phase_row** | Async context manager in `iteration/` that owns the `StatusDisplay` register/remove lifecycle for a single phase row; on entry calls `register(caller)`; yields a `PhaseRow` whose `close(shutdown_message, shutdown_style="success")` method calls `remove()` and marks the row as closed; if `close()` is never called before exit (exception path), automatically calls `remove(caller, "failed", shutdown_style="error")`; the canonical way to manage phase row lifecycle — replaces hand-rolled active-flag patterns | — |
|
|
202
|
+
| **status row** | One headerless line in the `StatusDisplay` live panel; created by `register` and removed by `remove`; two kinds: **agent rows** (one per active agent — `"Preflight Agent"`, `"Plan Agent"`, `"Implement Agent #N"`, `"Review Agent #N"`, `"Merge Agent"`) and **phase rows** (one per active phase — `"Preflight"`, `"Plan"`, `"Implement"`, `"Merge"`); phase rows and agent rows within the same phase coexist; format: `elapsed \| Name \| idle \| body`; elapsed is dim and right-justified; name is bold with any numeric part styled bold cyan; idle is dim; body column: for **agent rows**, shows the current agent lifecycle phase name for all non-Work states, or `work_body` during Work; for **phase rows**, shows the fixed `initial_phase` label passed at registration (`"Running"` for Preflight and Implement, `"Planning"` for Plan, `"Merging"` for Merge); elapsed counts up from `register` and never resets; idle resets on each Docker stream chunk; the live panel is preceded by one blank line to visually separate it from scrollback; ordered by orchestration phase (plan → implement → review → merge) then by issue number | agent status row, status entry, agent row |
|
|
201
203
|
| **IterationOutcome** | Sealed return type of `run_iteration()`; one of four variants: `Continue` (iteration completed, keep looping), `Done` (no issues found, stop cleanly), `AbortedHITL` (HITL verdict — carries `issue_number`; orchestrator exits non-zero), `AbortedUsageLimit` (token ceiling hit — worktrees preserved; orchestrator sleeps until 2 minutes past the next local-time full hour, then continues the loop to retry the current issue from scratch; repeats indefinitely on consecutive hits) | iteration result, loop result |
|
|
202
204
|
|
|
203
205
|
## Test Anti-Patterns (Red Flags)
|
|
@@ -231,8 +233,8 @@
|
|
|
231
233
|
- Host mounts per container: host repo → RO at `/home/agent/repo`; worktree → RW at `/home/agent/workspace`; `<host-repo>/.git` → RW at `/.pycastle-parent-git`; on Windows, gitdir overlay → RO over `/home/agent/workspace/.git`.
|
|
232
234
|
- A **Service** defines a Custom exception hierarchy so callers never handle raw subprocess exceptions; tests inject Default implementations from a test fixture and override per-test for error paths.
|
|
233
235
|
- **StatusDisplay** is a separate injectable in `Deps` alongside `Logger`; `Logger` owns file I/O, `StatusDisplay` owns the live terminal UI — they never overlap.
|
|
234
|
-
- Rich markup (e.g. `[red]...[/red]`) must never be embedded in a `StatusDisplay.print` message string; colouring is expressed exclusively via the `style` parameter (`"error"`, `"success"`).
|
|
235
|
-
- A **status row** is created by `StatusDisplay.register` and removed by `StatusDisplay.remove`; phase rows are
|
|
236
|
+
- Rich markup (e.g. `[red]...[/red]`) must never be embedded in a `StatusDisplay.print` message string; colouring is expressed exclusively via the `style` parameter (`"error"`, `"success"`, `"warning"`).
|
|
237
|
+
- A **status row** is created by `StatusDisplay.register` and removed by `StatusDisplay.remove`; phase rows are managed via the **`phase_row`** context manager — registered on entry and removed (with the phase outcome as the shutdown message) via `PhaseRow.close()`; agent rows are registered at container Setup and removed when the agent finishes or errors; the `rich` `Live` display is started on the first `register` call and stopped after the last `remove` call.
|
|
236
238
|
- All orchestrator-level terminal output (e.g. "Planning complete…") is routed through `StatusDisplay.print()` so `rich` can coordinate it with the live panel; bare `print()` calls are not used while a `StatusDisplay` is active.
|
|
237
239
|
- During the Work phase the container runner owns byte chunking, byte-to-line splitting, log writing, and idle timeout detection; it passes the decoded NDJSON line stream and an **`on_turn` callback** to **`process_stream`**, which assembles assistant turns (invoking the callback for each), detects usage limit lines and raises `UsageLimitError` immediately, unwraps the result envelope, and returns a typed `AgentOutput`; phases receive `AgentOutput` directly from `AgentRunner.run()` — no phase calls `parse()` or `assert_complete()`. Setup, Pre-flight, and Prepare phases produce no console output — their activity is reflected only in the body column of the agent status row.
|
|
238
240
|
|
|
@@ -1,151 +1,151 @@
|
|
|
1
|
-
import dataclasses
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
from typing import Any, Protocol
|
|
4
|
-
|
|
5
|
-
from .agent_output_protocol import AgentOutput, AgentRole
|
|
6
|
-
from .agent_result import CancellationToken, PreflightFailure
|
|
7
|
-
from .config import Config
|
|
8
|
-
from .container_runner import ContainerRunner
|
|
9
|
-
from .errors import AgentTimeoutError, UsageLimitError
|
|
10
|
-
from .services import GitService
|
|
11
|
-
from .status_display import PlainStatusDisplay
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@dataclasses.dataclass
|
|
15
|
-
class RunRequest:
|
|
16
|
-
name: str
|
|
17
|
-
prompt_file: Path
|
|
18
|
-
mount_path: Path
|
|
19
|
-
role: AgentRole = AgentRole.IMPLEMENTER
|
|
20
|
-
prompt_args: dict[str, str] | None = None
|
|
21
|
-
skip_preflight: bool = False
|
|
22
|
-
model: str = ""
|
|
23
|
-
effort: str = ""
|
|
24
|
-
stage: str = ""
|
|
25
|
-
token: CancellationToken | None = None
|
|
26
|
-
status_display: Any = None
|
|
27
|
-
issue_title: str = ""
|
|
28
|
-
work_body: str = ""
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
class AgentRunnerProtocol(Protocol):
|
|
32
|
-
async def run(self, request: RunRequest) -> AgentOutput | PreflightFailure: ...
|
|
33
|
-
|
|
34
|
-
async def run_preflight(
|
|
35
|
-
self,
|
|
36
|
-
*,
|
|
37
|
-
name: str,
|
|
38
|
-
mount_path: Path,
|
|
39
|
-
stage: str = "",
|
|
40
|
-
status_display=None,
|
|
41
|
-
work_body: str = "",
|
|
42
|
-
) -> list[tuple[str, str, str]]: ...
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
class AgentRunner:
|
|
46
|
-
def __init__(
|
|
47
|
-
self,
|
|
48
|
-
env: dict[str, str],
|
|
49
|
-
cfg: Config,
|
|
50
|
-
git_service: GitService,
|
|
51
|
-
docker_client=None,
|
|
52
|
-
) -> None:
|
|
53
|
-
self._env = env
|
|
54
|
-
self._cfg = cfg
|
|
55
|
-
self._git_service = git_service
|
|
56
|
-
self._docker_client = docker_client
|
|
57
|
-
|
|
58
|
-
async def run(self, request: RunRequest) -> AgentOutput | PreflightFailure:
|
|
59
|
-
name = request.name
|
|
60
|
-
prompt_file = request.prompt_file
|
|
61
|
-
mount_path = request.mount_path
|
|
62
|
-
prompt_args = request.prompt_args
|
|
63
|
-
skip_preflight = request.skip_preflight
|
|
64
|
-
model = request.model
|
|
65
|
-
effort = request.effort
|
|
66
|
-
token = request.token
|
|
67
|
-
status_display = request.status_display
|
|
68
|
-
work_body = request.work_body
|
|
69
|
-
|
|
70
|
-
if status_display is None:
|
|
71
|
-
status_display = PlainStatusDisplay()
|
|
72
|
-
|
|
73
|
-
_token = token if token is not None else CancellationToken()
|
|
74
|
-
if _token.is_cancelled:
|
|
75
|
-
raise UsageLimitError("Agent cancelled due to usage limit")
|
|
76
|
-
|
|
77
|
-
runner = ContainerRunner(
|
|
78
|
-
name,
|
|
79
|
-
mount_path,
|
|
80
|
-
self._env,
|
|
81
|
-
model=model,
|
|
82
|
-
effort=effort,
|
|
83
|
-
docker_client=self._docker_client,
|
|
84
|
-
status_display=status_display,
|
|
85
|
-
cfg=self._cfg,
|
|
86
|
-
)
|
|
87
|
-
try:
|
|
88
|
-
git_name = self._git_service.get_user_name()
|
|
89
|
-
git_email = self._git_service.get_user_email()
|
|
90
|
-
await runner.setup(git_name, git_email, work_body)
|
|
91
|
-
await runner.prepare(prompt_file, prompt_args or {})
|
|
92
|
-
if not skip_preflight:
|
|
93
|
-
failures = await runner.preflight(list(self._cfg.preflight_checks))
|
|
94
|
-
if failures:
|
|
95
|
-
return PreflightFailure(failures=tuple(failures))
|
|
96
|
-
retries_left = self._cfg.timeout_retries
|
|
97
|
-
while True:
|
|
98
|
-
try:
|
|
99
|
-
output = await runner.work(request.role)
|
|
100
|
-
return output
|
|
101
|
-
except AgentTimeoutError:
|
|
102
|
-
if retries_left <= 0:
|
|
103
|
-
raise
|
|
104
|
-
restart_num = self._cfg.timeout_retries - retries_left + 1
|
|
105
|
-
status_display.print(
|
|
106
|
-
name,
|
|
107
|
-
f"Timeout — restarting"
|
|
108
|
-
f" (attempt {restart_num}/{self._cfg.timeout_retries})",
|
|
109
|
-
)
|
|
110
|
-
retries_left -= 1
|
|
111
|
-
except UsageLimitError:
|
|
112
|
-
_token.cancel(preserve_worktree=True)
|
|
113
|
-
raise
|
|
114
|
-
finally:
|
|
115
|
-
status_display.remove(name)
|
|
116
|
-
try:
|
|
117
|
-
runner.__exit__(None, None, None)
|
|
118
|
-
except Exception:
|
|
119
|
-
pass
|
|
120
|
-
|
|
121
|
-
async def run_preflight(
|
|
122
|
-
self,
|
|
123
|
-
*,
|
|
124
|
-
name: str,
|
|
125
|
-
mount_path: Path,
|
|
126
|
-
stage: str = "",
|
|
127
|
-
status_display=None,
|
|
128
|
-
work_body: str = "",
|
|
129
|
-
) -> list[tuple[str, str, str]]:
|
|
130
|
-
if status_display is None:
|
|
131
|
-
status_display = PlainStatusDisplay()
|
|
132
|
-
|
|
133
|
-
git_name = self._git_service.get_user_name()
|
|
134
|
-
git_email = self._git_service.get_user_email()
|
|
135
|
-
runner = ContainerRunner(
|
|
136
|
-
name,
|
|
137
|
-
mount_path,
|
|
138
|
-
self._env,
|
|
139
|
-
docker_client=self._docker_client,
|
|
140
|
-
status_display=status_display,
|
|
141
|
-
cfg=self._cfg,
|
|
142
|
-
)
|
|
143
|
-
try:
|
|
144
|
-
await runner.setup(git_name, git_email, work_body)
|
|
145
|
-
return await runner.preflight(list(self._cfg.preflight_checks))
|
|
146
|
-
finally:
|
|
147
|
-
status_display.remove(name)
|
|
148
|
-
try:
|
|
149
|
-
runner.__exit__(None, None, None)
|
|
150
|
-
except Exception:
|
|
151
|
-
pass
|
|
1
|
+
import dataclasses
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Protocol
|
|
4
|
+
|
|
5
|
+
from .agent_output_protocol import AgentOutput, AgentRole
|
|
6
|
+
from .agent_result import CancellationToken, PreflightFailure
|
|
7
|
+
from .config import Config
|
|
8
|
+
from .container_runner import ContainerRunner
|
|
9
|
+
from .errors import AgentTimeoutError, UsageLimitError
|
|
10
|
+
from .services import GitService
|
|
11
|
+
from .status_display import PlainStatusDisplay
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclasses.dataclass
|
|
15
|
+
class RunRequest:
|
|
16
|
+
name: str
|
|
17
|
+
prompt_file: Path
|
|
18
|
+
mount_path: Path
|
|
19
|
+
role: AgentRole = AgentRole.IMPLEMENTER
|
|
20
|
+
prompt_args: dict[str, str] | None = None
|
|
21
|
+
skip_preflight: bool = False
|
|
22
|
+
model: str = ""
|
|
23
|
+
effort: str = ""
|
|
24
|
+
stage: str = ""
|
|
25
|
+
token: CancellationToken | None = None
|
|
26
|
+
status_display: Any = None
|
|
27
|
+
issue_title: str = ""
|
|
28
|
+
work_body: str = ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AgentRunnerProtocol(Protocol):
|
|
32
|
+
async def run(self, request: RunRequest) -> AgentOutput | PreflightFailure: ...
|
|
33
|
+
|
|
34
|
+
async def run_preflight(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
name: str,
|
|
38
|
+
mount_path: Path,
|
|
39
|
+
stage: str = "",
|
|
40
|
+
status_display=None,
|
|
41
|
+
work_body: str = "",
|
|
42
|
+
) -> list[tuple[str, str, str]]: ...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AgentRunner:
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
env: dict[str, str],
|
|
49
|
+
cfg: Config,
|
|
50
|
+
git_service: GitService,
|
|
51
|
+
docker_client=None,
|
|
52
|
+
) -> None:
|
|
53
|
+
self._env = env
|
|
54
|
+
self._cfg = cfg
|
|
55
|
+
self._git_service = git_service
|
|
56
|
+
self._docker_client = docker_client
|
|
57
|
+
|
|
58
|
+
async def run(self, request: RunRequest) -> AgentOutput | PreflightFailure:
|
|
59
|
+
name = request.name
|
|
60
|
+
prompt_file = request.prompt_file
|
|
61
|
+
mount_path = request.mount_path
|
|
62
|
+
prompt_args = request.prompt_args
|
|
63
|
+
skip_preflight = request.skip_preflight
|
|
64
|
+
model = request.model
|
|
65
|
+
effort = request.effort
|
|
66
|
+
token = request.token
|
|
67
|
+
status_display = request.status_display
|
|
68
|
+
work_body = request.work_body
|
|
69
|
+
|
|
70
|
+
if status_display is None:
|
|
71
|
+
status_display = PlainStatusDisplay()
|
|
72
|
+
|
|
73
|
+
_token = token if token is not None else CancellationToken()
|
|
74
|
+
if _token.is_cancelled:
|
|
75
|
+
raise UsageLimitError("Agent cancelled due to usage limit")
|
|
76
|
+
|
|
77
|
+
runner = ContainerRunner(
|
|
78
|
+
name,
|
|
79
|
+
mount_path,
|
|
80
|
+
self._env,
|
|
81
|
+
model=model,
|
|
82
|
+
effort=effort,
|
|
83
|
+
docker_client=self._docker_client,
|
|
84
|
+
status_display=status_display,
|
|
85
|
+
cfg=self._cfg,
|
|
86
|
+
)
|
|
87
|
+
try:
|
|
88
|
+
git_name = self._git_service.get_user_name()
|
|
89
|
+
git_email = self._git_service.get_user_email()
|
|
90
|
+
await runner.setup(git_name, git_email, work_body)
|
|
91
|
+
await runner.prepare(prompt_file, prompt_args or {})
|
|
92
|
+
if not skip_preflight:
|
|
93
|
+
failures = await runner.preflight(list(self._cfg.preflight_checks))
|
|
94
|
+
if failures:
|
|
95
|
+
return PreflightFailure(failures=tuple(failures))
|
|
96
|
+
retries_left = self._cfg.timeout_retries
|
|
97
|
+
while True:
|
|
98
|
+
try:
|
|
99
|
+
output = await runner.work(request.role)
|
|
100
|
+
return output
|
|
101
|
+
except AgentTimeoutError:
|
|
102
|
+
if retries_left <= 0:
|
|
103
|
+
raise
|
|
104
|
+
restart_num = self._cfg.timeout_retries - retries_left + 1
|
|
105
|
+
status_display.print(
|
|
106
|
+
name,
|
|
107
|
+
f"Timeout — restarting"
|
|
108
|
+
f" (attempt {restart_num}/{self._cfg.timeout_retries})",
|
|
109
|
+
)
|
|
110
|
+
retries_left -= 1
|
|
111
|
+
except UsageLimitError:
|
|
112
|
+
_token.cancel(preserve_worktree=True)
|
|
113
|
+
raise
|
|
114
|
+
finally:
|
|
115
|
+
status_display.remove(name)
|
|
116
|
+
try:
|
|
117
|
+
runner.__exit__(None, None, None)
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
async def run_preflight(
|
|
122
|
+
self,
|
|
123
|
+
*,
|
|
124
|
+
name: str,
|
|
125
|
+
mount_path: Path,
|
|
126
|
+
stage: str = "",
|
|
127
|
+
status_display=None,
|
|
128
|
+
work_body: str = "",
|
|
129
|
+
) -> list[tuple[str, str, str]]:
|
|
130
|
+
if status_display is None:
|
|
131
|
+
status_display = PlainStatusDisplay()
|
|
132
|
+
|
|
133
|
+
git_name = self._git_service.get_user_name()
|
|
134
|
+
git_email = self._git_service.get_user_email()
|
|
135
|
+
runner = ContainerRunner(
|
|
136
|
+
name,
|
|
137
|
+
mount_path,
|
|
138
|
+
self._env,
|
|
139
|
+
docker_client=self._docker_client,
|
|
140
|
+
status_display=status_display,
|
|
141
|
+
cfg=self._cfg,
|
|
142
|
+
)
|
|
143
|
+
try:
|
|
144
|
+
await runner.setup(git_name, git_email, work_body)
|
|
145
|
+
return await runner.preflight(list(self._cfg.preflight_checks))
|
|
146
|
+
finally:
|
|
147
|
+
status_display.remove(name)
|
|
148
|
+
try:
|
|
149
|
+
runner.__exit__(None, None, None)
|
|
150
|
+
except Exception:
|
|
151
|
+
pass
|
|
@@ -4,6 +4,7 @@ from typing import TypeAlias
|
|
|
4
4
|
from ..agent_result import CancellationToken, PreflightFailure
|
|
5
5
|
from ..worktree import worktree_name_for_branch, worktree_path
|
|
6
6
|
from ._deps import Deps
|
|
7
|
+
from ._phase_row import PhaseRow, phase_row
|
|
7
8
|
from .implement import branch_for, implement_phase
|
|
8
9
|
from .merge import merge_phase
|
|
9
10
|
from .preflight import PreflightAFK, PreflightHITL, PreflightReady, preflight_phase
|
|
@@ -42,7 +43,7 @@ def _is_in_flight(issue: dict, deps: Deps) -> bool:
|
|
|
42
43
|
|
|
43
44
|
|
|
44
45
|
async def run_iteration(deps: Deps) -> IterationOutcome:
|
|
45
|
-
deps.status_display.register("Preflight")
|
|
46
|
+
deps.status_display.register("Preflight", initial_phase="Running")
|
|
46
47
|
try:
|
|
47
48
|
preflight_result = await preflight_phase(deps)
|
|
48
49
|
finally:
|
|
@@ -50,7 +51,7 @@ async def run_iteration(deps: Deps) -> IterationOutcome:
|
|
|
50
51
|
|
|
51
52
|
if isinstance(preflight_result, PreflightHITL):
|
|
52
53
|
deps.status_display.print(
|
|
53
|
-
"",
|
|
54
|
+
"Preflight",
|
|
54
55
|
f"Preflight issue #{preflight_result.issue_number} requires human intervention. Exiting.",
|
|
55
56
|
)
|
|
56
57
|
return AbortedHITL(issue_number=preflight_result.issue_number)
|
|
@@ -64,13 +65,19 @@ async def run_iteration(deps: Deps) -> IterationOutcome:
|
|
|
64
65
|
if in_flight:
|
|
65
66
|
issues = in_flight
|
|
66
67
|
elif len(open_issues) >= 2:
|
|
67
|
-
deps.status_display
|
|
68
|
-
try:
|
|
68
|
+
async with phase_row(deps.status_display, "Plan", initial_phase="Planning") as row:
|
|
69
69
|
plan_result = await planning_phase(deps, sha, open_issues)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
issue_lines = [
|
|
71
|
+
f" #{i['number']}: {i['title']} → {branch_for(i['number'])}"
|
|
72
|
+
for i in plan_result.issues
|
|
73
|
+
]
|
|
74
|
+
row.close(
|
|
75
|
+
"\n".join(
|
|
76
|
+
[f"Planning complete, {len(plan_result.issues)} issue(s):"] + issue_lines
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
sha = plan_result.worktree_sha
|
|
80
|
+
issues = plan_result.issues
|
|
74
81
|
else:
|
|
75
82
|
issues = open_issues
|
|
76
83
|
elif isinstance(preflight_result, PreflightAFK):
|
|
@@ -79,53 +86,44 @@ async def run_iteration(deps: Deps) -> IterationOutcome:
|
|
|
79
86
|
|
|
80
87
|
issues = issues[: deps.cfg.max_parallel]
|
|
81
88
|
|
|
82
|
-
deps.status_display.print("", f"Planning complete. {len(issues)} issue(s):")
|
|
83
|
-
for issue in issues:
|
|
84
|
-
deps.status_display.print(
|
|
85
|
-
"",
|
|
86
|
-
f" #{issue['number']}: {issue['title']} → {branch_for(issue['number'])}",
|
|
87
|
-
)
|
|
88
|
-
|
|
89
89
|
token = CancellationToken()
|
|
90
|
-
deps.status_display
|
|
91
|
-
try:
|
|
90
|
+
async with phase_row(deps.status_display, "Implement", initial_phase="Running") as row:
|
|
92
91
|
impl_result = await implement_phase(issues, sha, deps, token=token)
|
|
93
|
-
finally:
|
|
94
|
-
deps.status_display.remove("Implement")
|
|
95
92
|
|
|
96
|
-
|
|
97
|
-
|
|
93
|
+
if impl_result.usage_limit_hit:
|
|
94
|
+
row.close("finished")
|
|
95
|
+
return AbortedUsageLimit()
|
|
98
96
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
deps.status_display.print(
|
|
103
|
-
"",
|
|
104
|
-
f" ✗ #{issue['number']} ({branch_for(issue['number'])}) pre-flight failed:",
|
|
105
|
-
)
|
|
106
|
-
for check_name, command, output in fs:
|
|
97
|
+
for issue, error in impl_result.errors:
|
|
98
|
+
match error:
|
|
99
|
+
case PreflightFailure(failures=fs):
|
|
107
100
|
deps.status_display.print(
|
|
108
|
-
"",
|
|
109
|
-
f"
|
|
101
|
+
"Implement",
|
|
102
|
+
f" ✗ #{issue['number']} ({branch_for(issue['number'])}) pre-flight failed:",
|
|
103
|
+
)
|
|
104
|
+
for check_name, command, output in fs:
|
|
105
|
+
deps.status_display.print(
|
|
106
|
+
"Implement",
|
|
107
|
+
f" ✗ {check_name} ({command}): {output}",
|
|
108
|
+
)
|
|
109
|
+
case _:
|
|
110
|
+
deps.status_display.print(
|
|
111
|
+
"Implement",
|
|
112
|
+
f" ✗ #{issue['number']} ({branch_for(issue['number'])}) failed: {error}",
|
|
110
113
|
)
|
|
111
|
-
case _:
|
|
112
|
-
deps.status_display.print(
|
|
113
|
-
"",
|
|
114
|
-
f" ✗ #{issue['number']} ({branch_for(issue['number'])}) failed: {error}",
|
|
115
|
-
)
|
|
116
114
|
|
|
117
|
-
|
|
115
|
+
completed = impl_result.completed
|
|
118
116
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
117
|
+
if not completed:
|
|
118
|
+
row.close("No commits produced. Nothing to merge.", shutdown_style="warning")
|
|
119
|
+
return Continue()
|
|
122
120
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
121
|
+
branch_lines = [f" {branch_for(i['number'])}" for i in completed]
|
|
122
|
+
row.close(
|
|
123
|
+
"\n".join(
|
|
124
|
+
[f"Execution complete, {len(completed)} branch(es) with commits:"] + branch_lines
|
|
125
|
+
)
|
|
126
|
+
)
|
|
129
127
|
|
|
130
128
|
await merge_phase(completed, deps)
|
|
131
129
|
|
|
@@ -34,8 +34,8 @@ class RecordingStatusDisplay:
|
|
|
34
34
|
def __init__(self) -> None:
|
|
35
35
|
self.calls: list[tuple] = []
|
|
36
36
|
|
|
37
|
-
def register(self, caller: str, startup_message: str = "started", work_body: str = "") -> None:
|
|
38
|
-
self.calls.append(("register", caller, startup_message,
|
|
37
|
+
def register(self, caller: str, startup_message: str = "started", work_body: str = "", initial_phase: str = "Setup") -> None:
|
|
38
|
+
self.calls.append(("register", caller, startup_message, initial_phase))
|
|
39
39
|
|
|
40
40
|
def update_phase(self, name: str, phase: str) -> None:
|
|
41
41
|
self.calls.append(("update_phase", name, phase))
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from collections.abc import AsyncGenerator
|
|
2
|
+
from contextlib import asynccontextmanager
|
|
3
|
+
|
|
4
|
+
from ..status_display import StatusDisplay
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PhaseRow:
|
|
8
|
+
def __init__(self, status_display: StatusDisplay, caller: str) -> None:
|
|
9
|
+
self._status_display = status_display
|
|
10
|
+
self._caller = caller
|
|
11
|
+
self._closed = False
|
|
12
|
+
|
|
13
|
+
def close(self, shutdown_message: str, shutdown_style: str = "success") -> None:
|
|
14
|
+
if self._closed:
|
|
15
|
+
return
|
|
16
|
+
self._status_display.remove(self._caller, shutdown_message, shutdown_style)
|
|
17
|
+
self._closed = True
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@asynccontextmanager
|
|
21
|
+
async def phase_row(
|
|
22
|
+
status_display: StatusDisplay, caller: str, initial_phase: str = "Setup"
|
|
23
|
+
) -> AsyncGenerator[PhaseRow, None]:
|
|
24
|
+
status_display.register(caller, initial_phase=initial_phase)
|
|
25
|
+
row = PhaseRow(status_display, caller)
|
|
26
|
+
try:
|
|
27
|
+
yield row
|
|
28
|
+
finally:
|
|
29
|
+
if not row._closed:
|
|
30
|
+
status_display.remove(caller, "failed", shutdown_style="error")
|
|
@@ -3,13 +3,14 @@ import asyncio
|
|
|
3
3
|
from ._deps import Deps
|
|
4
4
|
|
|
5
5
|
|
|
6
|
-
async def _wait_for_clean_working_tree(deps: Deps, phase: str = "
|
|
6
|
+
async def _wait_for_clean_working_tree(deps: Deps, caller: str, phase: str = "") -> None:
|
|
7
7
|
if deps.git_svc.is_working_tree_clean(deps.repo_root):
|
|
8
8
|
return
|
|
9
|
+
phase_name = phase or caller.lower()
|
|
9
10
|
deps.status_display.print(
|
|
10
|
-
|
|
11
|
+
caller,
|
|
11
12
|
"Working tree has uncommitted changes. "
|
|
12
|
-
f"Please commit or revert all local changes before the {
|
|
13
|
+
f"Please commit or revert all local changes before the {phase_name} phase can proceed.",
|
|
13
14
|
style="error",
|
|
14
15
|
)
|
|
15
16
|
while not deps.git_svc.is_working_tree_clean(deps.repo_root):
|