This commit is contained in:
1 parent
c1d4345316
commit
522311022f
12 files changed
+337
-22
No files matched your search
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user