603 lines
31 KiB
Python
603 lines
31 KiB
Python
"""Session-operated queue control; never executes issue text or business commands."""
|
|
from contextlib import contextmanager
|
|
from datetime import datetime
|
|
import fcntl
|
|
import json
|
|
import hashlib
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import subprocess
|
|
import uuid
|
|
|
|
from .transaction import InstallError, atomic_write, json_bytes, read, safe_path
|
|
|
|
DIRECTORY = '.local/agent/queue'
|
|
STATE = DIRECTORY + '/state.json'
|
|
STOP = DIRECTORY + '/stop'
|
|
LOCK = DIRECTORY + '/write.lock'
|
|
REF = 'refs/heads/personal-dev/queue-lock'
|
|
STAGES = {'claimed', 'planning', 'implementing', 'testing', 'waiting_user', 'reviewing', 'ready_to_merge'}
|
|
_EXPLICIT_START = object()
|
|
REASONS = {'needs_input', 'dependency', 'test_failed', 'auth', 'environment', 'review'}
|
|
|
|
|
|
def git(root, *args, data=None):
|
|
env = dict(os.environ, GIT_TERMINAL_PROMPT='0', GIT_AUTHOR_NAME='Personal Dev Queue',
|
|
GIT_AUTHOR_EMAIL='queue@example.invalid', GIT_COMMITTER_NAME='Personal Dev Queue',
|
|
GIT_COMMITTER_EMAIL='queue@example.invalid')
|
|
try:
|
|
result = subprocess.run(['git', '-C', str(root), *args], input=data,
|
|
capture_output=True, text=True, timeout=25, env=env)
|
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
raise InstallError('queue Git unavailable or timed out; inspect ownership before retry') from exc
|
|
if result.returncode:
|
|
raise InstallError('queue Git operation failed; verify authentication, branch and ownership')
|
|
return result.stdout.strip()
|
|
|
|
|
|
class QueueConflict(InstallError):
|
|
"""Requires reconciliation, not an automatic network retry."""
|
|
pass
|
|
|
|
|
|
class QueueBusy(InstallError):
|
|
pass
|
|
|
|
|
|
class GitLease:
|
|
"""Distinct orphan commit plus expected-ref CAS, not an advisory issue comment."""
|
|
def __init__(self, root, repository=None):
|
|
self.root, self.repository = root, repository
|
|
|
|
def _validate(self):
|
|
if self.repository: validate_remote(self.root, self.repository)
|
|
|
|
def current(self):
|
|
self._validate()
|
|
value = git(self.root, 'ls-remote', '--refs', 'origin', REF)
|
|
if not value: return None
|
|
fields = value.split()
|
|
if len(fields) != 2 or fields[1] != REF or not re.fullmatch('[0-9a-f]{40}', fields[0]):
|
|
raise InstallError('invalid queue ownership response')
|
|
return fields[0]
|
|
|
|
def prepare(self, session):
|
|
blob = git(self.root, 'hash-object', '-w', '--stdin', data=json.dumps({'session': session, 'purpose': 'personal-dev-queue'}))
|
|
tree = git(self.root, 'mktree', data='100644 blob ' + blob + '\tqueue.json\n')
|
|
return git(self.root, 'commit-tree', tree, data='Personal dev queue ownership ' + session + '\n')
|
|
|
|
def acquire(self, sha):
|
|
self._validate()
|
|
git(self.root, 'push', '--porcelain', '--force-with-lease=' + REF + ':', 'origin', sha + ':' + REF)
|
|
if self.current() != sha:
|
|
raise InstallError('queue ownership acquisition not confirmed')
|
|
|
|
def release(self, sha):
|
|
current = self.current()
|
|
if current is None: return
|
|
if current != sha: raise QueueConflict('queue ownership belongs to another session; do not delete it')
|
|
git(self.root, 'push', '--porcelain', '--force-with-lease=' + REF + ':' + sha, 'origin', ':' + REF)
|
|
if self.current() is not None:
|
|
raise InstallError('queue ownership release not confirmed')
|
|
|
|
def contains(self, commit, branch):
|
|
self._validate()
|
|
git(self.root, 'check-ref-format', '--branch', branch)
|
|
if branch.startswith('-'): raise InstallError('invalid integration branch')
|
|
ref = 'refs/heads/' + branch
|
|
fields = git(self.root, 'ls-remote', '--refs', 'origin', ref).split()
|
|
if len(fields) != 2 or fields[1] != ref or not re.fullmatch('[0-9a-f]{40}', fields[0]):
|
|
raise InstallError('integration branch snapshot unavailable')
|
|
target = fields[0]
|
|
git(self.root, 'fetch', '--no-tags', '--no-write-fetch-head', 'origin', target)
|
|
try:
|
|
git(self.root, 'merge-base', '--is-ancestor', commit, target)
|
|
return True
|
|
except InstallError:
|
|
return False
|
|
|
|
|
|
def main_project(root):
|
|
"""Git's first worktree is the common/main checkout; do not scan other projects."""
|
|
records = git(root, 'worktree', 'list', '--porcelain', '-z').split('\0')
|
|
if not records or not records[0].startswith('worktree '):
|
|
raise InstallError('cannot identify main project worktree')
|
|
main = Path(records[0][len('worktree '):]).resolve()
|
|
if not main.is_dir(): raise InstallError('main project worktree is unavailable')
|
|
return main
|
|
|
|
|
|
def labels(item):
|
|
return {x['name'].lower() for x in item.get('labels', []) if isinstance(x, dict) and isinstance(x.get('name'), str)}
|
|
|
|
|
|
def eligible(item):
|
|
names = labels(item)
|
|
return (type(item.get('number')) is int and item['number'] > 0 and item.get('state') == 'open'
|
|
and item.get('pull_request') is None and 'ai-ready' in names
|
|
and not names.intersection({'ai-blocked', 'ai-in-progress'})
|
|
and not item.get('assignees') and not item.get('assignee'))
|
|
|
|
|
|
def order(item):
|
|
names = labels(item)
|
|
priority = min((i for i in range(4) if 'p' + str(i) in names), default=4)
|
|
try:
|
|
created = datetime.fromisoformat(item['created_at'].replace('Z', '+00:00'))
|
|
if created.tzinfo is None: raise ValueError()
|
|
except (KeyError, AttributeError, TypeError, ValueError):
|
|
raise InstallError('valid timezone-aware issue creation timestamp required') from None
|
|
return priority, created, item['number']
|
|
|
|
|
|
class Queue:
|
|
def __init__(self, root, tracker=None, lease=None, branch=None, owner=None):
|
|
self.root, self.tracker = Path(root), tracker
|
|
self.lease = lease or GitLease(root)
|
|
self.branch, self.owner = branch, owner
|
|
|
|
def _directory(self):
|
|
safe_path(self.root, STATE)
|
|
current = self.root
|
|
for part in DIRECTORY.split('/'):
|
|
current = current / part
|
|
current.mkdir(mode=0o700, exist_ok=True)
|
|
current.chmod(0o700)
|
|
|
|
@contextmanager
|
|
def _locked(self):
|
|
self._directory()
|
|
path = safe_path(self.root, LOCK)
|
|
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o600)
|
|
with os.fdopen(fd, 'a+b') as handle:
|
|
try: fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except BlockingIOError as exc:
|
|
raise QueueBusy('another queue command is running; stop request remains effective') from exc
|
|
try: yield
|
|
finally: fcntl.flock(handle, fcntl.LOCK_UN)
|
|
|
|
def status(self):
|
|
raw = read(self.root, STATE)
|
|
if raw is None:
|
|
state = dict(version=1, enabled=False, session=None, repository=None, integration_branch=None,
|
|
lease=None, owner=None, current=None, blocked={}, completed=[], release='released', stop_ack=None)
|
|
else:
|
|
try: state = json.loads(raw)
|
|
except (ValueError, UnicodeError) as exc: raise InstallError('invalid queue state; preserve it for recovery') from exc
|
|
if (not isinstance(state, dict) or state.get('version') != 1 or type(state.get('enabled')) is not bool
|
|
or not isinstance(state.get('blocked'), dict) or not isinstance(state.get('completed'), list)):
|
|
raise InstallError('invalid queue state; preserve it for recovery')
|
|
if self._stopped(state):
|
|
state['enabled'] = False
|
|
if state.get('lease'):
|
|
state['release'] = 'retained_for_resume' if state.get('current') else 'pending'
|
|
return state
|
|
|
|
def _stop_token(self):
|
|
raw = read(self.root, STOP)
|
|
return hashlib.sha256(raw).hexdigest() if raw is not None else None
|
|
|
|
def _stopped(self, state):
|
|
return self._stop_token() != state.get('stop_ack')
|
|
|
|
def _save(self, state):
|
|
if self._stopped(state): state['enabled'] = False
|
|
atomic_write(self.root, STATE, json_bytes(state), 0o600)
|
|
|
|
def _transition(self, state):
|
|
# Called while holding the queue lock. Stop latches independently; if it
|
|
# races the replace, restore the previous checkpoint before releasing lock.
|
|
if self._stopped(state): raise InstallError('stop requested; queue transition rejected')
|
|
before = read(self.root, STATE)
|
|
atomic_write(self.root, STATE, json_bytes(state), 0o600)
|
|
if self._stopped(state):
|
|
atomic_write(self.root, STATE, before, 0o600)
|
|
raise InstallError('stop requested; previous task progress retained')
|
|
|
|
def _owner(self, state):
|
|
if not self.owner or state.get('owner') != self.owner:
|
|
raise QueueConflict('queue belongs to a different active conversation')
|
|
|
|
def _active(self, state):
|
|
self._owner(state)
|
|
if not state['enabled'] or self._stopped(state):
|
|
raise InstallError('queue is stopped; only explicit user start may resume')
|
|
if (not self.tracker or state['repository'] != self.tracker.repository
|
|
or state['integration_branch'] != self.branch):
|
|
raise QueueConflict('queue repository or integration branch changed; reconcile saved work first')
|
|
if not state['lease'] or self.lease.current() != state['lease']:
|
|
raise QueueConflict('queue ownership missing or changed; stop work')
|
|
if self._stopped(state):
|
|
raise InstallError('stop requested; do not advance')
|
|
|
|
def start(self, expected_stop=_EXPLICIT_START):
|
|
acknowledged_stop = self._stop_token() if expected_stop is _EXPLICIT_START else expected_stop
|
|
with self._locked():
|
|
state = self.status()
|
|
if self._stop_token() != acknowledged_stop:
|
|
raise InstallError('stop requested before start; authorization revoked')
|
|
if not self.tracker or not self.branch: raise InstallError('configured tracker and integration branch required')
|
|
if not self.owner or not re.fullmatch(r'[A-Za-z0-9_-]{1,100}', self.owner):
|
|
raise InstallError('explicit conversation owner ID required')
|
|
if state['enabled'] and state.get('owner') != self.owner:
|
|
raise QueueConflict('another conversation is running; stop it before transferring ownership')
|
|
if state['repository'] is not None and (state['repository'] != self.tracker.repository or state['integration_branch'] != self.branch):
|
|
raise QueueConflict('queue binding changed; reconcile saved work before starting')
|
|
# Acknowledge only the stop observed on entry; never delete a newer stop.
|
|
state.update(repository=self.tracker.repository, integration_branch=self.branch,
|
|
enabled=False, stop_ack=acknowledged_stop, owner=self.owner)
|
|
if state['session'] is None: state['session'] = str(uuid.uuid4())
|
|
current = self.lease.current()
|
|
if current is not None and current != state['lease']:
|
|
raise QueueConflict('repository queue is occupied; do not steal or expire its lock')
|
|
if current is None:
|
|
state['lease'] = self.lease.prepare(state['session'] + '-' + str(uuid.uuid4()))
|
|
state['release'] = 'pending'
|
|
self._save(state) # Save identity BEFORE an ambiguous push or process exit.
|
|
if self._stopped(state): raise InstallError('stop requested during start')
|
|
self.lease.acquire(state['lease'])
|
|
state.update(enabled=True, release='owned')
|
|
self._save(state)
|
|
return self.status()
|
|
|
|
def stop(self):
|
|
self._directory()
|
|
atomic_write(self.root, STOP, (str(uuid.uuid4()) + '\n').encode(), 0o600) # Does not wait for a long request.
|
|
try:
|
|
with self._locked():
|
|
state = self.status()
|
|
if isinstance(self.lease, GitLease):
|
|
self.lease.repository = state.get('repository')
|
|
state['enabled'] = False
|
|
if state['current'] is not None:
|
|
state['release'] = 'retained_for_resume'
|
|
self._save(state)
|
|
return state
|
|
return self._release(state)
|
|
except QueueBusy:
|
|
state = self.status()
|
|
state['cleanup_required'] = True
|
|
return state
|
|
|
|
def _release(self, state):
|
|
state['enabled'] = False
|
|
self._save(state)
|
|
try:
|
|
if state['lease']: self.lease.release(state['lease'])
|
|
except InstallError:
|
|
state['release'] = 'pending'
|
|
else:
|
|
state.update(lease=None, release='released')
|
|
self._save(state)
|
|
return state
|
|
|
|
def next(self):
|
|
# A stopped no-op must not even create a state directory.
|
|
if not self.status()['enabled']: raise InstallError('queue is stopped')
|
|
with self._locked():
|
|
state = self.status(); self._active(state)
|
|
if state['current'] is not None:
|
|
item = self.tracker.issue(state['current']['number'])
|
|
self._active(state)
|
|
if not eligible(item):
|
|
raise QueueConflict('current issue closed, withdrawn or assigned; reconcile before continuing')
|
|
return {'status': 'resume', 'issue': item, 'checkpoint': state['current']}
|
|
items = self.tracker.issues()
|
|
self._active(state)
|
|
candidates = sorted((x for x in items if eligible(x) and str(x['number']) not in state['blocked']
|
|
and x['number'] not in state['completed']), key=order)
|
|
for candidate in candidates:
|
|
item = self.tracker.issue(candidate['number']) # Refetch description + comments before claiming.
|
|
self._active(state)
|
|
if not eligible(item): continue
|
|
state['current'] = dict(number=item['number'], stage='claimed', branch=None, worktree=None,
|
|
commit=None, updated_at=item.get('updated_at'))
|
|
self._transition(state)
|
|
if not self.status()['enabled']: raise InstallError('stop requested; retain saved task for resume')
|
|
return {'status': 'claimed', 'issue': item, 'checkpoint': state['current']}
|
|
state = self._release(state)
|
|
return {'status': 'empty', 'queue': state}
|
|
|
|
def guard(self):
|
|
with self._locked():
|
|
state = self.status()
|
|
self._active(state)
|
|
if not state['current']: raise InstallError('no current queue issue')
|
|
return state
|
|
|
|
def cleanup_guard(self):
|
|
"""Authorize a queue boundary, not deletion of any particular worktree."""
|
|
with self._locked():
|
|
state = self.status()
|
|
self._active(state)
|
|
if state['current'] is not None:
|
|
raise InstallError('current issue still exists; defer worktree cleanup until after queue-finish')
|
|
return state
|
|
|
|
def checkpoint(self, stage, branch=None, worktree=None, commit=None):
|
|
if stage not in STAGES: raise InstallError('unknown queue stage')
|
|
if commit is not None and not re.fullmatch('[0-9a-f]{40}', commit): raise InstallError('full commit SHA required')
|
|
for value in (branch, worktree):
|
|
if value is not None and (not value or not value.isprintable() or len(value) > 1000):
|
|
raise InstallError('invalid checkpoint locator')
|
|
with self._locked():
|
|
state = self.status()
|
|
if not state['current']: raise InstallError('no current queue issue')
|
|
self._owner(state)
|
|
# Safe bookkeeping remains available after stop, without external actions.
|
|
state['current']['stage'] = stage
|
|
for name, value in (('branch', branch), ('worktree', worktree), ('commit', commit)):
|
|
if value is not None: state['current'][name] = value
|
|
self._save(state)
|
|
return state
|
|
|
|
def block(self, reason):
|
|
if reason not in REASONS: raise InstallError('unknown blocking reason')
|
|
with self._locked():
|
|
state = self.status(); self._active(state)
|
|
if not state['current']: raise InstallError('no current queue issue')
|
|
item = self.tracker.issue(state['current']['number'])
|
|
self._active(state)
|
|
if 'ai-blocked' not in labels(item):
|
|
raise InstallError('record reason and ai-blocked on the remote issue before skipping it')
|
|
state['blocked'][str(item['number'])] = dict(state['current'], reason=reason)
|
|
state['current'] = None
|
|
self._transition(state)
|
|
return state
|
|
|
|
def retry(self, number):
|
|
if type(number) is not int or number <= 0: raise InstallError('positive issue number required')
|
|
with self._locked():
|
|
state = self.status(); self._active(state)
|
|
if state['current']: raise InstallError('finish or pause the current issue first')
|
|
if str(number) not in state['blocked'] and number not in state['completed']:
|
|
raise InstallError('issue is not a previously blocked or completed queue task')
|
|
item = self.tracker.issue(number)
|
|
self._active(state)
|
|
if not eligible(item): raise InstallError('restore open ai-ready and clear blocking/assignment before retry')
|
|
state['current'] = state['blocked'].pop(str(number), dict(number=number, stage='claimed',
|
|
branch=None, worktree=None, commit=None, updated_at=item.get('updated_at')))
|
|
state['current'].pop('reason', None)
|
|
if number in state['completed']: state['completed'].remove(number)
|
|
self._transition(state)
|
|
return state
|
|
|
|
def finish(self, commit, evidence):
|
|
if not re.fullmatch('[0-9a-f]{40}', commit or ''): raise InstallError('full merge commit required')
|
|
if not evidence or not evidence.isprintable() or len(evidence) > 1000: raise InstallError('evidence locator required')
|
|
with self._locked():
|
|
state = self.status(); self._active(state)
|
|
if not state['current']: raise InstallError('no current queue issue')
|
|
number = state['current']['number']
|
|
Delivery(self.root, self.tracker.repository, self.branch).check(number, commit, self.tracker, self.lease)
|
|
self._active(state)
|
|
state['completed'].append(number)
|
|
state['last_completion'] = dict(number=number, commit=commit, evidence=evidence)
|
|
state['current'] = None
|
|
self._transition(state)
|
|
return state
|
|
|
|
|
|
DELIVERY_DIRECTORY = '.local/agent/delivery'
|
|
DELIVERY_STEPS = ('accepted', 'integration_verified', 'remote_synced', 'environment_restored', 'issue_updated')
|
|
|
|
|
|
class Delivery:
|
|
"""Local evidence checkpoints plus live completion checks, shared by single issues and queues.
|
|
|
|
Checkpoints are Agent assertions, not test execution or external write authorization.
|
|
Only check() reads the remote; it never pushes, comments, closes issues or starts services.
|
|
"""
|
|
def __init__(self, root, repository, branch):
|
|
self.root, self.repository, self.branch = Path(root), repository, branch
|
|
|
|
def _path(self, number):
|
|
if type(number) is not int or number <= 0:
|
|
raise InstallError('positive issue number required')
|
|
return DELIVERY_DIRECTORY + '/' + str(number) + '.json'
|
|
|
|
@contextmanager
|
|
def _locked(self):
|
|
path = safe_path(self.root, DELIVERY_DIRECTORY + '/write.lock')
|
|
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
path.parent.chmod(0o700)
|
|
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o600)
|
|
with os.fdopen(fd, 'a+b') as handle:
|
|
try: fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except BlockingIOError as exc:
|
|
raise InstallError('another delivery command is running') from exc
|
|
try: yield
|
|
finally: fcntl.flock(handle, fcntl.LOCK_UN)
|
|
|
|
def status(self, number):
|
|
raw = read(self.root, self._path(number))
|
|
if raw is None:
|
|
return {'status': 'unrecorded', 'issue': number, 'record': None, 'missing': list(DELIVERY_STEPS)}
|
|
try:
|
|
value = json.loads(raw)
|
|
if (not isinstance(value, dict) or value.get('version') != 1
|
|
or value.get('issue') != number
|
|
or value.get('repository') != self.repository
|
|
or value.get('integration_branch') != self.branch
|
|
or not isinstance(value.get('commit'), str)
|
|
or not re.fullmatch('[0-9a-f]{40}', value['commit'])
|
|
or not isinstance(value.get('steps'), dict)
|
|
or set(value['steps']) - set(DELIVERY_STEPS)
|
|
or any(not isinstance(v, str) or not v.strip() or not v.isprintable() or len(v) > 1000
|
|
for v in value['steps'].values())):
|
|
raise ValueError()
|
|
except (ValueError, TypeError, UnicodeError):
|
|
raise InstallError('invalid delivery record or repository/branch binding; preserve it for recovery') from None
|
|
return {'status': 'recorded', 'issue': number, 'record': value,
|
|
'missing': [step for step in DELIVERY_STEPS if step not in value['steps']],
|
|
'requires_live_check': True}
|
|
|
|
def _save(self, number, value):
|
|
atomic_write(self.root, self._path(number), json_bytes(value), 0o600)
|
|
|
|
def record(self, number, commit, stage, evidence):
|
|
self._path(number)
|
|
if not isinstance(commit, str) or not re.fullmatch('[0-9a-f]{40}', commit):
|
|
raise InstallError('full delivery commit required')
|
|
if stage not in (*DELIVERY_STEPS, 'waiting_user'):
|
|
raise InstallError('unknown delivery stage')
|
|
if not isinstance(evidence, str) or not evidence.strip() or not evidence.isprintable() or len(evidence) > 1000:
|
|
raise InstallError('evidence locator required; never store credentials or issue bodies')
|
|
with self._locked():
|
|
old = self.status(number)['record']
|
|
if old is None or old['commit'] != commit:
|
|
if old:
|
|
atomic_write(self.root, DELIVERY_DIRECTORY + '/archive/' + str(number) + '-' + old['commit'] + '.json',
|
|
json_bytes(old), 0o600)
|
|
value = dict(version=1, repository=self.repository, integration_branch=self.branch,
|
|
issue=number, commit=commit, steps={}, waiting=None, last_verified=None)
|
|
else:
|
|
value = old
|
|
if stage == 'waiting_user':
|
|
value.update(steps={}, waiting=evidence, last_verified=None)
|
|
elif value['steps'].get(stage) != evidence:
|
|
# New earlier evidence invalidates downstream claims; identical retries are no-ops.
|
|
index = DELIVERY_STEPS.index(stage)
|
|
value['steps'] = {k: v for k, v in value['steps'].items() if DELIVERY_STEPS.index(k) < index}
|
|
value['steps'][stage] = evidence
|
|
value.update(waiting=None, last_verified=None)
|
|
self._save(number, value)
|
|
return self.status(number)
|
|
|
|
def check(self, number, commit, tracker, lease):
|
|
with self._locked():
|
|
current = self.status(number)
|
|
value = current['record']
|
|
if value is None:
|
|
raise InstallError('missing delivery record; reconstruct evidence before finishing')
|
|
value['last_verified'] = None
|
|
self._save(number, value)
|
|
if value['commit'] != commit:
|
|
raise InstallError('delivery commit changed; revalidate and record final version')
|
|
if current['missing']:
|
|
raise InstallError('missing delivery evidence: ' + ', '.join(current['missing']))
|
|
if tracker.repository != self.repository:
|
|
raise InstallError('delivery tracker repository mismatch')
|
|
if not lease.contains(commit, self.branch):
|
|
raise InstallError('delivery commit is not reachable from remote integration branch')
|
|
item = tracker.issue(number)
|
|
if item.get('number') != number or item.get('state') != 'closed' or item.get('pull_request') is not None:
|
|
raise InstallError('remote issue must be closed and read back before completing delivery')
|
|
# A snapshot is never a permanent guarantee: later calls always re-read remote state.
|
|
value['last_verified'] = datetime.now().astimezone().isoformat()
|
|
self._save(number, value)
|
|
return {'status': 'verified', 'issue': number, 'repository': self.repository,
|
|
'integration_branch': self.branch, 'commit': commit, 'issue_state': 'closed',
|
|
'checked_at': value['last_verified']}
|
|
|
|
|
|
def validate_delivery_storage(root, delivery, number, next_commit=None):
|
|
"""Check every actual write target, not a representative filename."""
|
|
path = delivery._path(number)
|
|
if git(root, 'ls-files', '--', DELIVERY_DIRECTORY):
|
|
raise InstallError('delivery records must not be Git-tracked')
|
|
paths = [path, DELIVERY_DIRECTORY + '/write.lock']
|
|
old = delivery.status(number)['record']
|
|
if old and next_commit is not None and old['commit'] != next_commit:
|
|
paths.append(DELIVERY_DIRECTORY + '/archive/' + str(number) + '-' + old['commit'] + '.json')
|
|
for target in paths:
|
|
safe_path(root, target)
|
|
try:
|
|
git(root, 'check-ignore', '--quiet', '--', target)
|
|
except InstallError:
|
|
raise InstallError('delivery state, lock and archive targets must be Git-ignored') from None
|
|
|
|
|
|
def validate_remote(root, repository):
|
|
from .issue_tracker import canonical_repository
|
|
for flags in (('--all',), ('--push', '--all')):
|
|
urls = git(root, 'remote', 'get-url', *flags, 'origin').splitlines()
|
|
if len(urls) != 1 or canonical_repository(urls[0]) != repository:
|
|
raise InstallError('origin fetch/push URL must match the configured issue repository')
|
|
|
|
|
|
def configuration(root):
|
|
from .issue_tracker import canonical_repository
|
|
try:
|
|
config = json.loads(read(root, 'docs/agents/project.json') or b'{}')
|
|
tracker = config['project']['tracker']
|
|
repository = tracker['repository']
|
|
if not isinstance(repository, str) or not repository.startswith('https://'):
|
|
raise ValueError()
|
|
repository = canonical_repository(repository)
|
|
branch = config['project']['integration_branch']
|
|
if not isinstance(branch, str) or branch.startswith('-'): raise ValueError()
|
|
git(root, 'check-ref-format', '--branch', branch)
|
|
if tracker['kind'] not in ('gitea', 'github'): raise ValueError()
|
|
except (KeyError, TypeError, ValueError, UnicodeError):
|
|
raise InstallError('valid tracker repository and integration branch configuration required') from None
|
|
validate_remote(root, repository)
|
|
return config, tracker, repository, branch
|
|
|
|
|
|
def dispatch(root, args):
|
|
"""CLI boundary: resolve shared root, validate binding, then call a small state transition."""
|
|
root = main_project(root)
|
|
queue = Queue(root, owner=args.owner)
|
|
delivery_action = args.action.startswith('delivery-')
|
|
action = args.action.split('-', 1)[1]
|
|
if action == 'status' and not delivery_action: return queue.status()
|
|
if action == 'stop':
|
|
# Even malformed tracker config cannot prevent the local stop latch.
|
|
return queue.stop()
|
|
if action == 'checkpoint':
|
|
return queue.checkpoint(args.stage, args.branch, args.worktree, args.commit)
|
|
from .issue_tracker import Tracker
|
|
config, tracker, repository, branch = configuration(root)
|
|
if delivery_action:
|
|
delivery = Delivery(root, repository, branch)
|
|
if action == 'status':
|
|
if args.issue is not None:
|
|
return delivery.status(args.issue)
|
|
safe_path(root, DELIVERY_DIRECTORY + '/write.lock')
|
|
numbers = sorted(int(p.stem) for p in (root / DELIVERY_DIRECTORY).glob('*.json') if p.stem.isdigit())
|
|
return {'status': 'inventory', 'tasks': [delivery.status(n) for n in numbers]}
|
|
validate_delivery_storage(root, delivery, args.issue, args.commit if action == 'record' else None)
|
|
if action == 'record':
|
|
return delivery.record(args.issue, args.commit, args.stage, args.evidence)
|
|
# A queue task must use its guarded finish operation; standalone checking is not a queue bypass.
|
|
current = queue.status().get('current')
|
|
if current and current.get('number') == args.issue:
|
|
raise InstallError('current queue issue must use queue-finish with its owner')
|
|
permissions = config.get('permissions', {})
|
|
if action == 'start' and not args.apply:
|
|
return {'status': 'preview', 'project': str(root), 'repository': repository,
|
|
'integration_branch': branch, 'label': 'ai-ready', 'lock_ref': REF,
|
|
'effect': 'creates/deletes an orphan control branch; may trigger branch CI; no Tag or deployment',
|
|
'needs': ['explicit user start', 'Git write login', 'issue API access',
|
|
'recorded push_task_branch and write_issue permissions']}
|
|
if action == 'start':
|
|
if any(permissions.get(key) is not True for key in ('push_task_branch', 'write_issue')):
|
|
raise InstallError('record existing queue permissions for push_task_branch and write_issue before starting')
|
|
git(root, 'check-ignore', '.local/agent/queue/state.json')
|
|
if git(root, 'ls-files', '--', '.local/agent/queue'):
|
|
raise InstallError('queue state must not be Git-tracked')
|
|
credentials = config.get('environment', {}).get('secrets')
|
|
if credentials in (None, '', '.local/secrets/access.json', 'external-controlled-storage'):
|
|
credentials = None
|
|
if not delivery_action and action != 'start' and not queue.status()['enabled']:
|
|
raise InstallError('queue is stopped')
|
|
queue.tracker = Tracker(tracker['kind'], repository, root, credentials_path=credentials)
|
|
queue.lease = GitLease(root, repository=repository)
|
|
queue.branch = branch
|
|
if delivery_action:
|
|
return delivery.check(args.issue, args.commit, queue.tracker, queue.lease)
|
|
if action == 'start': return queue.start()
|
|
if action == 'next': return queue.next()
|
|
if action == 'guard': return queue.guard()
|
|
if action == 'cleanup-guard': return queue.cleanup_guard()
|
|
if action == 'block': return queue.block(args.reason)
|
|
if action == 'retry': return queue.retry(args.issue)
|
|
if action == 'finish':
|
|
current = queue.status().get('current')
|
|
if current:
|
|
validate_delivery_storage(root, Delivery(root, repository, branch), current['number'])
|
|
return queue.finish(args.commit, args.evidence)
|
|
raise InstallError('unknown queue action')
|