confluence_web/agent/supervisor
Artur Mukhamadiev 38a8ca67f7 agent: pi runtime track handoff (contract revision 1)
Pinned pi SDK 0.85.1 bridge, Python supervisor, artifact exporter,
scripted backend peer, image checks and boundary checks under agent/**.
Review findings F1-F3 are recorded in docs/implementation/PI_AGENT_REVIEW.md.
2026-09-14 21:57:54 +03:00

144 lines
4.9 KiB
Python
Executable File

#!/usr/bin/env python3
"""Immutable Linux PID-1 supervisor; host mode is development-only."""
import ctypes
import json
import os
import select
import signal
import socket
import subprocess
import sys
import time
from pathlib import Path
libc = ctypes.CDLL(None, use_errno=True)
# Adopt double-forked descendants on hosts, as well as under container PID 1.
if libc.prctl(36, 1, 0, 0, 0) != 0: # PR_SET_CHILD_SUBREAPER
sys.exit(1)
if libc.prctl(4, 0, 0, 0, 0) != 0: # PR_SET_DUMPABLE: deny same-UID proc memory/fd access
sys.exit(1)
container = os.getpid() == 1
if not container and '--dev' not in sys.argv:
sys.stderr.write('Supervisor requires container PID 1; use --dev only for host tests.\n')
sys.exit(1)
# PID 1 ignores namespace-local SIGKILL/SIGSTOP. Other signals terminate the
# run or are ignored; none can pause or extend the deadline. No exec after prctl.
for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP, signal.SIGUSR1, signal.SIGUSR2):
signal.signal(sig, signal.SIG_IGN)
started = time.monotonic()
deadline = started + 180
parent, child = socket.socketpair()
child.set_inheritable(True)
root = Path(__file__).resolve().parent
entry = root / 'dist' / 'bridge.js'
args = ['node', str(entry)]
env = {'PATH': '/usr/local/bin:/usr/bin:/bin', 'HOME': '/home/agent', 'LANG': 'C.UTF-8',
'AGENT_SUPERVISOR_FD': str(child.fileno())}
if '--dev' in sys.argv:
env['PATH'] = os.environ.get('PATH', env['PATH'])
env['HOME'] = os.environ.get('HOME', '/tmp')
env['AGENT_WORK_DIR'] = os.environ.get('AGENT_WORK_DIR', '/work')
bridge = subprocess.Popen(args, env=env, pass_fds=(child.fileno(),))
child.close()
parent.setblocking(False)
protected = {os.getpid(), bridge.pid}
buffer = b''
def descendants():
"""Never signal unrelated host processes. Adopted children retain this ancestry."""
parents = {}
for item in Path('/proc').iterdir():
if item.name.isdecimal():
try:
fields = (item / 'stat').read_text().rsplit(')', 1)[1].split()
parents[int(item.name)] = int(fields[1])
except (OSError, ValueError, IndexError):
pass
owned = {os.getpid()}
while True:
extra = {pid for pid, ppid in parents.items() if ppid in owned} - owned
if not extra:
break
owned.update(extra)
return owned - protected
def reap():
# Re-scan until every descendant is gone, including children forked during
# termination. Reap only adopted children, leaving Popen to reap the bridge.
while time.monotonic() < deadline:
victims = descendants()
if not victims:
return
for pid in victims:
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
try:
os.waitpid(pid, os.WNOHANG)
except ChildProcessError:
pass
time.sleep(0.01)
raise TimeoutError()
code = 1
try:
deadline_set = False
collecting = False
while time.monotonic() < deadline:
if bridge.poll() is not None:
code = bridge.returncode
break
readable, _, _ = select.select([parent], [], [], min(0.05, max(0, deadline - time.monotonic())))
if not readable:
continue
data = parent.recv(4096)
if not data:
try:
code = bridge.wait(timeout=min(1, max(0.01, deadline - time.monotonic())))
except subprocess.TimeoutExpired:
pass
break
buffer += data
if len(buffer) > 8192:
break
while b'\n' in buffer:
line, buffer = buffer.split(b'\n', 1)
msg = json.loads(line)
if not isinstance(msg, dict):
raise ValueError()
if msg.get('type') == 'set_deadline' and not deadline_set:
ms = msg.get('remaining_ms')
if type(ms) is not int or not 1 <= ms <= 180000:
raise ValueError()
deadline = min(deadline, time.monotonic() + ms / 1000)
deadline_set = True
elif msg == {'type': 'reap_descendants'} and not collecting:
reap()
collecting = True
parent.sendall(b'{"type":"reaped"}\n')
else:
raise ValueError()
except (OSError, ValueError, TimeoutError):
pass
finally:
if container and time.monotonic() >= deadline:
sys.exit(1) # Kernel destroys the namespace; do not add cleanup time.
# Killing PID 1 ends the container namespace. Host development cleanup is
# still restricted to descendants of this supervisor.
protected.discard(bridge.pid)
deadline = max(deadline, time.monotonic() + 1)
try:
reap()
except (OSError, TimeoutError):
pass
try:
bridge.wait(timeout=1)
except subprocess.TimeoutExpired:
bridge.kill()
parent.close()
sys.exit(code if code >= 0 else 1)