This commit is contained in:
commit
54bfc0f34f
357 files changed
+41075
No files matched your search
@@ -0,0 +1,359 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sequential fresh-process agent coordinator. Requires a configured tool-capable CLI."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import fnmatch
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from tasks import Tracker, atomic_write, now, read_json
|
||||
|
||||
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',
|
||||
'planning/ROADMAP.md', 'planning/TRACEABILITY.md'}
|
||||
|
||||
|
||||
def snapshot(root):
|
||||
result = {}
|
||||
for path in root.rglob('*'):
|
||||
relative = path.relative_to(root)
|
||||
if any(x in EXCLUDED_DIRS for x in relative.parts):
|
||||
continue
|
||||
name = relative.as_posix()
|
||||
if name.startswith(('state/', 'planning/tasks/', 'planning/stages/')) or name in GENERATED:
|
||||
continue
|
||||
if path.is_symlink():
|
||||
result[name] = 'symlink:' + os.readlink(path)
|
||||
elif path.is_file():
|
||||
result[name] = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
return result
|
||||
|
||||
|
||||
def scope_violations(before, after, scope):
|
||||
changed = {p for p in set(before) | set(after) if before.get(p) != after.get(p)}
|
||||
return sorted(p for p in changed if not any(fnmatch.fnmatchcase(p, pattern) for pattern in scope))
|
||||
|
||||
|
||||
class Runner:
|
||||
def __init__(self, root, config):
|
||||
self.root = Path(root).resolve()
|
||||
self.tracker = Tracker(self.root)
|
||||
self.config = config
|
||||
self.lock_path = self.root / '.runner.lock'
|
||||
self.max_sessions = config.get('max_sessions', 1000)
|
||||
self.timeout = config.get('timeout_seconds', 1800)
|
||||
self.max_prompt = config.get('max_prompt_chars', 80000)
|
||||
if not isinstance(self.max_sessions, int) or not 1 <= self.max_sessions <= 10000:
|
||||
raise ValueError('max_sessions must be 1..10000')
|
||||
if not isinstance(self.timeout, (int, float)) or not 0 < self.timeout <= 86400:
|
||||
raise ValueError('timeout_seconds must be >0 and <=86400')
|
||||
if not isinstance(self.max_prompt, int) or not 48000 <= self.max_prompt <= 90000:
|
||||
raise ValueError('max_prompt_chars must be 48000..90000')
|
||||
|
||||
@contextlib.contextmanager
|
||||
def lock(self):
|
||||
try:
|
||||
self.lock_path.mkdir()
|
||||
except FileExistsError:
|
||||
raise ValueError('Another runner may be active; inspect .runner.lock/owner.json, do not start a second coordinator')
|
||||
try:
|
||||
atomic_write(self.lock_path / 'owner.json', json.dumps({'pid': os.getpid(), 'at': now()}))
|
||||
yield
|
||||
finally:
|
||||
(self.lock_path / 'owner.json').unlink(missing_ok=True)
|
||||
self.lock_path.rmdir()
|
||||
|
||||
def choose(self, stage=None):
|
||||
ready = self.tracker.ready()
|
||||
if stage:
|
||||
index = self.tracker.index()
|
||||
ready = [r for r in ready if index[r['id']]['stage'] == stage]
|
||||
return ready[0] if ready else None
|
||||
|
||||
def command(self, item, agent, prompt_path):
|
||||
commands = self.config.get('commands_by_role', {})
|
||||
argv = commands.get(item['role'], self.config.get('argv'))
|
||||
if not isinstance(argv, list) or not argv or not all(isinstance(x, str) and x for x in argv):
|
||||
raise ValueError('Configure argv as a nonempty JSON array (no shell string)')
|
||||
values = {'root': str(self.root), 'task': item['id'], 'role': item['role'],
|
||||
'agent': agent, 'action': item['action'], 'prompt_file': str(prompt_path)}
|
||||
expanded = []
|
||||
for argument in argv:
|
||||
for key, value in values.items():
|
||||
argument = argument.replace('{' + key + '}', value)
|
||||
if 'REPLACE_' in argument:
|
||||
raise ValueError('Set your tool-capable CLI command in runner.local.json first')
|
||||
expanded.append(argument)
|
||||
return expanded
|
||||
|
||||
def prompt(self, item, agent):
|
||||
tracker = self.tracker
|
||||
task = tracker.task(item['id'])
|
||||
assigned = f'''# Assignment from coordinator
|
||||
|
||||
Agent id: {agent}
|
||||
Role: {item['role']}
|
||||
Task: {item['id']}
|
||||
Action: {item['action']}
|
||||
|
||||
This is a fresh session. Do not resume a CLI conversation or choose another task.
|
||||
The coordinator has claimed executor work; reviewers only review this task.
|
||||
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.
|
||||
'''
|
||||
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']
|
||||
# Only bounded, concrete referenced upstream artifacts, never the entire repository.
|
||||
for raw in task['inputs']:
|
||||
candidate = raw.split(' (')[0]
|
||||
if candidate not in paths and (self.root / candidate).is_file():
|
||||
paths.append(candidate)
|
||||
row = tracker.state()['tasks'][item['id']]
|
||||
if row.get('handoff'):
|
||||
paths.append(row['handoff'])
|
||||
if item['action'] == 'review' and row.get('evidence'):
|
||||
paths.append(row['evidence'])
|
||||
chunks = [assigned]
|
||||
for rel in dict.fromkeys(paths):
|
||||
path = (self.root / rel).resolve()
|
||||
if not path.is_relative_to(self.root):
|
||||
raise ValueError('Prompt input escapes project root')
|
||||
if not path.is_file():
|
||||
raise ValueError('Required prompt input missing: ' + rel)
|
||||
chunks.append('\n# File: ' + rel + '\n\n' + path.read_text(encoding='utf-8'))
|
||||
chunks.append(tracker.packet(item['id'], agent))
|
||||
result = '\n'.join(chunks)
|
||||
if len(result) > self.max_prompt:
|
||||
raise ValueError('Prompt exceeds configured character budget; split context/task, do not truncate requirements')
|
||||
return result
|
||||
|
||||
def quarantine(self, task_id, previous, agent):
|
||||
with self.tracker.lock():
|
||||
state = self.tracker.state()
|
||||
current = state['tasks'][task_id]
|
||||
# Preserve the real transition history; withdraw an untrusted approval/submission.
|
||||
state['tasks'][task_id] = dict(previous)
|
||||
state['revision'] += 1
|
||||
state['events'].append({'revision': state['revision'], 'at': now(), 'action': 'runner-quarantine',
|
||||
'task': task_id, 'agent': agent, 'from': current['status'], 'to': previous['status']})
|
||||
state['retired_agents'] = [x for x in state.get('retired_agents', []) if x != agent]
|
||||
self.tracker.write_state(state)
|
||||
|
||||
def execution_issue(self, item, agent, reason, external=False, previous=None):
|
||||
task = self.tracker.task(item['id'])
|
||||
state = self.tracker.state()
|
||||
row = state['tasks'][item['id']]
|
||||
if row['status'] == 'blocked' and agent in state.get('retired_agents', []):
|
||||
return {'already_escalated': True}
|
||||
if row['status'] not in {'in_progress', 'review'} or (item['action'] == 'claim' and row.get('owner') != agent):
|
||||
if previous is None:
|
||||
raise ValueError('Cannot safely escalate unexpected state; inspect runner record')
|
||||
self.quarantine(item['id'], previous, agent)
|
||||
issue = {
|
||||
'title': 'Устранить прерывание: ' + task['title'], 'reason': reason,
|
||||
'role': task['role'], 'reviewer_role': task['reviewer_role'], 'scope': task['scope'][:6],
|
||||
'outcome': 'Устранить одну указанную причину прерывания и доказать готовность исходной задачи к новому запуску; не выполнять соседние задачи.',
|
||||
'steps': ['Прочитать blocker report и handoff.', 'Воспроизвести конкретную причину.', 'Исправить только локальную причину в scope или уточнить внешний input.', 'Выполнить focused проверку и submit.'],
|
||||
'acceptance': ['Причина воспроизводимо устранена.', 'Исходная задача не завершена обходом требований.'],
|
||||
'checks': ['Повторить сценарий прерывания.', 'Проверить отсутствие изменений вне scope.'],
|
||||
'requirements': [], 'requires_user_input': external,
|
||||
}
|
||||
return self.tracker.escalate(item['id'], agent, item['role'], issue)
|
||||
|
||||
def launch(self, item):
|
||||
agent = 'auto-' + item['role'] + '-' + uuid.uuid4().hex[:12]
|
||||
session = self.root / 'state/runs' / agent
|
||||
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)
|
||||
if item['action'] == 'claim':
|
||||
self.tracker.mutate('claim', item['id'], agent, role=item['role'])
|
||||
previous = dict(self.tracker.state()['tasks'][item['id']])
|
||||
try:
|
||||
prompt = self.prompt(item, agent)
|
||||
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}
|
||||
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))
|
||||
before = snapshot(self.root)
|
||||
env = os.environ.copy()
|
||||
env.update({'LUMA_TASK_ID': item['id'], 'LUMA_ROLE': item['role'], 'LUMA_AGENT_ID': agent,
|
||||
'LUMA_ACTION': item['action'], 'LUMA_PROJECT_ROOT': str(self.root), 'LUMA_PROMPT_FILE': str(prompt_path)})
|
||||
failure = None
|
||||
stopped_after_transition = False
|
||||
returncode = None
|
||||
with (session / 'output.log').open('wb') as output:
|
||||
try:
|
||||
process = subprocess.Popen(argv, cwd=self.root, env=env, stdin=subprocess.PIPE,
|
||||
stdout=output, stderr=subprocess.STDOUT, start_new_session=(os.name == 'posix'))
|
||||
deadline = time.monotonic() + self.timeout
|
||||
first_communicate = True
|
||||
stopped_after_transition = False
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
process.communicate(prompt.encode('utf-8') if first_communicate else None,
|
||||
timeout=min(.2, max(.01, deadline - time.monotonic())))
|
||||
break
|
||||
except subprocess.TimeoutExpired:
|
||||
first_communicate = False
|
||||
try:
|
||||
live = self.tracker.state()
|
||||
current = live['tasks'][item['id']]
|
||||
blocker_exit = current['status'] == 'blocked' and agent in live.get('retired_agents', [])
|
||||
executor_exit = item['action'] == 'claim' and current['status'] == 'review' and current.get('owner') == agent
|
||||
reviewer_exit = item['action'] == 'review' and current['status'] in {'done', 'todo'} and current.get('reviewer') == agent
|
||||
except ValueError:
|
||||
blocker_exit = executor_exit = reviewer_exit = False
|
||||
if blocker_exit or executor_exit or reviewer_exit:
|
||||
stop_process_tree(process)
|
||||
process.communicate()
|
||||
stopped_after_transition = True
|
||||
break
|
||||
if time.monotonic() >= deadline:
|
||||
stop_process_tree(process)
|
||||
process.communicate()
|
||||
failure = 'Agent timed out; process terminated before escalation'
|
||||
break
|
||||
except BaseException:
|
||||
stop_process_tree(process)
|
||||
process.communicate()
|
||||
raise
|
||||
finally:
|
||||
# Even an exited harness must not leave source-writing descendants alive.
|
||||
stop_process_tree(process)
|
||||
returncode = process.returncode
|
||||
except OSError as exc:
|
||||
failure = 'Configured agent executable failed to start: ' + type(exc).__name__
|
||||
violations = scope_violations(before, snapshot(self.root), task_scope(self.tracker, item['id']))
|
||||
row = self.tracker.state()['tasks'][item['id']]
|
||||
if violations:
|
||||
self.quarantine(item['id'], previous, agent)
|
||||
result = self.execution_issue(item, agent, 'Scope violation: ' + ', '.join(violations[:10]) + '; inspect run record before permitting further edits.', external=True, previous=previous)
|
||||
elif row['status'] == 'blocked' and agent in self.tracker.state().get('retired_agents', []):
|
||||
result = {'blocked': True, 'resolution_tasks': row.get('blocked_by', [])}
|
||||
elif failure or (returncode != 0 and not stopped_after_transition):
|
||||
result = self.execution_issue(item, agent, failure or 'Agent exited with code ' + str(returncode), external=returncode is None, previous=previous)
|
||||
elif item['action'] == 'claim' and row['status'] != 'review':
|
||||
result = self.execution_issue(item, agent, 'Agent exited without submit or escalate', previous=previous)
|
||||
elif item['action'] == 'review' and row['status'] not in {'done', 'todo'}:
|
||||
result = self.execution_issue(item, agent, 'Reviewer exited without approve/reject/escalate', previous=previous)
|
||||
else:
|
||||
result = {'status': row['status']}
|
||||
record.update(status='finished', finished_at=now(), returncode=returncode, stopped_after_transition=stopped_after_transition, scope_violations=violations, result=result)
|
||||
atomic_write(session / 'run.json', json.dumps(record, ensure_ascii=False, indent=2))
|
||||
self.tracker.render()
|
||||
self.tracker.validate()
|
||||
return record
|
||||
|
||||
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)
|
||||
if dry_run:
|
||||
item = self.choose(stage)
|
||||
if not item:
|
||||
return self.summary('idle', [])
|
||||
prompt = self.prompt(item, 'auto-preview')
|
||||
try:
|
||||
self.command(item, 'auto-preview', self.root / 'state/runs/preview/prompt.md')
|
||||
configuration_required = False
|
||||
except ValueError:
|
||||
configuration_required = True
|
||||
return {'dry_run': True, 'next': item, 'prompt_characters': len(prompt),
|
||||
'configuration_required': configuration_required, 'state_unchanged': True}
|
||||
records = []
|
||||
with self.lock():
|
||||
self.tracker.validate()
|
||||
for _ in range(self.max_sessions):
|
||||
item = self.choose(stage)
|
||||
if not item:
|
||||
return self.summary('idle', records)
|
||||
records.append(self.launch(item))
|
||||
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)
|
||||
|
||||
def summary(self, stop, records):
|
||||
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,
|
||||
'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}
|
||||
|
||||
|
||||
def stop_process_tree(process):
|
||||
if os.name == 'posix':
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
elif process.poll() is None:
|
||||
process.terminate()
|
||||
if process.poll() is None:
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
if os.name == 'posix':
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
else:
|
||||
process.kill()
|
||||
process.wait()
|
||||
if os.name == 'posix':
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
|
||||
def task_scope(tracker, task_id):
|
||||
return tracker.task(task_id)['scope']
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--config', default='runner.local.json')
|
||||
parser.add_argument('--dry-run', action='store_true')
|
||||
parser.add_argument('--stage')
|
||||
parser.add_argument('--max-sessions', type=int)
|
||||
args = parser.parse_args()
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
path = root / args.config
|
||||
try:
|
||||
if path.is_file():
|
||||
config = read_json(path)
|
||||
elif args.dry_run:
|
||||
config = {}
|
||||
else:
|
||||
raise ValueError('Create runner.local.json from runner.example.json and set a tool-capable CLI argv once')
|
||||
if args.max_sessions is not None:
|
||||
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:
|
||||
print(json.dumps({'stop': 'configuration_or_workflow_error', 'reason': str(exc)}, ensure_ascii=False), file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Blocker -> bounded remediation task -> fresh execution. Uses Tracker's lock/journal."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def check_dag(data):
|
||||
index = {t['id']: t for t in data['tasks']}
|
||||
visited, active = set(), set()
|
||||
def visit(key):
|
||||
if key in active:
|
||||
raise ValueError('Remediation would create a dependency cycle: ' + key)
|
||||
if key in visited:
|
||||
return
|
||||
if key not in index:
|
||||
raise ValueError('Unknown remediation dependency: ' + key)
|
||||
active.add(key)
|
||||
for dep in index[key]['depends_on']:
|
||||
visit(dep)
|
||||
active.remove(key)
|
||||
visited.add(key)
|
||||
for key in index:
|
||||
visit(key)
|
||||
|
||||
|
||||
def escalate(tracker, task_id, agent, role, issue, handoff=None):
|
||||
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,79}', agent or ''):
|
||||
raise ValueError('A safe agent id is required')
|
||||
if not isinstance(issue, dict):
|
||||
raise ValueError('Issue must be a JSON object')
|
||||
for field in ['title', 'reason', 'outcome', 'role']:
|
||||
if not isinstance(issue.get(field), str) or not issue[field].strip():
|
||||
raise ValueError('Issue requires nonempty ' + field)
|
||||
for field, maximum in [('scope', 6), ('steps', 8), ('acceptance', 6), ('checks', 6)]:
|
||||
value = issue.get(field)
|
||||
if not isinstance(value, list) or not 1 <= len(value) <= maximum or not all(isinstance(x, str) and x.strip() for x in value):
|
||||
raise ValueError('Issue requires a bounded nonempty list: ' + field)
|
||||
if 'requires_user_input' in issue and not isinstance(issue['requires_user_input'], bool):
|
||||
raise ValueError('requires_user_input must be a JSON boolean')
|
||||
for section_key, maximum in [('spec_sections', 27), ('ui_sections', 26)]:
|
||||
sections = issue.get(section_key, [])
|
||||
if not isinstance(sections, list) or len(sections) > 6 or any(type(x) is not int or not 1 <= x <= maximum for x in sections):
|
||||
raise ValueError('Invalid/broad remediation context sections')
|
||||
if len(json.dumps(issue, ensure_ascii=False)) > 16000:
|
||||
raise ValueError('Issue too large; describe one atomic cause')
|
||||
with tracker.lock():
|
||||
data, state = tracker.definitions(), tracker.state()
|
||||
if agent in state.get('retired_agents', []):
|
||||
raise ValueError('Agent has ended its session after a blocker; use a fresh agent')
|
||||
index = {t['id']: t for t in data['tasks']}
|
||||
if task_id not in index:
|
||||
raise ValueError('Unknown original task')
|
||||
parent, row = index[task_id], state['tasks'][task_id]
|
||||
allowed_owner = row['status'] == 'in_progress' and row.get('owner') == agent and role == parent['role']
|
||||
allowed_reviewer = row['status'] == 'review' and agent != row.get('owner') and role == parent['reviewer_role']
|
||||
if not (allowed_owner or allowed_reviewer):
|
||||
raise ValueError('Only current executor or assigned-role reviewer may escalate its current task')
|
||||
roles = tracker.roles()
|
||||
if issue['role'] not in roles or issue.get('reviewer_role', parent['reviewer_role']) not in roles:
|
||||
raise ValueError('Unknown remediation operator/reviewer')
|
||||
for scope in issue['scope']:
|
||||
if Path(scope).is_absolute() or '..' in Path(scope).parts or scope in {'*', '**', '**/*', '.'}:
|
||||
raise ValueError('Remediation must have bounded project-local scope')
|
||||
if scope.startswith(('specs/', 'planning/', 'state/')):
|
||||
raise ValueError('Remediation source scope cannot rewrite specs/workflow state')
|
||||
extra = issue.get('depends_on', [])
|
||||
if not isinstance(extra, list) or any(d not in index for d in extra):
|
||||
raise ValueError('Remediation dependencies must reference existing task IDs')
|
||||
if task_id in extra:
|
||||
raise ValueError('Remediation cannot depend on its blocked parent')
|
||||
depth = parent.get('blocker_depth', 0) + 1
|
||||
root_id = parent.get('root_task', task_id)
|
||||
previous = sum(t.get('kind') == 'blocker' and t.get('root_task') == root_id for t in data['tasks'])
|
||||
external = bool(issue.get('requires_user_input')) or depth > 3 or previous >= 6
|
||||
trigger = issue['reason']
|
||||
if depth > 3 or previous >= 6:
|
||||
trigger += '\nAutomatic blocker limit reached; explicit external resolution required.'
|
||||
ordinal = max(int(t['id'].split('-')[1]) for t in data['tasks'] if t['stage'] == parent['stage']) + 1
|
||||
if ordinal > 999:
|
||||
raise ValueError('Stage task ID space exhausted')
|
||||
child_id = f'{parent["stage"]}-{ordinal:03}'
|
||||
requirements = issue.get('requirements', parent['requirements'])
|
||||
allowed_reqs = {r['id'] for r in tracker.requirements() if r['applicability'] != 'out_of_scope'}
|
||||
if not isinstance(requirements, list) or any(r not in allowed_reqs for r in requirements):
|
||||
raise ValueError('Unknown/out-of-scope remediation requirement')
|
||||
report = f'state/blockers/{child_id}.md'
|
||||
child = {
|
||||
'id': child_id, 'stage': parent['stage'], 'kind': 'blocker', 'root_task': root_id,
|
||||
'blocker_depth': depth, 'unblocks': [task_id], 'role': issue['role'],
|
||||
'reviewer_role': issue.get('reviewer_role', parent['reviewer_role']),
|
||||
'title': issue['title'], 'outcome': issue['outcome'],
|
||||
'depends_on': list(dict.fromkeys(parent['depends_on'] + extra)),
|
||||
'requirements': list(dict.fromkeys(requirements)), 'scope': issue['scope'],
|
||||
'inputs': list(dict.fromkeys(parent['inputs'] + [report])),
|
||||
'spec_sections': issue.get('spec_sections', []), 'ui_sections': issue.get('ui_sections', []),
|
||||
'steps': issue['steps'], 'acceptance': issue['acceptance'], 'checks': issue['checks'],
|
||||
'context_budget_chars': 48000,
|
||||
}
|
||||
# Keep the original stage gate last; dynamic task IDs are never reused.
|
||||
gate_id = next(s.get('exit_gate') for s in data['stages'] if s['id'] == parent['stage'])
|
||||
insert_at = next((i for i, t in enumerate(data['tasks']) if t['id'] == gate_id), len(data['tasks']))
|
||||
data['tasks'].insert(insert_at, child)
|
||||
parent['depends_on'].append(child_id)
|
||||
check_dag(data)
|
||||
old_status, old_owner = row['status'], row.get('owner')
|
||||
row.update(status='blocked', reason=trigger, blocked_by=[child_id], resume_status=old_status,
|
||||
blocker_report=report, updated_at=tracker.timestamp())
|
||||
if handoff:
|
||||
row['handoff'] = tracker.safe_file(handoff, 'state/handoffs')
|
||||
else:
|
||||
row['handoff'] = f'state/handoffs/{task_id}.md'
|
||||
state['tasks'][child_id] = {'status': 'blocked' if external else 'todo', 'owner': None,
|
||||
'external_wait': external, 'reason': trigger if external else None}
|
||||
state.setdefault('retired_agents', []).append(agent)
|
||||
state['retired_agents'] = list(dict.fromkeys(state['retired_agents']))
|
||||
state['revision'] += 1
|
||||
state['events'].append({'revision': state['revision'], 'at': tracker.timestamp(), 'action': 'escalate',
|
||||
'task': task_id, 'resolution_task': child_id, 'agent': agent, 'role': role,
|
||||
'from': old_status, 'to': 'blocked', 'reason': trigger})
|
||||
issue_text = '\n'.join([
|
||||
'# Blocker ' + child_id, '', '- Original task: ' + task_id, '- Root task: ' + root_id,
|
||||
'- Reporter: ' + agent, '- Role: ' + role, '- Original owner: ' + str(old_owner),
|
||||
'- Waits for user/external condition: ' + str(external), '', '## Cause', '', trigger,
|
||||
'', '## One remediation outcome', '', issue['outcome'], '',
|
||||
'## Evidence / reproduction', '', issue.get('reproduction', 'See original handoff and task evidence.'), '',
|
||||
'The reporting agent must stop now. A new agent implements only ' + child_id + '.', '',
|
||||
])
|
||||
artifacts = {report: issue_text, f"planning/tasks/{child_id}.md": tracker.card(child)}
|
||||
if not handoff:
|
||||
existing = tracker.root / row['handoff']
|
||||
previous_text = existing.read_text(encoding='utf-8') if existing.is_file() else ''
|
||||
artifacts[row['handoff']] = (previous_text + '\n\n# Blocked handoff\n\n' +
|
||||
f'Original: {task_id}; reporter: {agent}; resolution: {child_id}.\n\n' + trigger +
|
||||
'\n\nNext: complete and review the remediation; only a fresh session may continue.\n')
|
||||
tracker.commit_plan_state(data, state, artifacts)
|
||||
return {'task': task_id, 'status': 'blocked', 'resolution_task': child_id,
|
||||
'resolution_status': state['tasks'][child_id]['status'], 'agent_must_exit': True,
|
||||
'requires_user_input': external, 'revision': state['revision']}
|
||||
|
||||
|
||||
def unblock_resolved(tracker, state, completed_id):
|
||||
"""Called under lock as part of approval; no human/old-agent unblock needed."""
|
||||
for parent_id, row in state['tasks'].items():
|
||||
blockers = row.get('blocked_by', [])
|
||||
if row['status'] != 'blocked' or completed_id not in blockers:
|
||||
continue
|
||||
if not all(state['tasks'][b]['status'] == 'done' for b in blockers):
|
||||
continue
|
||||
row['previous_owner'] = row.get('owner')
|
||||
if row.get('resume_status') == 'review':
|
||||
row.update(status='review', reason=None)
|
||||
else:
|
||||
row.update(status='todo', owner=None, reason=None)
|
||||
row.update(blocked_by=[], resumed_after_blocker=True, updated_at=tracker.timestamp())
|
||||
state['events'].append({'revision': state['revision'] + 1, 'at': tracker.timestamp(),
|
||||
'action': 'auto-unblock', 'task': parent_id, 'resolved_by': completed_id,
|
||||
'to': row['status'], 'fresh_agent_required': True})
|
||||
|
||||
|
||||
def release_external(tracker, task_id, agent, role, reason):
|
||||
if role != 'orchestrator' or not reason or not reason.strip():
|
||||
raise ValueError('External release requires orchestrator and concrete resolution reason')
|
||||
with tracker.lock():
|
||||
state = tracker.state()
|
||||
if agent in state.get('retired_agents', []):
|
||||
raise ValueError('Use a fresh coordinator session')
|
||||
row = state['tasks'].get(task_id)
|
||||
if not row or row['status'] != 'blocked' or not row.get('external_wait'):
|
||||
raise ValueError('Not an externally waiting remediation')
|
||||
row.update(status='todo', owner=None, external_wait=False, reason=reason)
|
||||
state['revision'] += 1
|
||||
state['events'].append({'revision': state['revision'], 'at': tracker.timestamp(),
|
||||
'action': 'release-external', 'task': task_id, 'agent': agent, 'reason': reason})
|
||||
tracker.write_state(state)
|
||||
return {'id': task_id, 'status': 'todo', 'verification_required': True}
|
||||
@@ -0,0 +1,553 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dependency-free, repository-local task workflow. No model/network execution."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import csv
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import task_extensions
|
||||
|
||||
STATUSES = {'todo', 'in_progress', 'review', 'done', 'blocked'}
|
||||
ACTIVE = {'in_progress', 'review', 'blocked'}
|
||||
|
||||
|
||||
def now():
|
||||
return datetime.now(timezone.utc).isoformat(timespec='seconds')
|
||||
|
||||
|
||||
def read_json(path):
|
||||
return json.loads(path.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
def atomic_write(path, text):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, name = tempfile.mkstemp(prefix='.' + path.name, dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, 'w', encoding='utf-8', newline='') as handle:
|
||||
handle.write(text)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(name, path)
|
||||
finally:
|
||||
if os.path.exists(name):
|
||||
os.unlink(name)
|
||||
|
||||
|
||||
class Tracker:
|
||||
def __init__(self, root):
|
||||
self.root = Path(root).resolve()
|
||||
self.defs_path = self.root / 'planning/tasks.json'
|
||||
self.state_path = self.root / 'state/tasks.json'
|
||||
self.lock_path = self.root / '.state.lock'
|
||||
self.journal_path = self.root / "state/plan-transaction.json"
|
||||
self._locked = False
|
||||
|
||||
def definitions(self):
|
||||
self.recover_if_needed()
|
||||
return read_json(self.defs_path)
|
||||
|
||||
def state(self):
|
||||
self.recover_if_needed()
|
||||
return read_json(self.state_path)
|
||||
|
||||
def plan_state(self):
|
||||
if self._locked:
|
||||
return read_json(self.defs_path), read_json(self.state_path)
|
||||
with self.lock():
|
||||
return read_json(self.defs_path), read_json(self.state_path)
|
||||
|
||||
def roles(self):
|
||||
return read_json(self.root / "roles/index.json")["roles"]
|
||||
|
||||
def requirements(self):
|
||||
return read_json(self.root / "planning/requirements.json")
|
||||
|
||||
@staticmethod
|
||||
def timestamp():
|
||||
return now()
|
||||
|
||||
def write_state(self, state):
|
||||
atomic_write(self.state_path, json.dumps(state, ensure_ascii=False, indent=2) + "\n")
|
||||
|
||||
def recover_if_needed(self):
|
||||
if self.journal_path.exists() and not self._locked:
|
||||
with self.lock():
|
||||
pass
|
||||
|
||||
def recover_transaction(self):
|
||||
if not self.journal_path.exists():
|
||||
return
|
||||
journal = read_json(self.journal_path)
|
||||
for rel, content in journal.get("artifacts", {}).items():
|
||||
path = (self.root / rel).resolve()
|
||||
is_state = path.is_relative_to(self.root / "state")
|
||||
is_card = path.is_relative_to(self.root / "planning/tasks") and path.suffix == ".md"
|
||||
if not (is_state or is_card):
|
||||
raise ValueError("Unsafe plan transaction artifact")
|
||||
atomic_write(path, content)
|
||||
atomic_write(self.defs_path, json.dumps(journal["definitions"], ensure_ascii=False, indent=2) + "\n")
|
||||
self.write_state(journal["state"])
|
||||
self.journal_path.unlink()
|
||||
|
||||
def commit_plan_state(self, definitions, state, artifacts=None):
|
||||
if not self._locked:
|
||||
raise ValueError("Plan/state transaction requires lock")
|
||||
journal = {"definitions": definitions, "state": state, "artifacts": artifacts or {}}
|
||||
atomic_write(self.journal_path, json.dumps(journal, ensure_ascii=False, indent=2) + "\n")
|
||||
self.recover_transaction()
|
||||
|
||||
def escalate(self, task_id, agent, role, issue, handoff=None):
|
||||
return task_extensions.escalate(self, task_id, agent, role, issue, handoff)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def lock(self):
|
||||
try:
|
||||
self.lock_path.mkdir()
|
||||
except FileExistsError:
|
||||
raise ValueError('State lock exists. Inspect .state.lock/owner.json; do not steal a live lock.')
|
||||
try:
|
||||
(self.lock_path / 'owner.json').write_text(json.dumps({'pid': os.getpid(), 'at': now()}), encoding='utf-8')
|
||||
self._locked = True
|
||||
self.recover_transaction()
|
||||
yield
|
||||
finally:
|
||||
self._locked = False
|
||||
(self.lock_path / 'owner.json').unlink(missing_ok=True)
|
||||
self.lock_path.rmdir()
|
||||
|
||||
def index(self):
|
||||
return {t['id']: t for t in self.definitions()['tasks']}
|
||||
|
||||
def missing(self, task, state):
|
||||
return [d for d in task['depends_on'] if state['tasks'].get(d, {}).get('status') != 'done']
|
||||
|
||||
def task(self, task_id):
|
||||
try:
|
||||
return self.index()[task_id]
|
||||
except KeyError:
|
||||
raise ValueError('Unknown task: ' + task_id)
|
||||
|
||||
def safe_file(self, raw, folder, minimum=40):
|
||||
if not isinstance(raw, str) or not raw:
|
||||
raise ValueError("Evidence/handoff path is required")
|
||||
path = Path(raw)
|
||||
if not path.is_absolute():
|
||||
path = self.root / path
|
||||
resolved = path.resolve()
|
||||
base = (self.root / folder).resolve()
|
||||
if not resolved.is_relative_to(base) or resolved == base:
|
||||
raise ValueError('Path must remain inside ' + folder)
|
||||
relative = path.absolute().relative_to(self.root)
|
||||
cursor = self.root
|
||||
for part in relative.parts:
|
||||
cursor = cursor / part
|
||||
if cursor.is_symlink():
|
||||
raise ValueError('Symlink paths are not allowed for evidence/handoff')
|
||||
if not resolved.is_file() or resolved.stat().st_size < minimum:
|
||||
raise ValueError('A real nonempty evidence/handoff file is required: ' + str(raw))
|
||||
return resolved.relative_to(self.root).as_posix()
|
||||
|
||||
def ready(self, role=None):
|
||||
if role is not None and role not in read_json(self.root / "roles/index.json")["roles"]:
|
||||
raise ValueError("Unknown operator role: " + role)
|
||||
data, state = self.plan_state()
|
||||
out = []
|
||||
for task in data['tasks']:
|
||||
row = state['tasks'][task['id']]
|
||||
if row['status'] == 'review' and (role is None or role == task['reviewer_role']):
|
||||
out.append({'id': task['id'], 'action': 'review', 'role': task['reviewer_role'], 'title': task['title']})
|
||||
elif row['status'] == 'todo' and not self.missing(task, state) and (role is None or task['role'] == role):
|
||||
out.append({'id': task['id'], 'action': 'claim', 'role': task['role'], 'title': task['title']})
|
||||
index = {t['id']: t for t in data['tasks']}
|
||||
return sorted(out, key=lambda x: (0 if index[x['id']].get('kind') == 'blocker' else 1 if x['action'] == 'review' else 2 if state['tasks'][x['id']].get('resumed_after_blocker') else 3, x['id']))
|
||||
|
||||
def mutate(self, action, task_id, agent, role=None, reason=None, evidence=None, handoff=None, target=None):
|
||||
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,79}', agent or ''):
|
||||
raise ValueError('Use a nonempty safe agent id (letters/digits/._-, max 80)')
|
||||
with self.lock():
|
||||
task = self.task(task_id)
|
||||
state = self.state()
|
||||
if agent in state.get('retired_agents', []):
|
||||
raise ValueError('Agent ended after blocker; a fresh session is required')
|
||||
if action in {'block', 'unblock'}:
|
||||
raise ValueError('Use escalate with a remediation task; tracked blockers unblock automatically after review')
|
||||
row = state['tasks'][task_id]
|
||||
status = row['status']
|
||||
owner = row.get('owner')
|
||||
if action == 'claim':
|
||||
if role != task['role']:
|
||||
raise ValueError('Required operator role: ' + task['role'])
|
||||
if status != 'todo' or self.missing(task, state):
|
||||
raise ValueError('Task unavailable; status/dependencies: ' + status + ' ' + str(self.missing(task, state)))
|
||||
if any(v.get('owner') == agent and v['status'] in ACTIVE for v in state['tasks'].values()):
|
||||
raise ValueError('Agent already owns unfinished work. Use resume.')
|
||||
row.update(status='in_progress', owner=agent, started_at=now(), reason=None)
|
||||
elif action in {'submit', 'block', 'unblock', 'reassign'}:
|
||||
if owner != agent:
|
||||
raise ValueError('Only current owner can ' + action)
|
||||
if action == 'submit':
|
||||
if status != 'in_progress' or self.missing(task, state):
|
||||
raise ValueError('Only dependency-ready in_progress work can be submitted')
|
||||
row.update(status='review', evidence=self.safe_file(evidence, 'state/evidence'), handoff=self.safe_file(handoff, 'state/handoffs'), reason=None)
|
||||
elif action == 'block':
|
||||
if status not in {'in_progress', 'review'} or not reason or not reason.strip():
|
||||
raise ValueError('Blocking needs active task and specific reason')
|
||||
row.update(status='blocked', reason=reason)
|
||||
elif action == 'unblock':
|
||||
if status != 'blocked' or not reason or not reason.strip():
|
||||
raise ValueError('Unblock needs blocked status and resolution reason')
|
||||
row.update(status='in_progress', reason=reason)
|
||||
else:
|
||||
if row.get('blocked_by'):
|
||||
raise ValueError('Tracked blocker waits for reviewed remediation, not reassignment')
|
||||
if status not in {'in_progress', 'blocked'} or not reason or not reason.strip():
|
||||
raise ValueError('Reassign requires in_progress/blocked and explicit reason')
|
||||
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,79}', target or '') or target == agent:
|
||||
raise ValueError('New owner must be a distinct safe agent id')
|
||||
if target in state.get('retired_agents', []):
|
||||
raise ValueError('Retired reporter cannot be reassigned work')
|
||||
if any(v.get('owner') == target and v['status'] in ACTIVE for v in state['tasks'].values()):
|
||||
raise ValueError('New owner already has unfinished work')
|
||||
row.update(owner=target, reason=reason)
|
||||
elif action in {'approve', 'reject'}:
|
||||
if status != 'review' or agent == owner or role != task['reviewer_role']:
|
||||
raise ValueError('Review requires review state, distinct reviewer and role ' + task['reviewer_role'])
|
||||
if action == 'approve':
|
||||
if self.missing(task, state):
|
||||
raise ValueError('Dependencies no longer complete')
|
||||
row.update(status='done', reviewer=agent, review_evidence=self.safe_file(evidence, 'state/evidence'), completed_at=now(), reason=None)
|
||||
task_extensions.unblock_resolved(self, state, task_id)
|
||||
else:
|
||||
if not reason or not reason.strip():
|
||||
raise ValueError('Rejection needs actionable reason')
|
||||
row.update(status='todo', owner=None, previous_owner=owner, reviewer=agent, reason=reason)
|
||||
else:
|
||||
raise ValueError('Unsupported transition')
|
||||
state['revision'] += 1
|
||||
row['updated_at'] = now()
|
||||
event = {'revision': state['revision'], 'at': now(), 'action': action, 'task': task_id, 'agent': agent, 'role': role, 'from': status, 'to': row['status'], 'reason': reason}
|
||||
if action in {'submit', 'approve'}:
|
||||
event['evidence'] = row.get('review_evidence') if action == 'approve' else row.get('evidence')
|
||||
if action == 'reassign':
|
||||
event['new_owner'] = target
|
||||
state['events'].append(event)
|
||||
atomic_write(self.state_path, json.dumps(state, ensure_ascii=False, indent=2) + '\n')
|
||||
return {'id': task_id, 'revision': state['revision'], **row}
|
||||
|
||||
def sync(self):
|
||||
with self.lock():
|
||||
definitions = self.index()
|
||||
state = self.state()
|
||||
removed = set(state['tasks']) - set(definitions)
|
||||
if removed:
|
||||
raise ValueError('Cannot silently remove task history: ' + str(sorted(removed)))
|
||||
added = []
|
||||
for task_id in definitions:
|
||||
if task_id not in state['tasks']:
|
||||
state['tasks'][task_id] = {'status': 'todo', 'owner': None}
|
||||
added.append(task_id)
|
||||
if added:
|
||||
state['revision'] += 1
|
||||
state['events'].append({'revision': state['revision'], 'at': now(), 'action': 'sync', 'added': added})
|
||||
atomic_write(self.state_path, json.dumps(state, ensure_ascii=False, indent=2) + '\n')
|
||||
return added
|
||||
|
||||
def validate(self, generated=True):
|
||||
if self._locked:
|
||||
return self._validate(generated)
|
||||
with self.lock():
|
||||
return self._validate(generated)
|
||||
|
||||
def _validate(self, generated=True):
|
||||
data = self.definitions()
|
||||
tasks = data['tasks']
|
||||
ids = [t['id'] for t in tasks]
|
||||
if len(ids) != len(set(ids)):
|
||||
raise ValueError('Duplicate task IDs')
|
||||
index = self.index()
|
||||
roles = read_json(self.root / 'roles/index.json')['roles']
|
||||
stages = {s['id'] for s in data['stages']}
|
||||
if len(stages) != len(data["stages"]):
|
||||
raise ValueError("Duplicate stage IDs")
|
||||
requirements = read_json(self.root / 'planning/requirements.json')
|
||||
req_index = {r['id']: r for r in requirements}
|
||||
seen, walking = set(), set()
|
||||
def visit(task_id):
|
||||
if task_id in walking:
|
||||
raise ValueError('Dependency cycle: ' + task_id)
|
||||
if task_id in seen:
|
||||
return
|
||||
walking.add(task_id)
|
||||
for dep in index[task_id]['depends_on']:
|
||||
if dep not in index:
|
||||
raise ValueError('Missing dependency: ' + dep)
|
||||
visit(dep)
|
||||
walking.remove(task_id)
|
||||
seen.add(task_id)
|
||||
coverage = set()
|
||||
for task in tasks:
|
||||
if not re.fullmatch(r'S\d{2}-\d{3}', task['id']) or task['stage'] not in stages or not task["id"].startswith(task["stage"] + "-"):
|
||||
raise ValueError('Invalid task/stage ID')
|
||||
if task['role'] not in roles or task['reviewer_role'] not in roles:
|
||||
raise ValueError('Unknown role: ' + task['id'])
|
||||
for key in ['title', 'outcome', 'scope', 'inputs', 'steps', 'acceptance', 'checks']:
|
||||
if not task.get(key):
|
||||
raise ValueError('Missing field ' + key + ' for ' + task['id'])
|
||||
for path in task['scope']:
|
||||
if Path(path).is_absolute() or '..' in Path(path).parts:
|
||||
raise ValueError('Unsafe task scope')
|
||||
for req in task['requirements']:
|
||||
if req not in req_index or req_index[req]['applicability'] == 'out_of_scope':
|
||||
raise ValueError('Unknown/out-of-scope requirement: ' + req)
|
||||
coverage.add(req)
|
||||
visit(task['id'])
|
||||
if generated and not (self.root / f'planning/tasks/{task["id"]}.md').is_file():
|
||||
raise ValueError('Missing task card ' + task['id'])
|
||||
uncovered = {r['id'] for r in requirements if r['applicability'] != 'out_of_scope'} - coverage
|
||||
if uncovered:
|
||||
raise ValueError('Unallocated Web requirements: ' + str(sorted(uncovered)))
|
||||
for role in roles:
|
||||
if not (self.root / f'roles/{role}.md').is_file():
|
||||
raise ValueError('Role instructions missing: ' + role)
|
||||
manifest = read_json(self.root / 'specs/manifest.json')
|
||||
for name, expected in manifest['sha256'].items():
|
||||
if hashlib.sha256((self.root / 'specs' / name).read_bytes()).hexdigest() != expected:
|
||||
raise ValueError('Original spec snapshot changed: ' + name)
|
||||
source_ids = set()
|
||||
for name in manifest['sha256']:
|
||||
source_ids.update(re.findall(r'^\*\*([A-Z][A-Z0-9-]+)\.\*\*', (self.root / 'specs' / name).read_text(encoding='utf-8'), re.M))
|
||||
if source_ids != set(req_index) or len(requirements) != len(req_index):
|
||||
raise ValueError('Requirement index differs from original specs')
|
||||
state = self.state()
|
||||
if set(state['tasks']) != set(index):
|
||||
raise ValueError('Definition/state IDs differ; run sync for additions')
|
||||
owners = set()
|
||||
for task_id, row in state['tasks'].items():
|
||||
if row.get('status') not in STATUSES:
|
||||
raise ValueError('Invalid status ' + task_id)
|
||||
if row['status'] != 'todo' and not row.get('owner') and not (row['status'] == 'blocked' and row.get('external_wait')):
|
||||
raise ValueError('Active/completed work has no owner')
|
||||
if row['status'] in ACTIVE and row.get('owner'):
|
||||
if row['owner'] in owners:
|
||||
raise ValueError('Owner has multiple unfinished tasks')
|
||||
owners.add(row['owner'])
|
||||
if any(b not in index or b not in index[task_id]['depends_on'] for b in row.get('blocked_by', [])):
|
||||
raise ValueError('Invalid tracked blocker dependency')
|
||||
if row['status'] == 'in_progress' and row.get('owner') in state.get('retired_agents', []):
|
||||
raise ValueError('Retired reporter cannot resume execution')
|
||||
if row['status'] == 'blocked' and not row.get('reason'):
|
||||
raise ValueError('Blocked task lacks reason')
|
||||
if row['status'] in {'review', 'done'}:
|
||||
self.safe_file(row.get('evidence'), 'state/evidence')
|
||||
self.safe_file(row.get('handoff'), 'state/handoffs')
|
||||
if row['status'] == 'done':
|
||||
if not row.get('reviewer') or row['reviewer'] == row['owner'] or self.missing(index[task_id], state):
|
||||
raise ValueError('Invalid done/review/dependency state')
|
||||
self.safe_file(row.get('review_evidence'), 'state/evidence')
|
||||
return {'tasks': len(tasks), 'stages': len(stages), 'requirements': len(requirements), 'allocated_web_requirements': len(coverage), 'revision': state['revision']}
|
||||
|
||||
@staticmethod
|
||||
def section(text, number):
|
||||
match = re.search(r'^## ' + re.escape(str(number)) + r'\. .*$', text, re.M)
|
||||
if not match:
|
||||
raise ValueError('Missing specification section ' + str(number))
|
||||
end = re.search(r'^## ', text[match.end():], re.M)
|
||||
return text[match.start():match.end() + end.start() if end else len(text)].strip()
|
||||
|
||||
def card(self, task):
|
||||
out = [f'# {task["id"]}: {task["title"]}', '', '> Generated from planning/tasks.json; status is in state/tasks.json.', '', f'- Этап: {task["stage"]}', f'- Оператор: {task["role"]}', f'- Reviewer: {task["reviewer_role"]}', f'- Зависимости: {", ".join(task["depends_on"]) or "нет"}', f'- Требования: {", ".join(task["requirements"]) or "инженерная подготовка"}', '', '## Один результат', '', task['outcome'], '', '## Разрешённые пути', '']
|
||||
if task.get('kind') == 'blocker':
|
||||
out[2:2] = [f"- Remediation для: {', '.join(task.get('unblocks', []))}; root: {task.get('root_task')}; depth: {task.get('blocker_depth')}", '']
|
||||
out += ['- `' + p + '`' for p in task['scope']]
|
||||
out += ['', 'Не изменять другие modules, shared contracts или spec snapshots без изменения плана. Stem без расширения разрешает принятые stack-specific extensions и focused test рядом.', '', '## Прочитать', ''] + ['- ' + p for p in task['inputs']]
|
||||
for heading, key in [('Шаги', 'steps'), ('Приёмка', 'acceptance'), ('Focused checks', 'checks')]:
|
||||
out += ['', '## ' + heading, ''] + [f'{i}. {x}' for i, x in enumerate(task[key], 1)]
|
||||
out += ['', '## Блокер и завершение запуска', '', 'Для всех ролей scope — только эта задача. При блокере создать issue JSON по state/ISSUE_TEMPLATE.json, вызвать escalate с текущим ID/role/agent, проверить status=blocked и resolution_task, затем немедленно завершить запуск. Не решать blocker и не выбирать следующую задачу самому. Новый агент назначается runner. Правки metadata через CLI — единственное исключение scope.', '', '## Передача', '', 'Сохранить evidence/handoff; submit → review и завершить запуск. Reviewer также выполняет только эту задачу, включая обязательное escalate при блокере.', '']
|
||||
return '\n'.join(out)
|
||||
|
||||
def packet(self, task_id, agent):
|
||||
task = self.task(task_id)
|
||||
reqs = {r['id']: r for r in read_json(self.root / 'planning/requirements.json')}
|
||||
out = ['# Context packet ' + task_id, '', 'Agent: ' + agent, '', self.card(task), '## Точные требования', '']
|
||||
out += [f'**{r}.** {reqs[r]["text"]}' for r in task['requirements']]
|
||||
for number in task.get('spec_sections', []):
|
||||
out += ['', self.section((self.root / 'specs/tz_clear.md').read_text(encoding='utf-8'), number)]
|
||||
for number in task.get('ui_sections', []):
|
||||
out += ['', self.section((self.root / 'specs/uikit_clear.md').read_text(encoding='utf-8'), number)]
|
||||
state = self.state()
|
||||
current = state['tasks'][task_id]
|
||||
out += ['', '## Текущее состояние и предыдущая передача', '', json.dumps(current, ensure_ascii=False, indent=2)]
|
||||
if current.get('handoff'):
|
||||
out += ['Previous handoff: ' + current['handoff'] + ' — прочитать перед продолжением.']
|
||||
out += ['', '## Зависимости: результат и evidence', '']
|
||||
for dep in task['depends_on']:
|
||||
row = state['tasks'][dep]
|
||||
out += [f'- {dep}: {self.task(dep)["outcome"]}; status={row["status"]}; evidence={row.get("evidence", "нет")}; review={row.get("review_evidence", "нет")}; handoff={row.get("handoff", "нет")}.']
|
||||
result = '\n'.join(out) + '\n'
|
||||
if len(result) > 48000:
|
||||
raise ValueError('Context packet >48k chars; split task/read scope, do not truncate requirements')
|
||||
return result
|
||||
|
||||
def render(self):
|
||||
data, state = self.plan_state()
|
||||
out = ['# Матрица задач', '', f'Generated; state revision **{state["revision"]}**. Canonical status: state/tasks.json.', '', '| ID | Этап | Оператор | Reviewer | Статус | Owner | Зависимости | Задача |', '| --- | --- | --- | --- | --- | --- | --- | --- |']
|
||||
buffer = io.StringIO()
|
||||
writer = csv.writer(buffer)
|
||||
writer.writerow(['id', 'stage', 'operator', 'reviewer', 'status', 'owner', 'depends_on', 'title'])
|
||||
for task in data['tasks']:
|
||||
row = state['tasks'][task['id']]
|
||||
values = [task['id'], task['stage'], task['role'], task['reviewer_role'], row['status'], row.get('owner') or '', ', '.join(task['depends_on']), task['title']]
|
||||
escaped = [str(v).replace('|', '\\|').replace('\n', ' ') for v in values]
|
||||
escaped[0] = f'[{task["id"]}](tasks/{task["id"]}.md)'
|
||||
out.append('| ' + ' | '.join(escaped) + ' |')
|
||||
writer.writerow(values)
|
||||
atomic_write(self.root / f'planning/tasks/{task["id"]}.md', self.card(task))
|
||||
atomic_write(self.root / 'planning/TASK_MATRIX.md', '\n'.join(out) + '\n')
|
||||
atomic_write(self.root / 'planning/task_matrix.csv', buffer.getvalue())
|
||||
roadmap = ['# Этапы разработки', '', 'Последовательность gates предохраняет небольшого агента от догадок о ещё не принятых interfaces. Внутри этапа независимые атомарные задачи доступны параллельно при разных scopes/owners.', '']
|
||||
for stage in data['stages']:
|
||||
members = [t for t in data['tasks'] if t['stage'] == stage['id']]
|
||||
exit_id = stage.get('exit_gate') or members[-1]['id']
|
||||
lines = [f'# {stage["id"]}: {stage["title"]}', '', stage['goal'], '', f'Входной gate: {stage.get("entry_gate") or "нет"}. Выходной gate: {exit_id}.', '', '## Задачи', '']
|
||||
lines += [f'- [{t["id"]}](../tasks/{t["id"]}.md): {t["title"]} — {t["role"]}; {state["tasks"][t["id"]]["status"]}.' for t in members]
|
||||
lines += ['', '## Граница этапа', '', 'Выходной gate проверяет evidence/готовые проверки, не реализует функции за остальные задачи. Если отдельная карточка требует слишком большой контекст — split с сохранением requirements coverage.', '']
|
||||
atomic_write(self.root / f'planning/stages/{stage["id"]}.md', '\n'.join(lines))
|
||||
roadmap += [f'## [{stage["id"]}: {stage["title"]}](stages/{stage["id"]}.md)', '', stage['goal'], '', f'Задач: {len(members)}; entry: {stage.get("entry_gate") or "нет"}; exit: {exit_id}.', '']
|
||||
atomic_write(self.root / 'planning/ROADMAP.md', '\n'.join(roadmap))
|
||||
refs = {}
|
||||
for task in data['tasks']:
|
||||
for req in task['requirements']:
|
||||
refs.setdefault(req, []).append(task['id'])
|
||||
trace = ['# Traceability Web', '', 'Allocated означает наличие плана/приёмки, а не реализацию требования. Out-of-scope исключены только native-specific clauses; оригинальные specs сохранены.', '', '| Requirement | Применимость | Причина | Задачи |', '| --- | --- | --- | --- |']
|
||||
for req in read_json(self.root / 'planning/requirements.json'):
|
||||
trace.append('| ' + ' | '.join([req['id'], req['applicability'], req['reason'], ', '.join(refs.get(req['id'], []))]) + ' |')
|
||||
atomic_write(self.root / 'planning/TRACEABILITY.md', '\n'.join(trace) + '\n')
|
||||
return {'rendered': len(data['tasks']), 'revision': state['revision']}
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('command', choices=['status', 'list', 'next', 'show', 'resume', 'claim', 'submit', 'approve', 'reject', 'block', 'unblock', 'reassign', 'packet', 'validate', 'render', 'sync', 'unlock', 'escalate', 'release-external'])
|
||||
parser.add_argument('task', nargs='?')
|
||||
parser.add_argument('--agent')
|
||||
parser.add_argument('--role')
|
||||
parser.add_argument('--reason')
|
||||
parser.add_argument('--evidence')
|
||||
parser.add_argument('--handoff')
|
||||
parser.add_argument('--issue')
|
||||
parser.add_argument('--to', dest='target')
|
||||
parser.add_argument('--output')
|
||||
parser.add_argument('--stage')
|
||||
parser.add_argument('--status', choices=sorted(STATUSES))
|
||||
parser.add_argument('--query')
|
||||
parser.add_argument('--limit', type=int, default=20)
|
||||
parser.add_argument('--json', action='store_true')
|
||||
args = parser.parse_args(argv)
|
||||
tracker = Tracker(Path(__file__).resolve().parents[1])
|
||||
try:
|
||||
if args.command == 'escalate':
|
||||
issue_path = tracker.safe_file(args.issue, 'state/issues')
|
||||
result = tracker.escalate(args.task, args.agent, args.role, read_json(tracker.root / issue_path), args.handoff)
|
||||
tracker.render()
|
||||
elif args.command == 'release-external':
|
||||
result = task_extensions.release_external(tracker, args.task, args.agent, args.role, args.reason)
|
||||
tracker.render()
|
||||
elif args.command in {'claim', 'submit', 'approve', 'reject', 'block', 'unblock', 'reassign'}:
|
||||
result = tracker.mutate(args.command, args.task, args.agent, args.role, args.reason, args.evidence, args.handoff, args.target)
|
||||
elif args.command == 'validate':
|
||||
result = tracker.validate()
|
||||
elif args.command == 'render':
|
||||
result = tracker.render()
|
||||
elif args.command == 'sync':
|
||||
result = tracker.sync()
|
||||
elif args.command == 'status':
|
||||
state = tracker.state()
|
||||
result = {'revision': state['revision'], 'counts': {s: sum(v['status'] == s for v in state['tasks'].values()) for s in sorted(STATUSES)}, 'ready': len(tracker.ready()), 'blocked': {k: v['reason'] for k, v in state['tasks'].items() if v['status'] == 'blocked'}}
|
||||
elif args.command == 'list':
|
||||
if not 1 <= args.limit <= 100:
|
||||
raise ValueError('limit must be 1..100')
|
||||
if args.role is not None and args.role not in read_json(tracker.root / 'roles/index.json')['roles']:
|
||||
raise ValueError('Unknown operator role: ' + args.role)
|
||||
state = tracker.state()
|
||||
result = []
|
||||
for task in tracker.definitions()['tasks']:
|
||||
row = state['tasks'][task['id']]
|
||||
if args.role and task['role'] != args.role:
|
||||
continue
|
||||
if args.stage and task['stage'] != args.stage:
|
||||
continue
|
||||
if args.status and row['status'] != args.status:
|
||||
continue
|
||||
if args.query and args.query.casefold() not in (task['id'] + ' ' + task['title'] + ' ' + task['outcome']).casefold():
|
||||
continue
|
||||
result.append({'id': task['id'], 'stage': task['stage'], 'role': task['role'], 'title': task['title'], 'status': row['status'], 'owner': row.get('owner'), 'unmet_dependencies': tracker.missing(task, state)})
|
||||
result = result[:args.limit]
|
||||
elif args.command == 'next':
|
||||
if not 1 <= args.limit <= 100:
|
||||
raise ValueError('limit must be 1..100')
|
||||
result = tracker.ready(args.role)[:args.limit]
|
||||
elif args.command == 'resume':
|
||||
if not args.agent:
|
||||
raise ValueError('resume requires --agent')
|
||||
result = [{'id': k, **v} for k, v in tracker.state()['tasks'].items() if v.get('owner') == args.agent and v['status'] in ACTIVE]
|
||||
elif args.command == 'show':
|
||||
task = tracker.task(args.task)
|
||||
if args.json:
|
||||
result = {'definition': task, 'state': tracker.state()['tasks'][args.task], 'unmet_dependencies': tracker.missing(task, tracker.state())}
|
||||
else:
|
||||
print(tracker.card(task))
|
||||
print('Current state:', json.dumps(tracker.state()['tasks'][args.task], ensure_ascii=False))
|
||||
return 0
|
||||
elif args.command == 'packet':
|
||||
if not args.agent:
|
||||
raise ValueError('packet requires --agent')
|
||||
packet = tracker.packet(args.task, args.agent)
|
||||
if args.output:
|
||||
output = (tracker.root / args.output).resolve()
|
||||
if not output.is_relative_to(tracker.root / 'state') or not output.name.startswith('context-') or output.suffix != '.md':
|
||||
raise ValueError('Output must be state/context-*.md')
|
||||
atomic_write(output, packet)
|
||||
result = {'output': str(output), 'characters': len(packet)}
|
||||
else:
|
||||
print(packet)
|
||||
return 0
|
||||
else:
|
||||
if not args.reason or not args.reason.strip():
|
||||
raise ValueError('unlock requires --reason after checking crashed owner process')
|
||||
owner_path = tracker.lock_path / 'owner.json'
|
||||
owner = read_json(owner_path) if owner_path.exists() else {}
|
||||
pid = owner.get('pid')
|
||||
if pid:
|
||||
if os.name != "posix":
|
||||
raise ValueError("Automatic stale-process check is POSIX-only; inspect OS process state and recover the stale lock manually")
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except PermissionError:
|
||||
raise ValueError('Cannot prove lock owner process is dead')
|
||||
else:
|
||||
raise ValueError('Lock owner process is alive; refuse unlock')
|
||||
owner_path.unlink(missing_ok=True)
|
||||
tracker.lock_path.rmdir()
|
||||
result = {'unlocked': owner, 'reason': args.reason}
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except (ValueError, KeyError, TypeError, OSError) as exc:
|
||||
print('Error: ' + str(exc), file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user