2529 lines
121 KiB
Python
Executable File
2529 lines
121 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Local, cooperative agent coordination. OS isolation is intentionally out of scope."""
|
|
import argparse
|
|
import contextlib
|
|
import errno
|
|
import fcntl
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
import re
|
|
import signal
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from typing import Literal, Optional
|
|
import uuid
|
|
|
|
# Pure W4 helpers (deploy/projection/PI checks) live beside this file for tests.
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
import agentctl_w4 as w4 # noqa: E402
|
|
|
|
VERSION = 1
|
|
POLICY_REL = 'configs/agent-governance.json'
|
|
DEFAULT_POLICY = {
|
|
'protocol_version': 1,
|
|
'min_protocol_version': 1,
|
|
'policy_revision': 'agent-governance-2026-09-21-r1',
|
|
'shared_docs': ['.doc/ops/CURRENT.md', '.doc/status/FEATURE-MATRIX.md'],
|
|
'projectable_docs': ['.doc/ops/CURRENT.md', '.doc/status/FEATURE-MATRIX.md'],
|
|
'projectable_prefixes': ['.doc/ops/', '.doc/status/', '.doc/remediation/'],
|
|
'job_kinds': [
|
|
'validate', 'integrate', 'with', 'deploy_prepare', 'deploy_run',
|
|
'deploy_accept', 'docs_project', 'cleanup_inspect', 'noop',
|
|
],
|
|
'doc_rules': {
|
|
'.doc/ops/CURRENT.md': {
|
|
'own_row_only': True,
|
|
'slot_protected': True,
|
|
'header': '| owner | task |',
|
|
},
|
|
'.doc/status/FEATURE-MATRIX.md': {
|
|
'evidence_for_done': True,
|
|
'header': '| 模块 |',
|
|
},
|
|
},
|
|
'reserved_record_ids': ['holder', 'idle', 'Cursor', 'Codex', '-'],
|
|
'protected_sections': {'.doc/ops/CURRENT.md': ['## 集成槽位']},
|
|
'heartbeat_ttl_seconds': 300,
|
|
'suspect_ttl_seconds': 600,
|
|
}
|
|
SHARED_DOCS = set(DEFAULT_POLICY['shared_docs'])
|
|
RESERVED_RECORD_IDS = set(DEFAULT_POLICY['reserved_record_ids'])
|
|
DOC_RULES = dict(DEFAULT_POLICY['doc_rules'])
|
|
PROTECTED_SECTIONS = {k: list(v) for k, v in DEFAULT_POLICY['protected_sections'].items()}
|
|
HEARTBEAT_TTL_SECONDS = DEFAULT_POLICY['heartbeat_ttl_seconds']
|
|
SUSPECT_TTL_SECONDS = DEFAULT_POLICY['suspect_ttl_seconds']
|
|
ACTIVE = ('running', 'recovery_required', 'suspect', 'quarantine')
|
|
UNRESOLVED = ('running', 'recovery_required', 'queued', 'suspect', 'quarantine')
|
|
STATUS_RUNS = ('running', 'queued', 'recovery_required', 'busy', 'suspect', 'quarantine')
|
|
TERMINAL = ('completed', 'failed', 'recovered')
|
|
EXTERNAL_VERIFY_MARKER = 'verified:'
|
|
RUN_QUARANTINE_COLUMNS = (
|
|
('fence', 'INTEGER NOT NULL DEFAULT 0'),
|
|
('suspect_since', 'REAL'),
|
|
('quarantined_at', 'REAL'),
|
|
('quarantine_reason', 'TEXT'),
|
|
('verified_by', 'TEXT'),
|
|
)
|
|
QUEUE_TERMINAL = ('completed', 'failed', 'cancelled')
|
|
GIT_CONTEXT = ('GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE', 'GIT_PREFIX')
|
|
QUEUE_SCHEMA = '''
|
|
CREATE TABLE IF NOT EXISTS queue_items(
|
|
id TEXT PRIMARY KEY,
|
|
task TEXT NOT NULL,
|
|
head_sha TEXT NOT NULL,
|
|
expected_dev TEXT NOT NULL,
|
|
priority INTEGER NOT NULL DEFAULT 0,
|
|
state TEXT NOT NULL,
|
|
created REAL NOT NULL,
|
|
updated REAL NOT NULL,
|
|
claim_run TEXT,
|
|
reason TEXT,
|
|
UNIQUE(task, head_sha)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS queue_pending ON queue_items(state, priority DESC, created);
|
|
'''
|
|
CLAIM_SCHEMA = '''
|
|
CREATE TABLE IF NOT EXISTS claims(
|
|
id TEXT PRIMARY KEY,
|
|
resource TEXT NOT NULL,
|
|
mode TEXT NOT NULL,
|
|
task TEXT NOT NULL,
|
|
owner TEXT NOT NULL,
|
|
request_id TEXT,
|
|
state TEXT NOT NULL,
|
|
reason TEXT,
|
|
created REAL NOT NULL,
|
|
updated REAL NOT NULL,
|
|
UNIQUE(resource, task)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS claims_active ON claims(state, resource);
|
|
CREATE TABLE IF NOT EXISTS claim_queue(
|
|
id TEXT PRIMARY KEY,
|
|
resource TEXT NOT NULL,
|
|
task TEXT NOT NULL,
|
|
request_id TEXT,
|
|
state TEXT NOT NULL,
|
|
created REAL NOT NULL
|
|
);
|
|
'''
|
|
|
|
Lookup = Literal['ok', 'gone', 'unreadable']
|
|
AliveStatus = Literal['alive', 'dead', 'unknown']
|
|
DescentStatus = Literal['yes', 'no', 'unknown']
|
|
|
|
|
|
class Denied(Exception):
|
|
def __init__(self, code, message, **details):
|
|
self.code, self.message, self.details = code, message, details
|
|
super().__init__(message)
|
|
|
|
|
|
def deny(code, message, **details):
|
|
raise Denied(code, message, **details)
|
|
|
|
|
|
def emit(value, stream=sys.stdout):
|
|
print(json.dumps(value, ensure_ascii=False, sort_keys=True), file=stream, flush=True)
|
|
|
|
|
|
def digest(value):
|
|
return hashlib.sha256(value.encode() if isinstance(value, str) else value).hexdigest()
|
|
|
|
|
|
def _policy_path(main: Path) -> Path:
|
|
return Path(main) / POLICY_REL
|
|
|
|
|
|
def _validate_policy_shape(data) -> dict:
|
|
if not isinstance(data, dict):
|
|
deny('POLICY_INVALID', 'Governance policy must be a JSON object')
|
|
required = (
|
|
'protocol_version', 'min_protocol_version', 'policy_revision',
|
|
'shared_docs', 'projectable_docs', 'projectable_prefixes', 'job_kinds',
|
|
'doc_rules', 'reserved_record_ids', 'protected_sections',
|
|
)
|
|
missing = [key for key in required if key not in data]
|
|
if missing:
|
|
deny('POLICY_INVALID', 'Governance policy missing required keys', keys=missing)
|
|
if not isinstance(data['protocol_version'], int) or not isinstance(data['min_protocol_version'], int):
|
|
deny('POLICY_INVALID', 'protocol_version and min_protocol_version must be integers')
|
|
if data['protocol_version'] < data['min_protocol_version']:
|
|
deny('POLICY_MISMATCH', 'protocol_version below min_protocol_version',
|
|
protocol_version=data['protocol_version'],
|
|
min_protocol_version=data['min_protocol_version'])
|
|
for key in ('shared_docs', 'projectable_docs', 'projectable_prefixes',
|
|
'job_kinds', 'reserved_record_ids'):
|
|
if not isinstance(data[key], list) or not all(isinstance(item, str) for item in data[key]):
|
|
deny('POLICY_INVALID', f'{key} must be a list of strings')
|
|
if not isinstance(data['doc_rules'], dict) or not isinstance(data['protected_sections'], dict):
|
|
deny('POLICY_INVALID', 'doc_rules and protected_sections must be objects')
|
|
if not isinstance(data['policy_revision'], str) or not data['policy_revision']:
|
|
deny('POLICY_INVALID', 'policy_revision must be a nonempty string')
|
|
# Structure must stay compatible with the builtin DEFAULT_POLICY contract.
|
|
if set(data['shared_docs']) != set(DEFAULT_POLICY['shared_docs']):
|
|
deny('POLICY_MISMATCH', 'shared_docs diverges from DEFAULT_POLICY contract')
|
|
if set(data['job_kinds']) != set(DEFAULT_POLICY['job_kinds']):
|
|
deny('POLICY_MISMATCH', 'job_kinds diverges from DEFAULT_POLICY contract')
|
|
if set(data['projectable_docs']) != set(DEFAULT_POLICY['projectable_docs']):
|
|
deny('POLICY_MISMATCH', 'projectable_docs diverges from DEFAULT_POLICY contract')
|
|
if list(data['projectable_prefixes']) != list(DEFAULT_POLICY['projectable_prefixes']):
|
|
deny('POLICY_MISMATCH', 'projectable_prefixes diverges from DEFAULT_POLICY contract')
|
|
# Optional TTL keys: validate when present; missing → DEFAULT_POLICY at apply time.
|
|
for key in ('heartbeat_ttl_seconds', 'suspect_ttl_seconds'):
|
|
if key in data and (not isinstance(data[key], int) or data[key] <= 0):
|
|
deny('POLICY_INVALID', f'{key} must be a positive integer')
|
|
return data
|
|
|
|
|
|
def apply_policy(policy: dict) -> dict:
|
|
"""Install a validated policy into module globals used by write paths."""
|
|
global SHARED_DOCS, RESERVED_RECORD_IDS, DOC_RULES, PROTECTED_SECTIONS
|
|
global HEARTBEAT_TTL_SECONDS, SUSPECT_TTL_SECONDS
|
|
SHARED_DOCS = set(policy['shared_docs'])
|
|
RESERVED_RECORD_IDS = set(policy['reserved_record_ids'])
|
|
DOC_RULES = {key: dict(value) for key, value in policy['doc_rules'].items()}
|
|
PROTECTED_SECTIONS = {key: list(value) for key, value in policy['protected_sections'].items()}
|
|
HEARTBEAT_TTL_SECONDS = int(policy.get('heartbeat_ttl_seconds', DEFAULT_POLICY['heartbeat_ttl_seconds']))
|
|
SUSPECT_TTL_SECONDS = int(policy.get('suspect_ttl_seconds', DEFAULT_POLICY['suspect_ttl_seconds']))
|
|
w4.apply_policy(policy)
|
|
return policy
|
|
|
|
|
|
def load_policy(main: Path) -> dict:
|
|
"""Load governance policy from the main worktree. Fail-closed; never creates the file."""
|
|
path = _policy_path(main)
|
|
if not path.is_file():
|
|
deny('POLICY_MISSING', f'Governance policy missing at {POLICY_REL}', path=str(path))
|
|
try:
|
|
raw = path.read_text(encoding='utf-8')
|
|
data = json.loads(raw)
|
|
except (OSError, UnicodeError) as exc:
|
|
deny('POLICY_INVALID', f'Cannot read governance policy: {exc}', path=str(path))
|
|
except json.JSONDecodeError as exc:
|
|
deny('POLICY_INVALID', f'Governance policy is not valid JSON: {exc}', path=str(path))
|
|
policy = _validate_policy_shape(data)
|
|
return apply_policy(policy)
|
|
|
|
|
|
def policy_status(main: Path) -> dict:
|
|
"""Read-only policy probe for status/doctor. Never creates files."""
|
|
path = _policy_path(main)
|
|
if not path.is_file():
|
|
return {'status': 'missing', 'code': 'POLICY_MISSING', 'path': str(path)}
|
|
try:
|
|
data = json.loads(path.read_text(encoding='utf-8'))
|
|
policy = _validate_policy_shape(data)
|
|
apply_policy(policy)
|
|
return {
|
|
'status': 'ok',
|
|
'path': str(path),
|
|
'policy_revision': policy['policy_revision'],
|
|
'protocol_version': policy['protocol_version'],
|
|
}
|
|
except Denied as exc:
|
|
return {'status': 'denied', 'code': exc.code, 'message': exc.message, 'path': str(path),
|
|
**exc.details}
|
|
except (OSError, UnicodeError) as exc:
|
|
return {'status': 'denied', 'code': 'POLICY_INVALID', 'message': str(exc), 'path': str(path)}
|
|
|
|
|
|
def require_policy(repo) -> dict:
|
|
return load_policy(repo.main)
|
|
|
|
|
|
def git(path, *args, index=False, check=True, input=None):
|
|
env = os.environ.copy()
|
|
for key in GIT_CONTEXT:
|
|
if key != 'GIT_INDEX_FILE' or not index:
|
|
env.pop(key, None)
|
|
result = subprocess.run(['git', '-C', str(path), *args], env=env,
|
|
input=input, capture_output=True, text=True)
|
|
if check and result.returncode:
|
|
deny('GIT_ERROR', result.stderr.strip() or 'Git operation failed')
|
|
return result.stdout.rstrip('\n') if check else result
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProcInfo:
|
|
"""Process identity; start is an opaque token comparable within the same boot."""
|
|
pid: int
|
|
ppid: int
|
|
pgid: int
|
|
state: str
|
|
start: str
|
|
|
|
|
|
# ps columns: pid ppid pgid lstart state — lstart has spaces, state is last token.
|
|
_PS_LINE = re.compile(r'\s*(\d+)\s+(\d+)\s+(\d+)\s+(.+?)\s+(\S+)\s*$')
|
|
|
|
|
|
def _normalize_state(state):
|
|
return (state or '?')[0]
|
|
|
|
|
|
def _linux_read_proc(pid):
|
|
path = Path('/proc', str(pid), 'stat')
|
|
try:
|
|
raw = path.read_text().rsplit(')', 1)[1].split()
|
|
return 'ok', ProcInfo(pid=pid, ppid=int(raw[1]), pgid=int(raw[2]),
|
|
state=_normalize_state(raw[0]), start=raw[19])
|
|
except FileNotFoundError:
|
|
return 'gone', None
|
|
except (PermissionError, IsADirectoryError):
|
|
return 'unreadable', None
|
|
except OSError as exc:
|
|
if getattr(exc, 'errno', None) in (errno.ENOENT, errno.ESRCH):
|
|
return 'gone', None
|
|
return 'unreadable', None
|
|
except (ValueError, IndexError):
|
|
return 'unreadable', None
|
|
|
|
|
|
def _parse_ps_line(line):
|
|
match = _PS_LINE.match(line)
|
|
if not match:
|
|
return None
|
|
pid, ppid, pgid, start, state = match.groups()
|
|
return ProcInfo(pid=int(pid), ppid=int(ppid), pgid=int(pgid),
|
|
state=_normalize_state(state), start=start.strip())
|
|
|
|
|
|
def _darwin_read_proc(pid):
|
|
try:
|
|
result = subprocess.run(
|
|
['ps', '-o', 'pid=,ppid=,pgid=,lstart=,state=', '-p', str(pid)],
|
|
capture_output=True, text=True)
|
|
except OSError:
|
|
return 'unreadable', None
|
|
# BSD ps: nonexistent pid → nonzero + empty stdout. Any other opacity → unreadable.
|
|
if result.returncode != 0 and not result.stdout.strip():
|
|
# Errno-style chatter (e.g. "process id too large") still means no such process.
|
|
return 'gone', None
|
|
if not result.stdout.strip():
|
|
return 'unreadable', None
|
|
info = _parse_ps_line(result.stdout.splitlines()[0])
|
|
if not info or info.pid != int(pid):
|
|
return 'unreadable', None
|
|
return 'ok', info
|
|
|
|
|
|
def boot_epoch():
|
|
"""Opaque boot generation token; DB field `boot` stays compatible."""
|
|
if sys.platform == 'darwin':
|
|
try:
|
|
raw = subprocess.check_output(['sysctl', '-n', 'kern.boottime'], text=True)
|
|
except (OSError, subprocess.CalledProcessError) as exc:
|
|
deny('PROCESS_UNREADABLE', f'Unable to read kern.boottime: {exc}')
|
|
match = re.search(r'sec\s*=\s*(\d+)', raw)
|
|
if not match:
|
|
deny('PROCESS_UNREADABLE', 'Unable to parse kern.boottime')
|
|
return match.group(1)
|
|
if sys.platform.startswith('linux'):
|
|
try:
|
|
return Path('/proc/sys/kernel/random/boot_id').read_text().strip()
|
|
except OSError as exc:
|
|
deny('PROCESS_UNREADABLE', f'Unable to read boot_id: {exc}')
|
|
deny('UNSUPPORTED_PLATFORM', f'Boot epoch unsupported on {sys.platform}')
|
|
|
|
|
|
def read_proc(pid) -> tuple[Lookup, Optional[ProcInfo]]:
|
|
"""Lookup a single process. unreadable must never be treated as exited."""
|
|
if sys.platform == 'darwin':
|
|
return _darwin_read_proc(pid)
|
|
if sys.platform.startswith('linux'):
|
|
return _linux_read_proc(pid)
|
|
deny('UNSUPPORTED_PLATFORM', f'Process inspection unsupported on {sys.platform}')
|
|
|
|
|
|
def _enumerate_group(pgid):
|
|
"""Yield ProcInfo in pgid. Returns (saw_member_alive, enum_status).
|
|
|
|
enum_status is 'ok' when every observable entry was readable, else 'unknown'.
|
|
Fail-closed: enum failure or any unreadable member never reports 'all dead'.
|
|
"""
|
|
if sys.platform == 'darwin':
|
|
try:
|
|
result = subprocess.run(
|
|
['ps', '-axo', 'pid=,ppid=,pgid=,lstart=,state='],
|
|
capture_output=True, text=True)
|
|
except OSError:
|
|
return False, 'unknown'
|
|
if result.returncode != 0:
|
|
return False, 'unknown'
|
|
alive_member = False
|
|
for line in result.stdout.splitlines():
|
|
if not line.strip():
|
|
continue
|
|
info = _parse_ps_line(line)
|
|
if info is None:
|
|
return alive_member, 'unknown'
|
|
if info.pgid == pgid and info.state != 'Z':
|
|
alive_member = True
|
|
return alive_member, 'ok'
|
|
if sys.platform.startswith('linux'):
|
|
try:
|
|
items = list(Path('/proc').iterdir())
|
|
except OSError:
|
|
return False, 'unknown'
|
|
alive_member, saw_unreadable = False, False
|
|
for item in items:
|
|
if not item.name.isdigit():
|
|
continue
|
|
lookup, info = _linux_read_proc(int(item.name))
|
|
if lookup == 'unreadable':
|
|
saw_unreadable = True
|
|
continue
|
|
if lookup == 'ok' and info and info.pgid == pgid and info.state != 'Z':
|
|
alive_member = True
|
|
return alive_member, ('unknown' if saw_unreadable else 'ok')
|
|
deny('UNSUPPORTED_PLATFORM', f'Process enumeration unsupported on {sys.platform}')
|
|
|
|
|
|
def alive_status(pid, start, boot_id) -> AliveStatus:
|
|
if boot_id != boot_epoch():
|
|
return 'dead'
|
|
lookup, info = read_proc(pid)
|
|
if lookup == 'gone':
|
|
return 'dead'
|
|
if lookup == 'unreadable':
|
|
return 'unknown'
|
|
if not info or info.state == 'Z' or info.start != start:
|
|
return 'dead'
|
|
return 'alive'
|
|
|
|
|
|
def group_status(pgid) -> AliveStatus:
|
|
if not pgid:
|
|
return 'dead'
|
|
alive_member, enum_status = _enumerate_group(pgid)
|
|
if alive_member:
|
|
return 'alive'
|
|
if enum_status != 'ok':
|
|
return 'unknown'
|
|
return 'dead'
|
|
|
|
|
|
def descent_status(ancestor) -> DescentStatus:
|
|
current = os.getpid()
|
|
seen = set()
|
|
while current > 1 and current not in seen:
|
|
if current == ancestor:
|
|
return 'yes'
|
|
seen.add(current)
|
|
lookup, info = read_proc(current)
|
|
if lookup == 'unreadable':
|
|
return 'unknown'
|
|
if lookup == 'gone' or not info:
|
|
return 'no'
|
|
current = info.ppid
|
|
return 'no'
|
|
|
|
|
|
def boot():
|
|
return boot_epoch()
|
|
|
|
|
|
def proc(pid):
|
|
"""Compat: ok→dict(start/ppid/group/state); gone→None; unreadable→deny (fail-closed)."""
|
|
lookup, info = read_proc(pid)
|
|
if lookup == 'gone':
|
|
return None
|
|
if lookup != 'ok' or not info:
|
|
deny('PROCESS_UNREADABLE', f'Unable to inspect process {pid}')
|
|
return {'pid': info.pid, 'ppid': info.ppid, 'group': info.pgid, 'state': info.state, 'start': info.start}
|
|
|
|
|
|
|
|
def alive(pid, start, boot_id):
|
|
"""True when the process may still hold resources (alive or unknown)."""
|
|
return alive_status(pid, start, boot_id) != 'dead'
|
|
|
|
|
|
def group_alive(group):
|
|
"""True when the group may still hold resources (alive or unknown)."""
|
|
return group_status(group) != 'dead'
|
|
|
|
|
|
def descendants_of(pid):
|
|
return descent_status(pid) == 'yes'
|
|
|
|
|
|
def relative(value):
|
|
if '\x00' in value or '\n' in value or '\\' in value or value.startswith('/'):
|
|
deny('PATH_OUT_OF_SCOPE', 'Expected a repository-relative path')
|
|
parts = Path(value).parts
|
|
if '..' in parts:
|
|
deny('PATH_OUT_OF_SCOPE', 'Parent traversal is not allowed')
|
|
return Path(value).as_posix().rstrip('/') or '.'
|
|
|
|
|
|
def covers(parent, child):
|
|
return parent == '.' or parent == child or child.startswith(parent + '/')
|
|
|
|
|
|
def resource(value):
|
|
if value.startswith('path:'):
|
|
return 'path:' + relative(value[5:])
|
|
if value.startswith('claim:'):
|
|
return 'claim:' + relative(value[6:])
|
|
if value in ('docs:publish', 'git:worktrees', 'git:index') or re.fullmatch(r'(env|git):(dev|test|main|shared)', value):
|
|
return value
|
|
if value.startswith('tree:'):
|
|
return value
|
|
deny('INVALID_RESOURCE', 'Unsupported resource', resource=value)
|
|
|
|
|
|
def overlaps(a, b):
|
|
if a == b:
|
|
return True
|
|
if a.startswith('path:') and b.startswith('path:'):
|
|
return covers(a[5:], b[5:]) or covers(b[5:], a[5:])
|
|
if a.startswith('claim:') and b.startswith('claim:'):
|
|
return covers(a[6:], b[6:]) or covers(b[6:], a[6:])
|
|
# Publishing protected docs conflicts with direct managed path writers, too.
|
|
if a == 'docs:publish' and b.startswith('path:'):
|
|
return any(covers(b[5:], p) or covers(p, b[5:]) for p in SHARED_DOCS)
|
|
if b == 'docs:publish' and a.startswith('path:'):
|
|
return overlaps(b, a)
|
|
return False
|
|
|
|
|
|
def safe_path(root, rel):
|
|
rel = relative(rel)
|
|
target = root / rel
|
|
if not target.resolve().is_relative_to(root.resolve()):
|
|
deny('PATH_OUT_OF_SCOPE', 'Path escapes the worktree')
|
|
cur = root
|
|
for part in Path(rel).parts:
|
|
cur /= part
|
|
if cur.is_symlink():
|
|
deny('PATH_OUT_OF_SCOPE', 'Symlink paths are not managed')
|
|
if target.exists() and target.is_file() and target.stat().st_nlink > 1:
|
|
deny('PATH_OUT_OF_SCOPE', 'Hardlinked working files are not managed')
|
|
return target
|
|
|
|
|
|
def secret_path(name):
|
|
parts = Path(name).parts
|
|
leaf = Path(name).name
|
|
return ('.locks' in parts or '.git' in parts or (parts and 'data' == parts[0])
|
|
or leaf == '.env' or ('.env.' in leaf and not leaf.endswith('.example'))
|
|
or leaf in ('.env.local', 'phase-secrets.local.json')
|
|
or any(p in ('.secrets', 'node_modules') for p in parts))
|
|
|
|
|
|
def clean_source(path, build=False):
|
|
if not build:
|
|
dirty = bool(git(path, 'status', '--porcelain', '--untracked-files=all'))
|
|
else:
|
|
tracked = git(path, 'status', '--porcelain', '--untracked-files=no')
|
|
untracked = git(path, 'ls-files', '--others', '--exclude-standard', '-z').split('\0')
|
|
# These are the existing nightly producer's non-source outputs. git archive
|
|
# excludes all untracked files, independently of Docker ignore patterns.
|
|
evidence_prefix = '.doc/test-reports/assets/promotion-receipts/'
|
|
dirty = bool(tracked or any(n and not n.startswith(evidence_prefix) for n in untracked))
|
|
if dirty:
|
|
deny('DIRTY_TREE', 'Source worktree must be clean; only generated build evidence is excepted')
|
|
|
|
|
|
class Repo:
|
|
def __init__(self, path):
|
|
self.current = Path(git(Path(path).resolve(), 'rev-parse', '--show-toplevel')).resolve()
|
|
self.common = Path(git(self.current, 'rev-parse', '--path-format=absolute', '--git-common-dir')).resolve()
|
|
rows = self.worktrees()
|
|
if not rows or rows[0].get('bare'):
|
|
deny('UNSUPPORTED_REPOSITORY', 'A non-bare main worktree is required')
|
|
self.main = Path(rows[0]['worktree']).resolve()
|
|
if self.common != (self.main / '.git').resolve():
|
|
deny('UNSUPPORTED_REPOSITORY', 'External Git directories require explicit migration')
|
|
self.root = self.main / '.locks'
|
|
if self.root.is_symlink():
|
|
deny('INVALID_LOCK_ROOT', 'The canonical .locks directory must not be a symlink')
|
|
self.dbfile = self.root / 'state.sqlite'
|
|
|
|
def worktrees(self):
|
|
records = []
|
|
for block in git(self.current, 'worktree', 'list', '--porcelain', '-z').split('\0\0'):
|
|
record = {}
|
|
for field in block.split('\0'):
|
|
if field:
|
|
key, _, value = field.partition(' ')
|
|
record[key] = value
|
|
if record:
|
|
records.append(record)
|
|
return records
|
|
|
|
def identity(self):
|
|
try:
|
|
identity = json.loads((self.root / 'identity.json').read_text())
|
|
except FileNotFoundError:
|
|
deny('NOT_INITIALIZED', 'Run agentctl init explicitly from the main worktree')
|
|
except (ValueError, OSError):
|
|
deny('RECOVERY_REQUIRED', 'Lock identity cannot be read')
|
|
if (identity.get('main') != str(self.main) or identity.get('common') != str(self.common)
|
|
or identity.get('version') != VERSION or not self.dbfile.exists()):
|
|
deny('POLICY_MISMATCH', 'Lock identity, schema or repository topology changed')
|
|
old = self.common / 'agent-control' / 'dev-integration.lock'
|
|
bridge = self.root / 'os' / 'dev-integration.lock'
|
|
if not old.exists() or not bridge.exists() or old.is_symlink() or bridge.is_symlink() or not os.path.samefile(old, bridge):
|
|
deny('RECOVERY_REQUIRED', 'Compatibility lock identity changed; stop and inspect')
|
|
if self.dbfile.is_symlink() or (self.root / 'os').is_symlink():
|
|
deny('INVALID_LOCK_ROOT', 'Coordination state must not use symlink aliases')
|
|
return identity
|
|
|
|
@contextlib.contextmanager
|
|
def db(self, write=False):
|
|
self.identity()
|
|
# mode=rw never silently creates a fresh authority when state is missing.
|
|
db = sqlite3.connect(self.dbfile.as_uri() + '?mode=' + ('rw' if write else 'ro'),
|
|
uri=True, timeout=5, isolation_level=None)
|
|
db.row_factory = sqlite3.Row
|
|
try:
|
|
db.execute('PRAGMA busy_timeout=5000')
|
|
if write:
|
|
db.execute('PRAGMA synchronous=FULL')
|
|
# DDL inside ensure_schema implicitly commits any open txn; take the
|
|
# exclusive lock only after schema is stable so claim/lease CAS holds.
|
|
db.execute('BEGIN IMMEDIATE')
|
|
ensure_schema(db)
|
|
if db.in_transaction:
|
|
db.execute('COMMIT')
|
|
db.execute('BEGIN IMMEDIATE')
|
|
yield db
|
|
if write and db.in_transaction:
|
|
db.commit()
|
|
except BaseException:
|
|
if write and db.in_transaction:
|
|
db.rollback()
|
|
raise
|
|
finally:
|
|
db.close()
|
|
|
|
def os_file(self, name):
|
|
self.identity()
|
|
path = self.root / 'os' / name
|
|
if path.is_symlink():
|
|
deny('INVALID_LOCK_ROOT', 'Stable OS lock must be a regular file')
|
|
return path
|
|
|
|
|
|
def atomic_write(path, content):
|
|
data = content.encode() if isinstance(content, str) else content
|
|
mode = path.stat().st_mode & 0o777 if path.exists() else 0o600
|
|
fd, tmp = tempfile.mkstemp(prefix='.' + path.name + '.agentctl-', dir=path.parent)
|
|
try:
|
|
os.fchmod(fd, mode)
|
|
with os.fdopen(fd, 'wb') as f:
|
|
f.write(data)
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
os.replace(tmp, path)
|
|
directory = os.open(path.parent, os.O_DIRECTORY)
|
|
try:
|
|
os.fsync(directory)
|
|
finally:
|
|
os.close(directory)
|
|
finally:
|
|
if os.path.exists(tmp):
|
|
os.unlink(tmp)
|
|
|
|
|
|
def initialize(repo):
|
|
if repo.current != repo.main:
|
|
deny('MAIN_WORKTREE_REQUIRED', 'Initialize from the main worktree')
|
|
legacy = repo.common / 'agent-control'
|
|
legacy.mkdir(exist_ok=True)
|
|
with (legacy / 'init.lock').open('a+') as initialization:
|
|
fcntl.flock(initialization, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
if (repo.root / 'identity.json').exists():
|
|
identity = repo.identity()
|
|
with repo.db(True) as db:
|
|
db.execute('SELECT count(*) FROM runs').fetchone()
|
|
db.execute('SELECT count(*) FROM queue_items').fetchone()
|
|
emit({'status': 'initialized', 'lock_root': str(repo.root), **identity})
|
|
return
|
|
old = legacy / 'dev-integration.lock'
|
|
if old.is_symlink():
|
|
deny('RECOVERY_REQUIRED', 'Legacy lock is a symlink; inspect before migration')
|
|
with old.open('a+') as devlock:
|
|
try:
|
|
fcntl.flock(devlock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except BlockingIOError:
|
|
deny('LOCK_BUSY', 'Legacy dev integration is currently locked')
|
|
state = legacy / 'active-operation.json'
|
|
if state.exists():
|
|
value = json.loads(state.read_text())
|
|
if value.get('phase') not in ('idle', 'released', 'closed', 'completed'):
|
|
deny('RECOVERY_REQUIRED', 'Legacy operation is not terminal; inspect its owner first')
|
|
repo.root.mkdir(mode=0o700, exist_ok=True)
|
|
if repo.dbfile.is_symlink() or (repo.root/'identity.json').is_symlink() or (repo.root/'os').is_symlink():
|
|
deny('INVALID_LOCK_ROOT', 'Initialization will not follow existing state symlinks')
|
|
os.chmod(repo.root, 0o700)
|
|
(repo.root / 'os').mkdir(mode=0o700, exist_ok=True)
|
|
bridge = repo.root / 'os' / 'dev-integration.lock'
|
|
if not bridge.exists():
|
|
os.link(old, bridge)
|
|
if old.stat().st_ino != bridge.stat().st_ino or old.stat().st_dev != bridge.stat().st_dev:
|
|
deny('RECOVERY_REQUIRED', 'Old and new dev locks do not share the same inode')
|
|
# Initialization may resume an interrupted schema creation, never a mismatched identity.
|
|
db = sqlite3.connect(repo.dbfile)
|
|
try:
|
|
db.execute('PRAGMA journal_mode=WAL')
|
|
db.execute('PRAGMA synchronous=FULL')
|
|
db.executescript('''
|
|
CREATE TABLE IF NOT EXISTS tasks(id TEXT PRIMARY KEY, owner TEXT NOT NULL,
|
|
worktree TEXT NOT NULL, branch TEXT NOT NULL, scopes TEXT NOT NULL,
|
|
capabilities TEXT NOT NULL, state TEXT NOT NULL, base_sha TEXT NOT NULL);
|
|
CREATE TABLE IF NOT EXISTS runs(generation INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
id TEXT UNIQUE NOT NULL, task TEXT NOT NULL, fingerprint TEXT NOT NULL,
|
|
resources TEXT NOT NULL, mode TEXT NOT NULL, state TEXT NOT NULL,
|
|
pid INTEGER NOT NULL, start TEXT NOT NULL, boot TEXT NOT NULL,
|
|
child_pid INTEGER, child_start TEXT, pgid INTEGER, created REAL NOT NULL,
|
|
heartbeat REAL NOT NULL, exit_code INTEGER, reason TEXT, kind TEXT NOT NULL DEFAULT 'command');
|
|
CREATE INDEX IF NOT EXISTS runs_active ON runs(state, generation);
|
|
CREATE INDEX IF NOT EXISTS runs_task ON runs(task, state);
|
|
CREATE TABLE IF NOT EXISTS documents(run TEXT PRIMARY KEY, task TEXT NOT NULL,
|
|
worktree TEXT NOT NULL, state TEXT NOT NULL, payload TEXT NOT NULL);
|
|
CREATE TABLE IF NOT EXISTS receipts(task TEXT, file TEXT, hash TEXT,
|
|
PRIMARY KEY(task,file));
|
|
CREATE TABLE IF NOT EXISTS validations(task TEXT, sha TEXT, command_hash TEXT,
|
|
created REAL, PRIMARY KEY(task,sha));
|
|
CREATE TABLE IF NOT EXISTS freezes(resource TEXT PRIMARY KEY, owner TEXT, reason TEXT);
|
|
CREATE TABLE IF NOT EXISTS events(id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
created REAL NOT NULL, operation TEXT NOT NULL, detail TEXT NOT NULL);
|
|
''' + QUEUE_SCHEMA)
|
|
db.commit()
|
|
finally:
|
|
db.close()
|
|
os.chmod(repo.dbfile, 0o600)
|
|
identity = {'version': VERSION, 'repo_id': str(uuid.uuid4()), 'epoch': str(uuid.uuid4()),
|
|
'main': str(repo.main), 'common': str(repo.common)}
|
|
atomic_write(repo.root / 'identity.json', json.dumps(identity))
|
|
emit({'status': 'initialized', 'lock_root': str(repo.root), **identity})
|
|
|
|
|
|
def ensure_schema(db):
|
|
"""Additive tables for durable queue + W4 deploy/projection + claims; write txn only."""
|
|
for statement in filter(None, (part.strip() for part in QUEUE_SCHEMA.split(';'))):
|
|
db.execute(statement)
|
|
for statement in filter(None, (part.strip() for part in CLAIM_SCHEMA.split(';'))):
|
|
db.execute(statement)
|
|
existing = {row[1] for row in db.execute('PRAGMA table_info(runs)')}
|
|
for name, decl in RUN_QUARANTINE_COLUMNS:
|
|
if name not in existing:
|
|
db.execute(f'ALTER TABLE runs ADD COLUMN {name} {decl}')
|
|
w4.ensure_schema(db)
|
|
|
|
|
|
def task_row(repo, task_id):
|
|
with repo.db() as db:
|
|
row = db.execute('SELECT * FROM tasks WHERE id=?', (task_id,)).fetchone()
|
|
if not row:
|
|
deny('UNKNOWN_TASK', 'Register the task before requesting a lease')
|
|
if row['state'] != 'active':
|
|
deny('TASK_CLOSED', 'This task is no longer active')
|
|
if not Path(row['worktree']).exists() or git(row['worktree'], 'branch', '--show-current') != row['branch']:
|
|
deny('WORKTREE_MISMATCH', 'Registered task branch/worktree changed')
|
|
return dict(row)
|
|
|
|
|
|
def check_resources(repo, task, resources):
|
|
scopes, caps = json.loads(task['scopes']), json.loads(task['capabilities'])
|
|
for value in resources:
|
|
if value.startswith('path:'):
|
|
p = value[5:]
|
|
if p != '.' and secret_path(p):
|
|
deny('PATH_OUT_OF_SCOPE', 'Runtime and Git administration paths are not task source scopes')
|
|
safe_path(Path(task['worktree']), p)
|
|
if not any(covers(s, p) for s in scopes):
|
|
deny('PATH_OUT_OF_SCOPE', 'Resource is outside the registered write scope', resource=value)
|
|
elif value.startswith('claim:'):
|
|
p = value[6:]
|
|
if p != '.' and secret_path(p):
|
|
deny('PATH_OUT_OF_SCOPE', 'Runtime and Git administration paths are not claimable')
|
|
if not any(covers(s, p) for s in scopes):
|
|
deny('PATH_OUT_OF_SCOPE', 'Claim resource is outside the registered write scope', resource=value)
|
|
elif value.startswith('tree:'):
|
|
if value != 'tree:' + task['worktree']:
|
|
deny('PATH_OUT_OF_SCOPE', 'Task cannot acquire another worktree index')
|
|
elif value not in caps:
|
|
deny('CAPABILITY_REQUIRED', 'Task lacks this registered capability', resource=value)
|
|
|
|
|
|
def event(db, operation, detail):
|
|
db.execute('INSERT INTO events(created,operation,detail) VALUES(?,?,?)',
|
|
(time.time(), operation, json.dumps(detail, ensure_ascii=False)))
|
|
|
|
|
|
def physical_names(resources):
|
|
names = set()
|
|
for item in resources:
|
|
if item in ('git:dev', 'env:dev'):
|
|
names.add('dev-integration.lock')
|
|
if item == 'git:worktrees':
|
|
names.add('worktrees.lock')
|
|
if item == 'docs:publish' or (item.startswith('path:') and any(covers(item[5:], p) for p in SHARED_DOCS)):
|
|
names.add('shared-writeback.lock')
|
|
if item.startswith('tree:'):
|
|
names.add('tree-' + digest(item) + '.lock')
|
|
return sorted(names)
|
|
|
|
|
|
class Lease:
|
|
def __init__(self, repo, task, resources, request_id, fingerprint, wait=0, mode='X', kind='command'):
|
|
self.repo, self.task = repo, task
|
|
self.resources = sorted(set('tree:' + task['worktree'] if r == 'git:index' else resource(r) for r in resources))
|
|
check_resources(repo, task, self.resources)
|
|
self.request_id = request_id or str(uuid.uuid4())
|
|
if not isinstance(self.request_id, str) or not re.fullmatch(r'[A-Za-z0-9._-]{1,128}', self.request_id):
|
|
deny('INVALID_REQUEST', 'Invalid request id')
|
|
self.fingerprint, self.wait, self.mode, self.kind = fingerprint, max(0, min(wait, 60)), mode, kind
|
|
self.handles, self.row, self.replay = [], None, False
|
|
|
|
def unlock(self):
|
|
for handle in reversed(self.handles):
|
|
handle.close()
|
|
self.handles = []
|
|
|
|
def __enter__(self):
|
|
lookup, info = read_proc(os.getpid())
|
|
if lookup != 'ok' or not info:
|
|
deny('PROCESS_UNREADABLE', 'Cannot observe supervisor process', lookup=lookup)
|
|
with self.repo.db(True) as db:
|
|
ensure_schema(db)
|
|
old = db.execute('SELECT * FROM runs WHERE id=?', (self.request_id,)).fetchone()
|
|
if old:
|
|
if old['task'] != self.task['id'] or old['fingerprint'] != self.fingerprint or old['kind'] != self.kind:
|
|
deny('REQUEST_ID_REUSED', 'Request id belongs to a different operation')
|
|
if old['state'] in TERMINAL:
|
|
self.row, self.replay = dict(old), True
|
|
return self
|
|
if old['state'] not in ('busy',):
|
|
deny('RECOVERY_REQUIRED', 'Operation already exists; query or recover it')
|
|
db.execute("UPDATE runs SET state='queued',pid=?,start=?,boot=?,heartbeat=? WHERE id=?",
|
|
(os.getpid(), info.start, boot_epoch(), time.time(), self.request_id))
|
|
else:
|
|
db.execute('''INSERT INTO runs(id,task,fingerprint,resources,mode,state,pid,start,boot,created,heartbeat,kind)
|
|
VALUES(?,?,?,?,?,'queued',?,?,?,?,?,?)''',
|
|
(self.request_id, self.task['id'], self.fingerprint, json.dumps(self.resources),
|
|
self.mode, os.getpid(), info.start, boot_epoch(), time.time(), time.time(), self.kind))
|
|
self.row = dict(db.execute('SELECT * FROM runs WHERE id=?', (self.request_id,)).fetchone())
|
|
deadline = time.monotonic() + self.wait
|
|
holder = None
|
|
while True:
|
|
try:
|
|
for name in physical_names(self.resources):
|
|
handle = self.repo.os_file(name).open('a+')
|
|
self.handles.append(handle)
|
|
fcntl.flock(handle, (fcntl.LOCK_SH if self.mode == 'S' else fcntl.LOCK_EX) | fcntl.LOCK_NB)
|
|
# Lifecycle lock prevents cleanup between registry validation and execution.
|
|
lifecycle = self.repo.os_file('worktree-' + digest(self.task['worktree']) + '.lock').open('a+')
|
|
self.handles.append(lifecycle)
|
|
fcntl.flock(lifecycle, fcntl.LOCK_SH | fcntl.LOCK_NB)
|
|
task_row(self.repo, self.task['id'])
|
|
with self.repo.db(True) as db:
|
|
for frozen in db.execute('SELECT * FROM freezes'):
|
|
if self.mode == 'X' and any(overlaps(r, frozen['resource']) for r in self.resources):
|
|
deny('FROZEN', 'Resource is frozen', resource=frozen['resource'])
|
|
candidates = db.execute(
|
|
"SELECT * FROM runs WHERE id!=? AND state IN ('running','recovery_required','queued','suspect','quarantine') ORDER BY generation",
|
|
(self.request_id,)).fetchall()
|
|
for other in candidates:
|
|
if other['state'] == 'queued':
|
|
# Only reclaim when proven dead; unknown keeps the slot.
|
|
if alive_status(other['pid'], other['start'], other['boot']) == 'dead':
|
|
db.execute("UPDATE runs SET state='busy' WHERE id=?", (other['id'],))
|
|
continue
|
|
if other['generation'] > self.row['generation']:
|
|
continue
|
|
if self.mode == other['mode'] == 'S':
|
|
continue
|
|
if any(overlaps(a,b) for a in self.resources for b in json.loads(other['resources'])):
|
|
holder = {'task': other['task'], 'run_id': other['id'], 'state': other['state']}
|
|
raise BlockingIOError()
|
|
db.execute("UPDATE runs SET state='running',heartbeat=? WHERE id=?", (time.time(), self.request_id))
|
|
event(db, self.request_id, {'event': 'acquired', 'task': self.task['id'], 'resources': self.resources})
|
|
return self
|
|
except BlockingIOError:
|
|
self.unlock()
|
|
if time.monotonic() >= deadline:
|
|
with self.repo.db(True) as db:
|
|
db.execute("UPDATE runs SET state='busy' WHERE id=?", (self.request_id,))
|
|
deny('LOCK_BUSY', 'Resource is busy; retry this request id or do independent work',
|
|
request_id=self.request_id, holder=holder, retry_after=1)
|
|
time.sleep(min(0.1, max(0, deadline - time.monotonic())))
|
|
except BaseException:
|
|
self.unlock()
|
|
with self.repo.db(True) as db:
|
|
db.execute("UPDATE runs SET state='failed',exit_code=1 WHERE id=? AND state='queued'", (self.request_id,))
|
|
raise
|
|
|
|
def finish(self, code=0, recovery=False):
|
|
if self.replay:
|
|
return
|
|
with self.repo.db(True) as db:
|
|
db.execute('UPDATE runs SET state=?,exit_code=?,heartbeat=? WHERE id=?',
|
|
('recovery_required' if recovery else ('completed' if code == 0 else 'failed'),
|
|
code, time.time(), self.request_id))
|
|
event(db, self.request_id, {'event': 'finished', 'exit_code': code, 'recovery_required': recovery})
|
|
|
|
def __exit__(self, typ, error, tb):
|
|
if typ and not self.replay:
|
|
try:
|
|
with self.repo.db(True) as db:
|
|
doc = db.execute('SELECT state FROM documents WHERE run=?', (self.request_id,)).fetchone()
|
|
state = db.execute('SELECT * FROM runs WHERE id=?', (self.request_id,)).fetchone()
|
|
if state and state['state'] == 'running':
|
|
# unknown/alive both require recovery; only proven-dead releases cleanly.
|
|
remaining = group_status(state['pgid']) != 'dead'
|
|
self.finish(1, bool(doc and doc['state'] == 'prepared') or remaining)
|
|
finally:
|
|
self.unlock()
|
|
else:
|
|
self.unlock()
|
|
|
|
|
|
def inherited(repo, required=None):
|
|
run_id = os.getenv('AGENTCTL_RUN_ID', '')
|
|
with repo.db() as db:
|
|
row = db.execute('SELECT * FROM runs WHERE id=?', (run_id,)).fetchone()
|
|
if not row or row['state'] != 'running':
|
|
deny('LEASE_REQUIRED', 'Run the operation through agentctl with')
|
|
# Fail-closed: unknown supervisor or uncertain descent denies the lease identity.
|
|
if (os.getenv('AGENTCTL_TASK_ID') != row['task']
|
|
or os.getenv('AGENTCTL_GENERATION') != str(row['generation'])
|
|
or os.getenv('AGENTCTL_EPOCH') != repo.identity()['epoch']
|
|
or alive_status(row['pid'], row['start'], row['boot']) != 'alive'
|
|
or descent_status(row['pid']) != 'yes'):
|
|
deny('STALE_GENERATION', 'Lease identity is stale or caller is not a supervised descendant')
|
|
# Unset env equates to fence 0 so pre-upgrade supervisors / self-bootstrap
|
|
# ff hooks (which re-exec the updated agentctl.py mid-merge) stay compatible.
|
|
expected_fence = str(row['fence'] if 'fence' in row.keys() else 0)
|
|
observed_fence = os.getenv('AGENTCTL_FENCE', '0')
|
|
if observed_fence != expected_fence:
|
|
deny('STALE_GENERATION', 'Lease fence is stale or missing', expected=expected_fence,
|
|
observed=os.getenv('AGENTCTL_FENCE'))
|
|
if required:
|
|
resources = json.loads(row['resources'])
|
|
required = 'tree:' + task_row(repo, row['task'])['worktree'] if required == 'git:index' else resource(required)
|
|
permitted = required in resources or (required.startswith('path:') and any(r.startswith('path:') and covers(r[5:], required[5:]) for r in resources))
|
|
if not permitted or row['mode'] != 'X':
|
|
deny('LEASE_REQUIRED', 'Required exclusive resource is not held', resource=required)
|
|
task = task_row(repo, row['task'])
|
|
return dict(row), task
|
|
|
|
|
|
def supervise(repo, lease, command, cwd, env=None):
|
|
"""Retain the lease until the child group stops or recovery is recorded."""
|
|
child = None
|
|
stopping = []
|
|
|
|
def stop(signum, frame):
|
|
stopping.append(signum)
|
|
if child is not None:
|
|
with contextlib.suppress(ProcessLookupError):
|
|
os.killpg(child.pid, signal.SIGTERM)
|
|
|
|
previous = {sig: signal.signal(sig, stop) for sig in (signal.SIGINT, signal.SIGTERM)}
|
|
try:
|
|
if stopping:
|
|
return 128 + stopping[0], False
|
|
child = subprocess.Popen(command, cwd=cwd, env=env, start_new_session=True)
|
|
lookup, info = read_proc(child.pid)
|
|
child_start = info.start if lookup == 'ok' and info else ''
|
|
with repo.db(True) as db:
|
|
db.execute('UPDATE runs SET child_pid=?,child_start=?,pgid=? WHERE id=?',
|
|
(child.pid, child_start, child.pid, lease.request_id))
|
|
last_beat, stop_at = time.monotonic(), None
|
|
while child.poll() is None:
|
|
if stopping:
|
|
stop_at = stop_at or time.monotonic()
|
|
with contextlib.suppress(ProcessLookupError):
|
|
os.killpg(child.pid, signal.SIGKILL if time.monotonic() - stop_at > 5 else signal.SIGTERM)
|
|
if time.monotonic() - last_beat > 10:
|
|
with repo.db(True) as db:
|
|
db.execute('UPDATE runs SET heartbeat=? WHERE id=?', (time.time(), lease.request_id))
|
|
last_beat = time.monotonic()
|
|
time.sleep(0.1)
|
|
code = child.wait()
|
|
# remaining=True when alive or unknown — never treat opaque enum as all-dead.
|
|
return (code if code >= 0 else 128 - code), group_status(child.pid) != 'dead'
|
|
except BaseException:
|
|
if child is not None:
|
|
with contextlib.suppress(ProcessLookupError):
|
|
os.killpg(child.pid, signal.SIGKILL)
|
|
child.wait()
|
|
lease.finish(1, True)
|
|
raise
|
|
finally:
|
|
for sig, handler in previous.items():
|
|
signal.signal(sig, handler)
|
|
|
|
|
|
def execute(repo, args):
|
|
task = task_row(repo, args.task)
|
|
command = args.command[1:] if args.command[:1] == ['--'] else args.command
|
|
if not command:
|
|
deny('INVALID_REQUEST', 'Expected a child command after --')
|
|
cwd = Path(args.cwd).resolve() if args.cwd else Path(task['worktree'])
|
|
known = {Path(r['worktree']).resolve() for r in repo.worktrees()}
|
|
if cwd not in known or cwd not in (Path(task['worktree']), repo.main):
|
|
deny('WORKTREE_MISMATCH', 'Command cwd must be this task worktree or authorized main operation')
|
|
if cwd == repo.main and not any(r.startswith('env:') or r in ('git:dev','git:test','git:main','git:worktrees') for r in args.resource):
|
|
deny('MAIN_WORKTREE_REQUIRED', 'Main checkout is reserved for registered integration/environment operations')
|
|
if cwd == repo.main and 'git:dev' in args.resource:
|
|
deny('USE_INTEGRATE', 'Protected dev updates use agentctl integrate, not an arbitrary main-worktree command')
|
|
fingerprint = digest(json.dumps([command, str(cwd), sorted(args.resource), args.shared]))
|
|
with Lease(repo, task, args.resource, args.request_id, fingerprint, args.wait,
|
|
'S' if args.shared else 'X') as lease:
|
|
if lease.replay:
|
|
emit({'status': 'replayed', 'run_id': lease.request_id, 'exit_code': lease.row['exit_code']}, sys.stderr)
|
|
return lease.row['exit_code'] or 0
|
|
env = os.environ.copy()
|
|
env.update(AGENTCTL_RUN_ID=lease.request_id, AGENTCTL_TASK_ID=args.task,
|
|
AGENTCTL_GENERATION=str(lease.row['generation']), AGENTCTL_EPOCH=repo.identity()['epoch'],
|
|
AGENTCTL_FENCE=str(lease.row.get('fence') or 0))
|
|
emit({'status': 'running', 'run_id': lease.request_id, 'generation': lease.row['generation']}, sys.stderr)
|
|
code, remaining = supervise(repo, lease, command, cwd, env)
|
|
recovery = remaining or bool(code and any(r.startswith(('env:', 'git:dev')) for r in args.resource))
|
|
lease.finish(code, recovery)
|
|
if recovery:
|
|
emit({'status': 'recovery_required', 'run_id': lease.request_id, 'child_group_alive': remaining}, sys.stderr)
|
|
return code or (1 if remaining else 0)
|
|
|
|
|
|
def doc_rows(content, record_id):
|
|
matches = []
|
|
for number, line in enumerate(content.splitlines()):
|
|
if line.lstrip(' ').startswith('|'):
|
|
cells = [cell.strip() for cell in line.strip().strip('|').split('|')]
|
|
if record_id in cells[:2]:
|
|
matches.append((number, line))
|
|
if len(matches) > 1:
|
|
deny('AMBIGUOUS_RECORD', 'Record id is not unique in this document')
|
|
return matches
|
|
|
|
|
|
def table_cells(line):
|
|
return [cell.strip() for cell in re.split(r'(?<!\\)\|', line.strip().strip('|'))]
|
|
|
|
|
|
def matrix_states(content):
|
|
states, status_index, width = {}, None, None
|
|
for line in content.splitlines():
|
|
if not line.lstrip(' ').startswith('|'):
|
|
if status_index is not None:
|
|
break
|
|
continue
|
|
cells = table_cells(line)
|
|
if status_index is None:
|
|
if cells[0].casefold() not in ('模块', 'module', 'feature'):
|
|
continue
|
|
labels = [c.strip('*').casefold() for c in cells]
|
|
columns = [i for i,c in enumerate(labels) if c in ('状态','state','status')]
|
|
if len(columns) != 1:
|
|
deny('INVALID_DOCUMENT', 'Matrix needs one explicit status column')
|
|
status_index, width = columns[0], len(cells)
|
|
continue
|
|
if all(re.fullmatch(r':?-+:?', cell.replace(' ', '')) for cell in cells):
|
|
continue
|
|
if len(cells) != width or cells[0] in states:
|
|
deny('INVALID_DOCUMENT', 'Matrix records must have unique ids and match its columns')
|
|
value = cells[status_index]
|
|
if value not in ('done','partial','planned','deferred','blocked','removed'):
|
|
deny('INVALID_STATE', 'Matrix status must use an unformatted canonical state')
|
|
states[cells[0]] = value
|
|
if status_index is None:
|
|
deny('INVALID_DOCUMENT', 'Matrix table header is missing')
|
|
return states
|
|
|
|
|
|
def docs_snapshot(repo, args):
|
|
if args.file not in SHARED_DOCS:
|
|
deny('PATH_OUT_OF_SCOPE', 'Only the two shared governance documents are managed')
|
|
content = safe_path(repo.current, args.file).read_text()
|
|
row = [(0, content)] if args.record == '@document' else doc_rows(content, args.record)
|
|
emit({'file': args.file, 'record_id': args.record, 'sha256': digest(row[0][1]) if row else None,
|
|
'row': row[0][1] if row else None, 'document_sha256': digest(content)})
|
|
|
|
|
|
def docs_apply(repo, args):
|
|
task = task_row(repo, args.task)
|
|
if repo.current != Path(task['worktree']) or repo.current == repo.main:
|
|
deny('WORKTREE_MISMATCH', 'Shared documents are edited only in the task worktree')
|
|
request = json.loads(Path(args.request).read_text())
|
|
if not isinstance(request, dict) or not isinstance(request.get('changes'), list) or not request['changes'] or any(not isinstance(c, dict) for c in request['changes']):
|
|
deny('INVALID_REQUEST', 'Expected a nonempty changes array')
|
|
resources = ['docs:publish']
|
|
for change in request['changes']:
|
|
if not isinstance(change.get('file'), str) or change['file'] not in SHARED_DOCS:
|
|
deny('PATH_OUT_OF_SCOPE', 'Unknown shared document')
|
|
resources.append('path:' + change['file'])
|
|
with Lease(repo, task, resources, request.get('request_id'), digest(json.dumps(request, sort_keys=True))) as lease:
|
|
if lease.replay:
|
|
emit({'status': 'replayed', 'run_id': lease.request_id, 'exit_code': lease.row['exit_code']})
|
|
return lease.row['exit_code'] or 0
|
|
before, after, seen = {}, {}, set()
|
|
for change in request['changes']:
|
|
filename, record_id = change['file'], change.get('record_id')
|
|
if not isinstance(record_id, str) or not record_id or (filename, record_id) in seen:
|
|
deny('INVALID_REQUEST', 'Each record must have a unique nonempty id')
|
|
seen.add((filename, record_id))
|
|
if record_id == '@document' and 'docs:admin' not in json.loads(task['capabilities']):
|
|
deny('CAPABILITY_REQUIRED', 'Document layout changes require docs:admin')
|
|
if filename.endswith('CURRENT.md') and record_id != args.task and 'docs:admin' not in json.loads(task['capabilities']):
|
|
deny('PATH_OUT_OF_SCOPE', 'A task can update only its own CURRENT record')
|
|
if record_id in RESERVED_RECORD_IDS:
|
|
deny('PATH_OUT_OF_SCOPE', 'Use task ids; slot ownership is not a document edit')
|
|
path = safe_path(repo.current, filename)
|
|
if filename not in before:
|
|
before[filename] = path.read_text()
|
|
base = git(repo.current, 'show', 'HEAD:' + filename, check=False)
|
|
with repo.db() as db:
|
|
prior = db.execute('SELECT hash FROM receipts WHERE task=? AND file=?', (args.task, filename)).fetchone()
|
|
if base.returncode != 0 or (before[filename] != base.stdout and (not prior or prior['hash'] != digest(before[filename]))):
|
|
deny('UNMANAGED_DOCUMENT_CHANGE', 'Shared document differs from HEAD and its last managed receipt; preserve and reconcile first')
|
|
after[filename] = before[filename]
|
|
existing = [(0, before[filename])] if record_id == '@document' else doc_rows(before[filename], record_id)
|
|
expected = digest(existing[0][1]) if existing else None
|
|
if 'expected_sha256' not in change or change['expected_sha256'] != expected:
|
|
deny('BASE_CHANGED', 'Record changed; read the current snapshot before editing', record_id=record_id)
|
|
replacement = change.get('replacement')
|
|
if record_id == '@document':
|
|
if not isinstance(replacement, str):
|
|
deny('INVALID_REQUEST', 'Expected full document text')
|
|
if any(other.get('file') == filename and other is not change for other in request['changes']):
|
|
deny('INVALID_REQUEST', 'Whole-document operation cannot mix row edits for the same file')
|
|
for section in PROTECTED_SECTIONS.get(filename, []):
|
|
def slot(text, marker=section):
|
|
match = re.search(r'(?m)^' + re.escape(marker) + r'\s*\n(.*?)(?=^## |\Z)', text, re.S | re.M)
|
|
return match.group(1) if match else None
|
|
if slot(replacement) != slot(before[filename]):
|
|
deny('PATH_OUT_OF_SCOPE', 'Document admin cannot overwrite integration slot ownership')
|
|
# A layout update cannot bypass the feature-completion evidence rule.
|
|
if DOC_RULES.get(filename, {}).get('evidence_for_done') or filename.endswith('FEATURE-MATRIX.md'):
|
|
old_states = matrix_states(before[filename])
|
|
new_states = matrix_states(replacement)
|
|
if any(value == 'done' and old_states.get(key) != 'done' for key,value in new_states.items()):
|
|
deny('EVIDENCE_REQUIRED', 'Change capability completion using a reviewed row update')
|
|
after[filename] = replacement
|
|
continue
|
|
if not isinstance(replacement, str) or len(replacement.splitlines()) != 1 or not replacement.startswith('|'):
|
|
deny('INVALID_REQUEST', 'Replacement must be one Markdown table row')
|
|
cells = table_cells(replacement)
|
|
if record_id not in cells[:2]:
|
|
deny('INVALID_REQUEST', 'Replacement must preserve the record id')
|
|
if filename.endswith('FEATURE-MATRIX.md') and 'done' in cells and matrix_states(before[filename]).get(record_id) != 'done':
|
|
evidence = change.get('evidence', [])
|
|
if 'docs:approve' not in json.loads(task['capabilities']) or not isinstance(evidence, list) or not evidence or any(not isinstance(e, str) for e in evidence):
|
|
deny('EVIDENCE_REQUIRED', 'Capability completion requires approval capability and committed evidence')
|
|
for evidence_file in evidence:
|
|
safe_path(repo.current, evidence_file)
|
|
git(repo.current, 'cat-file', '-e', 'HEAD:' + relative(evidence_file))
|
|
lines = after[filename].splitlines()
|
|
live = doc_rows(after[filename], record_id)
|
|
if live:
|
|
lines[live[0][0]] = replacement
|
|
else:
|
|
header = DOC_RULES.get(filename, {}).get('header')
|
|
if not header:
|
|
header = '| owner | task |' if filename.endswith('CURRENT.md') else '| 模块 |'
|
|
positions = [i for i, line in enumerate(lines) if line.startswith(header)]
|
|
if len(positions) != 1:
|
|
deny('INVALID_DOCUMENT', 'Target table header is absent or ambiguous')
|
|
lines.insert(positions[0] + 2, replacement)
|
|
after[filename] = '\n'.join(lines) + '\n'
|
|
for name, content in after.items():
|
|
if name.endswith('FEATURE-MATRIX.md'):
|
|
matrix_states(content)
|
|
payload = {name: {'before': before[name], 'after': after[name]} for name in before}
|
|
with repo.db(True) as db:
|
|
db.execute("INSERT INTO documents VALUES(?,?,?,'prepared',?)",
|
|
(lease.request_id, args.task, str(repo.current), json.dumps(payload)))
|
|
for name, values in payload.items():
|
|
path = safe_path(repo.current, name)
|
|
if path.read_text() != values['before']:
|
|
deny('BASE_CHANGED', 'Document changed outside the managed entrypoint; recovery required')
|
|
atomic_write(path, values['after'])
|
|
with repo.db(True) as db:
|
|
for name, values in payload.items():
|
|
db.execute('INSERT OR REPLACE INTO receipts VALUES(?,?,?)', (args.task, name, digest(values['after'])))
|
|
db.execute("UPDATE documents SET state='applied' WHERE run=?", (lease.request_id,))
|
|
lease.finish()
|
|
emit({'status': 'applied', 'run_id': lease.request_id,
|
|
'files': {name: digest(values['after']) for name, values in payload.items()}})
|
|
return 0
|
|
|
|
|
|
def guard(repo, args):
|
|
if args.kind == 'build':
|
|
sha = git(repo.current, 'rev-parse', '--verify', args.commit + '^{commit}')
|
|
if git(repo.current, 'rev-parse', 'HEAD') != sha:
|
|
deny('BASE_CHANGED', 'Build source HEAD does not match the requested commit')
|
|
clean_source(repo.current, build=True)
|
|
elif args.kind == 'resource':
|
|
inherited(repo, args.resource)
|
|
elif args.kind == 'env':
|
|
if repo.current != repo.main or git(repo.main, 'branch', '--show-current') != 'dev':
|
|
deny('MAIN_WORKTREE_REQUIRED', 'Shared services run only from the main dev worktree')
|
|
targets = ['dev', 'test', 'main', 'shared'] if args.target == 'all' else [args.target]
|
|
for target in targets:
|
|
inherited(repo, 'env:' + target)
|
|
clean_source(repo.main)
|
|
emit({'status': 'allowed'})
|
|
|
|
|
|
def worktree_check(repo, path):
|
|
target = Path(path).resolve()
|
|
with repo.db() as db:
|
|
tasks = db.execute('SELECT id,state FROM tasks WHERE worktree=?', (str(target),)).fetchall()
|
|
for task in tasks:
|
|
row = db.execute(
|
|
"SELECT id FROM runs WHERE task=? AND state IN ('running','recovery_required','queued','suspect','quarantine')",
|
|
(task['id'],)).fetchone()
|
|
if row:
|
|
deny('LOCK_BUSY', 'Worktree has a live or unresolved operation', run_id=row['id'])
|
|
if task['state'] == 'active':
|
|
deny('TASK_ACTIVE', 'Close the registered task before removing its worktree')
|
|
emit({'status': 'allowed', 'path': str(target)})
|
|
|
|
|
|
def _external_side_effect_resources(resources):
|
|
return [r for r in resources if r.startswith('env:') or r == 'git:dev']
|
|
|
|
|
|
def _require_external_verification(resources, reason):
|
|
external = _external_side_effect_resources(resources)
|
|
if external and EXTERNAL_VERIFY_MARKER not in (reason or ''):
|
|
deny('EVIDENCE_REQUIRED',
|
|
'env:* or git:dev recovery requires an explicit verified: marker in --reason',
|
|
resources=external)
|
|
return external
|
|
|
|
|
|
def _acquire_physical_locks(repo, resources):
|
|
handles = []
|
|
try:
|
|
for name in physical_names(resources):
|
|
handle = repo.os_file(name).open('a+')
|
|
handles.append(handle)
|
|
try:
|
|
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except BlockingIOError:
|
|
deny('LOCK_BUSY', 'A physical operation is still in progress')
|
|
return handles
|
|
except BaseException:
|
|
for handle in reversed(handles):
|
|
handle.close()
|
|
raise
|
|
|
|
|
|
def _process_recovery_gates(row):
|
|
# Deny unless supervisor and (same-boot) child group are proven dead.
|
|
if alive_status(row['pid'], row['start'], row['boot']) != 'dead':
|
|
deny('LOCK_BUSY', 'Supervisor or child process group is still alive; no takeover')
|
|
if row['boot'] == boot_epoch() and group_status(row['pgid']) != 'dead':
|
|
deny('LOCK_BUSY', 'Supervisor or child process group is still alive; no takeover')
|
|
|
|
|
|
def _apply_prepared_document(repo, row, document):
|
|
if not document or document['state'] != 'prepared':
|
|
return
|
|
root = Path(document['worktree'])
|
|
payload = json.loads(document['payload'])
|
|
for name, values in payload.items():
|
|
if safe_path(root, name).read_text() not in (values['before'], values['after']):
|
|
deny('BASE_CHANGED', 'Recovery would overwrite third-party content; preserve the worktree')
|
|
for name, values in payload.items():
|
|
if safe_path(root, name).read_text() != values['after']:
|
|
atomic_write(safe_path(root, name), values['after'])
|
|
with repo.db(True) as db:
|
|
ensure_schema(db)
|
|
for name, values in payload.items():
|
|
db.execute('INSERT OR REPLACE INTO receipts VALUES(?,?,?)', (row['task'], name, digest(values['after'])))
|
|
db.execute("UPDATE documents SET state='applied' WHERE run=?", (row['id'],))
|
|
|
|
|
|
def recover(repo, args):
|
|
with repo.db(True) as db:
|
|
ensure_schema(db)
|
|
row = db.execute('SELECT * FROM runs WHERE id=?', (args.run,)).fetchone()
|
|
document = db.execute('SELECT * FROM documents WHERE run=?', (args.run,)).fetchone()
|
|
if not row:
|
|
deny('UNKNOWN_OPERATION', 'Unknown run id')
|
|
if row['state'] in TERMINAL:
|
|
emit({'status': row['state'], 'run_id': args.run})
|
|
return
|
|
resources = json.loads(row['resources'])
|
|
external = _require_external_verification(resources, args.reason)
|
|
_process_recovery_gates(row)
|
|
handles = _acquire_physical_locks(repo, resources)
|
|
try:
|
|
_apply_prepared_document(repo, row, document)
|
|
with repo.db(True) as db:
|
|
ensure_schema(db)
|
|
db.execute(
|
|
"UPDATE runs SET state='recovered',reason=?,exit_code=?,fence=COALESCE(fence,0)+1 WHERE id=?",
|
|
(args.reason, 0 if document else 1, args.run))
|
|
event(db, args.run, {
|
|
'event': 'operator_recovery',
|
|
'reason': args.reason,
|
|
'external_side_effects': bool(external),
|
|
'resources': external,
|
|
})
|
|
emit({'status': 'recovered', 'run_id': args.run})
|
|
finally:
|
|
for handle in reversed(handles):
|
|
handle.close()
|
|
|
|
|
|
def quarantine_sweep(repo):
|
|
"""Demote stale/unobservable runs to suspect/quarantine. Never auto-releases."""
|
|
now = time.time()
|
|
with repo.db(True) as db:
|
|
ensure_schema(db)
|
|
rows = [dict(r) for r in db.execute(
|
|
"SELECT * FROM runs WHERE state IN ('running','recovery_required','suspect')").fetchall()]
|
|
# Observe processes outside the write transaction (no flock/git/subprocess in txn).
|
|
decisions = []
|
|
for row in rows:
|
|
alive = alive_status(row['pid'], row['start'], row['boot'])
|
|
group = group_status(row['pgid'])
|
|
heartbeat = float(row['heartbeat'] or 0)
|
|
age = now - heartbeat
|
|
state = row['state']
|
|
if state in ('running', 'recovery_required'):
|
|
if age > HEARTBEAT_TTL_SECONDS and alive != 'alive':
|
|
decisions.append({
|
|
'id': row['id'], 'from': state, 'to': 'suspect',
|
|
'suspect_since': now, 'alive': alive, 'group': group,
|
|
'reason': 'heartbeat_ttl',
|
|
})
|
|
continue
|
|
if state == 'suspect':
|
|
since = float(row['suspect_since'] or row['heartbeat'] or 0)
|
|
if now - since <= SUSPECT_TTL_SECONDS:
|
|
continue
|
|
# Fail-closed: unknown supervisor or non-dead group stays at suspect.
|
|
if alive == 'unknown' or group != 'dead':
|
|
continue
|
|
decisions.append({
|
|
'id': row['id'], 'from': state, 'to': 'quarantine',
|
|
'quarantined_at': now, 'alive': alive, 'group': group,
|
|
'reason': 'suspect_ttl',
|
|
})
|
|
applied = []
|
|
with repo.db(True) as db:
|
|
ensure_schema(db)
|
|
for item in decisions:
|
|
current = db.execute('SELECT state FROM runs WHERE id=?', (item['id'],)).fetchone()
|
|
if not current or current['state'] != item['from']:
|
|
continue
|
|
if item['to'] == 'suspect':
|
|
db.execute(
|
|
"UPDATE runs SET state='suspect',suspect_since=?,quarantine_reason=? WHERE id=?",
|
|
(item['suspect_since'], item['reason'], item['id']))
|
|
else:
|
|
db.execute(
|
|
"UPDATE runs SET state='quarantine',quarantined_at=?,quarantine_reason=? WHERE id=?",
|
|
(item['quarantined_at'], item['reason'], item['id']))
|
|
event(db, item['id'], {
|
|
'event': 'quarantine_demote', 'from': item['from'], 'to': item['to'],
|
|
'reason': item['reason'], 'alive_status': item['alive'], 'group_status': item['group'],
|
|
})
|
|
applied.append({'run_id': item['id'], 'from': item['from'], 'to': item['to']})
|
|
emit({'status': 'ok', 'demoted': applied, 'heartbeat_ttl_seconds': HEARTBEAT_TTL_SECONDS,
|
|
'suspect_ttl_seconds': SUSPECT_TTL_SECONDS})
|
|
return 0
|
|
|
|
|
|
def quarantine_list(repo):
|
|
with repo.db(True) as db:
|
|
ensure_schema(db)
|
|
rows = [dict(r) for r in db.execute(
|
|
"SELECT * FROM runs WHERE state IN ('suspect','quarantine') ORDER BY generation").fetchall()]
|
|
emit({'status': 'ok', 'runs': rows})
|
|
return 0
|
|
|
|
|
|
def quarantine_release(repo, args):
|
|
if not args.verified_by or not str(args.verified_by).strip():
|
|
deny('INVALID_REQUEST', 'quarantine release requires --verified-by')
|
|
with repo.db(True) as db:
|
|
ensure_schema(db)
|
|
row = db.execute('SELECT * FROM runs WHERE id=?', (args.run,)).fetchone()
|
|
document = db.execute('SELECT * FROM documents WHERE run=?', (args.run,)).fetchone()
|
|
if not row:
|
|
deny('UNKNOWN_OPERATION', 'Unknown run id')
|
|
if row['state'] in TERMINAL:
|
|
emit({'status': row['state'], 'run_id': args.run})
|
|
return 0
|
|
if row['state'] not in ('suspect', 'quarantine', 'recovery_required', 'running', 'queued', 'busy'):
|
|
deny('INVALID_REQUEST', 'Run is not in a releasable unresolved state', state=row['state'])
|
|
resources = json.loads(row['resources'])
|
|
external = _require_external_verification(resources, args.reason)
|
|
_process_recovery_gates(row)
|
|
handles = _acquire_physical_locks(repo, resources)
|
|
try:
|
|
_apply_prepared_document(repo, row, document)
|
|
with repo.db(True) as db:
|
|
ensure_schema(db)
|
|
db.execute(
|
|
"UPDATE runs SET state='recovered',reason=?,exit_code=?,fence=COALESCE(fence,0)+1,"
|
|
"verified_by=? WHERE id=?",
|
|
(args.reason, 0 if document else 1, args.verified_by, args.run))
|
|
event(db, args.run, {
|
|
'event': 'quarantine_release',
|
|
'reason': args.reason,
|
|
'verified_by': args.verified_by,
|
|
'external_side_effects': bool(external),
|
|
'resources': external,
|
|
})
|
|
emit({'status': 'recovered', 'run_id': args.run, 'verified_by': args.verified_by})
|
|
finally:
|
|
for handle in reversed(handles):
|
|
handle.close()
|
|
return 0
|
|
|
|
|
|
def hook_check(repo, merge=False):
|
|
if repo.current == repo.main or git(repo.current, 'branch', '--show-current') in ('dev', 'test', 'main', ''):
|
|
deny('PROTECTED_BRANCH', 'Direct development commits require a registered feature/fix worktree')
|
|
run, task = inherited(repo, 'git:index')
|
|
if str(repo.current) != task['worktree']:
|
|
deny('WORKTREE_MISMATCH', 'Commit worktree differs from registered task')
|
|
names = git(repo.current, 'diff', '--cached', '--name-only', '-z', index=True).split('\0')
|
|
scopes = json.loads(task['scopes'])
|
|
run_resources = json.loads(run['resources'])
|
|
merge_has_dev_lease = merge and 'git:dev' in run_resources
|
|
for name in filter(None, names):
|
|
if secret_path(name):
|
|
deny('PROTECTED_PATH', 'Sensitive/runtime paths cannot enter a task commit', path=name)
|
|
if not any(covers(scope, name) for scope in scopes):
|
|
deny('PATH_OUT_OF_SCOPE', 'Staged file is outside task scope', path=name)
|
|
safe_path(repo.current, name)
|
|
if not merge_has_dev_lease and not any(r.startswith('path:') and covers(r[5:], name) for r in run_resources):
|
|
deny('LEASE_REQUIRED', 'Staged path is not covered by this operation lease', path=name)
|
|
# Integration merge is separately fenced by git:dev and exact candidate validation.
|
|
if name in SHARED_DOCS and not merge_has_dev_lease:
|
|
value = git(repo.current, 'show', ':' + name, index=True, check=False)
|
|
staged = value.stdout if value.returncode == 0 else ''
|
|
with repo.db() as db:
|
|
receipt = db.execute('SELECT hash FROM receipts WHERE task=? AND file=?', (task['id'], name)).fetchone()
|
|
if not receipt or receipt['hash'] != digest(staged):
|
|
deny('DOCUMENT_RECEIPT_REQUIRED', 'Use docs apply for the staged shared document', path=name)
|
|
|
|
|
|
def admit_candidate(repo, task, candidate, expected):
|
|
parents = git(repo.main, 'rev-list', '--parents', '-n', '1', candidate).split()[1:]
|
|
if len(parents) != 2 or parents[0] != expected:
|
|
deny('INVALID_CANDIDATE', 'Candidate must have two parents and the frozen target as its first parent')
|
|
if git(task['worktree'], 'rev-parse', 'HEAD') != candidate:
|
|
deny('BASE_CHANGED', 'Candidate is not the owning task HEAD')
|
|
with repo.db() as db:
|
|
found = db.execute('SELECT sha FROM validations WHERE task=? AND sha=?', (task['id'], candidate)).fetchone()
|
|
if not found:
|
|
deny('VALIDATION_REQUIRED', 'Validate this exact candidate with its owning task first')
|
|
|
|
|
|
def hook_ref(repo, state):
|
|
lines = sys.stdin.read().splitlines()
|
|
if state != 'prepared':
|
|
return
|
|
for line in lines:
|
|
old, new, ref = line.split()
|
|
if ref not in ('refs/heads/dev', 'refs/heads/test', 'refs/heads/main'):
|
|
continue
|
|
target = ref.rsplit('/',1)[1]
|
|
run, task = inherited(repo, 'git:' + target)
|
|
if set(new) == {'0'} or set(old) == {'0'}:
|
|
deny('PROTECTED_BRANCH', 'Protected branch creation/deletion is not a task operation')
|
|
admit_candidate(repo, task, new, old)
|
|
if target == 'dev' and (run['kind'] != 'integrate' or run['fingerprint'] != digest(json.dumps([new, old]))):
|
|
deny('USE_INTEGRATE', 'dev requires the controller operation bound to this old/new pair')
|
|
for name in git(repo.main, 'diff', '--name-only', old, new).splitlines():
|
|
if secret_path(name):
|
|
deny('PROTECTED_PATH', 'Candidate contains a sensitive/runtime path')
|
|
|
|
|
|
def install_hooks(repo):
|
|
if repo.current != repo.main:
|
|
deny('MAIN_WORKTREE_REQUIRED', 'Install hooks from the main worktree after integration')
|
|
repo.identity()
|
|
configured = git(repo.main, 'config', '--get', 'core.hooksPath', check=False)
|
|
if configured.returncode == 0:
|
|
deny('EXISTING_HOOKS', 'Custom hooksPath detected; integrate hooks explicitly instead of overriding it')
|
|
hooks = repo.common / 'hooks'
|
|
hooks.mkdir(exist_ok=True)
|
|
commands = {'pre-commit':'hooks check', 'pre-merge-commit':'hooks check --merge', 'reference-transaction':'hooks ref "$1"'}
|
|
for name in commands:
|
|
p = hooks / name
|
|
if p.exists() and '# agentctl-managed-v1' not in p.read_text():
|
|
deny('EXISTING_HOOKS', 'Existing hook will not be overwritten', hook=name)
|
|
import shlex
|
|
entry = shlex.quote(str(repo.main/'scripts/agentctl.py'))
|
|
for name, command in commands.items():
|
|
p = hooks/name
|
|
atomic_write(p, '#!/bin/sh\n# agentctl-managed-v1\nexec python3 '+entry+' '+command+'\n')
|
|
p.chmod(0o755)
|
|
emit({'status':'installed','hooks':list(commands)})
|
|
|
|
|
|
def validate(repo, args):
|
|
task = task_row(repo, args.task)
|
|
if repo.current != Path(task['worktree']):
|
|
deny('WORKTREE_MISMATCH', 'Validate from the registered task worktree')
|
|
command = args.command[1:] if args.command[:1] == ['--'] else args.command
|
|
if not command:
|
|
deny('INVALID_REQUEST','Validation requires an explicit command')
|
|
# Read all source for exact-HEAD evidence, not just the task's changed paths.
|
|
resources = ['path:' + scope for scope in json.loads(task['scopes'])] + ['git:index']
|
|
with Lease(repo, task, resources, None, digest(json.dumps(['validate', command])), mode='S') as lease:
|
|
clean_source(repo.current)
|
|
sha = git(repo.current, 'rev-parse', 'HEAD')
|
|
with repo.db(True) as db:
|
|
db.execute('DELETE FROM validations WHERE task=? AND sha=?', (args.task, sha))
|
|
code, orphan = supervise(repo, lease, command, repo.current)
|
|
if code or orphan:
|
|
lease.finish(code or 1, orphan)
|
|
return code or 1
|
|
clean_source(repo.current)
|
|
if git(repo.current, 'rev-parse', 'HEAD') != sha:
|
|
deny('BASE_CHANGED','Validation changed HEAD')
|
|
with repo.db(True) as db:
|
|
db.execute('INSERT OR REPLACE INTO validations VALUES(?,?,?,?)',
|
|
(args.task,sha,digest(json.dumps(command)),time.time()))
|
|
lease.finish()
|
|
emit({'status':'validated','sha':sha,'task':args.task,'run_id':lease.request_id})
|
|
return 0
|
|
|
|
|
|
def integrate(repo, args):
|
|
task = task_row(repo, args.task)
|
|
candidate = git(task['worktree'], 'rev-parse', 'HEAD')
|
|
expected = git(repo.main, 'rev-parse', args.expected_dev+'^{commit}')
|
|
admit_candidate(repo, task, candidate, expected)
|
|
with Lease(repo,task,['git:dev','git:index'],args.request_id,digest(json.dumps([candidate,expected])),kind='integrate') as lease:
|
|
if lease.replay:
|
|
emit({'status':'replayed','run_id':lease.request_id,'exit_code':lease.row['exit_code']})
|
|
return lease.row['exit_code'] or 0
|
|
admit_candidate(repo, task, candidate, expected)
|
|
clean_source(repo.main)
|
|
clean_source(Path(task['worktree']))
|
|
if git(repo.main,'branch','--show-current') != 'dev' or git(repo.main,'rev-parse','dev') != expected:
|
|
deny('BASE_CHANGED','Main dev moved; rebuild and validate the candidate')
|
|
env=os.environ.copy();env.update(AGENTCTL_RUN_ID=lease.request_id,AGENTCTL_TASK_ID=args.task,
|
|
AGENTCTL_GENERATION=str(lease.row['generation']),AGENTCTL_EPOCH=repo.identity()['epoch'],
|
|
AGENTCTL_FENCE=str(lease.row.get('fence') or 0))
|
|
code, orphan = supervise(repo, lease, ['git','-C',str(repo.main),'merge','--ff-only',candidate], repo.main, env)
|
|
lease.finish(code,bool(code) or orphan)
|
|
emit({'status':'merged' if code==0 else 'recovery_required','sha':candidate,'run_id':lease.request_id})
|
|
return code
|
|
|
|
|
|
def queue_row(row):
|
|
return dict(row) if row is not None else None
|
|
|
|
|
|
def queue_enqueue(repo, args):
|
|
task = task_row(repo, args.task)
|
|
if repo.current != Path(task['worktree']):
|
|
deny('WORKTREE_MISMATCH', 'Enqueue from the registered task worktree')
|
|
head = git(task['worktree'], 'rev-parse', 'HEAD')
|
|
if args.head and git(task['worktree'], 'rev-parse', args.head + '^{commit}') != head:
|
|
deny('BASE_CHANGED', 'Requested head is not the owning task HEAD')
|
|
expected = git(repo.main, 'rev-parse', args.expected_dev + '^{commit}')
|
|
admit_candidate(repo, task, head, expected)
|
|
item_id = args.request_id or str(uuid.uuid4())
|
|
if not re.fullmatch(r'[A-Za-z0-9._-]{1,128}', item_id):
|
|
deny('INVALID_REQUEST', 'Invalid queue request id')
|
|
now = time.time()
|
|
with repo.db(True) as db:
|
|
existing = db.execute(
|
|
"SELECT * FROM queue_items WHERE task=? AND head_sha=? AND state NOT IN ('cancelled')",
|
|
(args.task, head)).fetchone()
|
|
if existing:
|
|
if existing['state'] in ('pending', 'claimed') and existing['expected_dev'] == expected:
|
|
emit({'status': 'duplicate', 'item': queue_row(existing)})
|
|
return 0
|
|
deny('QUEUE_CONFLICT', 'A non-cancelled queue item already covers this task head',
|
|
item=queue_row(existing))
|
|
if db.execute('SELECT id FROM queue_items WHERE id=?', (item_id,)).fetchone():
|
|
deny('REQUEST_ID_REUSED', 'Queue request id already exists')
|
|
db.execute(
|
|
'''INSERT INTO queue_items(id,task,head_sha,expected_dev,priority,state,created,updated,claim_run,reason)
|
|
VALUES(?,?,?,?,?,'pending',?,?,NULL,NULL)''',
|
|
(item_id, args.task, head, expected, max(0, int(args.priority)), now, now))
|
|
event(db, item_id, {'event': 'queue_enqueued', 'task': args.task, 'head_sha': head})
|
|
row = db.execute('SELECT * FROM queue_items WHERE id=?', (item_id,)).fetchone()
|
|
emit({'status': 'enqueued', 'item': queue_row(row)})
|
|
return 0
|
|
|
|
|
|
def queue_list(repo, args):
|
|
states = None if args.all else ('pending', 'claimed')
|
|
with repo.db(True) as db:
|
|
if states:
|
|
rows = db.execute(
|
|
"SELECT * FROM queue_items WHERE state IN (?,?) ORDER BY priority DESC, created ASC",
|
|
states).fetchall()
|
|
else:
|
|
rows = db.execute('SELECT * FROM queue_items ORDER BY created ASC').fetchall()
|
|
emit({'status': 'ok', 'items': [queue_row(r) for r in rows]})
|
|
return 0
|
|
|
|
|
|
def queue_cancel(repo, args):
|
|
task_row(repo, args.task)
|
|
with repo.db(True) as db:
|
|
row = db.execute('SELECT * FROM queue_items WHERE id=?', (args.id,)).fetchone()
|
|
if not row:
|
|
deny('UNKNOWN_QUEUE_ITEM', 'Unknown queue item id')
|
|
if row['task'] != args.task:
|
|
deny('PATH_OUT_OF_SCOPE', 'Queue item belongs to another task')
|
|
if row['state'] in QUEUE_TERMINAL:
|
|
emit({'status': 'replayed', 'item': queue_row(row)})
|
|
return 0
|
|
if row['state'] == 'claimed':
|
|
deny('LOCK_BUSY', 'Queue item is claimed by an in-flight schedule; recover or wait')
|
|
db.execute("UPDATE queue_items SET state='cancelled',reason=?,updated=? WHERE id=?",
|
|
(args.reason, time.time(), args.id))
|
|
event(db, args.id, {'event': 'queue_cancelled', 'reason': args.reason})
|
|
row = db.execute('SELECT * FROM queue_items WHERE id=?', (args.id,)).fetchone()
|
|
emit({'status': 'cancelled', 'item': queue_row(row)})
|
|
return 0
|
|
|
|
|
|
def queue_schedule(repo, args):
|
|
"""Claim the next durable queue item and run integrate once (or until drain/stop)."""
|
|
if repo.current != repo.main or git(repo.main, 'branch', '--show-current') != 'dev':
|
|
deny('MAIN_WORKTREE_REQUIRED', 'Schedule integrations only from the main dev worktree')
|
|
processed, results = 0, []
|
|
while True:
|
|
with repo.db(True) as db:
|
|
item = db.execute(
|
|
"SELECT * FROM queue_items WHERE state='pending' ORDER BY priority DESC, created ASC LIMIT 1"
|
|
).fetchone()
|
|
if not item:
|
|
break
|
|
db.execute("UPDATE queue_items SET state='claimed',updated=?,claim_run=? WHERE id=? AND state='pending'",
|
|
(time.time(), None, item['id']))
|
|
claimed = db.execute('SELECT * FROM queue_items WHERE id=?', (item['id'],)).fetchone()
|
|
item = queue_row(claimed)
|
|
try:
|
|
admit_candidate(repo, task_row(repo, item['task']), item['head_sha'], item['expected_dev'])
|
|
except Denied as exc:
|
|
with repo.db(True) as db:
|
|
db.execute("UPDATE queue_items SET state='failed',reason=?,updated=? WHERE id=?",
|
|
(exc.code + ': ' + exc.message, time.time(), item['id']))
|
|
event(db, item['id'], {'event': 'queue_failed', 'code': exc.code})
|
|
results.append({'id': item['id'], 'status': 'failed', 'code': exc.code, 'message': exc.message})
|
|
processed += 1
|
|
if not args.drain:
|
|
break
|
|
continue
|
|
request_id = 'queue-' + item['id']
|
|
integrate_args = argparse.Namespace(task=item['task'], expected_dev=item['expected_dev'],
|
|
request_id=request_id)
|
|
try:
|
|
code = integrate(repo, integrate_args)
|
|
except Denied as exc:
|
|
with repo.db(True) as db:
|
|
state = 'pending' if exc.code == 'LOCK_BUSY' else 'failed'
|
|
db.execute("UPDATE queue_items SET state=?,reason=?,updated=?,claim_run=NULL WHERE id=?",
|
|
(state, exc.code + ': ' + exc.message, time.time(), item['id']))
|
|
event(db, item['id'], {'event': 'queue_rescheduled' if state == 'pending' else 'queue_failed',
|
|
'code': exc.code})
|
|
results.append({'id': item['id'], 'status': state, 'code': exc.code, 'message': exc.message})
|
|
processed += 1
|
|
if exc.code == 'LOCK_BUSY' or not args.drain:
|
|
break
|
|
continue
|
|
with repo.db(True) as db:
|
|
if code == 0:
|
|
db.execute("UPDATE queue_items SET state='completed',updated=?,claim_run=?,reason=NULL WHERE id=?",
|
|
(time.time(), request_id, item['id']))
|
|
event(db, item['id'], {'event': 'queue_completed', 'run_id': request_id})
|
|
results.append({'id': item['id'], 'status': 'completed', 'run_id': request_id})
|
|
else:
|
|
db.execute("UPDATE queue_items SET state='failed',updated=?,claim_run=?,reason=? WHERE id=?",
|
|
(time.time(), request_id, 'integrate exit ' + str(code), item['id']))
|
|
event(db, item['id'], {'event': 'queue_failed', 'exit_code': code})
|
|
results.append({'id': item['id'], 'status': 'failed', 'exit_code': code, 'run_id': request_id})
|
|
processed += 1
|
|
if not args.drain:
|
|
break
|
|
emit({'status': 'ok', 'processed': processed, 'results': results})
|
|
return 0 if all(r.get('status') == 'completed' for r in results) or not results else 1
|
|
|
|
|
|
def docs_project(repo, args):
|
|
"""Project registry/queue into a docs-apply request (row or whole-document CAS)."""
|
|
task = task_row(repo, args.task)
|
|
if repo.current != Path(task['worktree']):
|
|
deny('WORKTREE_MISMATCH', 'Project documents from the registered task worktree')
|
|
filename = args.file
|
|
if not w4.projectable_file(filename):
|
|
deny('PATH_OUT_OF_SCOPE', 'File is not registered as projectable')
|
|
mode = getattr(args, 'mode', None) or 'row'
|
|
if mode not in ('row', 'document'):
|
|
deny('INVALID_REQUEST', 'Projection mode must be row or document')
|
|
# Controlled apply still requires shared-governance docs (docs apply gate).
|
|
if getattr(args, 'apply', False) and filename not in SHARED_DOCS:
|
|
deny('PATH_OUT_OF_SCOPE', 'Controlled apply is limited to shared governance documents')
|
|
content = safe_path(repo.current, filename).read_text()
|
|
with repo.db(True) as db:
|
|
ensure_schema(db)
|
|
pending = [queue_row(r) for r in db.execute(
|
|
"SELECT * FROM queue_items WHERE task=? AND state IN ('pending','claimed') ORDER BY priority DESC, created",
|
|
(args.task,)).fetchall()]
|
|
active_runs = [dict(r) for r in db.execute(
|
|
"SELECT id,state,resources,kind FROM runs WHERE task=? AND state IN ('running','queued','recovery_required','busy','suspect','quarantine')",
|
|
(args.task,)).fetchall()]
|
|
queue_state = pending[0]['state'] if pending else 'idle'
|
|
status = args.status or ('queued' if pending else 'in_progress')
|
|
worktree_path = Path(task['worktree'])
|
|
try:
|
|
worktree_cell = str(worktree_path.relative_to(repo.main))
|
|
except ValueError:
|
|
worktree_cell = str(worktree_path)
|
|
|
|
if mode == 'document':
|
|
if filename not in SHARED_DOCS and not getattr(args, 'replacement', None):
|
|
deny('INVALID_REQUEST', 'Document projection outside shared docs requires --replacement')
|
|
replacement = args.replacement
|
|
if replacement is None:
|
|
# Default document projection keeps content and only emits CAS baseline.
|
|
replacement = content
|
|
change = w4.build_document_projection_change(
|
|
file=filename, current_text=content, replacement=replacement)
|
|
record_id = '@document'
|
|
source = 'document'
|
|
else:
|
|
if filename not in SHARED_DOCS:
|
|
deny('INVALID_REQUEST', 'Row projection currently supports shared governance docs only')
|
|
record_id = getattr(args, 'record', None) or args.task
|
|
existing = doc_rows(content, record_id)
|
|
expected_row = existing[0][1] if existing else None
|
|
if args.replacement:
|
|
replacement = args.replacement
|
|
elif filename.endswith('CURRENT.md'):
|
|
replacement = (
|
|
f"| {task['owner']} | {task['id']} | {task['branch']} | {worktree_cell} "
|
|
f"| {args.mutex or '-'} | {status} |"
|
|
)
|
|
else:
|
|
deny('INVALID_REQUEST', 'Row projection for this file requires --replacement')
|
|
change = w4.build_row_projection_change(
|
|
file=filename, record_id=record_id, current_row=expected_row,
|
|
replacement=replacement)
|
|
source = 'row'
|
|
|
|
with repo.db(True) as db:
|
|
ensure_schema(db)
|
|
w4.record_projection(
|
|
db, file=filename, record_id=record_id, task=args.task,
|
|
source=source, content_hash=change['expected_sha256'] or digest(content))
|
|
|
|
machine = {
|
|
'task': {
|
|
'id': task['id'], 'owner': task['owner'], 'branch': task['branch'],
|
|
'worktree': task['worktree'], 'state': task['state'],
|
|
'scopes': json.loads(task['scopes']), 'capabilities': json.loads(task['capabilities']),
|
|
'head_sha': git(task['worktree'], 'rev-parse', 'HEAD'),
|
|
},
|
|
'queue': pending,
|
|
'runs': active_runs,
|
|
'queue_phase': queue_state,
|
|
'snapshot_time': time.time(),
|
|
'dev_sha': git(repo.main, 'rev-parse', 'dev'),
|
|
'mode': mode,
|
|
}
|
|
request = {
|
|
'request_id': args.request_id or (args.task + '-project-1'),
|
|
'changes': [change],
|
|
}
|
|
applied = None
|
|
if getattr(args, 'apply', False):
|
|
# Controlled apply via the same docs-apply entrypoint (lease + receipt).
|
|
req_path = Path(tempfile.mkdtemp(prefix='agentctl-project-')) / 'request.json'
|
|
req_path.write_text(json.dumps(request), encoding='utf-8')
|
|
apply_args = argparse.Namespace(task=args.task, request=str(req_path))
|
|
code = docs_apply(repo, apply_args)
|
|
applied = {'exit_code': code, 'request': str(req_path)}
|
|
if code != 0:
|
|
emit({'status': 'project_apply_failed', 'request': request, 'machine': machine,
|
|
'applied': applied})
|
|
return code
|
|
emit({
|
|
'status': 'projected' if not applied else 'projected_and_applied',
|
|
'request': request,
|
|
'machine': machine,
|
|
'applied': applied,
|
|
})
|
|
return 0
|
|
|
|
|
|
def deploy_cmd(repo, args):
|
|
"""W4 deploy/recover helpers; never touches shared docker from this entrypoint."""
|
|
task = task_row(repo, args.task) if getattr(args, 'task', None) else None
|
|
action = args.deploy_action
|
|
with repo.db(True) as db:
|
|
ensure_schema(db)
|
|
if action == 'run-loop':
|
|
if not task:
|
|
deny('INVALID_REQUEST', 'deploy run-loop requires --task')
|
|
command = list(args.command or [])
|
|
if command and command[0] == '--':
|
|
command = command[1:]
|
|
if not command and not args.dry_run:
|
|
deny('INVALID_REQUEST', 'Provide a deploy command argv or --dry-run')
|
|
if args.dry_run and not command:
|
|
command = [sys.executable, '-c', 'print("dry-run-placeholder")']
|
|
result = w4.execute_deploy_recover_loop(
|
|
db,
|
|
deploy_id=args.id,
|
|
task=args.task,
|
|
env=args.env,
|
|
expected_commit=args.expected_commit,
|
|
command=command,
|
|
dry_run=bool(args.dry_run),
|
|
manifest=args.manifest,
|
|
cwd=str(repo.current),
|
|
)
|
|
emit({'status': result['status'], **result})
|
|
return 0 if result['status'] == 'passed' else 1
|
|
if action == 'status':
|
|
row = w4.deployment_get(db, args.id)
|
|
if not row:
|
|
deny('UNKNOWN_DEPLOY', 'Deploy id not found')
|
|
emit({'status': 'ok', 'deploy': row})
|
|
return 0
|
|
if action == 'recover':
|
|
row = w4.recover_deploy(db, args.id, reason=args.reason)
|
|
emit({'status': 'recovered', 'deploy': row})
|
|
return 0
|
|
deny('INVALID_REQUEST', f'Unknown deploy action {action}')
|
|
|
|
|
|
def cleanup_process_gate(path):
|
|
"""Report process-cwd visibility without deleting. Fail-closed unknowns are explicit."""
|
|
path = Path(path).resolve()
|
|
visibility = w4.cwd_visibility(path)
|
|
status = visibility.get('status')
|
|
if status == 'busy':
|
|
return {
|
|
'status': 'blocked', 'platform': sys.platform,
|
|
'holders': visibility.get('pids', []),
|
|
'unreadable': visibility.get('unreadable', []),
|
|
'reason': 'live process cwd uses worktree',
|
|
}
|
|
if status == 'clear':
|
|
return {
|
|
'status': 'clear', 'platform': sys.platform,
|
|
'holders': [], 'unreadable': [], 'reason': None,
|
|
'platform_note': visibility.get('platform_note'),
|
|
}
|
|
if status == 'unsupported':
|
|
return {
|
|
'status': 'unknown', 'platform': sys.platform,
|
|
'reason': visibility.get('message') or 'process cwd enumeration unsupported; cleanup_pending',
|
|
'holders': [], 'unreadable': [],
|
|
}
|
|
return {
|
|
'status': 'unknown', 'platform': sys.platform,
|
|
'holders': [],
|
|
'unreadable': visibility.get('unreadable', []),
|
|
'reason': visibility.get('message') or 'cannot verify cwd; cleanup_pending',
|
|
'platform_note': visibility.get('platform_note'),
|
|
}
|
|
|
|
|
|
def cleanup_status(repo, args):
|
|
"""Structured cleanup gate report; never removes a worktree."""
|
|
if args.path:
|
|
target = Path(args.path).resolve()
|
|
else:
|
|
matches = [e for e in repo.worktrees() if e.get('branch') == f'refs/heads/{args.branch}']
|
|
if len(matches) != 1:
|
|
deny('UNKNOWN_WORKTREE', 'Expected exactly one worktree for branch', branch=args.branch)
|
|
target = Path(matches[0]['worktree']).resolve()
|
|
gates = []
|
|
|
|
def gate(name, status, detail=None, **extra):
|
|
item = {'name': name, 'status': status, 'detail': detail}
|
|
item.update(extra)
|
|
gates.append(item)
|
|
|
|
if not target.is_dir():
|
|
gate('path_exists', 'fail', 'worktree directory missing')
|
|
emit({'status': 'blocked', 'path': str(target), 'gates': gates, 'cleanup_allowed': False})
|
|
return 0
|
|
|
|
branch_result = git(target, 'branch', '--show-current', check=False)
|
|
head_result = git(target, 'rev-parse', 'HEAD', check=False)
|
|
branch = branch_result.stdout.strip() if branch_result.returncode == 0 else ''
|
|
head = head_result.stdout.strip() if head_result.returncode == 0 else ''
|
|
if not branch or not head:
|
|
gate('git_identity', 'fail', 'unable to read branch/HEAD')
|
|
else:
|
|
gate('git_identity', 'pass', branch=branch, head=head)
|
|
if git(repo.main, 'merge-base', '--is-ancestor', head, 'refs/heads/dev', check=False).returncode:
|
|
gate('dev_ancestor', 'fail', 'task HEAD is not an ancestor of dev; cleanup_pending')
|
|
else:
|
|
gate('dev_ancestor', 'pass')
|
|
|
|
unfinished = []
|
|
for marker in ('MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'rebase-merge', 'rebase-apply', 'BISECT_START'):
|
|
marker_abs = Path(git(target, 'rev-parse', '--path-format=absolute', '--git-path', marker))
|
|
if marker_abs.exists():
|
|
unfinished.append(marker)
|
|
if unfinished:
|
|
gate('git_operation', 'fail', 'unfinished Git operation; cleanup_pending', markers=unfinished)
|
|
else:
|
|
gate('git_operation', 'pass')
|
|
|
|
porcelain = git(target, 'status', '--porcelain=v1', '--untracked-files=all', '--ignored=matching')
|
|
if porcelain:
|
|
gate('worktree_clean', 'fail', 'tracked, untracked or ignored files remain',
|
|
sample=porcelain.splitlines()[:20])
|
|
else:
|
|
gate('worktree_clean', 'pass')
|
|
|
|
with repo.db(True) as db:
|
|
tasks = [dict(r) for r in db.execute('SELECT id,state FROM tasks WHERE worktree=?', (str(target),)).fetchall()]
|
|
busy = []
|
|
for task in tasks:
|
|
row = db.execute(
|
|
"SELECT id,state FROM runs WHERE task=? AND state IN ('running','recovery_required','queued','suspect','quarantine')",
|
|
(task['id'],)).fetchone()
|
|
if row:
|
|
busy.append(dict(row))
|
|
if any(t['state'] == 'active' for t in tasks):
|
|
gate('registry_task', 'fail', 'active registered task owns this worktree', tasks=tasks)
|
|
else:
|
|
gate('registry_task', 'pass', tasks=tasks)
|
|
if busy:
|
|
gate('registry_runs', 'fail', 'live or unresolved operation', runs=busy)
|
|
else:
|
|
gate('registry_runs', 'pass')
|
|
|
|
key = digest(str(target))
|
|
lock_path = repo.root / 'os' / f'worktree-{key}.lock'
|
|
lifecycle = 'unknown'
|
|
if lock_path.exists():
|
|
try:
|
|
with lock_path.open('a+') as handle:
|
|
try:
|
|
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
fcntl.flock(handle, fcntl.LOCK_UN)
|
|
lifecycle = 'free'
|
|
except BlockingIOError:
|
|
lifecycle = 'held'
|
|
except OSError as exc:
|
|
lifecycle = f'error:{exc}'
|
|
else:
|
|
lifecycle = 'absent'
|
|
gate('lifecycle_lock', 'fail' if lifecycle == 'held' else ('pass' if lifecycle in ('free', 'absent') else 'unknown'),
|
|
lifecycle)
|
|
|
|
process = cleanup_process_gate(target)
|
|
gate('process_cwd', 'pass' if process['status'] == 'clear' else (
|
|
'fail' if process['status'] == 'blocked' else 'unknown'), process.get('reason'), **process)
|
|
|
|
allowed = all(g['status'] == 'pass' for g in gates)
|
|
emit({
|
|
'status': 'allowed' if allowed else 'blocked',
|
|
'path': str(target),
|
|
'cleanup_allowed': allowed,
|
|
'gates': gates,
|
|
})
|
|
return 0
|
|
|
|
|
|
def _claim_fingerprint(task_id, resources, mode='X'):
|
|
return digest(json.dumps({'task': task_id, 'resources': sorted(resources), 'mode': mode}, sort_keys=True))
|
|
|
|
|
|
def claim_acquire(repo, args):
|
|
task = task_row(repo, args.task)
|
|
resources = sorted({resource(item) for item in args.resource})
|
|
if not resources or any(not item.startswith('claim:') for item in resources):
|
|
deny('INVALID_RESOURCE', 'claim acquire requires one or more claim: resources')
|
|
check_resources(repo, task, resources)
|
|
request_id = args.request_id or str(uuid.uuid4())
|
|
if not re.fullmatch(r'[A-Za-z0-9._-]{1,128}', request_id):
|
|
deny('INVALID_REQUEST', 'Invalid request id')
|
|
fingerprint = _claim_fingerprint(task['id'], resources)
|
|
wait = max(0.0, min(float(args.wait or 0), 60.0))
|
|
deadline = time.monotonic() + wait
|
|
while True:
|
|
busy = None
|
|
with repo.db(True) as db:
|
|
prior = [dict(r) for r in db.execute(
|
|
'SELECT * FROM claims WHERE request_id=?', (request_id,)).fetchall()]
|
|
if prior:
|
|
held = [row for row in prior if row['state'] == 'held']
|
|
payload = sorted(row['resource'] for row in held) if held else sorted(
|
|
row['resource'] for row in prior)
|
|
same_task = all(row['task'] == task['id'] for row in prior)
|
|
same_fp = _claim_fingerprint(task['id'], payload) == fingerprint and same_task
|
|
if held and same_fp and set(payload) == set(resources):
|
|
emit({'status': 'held', 'replayed': True, 'request_id': request_id,
|
|
'claims': held})
|
|
return 0
|
|
deny('REQUEST_ID_REUSED', 'Claim request id belongs to a different payload',
|
|
request_id=request_id)
|
|
|
|
frozen = []
|
|
for row in db.execute('SELECT * FROM freezes'):
|
|
if any(overlaps(item, row['resource']) for item in resources):
|
|
frozen.append(dict(row))
|
|
if frozen:
|
|
deny('FROZEN', 'Resource is frozen', resource=frozen[0]['resource'],
|
|
holder=frozen[0]['owner'])
|
|
|
|
holders = []
|
|
for row in db.execute("SELECT * FROM claims WHERE state='held'"):
|
|
if any(overlaps(item, row['resource']) for item in resources):
|
|
# Self re-acquire of identical resource is covered by UNIQUE(resource,task)
|
|
if row['task'] == task['id'] and row['resource'] in resources:
|
|
continue
|
|
holders.append(dict(row))
|
|
if holders:
|
|
tickets = []
|
|
now = time.time()
|
|
for item in resources:
|
|
ticket = str(uuid.uuid4())
|
|
db.execute(
|
|
'INSERT INTO claim_queue(id,resource,task,request_id,state,created) VALUES(?,?,?,?,?,?)',
|
|
(ticket, item, task['id'], request_id, 'queued', now))
|
|
tickets.append(ticket)
|
|
event(db, ticket, {'event': 'claim_queued', 'resource': item,
|
|
'task': task['id'], 'holder': holders[0]['task']})
|
|
busy = {
|
|
'holder': holders[0]['task'],
|
|
'ticket': tickets[0],
|
|
'tickets': tickets,
|
|
'retry_after': 1,
|
|
'resources': resources,
|
|
}
|
|
else:
|
|
now = time.time()
|
|
claims = []
|
|
for item in resources:
|
|
existing = db.execute(
|
|
'SELECT * FROM claims WHERE resource=? AND task=?',
|
|
(item, task['id'])).fetchone()
|
|
if existing and existing['state'] == 'held':
|
|
claims.append(dict(existing))
|
|
continue
|
|
claim_id = request_id if len(resources) == 1 else f'{request_id}:{digest(item)[:12]}'
|
|
if existing:
|
|
db.execute(
|
|
'''UPDATE claims SET id=?, mode=?, owner=?, request_id=?, state='held',
|
|
reason=?, updated=? WHERE resource=? AND task=?''',
|
|
(claim_id, 'X', task['owner'], request_id, fingerprint, now,
|
|
item, task['id']))
|
|
else:
|
|
db.execute(
|
|
'''INSERT INTO claims(id,resource,mode,task,owner,request_id,state,reason,created,updated)
|
|
VALUES(?,?,?,?,?,?,'held',?,?,?)''',
|
|
(claim_id, item, 'X', task['id'], task['owner'], request_id,
|
|
fingerprint, now, now))
|
|
row = dict(db.execute(
|
|
'SELECT * FROM claims WHERE resource=? AND task=?',
|
|
(item, task['id'])).fetchone())
|
|
claims.append(row)
|
|
event(db, claim_id, {'event': 'claim_acquired', 'resource': item,
|
|
'task': task['id'], 'request_id': request_id})
|
|
emit({'status': 'held', 'request_id': request_id, 'claims': claims})
|
|
return 0
|
|
if busy:
|
|
if time.monotonic() >= deadline:
|
|
deny('LOCK_BUSY', 'Claim resource is held; retry with ticket or do independent work',
|
|
**busy)
|
|
time.sleep(min(0.05, max(0.0, deadline - time.monotonic())))
|
|
continue
|
|
deny('RECOVERY_REQUIRED', 'Claim acquire exited without result')
|
|
|
|
|
|
def claim_release(repo, args):
|
|
task = task_row(repo, args.task)
|
|
target = resource(args.resource)
|
|
if not target.startswith('claim:'):
|
|
deny('INVALID_RESOURCE', 'claim release requires a claim: resource')
|
|
with repo.db(True) as db:
|
|
row = db.execute(
|
|
"SELECT * FROM claims WHERE resource=? AND task=? AND state='held'",
|
|
(target, task['id'])).fetchone()
|
|
if not row:
|
|
deny('UNKNOWN_CLAIM', 'No held claim for this task/resource')
|
|
now = time.time()
|
|
db.execute("UPDATE claims SET state='released', updated=?, reason=? WHERE id=?",
|
|
(now, args.reason or 'released', row['id']))
|
|
event(db, row['id'], {'event': 'claim_released', 'resource': target, 'task': task['id']})
|
|
# Promote earliest queued waiter to visibility only; acquire still races fairly.
|
|
waiting = db.execute(
|
|
"SELECT * FROM claim_queue WHERE resource=? AND state='queued' ORDER BY created LIMIT 1",
|
|
(target,)).fetchone()
|
|
emit({'status': 'released', 'claim': dict(row), 'next_ticket': waiting['id'] if waiting else None})
|
|
return 0
|
|
|
|
|
|
def claim_status(repo, args):
|
|
target = resource(args.resource)
|
|
if not target.startswith('claim:'):
|
|
deny('INVALID_RESOURCE', 'claim status requires a claim: resource')
|
|
with repo.db() as db:
|
|
try:
|
|
held = [dict(r) for r in db.execute(
|
|
"SELECT * FROM claims WHERE state='held'").fetchall()
|
|
if overlaps(target, r['resource'])]
|
|
queued = [dict(r) for r in db.execute(
|
|
"SELECT * FROM claim_queue WHERE state='queued' ORDER BY created").fetchall()
|
|
if overlaps(target, r['resource'])]
|
|
except sqlite3.Error:
|
|
held, queued = [], []
|
|
emit({'status': 'ok', 'resource': target, 'held': held, 'queued': queued})
|
|
return 0
|
|
|
|
|
|
def claim_list(repo, args):
|
|
with repo.db() as db:
|
|
try:
|
|
if args.all:
|
|
rows = [dict(r) for r in db.execute(
|
|
'SELECT * FROM claims ORDER BY updated DESC').fetchall()]
|
|
else:
|
|
rows = [dict(r) for r in db.execute(
|
|
"SELECT * FROM claims WHERE state='held' ORDER BY updated DESC").fetchall()]
|
|
except sqlite3.Error:
|
|
rows = []
|
|
emit({'status': 'ok', 'claims': rows})
|
|
return 0
|
|
|
|
|
|
def claim_cancel(repo, args):
|
|
task = task_row(repo, args.task)
|
|
with repo.db(True) as db:
|
|
row = db.execute('SELECT * FROM claim_queue WHERE id=?', (args.id,)).fetchone()
|
|
if not row:
|
|
deny('UNKNOWN_TICKET', 'Unknown claim queue ticket')
|
|
if row['task'] != task['id']:
|
|
deny('PATH_OUT_OF_SCOPE', 'Only the ticket owner can cancel')
|
|
if row['state'] != 'queued':
|
|
deny('INVALID_REQUEST', f'Ticket is already {row["state"]}')
|
|
db.execute("UPDATE claim_queue SET state='cancelled' WHERE id=?", (args.id,))
|
|
event(db, args.id, {'event': 'claim_cancelled', 'reason': args.reason, 'task': task['id']})
|
|
emit({'status': 'cancelled', 'ticket': args.id, 'reason': args.reason})
|
|
return 0
|
|
|
|
|
|
|
|
MANAGER_LOCK_NAME = 'manager.lock'
|
|
DAEMON_HEARTBEAT_NAME = 'daemon.heartbeat'
|
|
|
|
|
|
def _read_manager_holder_pid(path):
|
|
try:
|
|
raw = path.read_text().strip()
|
|
except OSError:
|
|
return None
|
|
if not raw:
|
|
return None
|
|
try:
|
|
return int(raw.split()[0])
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def acquire_manager_lock(repo):
|
|
"""Exclusive flock on manager.lock; not part of physical_names() lease set."""
|
|
path = repo.os_file(MANAGER_LOCK_NAME)
|
|
handle = path.open('a+')
|
|
try:
|
|
try:
|
|
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except BlockingIOError:
|
|
holder_pid = None
|
|
for _ in range(20):
|
|
holder_pid = _read_manager_holder_pid(path)
|
|
if holder_pid is not None:
|
|
break
|
|
time.sleep(0.01)
|
|
deny('LOCK_BUSY', 'Daemon manager lock is held',
|
|
holder_pid=holder_pid, retry_after=1)
|
|
handle.seek(0)
|
|
handle.truncate()
|
|
handle.write(f'{os.getpid()}\n')
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
yield handle
|
|
finally:
|
|
try:
|
|
fcntl.flock(handle, fcntl.LOCK_UN)
|
|
except OSError:
|
|
pass
|
|
handle.close()
|
|
|
|
|
|
def write_daemon_heartbeat(repo, cycle):
|
|
payload = {
|
|
'pid': os.getpid(),
|
|
'updated': time.time(),
|
|
'cycle': cycle,
|
|
}
|
|
atomic_write(repo.os_file(DAEMON_HEARTBEAT_NAME), json.dumps(payload, sort_keys=True))
|
|
|
|
|
|
def read_daemon_heartbeat(repo):
|
|
path = repo.root / 'os' / DAEMON_HEARTBEAT_NAME
|
|
if not path.exists() or path.is_symlink():
|
|
return None
|
|
try:
|
|
data = json.loads(path.read_text())
|
|
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
|
return None
|
|
if not isinstance(data, dict):
|
|
return None
|
|
return data
|
|
|
|
|
|
def manager_lock_status(repo):
|
|
"""Read-only probe: never create files or take EX locks."""
|
|
path = repo.root / 'os' / MANAGER_LOCK_NAME
|
|
if not path.exists() or path.is_symlink():
|
|
return {'held': False, 'holder_pid': None, 'path': str(path)}
|
|
try:
|
|
with path.open('r') as handle:
|
|
try:
|
|
fcntl.flock(handle, fcntl.LOCK_SH | fcntl.LOCK_NB)
|
|
except BlockingIOError:
|
|
return {
|
|
'held': True,
|
|
'holder_pid': _read_manager_holder_pid(path),
|
|
'path': str(path),
|
|
}
|
|
fcntl.flock(handle, fcntl.LOCK_UN)
|
|
except OSError as exc:
|
|
return {'held': False, 'holder_pid': None, 'path': str(path), 'error': str(exc)}
|
|
return {'held': False, 'holder_pid': None, 'path': str(path)}
|
|
|
|
|
|
def daemon_run(repo, args):
|
|
"""Foreground coordinator loop: exclusive manager.lock + queue_schedule cycles."""
|
|
interval = float(getattr(args, 'interval', 1.0) or 1.0)
|
|
if interval < 0:
|
|
deny('INVALID_REQUEST', 'daemon interval must be >= 0')
|
|
max_cycles = getattr(args, 'max_cycles', None)
|
|
if max_cycles is not None:
|
|
max_cycles = int(max_cycles)
|
|
if max_cycles < 1:
|
|
deny('INVALID_REQUEST', 'daemon max-cycles must be >= 1')
|
|
stop = {'flag': False}
|
|
|
|
def _request_stop(signum, frame):
|
|
stop['flag'] = True
|
|
|
|
previous_term = signal.signal(signal.SIGTERM, _request_stop)
|
|
previous_int = signal.signal(signal.SIGINT, _request_stop)
|
|
cycles = 0
|
|
try:
|
|
with acquire_manager_lock(repo):
|
|
emit({
|
|
'status': 'daemon_started',
|
|
'pid': os.getpid(),
|
|
'interval': interval,
|
|
'max_cycles': max_cycles,
|
|
})
|
|
while not stop['flag']:
|
|
cycles += 1
|
|
write_daemon_heartbeat(repo, cycles)
|
|
try:
|
|
queue_schedule(repo, argparse.Namespace(drain=False))
|
|
except Denied as exc:
|
|
detail = {
|
|
'event': 'daemon_schedule_denied',
|
|
'code': exc.code,
|
|
'message': exc.message,
|
|
**exc.details,
|
|
}
|
|
if exc.code != 'LOCK_BUSY':
|
|
with repo.db(True) as db:
|
|
ensure_schema(db)
|
|
event(db, 'daemon', detail)
|
|
emit({'status': 'denied', 'code': exc.code, 'message': exc.message,
|
|
**exc.details}, sys.stderr)
|
|
if max_cycles is not None and cycles >= max_cycles:
|
|
break
|
|
if stop['flag']:
|
|
break
|
|
deadline = time.monotonic() + interval
|
|
while not stop['flag'] and time.monotonic() < deadline:
|
|
time.sleep(min(0.05, max(0.0, deadline - time.monotonic())))
|
|
write_daemon_heartbeat(repo, cycles)
|
|
emit({'status': 'daemon_stopped', 'pid': os.getpid(), 'cycles': cycles,
|
|
'reason': 'signal' if stop['flag'] else 'max_cycles'})
|
|
return 0
|
|
finally:
|
|
signal.signal(signal.SIGTERM, previous_term)
|
|
signal.signal(signal.SIGINT, previous_int)
|
|
|
|
|
|
def daemon_status(repo, args):
|
|
"""Read-only daemon probe: lock holder, heartbeat age, queue counts."""
|
|
try:
|
|
identity = repo.identity()
|
|
except Denied as exc:
|
|
emit({'status': 'denied', 'code': exc.code, 'message': exc.message, **exc.details})
|
|
return 1
|
|
lock = manager_lock_status(repo)
|
|
heartbeat = read_daemon_heartbeat(repo)
|
|
heartbeat_age = None
|
|
if heartbeat and isinstance(heartbeat.get('updated'), (int, float)):
|
|
heartbeat_age = max(0.0, time.time() - float(heartbeat['updated']))
|
|
pending = claimed = 0
|
|
try:
|
|
with repo.db() as db:
|
|
try:
|
|
pending = db.execute(
|
|
"SELECT count(*) FROM queue_items WHERE state='pending'").fetchone()[0]
|
|
claimed = db.execute(
|
|
"SELECT count(*) FROM queue_items WHERE state='claimed'").fetchone()[0]
|
|
except sqlite3.Error:
|
|
pending = claimed = 0
|
|
except Denied:
|
|
pass
|
|
emit({
|
|
'status': 'ok',
|
|
'lock_root': str(repo.root),
|
|
'epoch': identity.get('epoch'),
|
|
'manager_lock': lock,
|
|
'heartbeat': heartbeat,
|
|
'heartbeat_age_seconds': heartbeat_age,
|
|
'queue': {'pending': pending, 'claimed': claimed},
|
|
})
|
|
return 0
|
|
|
|
|
|
def parser():
|
|
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--repo',default='.')
|
|
sub=p.add_subparsers(dest='action',required=True)
|
|
for name in ('init','status','doctor'):sub.add_parser(name)
|
|
task=sub.add_parser('task').add_subparsers(dest='task_action',required=True)
|
|
reg=task.add_parser('register');reg.add_argument('--id',required=True);reg.add_argument('--owner',required=True);reg.add_argument('--scope',action='append',required=True);reg.add_argument('--capability',action='append',default=[])
|
|
close=task.add_parser('close');close.add_argument('--id',required=True);close.add_argument('--status',choices=['done','cancelled'],default='done')
|
|
w=sub.add_parser('with');w.add_argument('--task',required=True);w.add_argument('--resource',action='append',required=True);w.add_argument('--shared',action='store_true');w.add_argument('--request-id');w.add_argument('--wait',type=float,default=0);w.add_argument('--cwd');w.add_argument('command',nargs=argparse.REMAINDER)
|
|
g=sub.add_parser('guard').add_subparsers(dest='kind',required=True)
|
|
r=g.add_parser('resource');r.add_argument('resource')
|
|
e=g.add_parser('env');e.add_argument('--target',choices=['dev','test','main','shared','all'],required=True)
|
|
b=g.add_parser('build');b.add_argument('--commit',required=True)
|
|
check=sub.add_parser('worktree-check');check.add_argument('--path',required=True)
|
|
d=sub.add_parser('docs').add_subparsers(dest='doc_action',required=True)
|
|
snap=d.add_parser('snapshot');snap.add_argument('--file',required=True);snap.add_argument('--record',required=True)
|
|
apply=d.add_parser('apply');apply.add_argument('--task',required=True);apply.add_argument('--request',required=True)
|
|
proj=d.add_parser('project');proj.add_argument('--task',required=True);proj.add_argument('--file',default='.doc/ops/CURRENT.md')
|
|
proj.add_argument('--mutex',default='');proj.add_argument('--status');proj.add_argument('--request-id')
|
|
proj.add_argument('--mode',choices=['row','document'],default='row')
|
|
proj.add_argument('--record');proj.add_argument('--replacement')
|
|
proj.add_argument('--apply',action='store_true',help='Apply projected change via docs apply')
|
|
q=sub.add_parser('queue').add_subparsers(dest='queue_action',required=True)
|
|
qe=q.add_parser('enqueue');qe.add_argument('--task',required=True);qe.add_argument('--expected-dev',required=True)
|
|
qe.add_argument('--head');qe.add_argument('--priority',type=int,default=0);qe.add_argument('--request-id')
|
|
ql=q.add_parser('list');ql.add_argument('--all',action='store_true')
|
|
qc=q.add_parser('cancel');qc.add_argument('--task',required=True);qc.add_argument('--id',required=True);qc.add_argument('--reason',required=True)
|
|
qs=q.add_parser('schedule');qs.add_argument('--drain',action='store_true')
|
|
cu=sub.add_parser('cleanup').add_subparsers(dest='cleanup_action',required=True)
|
|
cs=cu.add_parser('status');cs.add_argument('--path');cs.add_argument('--branch')
|
|
rec=sub.add_parser('recover');rec.add_argument('--run',required=True);rec.add_argument('--reason',required=True)
|
|
op=sub.add_parser('operation');op.add_argument('run')
|
|
for action in ('freeze','unfreeze'):
|
|
fr=sub.add_parser(action);fr.add_argument('--task',required=True);fr.add_argument('--resource',required=True);fr.add_argument('--reason',required=True)
|
|
hooks=sub.add_parser('hooks').add_subparsers(dest='hook_action',required=True)
|
|
hooks.add_parser('install');hc=hooks.add_parser('check');hc.add_argument('--merge',action='store_true');hr=hooks.add_parser('ref');hr.add_argument('state')
|
|
v=sub.add_parser('validate');v.add_argument('--task',required=True);v.add_argument('command',nargs=argparse.REMAINDER)
|
|
i=sub.add_parser('integrate');i.add_argument('--task',required=True);i.add_argument('--expected-dev',required=True);i.add_argument('--request-id')
|
|
dep=sub.add_parser('deploy').add_subparsers(dest='deploy_action',required=True)
|
|
dr=dep.add_parser('run-loop')
|
|
dr.add_argument('--task',required=True);dr.add_argument('--id',required=True)
|
|
dr.add_argument('--env',choices=['dev','test','main'],required=True)
|
|
dr.add_argument('--expected-commit',required=True);dr.add_argument('--manifest')
|
|
dr.add_argument('--dry-run',action='store_true')
|
|
dr.add_argument('command',nargs=argparse.REMAINDER)
|
|
ds=dep.add_parser('status');ds.add_argument('--id',required=True)
|
|
drec=dep.add_parser('recover');drec.add_argument('--id',required=True);drec.add_argument('--reason',required=True)
|
|
cl=sub.add_parser('claim').add_subparsers(dest='claim_action',required=True)
|
|
ca=cl.add_parser('acquire');ca.add_argument('--task',required=True);ca.add_argument('--resource',action='append',required=True)
|
|
ca.add_argument('--request-id');ca.add_argument('--wait',type=float,default=0)
|
|
crl=cl.add_parser('release');crl.add_argument('--task',required=True);crl.add_argument('--resource',required=True);crl.add_argument('--reason',default='')
|
|
cst=cl.add_parser('status');cst.add_argument('--resource',required=True);cst.add_argument('--json',action='store_true')
|
|
clist=cl.add_parser('list');clist.add_argument('--all',action='store_true')
|
|
cc=cl.add_parser('cancel');cc.add_argument('--task',required=True);cc.add_argument('--id',required=True);cc.add_argument('--reason',required=True)
|
|
qtn=sub.add_parser('quarantine').add_subparsers(dest='quarantine_action',required=True)
|
|
qtn.add_parser('sweep')
|
|
qtn.add_parser('list')
|
|
qrel=qtn.add_parser('release');qrel.add_argument('--run',required=True);qrel.add_argument('--reason',required=True)
|
|
qrel.add_argument('--verified-by',required=True,dest='verified_by')
|
|
dm=sub.add_parser('daemon').add_subparsers(dest='daemon_action',required=True)
|
|
drun=dm.add_parser('run')
|
|
drun.add_argument('--interval',type=float,default=1.0,help='Seconds between schedule cycles')
|
|
drun.add_argument('--max-cycles',type=int,dest='max_cycles',
|
|
help='Stop after N cycles (tests / bounded runs)')
|
|
dm.add_parser('status')
|
|
return p
|
|
|
|
|
|
def main():
|
|
args=parser().parse_args();repo=Repo(args.repo)
|
|
write_actions = {
|
|
'init', 'task', 'with', 'docs', 'validate', 'integrate', 'queue',
|
|
'deploy', 'recover', 'freeze', 'unfreeze', 'hooks', 'guard', 'cleanup', 'claim',
|
|
'quarantine', 'daemon',
|
|
}
|
|
# status/doctor remain readable without a policy file and never create one.
|
|
if args.action in write_actions:
|
|
if args.action == 'docs' and getattr(args, 'doc_action', None) == 'snapshot':
|
|
pass # snapshot is read-only CAS helper
|
|
elif args.action == 'queue' and getattr(args, 'queue_action', None) == 'list':
|
|
pass
|
|
elif args.action == 'claim' and getattr(args, 'claim_action', None) in ('status', 'list'):
|
|
require_policy(repo) # status/list need schema constants but are non-mutating
|
|
elif args.action == 'quarantine' and getattr(args, 'quarantine_action', None) == 'list':
|
|
require_policy(repo) # list needs TTL/schema constants
|
|
elif args.action == 'daemon' and getattr(args, 'daemon_action', None) == 'status':
|
|
pass # read-only probe; never creates manager.lock or heartbeat
|
|
elif args.action == 'cleanup':
|
|
require_policy(repo)
|
|
else:
|
|
require_policy(repo)
|
|
if args.action=='init':initialize(repo)
|
|
elif args.action in ('status','doctor'):
|
|
identity=repo.identity()
|
|
policy=policy_status(repo.main)
|
|
try:
|
|
with repo.db(True) as db:
|
|
ensure_schema(db)
|
|
except Denied:
|
|
pass
|
|
with repo.db() as db:
|
|
tasks=[dict(r) for r in db.execute('SELECT * FROM tasks')]
|
|
runs=[dict(r) for r in db.execute(
|
|
"SELECT * FROM runs WHERE state IN ('running','queued','recovery_required','busy','suspect','quarantine')")]
|
|
try:
|
|
queue=[dict(r) for r in db.execute(
|
|
"SELECT * FROM queue_items WHERE state IN ('pending','claimed') ORDER BY priority DESC, created")]
|
|
except sqlite3.Error:
|
|
queue=[]
|
|
for row in runs:row['supervisor_alive']=alive_status(row['pid'],row['start'],row['boot'])
|
|
emit({'status':'ok','lock_root':str(repo.root),'main':str(repo.main),'version':VERSION,'epoch':identity['epoch'],
|
|
'tasks':tasks,'runs':runs,'queue':queue,'policy':policy})
|
|
elif args.action=='task':
|
|
if args.task_action=='register':
|
|
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]{0,127}',args.id):deny('INVALID_TASK','Invalid task id')
|
|
branch=git(repo.current,'branch','--show-current')
|
|
if repo.current==repo.main or repo.current.parent!=repo.main/'.worktrees' or not branch.startswith(('feature/','fix/')):
|
|
deny('WORKTREE_REQUIRED','Register from a direct .worktrees feature/fix checkout')
|
|
scopes=sorted(set(relative(v) for v in args.scope))
|
|
for scope in scopes:
|
|
if scope != '.' and secret_path(scope):deny('PATH_OUT_OF_SCOPE','Git and runtime internals cannot be registered as source scopes')
|
|
safe_path(repo.current,scope)
|
|
for cap in args.capability:
|
|
if cap not in ('docs:admin','docs:approve'):resource(cap)
|
|
values=(args.id,args.owner,str(repo.current),branch,json.dumps(scopes),json.dumps(sorted(set(args.capability))),'active',git(repo.current,'rev-parse','HEAD'))
|
|
with repo.os_file('worktree-' + digest(str(repo.current)) + '.lock').open('a+') as lifecycle, repo.db(True) as db:
|
|
fcntl.flock(lifecycle,fcntl.LOCK_SH|fcntl.LOCK_NB)
|
|
if not repo.current.exists():deny('WORKTREE_MISMATCH','Worktree disappeared during registration')
|
|
old=db.execute('SELECT * FROM tasks WHERE id=?',(args.id,)).fetchone()
|
|
if old:
|
|
if tuple(old)[:6]!=values[:6] or old['state']!='active':deny('TASK_EXISTS','Task registration differs; no implicit authority expansion')
|
|
else:
|
|
if db.execute("SELECT id FROM tasks WHERE worktree=? AND state='active'", (str(repo.current),)).fetchone():
|
|
deny('WORKTREE_OCCUPIED', 'Another active task already owns this checkout')
|
|
db.execute('INSERT INTO tasks VALUES(?,?,?,?,?,?,?,?)',values)
|
|
event(db,args.id,{'event':'registered','worktree':str(repo.current)})
|
|
emit({'status':'registered','task':args.id,'worktree':str(repo.current)})
|
|
else:
|
|
task_row(repo,args.id)
|
|
with repo.db(True) as db:
|
|
if db.execute("SELECT id FROM runs WHERE task=? AND state IN ('running','queued','recovery_required','suspect','quarantine')",(args.id,)).fetchone():deny('LOCK_BUSY','Task still has an active or unresolved operation')
|
|
if db.execute("SELECT id FROM claims WHERE task=? AND state='held'",(args.id,)).fetchone():
|
|
deny('LOCK_BUSY','Task still holds claims; release them before close')
|
|
db.execute('UPDATE tasks SET state=? WHERE id=?',(args.status,args.id))
|
|
emit({'status':args.status,'task':args.id})
|
|
elif args.action=='with':return execute(repo,args)
|
|
elif args.action=='guard':guard(repo,args)
|
|
elif args.action=='worktree-check':worktree_check(repo,args.path)
|
|
elif args.action=='docs':
|
|
if args.doc_action=='snapshot':docs_snapshot(repo,args)
|
|
elif args.doc_action=='project':return docs_project(repo,args)
|
|
else:return docs_apply(repo,args)
|
|
elif args.action=='queue':
|
|
if args.queue_action=='enqueue':return queue_enqueue(repo,args)
|
|
if args.queue_action=='list':return queue_list(repo,args)
|
|
if args.queue_action=='cancel':return queue_cancel(repo,args)
|
|
return queue_schedule(repo,args)
|
|
elif args.action=='cleanup':
|
|
if not args.path and not args.branch:
|
|
deny('INVALID_REQUEST', 'cleanup status requires --path or --branch')
|
|
if args.path and args.branch:
|
|
deny('INVALID_REQUEST', 'Provide only one of --path or --branch')
|
|
return cleanup_status(repo,args)
|
|
elif args.action=='recover':recover(repo,args)
|
|
elif args.action=='operation':
|
|
with repo.db() as db:row=db.execute('SELECT * FROM runs WHERE id=?',(args.run,)).fetchone()
|
|
if not row:deny('UNKNOWN_OPERATION','Unknown run id')
|
|
emit(dict(row))
|
|
elif args.action in ('freeze','unfreeze'):
|
|
task=task_row(repo,args.task);r=resource(args.resource);check_resources(repo,task,[r])
|
|
with repo.db(True) as db:
|
|
if args.action=='freeze':
|
|
for row in db.execute("SELECT resources FROM runs WHERE state IN ('running','recovery_required','suspect','quarantine')"):
|
|
if any(overlaps(r,v) for v in json.loads(row['resources'])):deny('LOCK_BUSY','Cannot freeze a resource with an active writer')
|
|
db.execute('INSERT INTO freezes VALUES(?,?,?)',(r,args.task,args.reason))
|
|
else:
|
|
row=db.execute('SELECT owner FROM freezes WHERE resource=?',(r,)).fetchone()
|
|
if row and row['owner']!=args.task:deny('PATH_OUT_OF_SCOPE','Only the freeze owner can unfreeze')
|
|
db.execute('DELETE FROM freezes WHERE resource=?',(r,))
|
|
event(db,args.task,{'event':args.action,'resource':r,'reason':args.reason})
|
|
emit({'status':args.action,'resource':r})
|
|
elif args.action=='hooks':
|
|
if args.hook_action=='install':install_hooks(repo)
|
|
elif args.hook_action=='check':hook_check(repo,args.merge)
|
|
else:hook_ref(repo,args.state)
|
|
elif args.action=='validate':return validate(repo,args)
|
|
elif args.action=='integrate':return integrate(repo,args)
|
|
elif args.action=='deploy':return deploy_cmd(repo,args)
|
|
elif args.action=='claim':
|
|
if args.claim_action=='acquire':return claim_acquire(repo,args)
|
|
if args.claim_action=='release':return claim_release(repo,args)
|
|
if args.claim_action=='status':return claim_status(repo,args)
|
|
if args.claim_action=='list':return claim_list(repo,args)
|
|
if args.claim_action=='cancel':return claim_cancel(repo,args)
|
|
elif args.action=='quarantine':
|
|
if args.quarantine_action=='sweep':return quarantine_sweep(repo)
|
|
if args.quarantine_action=='list':return quarantine_list(repo)
|
|
if args.quarantine_action=='release':return quarantine_release(repo,args)
|
|
elif args.action=='daemon':
|
|
if args.daemon_action=='run':return daemon_run(repo,args)
|
|
if args.daemon_action=='status':return daemon_status(repo,args)
|
|
return 0
|
|
|
|
|
|
if __name__=='__main__':
|
|
try:
|
|
sys.exit(main() or 0)
|
|
except Denied as exc:
|
|
emit({'status':'denied','code':exc.code,'message':exc.message,**exc.details},sys.stderr)
|
|
sys.exit(75 if exc.code=='LOCK_BUSY' else 1)
|
|
except (OSError,sqlite3.Error,ValueError,KeyError) as exc:
|
|
emit({'status':'denied','code':'RECOVERY_REQUIRED','message':str(exc)},sys.stderr)
|
|
sys.exit(1)
|