This commit is contained in:
1 parent
c1d4345316
commit
522311022f
12 files changed
+337
-22
No files matched your search
@@ -16,6 +16,7 @@
|
||||
10. Неподтверждённый тест не объявлять пройденным. Недоступный сервер/браузер/secret отмечать как внешнюю проверку или blocker с причиной.
|
||||
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 не считается завершённым циклом автоматизации.
|
||||
|
||||
## Проверка основы
|
||||
|
||||
|
||||
@@ -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
@@ -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 продуктовых задач не запускаются при обновлении основы.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Осмысленные commits по задачам
|
||||
|
||||
В автоматическом режиме Git управляет runner. Все роли выполняют одну текущую задачу; написание сообщения — служебный артефакт этой задачи, а не разрешение менять Git. Не запускать git add/commit/reset/stash/checkout/push, не менять hooks/config и не обходить проверки. Автоматического push нет.
|
||||
|
||||
## Границы commits
|
||||
|
||||
- После executor submit: commit реализации, evidence, handoff и перехода в review. Он ещё не означает acceptance.
|
||||
- После reviewer approve: отдельный commit решения review, проверок и перехода в done. Только теперь задача принята.
|
||||
- После reject: commit замечаний и возврата в todo; новый executor исправляет только эту задачу.
|
||||
- После blocker/аварии: checkpoint ограниченной незавершённой работы и remediation. Сообщение явно говорит, что реализация не принята. Новый агент начинается после checkpoint; работа исходной задачи не смешивается с resolver.
|
||||
- Нарушение scope: автоматического commit нет. Runner сохраняет incident, останавливается и оставляет изменения для разбора.
|
||||
|
||||
Две записи для обычной задачи позволяют независимо увидеть реализацию и результат её проверки. История блокеров также остаётся видимой. Один commit не объединяет несколько agent sessions/задач, кроме неизбежного workflow metadata: например, принятие resolver одновременно возвращает original в очередь.
|
||||
|
||||
## Сообщение агента
|
||||
|
||||
До submit/approve/reject создать UTF-8 файл `state/commit_messages/<agent-id>.txt`. После terminal command runner может немедленно завершить процесс: писать сообщение после команды поздно.
|
||||
|
||||
Формат: English imperative subject длиной 15..100 символов, пустая строка, конкретные `Changes:` и `Validation:`. Полное сообщение максимум 12 000 символов. Тело можно писать по-русски. Не ставить task ID в начале самостоятельно: runner добавляет его и поля Task/Agent/Role/Action/Status/Task title.
|
||||
|
||||
Пример executor:
|
||||
|
||||
```text
|
||||
Persist per-account archive checkpoints
|
||||
|
||||
Changes: Сохранение MAM checkpoint изолировано по аккаунтам; добавлена миграция отсутствующего checkpoint.
|
||||
Validation: Команда <точная команда> завершилась с exit 0; проверены restart и переключение аккаунта. Проверка с живым сервером не выполнялась.
|
||||
```
|
||||
|
||||
Пример reviewer:
|
||||
|
||||
```text
|
||||
Approve archive checkpoint persistence after restart checks
|
||||
|
||||
Changes: Принята реализация сохранения checkpoint; приложен независимый review report.
|
||||
Validation: Проверены diff и scope; повторена <точная команда>, exit 0. Ограничения внешней проверки перечислены в report.
|
||||
```
|
||||
|
||||
Примеры не являются evidence: заменить их фактическими результатами. «Update files», «Fix stuff», «Task done» и пересказ плана без actual diff недопустимы. Reviewer проверяет смысл сообщения executor вместе с результатом. Инструмент проверяет структуру, но не умеет доказывать истинность текста.
|
||||
|
||||
Reviewer читает последнюю реализацию своей задачи через read-only `git log --all --grep='^<TASK-ID>:'` и `git show <hash>`, сопоставляет diff, acceptance и evidence. При возврате задачи из blocker также учитывает предыдущие checkpoints этого же ID. Просмотр Git разрешён; изменение Git выполняет только coordinator.
|
||||
|
||||
При blocker обязательное сообщение не требуется: runner формирует checkpoint из task ID, конкретной причины и статуса. Нельзя задерживать обязательное завершение агента ради Git.
|
||||
|
||||
## Однократная подготовка Git
|
||||
|
||||
`git_commits` по умолчанию true даже в старом runner.local.json без этого поля. Нужен Git checkout с первоначальным commit, активной веткой и настроенными user.name/user.email. Foundation сначала сохранить отдельным начальным commit; его создание не выполняется автоматически в пользовательском репозитории. В выделенном новом Web-репозитории это обычные git init/add/commit, выполненные владельцем при подготовке checkout. Не включать реальные secrets или runner.local.json.
|
||||
|
||||
Перед каждым агентом весь repository working tree и index должны быть чистыми. Это действует и при размещении основы в подкаталоге Apple-репозитория: чужие изменения не включаются автоматически. Не вести параллельные ручные правки в checkout. Hooks и signing выполняются согласно Git config; runner не отключает их. `git_commits: false` доступен для изолированных тестов/явно ручного режима и отключает гарантию commits.
|
||||
|
||||
Runner проверяет неизменность HEAD, scope, затем явно перечисляет изменённые project paths для staging. Массовый git add всего родительского репозитория не используется. В commit входят исходники и versioned workflow artifacts; `state/runs/`, output logs, runner.local.json и прочие ignored files не входят. Hash commit записывается в `state/runs/<agent-id>/run.json` и summary `last_commit`. Найти историю задачи можно через `git log --all --grep='^S00-001:'`.
|
||||
|
||||
## Ошибка Git
|
||||
|
||||
Нет сообщения, hook/signing отказал, HEAD изменён агентом, обнаружены чужие правки или commit не состоялся: runner возвращает `stop: git_error`, exit code 2, и не запускает следующего агента. Статус задачи и изменения сохраняются; даже done в tracker не означает успешный checkpoint. Автоматического reset/очистки/index rollback и обхода hooks нет.
|
||||
|
||||
Владелец проверяет последний run.json, diff и staged diff, устраняет причину, затем сохраняет именно изменения остановленной сессии обычным commit с подготовленным сообщением (для missing message сначала восстановить правдивый текст по diff/evidence). Если сообщение уже сформировано runner, оно лежит в `state/runs/<agent-id>/commit-message.txt`; использовать `git commit --file <этот путь>`. Убедиться в чистоте checkout и повторно запустить runner. При scope violation сначала разобрать incident и сохранить/отделить изменения; не принимать их общим commit вслепую.
|
||||
|
||||
В ручном запуске без runner эти автоматические гарантии не действуют: владелец делает соответствующий commit перед передачей следующему агенту. Модель не должна обходить coordinator через самостоятельное управление Git.
|
||||
@@ -35,6 +35,8 @@ python3 scripts/tasks.py reject <ID> --agent <new-reviewer> --role <reviewer-rol
|
||||
|
||||
После submit/approve/reject запуск тоже заканчивается. Автоматический runner сам выбирает следующий task/role, создаёт fresh agent id и передаёт master prompt. Подробности: `docs/AUTOMATION.md`.
|
||||
|
||||
До submit/approve/reject агент записывает осмысленное сообщение 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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Task-scoped Git checkpoints owned by the coordinator, never by the model."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
|
||||
class TaskCommits:
|
||||
def __init__(self, root, enabled=True):
|
||||
if not isinstance(enabled, bool):
|
||||
raise ValueError('git_commits must be a JSON boolean')
|
||||
self.root = Path(root).resolve()
|
||||
self.enabled = enabled
|
||||
|
||||
def git(self, *args):
|
||||
result = subprocess.run(['git', '-C', str(self.root), *args],
|
||||
capture_output=True, timeout=60)
|
||||
if result.returncode:
|
||||
# Do not leak hooks, environment, signing details or credentials into reports.
|
||||
raise ValueError('Git ' + args[0] + ' failed; inspect Git configuration/hooks locally; changes preserved')
|
||||
return result.stdout.decode('utf-8', errors='surrogateescape')
|
||||
|
||||
def changes(self):
|
||||
# Disable rename folding so each source/deletion is staged explicitly.
|
||||
raw = self.git('status', '--porcelain=v1', '-z', '--untracked-files=all', '--no-renames')
|
||||
return [row[3:] for row in raw.split('\0') if row]
|
||||
|
||||
def preflight(self):
|
||||
if not self.enabled:
|
||||
return None
|
||||
self.repo = Path(self.git('rev-parse', '--show-toplevel').strip()).resolve()
|
||||
head = self.git('rev-parse', '--verify', 'HEAD').strip()
|
||||
self.git('symbolic-ref', '--quiet', 'HEAD')
|
||||
self.git('var', 'GIT_AUTHOR_IDENT')
|
||||
self.git('var', 'GIT_COMMITTER_IDENT')
|
||||
if self.changes():
|
||||
raise ValueError('Git working tree/index must be clean before each agent; commit or separately preserve existing changes first')
|
||||
return head
|
||||
|
||||
def message(self, item, agent, row, result):
|
||||
task_id = item['id']
|
||||
if row['status'] in {'review', 'done', 'todo'} and not result.get('resolution_task'):
|
||||
path = self.root / 'state/commit_messages' / (agent + '.txt')
|
||||
if path.is_symlink() or not path.resolve().is_relative_to(self.root) or not path.is_file():
|
||||
raise ValueError('Missing task commit message: state/commit_messages/' + agent + '.txt')
|
||||
text = path.read_text(encoding='utf-8').strip()
|
||||
lines = text.splitlines()
|
||||
if (not lines or not 15 <= len(lines[0]) <= 100 or
|
||||
not lines[0][0].isascii() or not lines[0][0].isupper() or
|
||||
any(ord(c) < 32 for c in text if c != '\n') or
|
||||
len(text) > 12000 or len(lines) < 4 or lines[1] != '' or
|
||||
'Changes:' not in text or 'Validation:' not in text or
|
||||
len('\n'.join(lines[2:])) < 40):
|
||||
raise ValueError('Commit message needs a concrete English subject (15..100 chars), blank line, Changes: and Validation: with actual results')
|
||||
else:
|
||||
# An interrupted model cannot be relied on to write a completion message.
|
||||
# Preserve its bounded partial work explicitly as incomplete before remediation.
|
||||
reason = row.get('reason') or str(result)
|
||||
text = ('Record blocked task and remediation handoff\n\n'
|
||||
'Changes: Preserve incomplete work and blocker metadata for ' + task_id +
|
||||
'. This checkpoint does not mark implementation accepted.\n'
|
||||
'Validation: Session ended with status ' + row['status'] + '.\n'
|
||||
'Blocker: ' + reason)
|
||||
return task_id + ': ' + text + '\n\nTask: ' + task_id + '\nAgent: ' + agent + \
|
||||
'\nRole: ' + item['role'] + '\nAction: ' + item['action'] + '\nStatus: ' + row['status'] + '\n'
|
||||
|
||||
def commit(self, item, agent, row, result, expected_head, task_title):
|
||||
if not self.enabled:
|
||||
return None
|
||||
if self.git('rev-parse', 'HEAD').strip() != expected_head:
|
||||
raise ValueError('Agent changed Git HEAD; coordinator will not commit or continue automatically')
|
||||
paths = self.changes()
|
||||
for name in paths:
|
||||
if not (self.repo / name).is_relative_to(self.root):
|
||||
raise ValueError('Changes outside project root; refusing to include unrelated files in task commit')
|
||||
message = self.message(item, agent, row, result) + '\nTask title: ' + task_title + '\n'
|
||||
if not paths:
|
||||
raise ValueError('Task transition produced no Git changes; refusing an empty task commit')
|
||||
self.git('add', '--', *[':(top,literal)' + name for name in paths])
|
||||
# Include only the explicitly enumerated project files; never git add . / -A.
|
||||
staged = self.git('diff', '--cached', '--no-relative', '--name-only', '-z').split('\0')
|
||||
if any(name and name not in paths for name in staged):
|
||||
raise ValueError('Unexpected staged files; changes preserved for inspection')
|
||||
message_path = self.root / 'state/runs' / agent / 'commit-message.txt'
|
||||
message_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
message_path.write_text(message, encoding='utf-8')
|
||||
self.git('commit', '--file', str(message_path))
|
||||
commit = self.git('rev-parse', 'HEAD').strip()
|
||||
if self.changes():
|
||||
raise ValueError('Git hook or concurrent writer left changes after commit; inspect before next agent')
|
||||
return commit
|
||||
+31
-5
@@ -17,6 +17,7 @@ import uuid
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
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
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Real isolated Git repositories, fake agents; no user checkout commits."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
from test_automation import RunnerTests, RUN_MODULE
|
||||
|
||||
|
||||
class GitCommitTests(RunnerTests):
|
||||
def git(self, *args):
|
||||
return subprocess.check_output(['git', '-C', str(self.root), *args], text=True).strip()
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
(self.root / '.gitignore').write_text('state/runs/\n.runner.lock/\n.state.lock/\n__pycache__/\n')
|
||||
fake = self.root / 'fake_agent.py'
|
||||
text = fake.read_text()
|
||||
text = text.replace("if action=='review':t.mutate", """messages=r/'state/commit_messages';messages.mkdir(exist_ok=True)
|
||||
(messages/(agent+'.txt')).write_text(('Review restored input and record acceptance' if action=='review' else 'Restore required input within assigned scope')+'\\n\\nChanges: Record the bounded fixture result and handoff.\\nValidation: Isolated positive and negative fixture checks passed.\\n')
|
||||
if action=='claim':
|
||||
source=r/('src/fix.txt' if id.startswith('B') else 'src/'+str(int(id.split('-')[-1]))+'.txt');source.parent.mkdir(exist_ok=True);source.write_text('verified task '+id)
|
||||
if action=='review':t.mutate""")
|
||||
# Remediation IDs use the actual parent-derived naming convention.
|
||||
text = text.replace("id.startswith('B')", "id not in {'S00-001','S00-002','S00-003'}")
|
||||
fake.write_text(text)
|
||||
self.tracker.render()
|
||||
self.git('init', '-b', 'main')
|
||||
self.git('config', 'user.name', 'Fixture Agent')
|
||||
self.git('config', 'user.email', 'fixture@example.invalid')
|
||||
self.git('config', 'commit.gpgsign', 'false')
|
||||
self.git('add', '.')
|
||||
self.git('commit', '-m', 'Initialize isolated fixture')
|
||||
self.config['git_commits'] = True
|
||||
|
||||
def test_chain_commits_each_transition_with_task_and_actual_message(self):
|
||||
result = RUN_MODULE.Runner(self.root, self.config).run()
|
||||
self.assertEqual(result['stop'], 'complete', result)
|
||||
self.assertEqual(self.git('rev-list', '--count', 'HEAD'), '10')
|
||||
self.assertEqual(self.git('status', '--porcelain'), '')
|
||||
log = self.git('log', '--format=%B')
|
||||
self.assertIn('S00-001: Restore required input within assigned scope', log)
|
||||
self.assertIn('S00-001: Review restored input and record acceptance', log)
|
||||
self.assertIn('Record blocked task and remediation handoff', log)
|
||||
self.assertIn('Validation: Isolated positive and negative fixture checks passed.', log)
|
||||
self.assertEqual(result['last_commit'], self.git('rev-parse', 'HEAD'))
|
||||
commits = self.git('log', '--format=%H', '--grep=^S00-002: Restore').splitlines()
|
||||
files = self.git('show', '--format=', '--name-only', commits[0])
|
||||
self.assertIn('src/2.txt', files)
|
||||
self.assertIn('state/tasks.json', files)
|
||||
self.assertNotIn('output.log', files)
|
||||
|
||||
def test_dirty_existing_file_is_not_claimed_or_committed(self):
|
||||
before = self.tracker.state()
|
||||
(self.root / 'unrelated.txt').write_text('user work')
|
||||
with self.assertRaisesRegex(ValueError, 'must be clean'):
|
||||
RUN_MODULE.Runner(self.root, self.config).run()
|
||||
self.assertEqual(before, self.tracker.state())
|
||||
self.assertEqual(self.git('rev-list', '--count', 'HEAD'), '1')
|
||||
|
||||
def test_missing_message_stops_before_next_agent_and_preserves_work(self):
|
||||
fake = self.root / 'fake_agent.py'
|
||||
fake.write_text(fake.read_text().replace("(messages/(agent+'.txt')).write_text", "(messages/'wrong-name.txt').write_text"))
|
||||
self.git('add', 'fake_agent.py')
|
||||
self.git('commit', '-m', 'Simulate missing task message')
|
||||
result = RUN_MODULE.Runner(self.root, self.config).run()
|
||||
self.assertEqual(result['stop'], 'git_error')
|
||||
self.assertEqual(result['launched'], 2) # Blocker checkpoint, then failed resolver submit.
|
||||
self.assertIn('Missing task commit message', result['commit_error'])
|
||||
self.assertTrue(self.git('status', '--porcelain'))
|
||||
|
||||
def test_rejected_git_hook_stops_without_starting_another_agent(self):
|
||||
hook = self.root / '.git/hooks/pre-commit'
|
||||
hook.write_text('#!/bin/sh\nexit 1\n')
|
||||
hook.chmod(0o755)
|
||||
result = RUN_MODULE.Runner(self.root, self.config).run()
|
||||
self.assertEqual(result['stop'], 'git_error')
|
||||
self.assertEqual(result['launched'], 1)
|
||||
self.assertIn('Git commit failed', result['commit_error'])
|
||||
self.assertEqual(self.git('rev-list', '--count', 'HEAD'), '1')
|
||||
self.assertTrue(self.git('diff', '--cached', '--name-only'))
|
||||
|
||||
def test_changed_head_is_not_accepted_as_coordinator_commit(self):
|
||||
committer = RUN_MODULE.Runner(self.root, self.config).commits
|
||||
head = committer.preflight()
|
||||
self.git('commit', '--allow-empty', '-m', 'Unexpected agent commit')
|
||||
with self.assertRaisesRegex(ValueError, 'changed Git HEAD'):
|
||||
committer.commit({'id': 'S00-001'}, 'agent', {}, {}, head, 'Fixture')
|
||||
|
||||
def test_nested_project_rejects_edits_to_parent_files(self):
|
||||
project = self.root / 'nested'
|
||||
project.mkdir()
|
||||
committer = RUN_MODULE.TaskCommits(project)
|
||||
head = committer.preflight()
|
||||
(self.root / 'parent.txt').write_text('unrelated')
|
||||
with self.assertRaisesRegex(ValueError, 'outside project root'):
|
||||
committer.commit({'id': 'S00-001'}, 'agent', {}, {}, head, 'Fixture')
|
||||
self.assertEqual(self.git('diff', '--cached', '--name-only'), '')
|
||||
|
||||
def test_nested_project_commits_literal_paths_and_deletions(self):
|
||||
project = self.root / 'nested'
|
||||
project.mkdir()
|
||||
(project / '.gitignore').write_text('state/runs/\n')
|
||||
removed = project / 'old.txt'
|
||||
removed.write_text('old')
|
||||
self.git('add', 'nested')
|
||||
self.git('commit', '-m', 'Initialize nested fixture')
|
||||
committer = RUN_MODULE.TaskCommits(project)
|
||||
head = committer.preflight()
|
||||
removed.unlink()
|
||||
(project / 'file [1].txt').write_text('bounded output')
|
||||
draft = project / 'state/commit_messages/agent.txt'
|
||||
draft.parent.mkdir(parents=True)
|
||||
draft.write_text('Replace obsolete fixture with literal named output\n\nChanges: Replace the obsolete fixture file.\nValidation: Checked deletion and literal path staging.\n')
|
||||
# A nested foundation ships its own project-relative ignore rules.
|
||||
commit = committer.commit({'id': 'S00-001', 'role': 'front', 'action': 'claim'},
|
||||
'agent', {'status': 'review'}, {}, head, 'Fixture')
|
||||
files = self.git('show', '--format=', '--name-only', commit)
|
||||
self.assertIn('nested/file [1].txt', files)
|
||||
self.assertIn('nested/old.txt', files)
|
||||
self.assertFalse(removed.exists())
|
||||
self.assertEqual(self.git('status', '--porcelain'), '')
|
||||
|
||||
def test_commit_message_rejects_empty_or_unstructured_completion(self):
|
||||
committer = RUN_MODULE.TaskCommits(self.root)
|
||||
message = self.root / 'state/commit_messages/agent.txt'
|
||||
message.parent.mkdir(parents=True)
|
||||
message.write_text('Task done\n')
|
||||
with self.assertRaisesRegex(ValueError, 'concrete English subject'):
|
||||
committer.message({'id': 'S00-001'}, 'agent', {'status': 'review'}, {})
|
||||
|
||||
|
||||
def load_tests(loader, tests, pattern):
|
||||
return unittest.TestSuite(GitCommitTests(name) for name in GitCommitTests.__dict__ if name.startswith('test_'))
|
||||
Reference in new issue
Block a user