added git commit after task
Foundation checks / foundation (push) Successful in 2m48s

This commit is contained in:
wt committed 2026-10-14 18:06:35 +07:00
1 parent c1d4345316
commit 522311022f
12 files changed
+337 -22

No files matched your search

+1
View File
@@ -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 не считается завершённым циклом автоматизации.
## Проверка основы ## Проверка основы
+2
View File
@@ -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
View File
@@ -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 продуктовых задач не запускаются при обновлении основы.
+59
View File
@@ -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.
+2
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
+89
View File
@@ -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
View File
@@ -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
+6 -6
View File
@@ -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')
+134
View File
@@ -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_'))