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 с причиной.
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 не считается завершённым циклом автоматизации.
## Проверка основы
+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`.
После каждого завершённого запуска 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):
```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 продуктовых задач не запускаются при обновлении основы.
+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 агент записывает осмысленное сообщение commit по `docs/GIT_WORKFLOW.md`. Runner после проверки scope фиксирует реализацию/review отдельными commits с ID задачи, actual changes и checks. Следующий процесс запускается только после успешного commit. Blocker тоже сохраняется явно незавершённым checkpoint; при нарушении scope или ошибке Git очередь останавливается без сброса изменений.
## Scope и роли
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`.
2. `README.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>`.
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/<id>.txt` (id агента, не задачи) по `docs/GIT_WORKFLOW.md`. Сообщение описывает actual diff и фактические checks. Это обязательное workflow metadata текущей задачи. Агент не запускает Git mutation commands. Runner остановит процесс, проверит scope и сделает commit перед следующим агентом.
```sh
python3 scripts/tasks.py submit <TASK-ID> --agent <id> \
--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:
Проверить соответствие сообщения executor изменениям, затем до approve/reject записать собственное сообщение `state/commit_messages/<reviewer-id>.txt` с решением и выполненными проверками. Executor commit фиксирует реализацию в review; отдельный review commit фиксирует done или возврат в todo. Не называть непроверенную реализацию принятой.
```sh
python3 scripts/tasks.py approve <TASK-ID> --agent <reviewer-id> --role <reviewer-role> \
--evidence state/evidence/<TASK-ID>-review.md
+1
View File
@@ -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/<agent-id>.txt`: конкретный English imperative subject, пустая строка, `Changes:` с фактическим результатом и `Validation:` с реальными проверками и ограничениями. Не писать «Update files»/«Task done». Reviewer проверяет сообщение executor и diff, затем готовит своё сообщение о решении review. Git делает только runner; инструкции — `docs/GIT_WORKFLOW.md`.
## Обязательный blocker protocol — для всех без исключения
+2 -8
View File
@@ -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
+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))
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
+6 -6
View File
@@ -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')
+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_'))