This commit is contained in:
1 parent
c1d4345316
commit
522311022f
12 files changed
+337
-22
No files matched your search
@@ -16,6 +16,7 @@
|
|||||||
10. Неподтверждённый тест не объявлять пройденным. Недоступный сервер/браузер/secret отмечать как внешнюю проверку или blocker с причиной.
|
10. Неподтверждённый тест не объявлять пройденным. Недоступный сервер/браузер/secret отмечать как внешнюю проверку или blocker с причиной.
|
||||||
11. Не обходить encryption/сертификаты/CORS ради демо. Fixtures и production transports должны быть явно разделены.
|
11. Не обходить encryption/сертификаты/CORS ради демо. Fixtures и production transports должны быть явно разделены.
|
||||||
12. Не менять stack самовольно после принятого ADR. Не добавлять зависимости без проверки лицензии, поддержки браузера и API.
|
12. Не менять stack самовольно после принятого ADR. Не добавлять зависимости без проверки лицензии, поддержки браузера и API.
|
||||||
|
13. До submit/approve/reject подготовить осмысленное сообщение commit по `docs/GIT_WORKFLOW.md`. Git add/commit выполняет runner после остановки процесса и проверки scope. Агент не делает commit/push/reset/stash/checkout и не меняет Git config/hooks. Следующая сессия начинается после успешного commit; `done` без commit не считается завершённым циклом автоматизации.
|
||||||
|
|
||||||
## Проверка основы
|
## Проверка основы
|
||||||
|
|
||||||
|
|||||||
@@ -55,3 +55,5 @@ python3 scripts/runner.py --config runner.local.json
|
|||||||
```
|
```
|
||||||
|
|
||||||
Каждый запуск модели получает ровно одну задачу и fresh session. При blocker инструмент создаёт remediation и блокирует original; reporter заканчивает работу. Новый агент исправляет причину, другой проверяет, новый продолжает original. При необходимости внешнего input runner останавливается с конкретным запросом. Подробности — `docs/AUTOMATION.md`.
|
Каждый запуск модели получает ровно одну задачу и fresh session. При blocker инструмент создаёт remediation и блокирует original; reporter заканчивает работу. Новый агент исправляет причину, другой проверяет, новый продолжает original. При необходимости внешнего input runner останавливается с конкретным запросом. Подробности — `docs/AUTOMATION.md`.
|
||||||
|
|
||||||
|
После каждого завершённого запуска runner делает осмысленный commit с task ID, описанием actual changes и проверок: реализация → отдельный review → следующая задача. Blocker сохраняется как незавершённый checkpoint. Агент заранее пишет сообщение, Git выполняет coordinator. Перед первым запуском нужен первоначальный commit основы и чистый Git checkout; подробности — `docs/GIT_WORKFLOW.md`. Ошибка commit останавливает очередь и сохраняет изменения.
|
||||||
+5
-2
@@ -9,13 +9,15 @@
|
|||||||
Пример формы настройки (имя/флаги заменить реальными для вашего harness):
|
Пример формы настройки (имя/флаги заменить реальными для вашего harness):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"argv": ["your-agent-cli", "--prompt-file", "{prompt_file}"], "max_sessions": 1000, "timeout_seconds": 1800, "max_prompt_chars": 80000}
|
{"argv": ["your-agent-cli", "--prompt-file", "{prompt_file}"], "git_commits": true, "max_sessions": 1000, "timeout_seconds": 1800, "max_prompt_chars": 80000}
|
||||||
```
|
```
|
||||||
|
|
||||||
`commands_by_role` позволяет указать другую argv-array для отдельных ролей, например crypto/security. Не хранить secrets в аргументах/JSON; использовать безопасное окружение harness. Конкретная модель и команда не выдумываются автоматически.
|
`commands_by_role` позволяет указать другую argv-array для отдельных ролей, например crypto/security. Не хранить secrets в аргументах/JSON; использовать безопасное окружение harness. Конкретная модель и команда не выдумываются автоматически.
|
||||||
|
|
||||||
Harness обязан запускать новую conversation/session для каждого процесса, исполнять file/tool commands, соблюдать project sandbox и завершаться после submit/review/escalate. Не использовать флаги resume/thread reuse и auto-next-task внутри harness.
|
Harness обязан запускать новую conversation/session для каждого процесса, исполнять file/tool commands, соблюдать project sandbox и завершаться после submit/review/escalate. Не использовать флаги resume/thread reuse и auto-next-task внутри harness.
|
||||||
|
|
||||||
|
Автоматические commits включены по умолчанию. Перед первым реальным запуском foundation должна быть сохранена начальным commit в Git checkout с активной веткой, настроенной identity и чистым index/working tree. Каждый agent transition заканчивается отдельным осмысленным commit перед следующим процессом. Executor и reviewer заранее пишут сообщения; Git выполняет runner. Правила, примеры и восстановление — `docs/GIT_WORKFLOW.md`.
|
||||||
|
|
||||||
## Запуск
|
## Запуск
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -30,6 +32,7 @@ Dry run не назначает tasks, не пишет state и работает
|
|||||||
## Blocker flow
|
## Blocker flow
|
||||||
|
|
||||||
1. Текущий агент записывает handoff/issue, `escalate`, заканчивает работу.
|
1. Текущий агент записывает handoff/issue, `escalate`, заканчивает работу.
|
||||||
|
Runner сохраняет checkpoint незавершённой работы и причины; это не acceptance. Только после commit запускается resolver.
|
||||||
2. Оригинал blocked, resolver todo с ограниченным scope и исходными prerequisites. Resolver не зависит от blocked оригинала.
|
2. Оригинал blocked, resolver todo с ограниченным scope и исходными prerequisites. Resolver не зависит от blocked оригинала.
|
||||||
3. Runner приоритетно запускает нового resolver-агента.
|
3. Runner приоритетно запускает нового resolver-агента.
|
||||||
4. Другой новый процесс проверяет resolver.
|
4. Другой новый процесс проверяет resolver.
|
||||||
@@ -66,4 +69,4 @@ Release только делает resolver готовым к проверяем
|
|||||||
|
|
||||||
Dependency/role guards, retired reporter, recovery journal definitions+state, fresh subprocess IDs, bounded prompts, separate reviewer, automatic unblocking only after done и stop на внешнем blocker. Истинность evidence/качество модели/OS sandbox обеспечиваются реальным harness и review, а не названием роли.
|
Dependency/role guards, retired reporter, recovery journal definitions+state, fresh subprocess IDs, bounded prompts, separate reviewer, automatic unblocking only after done и stop на внешнем blocker. Истинность evidence/качество модели/OS sandbox обеспечиваются реальным harness и review, а не названием роли.
|
||||||
|
|
||||||
В поставляемой основе автоматизация проверяется fake tool-capable harness в isolated temporary project. Реальные 275 продуктовых задач не запускаются при обновлении основы.
|
В поставляемой основе автоматизация проверяется fake tool-capable harness в isolated temporary project, включая реальные commits в временных Git repositories, независимое review и ошибки hooks/messages. Реальные 275 продуктовых задач не запускаются при обновлении основы.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Осмысленные commits по задачам
|
||||||
|
|
||||||
|
В автоматическом режиме Git управляет runner. Все роли выполняют одну текущую задачу; написание сообщения — служебный артефакт этой задачи, а не разрешение менять Git. Не запускать git add/commit/reset/stash/checkout/push, не менять hooks/config и не обходить проверки. Автоматического push нет.
|
||||||
|
|
||||||
|
## Границы commits
|
||||||
|
|
||||||
|
- После executor submit: commit реализации, evidence, handoff и перехода в review. Он ещё не означает acceptance.
|
||||||
|
- После reviewer approve: отдельный commit решения review, проверок и перехода в done. Только теперь задача принята.
|
||||||
|
- После reject: commit замечаний и возврата в todo; новый executor исправляет только эту задачу.
|
||||||
|
- После blocker/аварии: checkpoint ограниченной незавершённой работы и remediation. Сообщение явно говорит, что реализация не принята. Новый агент начинается после checkpoint; работа исходной задачи не смешивается с resolver.
|
||||||
|
- Нарушение scope: автоматического commit нет. Runner сохраняет incident, останавливается и оставляет изменения для разбора.
|
||||||
|
|
||||||
|
Две записи для обычной задачи позволяют независимо увидеть реализацию и результат её проверки. История блокеров также остаётся видимой. Один commit не объединяет несколько agent sessions/задач, кроме неизбежного workflow metadata: например, принятие resolver одновременно возвращает original в очередь.
|
||||||
|
|
||||||
|
## Сообщение агента
|
||||||
|
|
||||||
|
До submit/approve/reject создать UTF-8 файл `state/commit_messages/<agent-id>.txt`. После terminal command runner может немедленно завершить процесс: писать сообщение после команды поздно.
|
||||||
|
|
||||||
|
Формат: English imperative subject длиной 15..100 символов, пустая строка, конкретные `Changes:` и `Validation:`. Полное сообщение максимум 12 000 символов. Тело можно писать по-русски. Не ставить task ID в начале самостоятельно: runner добавляет его и поля Task/Agent/Role/Action/Status/Task title.
|
||||||
|
|
||||||
|
Пример executor:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Persist per-account archive checkpoints
|
||||||
|
|
||||||
|
Changes: Сохранение MAM checkpoint изолировано по аккаунтам; добавлена миграция отсутствующего checkpoint.
|
||||||
|
Validation: Команда <точная команда> завершилась с exit 0; проверены restart и переключение аккаунта. Проверка с живым сервером не выполнялась.
|
||||||
|
```
|
||||||
|
|
||||||
|
Пример reviewer:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Approve archive checkpoint persistence after restart checks
|
||||||
|
|
||||||
|
Changes: Принята реализация сохранения checkpoint; приложен независимый review report.
|
||||||
|
Validation: Проверены diff и scope; повторена <точная команда>, exit 0. Ограничения внешней проверки перечислены в report.
|
||||||
|
```
|
||||||
|
|
||||||
|
Примеры не являются evidence: заменить их фактическими результатами. «Update files», «Fix stuff», «Task done» и пересказ плана без actual diff недопустимы. Reviewer проверяет смысл сообщения executor вместе с результатом. Инструмент проверяет структуру, но не умеет доказывать истинность текста.
|
||||||
|
|
||||||
|
Reviewer читает последнюю реализацию своей задачи через read-only `git log --all --grep='^<TASK-ID>:'` и `git show <hash>`, сопоставляет diff, acceptance и evidence. При возврате задачи из blocker также учитывает предыдущие checkpoints этого же ID. Просмотр Git разрешён; изменение Git выполняет только coordinator.
|
||||||
|
|
||||||
|
При blocker обязательное сообщение не требуется: runner формирует checkpoint из task ID, конкретной причины и статуса. Нельзя задерживать обязательное завершение агента ради Git.
|
||||||
|
|
||||||
|
## Однократная подготовка Git
|
||||||
|
|
||||||
|
`git_commits` по умолчанию true даже в старом runner.local.json без этого поля. Нужен Git checkout с первоначальным commit, активной веткой и настроенными user.name/user.email. Foundation сначала сохранить отдельным начальным commit; его создание не выполняется автоматически в пользовательском репозитории. В выделенном новом Web-репозитории это обычные git init/add/commit, выполненные владельцем при подготовке checkout. Не включать реальные secrets или runner.local.json.
|
||||||
|
|
||||||
|
Перед каждым агентом весь repository working tree и index должны быть чистыми. Это действует и при размещении основы в подкаталоге Apple-репозитория: чужие изменения не включаются автоматически. Не вести параллельные ручные правки в checkout. Hooks и signing выполняются согласно Git config; runner не отключает их. `git_commits: false` доступен для изолированных тестов/явно ручного режима и отключает гарантию commits.
|
||||||
|
|
||||||
|
Runner проверяет неизменность HEAD, scope, затем явно перечисляет изменённые project paths для staging. Массовый git add всего родительского репозитория не используется. В commit входят исходники и versioned workflow artifacts; `state/runs/`, output logs, runner.local.json и прочие ignored files не входят. Hash commit записывается в `state/runs/<agent-id>/run.json` и summary `last_commit`. Найти историю задачи можно через `git log --all --grep='^S00-001:'`.
|
||||||
|
|
||||||
|
## Ошибка Git
|
||||||
|
|
||||||
|
Нет сообщения, hook/signing отказал, HEAD изменён агентом, обнаружены чужие правки или commit не состоялся: runner возвращает `stop: git_error`, exit code 2, и не запускает следующего агента. Статус задачи и изменения сохраняются; даже done в tracker не означает успешный checkpoint. Автоматического reset/очистки/index rollback и обхода hooks нет.
|
||||||
|
|
||||||
|
Владелец проверяет последний run.json, diff и staged diff, устраняет причину, затем сохраняет именно изменения остановленной сессии обычным commit с подготовленным сообщением (для missing message сначала восстановить правдивый текст по diff/evidence). Если сообщение уже сформировано runner, оно лежит в `state/runs/<agent-id>/commit-message.txt`; использовать `git commit --file <этот путь>`. Убедиться в чистоте checkout и повторно запустить runner. При scope violation сначала разобрать incident и сохранить/отделить изменения; не принимать их общим commit вслепую.
|
||||||
|
|
||||||
|
В ручном запуске без runner эти автоматические гарантии не действуют: владелец делает соответствующий commit перед передачей следующему агенту. Модель не должна обходить coordinator через самостоятельное управление Git.
|
||||||
@@ -35,6 +35,8 @@ python3 scripts/tasks.py reject <ID> --agent <new-reviewer> --role <reviewer-rol
|
|||||||
|
|
||||||
После submit/approve/reject запуск тоже заканчивается. Автоматический runner сам выбирает следующий task/role, создаёт fresh agent id и передаёт master prompt. Подробности: `docs/AUTOMATION.md`.
|
После submit/approve/reject запуск тоже заканчивается. Автоматический runner сам выбирает следующий task/role, создаёт fresh agent id и передаёт master prompt. Подробности: `docs/AUTOMATION.md`.
|
||||||
|
|
||||||
|
До submit/approve/reject агент записывает осмысленное сообщение commit по `docs/GIT_WORKFLOW.md`. Runner после проверки scope фиксирует реализацию/review отдельными commits с ID задачи, actual changes и checks. Следующий процесс запускается только после успешного commit. Blocker тоже сохраняется явно незавершённым checkpoint; при нарушении scope или ошибке Git очередь останавливается без сброса изменений.
|
||||||
|
|
||||||
## Scope и роли
|
## Scope и роли
|
||||||
|
|
||||||
Scope текущей карточки одинаково обязателен для front/backend/protocol/crypto/storage/media/realtime/security/qa/ci-cd/docs/orchestrator и reviewer. Shared interface вне scope — отдельная remediation, не импровизация. Единственное служебное исключение — task metadata/evidence/handoff через инструмент.
|
Scope текущей карточки одинаково обязателен для front/backend/protocol/crypto/storage/media/realtime/security/qa/ci-cd/docs/orchestrator и reviewer. Shared interface вне scope — отдельная remediation, не импровизация. Единственное служебное исключение — task metadata/evidence/handoff через инструмент.
|
||||||
|
|||||||
+5
-1
@@ -13,7 +13,7 @@
|
|||||||
1. `AGENTS.md`.
|
1. `AGENTS.md`.
|
||||||
2. `README.md`.
|
2. `README.md`.
|
||||||
3. `roles/common.md`, затем `roles/<role>.md`.
|
3. `roles/common.md`, затем `roles/<role>.md`.
|
||||||
4. `docs/WEB_PROFILE.md`, `docs/ARCHITECTURE.md`, `docs/CONTRACTS.md`, `docs/WORKFLOW.md`, `docs/AUTOMATION.md`.
|
4. `docs/WEB_PROFILE.md`, `docs/ARCHITECTURE.md`, `docs/CONTRACTS.md`, `docs/WORKFLOW.md`, `docs/AUTOMATION.md`, `docs/GIT_WORKFLOW.md`.
|
||||||
5. `python3 scripts/tasks.py resume --agent <id>` и `show <TASK-ID>`.
|
5. `python3 scripts/tasks.py resume --agent <id>` и `show <TASK-ID>`.
|
||||||
6. Карточку текущей задачи, context packet, предыдущий handoff, точные requirements.
|
6. Карточку текущей задачи, context packet, предыдущий handoff, точные requirements.
|
||||||
7. Указанные existing contracts/accepted ADR и evidence прямых зависимостей.
|
7. Указанные existing contracts/accepted ADR и evidence прямых зависимостей.
|
||||||
@@ -55,6 +55,8 @@ CLI создаёт resolver task, добавляет dependency, сохраня
|
|||||||
|
|
||||||
Executor:
|
Executor:
|
||||||
|
|
||||||
|
Перед terminal command записать `state/commit_messages/<id>.txt` (id агента, не задачи) по `docs/GIT_WORKFLOW.md`. Сообщение описывает actual diff и фактические checks. Это обязательное workflow metadata текущей задачи. Агент не запускает Git mutation commands. Runner остановит процесс, проверит scope и сделает commit перед следующим агентом.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 scripts/tasks.py submit <TASK-ID> --agent <id> \
|
python3 scripts/tasks.py submit <TASK-ID> --agent <id> \
|
||||||
--evidence state/evidence/<TASK-ID>-<id>.md --handoff state/handoffs/<TASK-ID>.md
|
--evidence state/evidence/<TASK-ID>-<id>.md --handoff state/handoffs/<TASK-ID>.md
|
||||||
@@ -64,6 +66,8 @@ python3 scripts/tasks.py submit <TASK-ID> --agent <id> \
|
|||||||
|
|
||||||
Reviewer проверяет только эту задачу, actual patch/checks/evidence и записывает свой report:
|
Reviewer проверяет только эту задачу, actual patch/checks/evidence и записывает свой report:
|
||||||
|
|
||||||
|
Проверить соответствие сообщения executor изменениям, затем до approve/reject записать собственное сообщение `state/commit_messages/<reviewer-id>.txt` с решением и выполненными проверками. Executor commit фиксирует реализацию в review; отдельный review commit фиксирует done или возврат в todo. Не называть непроверенную реализацию принятой.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 scripts/tasks.py approve <TASK-ID> --agent <reviewer-id> --role <reviewer-role> \
|
python3 scripts/tasks.py approve <TASK-ID> --agent <reviewer-id> --role <reviewer-role> \
|
||||||
--evidence state/evidence/<TASK-ID>-review.md
|
--evidence state/evidence/<TASK-ID>-review.md
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
8. Если карточка слишком широка — оформить blocker/remediation для decomposition, не расширять scope самому.
|
8. Если карточка слишком широка — оформить blocker/remediation для decomposition, не расширять scope самому.
|
||||||
9. При blocker создать remediation и original blocked; при аварийном завершении это сделает runner. Чужой активный owner не захватывается автоматически.
|
9. При blocker создать remediation и original blocked; при аварийном завершении это сделает runner. Чужой активный owner не захватывается автоматически.
|
||||||
10. Проверяй реальные production paths отдельно от fixtures. Для задач docs/research результат — принятый документ/эксперимент, а не кодовая заглушка.
|
10. Проверяй реальные production paths отдельно от fixtures. Для задач docs/research результат — принятый документ/эксперимент, а не кодовая заглушка.
|
||||||
|
11. Перед submit/approve/reject записать `state/commit_messages/<agent-id>.txt`: конкретный English imperative subject, пустая строка, `Changes:` с фактическим результатом и `Validation:` с реальными проверками и ограничениями. Не писать «Update files»/«Task done». Reviewer проверяет сообщение executor и diff, затем готовит своё сообщение о решении review. Git делает только runner; инструкции — `docs/GIT_WORKFLOW.md`.
|
||||||
|
|
||||||
## Обязательный blocker protocol — для всех без исключения
|
## Обязательный blocker protocol — для всех без исключения
|
||||||
|
|
||||||
|
|||||||
+2
-8
@@ -1,13 +1,7 @@
|
|||||||
{
|
{
|
||||||
"argv": [
|
"argv": ["REPLACE_WITH_YOUR_TOOL_CAPABLE_AGENT_CLI"],
|
||||||
"codex",
|
|
||||||
"--ask-for-approval", "never",
|
|
||||||
"exec",
|
|
||||||
"--ephemeral",
|
|
||||||
"--sandbox", "workspace-write",
|
|
||||||
"-"
|
|
||||||
],
|
|
||||||
"commands_by_role": {},
|
"commands_by_role": {},
|
||||||
|
"git_commits": true,
|
||||||
"max_sessions": 1000,
|
"max_sessions": 1000,
|
||||||
"timeout_seconds": 1800,
|
"timeout_seconds": 1800,
|
||||||
"max_prompt_chars": 80000
|
"max_prompt_chars": 80000
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Task-scoped Git checkpoints owned by the coordinator, never by the model."""
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
class TaskCommits:
|
||||||
|
def __init__(self, root, enabled=True):
|
||||||
|
if not isinstance(enabled, bool):
|
||||||
|
raise ValueError('git_commits must be a JSON boolean')
|
||||||
|
self.root = Path(root).resolve()
|
||||||
|
self.enabled = enabled
|
||||||
|
|
||||||
|
def git(self, *args):
|
||||||
|
result = subprocess.run(['git', '-C', str(self.root), *args],
|
||||||
|
capture_output=True, timeout=60)
|
||||||
|
if result.returncode:
|
||||||
|
# Do not leak hooks, environment, signing details or credentials into reports.
|
||||||
|
raise ValueError('Git ' + args[0] + ' failed; inspect Git configuration/hooks locally; changes preserved')
|
||||||
|
return result.stdout.decode('utf-8', errors='surrogateescape')
|
||||||
|
|
||||||
|
def changes(self):
|
||||||
|
# Disable rename folding so each source/deletion is staged explicitly.
|
||||||
|
raw = self.git('status', '--porcelain=v1', '-z', '--untracked-files=all', '--no-renames')
|
||||||
|
return [row[3:] for row in raw.split('\0') if row]
|
||||||
|
|
||||||
|
def preflight(self):
|
||||||
|
if not self.enabled:
|
||||||
|
return None
|
||||||
|
self.repo = Path(self.git('rev-parse', '--show-toplevel').strip()).resolve()
|
||||||
|
head = self.git('rev-parse', '--verify', 'HEAD').strip()
|
||||||
|
self.git('symbolic-ref', '--quiet', 'HEAD')
|
||||||
|
self.git('var', 'GIT_AUTHOR_IDENT')
|
||||||
|
self.git('var', 'GIT_COMMITTER_IDENT')
|
||||||
|
if self.changes():
|
||||||
|
raise ValueError('Git working tree/index must be clean before each agent; commit or separately preserve existing changes first')
|
||||||
|
return head
|
||||||
|
|
||||||
|
def message(self, item, agent, row, result):
|
||||||
|
task_id = item['id']
|
||||||
|
if row['status'] in {'review', 'done', 'todo'} and not result.get('resolution_task'):
|
||||||
|
path = self.root / 'state/commit_messages' / (agent + '.txt')
|
||||||
|
if path.is_symlink() or not path.resolve().is_relative_to(self.root) or not path.is_file():
|
||||||
|
raise ValueError('Missing task commit message: state/commit_messages/' + agent + '.txt')
|
||||||
|
text = path.read_text(encoding='utf-8').strip()
|
||||||
|
lines = text.splitlines()
|
||||||
|
if (not lines or not 15 <= len(lines[0]) <= 100 or
|
||||||
|
not lines[0][0].isascii() or not lines[0][0].isupper() or
|
||||||
|
any(ord(c) < 32 for c in text if c != '\n') or
|
||||||
|
len(text) > 12000 or len(lines) < 4 or lines[1] != '' or
|
||||||
|
'Changes:' not in text or 'Validation:' not in text or
|
||||||
|
len('\n'.join(lines[2:])) < 40):
|
||||||
|
raise ValueError('Commit message needs a concrete English subject (15..100 chars), blank line, Changes: and Validation: with actual results')
|
||||||
|
else:
|
||||||
|
# An interrupted model cannot be relied on to write a completion message.
|
||||||
|
# Preserve its bounded partial work explicitly as incomplete before remediation.
|
||||||
|
reason = row.get('reason') or str(result)
|
||||||
|
text = ('Record blocked task and remediation handoff\n\n'
|
||||||
|
'Changes: Preserve incomplete work and blocker metadata for ' + task_id +
|
||||||
|
'. This checkpoint does not mark implementation accepted.\n'
|
||||||
|
'Validation: Session ended with status ' + row['status'] + '.\n'
|
||||||
|
'Blocker: ' + reason)
|
||||||
|
return task_id + ': ' + text + '\n\nTask: ' + task_id + '\nAgent: ' + agent + \
|
||||||
|
'\nRole: ' + item['role'] + '\nAction: ' + item['action'] + '\nStatus: ' + row['status'] + '\n'
|
||||||
|
|
||||||
|
def commit(self, item, agent, row, result, expected_head, task_title):
|
||||||
|
if not self.enabled:
|
||||||
|
return None
|
||||||
|
if self.git('rev-parse', 'HEAD').strip() != expected_head:
|
||||||
|
raise ValueError('Agent changed Git HEAD; coordinator will not commit or continue automatically')
|
||||||
|
paths = self.changes()
|
||||||
|
for name in paths:
|
||||||
|
if not (self.repo / name).is_relative_to(self.root):
|
||||||
|
raise ValueError('Changes outside project root; refusing to include unrelated files in task commit')
|
||||||
|
message = self.message(item, agent, row, result) + '\nTask title: ' + task_title + '\n'
|
||||||
|
if not paths:
|
||||||
|
raise ValueError('Task transition produced no Git changes; refusing an empty task commit')
|
||||||
|
self.git('add', '--', *[':(top,literal)' + name for name in paths])
|
||||||
|
# Include only the explicitly enumerated project files; never git add . / -A.
|
||||||
|
staged = self.git('diff', '--cached', '--no-relative', '--name-only', '-z').split('\0')
|
||||||
|
if any(name and name not in paths for name in staged):
|
||||||
|
raise ValueError('Unexpected staged files; changes preserved for inspection')
|
||||||
|
message_path = self.root / 'state/runs' / agent / 'commit-message.txt'
|
||||||
|
message_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
message_path.write_text(message, encoding='utf-8')
|
||||||
|
self.git('commit', '--file', str(message_path))
|
||||||
|
commit = self.git('rev-parse', 'HEAD').strip()
|
||||||
|
if self.changes():
|
||||||
|
raise ValueError('Git hook or concurrent writer left changes after commit; inspect before next agent')
|
||||||
|
return commit
|
||||||
+31
-5
@@ -17,6 +17,7 @@ import uuid
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
from tasks import Tracker, atomic_write, now, read_json
|
from tasks import Tracker, atomic_write, now, read_json
|
||||||
|
from git_commits import TaskCommits
|
||||||
|
|
||||||
EXCLUDED_DIRS = {'.git', 'node_modules', '.venv', '__pycache__', '.state.lock', '.runner.lock', 'dist', 'coverage'}
|
EXCLUDED_DIRS = {'.git', 'node_modules', '.venv', '__pycache__', '.state.lock', '.runner.lock', 'dist', 'coverage'}
|
||||||
GENERATED = {'planning/tasks.json', 'planning/TASK_MATRIX.md', 'planning/task_matrix.csv',
|
GENERATED = {'planning/tasks.json', 'planning/TASK_MATRIX.md', 'planning/task_matrix.csv',
|
||||||
@@ -49,6 +50,7 @@ class Runner:
|
|||||||
self.root = Path(root).resolve()
|
self.root = Path(root).resolve()
|
||||||
self.tracker = Tracker(self.root)
|
self.tracker = Tracker(self.root)
|
||||||
self.config = config
|
self.config = config
|
||||||
|
self.commits = TaskCommits(self.root, config.get('git_commits', True))
|
||||||
self.lock_path = self.root / '.runner.lock'
|
self.lock_path = self.root / '.runner.lock'
|
||||||
self.max_sessions = config.get('max_sessions', 1000)
|
self.max_sessions = config.get('max_sessions', 1000)
|
||||||
self.timeout = config.get('timeout_seconds', 1800)
|
self.timeout = config.get('timeout_seconds', 1800)
|
||||||
@@ -112,6 +114,10 @@ Read master_prompt and instructions below in order. Your scope is only this card
|
|||||||
At a blocker: write one issue JSON, call tasks.py escalate, then EXIT immediately.
|
At a blocker: write one issue JSON, call tasks.py escalate, then EXIT immediately.
|
||||||
Do not solve the new remediation, do not unblock yourself, do not launch another agent.
|
Do not solve the new remediation, do not unblock yourself, do not launch another agent.
|
||||||
After submit/approve/reject EXIT. The coordinator will launch the next fresh process.
|
After submit/approve/reject EXIT. The coordinator will launch the next fresh process.
|
||||||
|
Before submit/approve/reject write state/commit_messages/{agent}.txt with a concrete
|
||||||
|
English imperative subject (15..100 chars), blank line, Changes: and Validation:
|
||||||
|
describing only actual changes/checks. Never run git add/commit/reset/push yourself.
|
||||||
|
The coordinator commits each transition before launching another agent.
|
||||||
'''
|
'''
|
||||||
paths = ['AGENTS.md', 'README.md', 'master_prompt.md', 'roles/common.md', f'roles/{item["role"]}.md',
|
paths = ['AGENTS.md', 'README.md', 'master_prompt.md', 'roles/common.md', f'roles/{item["role"]}.md',
|
||||||
'docs/WEB_PROFILE.md', 'docs/ARCHITECTURE.md', 'docs/CONTRACTS.md', 'docs/WORKFLOW.md', 'docs/AUTOMATION.md']
|
'docs/WEB_PROFILE.md', 'docs/ARCHITECTURE.md', 'docs/CONTRACTS.md', 'docs/WORKFLOW.md', 'docs/AUTOMATION.md']
|
||||||
@@ -178,6 +184,7 @@ After submit/approve/reject EXIT. The coordinator will launch the next fresh pro
|
|||||||
prompt_path = session / 'prompt.md'
|
prompt_path = session / 'prompt.md'
|
||||||
# Validate command before claiming; missing local configuration is not a product task blocker.
|
# Validate command before claiming; missing local configuration is not a product task blocker.
|
||||||
argv = self.command(item, agent, prompt_path)
|
argv = self.command(item, agent, prompt_path)
|
||||||
|
git_head = self.commits.preflight()
|
||||||
if item['action'] == 'claim':
|
if item['action'] == 'claim':
|
||||||
self.tracker.mutate('claim', item['id'], agent, role=item['role'])
|
self.tracker.mutate('claim', item['id'], agent, role=item['role'])
|
||||||
previous = dict(self.tracker.state()['tasks'][item['id']])
|
previous = dict(self.tracker.state()['tasks'][item['id']])
|
||||||
@@ -186,7 +193,9 @@ After submit/approve/reject EXIT. The coordinator will launch the next fresh pro
|
|||||||
except (ValueError, OSError) as exc:
|
except (ValueError, OSError) as exc:
|
||||||
result = self.execution_issue(item, agent, 'Context preparation failed: ' + str(exc), previous=previous)
|
result = self.execution_issue(item, agent, 'Context preparation failed: ' + str(exc), previous=previous)
|
||||||
self.tracker.render()
|
self.tracker.render()
|
||||||
return {'agent': agent, 'task': item['id'], 'action': item['action'], 'result': result}
|
record = {'agent': agent, 'task': item['id'], 'action': item['action'], 'result': result}
|
||||||
|
self.checkpoint(item, agent, result, git_head, record)
|
||||||
|
return record
|
||||||
atomic_write(prompt_path, prompt)
|
atomic_write(prompt_path, prompt)
|
||||||
record = {'agent': agent, 'task': item['id'], 'role': item['role'], 'action': item['action'], 'started_at': now(), 'status': 'running'}
|
record = {'agent': agent, 'task': item['id'], 'role': item['role'], 'action': item['action'], 'started_at': now(), 'status': 'running'}
|
||||||
atomic_write(session / 'run.json', json.dumps(record, ensure_ascii=False, indent=2))
|
atomic_write(session / 'run.json', json.dumps(record, ensure_ascii=False, indent=2))
|
||||||
@@ -259,8 +268,21 @@ After submit/approve/reject EXIT. The coordinator will launch the next fresh pro
|
|||||||
atomic_write(session / 'run.json', json.dumps(record, ensure_ascii=False, indent=2))
|
atomic_write(session / 'run.json', json.dumps(record, ensure_ascii=False, indent=2))
|
||||||
self.tracker.render()
|
self.tracker.render()
|
||||||
self.tracker.validate()
|
self.tracker.validate()
|
||||||
|
if violations and self.commits.enabled:
|
||||||
|
record['commit_error'] = 'Scope violation: no automatic commit; inspect preserved changes'
|
||||||
|
else:
|
||||||
|
self.checkpoint(item, agent, result, git_head, record)
|
||||||
|
atomic_write(session / 'run.json', json.dumps(record, ensure_ascii=False, indent=2))
|
||||||
return record
|
return record
|
||||||
|
|
||||||
|
def checkpoint(self, item, agent, result, git_head, record):
|
||||||
|
try:
|
||||||
|
record['commit'] = self.commits.commit(item, agent,
|
||||||
|
self.tracker.state()['tasks'][item['id']], result, git_head,
|
||||||
|
self.tracker.task(item['id'])['title'])
|
||||||
|
except (ValueError, OSError, subprocess.TimeoutExpired) as exc:
|
||||||
|
record['commit_error'] = str(exc)
|
||||||
|
|
||||||
def run(self, stage=None, dry_run=False):
|
def run(self, stage=None, dry_run=False):
|
||||||
if stage and stage not in {s['id'] for s in self.tracker.definitions()['stages']}:
|
if stage and stage not in {s['id'] for s in self.tracker.definitions()['stages']}:
|
||||||
raise ValueError('Unknown stage: ' + stage)
|
raise ValueError('Unknown stage: ' + stage)
|
||||||
@@ -284,6 +306,8 @@ After submit/approve/reject EXIT. The coordinator will launch the next fresh pro
|
|||||||
if not item:
|
if not item:
|
||||||
return self.summary('idle', records)
|
return self.summary('idle', records)
|
||||||
records.append(self.launch(item))
|
records.append(self.launch(item))
|
||||||
|
if records[-1].get('commit_error'):
|
||||||
|
return self.summary('git_error', records)
|
||||||
if any(r.get('external_wait') for r in self.tracker.state()['tasks'].values()):
|
if any(r.get('external_wait') for r in self.tracker.state()['tasks'].values()):
|
||||||
return self.summary('external', records)
|
return self.summary('external', records)
|
||||||
return self.summary('session_limit', records)
|
return self.summary('session_limit', records)
|
||||||
@@ -292,10 +316,12 @@ After submit/approve/reject EXIT. The coordinator will launch the next fresh pro
|
|||||||
state = self.tracker.state()
|
state = self.tracker.state()
|
||||||
done = all(r['status'] == 'done' for r in state['tasks'].values())
|
done = all(r['status'] == 'done' for r in state['tasks'].values())
|
||||||
external = {k: r.get('reason') for k, r in state['tasks'].items() if r.get('external_wait')}
|
external = {k: r.get('reason') for k, r in state['tasks'].items() if r.get('external_wait')}
|
||||||
return {'stop': 'complete' if done else 'needs_input' if external and stop in {'idle', 'external'} else stop,
|
return {'stop': 'git_error' if stop == 'git_error' else 'complete' if done else 'needs_input' if external and stop in {'idle', 'external'} else stop,
|
||||||
'launched': len(records), 'revision': state['revision'], 'external_blockers': external,
|
'launched': len(records), 'revision': state['revision'], 'external_blockers': external,
|
||||||
'unfinished': sum(r['status'] != 'done' for r in state['tasks'].values()),
|
'unfinished': sum(r['status'] != 'done' for r in state['tasks'].values()),
|
||||||
'last_session': records[-1].get('agent') if records else None}
|
'last_session': records[-1].get('agent') if records else None,
|
||||||
|
'last_commit': records[-1].get('commit') if records else None,
|
||||||
|
'commit_error': records[-1].get('commit_error') if records else None}
|
||||||
|
|
||||||
|
|
||||||
def stop_process_tree(process):
|
def stop_process_tree(process):
|
||||||
@@ -349,8 +375,8 @@ def main():
|
|||||||
config['max_sessions'] = args.max_sessions
|
config['max_sessions'] = args.max_sessions
|
||||||
result = Runner(root, config).run(stage=args.stage, dry_run=args.dry_run)
|
result = Runner(root, config).run(stage=args.stage, dry_run=args.dry_run)
|
||||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
return 3 if result.get('stop') == 'needs_input' else 0
|
return 2 if result.get('stop') == 'git_error' else 3 if result.get('stop') == 'needs_input' else 0
|
||||||
except (ValueError, OSError, KeyError) as exc:
|
except (ValueError, OSError, KeyError, subprocess.TimeoutExpired) as exc:
|
||||||
print(json.dumps({'stop': 'configuration_or_workflow_error', 'reason': str(exc)}, ensure_ascii=False), file=sys.stderr)
|
print(json.dumps({'stop': 'configuration_or_workflow_error', 'reason': str(exc)}, ensure_ascii=False), file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ class RunnerTests(WorkflowTests):
|
|||||||
super().setUp()
|
super().setUp()
|
||||||
for rel in ['AGENTS.md', 'README.md', 'master_prompt.md', 'roles/common.md',
|
for rel in ['AGENTS.md', 'README.md', 'master_prompt.md', 'roles/common.md',
|
||||||
'docs/WEB_PROFILE.md', 'docs/ARCHITECTURE.md', 'docs/CONTRACTS.md',
|
'docs/WEB_PROFILE.md', 'docs/ARCHITECTURE.md', 'docs/CONTRACTS.md',
|
||||||
'docs/WORKFLOW.md', 'docs/AUTOMATION.md']:
|
'docs/WORKFLOW.md', 'docs/AUTOMATION.md', 'docs/GIT_WORKFLOW.md']:
|
||||||
path = self.root / rel
|
path = self.root / rel
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
path.write_text('# Fresh one-task instructions\n', encoding='utf-8')
|
path.write_text('# Fresh one-task instructions\n', encoding='utf-8')
|
||||||
@@ -175,7 +175,7 @@ else:
|
|||||||
handoff=h/(id+'.md');handoff.write_text('# Handoff\\n\\nFixture implementation completed; next step independent review of this task.\\n')
|
handoff=h/(id+'.md');handoff.write_text('# Handoff\\n\\nFixture implementation completed; next step independent review of this task.\\n')
|
||||||
t.mutate('submit',id,agent,evidence=str(evidence),handoff=str(handoff))
|
t.mutate('submit',id,agent,evidence=str(evidence),handoff=str(handoff))
|
||||||
''', encoding='utf-8')
|
''', encoding='utf-8')
|
||||||
self.config = {'argv': [sys.executable, str(fake), str(ROOT / 'scripts/tasks.py')], 'max_sessions': 20, 'timeout_seconds': 5}
|
self.config = {'git_commits': False, 'argv': [sys.executable, str(fake), str(ROOT / 'scripts/tasks.py')], 'max_sessions': 20, 'timeout_seconds': 5}
|
||||||
|
|
||||||
def test_fresh_process_blocker_resolver_review_resume_chain(self):
|
def test_fresh_process_blocker_resolver_review_resume_chain(self):
|
||||||
runner = RUN_MODULE.Runner(self.root, self.config)
|
runner = RUN_MODULE.Runner(self.root, self.config)
|
||||||
@@ -205,7 +205,7 @@ else:
|
|||||||
runner.run()
|
runner.run()
|
||||||
|
|
||||||
def test_missing_executable_creates_external_resolution_and_stops(self):
|
def test_missing_executable_creates_external_resolution_and_stops(self):
|
||||||
runner = RUN_MODULE.Runner(self.root, {'argv': ['missing-tool-8b5e6d']})
|
runner = RUN_MODULE.Runner(self.root, {'git_commits': False, 'argv': ['missing-tool-8b5e6d']})
|
||||||
result = runner.run()
|
result = runner.run()
|
||||||
self.assertEqual(result['stop'], 'needs_input')
|
self.assertEqual(result['stop'], 'needs_input')
|
||||||
self.assertEqual(result['launched'], 1)
|
self.assertEqual(result['launched'], 1)
|
||||||
@@ -213,7 +213,7 @@ else:
|
|||||||
self.assertTrue(result['external_blockers'])
|
self.assertTrue(result['external_blockers'])
|
||||||
|
|
||||||
def test_incomplete_agent_exit_creates_remediation(self):
|
def test_incomplete_agent_exit_creates_remediation(self):
|
||||||
runner = RUN_MODULE.Runner(self.root, {'argv': [sys.executable, '-c', 'pass'], 'max_sessions': 1})
|
runner = RUN_MODULE.Runner(self.root, {'git_commits': False, 'argv': [sys.executable, '-c', 'pass'], 'max_sessions': 1})
|
||||||
result = runner.run()
|
result = runner.run()
|
||||||
self.assertEqual(result['stop'], 'session_limit')
|
self.assertEqual(result['stop'], 'session_limit')
|
||||||
state = self.tracker.state()
|
state = self.tracker.state()
|
||||||
@@ -221,7 +221,7 @@ else:
|
|||||||
self.assertTrue(state['retired_agents'])
|
self.assertTrue(state['retired_agents'])
|
||||||
|
|
||||||
def test_scope_violation_quarantines_and_requires_input(self):
|
def test_scope_violation_quarantines_and_requires_input(self):
|
||||||
runner = RUN_MODULE.Runner(self.root, {'argv': [sys.executable, '-c', "from pathlib import Path;Path('bad-edit.txt').write_text('wrong scope')"]})
|
runner = RUN_MODULE.Runner(self.root, {'git_commits': False, 'argv': [sys.executable, '-c', "from pathlib import Path;Path('bad-edit.txt').write_text('wrong scope')"]})
|
||||||
result = runner.run()
|
result = runner.run()
|
||||||
self.assertEqual(result['stop'], 'needs_input')
|
self.assertEqual(result['stop'], 'needs_input')
|
||||||
self.assertEqual(self.tracker.state()['tasks']['S00-001']['status'], 'blocked')
|
self.assertEqual(self.tracker.state()['tasks']['S00-001']['status'], 'blocked')
|
||||||
@@ -247,7 +247,7 @@ else:
|
|||||||
RUN_MODULE.Runner(self.root, {}).run(stage='S99', dry_run=True)
|
RUN_MODULE.Runner(self.root, {}).run(stage='S99', dry_run=True)
|
||||||
|
|
||||||
def test_timeout_terminates_before_queuing_resolution(self):
|
def test_timeout_terminates_before_queuing_resolution(self):
|
||||||
runner = RUN_MODULE.Runner(self.root, {'argv': [sys.executable, '-c', 'import time;time.sleep(2)'], 'timeout_seconds': .05, 'max_sessions': 1})
|
runner = RUN_MODULE.Runner(self.root, {'git_commits': False, 'argv': [sys.executable, '-c', 'import time;time.sleep(2)'], 'timeout_seconds': .05, 'max_sessions': 1})
|
||||||
result = runner.run()
|
result = runner.run()
|
||||||
self.assertEqual(result['stop'], 'session_limit')
|
self.assertEqual(result['stop'], 'session_limit')
|
||||||
self.assertEqual(self.tracker.state()['tasks']['S00-001']['status'], 'blocked')
|
self.assertEqual(self.tracker.state()['tasks']['S00-001']['status'], 'blocked')
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""Real isolated Git repositories, fake agents; no user checkout commits."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from test_automation import RunnerTests, RUN_MODULE
|
||||||
|
|
||||||
|
|
||||||
|
class GitCommitTests(RunnerTests):
|
||||||
|
def git(self, *args):
|
||||||
|
return subprocess.check_output(['git', '-C', str(self.root), *args], text=True).strip()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
(self.root / '.gitignore').write_text('state/runs/\n.runner.lock/\n.state.lock/\n__pycache__/\n')
|
||||||
|
fake = self.root / 'fake_agent.py'
|
||||||
|
text = fake.read_text()
|
||||||
|
text = text.replace("if action=='review':t.mutate", """messages=r/'state/commit_messages';messages.mkdir(exist_ok=True)
|
||||||
|
(messages/(agent+'.txt')).write_text(('Review restored input and record acceptance' if action=='review' else 'Restore required input within assigned scope')+'\\n\\nChanges: Record the bounded fixture result and handoff.\\nValidation: Isolated positive and negative fixture checks passed.\\n')
|
||||||
|
if action=='claim':
|
||||||
|
source=r/('src/fix.txt' if id.startswith('B') else 'src/'+str(int(id.split('-')[-1]))+'.txt');source.parent.mkdir(exist_ok=True);source.write_text('verified task '+id)
|
||||||
|
if action=='review':t.mutate""")
|
||||||
|
# Remediation IDs use the actual parent-derived naming convention.
|
||||||
|
text = text.replace("id.startswith('B')", "id not in {'S00-001','S00-002','S00-003'}")
|
||||||
|
fake.write_text(text)
|
||||||
|
self.tracker.render()
|
||||||
|
self.git('init', '-b', 'main')
|
||||||
|
self.git('config', 'user.name', 'Fixture Agent')
|
||||||
|
self.git('config', 'user.email', 'fixture@example.invalid')
|
||||||
|
self.git('config', 'commit.gpgsign', 'false')
|
||||||
|
self.git('add', '.')
|
||||||
|
self.git('commit', '-m', 'Initialize isolated fixture')
|
||||||
|
self.config['git_commits'] = True
|
||||||
|
|
||||||
|
def test_chain_commits_each_transition_with_task_and_actual_message(self):
|
||||||
|
result = RUN_MODULE.Runner(self.root, self.config).run()
|
||||||
|
self.assertEqual(result['stop'], 'complete', result)
|
||||||
|
self.assertEqual(self.git('rev-list', '--count', 'HEAD'), '10')
|
||||||
|
self.assertEqual(self.git('status', '--porcelain'), '')
|
||||||
|
log = self.git('log', '--format=%B')
|
||||||
|
self.assertIn('S00-001: Restore required input within assigned scope', log)
|
||||||
|
self.assertIn('S00-001: Review restored input and record acceptance', log)
|
||||||
|
self.assertIn('Record blocked task and remediation handoff', log)
|
||||||
|
self.assertIn('Validation: Isolated positive and negative fixture checks passed.', log)
|
||||||
|
self.assertEqual(result['last_commit'], self.git('rev-parse', 'HEAD'))
|
||||||
|
commits = self.git('log', '--format=%H', '--grep=^S00-002: Restore').splitlines()
|
||||||
|
files = self.git('show', '--format=', '--name-only', commits[0])
|
||||||
|
self.assertIn('src/2.txt', files)
|
||||||
|
self.assertIn('state/tasks.json', files)
|
||||||
|
self.assertNotIn('output.log', files)
|
||||||
|
|
||||||
|
def test_dirty_existing_file_is_not_claimed_or_committed(self):
|
||||||
|
before = self.tracker.state()
|
||||||
|
(self.root / 'unrelated.txt').write_text('user work')
|
||||||
|
with self.assertRaisesRegex(ValueError, 'must be clean'):
|
||||||
|
RUN_MODULE.Runner(self.root, self.config).run()
|
||||||
|
self.assertEqual(before, self.tracker.state())
|
||||||
|
self.assertEqual(self.git('rev-list', '--count', 'HEAD'), '1')
|
||||||
|
|
||||||
|
def test_missing_message_stops_before_next_agent_and_preserves_work(self):
|
||||||
|
fake = self.root / 'fake_agent.py'
|
||||||
|
fake.write_text(fake.read_text().replace("(messages/(agent+'.txt')).write_text", "(messages/'wrong-name.txt').write_text"))
|
||||||
|
self.git('add', 'fake_agent.py')
|
||||||
|
self.git('commit', '-m', 'Simulate missing task message')
|
||||||
|
result = RUN_MODULE.Runner(self.root, self.config).run()
|
||||||
|
self.assertEqual(result['stop'], 'git_error')
|
||||||
|
self.assertEqual(result['launched'], 2) # Blocker checkpoint, then failed resolver submit.
|
||||||
|
self.assertIn('Missing task commit message', result['commit_error'])
|
||||||
|
self.assertTrue(self.git('status', '--porcelain'))
|
||||||
|
|
||||||
|
def test_rejected_git_hook_stops_without_starting_another_agent(self):
|
||||||
|
hook = self.root / '.git/hooks/pre-commit'
|
||||||
|
hook.write_text('#!/bin/sh\nexit 1\n')
|
||||||
|
hook.chmod(0o755)
|
||||||
|
result = RUN_MODULE.Runner(self.root, self.config).run()
|
||||||
|
self.assertEqual(result['stop'], 'git_error')
|
||||||
|
self.assertEqual(result['launched'], 1)
|
||||||
|
self.assertIn('Git commit failed', result['commit_error'])
|
||||||
|
self.assertEqual(self.git('rev-list', '--count', 'HEAD'), '1')
|
||||||
|
self.assertTrue(self.git('diff', '--cached', '--name-only'))
|
||||||
|
|
||||||
|
def test_changed_head_is_not_accepted_as_coordinator_commit(self):
|
||||||
|
committer = RUN_MODULE.Runner(self.root, self.config).commits
|
||||||
|
head = committer.preflight()
|
||||||
|
self.git('commit', '--allow-empty', '-m', 'Unexpected agent commit')
|
||||||
|
with self.assertRaisesRegex(ValueError, 'changed Git HEAD'):
|
||||||
|
committer.commit({'id': 'S00-001'}, 'agent', {}, {}, head, 'Fixture')
|
||||||
|
|
||||||
|
def test_nested_project_rejects_edits_to_parent_files(self):
|
||||||
|
project = self.root / 'nested'
|
||||||
|
project.mkdir()
|
||||||
|
committer = RUN_MODULE.TaskCommits(project)
|
||||||
|
head = committer.preflight()
|
||||||
|
(self.root / 'parent.txt').write_text('unrelated')
|
||||||
|
with self.assertRaisesRegex(ValueError, 'outside project root'):
|
||||||
|
committer.commit({'id': 'S00-001'}, 'agent', {}, {}, head, 'Fixture')
|
||||||
|
self.assertEqual(self.git('diff', '--cached', '--name-only'), '')
|
||||||
|
|
||||||
|
def test_nested_project_commits_literal_paths_and_deletions(self):
|
||||||
|
project = self.root / 'nested'
|
||||||
|
project.mkdir()
|
||||||
|
(project / '.gitignore').write_text('state/runs/\n')
|
||||||
|
removed = project / 'old.txt'
|
||||||
|
removed.write_text('old')
|
||||||
|
self.git('add', 'nested')
|
||||||
|
self.git('commit', '-m', 'Initialize nested fixture')
|
||||||
|
committer = RUN_MODULE.TaskCommits(project)
|
||||||
|
head = committer.preflight()
|
||||||
|
removed.unlink()
|
||||||
|
(project / 'file [1].txt').write_text('bounded output')
|
||||||
|
draft = project / 'state/commit_messages/agent.txt'
|
||||||
|
draft.parent.mkdir(parents=True)
|
||||||
|
draft.write_text('Replace obsolete fixture with literal named output\n\nChanges: Replace the obsolete fixture file.\nValidation: Checked deletion and literal path staging.\n')
|
||||||
|
# A nested foundation ships its own project-relative ignore rules.
|
||||||
|
commit = committer.commit({'id': 'S00-001', 'role': 'front', 'action': 'claim'},
|
||||||
|
'agent', {'status': 'review'}, {}, head, 'Fixture')
|
||||||
|
files = self.git('show', '--format=', '--name-only', commit)
|
||||||
|
self.assertIn('nested/file [1].txt', files)
|
||||||
|
self.assertIn('nested/old.txt', files)
|
||||||
|
self.assertFalse(removed.exists())
|
||||||
|
self.assertEqual(self.git('status', '--porcelain'), '')
|
||||||
|
|
||||||
|
def test_commit_message_rejects_empty_or_unstructured_completion(self):
|
||||||
|
committer = RUN_MODULE.TaskCommits(self.root)
|
||||||
|
message = self.root / 'state/commit_messages/agent.txt'
|
||||||
|
message.parent.mkdir(parents=True)
|
||||||
|
message.write_text('Task done\n')
|
||||||
|
with self.assertRaisesRegex(ValueError, 'concrete English subject'):
|
||||||
|
committer.message({'id': 'S00-001'}, 'agent', {'status': 'review'}, {})
|
||||||
|
|
||||||
|
|
||||||
|
def load_tests(loader, tests, pattern):
|
||||||
|
return unittest.TestSuite(GitCommitTests(name) for name in GitCommitTests.__dict__ if name.startswith('test_'))
|
||||||
Reference in new issue
Block a user