diff --git a/AGENTS.md b/AGENTS.md index d828b22..5ce892b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ 10. Неподтверждённый тест не объявлять пройденным. Недоступный сервер/браузер/secret отмечать как внешнюю проверку или blocker с причиной. 11. Не обходить encryption/сертификаты/CORS ради демо. Fixtures и production transports должны быть явно разделены. 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 не считается завершённым циклом автоматизации. ## Проверка основы diff --git a/README.md b/README.md index f7b14e2..6363431 100644 --- a/README.md +++ b/README.md @@ -55,3 +55,5 @@ python3 scripts/runner.py --config runner.local.json ``` Каждый запуск модели получает ровно одну задачу и 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 останавливает очередь и сохраняет изменения. diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index 299401e..65a774a 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -9,13 +9,15 @@ Пример формы настройки (имя/флаги заменить реальными для вашего harness): ```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. Конкретная модель и команда не выдумываются автоматически. 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 @@ -30,6 +32,7 @@ Dry run не назначает tasks, не пишет state и работает ## Blocker flow 1. Текущий агент записывает handoff/issue, `escalate`, заканчивает работу. + Runner сохраняет checkpoint незавершённой работы и причины; это не acceptance. Только после commit запускается resolver. 2. Оригинал blocked, resolver todo с ограниченным scope и исходными prerequisites. Resolver не зависит от blocked оригинала. 3. Runner приоритетно запускает нового 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, а не названием роли. -В поставляемой основе автоматизация проверяется fake tool-capable harness в isolated temporary project. Реальные 275 продуктовых задач не запускаются при обновлении основы. +В поставляемой основе автоматизация проверяется fake tool-capable harness в isolated temporary project, включая реальные commits в временных Git repositories, независимое review и ошибки hooks/messages. Реальные 275 продуктовых задач не запускаются при обновлении основы. diff --git a/docs/GIT_WORKFLOW.md b/docs/GIT_WORKFLOW.md new file mode 100644 index 0000000..88651f2 --- /dev/null +++ b/docs/GIT_WORKFLOW.md @@ -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/.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='^:'` и `git show `, сопоставляет 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//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//commit-message.txt`; использовать `git commit --file <этот путь>`. Убедиться в чистоте checkout и повторно запустить runner. При scope violation сначала разобрать incident и сохранить/отделить изменения; не принимать их общим commit вслепую. + +В ручном запуске без runner эти автоматические гарантии не действуют: владелец делает соответствующий commit перед передачей следующему агенту. Модель не должна обходить coordinator через самостоятельное управление Git. diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index 290300c..e3899dc 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -35,6 +35,8 @@ python3 scripts/tasks.py reject --agent --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 ` и `show `. 6. Карточку текущей задачи, context packet, предыдущий handoff, точные requirements. 7. Указанные existing contracts/accepted ADR и evidence прямых зависимостей. @@ -55,6 +55,8 @@ CLI создаёт resolver task, добавляет dependency, сохраня Executor: +Перед terminal command записать `state/commit_messages/.txt` (id агента, не задачи) по `docs/GIT_WORKFLOW.md`. Сообщение описывает actual diff и фактические checks. Это обязательное workflow metadata текущей задачи. Агент не запускает Git mutation commands. Runner остановит процесс, проверит scope и сделает commit перед следующим агентом. + ```sh python3 scripts/tasks.py submit --agent \ --evidence state/evidence/-.md --handoff state/handoffs/.md @@ -64,6 +66,8 @@ python3 scripts/tasks.py submit --agent \ Reviewer проверяет только эту задачу, actual patch/checks/evidence и записывает свой report: +Проверить соответствие сообщения executor изменениям, затем до approve/reject записать собственное сообщение `state/commit_messages/.txt` с решением и выполненными проверками. Executor commit фиксирует реализацию в review; отдельный review commit фиксирует done или возврат в todo. Не называть непроверенную реализацию принятой. + ```sh python3 scripts/tasks.py approve --agent --role \ --evidence state/evidence/-review.md diff --git a/roles/common.md b/roles/common.md index 1fea127..ac8f98f 100644 --- a/roles/common.md +++ b/roles/common.md @@ -10,6 +10,7 @@ 8. Если карточка слишком широка — оформить blocker/remediation для decomposition, не расширять scope самому. 9. При blocker создать remediation и original blocked; при аварийном завершении это сделает runner. Чужой активный owner не захватывается автоматически. 10. Проверяй реальные production paths отдельно от fixtures. Для задач docs/research результат — принятый документ/эксперимент, а не кодовая заглушка. +11. Перед submit/approve/reject записать `state/commit_messages/.txt`: конкретный English imperative subject, пустая строка, `Changes:` с фактическим результатом и `Validation:` с реальными проверками и ограничениями. Не писать «Update files»/«Task done». Reviewer проверяет сообщение executor и diff, затем готовит своё сообщение о решении review. Git делает только runner; инструкции — `docs/GIT_WORKFLOW.md`. ## Обязательный blocker protocol — для всех без исключения diff --git a/runner.example.json b/runner.example.json index 56d9b88..3499b94 100644 --- a/runner.example.json +++ b/runner.example.json @@ -1,13 +1,7 @@ { - "argv": [ - "codex", - "--ask-for-approval", "never", - "exec", - "--ephemeral", - "--sandbox", "workspace-write", - "-" - ], + "argv": ["REPLACE_WITH_YOUR_TOOL_CAPABLE_AGENT_CLI"], "commands_by_role": {}, + "git_commits": true, "max_sessions": 1000, "timeout_seconds": 1800, "max_prompt_chars": 80000 diff --git a/scripts/git_commits.py b/scripts/git_commits.py new file mode 100644 index 0000000..59d2dbb --- /dev/null +++ b/scripts/git_commits.py @@ -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 diff --git a/scripts/runner.py b/scripts/runner.py index 2011e7a..af0e410 100644 --- a/scripts/runner.py +++ b/scripts/runner.py @@ -17,6 +17,7 @@ import uuid sys.path.insert(0, str(Path(__file__).resolve().parent)) 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'} GENERATED = {'planning/tasks.json', 'planning/TASK_MATRIX.md', 'planning/task_matrix.csv', @@ -49,6 +50,7 @@ class Runner: self.root = Path(root).resolve() self.tracker = Tracker(self.root) self.config = config + self.commits = TaskCommits(self.root, config.get('git_commits', True)) self.lock_path = self.root / '.runner.lock' self.max_sessions = config.get('max_sessions', 1000) 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. 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. +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', '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' # Validate command before claiming; missing local configuration is not a product task blocker. argv = self.command(item, agent, prompt_path) + git_head = self.commits.preflight() if item['action'] == 'claim': self.tracker.mutate('claim', item['id'], agent, role=item['role']) 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: result = self.execution_issue(item, agent, 'Context preparation failed: ' + str(exc), previous=previous) 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) 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)) @@ -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)) self.tracker.render() 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 + 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): if stage and stage not in {s['id'] for s in self.tracker.definitions()['stages']}: 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: return self.summary('idle', records) 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()): return self.summary('external', 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() 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')} - 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, '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): @@ -349,8 +375,8 @@ def main(): config['max_sessions'] = args.max_sessions result = Runner(root, config).run(stage=args.stage, dry_run=args.dry_run) print(json.dumps(result, ensure_ascii=False, indent=2)) - return 3 if result.get('stop') == 'needs_input' else 0 - except (ValueError, OSError, KeyError) as exc: + return 2 if result.get('stop') == 'git_error' else 3 if result.get('stop') == 'needs_input' else 0 + 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) return 2 diff --git a/tests/tooling/test_automation.py b/tests/tooling/test_automation.py index 981de7f..7fdb3ea 100644 --- a/tests/tooling/test_automation.py +++ b/tests/tooling/test_automation.py @@ -153,7 +153,7 @@ class RunnerTests(WorkflowTests): super().setUp() for rel in ['AGENTS.md', 'README.md', 'master_prompt.md', 'roles/common.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.parent.mkdir(parents=True, exist_ok=True) 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') t.mutate('submit',id,agent,evidence=str(evidence),handoff=str(handoff)) ''', 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): runner = RUN_MODULE.Runner(self.root, self.config) @@ -205,7 +205,7 @@ else: runner.run() 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() self.assertEqual(result['stop'], 'needs_input') self.assertEqual(result['launched'], 1) @@ -213,7 +213,7 @@ else: self.assertTrue(result['external_blockers']) 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() self.assertEqual(result['stop'], 'session_limit') state = self.tracker.state() @@ -221,7 +221,7 @@ else: self.assertTrue(state['retired_agents']) 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() self.assertEqual(result['stop'], 'needs_input') 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) 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() self.assertEqual(result['stop'], 'session_limit') self.assertEqual(self.tracker.state()['tasks']['S00-001']['status'], 'blocked') diff --git a/tests/tooling/test_git_commits.py b/tests/tooling/test_git_commits.py new file mode 100644 index 0000000..88f2cc3 --- /dev/null +++ b/tests/tooling/test_git_commits.py @@ -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_'))