#!/usr/bin/env python3 """W4 automation helpers for agentctl: durable jobs, doc projection, deploy loop. Linux-first. Process liveness reuses the host agentctl proc/alive/boot helpers passed in as callbacks so a future macOS process layer can plug in without rewriting queue semantics. """ from __future__ import annotations import hashlib import json import os import re import sqlite3 import subprocess import sys import time import uuid from pathlib import Path from typing import Any, Callable, Optional SCHEMA_SQL = ''' CREATE TABLE IF NOT EXISTS jobs( id TEXT PRIMARY KEY, task TEXT NOT NULL, kind TEXT NOT NULL, payload TEXT NOT NULL, state TEXT NOT NULL, priority INTEGER NOT NULL DEFAULT 100, not_before REAL NOT NULL, created REAL NOT NULL, updated REAL NOT NULL, lease_run TEXT, attempts INTEGER NOT NULL DEFAULT 0, last_error TEXT, result TEXT ); CREATE INDEX IF NOT EXISTS jobs_ready ON jobs(state, not_before, priority, created); CREATE TABLE IF NOT EXISTS deployments( id TEXT PRIMARY KEY, task TEXT NOT NULL, env TEXT NOT NULL, expected_commit TEXT NOT NULL, manifest TEXT, phase TEXT NOT NULL, dry_run INTEGER NOT NULL DEFAULT 0, created REAL NOT NULL, updated REAL NOT NULL, receipt TEXT, last_error TEXT ); CREATE INDEX IF NOT EXISTS deployments_task ON deployments(task, phase); CREATE TABLE IF NOT EXISTS projections( file TEXT NOT NULL, record_id TEXT NOT NULL, task TEXT NOT NULL, source TEXT NOT NULL, content_hash TEXT NOT NULL, updated REAL NOT NULL, PRIMARY KEY(file, record_id) ); ''' JOB_KINDS = frozenset({ 'validate', 'integrate', 'with', 'deploy_prepare', 'deploy_run', 'deploy_accept', 'docs_project', 'cleanup_inspect', 'noop', }) JOB_ACTIVE = ('pending', 'leased', 'running') JOB_TERMINAL = ('completed', 'failed', 'cancelled') DEPLOY_PHASES = ( 'prepared', 'deploying', 'accepting', 'passed', 'failed', 'recovery_required', ) # Beyond CURRENT/MATRIX: any Markdown table under .doc/ may be projected when # registered here. Layout changes still require docs:admin via docs apply. PROJECTABLE_DOCS = frozenset({ '.doc/ops/CURRENT.md', '.doc/status/FEATURE-MATRIX.md', }) PROJECTABLE_PREFIXES = ('.doc/ops/', '.doc/status/', '.doc/remediation/') def apply_policy(policy: dict) -> None: """Apply governance policy fields that W4 owns (job kinds / projectable docs).""" global JOB_KINDS, PROJECTABLE_DOCS, PROJECTABLE_PREFIXES JOB_KINDS = frozenset(policy['job_kinds']) PROJECTABLE_DOCS = frozenset(policy['projectable_docs']) PROJECTABLE_PREFIXES = tuple(policy['projectable_prefixes']) def ensure_schema(db: sqlite3.Connection) -> None: db.executescript(SCHEMA_SQL) def _now() -> float: return time.time() def queue_enqueue(db, *, task: str, kind: str, payload: dict, priority: int = 100, not_before: Optional[float] = None, job_id: Optional[str] = None) -> dict: if kind not in JOB_KINDS: raise ValueError(f'unsupported job kind: {kind}') if not isinstance(payload, dict): raise ValueError('payload must be an object') jid = job_id or str(uuid.uuid4()) if not re.fullmatch(r'[A-Za-z0-9._-]{1,128}', jid): raise ValueError('invalid job id') ts = _now() when = float(not_before) if not_before is not None else ts existing = db.execute('SELECT * FROM jobs WHERE id=?', (jid,)).fetchone() if existing: if (existing['task'] != task or existing['kind'] != kind or existing['payload'] != json.dumps(payload, sort_keys=True)): raise ValueError('job id reused with different payload') return dict(existing) db.execute( '''INSERT INTO jobs(id,task,kind,payload,state,priority,not_before,created,updated) VALUES(?,?,?,?,?,?,?,?,?)''', (jid, task, kind, json.dumps(payload, sort_keys=True), 'pending', int(priority), when, ts, ts)) return dict(db.execute('SELECT * FROM jobs WHERE id=?', (jid,)).fetchone()) def queue_list(db, *, task: Optional[str] = None, state: Optional[str] = None, limit: int = 50) -> list: clauses, args = [], [] if task: clauses.append('task=?') args.append(task) if state: clauses.append('state=?') args.append(state) where = (' WHERE ' + ' AND '.join(clauses)) if clauses else '' rows = db.execute( f'SELECT * FROM jobs{where} ORDER BY priority ASC, created ASC LIMIT ?', (*args, max(1, min(int(limit), 500)))).fetchall() return [dict(r) for r in rows] def queue_cancel(db, job_id: str, reason: str) -> dict: row = db.execute('SELECT * FROM jobs WHERE id=?', (job_id,)).fetchone() if not row: raise KeyError(job_id) if row['state'] in JOB_TERMINAL: return dict(row) if row['state'] == 'running': raise RuntimeError('cannot cancel a running job; recover the lease first') db.execute( "UPDATE jobs SET state='cancelled',last_error=?,updated=? WHERE id=?", (reason, _now(), job_id)) return dict(db.execute('SELECT * FROM jobs WHERE id=?', (job_id,)).fetchone()) def claim_next_job(db, *, worker: str) -> Optional[dict]: """Atomically lease the next due pending job. Caller must be in a write txn.""" ts = _now() row = db.execute( '''SELECT * FROM jobs WHERE state='pending' AND not_before<=? ORDER BY priority ASC, created ASC LIMIT 1''', (ts,)).fetchone() if not row: return None db.execute( '''UPDATE jobs SET state='leased',lease_run=?,attempts=attempts+1,updated=? WHERE id=? AND state='pending' ''', (worker, ts, row['id'])) claimed = db.execute('SELECT * FROM jobs WHERE id=?', (row['id'],)).fetchone() if not claimed or claimed['state'] != 'leased': return None return dict(claimed) def finish_job(db, job_id: str, *, ok: bool, result: Optional[dict] = None, error: Optional[str] = None) -> dict: state = 'completed' if ok else 'failed' db.execute( '''UPDATE jobs SET state=?,result=?,last_error=?,updated=? WHERE id=?''', (state, json.dumps(result or {}, sort_keys=True), error, _now(), job_id)) return dict(db.execute('SELECT * FROM jobs WHERE id=?', (job_id,)).fetchone()) def mark_job_running(db, job_id: str) -> None: db.execute("UPDATE jobs SET state='running',updated=? WHERE id=?", (_now(), job_id)) def projectable_file(path: str) -> bool: if path in PROJECTABLE_DOCS or path in {'.doc/ops/CURRENT.md', '.doc/status/FEATURE-MATRIX.md'}: return True return any(path.startswith(prefix) for prefix in PROJECTABLE_PREFIXES) and path.endswith('.md') def build_projection_row(*, task_id: str, owner: str, branch: str, worktree: str, mutex: str, status: str, extra: Optional[dict] = None) -> str: """Canonical CURRENT-style row used for machine→doc projection.""" cells = [owner, task_id, branch, worktree, mutex, status] if extra: for key in sorted(extra): cells.append(str(extra[key])) return '| ' + ' | '.join(cells) + ' |' def record_projection(db, *, file: str, record_id: str, task: str, source: str, content_hash: str) -> None: db.execute( '''INSERT INTO projections(file,record_id,task,source,content_hash,updated) VALUES(?,?,?,?,?,?) ON CONFLICT(file,record_id) DO UPDATE SET task=excluded.task, source=excluded.source, content_hash=excluded.content_hash, updated=excluded.updated''', (file, record_id, task, source, content_hash, _now())) def content_digest(text: str) -> str: return hashlib.sha256(text.encode()).hexdigest() def build_row_projection_change(*, file: str, record_id: str, current_row: Optional[str], replacement: str) -> dict: """Docs-apply compatible single-row change (CAS on the existing row text).""" if not isinstance(replacement, str) or len(replacement.splitlines()) != 1 \ or not replacement.startswith('|'): raise ValueError('replacement must be one Markdown table row') return { 'file': file, 'record_id': record_id, 'expected_sha256': content_digest(current_row) if current_row else None, 'replacement': replacement, } def build_document_projection_change(*, file: str, current_text: str, replacement: str) -> dict: """Docs-apply compatible whole-document change (CAS on full file hash).""" if not isinstance(replacement, str): raise ValueError('replacement must be full document text') return { 'file': file, 'record_id': '@document', 'expected_sha256': content_digest(current_text), 'replacement': replacement, } def apply_projection_locally(path: Path, change: dict) -> dict: """Controlled apply mirroring docs-apply CAS for temp-repo tests (no lease). Supports row edits and @document whole-file replacement. Refuses on CAS miss. """ if not isinstance(change, dict): raise ValueError('change must be an object') file_rel = change.get('file') record_id = change.get('record_id') replacement = change.get('replacement') expected = change.get('expected_sha256') if not isinstance(file_rel, str) or not file_rel: raise ValueError('file required') if not projectable_file(file_rel): raise ValueError(f'file not projectable: {file_rel}') target = (path / file_rel).resolve() if path.resolve() not in target.parents and target != path.resolve(): raise ValueError('path escapes repo root') before = target.read_text(encoding='utf-8') if record_id == '@document': if content_digest(before) != expected: raise RuntimeError('BASE_CHANGED: document hash mismatch') if not isinstance(replacement, str): raise ValueError('Expected full document text') target.write_text(replacement, encoding='utf-8') after = replacement else: lines = before.splitlines() matches = [] for i, line in enumerate(lines): if line.lstrip(' ').startswith('|'): cells = [cell.strip() for cell in line.strip().strip('|').split('|')] if record_id and record_id in cells[:2]: matches.append(i) if len(matches) > 1: raise RuntimeError('AMBIGUOUS_RECORD: record id is not unique in this document') current_row = lines[matches[0]] if matches else None live_hash = content_digest(current_row) if current_row else None if expected != live_hash: raise RuntimeError('BASE_CHANGED: record hash mismatch') if not isinstance(replacement, str) or len(replacement.splitlines()) != 1: raise ValueError('Replacement must be one Markdown table row') if matches: lines[matches[0]] = replacement else: # Insert after first markdown table separator when adding a new row. sep = next((i for i, line in enumerate(lines) if re.match(r'^\|\s*-+', line)), None) if sep is None: raise ValueError('Target table header/separator absent') lines.insert(sep + 1, replacement) after = '\n'.join(lines) + ('\n' if before.endswith('\n') or before == '' else '') target.write_text(after, encoding='utf-8') return { 'file': file_rel, 'record_id': record_id, 'before_sha256': content_digest(before), 'after_sha256': content_digest(after), 'status': 'applied', } def deployment_create(db, *, deploy_id: str, task: str, env: str, expected_commit: str, manifest: Optional[str], dry_run: bool) -> dict: if env not in ('dev', 'test', 'main'): raise ValueError('env must be dev|test|main') if not re.fullmatch(r'[0-9a-f]{7,40}', expected_commit): raise ValueError('expected_commit must be a hex SHA') ts = _now() existing = db.execute('SELECT * FROM deployments WHERE id=?', (deploy_id,)).fetchone() if existing: if (existing['task'] != task or existing['env'] != env or existing['expected_commit'] != expected_commit): raise ValueError('deploy id reused with different identity') return dict(existing) db.execute( '''INSERT INTO deployments(id,task,env,expected_commit,manifest,phase,dry_run,created,updated) VALUES(?,?,?,?,?,?,?,?,?)''', (deploy_id, task, env, expected_commit, manifest, 'prepared', 1 if dry_run else 0, ts, ts)) return dict(db.execute('SELECT * FROM deployments WHERE id=?', (deploy_id,)).fetchone()) def deployment_set_phase(db, deploy_id: str, phase: str, *, receipt: Optional[dict] = None, error: Optional[str] = None) -> dict: if phase not in DEPLOY_PHASES: raise ValueError(f'invalid deploy phase: {phase}') current = deployment_get(db, deploy_id) if current is None: raise RuntimeError(f'unknown deploy: {deploy_id}') from_phase = current['phase'] # Terminal passed has empty outbound set; recover may jump failed/recovery_required → passed. allowed = { 'prepared': {'deploying', 'failed', 'recovery_required'}, 'deploying': {'accepting', 'failed', 'recovery_required'}, 'accepting': {'passed', 'failed', 'recovery_required'}, 'failed': {'recovery_required', 'passed'}, 'recovery_required': {'deploying', 'passed'}, 'passed': set(), } if phase != from_phase and phase not in allowed.get(from_phase, set()): raise RuntimeError(f'illegal transition {from_phase} -> {phase}') db.execute( '''UPDATE deployments SET phase=?,receipt=?,last_error=?,updated=? WHERE id=?''', (phase, json.dumps(receipt or {}, sort_keys=True) if receipt is not None else None, error, _now(), deploy_id)) return dict(db.execute('SELECT * FROM deployments WHERE id=?', (deploy_id,)).fetchone()) def deployment_get(db, deploy_id: str) -> Optional[dict]: row = db.execute('SELECT * FROM deployments WHERE id=?', (deploy_id,)).fetchone() return dict(row) if row else None def dry_run_deploy_steps(deploy: dict) -> dict: """Closed-loop steps without touching shared docker. Used by tests and design.""" return { 'env': deploy['env'], 'expected_commit': deploy['expected_commit'], 'manifest': deploy.get('manifest'), 'steps': [ {'name': 'guard_env_lease', 'status': 'ok'}, {'name': 'verify_main_clean', 'status': 'ok'}, {'name': 'apply_or_rebuild', 'status': 'skipped_dry_run'}, {'name': 'health_ready', 'status': 'skipped_dry_run'}, {'name': 'pi_image_identity', 'status': 'skipped_dry_run'}, ], 'dry_run': True, } def run_deploy_command(command: list, *, cwd: Optional[str] = None, timeout: float = 30.0) -> dict: """Execute a real subprocess for deploy_run; used with a fake command in tests.""" if not command or not isinstance(command, list) or not all(isinstance(c, str) for c in command): raise ValueError('command must be a nonempty argv list') try: result = subprocess.run( command, cwd=cwd, capture_output=True, text=True, timeout=timeout) except subprocess.TimeoutExpired as exc: return { 'ok': False, 'exit_code': None, 'stdout_tail': (exc.stdout or '')[-2000:], 'stderr_tail': (exc.stderr or '')[-2000:], 'error': 'timeout', 'command': command, } except OSError as exc: return { 'ok': False, 'exit_code': None, 'stdout_tail': '', 'stderr_tail': str(exc), 'error': 'spawn_failed', 'command': command, } return { 'ok': result.returncode == 0, 'exit_code': result.returncode, 'stdout_tail': (result.stdout or '')[-2000:], 'stderr_tail': (result.stderr or '')[-2000:], 'error': None if result.returncode == 0 else 'nonzero_exit', 'command': command, } def execute_deploy_recover_loop( db, *, deploy_id: str, task: str, env: str, expected_commit: str, command: list, dry_run: bool = False, manifest: Optional[str] = None, worker: str = 'w4-deploy-worker', cwd: Optional[str] = None, ) -> dict: """Real enqueue→claim→deploy(command)→receipt chain; receipts persist in SQLite. Does not touch shared docker. Pass a no-op/fake argv for black-box tests. """ prepare = queue_enqueue( db, task=task, kind='deploy_prepare', payload={ 'deploy_id': deploy_id, 'env': env, 'expected_commit': expected_commit, 'dry_run': dry_run, }, job_id=f'{deploy_id}-prepare') claimed = claim_next_job(db, worker=worker) if not claimed or claimed['id'] != prepare['id']: raise RuntimeError('failed to claim deploy_prepare job') deploy = deployment_create( db, deploy_id=deploy_id, task=task, env=env, expected_commit=expected_commit, manifest=manifest, dry_run=dry_run) finish_job(db, prepare['id'], ok=True, result={'phase': 'prepared', 'deploy': deploy}) run_job = queue_enqueue( db, task=task, kind='deploy_run', payload={'deploy_id': deploy_id, 'command': command, 'dry_run': dry_run}, job_id=f'{deploy_id}-run') claimed = claim_next_job(db, worker=worker) if not claimed or claimed['id'] != run_job['id']: raise RuntimeError('failed to claim deploy_run job') deployment_set_phase(db, deploy_id, 'deploying') if dry_run: receipt = dry_run_deploy_steps(deploy) ok = True else: receipt = run_deploy_command(command, cwd=cwd) ok = bool(receipt.get('ok')) if not ok: deploy = deployment_set_phase( db, deploy_id, 'failed', receipt=receipt, error=receipt.get('error') or 'deploy_command_failed') finish_job(db, run_job['id'], ok=False, result=receipt, error=receipt.get('error') or 'deploy_command_failed') # recovery job leaves phase recovery_required for operator recover recover_job = queue_enqueue( db, task=task, kind='deploy_accept', payload={'deploy_id': deploy_id, 'action': 'recover'}, job_id=f'{deploy_id}-recover-pending') deployment_set_phase(db, deploy_id, 'recovery_required', receipt=receipt, error=receipt.get('error') or 'deploy_command_failed') return { 'status': 'recovery_required', 'deploy': deployment_get(db, deploy_id), 'jobs': {'prepare': prepare['id'], 'run': run_job['id'], 'recover_pending': recover_job['id']}, 'receipt': receipt, } deployment_set_phase(db, deploy_id, 'accepting', receipt=receipt) finish_job(db, run_job['id'], ok=True, result=receipt) accept_job = queue_enqueue( db, task=task, kind='deploy_accept', payload={'deploy_id': deploy_id, 'action': 'accept'}, job_id=f'{deploy_id}-accept') claimed = claim_next_job(db, worker=worker) if not claimed or claimed['id'] != accept_job['id']: raise RuntimeError('failed to claim deploy_accept job') final_receipt = dict(receipt) final_receipt['accepted'] = True final_receipt['accepted_at'] = _now() deploy = deployment_set_phase(db, deploy_id, 'passed', receipt=final_receipt) finish_job(db, accept_job['id'], ok=True, result=final_receipt) return { 'status': 'passed', 'deploy': deploy, 'jobs': {'prepare': prepare['id'], 'run': run_job['id'], 'accept': accept_job['id']}, 'receipt': final_receipt, } def recover_deploy(db, deploy_id: str, *, reason: str) -> dict: """Mark a failed/recovery_required deploy as recovered without re-running env writes.""" row = deployment_get(db, deploy_id) if not row: raise ValueError('unknown deploy id') if row['phase'] not in ('failed', 'recovery_required'): raise ValueError(f'cannot recover deploy in phase {row["phase"]}') receipt = json.loads(row['receipt']) if row.get('receipt') else {} receipt['recovered'] = True receipt['recover_reason'] = reason receipt['recovered_at'] = _now() return deployment_set_phase(db, deploy_id, 'passed', receipt=receipt, error=None) def cleanup_visibility_report( *, path: Path, branch: str, head: str, ancestor_of_dev: bool, dirty: bool, unfinished_git: list, registry: dict, process: dict, ) -> dict: """Structured cleanup gate report. Never deletes; visibility only.""" blockers = [] if not ancestor_of_dev: blockers.append({'code': 'NOT_MERGED', 'message': 'HEAD is not an ancestor of dev'}) if dirty: blockers.append({'code': 'DIRTY_TREE', 'message': 'tracked/untracked/ignored files remain'}) for marker in unfinished_git: blockers.append({'code': 'GIT_IN_PROGRESS', 'message': f'unfinished Git operation: {marker}'}) if registry.get('active_task'): blockers.append({'code': 'TASK_ACTIVE', 'message': 'registered task still active'}) if registry.get('live_run'): blockers.append({'code': 'LOCK_BUSY', 'message': 'live or unresolved operation', 'run_id': registry.get('live_run')}) status = process.get('status') if status == 'busy': blockers.append({'code': 'PROCESS_BUSY', 'message': 'process still uses worktree cwd', 'pids': process.get('pids', [])}) elif status == 'unreadable': blockers.append({'code': 'PROCESS_UNREADABLE', 'message': 'cannot verify same-user cwd', 'pids': process.get('unreadable', []), 'platform_note': process.get('platform_note')}) elif status == 'unsupported': blockers.append({'code': 'PROCESS_UNSUPPORTED', 'message': process.get('message', 'process inspection unsupported'), 'platform_note': process.get('platform_note')}) return { 'status': 'ready' if not blockers else 'cleanup_pending', 'path': str(path), 'branch': branch, 'head': head, 'gates': { 'ancestor_of_dev': ancestor_of_dev, 'dirty': dirty, 'unfinished_git': unfinished_git, 'registry': registry, 'process': process, }, 'blockers': blockers, } def project_cleanup_markdown(entries): """Render cleanup visibility markdown from structured entries (report only). This is a readout helper for operators; it is not a docs-apply write path. """ lines = [ '# Cleanup visibility projection', '', '> Generated cleanup report render. Not a lock; machine `.locks` is authoritative.', '', '| 路径/事项 | 分类与保留原因 | cleanup_owner | 复查触发 |', '| --- | --- | --- | --- |', ] if not entries: lines.append('| — | none_pending | — | — |') else: for item in entries: path = str(item.get('path') or item.get('item') or '').replace('|', '\\|') reason = str(item.get('reason') or item.get('category') or '').replace('|', '\\|') owner = str(item.get('cleanup_owner') or 'independent-maintainer').replace('|', '\\|') trigger = str(item.get('trigger') or item.get('复查触发') or '').replace('|', '\\|') lines.append(f'| {path} | {reason} | {owner} | {trigger} |') lines.append('') return '\n'.join(lines) def _darwin_cwd_visibility(path: Path, uid: Optional[int] = None) -> dict: """Fail-closed cwd scan via lsof on Darwin (agentctl-macos process layer).""" uid = os.getuid() if uid is None else uid try: result = subprocess.run( ['lsof', '-a', '-d', 'cwd', '-u', str(uid), '-F', 'pn'], capture_output=True, text=True) except OSError as exc: return { 'status': 'unreadable', 'message': f'cannot enumerate process cwd via lsof ({exc})', 'platform_note': 'fail-closed', 'pids': [], 'unreadable': ['*'], } # lsof exits 1 when selection empty; other nonzero with empty stdout is opaque. if result.returncode not in (0, 1): return { 'status': 'unreadable', 'message': f'lsof exit {result.returncode}', 'platform_note': 'fail-closed', 'pids': [], 'unreadable': ['*'], } if result.stderr.strip() and result.returncode != 0 and not result.stdout.strip(): return { 'status': 'unreadable', 'message': 'cannot verify process cwd via lsof', 'platform_note': 'fail-closed', 'pids': [], 'unreadable': ['*'], } path = Path(path).resolve() busy, unreadable = [], [] pid, saw_cwd = None, False for line in result.stdout.splitlines(): if not line: continue code, value = line[0], line[1:] if code == 'p': if pid is not None and not saw_cwd: unreadable.append(pid) pid, saw_cwd = value, False elif code == 'n' and pid is not None: try: cwd = Path(value).resolve() except OSError: unreadable.append(pid) saw_cwd = True continue if cwd == path or path in cwd.parents: busy.append(pid) saw_cwd = True if pid is not None and not saw_cwd: unreadable.append(pid) if busy: return {'status': 'busy', 'pids': busy, 'unreadable': unreadable, 'platform_note': 'darwin-lsof'} if unreadable: return {'status': 'unreadable', 'pids': [], 'unreadable': unreadable, 'platform_note': 'fail-closed; do not treat as exited'} return {'status': 'clear', 'pids': [], 'unreadable': [], 'platform_note': 'darwin-lsof'} def linux_cwd_visibility(path: Path, uid: Optional[int] = None) -> dict: """Fail-closed cwd scan: /proc on Linux, lsof on Darwin.""" if sys.platform == 'darwin': return _darwin_cwd_visibility(path, uid=uid) if not sys.platform.startswith('linux'): return { 'status': 'unsupported', 'message': f'cwd scan unavailable on {sys.platform}', 'platform_note': 'treat unknown as cleanup_pending', 'pids': [], 'unreadable': [], } uid = os.getuid() if uid is None else uid busy, unreadable = [], [] try: entries = list(Path('/proc').iterdir()) except OSError: return { 'status': 'unreadable', 'message': 'cannot list /proc', 'platform_note': 'fail-closed', 'pids': [], 'unreadable': ['*'], } path = Path(path).resolve() for proc in entries: if not proc.name.isdigit(): continue try: if proc.stat().st_uid != uid: continue cwd = Path(os.readlink(proc / 'cwd')) if cwd == path or path in cwd.parents: busy.append(proc.name) except (FileNotFoundError, ProcessLookupError): continue except PermissionError: try: if (proc / 'comm').read_text().strip() == 'systemd': continue except (OSError, UnicodeError): pass unreadable.append(proc.name) if busy: return {'status': 'busy', 'pids': busy, 'unreadable': unreadable} if unreadable: return {'status': 'unreadable', 'pids': [], 'unreadable': unreadable, 'platform_note': 'fail-closed; do not treat as exited'} return {'status': 'clear', 'pids': [], 'unreadable': []} # Back-compat alias used by cleanup reports. cwd_visibility = linux_cwd_visibility def pi_source_bind_paths() -> tuple: """Host paths that must not be bind-mounted when PI image delivery is enforced.""" return ( './deploy/pi-agent/runner.mjs', './deploy/pi-agent/pi-args.mjs', './deploy/pi-agent/result-v1.mjs', './deploy/pi-agent/extensions', 'deploy/pi-agent/runner.mjs', 'deploy/pi-agent/pi-args.mjs', 'deploy/pi-agent/result-v1.mjs', 'deploy/pi-agent/extensions', ) def inspect_compose_for_pi_binds(compose_text: str) -> dict: """Static check: flag source bind mounts under pi-agent service.""" forbidden = pi_source_bind_paths() hits = [] in_pi = False for line in compose_text.splitlines(): stripped = line.strip() if re.match(r'^pi-agent\s*:', stripped): in_pi = True continue if in_pi and re.match(r'^[A-Za-z0-9_.-]+:\s*$', stripped): in_pi = False if in_pi: for item in forbidden: if item in stripped and (':/app/' in stripped or stripped.startswith('-')): hits.append(stripped) break return { 'ok': not hits, 'source_binds': hits, 'recommendation': ( 'Apply docker-compose.dev.pi-image.yml overlay so runner source ships in the image' if hits else 'no pi-agent source binds detected' ), } # Type aliases for agentctl callbacks (avoid circular imports at runtime). EmitFn = Callable[[Any], None] DenyFn = Callable[..., None]