pycastle 0.1.3.8.dev0__tar.gz → 0.1.3.9.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.8.dev0 → pycastle-0.1.3.9.dev0}/CONTEXT.md +14 -12
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/PKG-INFO +1 -1
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/agent_output_protocol.py +78 -39
- pycastle-0.1.3.9.dev0/src/pycastle/agent_runner.py +151 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/container_runner.py +18 -39
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/iteration/__init__.py +9 -9
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/iteration/_deps.py +4 -3
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/iteration/_utils.py +4 -3
- pycastle-0.1.3.9.dev0/src/pycastle/iteration/implement.py +179 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/iteration/merge.py +4 -4
- pycastle-0.1.3.9.dev0/src/pycastle/iteration/planning.py +48 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/iteration/preflight.py +13 -8
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/orchestrator.py +35 -34
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/rich_status_display.py +18 -5
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/status_display.py +52 -43
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle.egg-info/PKG-INFO +1 -1
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle.egg-info/SOURCES.txt +0 -2
- pycastle-0.1.3.9.dev0/tests/test_agent_output_protocol.py +571 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/test_agent_runner.py +39 -221
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/test_container_runner.py +129 -75
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/test_deps.py +19 -14
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/test_implement.py +263 -30
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/test_iteration.py +37 -30
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/test_merge.py +21 -22
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/test_orchestrator.py +335 -221
- pycastle-0.1.3.9.dev0/tests/test_plain_status_display.py +267 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/test_planning.py +27 -17
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/test_preflight.py +77 -15
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/test_status_display.py +213 -4
- pycastle-0.1.3.8.dev0/src/pycastle/agent_runner.py +0 -215
- pycastle-0.1.3.8.dev0/src/pycastle/iteration/implement.py +0 -126
- pycastle-0.1.3.8.dev0/src/pycastle/iteration/planning.py +0 -45
- pycastle-0.1.3.8.dev0/src/pycastle/stream_parser.py +0 -22
- pycastle-0.1.3.8.dev0/tests/test_agent_output_protocol.py +0 -388
- pycastle-0.1.3.8.dev0/tests/test_plain_status_display.py +0 -5
- pycastle-0.1.3.8.dev0/tests/test_stream_parser.py +0 -222
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/.github/workflows/publish.yml +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/.gitignore +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/.python-version +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/CLAUDE.md +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/LICENSE +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/README.md +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/docs/adr/0001-runtime-dependency-installation.md +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/docs/agents/domain.md +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/docs/agents/issue-tracker.md +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/docs/agents/triage-labels.md +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/pyproject.toml +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/setup.cfg +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__init__.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__pycache__/__init__.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__pycache__/_types.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__pycache__/agent_output_protocol.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__pycache__/agent_result.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__pycache__/agent_runner.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__pycache__/build_command.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__pycache__/container_runner.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__pycache__/errors.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__pycache__/init_command.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__pycache__/labels.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__pycache__/main.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__pycache__/orchestrator.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__pycache__/prompt_pipeline.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__pycache__/prompt_utils.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__pycache__/rich_status_display.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__pycache__/stream_parser.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/__pycache__/worktree.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/_types.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/agent_result.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/build_command.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/config/__init__.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/config/__pycache__/__init__.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/config/__pycache__/loader.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/config/__pycache__/validator.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/config/loader.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/config/validator.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/defaults/.gitignore +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/defaults/Dockerfile +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/defaults/config.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/defaults/prompts/coding-standards/deep-modules.md +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/defaults/prompts/coding-standards/interfaces.md +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/defaults/prompts/coding-standards/mocking.md +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/defaults/prompts/coding-standards/refactoring.md +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/defaults/prompts/coding-standards/tests.md +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/defaults/prompts/implement-prompt.md +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/defaults/prompts/merge-prompt.md +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/defaults/prompts/plan-prompt.md +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/defaults/prompts/preflight-issue.md +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/defaults/prompts/review-prompt.md +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/errors.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/init_command.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/iteration/__pycache__/__init__.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/iteration/__pycache__/_deps.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/iteration/__pycache__/_utils.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/iteration/__pycache__/implement.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/iteration/__pycache__/merge.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/iteration/__pycache__/planning.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/iteration/__pycache__/preflight.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/labels.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/main.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/prompt_pipeline.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/prompt_utils.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/services/__init__.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/services/__pycache__/__init__.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/services/__pycache__/_base.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/services/__pycache__/claude_service.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/services/__pycache__/docker_service.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/services/__pycache__/git_service.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/services/__pycache__/github_service.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/services/_base.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/services/claude_service.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/services/docker_service.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/services/git_service.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/services/github_service.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle/worktree.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle.egg-info/dependency_links.txt +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle.egg-info/entry_points.txt +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle.egg-info/requires.txt +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/src/pycastle.egg-info/top_level.txt +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__init__.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/__init__.cpython-311.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/conftest.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_agent_output_protocol.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_agent_result.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_agent_runner.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_build_command.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_claude_service.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_config_new.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_container_runner.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_default_prompts.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_deps.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_docker_service.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_errors.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_git_service.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_github_service.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_implement.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_init_command.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_integration.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_iteration.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_labels.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_main.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_merge.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_orchestrator.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_plan.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_planning.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_preflight.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_prompt_pipeline.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_prompt_utils.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_status_display.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_stream_parser.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_subprocess_service.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/__pycache__/test_worktree.cpython-311-pytest-9.0.3.pyc +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/conftest.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/test_agent_result.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/test_build_command.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/test_claude_service.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/test_config_new.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/test_default_prompts.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/test_docker_service.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/test_errors.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/test_git_service.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/test_github_service.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/test_init_command.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/test_integration.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/test_labels.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/test_main.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/test_plan.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/test_prompt_pipeline.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/test_prompt_utils.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/test_subprocess_service.py +0 -0
- {pycastle-0.1.3.8.dev0 → pycastle-0.1.3.9.dev0}/tests/test_worktree.py +0 -0
|
@@ -109,14 +109,14 @@
|
|
|
109
109
|
|
|
110
110
|
| Term | Definition | Aliases to avoid |
|
|
111
111
|
| --- | --- | --- |
|
|
112
|
-
| **agent output protocol** | The contract between prompts and the orchestrator: the set of XML tags agents emit to signal structured output (`<plan>`, `<issue>`, `<promise>`), plus the module that owns
|
|
112
|
+
| **agent output protocol** | The contract between prompts and the orchestrator: the set of XML tags agents emit to signal structured output (`<plan>`, `<issue>`, `<promise>`), plus the module that owns the complete NDJSON stream → typed output pipeline | output format, agent tags, agent signals |
|
|
113
113
|
| **`<plan>` tag** | XML tag emitted by the Planner containing a JSON payload listing unblocked issues for the current iteration; extracted by the agent output protocol module | plan output, plan block |
|
|
114
114
|
| **`<issue>` tag** | XML tag emitted by the preflight-issue agent containing the GitHub issue number it filed; extracted by the agent output protocol module | issue output, issue number tag |
|
|
115
115
|
| **`<promise>COMPLETE</promise>`** | XML tag emitted by Implementers, Reviewers, and the Merger to declare that their work phase is complete; detected by the agent output protocol module | done signal, completion tag |
|
|
116
116
|
| **`AgentOutputProtocolError`** | Base exception raised by the agent output protocol module when a required tag is missing or malformed; subclassed by `PlanParseError`, `IssueParseError`, and `PromiseParseError` | parse error, protocol error |
|
|
117
|
-
| **`
|
|
118
|
-
| **`
|
|
119
|
-
| **Claude streaming envelope** | The NDJSON format Claude Code uses for structured output; lines are JSON objects and the agent's final result is carried in the `{"type": "result", "result": "..."}` line; unwrapped internally by
|
|
117
|
+
| **`process_stream()`** | Single entry point in the agent output protocol module; accepts an iterable of decoded NDJSON lines, an `on_turn` callback, an `AgentRole`, and `usage_limit_patterns`; drives the per-line loop, emits complete assistant turns via the callback, raises `UsageLimitError` immediately on detection, unwraps the result envelope, and returns a typed `AgentOutput`; the container runner is the only caller — phases never call it directly | protocol entry point, stream processor |
|
|
118
|
+
| **`on_turn` callback** | A `Callable[[str], None]` passed to `process_stream` by the container runner; invoked once per complete assistant turn during the Work phase; constructed by the container runner as a lambda over `StatusDisplay.print` so the agent output protocol module has no dependency on `StatusDisplay` | turn callback, display hook |
|
|
119
|
+
| **Claude streaming envelope** | The NDJSON format Claude Code uses for structured output; lines are JSON objects and the agent's final result is carried in the `{"type": "result", "result": "..."}` line; unwrapped internally by `process_stream` before tag extraction | streaming format, NDJSON output |
|
|
120
120
|
|
|
121
121
|
## Agent Lifecycle
|
|
122
122
|
|
|
@@ -143,7 +143,7 @@
|
|
|
143
143
|
| Term | Definition | Aliases to avoid |
|
|
144
144
|
| --- | --- | --- |
|
|
145
145
|
| **Dockerfile** | File in the pycastle directory defining the Docker image for agent containers — ships without baked-in credentials and without baked-in dev tools; system utilities (git, gh), Claude Code CLI, and the Python runtime are the only baked-in contents; all dev tools (e.g. ruff, mypy, pytest) must be declared in the consuming project's dependency file and are installed at runtime during the Setup phase | image definition |
|
|
146
|
-
| **container runner** | Package module that manages Docker container lifecycle, injects runtime secrets, and drives the four agent lifecycle phases (Setup, Pre-flight, Prepare, Work) via instance methods; holds `status_display` at construction time so phase methods can update terminal state without caller involvement | docker wrapper |
|
|
146
|
+
| **container runner** | Package module that manages Docker container lifecycle, injects runtime secrets, and drives the four agent lifecycle phases (Setup, Pre-flight, Prepare, Work) via instance methods; holds `status_display` at construction time so phase methods can update terminal state without caller involvement; during the Work phase owns Docker byte chunking, byte-to-line splitting, log writing, and idle timeout detection, then delegates the line stream to `process_stream` | docker wrapper |
|
|
147
147
|
| **host repo** | The git repository on the developer's machine that is mounted into each agent container | project repo, local repo |
|
|
148
148
|
| **volume mount** | A Docker bind mount attaching a host filesystem path to a container-internal path, with an explicit read/write mode | bind mount, volume |
|
|
149
149
|
| **RO mount** | A volume mount with `mode: "ro"` — the container cannot write to it; used for the host repo | read-only mount |
|
|
@@ -158,10 +158,11 @@
|
|
|
158
158
|
| **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 |
|
|
159
159
|
| **`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 |
|
|
160
160
|
| **`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 and optionally deletes the branch; used by `merge_phase` for the merge-sandbox worktree | managed_worktree |
|
|
161
|
+
| **`_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 |
|
|
161
162
|
| **`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` | — |
|
|
162
163
|
| **`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 | — |
|
|
163
164
|
| **runtime injection** | The act of reading `~/.claude.json` from the host and writing it to `/home/agent/.claude.json` inside a container before the agent runs | baking in, build-time config |
|
|
164
|
-
| **StreamParser** |
|
|
165
|
+
| **StreamParser** | Retired — its assistant-turn assembly logic is now a private implementation detail of `process_stream` in the agent output protocol module; `stream_parser.py` no longer exists as a public module | stream processor, message parser |
|
|
165
166
|
| **agent message** | The text content emitted by an agent during a single assistant turn; excludes tool-use and tool-result blocks; during the Work phase, printed to the console prefixed with the agent name and followed by a blank line; not shown in the status panel | assistant message, agent output |
|
|
166
167
|
| **PycastleError** | Base exception class for all pycastle domain errors | — |
|
|
167
168
|
| **DockerError** | Error raised when a Docker operation (container start, stop, remove) fails | container error |
|
|
@@ -187,11 +188,11 @@
|
|
|
187
188
|
| **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 |
|
|
188
189
|
| **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 |
|
|
189
190
|
| **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 |
|
|
190
|
-
| **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
|
|
191
|
+
| **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, or unconditionally when the caller is `""` (anonymous outputs always stand alone); canonical callers — phase rows: `"Preflight"`, `"Plan"`, `"Implement"`, `"Merge"`; agents: `"Preflight Agent"`, `"Plan Agent"`, `"Implement Agent #N"`, `"Review Agent #N"`, `"Merge Agent"` | source, label |
|
|
191
192
|
| **work_body** | The caller-constructed string passed as the third argument to `register`; displayed in the body column of the status row during the Work phase; empty string for callers that do not reach Work | — |
|
|
192
|
-
| **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 and style ignored; used in tests so assertions can match the full formatted line | NullStatusDisplay |
|
|
193
|
+
| **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 |
|
|
193
194
|
| **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 shows the current lifecycle phase name for all non-Work states, or the `work_body` string during Work; 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 |
|
|
194
|
-
| **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,
|
|
195
|
+
| **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 |
|
|
195
196
|
|
|
196
197
|
## Test Anti-Patterns (Red Flags)
|
|
197
198
|
|
|
@@ -217,14 +218,15 @@
|
|
|
217
218
|
- The **Planner** and all **Implementer** worktrees are created from the pinned **safe SHA**, never from HEAD directly; this guarantees every agent sees the same verified-clean committed state regardless of external commits that land on main after preflight passes.
|
|
218
219
|
- In **sequential mode** (`max_parallel = 1`), the iteration processes issues one by one: after each issue's merge the safe SHA is re-pinned to the new HEAD, and the next Implementer starts from that SHA; a failed issue is skipped (remains `ready-for-agent`) and the queue continues; the Merger remains available as a fallback for unexpected conflicts; no additional pre-flight checks run between issues.
|
|
219
220
|
- The **Pre-flight phase** (agent lifecycle) runs quality checks inside the container and returns a list of failure tuples to the orchestrator; it never spawns agents internally.
|
|
220
|
-
- An **orphan sweep** runs once at orchestrator startup; **collision detection**
|
|
221
|
-
- **`detached_worktree`** is used by `planning_phase` (for the plan-sandbox worktree) and `preflight_phase` (for the pre-flight-sandbox worktree); **`branch_worktree`** is used by `merge_phase` (for the merge-sandbox worktree);
|
|
221
|
+
- An **orphan sweep** runs once at orchestrator startup; **collision detection** uses a per-branch `asyncio.Lock` held in `implement_phase` for the full duration of each `run_issue` call — from first worktree creation to final worktree teardown.
|
|
222
|
+
- **`detached_worktree`** is used by `planning_phase` (for the plan-sandbox worktree) and `preflight_phase` (for the pre-flight-sandbox worktree); **`branch_worktree`** is used by `merge_phase` (for the merge-sandbox worktree); **`_agent_worktree`** is used by `run_issue` in `implement.py` for Implementer and Reviewer worktrees — its cleanup is conditional on cancellation state, unlike the unconditional teardown in `detached_worktree` and `branch_worktree`. **`worktree_path`** and **`worktree_name_for_branch`** are the single authoritative path and name expressions used by all of the above.
|
|
222
223
|
- 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`.
|
|
223
224
|
- 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.
|
|
224
225
|
- **StatusDisplay** is a separate injectable in `Deps` alongside `Logger`; `Logger` owns file I/O, `StatusDisplay` owns the live terminal UI — they never overlap.
|
|
226
|
+
- 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"`).
|
|
225
227
|
- A **status row** is created by `StatusDisplay.register` and removed by `StatusDisplay.remove`; phase rows are registered at the start of each orchestration phase and removed at its end; 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.
|
|
226
228
|
- 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.
|
|
227
|
-
-
|
|
229
|
+
- 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.
|
|
228
230
|
|
|
229
231
|
## Example dialogue
|
|
230
232
|
|
|
@@ -2,7 +2,10 @@ import dataclasses
|
|
|
2
2
|
import enum
|
|
3
3
|
import json
|
|
4
4
|
import re
|
|
5
|
-
from
|
|
5
|
+
from collections.abc import Callable, Iterable
|
|
6
|
+
from typing import TypeAlias
|
|
7
|
+
|
|
8
|
+
from .errors import UsageLimitError
|
|
6
9
|
|
|
7
10
|
|
|
8
11
|
class AgentRole(enum.Enum):
|
|
@@ -48,21 +51,6 @@ class PromiseParseError(AgentOutputProtocolError):
|
|
|
48
51
|
pass
|
|
49
52
|
|
|
50
53
|
|
|
51
|
-
def _unwrap(output: str) -> str:
|
|
52
|
-
for line in output.splitlines():
|
|
53
|
-
line = line.strip()
|
|
54
|
-
if not line:
|
|
55
|
-
continue
|
|
56
|
-
try:
|
|
57
|
-
obj = json.loads(line)
|
|
58
|
-
except json.JSONDecodeError:
|
|
59
|
-
continue
|
|
60
|
-
if isinstance(obj, dict) and obj.get("type") == "result":
|
|
61
|
-
result = obj.get("result")
|
|
62
|
-
return result if isinstance(result, str) else output
|
|
63
|
-
return output
|
|
64
|
-
|
|
65
|
-
|
|
66
54
|
def _extract_planner_output(text: str) -> PlannerOutput:
|
|
67
55
|
match = re.search(r"<plan>([\s\S]*?)</plan>", text)
|
|
68
56
|
if not match:
|
|
@@ -111,20 +99,80 @@ def _extract_issue_output(text: str) -> IssueOutput:
|
|
|
111
99
|
return IssueOutput(labels=labels, number=number)
|
|
112
100
|
|
|
113
101
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
102
|
+
def _is_usage_limit_line(line: str, patterns: tuple[str, ...]) -> bool:
|
|
103
|
+
try:
|
|
104
|
+
obj = json.loads(line)
|
|
105
|
+
if isinstance(obj, dict):
|
|
106
|
+
if obj.get("type") == "result" and obj.get("is_error"):
|
|
107
|
+
if obj.get("api_error_status") == 429:
|
|
108
|
+
return True
|
|
109
|
+
result_text = obj.get("result")
|
|
110
|
+
if isinstance(result_text, str) and any(
|
|
111
|
+
p.lower() in result_text.lower() for p in patterns
|
|
112
|
+
):
|
|
113
|
+
return True
|
|
114
|
+
return False
|
|
115
|
+
except json.JSONDecodeError:
|
|
116
|
+
pass
|
|
117
|
+
line_lower = line.lower()
|
|
118
|
+
return any(p.lower() in line_lower for p in patterns)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _extract_turn(line: str) -> str | None:
|
|
122
|
+
try:
|
|
123
|
+
obj = json.loads(line)
|
|
124
|
+
except json.JSONDecodeError:
|
|
125
|
+
return None
|
|
126
|
+
if not isinstance(obj, dict) or obj.get("type") != "assistant":
|
|
127
|
+
return None
|
|
128
|
+
content = (obj.get("message") or {}).get("content") or []
|
|
129
|
+
parts: list[str] = []
|
|
130
|
+
for block in content:
|
|
131
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
132
|
+
text = (block.get("text") or "").strip()
|
|
133
|
+
if text:
|
|
134
|
+
parts.append(text)
|
|
135
|
+
return "\n\n".join(parts) if parts else None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def process_stream(
|
|
139
|
+
lines: Iterable[str],
|
|
140
|
+
on_turn: Callable[[str], None],
|
|
141
|
+
role: AgentRole,
|
|
142
|
+
usage_limit_patterns: tuple[str, ...],
|
|
143
|
+
) -> AgentOutput:
|
|
144
|
+
collected: list[str] = []
|
|
145
|
+
result_text: str | None = None
|
|
146
|
+
for line in lines:
|
|
147
|
+
collected.append(line)
|
|
148
|
+
if _is_usage_limit_line(line, usage_limit_patterns):
|
|
149
|
+
raise UsageLimitError(line)
|
|
150
|
+
turn = _extract_turn(line)
|
|
151
|
+
if turn is not None:
|
|
152
|
+
on_turn(turn)
|
|
153
|
+
if role in (AgentRole.IMPLEMENTER, AgentRole.REVIEWER, AgentRole.MERGER):
|
|
154
|
+
if re.search(r"<promise>COMPLETE</promise>", turn):
|
|
155
|
+
return CompletionOutput()
|
|
156
|
+
elif role == AgentRole.PLANNER:
|
|
157
|
+
try:
|
|
158
|
+
return _extract_planner_output(turn)
|
|
159
|
+
except PlanParseError:
|
|
160
|
+
pass
|
|
161
|
+
elif role == AgentRole.PREFLIGHT_ISSUE:
|
|
162
|
+
try:
|
|
163
|
+
return _extract_issue_output(turn)
|
|
164
|
+
except IssueParseError:
|
|
165
|
+
pass
|
|
166
|
+
try:
|
|
167
|
+
obj = json.loads(line)
|
|
168
|
+
except json.JSONDecodeError:
|
|
169
|
+
continue
|
|
170
|
+
if isinstance(obj, dict) and obj.get("type") == "result":
|
|
171
|
+
r = obj.get("result")
|
|
172
|
+
if isinstance(r, str):
|
|
173
|
+
result_text = r
|
|
174
|
+
break
|
|
175
|
+
text = result_text if result_text is not None else "\n".join(collected)
|
|
128
176
|
tail = f"\nOutput tail: {text[-300:]!r}"
|
|
129
177
|
if role == AgentRole.PREFLIGHT_ISSUE:
|
|
130
178
|
try:
|
|
@@ -141,12 +189,3 @@ def parse(output: str, role: AgentRole) -> AgentOutput:
|
|
|
141
189
|
f"Agent produced no <promise>COMPLETE</promise> tag.{tail}"
|
|
142
190
|
)
|
|
143
191
|
return CompletionOutput()
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def assert_complete(output: str) -> None:
|
|
147
|
-
text = _unwrap(output)
|
|
148
|
-
if not re.search(r"<promise>COMPLETE</promise>", text):
|
|
149
|
-
tail = text[-200:]
|
|
150
|
-
raise PromiseParseError(
|
|
151
|
-
f"Agent produced no <promise>COMPLETE</promise> tag. Output tail: {tail!r}"
|
|
152
|
-
)
|
|
@@ -0,0 +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,6 +1,5 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import io
|
|
3
|
-
import json
|
|
4
3
|
import os
|
|
5
4
|
import queue
|
|
6
5
|
import re
|
|
@@ -8,11 +7,13 @@ import shlex
|
|
|
8
7
|
import sys
|
|
9
8
|
import tarfile
|
|
10
9
|
import threading
|
|
10
|
+
from collections.abc import Callable, Generator
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
|
|
13
13
|
import docker
|
|
14
14
|
from docker.models.containers import Container as DockerContainer
|
|
15
15
|
|
|
16
|
+
from .agent_output_protocol import AgentOutput, AgentRole, process_stream
|
|
16
17
|
from .config import Config
|
|
17
18
|
from .errors import (
|
|
18
19
|
AgentTimeoutError,
|
|
@@ -21,33 +22,12 @@ from .errors import (
|
|
|
21
22
|
UsageLimitError,
|
|
22
23
|
)
|
|
23
24
|
from .status_display import PlainStatusDisplay
|
|
24
|
-
from .stream_parser import StreamParser
|
|
25
25
|
from .worktree import (
|
|
26
26
|
CONTAINER_PARENT_GIT,
|
|
27
27
|
patch_gitdir_for_container,
|
|
28
28
|
)
|
|
29
29
|
|
|
30
30
|
|
|
31
|
-
def _is_usage_limit_line(line: str, patterns: tuple[str, ...]) -> bool:
|
|
32
|
-
"""Return True if line signals a usage limit — plain-text or a JSON result error."""
|
|
33
|
-
try:
|
|
34
|
-
obj = json.loads(line)
|
|
35
|
-
if isinstance(obj, dict):
|
|
36
|
-
if obj.get("type") == "result" and obj.get("is_error"):
|
|
37
|
-
if obj.get("api_error_status") == 429:
|
|
38
|
-
return True
|
|
39
|
-
result_text = obj.get("result")
|
|
40
|
-
if isinstance(result_text, str) and any(
|
|
41
|
-
p.lower() in result_text.lower() for p in patterns
|
|
42
|
-
):
|
|
43
|
-
return True
|
|
44
|
-
return False
|
|
45
|
-
except json.JSONDecodeError:
|
|
46
|
-
pass
|
|
47
|
-
line_lower = line.lower()
|
|
48
|
-
return any(p.lower() in line_lower for p in patterns)
|
|
49
|
-
|
|
50
|
-
|
|
51
31
|
def _build_claude_command(model: str = "", effort: str = "") -> str:
|
|
52
32
|
flags = "--verbose --dangerously-skip-permissions --output-format stream-json -p -"
|
|
53
33
|
if model:
|
|
@@ -297,16 +277,19 @@ class ContainerRunner:
|
|
|
297
277
|
|
|
298
278
|
self._prompt = await prepare_prompt(prompt_file, prompt_args, container_exec)
|
|
299
279
|
|
|
300
|
-
async def work(self) ->
|
|
280
|
+
async def work(self, role: AgentRole) -> AgentOutput:
|
|
301
281
|
self._status_display.update_phase(self.name, "Work")
|
|
302
282
|
loop = asyncio.get_running_loop()
|
|
283
|
+
on_turn: Callable[[str], None] = lambda turn: self._status_display.print(
|
|
284
|
+
self.name, turn
|
|
285
|
+
)
|
|
303
286
|
return await loop.run_in_executor(
|
|
304
|
-
None, lambda: self.run_streaming(
|
|
287
|
+
None, lambda: self.run_streaming(role=role, on_turn=on_turn)
|
|
305
288
|
)
|
|
306
289
|
|
|
307
|
-
def run_streaming(self,
|
|
290
|
+
def run_streaming(self, role: AgentRole, on_turn: Callable[[str], None]) -> AgentOutput:
|
|
308
291
|
self.write_file(self._prompt, "/tmp/.pycastle_prompt")
|
|
309
|
-
|
|
292
|
+
exec_result = self._active_container.exec_run(
|
|
310
293
|
["bash", "-c", _build_claude_command(model=self.model, effort=self.effort)],
|
|
311
294
|
stream=True,
|
|
312
295
|
workdir=self._worktree_path,
|
|
@@ -317,18 +300,17 @@ class ContainerRunner:
|
|
|
317
300
|
|
|
318
301
|
def _feed():
|
|
319
302
|
try:
|
|
320
|
-
for chunk in
|
|
303
|
+
for chunk in exec_result.output:
|
|
321
304
|
q.put(chunk)
|
|
322
305
|
finally:
|
|
323
306
|
q.put(_sentinel)
|
|
324
307
|
|
|
325
308
|
threading.Thread(target=_feed, daemon=True).start()
|
|
326
309
|
|
|
327
|
-
|
|
328
|
-
line_buf = ""
|
|
329
|
-
parser = StreamParser()
|
|
310
|
+
log = open(self._log_path, "wb") # noqa: WPS515
|
|
330
311
|
try:
|
|
331
|
-
|
|
312
|
+
def _lines() -> Generator[str, None, None]:
|
|
313
|
+
line_buf = ""
|
|
332
314
|
while True:
|
|
333
315
|
try:
|
|
334
316
|
chunk = q.get(timeout=self._cfg.idle_timeout)
|
|
@@ -337,21 +319,19 @@ class ContainerRunner:
|
|
|
337
319
|
f"Agent idle for more than {self._cfg.idle_timeout}s"
|
|
338
320
|
)
|
|
339
321
|
if chunk is _sentinel:
|
|
340
|
-
|
|
322
|
+
return
|
|
341
323
|
log.write(chunk)
|
|
342
324
|
log.flush()
|
|
343
325
|
text = chunk.decode("utf-8", errors="replace")
|
|
344
|
-
parts.append(text)
|
|
345
326
|
self._status_display.reset_idle_timer(self.name)
|
|
346
327
|
line_buf += text
|
|
347
328
|
while "\n" in line_buf:
|
|
348
329
|
line, line_buf = line_buf.split("\n", 1)
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
if print_output and turn is not None:
|
|
353
|
-
self._status_display.print(self.name, turn)
|
|
330
|
+
yield line
|
|
331
|
+
|
|
332
|
+
return process_stream(_lines(), on_turn, role, self._cfg.usage_limit_patterns)
|
|
354
333
|
finally:
|
|
334
|
+
log.close()
|
|
355
335
|
try:
|
|
356
336
|
self._active_container.exec_run(
|
|
357
337
|
["bash", "-c", "rm -f /tmp/.pycastle_prompt"],
|
|
@@ -359,4 +339,3 @@ class ContainerRunner:
|
|
|
359
339
|
)
|
|
360
340
|
except Exception:
|
|
361
341
|
pass
|
|
362
|
-
return "".join(parts)
|
|
@@ -41,7 +41,7 @@ async def run_iteration(deps: Deps) -> IterationOutcome:
|
|
|
41
41
|
|
|
42
42
|
if isinstance(preflight_result, PreflightHITL):
|
|
43
43
|
deps.status_display.print(
|
|
44
|
-
"
|
|
44
|
+
"",
|
|
45
45
|
f"Preflight issue #{preflight_result.issue_number} requires human intervention. Exiting.",
|
|
46
46
|
)
|
|
47
47
|
return AbortedHITL(issue_number=preflight_result.issue_number)
|
|
@@ -67,10 +67,10 @@ async def run_iteration(deps: Deps) -> IterationOutcome:
|
|
|
67
67
|
|
|
68
68
|
issues = issues[: deps.cfg.max_parallel]
|
|
69
69
|
|
|
70
|
-
deps.status_display.print("
|
|
70
|
+
deps.status_display.print("", f"Planning complete. {len(issues)} issue(s):")
|
|
71
71
|
for issue in issues:
|
|
72
72
|
deps.status_display.print(
|
|
73
|
-
"
|
|
73
|
+
"",
|
|
74
74
|
f" #{issue['number']}: {issue['title']} → {branch_for(issue['number'])}",
|
|
75
75
|
)
|
|
76
76
|
|
|
@@ -88,32 +88,32 @@ async def run_iteration(deps: Deps) -> IterationOutcome:
|
|
|
88
88
|
match error:
|
|
89
89
|
case PreflightFailure(failures=fs):
|
|
90
90
|
deps.status_display.print(
|
|
91
|
-
"
|
|
91
|
+
"",
|
|
92
92
|
f" ✗ #{issue['number']} ({branch_for(issue['number'])}) pre-flight failed:",
|
|
93
93
|
)
|
|
94
94
|
for check_name, command, output in fs:
|
|
95
95
|
deps.status_display.print(
|
|
96
|
-
"
|
|
96
|
+
"",
|
|
97
97
|
f" ✗ {check_name} ({command}): {output}",
|
|
98
98
|
)
|
|
99
99
|
case _:
|
|
100
100
|
deps.status_display.print(
|
|
101
|
-
"
|
|
101
|
+
"",
|
|
102
102
|
f" ✗ #{issue['number']} ({branch_for(issue['number'])}) failed: {error}",
|
|
103
103
|
)
|
|
104
104
|
|
|
105
105
|
completed = impl_result.completed
|
|
106
106
|
|
|
107
107
|
if not completed:
|
|
108
|
-
deps.status_display.print("
|
|
108
|
+
deps.status_display.print("", "No commits produced. Nothing to merge.")
|
|
109
109
|
return Continue()
|
|
110
110
|
|
|
111
111
|
deps.status_display.print(
|
|
112
|
-
"
|
|
112
|
+
"",
|
|
113
113
|
f"Execution complete. {len(completed)} branch(es) with commits:",
|
|
114
114
|
)
|
|
115
115
|
for i in completed:
|
|
116
|
-
deps.status_display.print("
|
|
116
|
+
deps.status_display.print("", f" {branch_for(i['number'])}")
|
|
117
117
|
|
|
118
118
|
await merge_phase(completed, deps)
|
|
119
119
|
|
|
@@ -4,6 +4,7 @@ from collections.abc import Callable
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
from typing import Any, Protocol
|
|
6
6
|
|
|
7
|
+
from ..agent_output_protocol import AgentOutput
|
|
7
8
|
from ..agent_result import PreflightFailure
|
|
8
9
|
from ..agent_runner import AgentRunnerProtocol, RunRequest
|
|
9
10
|
from ..config import Config
|
|
@@ -54,12 +55,12 @@ class FakeAgentRunner:
|
|
|
54
55
|
|
|
55
56
|
def __init__(
|
|
56
57
|
self,
|
|
57
|
-
responses: list[
|
|
58
|
+
responses: list[AgentOutput | PreflightFailure | BaseException] | None = None,
|
|
58
59
|
*,
|
|
59
60
|
side_effect: Callable[..., Any] | None = None,
|
|
60
61
|
preflight_responses: list[list[tuple[str, str, str]] | BaseException] | None = None,
|
|
61
62
|
) -> None:
|
|
62
|
-
self._responses: list[
|
|
63
|
+
self._responses: list[AgentOutput | PreflightFailure | BaseException] = list(
|
|
63
64
|
responses or []
|
|
64
65
|
)
|
|
65
66
|
self._side_effect = side_effect
|
|
@@ -69,7 +70,7 @@ class FakeAgentRunner:
|
|
|
69
70
|
self.calls: list[RunRequest] = []
|
|
70
71
|
self.preflight_calls: list[dict] = []
|
|
71
72
|
|
|
72
|
-
async def run(self, request: RunRequest) ->
|
|
73
|
+
async def run(self, request: RunRequest) -> AgentOutput | PreflightFailure:
|
|
73
74
|
self.calls.append(request)
|
|
74
75
|
if self._side_effect is not None:
|
|
75
76
|
result = self._side_effect(request)
|
|
@@ -7,9 +7,10 @@ async def _wait_for_clean_working_tree(deps: Deps, phase: str = "merge") -> None
|
|
|
7
7
|
if deps.git_svc.is_working_tree_clean(deps.repo_root):
|
|
8
8
|
return
|
|
9
9
|
deps.status_display.print(
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
f"Please commit or revert all local changes before the {phase} phase can proceed.
|
|
10
|
+
"",
|
|
11
|
+
"Working tree has uncommitted changes. "
|
|
12
|
+
f"Please commit or revert all local changes before the {phase} phase can proceed.",
|
|
13
|
+
style="error",
|
|
13
14
|
)
|
|
14
15
|
while not deps.git_svc.is_working_tree_clean(deps.repo_root):
|
|
15
16
|
await asyncio.sleep(10)
|