diff --git a/.doc/ops/short-term/2026-09-22-agentctl-macos.md b/.doc/ops/short-term/2026-09-22-agentctl-macos.md new file mode 100644 index 00000000..28d14104 --- /dev/null +++ b/.doc/ops/short-term/2026-09-22-agentctl-macos.md @@ -0,0 +1,65 @@ +--- +title: "agentctl macOS 进程平台层(W1-B)" +task_id: agentctl-macos-2026-09-22 +status: done +created: 2026-09-22 +updated: 2026-09-22 +owner: Cursor +task_type: implementation +task_tier: small +policy_revision: "AGENTS+worktree-ops @ 364ed7f1" +branch: feature/agentctl-macos-2026-09-22 +worktree: .worktrees/agentctl-macos-2026-09-22 +base_dev_sha: "364ed7f1a3f0393ef77ec4623a9518116ed2cb87" +write_scope: ["scripts/agentctl.py", "scripts/agent_worktree.py", "scripts/tests/", ".doc/ops/short-term/"] +allowed_environments: [] +head_sha: "" +mutex: agent-workflow-governance +review_state: pending +integration_state: pending +deployment: not_required +cleanup: pending +cleanup_owner: 独立集成/维护执行者 +cleanup_trigger: tip 为 origin/dev 祖先且无进程使用后 remove +remote_sync: not_requested +--- + +# agentctl macOS 进程平台层(W1-B) + +## 用户目标与授权 + +- 用户目标:去掉 `scripts/agentctl.py` 对 Linux `/proc` 的硬依赖,使本机 macOS 可跑 `with` / `docs apply` / `validate` / `integrate`。 +- 交付终点:平台层落地、三值语义 fail-closed、单测通过、本机 `with` 不再 TypeError、提交落盘。 +- 禁止项:不编辑计划文件;不 push;不操作共享环境;不碰其他 worktree。 + +## 范围与非目标 + +- 允许写入:`agentctl` 进程/boot 探测与调用点、相关测试、本 short-term 记录;顺带为 `agent_worktree` 清理路径提供 macOS `lsof` cwd 枚举(非租约阻塞项)。 +- 非目标:治理例外撤销、dev 集成、CURRENT 共享槽位发布。 + +## 实现要点 + +- API:`read_proc` → `Lookup`/`ProcInfo`;`boot_epoch`;`alive_status` / `group_status` / `descent_status`(`alive|dead|unknown` / `yes|no|unknown`)。 +- Linux:`/proc//stat` + `/proc/sys/kernel/random/boot_id`;macOS:`ps -o pid=,ppid=,pgid=,lstart=,state=` 与批量枚举;`sysctl -n kern.boottime` 取 `sec=`。 +- 语义:僵尸 `Z` 不算 alive;`unreadable`/`unknown` 不得当已退出(不 reclaim queued、不 recover、不把进程组判全死);DB 字段 `pid/start/boot/pgid` 保持兼容。 +- Darwin 硬化:`ps`/`sysctl`/`lsof` 的 `OSError` → unreadable/unknown;枚举跳过空行;解析失败 fail-closed。 +- 调用点:`Lease.__enter__/__exit__`、`inherited`、`supervise`、`recover`、`status/doctor` 使用三值状态。 +- 测试:平台层 mock(ok/gone/unreadable/zombie);Linux-only `pidfd_open` 用例在 macOS skip;`test_herdr_agents` 的 `MOCK_CWD` 使用 resolve 路径以对齐 macOS `/var`→`/private/var`。 + +## 验证与证据 + +| 范围 / 被测 SHA | 命令与环境 | 预期 | 实际 / 判定 | 证据 / 脱敏说明 | +|---|---|---|---|---| +| scripts platform + agentctl | `python3 -B -m unittest discover -s scripts/tests -p 'test_*.py' -v`(Darwin) | 尽可能全绿;pidfd 用例 skip | **PASS**:94 ran,2 skipped(pidfd),0 fail | 本机 macOS;无业务正文 | +| 本机租约冒烟 | `python3 -B scripts/agentctl.py status`;`with --task agentctl-macos-2026-09-22 --resource path:scripts -- /usr/bin/true` | status JSON ok;无 TypeError;with exit 0 | **PASS**:status ok;with exit 0;`boot_epoch`/`read_proc`/`alive`/`group` 自检 ok | 真实执行;run_id `438f5e73-…` | + +## 收口与交接 + +- 本地提交 / 待 `dev` 集成 / 无部署要求 / 未请求远端。 +- Follow-up:`agent_worktree` cwd 枚举已有 Darwin `lsof` 路径;后续若需与 Linux `/proc` 行为完全对账可再加独立用例。 +- 集成后可撤销依赖本机 `/proc` 不可用而登记的治理例外。 + +## 相关链接 + +- Plan:推进未完成条目全量计划(W1-B,勿改计划文件) +- 验证契约:[.doc/ops/2026-09-21-validation-contract.md](../2026-09-21-validation-contract.md) diff --git a/scripts/agent_worktree.py b/scripts/agent_worktree.py index 17eec591..6bb61e9b 100644 --- a/scripts/agent_worktree.py +++ b/scripts/agent_worktree.py @@ -121,7 +121,7 @@ def create(repo, main, branch): print(json.dumps({"branch": branch, "worktree": str(path), "base_dev_sha": base})) -def assert_no_process_cwd(path): +def _linux_same_user_cwds(): unreadable = [] for proc in Path("/proc").iterdir(): if not proc.name.isdigit(): @@ -130,8 +130,7 @@ def assert_no_process_cwd(path): if proc.stat().st_uid != os.getuid(): continue cwd = Path(os.readlink(proc / "cwd")) - if cwd == path or path in cwd.parents: - raise Refused(f"process {proc.name} still uses this worktree as cwd") + yield proc.name, cwd except (FileNotFoundError, ProcessLookupError): continue except PermissionError: @@ -150,6 +149,56 @@ def assert_no_process_cwd(path): raise Refused(f"cannot verify cwd of same-user process {','.join(unreadable)}; cleanup_pending") +def _darwin_same_user_cwds(): + try: + result = subprocess.run( + ["lsof", "-a", "-d", "cwd", "-u", str(os.getuid()), "-F", "pn"], + capture_output=True, text=True) + except OSError as exc: + raise Refused(f"cannot enumerate process cwd via lsof ({exc}); cleanup_pending") from exc + if result.returncode not in (0, 1): + # lsof exits 1 when the selection is empty; other codes are opaque failure. + raise Refused(f"cannot enumerate process cwd via lsof (exit {result.returncode}); cleanup_pending") + if result.stderr.strip() and result.returncode != 0 and not result.stdout.strip(): + raise Refused("cannot verify process cwd via lsof; cleanup_pending") + 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: + raise Refused(f"cannot verify cwd of same-user process {pid}; cleanup_pending") + pid, saw_cwd = value, False + elif code == "n" and pid is not None: + yield pid, Path(value) + saw_cwd = True + # macOS lsof may still emit f/type lines; ignore them. + if pid is not None and not saw_cwd: + raise Refused(f"cannot verify cwd of same-user process {pid}; cleanup_pending") + + +def iter_same_user_cwds(): + """Yield (pid_str, cwd Path) for same-uid processes. Fail closed on gaps.""" + if sys.platform.startswith("linux"): + yield from _linux_same_user_cwds() + elif sys.platform == "darwin": + yield from _darwin_same_user_cwds() + else: + raise Refused(f"process cwd checks unsupported on {sys.platform}; cleanup_pending") + + +def assert_no_process_cwd(path): + path = path.resolve() + for pid, cwd in iter_same_user_cwds(): + try: + resolved = cwd.resolve() + except OSError as exc: + raise Refused(f"cannot verify cwd of same-user process {pid}; cleanup_pending") from exc + if resolved == path or path in resolved.parents: + raise Refused(f"process {pid} still uses this worktree as cwd") + + @contextmanager def removal_lock(main, path): # Every agentctl run/registration holds the shared side of this same lock. diff --git a/scripts/agentctl.py b/scripts/agentctl.py index 888d90c0..e0bc4c04 100755 --- a/scripts/agentctl.py +++ b/scripts/agentctl.py @@ -2,10 +2,12 @@ """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 @@ -14,6 +16,7 @@ import subprocess import sys import tempfile import time +from typing import Literal, Optional import uuid VERSION = 1 @@ -22,6 +25,10 @@ ACTIVE = ('running', 'recovery_required') TERMINAL = ('completed', 'failed', 'recovered') GIT_CONTEXT = ('GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE', 'GIT_PREFIX') +Lookup = Literal['ok', 'gone', 'unreadable'] +AliveStatus = Literal['alive', 'dead', 'unknown'] +DescentStatus = Literal['yes', 'no', 'unknown'] + class Denied(Exception): def __init__(self, code, message, **details): @@ -53,46 +60,209 @@ def git(path, *args, index=False, check=True, input=None): return result.stdout.rstrip('\n') if check else result -def proc(pid): +@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('/proc', str(pid), 'stat').read_text().rsplit(')', 1)[1].split() - return {'state': raw[0], 'ppid': int(raw[1]), 'group': int(raw[2]), 'start': raw[19]} - except (OSError, ValueError, IndexError): + 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 boot(): - return Path('/proc/sys/kernel/random/boot_id').read_text().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 alive(pid, start, boot_id): - info = proc(pid) - return bool(info and info['state'] != 'Z' and info['start'] == start and boot_id == boot()) +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 descendants_of(pid): +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 == pid: - return True + if current == ancestor: + return 'yes' seen.add(current) - info = proc(current) - if not info: - break - current = info['ppid'] - return False + 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): - if not group: - return False - for item in Path('/proc').iterdir(): - if item.name.isdigit(): - info = proc(int(item.name)) - if info and info['group'] == group and info['state'] != 'Z': - return True - return False + """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): @@ -403,7 +573,9 @@ class Lease: self.handles = [] def __enter__(self): - info = proc(os.getpid()) + 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: old = db.execute('SELECT * FROM runs WHERE id=?', (self.request_id,)).fetchone() if old: @@ -415,12 +587,12 @@ class Lease: 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(), time.time(), self.request_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(), time.time(), time.time(), self.kind)) + 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 @@ -442,7 +614,8 @@ class Lease: candidates = db.execute("SELECT * FROM runs WHERE id!=? AND state IN ('running','recovery_required','queued') ORDER BY generation", (self.request_id,)).fetchall() for other in candidates: if other['state'] == 'queued': - if not alive(other['pid'], other['start'], other['boot']): + # 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']: @@ -485,7 +658,9 @@ class Lease: 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': - self.finish(1, bool(doc and doc['state'] == 'prepared') or group_alive(state['pgid'])) + # 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: @@ -498,11 +673,12 @@ def inherited(repo, required=None): 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 not alive(row['pid'], row['start'], row['boot']) - or not descendants_of(row['pid'])): + 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') if required: resources = json.loads(row['resources']) @@ -530,10 +706,11 @@ def supervise(repo, lease, command, cwd, env=None): if stopping: return 128 + stopping[0], False child = subprocess.Popen(command, cwd=cwd, env=env, start_new_session=True) - info = proc(child.pid) + 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, info['start'] if info else '', child.pid, lease.request_id)) + (child.pid, child_start, child.pid, lease.request_id)) last_beat, stop_at = time.monotonic(), None while child.poll() is None: if stopping: @@ -546,7 +723,8 @@ def supervise(repo, lease, command, cwd, env=None): last_beat = time.monotonic() time.sleep(0.1) code = child.wait() - return (code if code >= 0 else 128 - code), group_alive(child.pid) + # 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): @@ -791,7 +969,10 @@ def recover(repo, args): if row['state'] in TERMINAL: emit({'status': row['state'], 'run_id': args.run}) return - if alive(row['pid'], row['start'], row['boot']) or (row['boot'] == boot() and group_alive(row['pgid'])): + # 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') handles = [] try: @@ -993,7 +1174,7 @@ def main(): 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')")] - for row in runs:row['supervisor_alive']=alive(row['pid'],row['start'],row['boot']) + 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}) elif args.action=='task': if args.task_action=='register': diff --git a/scripts/tests/test_agent_worktree.py b/scripts/tests/test_agent_worktree.py index ef16b22c..e2aa5509 100644 --- a/scripts/tests/test_agent_worktree.py +++ b/scripts/tests/test_agent_worktree.py @@ -64,9 +64,9 @@ class WorktreeTest(unittest.TestCase): return Path(json.loads(result.stdout)["worktree"]) def remove_with_visibility_fixture(self, *args): - """Fixture only /proc enumeration; Git, registry and lock checks stay real. + """Fixture only process cwd enumeration; Git, registry and lock checks stay real. - This host hides unrelated same-user daemon cwds. Positive removal tests + This host may hide unrelated same-user daemon cwds. Positive removal tests model a fully visible process namespace containing the helper itself. Live cwd rejection below still runs the actual host process enumeration. """ @@ -77,13 +77,10 @@ from unittest import mock spec = importlib.util.spec_from_file_location("helper", sys.argv[1]) helper = importlib.util.module_from_spec(spec) spec.loader.exec_module(helper) -original = Path.iterdir -def visible_processes(path): - if path == Path("/proc"): - return iter([path / str(os.getpid())]) - return original(path) +def visible_cwds(): + yield str(os.getpid()), Path.cwd() sys.argv = sys.argv[1:] -with mock.patch.object(Path, "iterdir", visible_processes): +with mock.patch.object(helper, "iter_same_user_cwds", visible_cwds): sys.exit(helper.main()) ''' return self.run_cmd([sys.executable, "-c", code, str(self.root / "scripts/agent_worktree.py"), "remove", *args]) @@ -110,7 +107,7 @@ with mock.patch.object(Path, "iterdir", visible_processes): self.assertEqual(result.returncode, 0, result.stderr) data = json.loads(result.stdout) self.assertEqual(data["base_dev_sha"], self.base) - self.assertEqual(Path(data["worktree"]), self.root / ".worktrees/second") + self.assertEqual(Path(data["worktree"]).resolve(), (self.root / ".worktrees/second").resolve()) self.assertEqual(self.git("rev-parse", "fix/second").stdout.strip(), self.base) self.assertFalse((first / ".worktrees").exists()) self.assertFalse((first / ".locks").exists()) @@ -308,12 +305,28 @@ class ProcessVisibilityTest(unittest.TestCase): spec = importlib.util.spec_from_file_location("agent_worktree_under_test", SOURCE / "agent_worktree.py") helper = importlib.util.module_from_spec(spec) spec.loader.exec_module(helper) + + def unreadable(): + raise helper.Refused("cannot verify cwd of same-user process 987654321; cleanup_pending") + + with tempfile.TemporaryDirectory() as temp: + with mock.patch.object(helper, "iter_same_user_cwds", side_effect=unreadable): + with self.assertRaisesRegex(helper.Refused, "cannot verify cwd"): + helper.assert_no_process_cwd(Path(temp) / "task") + + def test_linux_permission_error_path_remains_fail_closed(self): + spec = importlib.util.spec_from_file_location("agent_worktree_under_test", SOURCE / "agent_worktree.py") + helper = importlib.util.module_from_spec(spec) + spec.loader.exec_module(helper) with tempfile.TemporaryDirectory() as temp: proc = Path(temp) / "987654321" proc.mkdir() (proc / "comm").write_text("coding-agent\n") - with mock.patch.object(helper.Path, "iterdir", return_value=iter([proc])), \ - mock.patch.object(helper.os, "readlink", side_effect=PermissionError): + with mock.patch.object(helper.sys, "platform", "linux"), \ + mock.patch.object(helper.Path, "iterdir", return_value=iter([proc])), \ + mock.patch.object(helper.os, "getuid", return_value=0), \ + mock.patch.object(helper.os, "readlink", side_effect=PermissionError), \ + mock.patch.object(Path, "stat", lambda self: type("S", (), {"st_uid": 0})()): with self.assertRaisesRegex(helper.Refused, "cannot verify cwd"): helper.assert_no_process_cwd(Path(temp) / "task") diff --git a/scripts/tests/test_agentctl.py b/scripts/tests/test_agentctl.py index 1b643ff0..f89ff0f6 100644 --- a/scripts/tests/test_agentctl.py +++ b/scripts/tests/test_agentctl.py @@ -258,6 +258,8 @@ class AgentctlTests(unittest.TestCase): self.finish_lease(process, connection) def test_recovery_refuses_dead_supervisor_with_live_child(self): + if not hasattr(os, "pidfd_open"): + self.skipTest("pidfd_open is Linux-only") task = self.initialized_task() process, connection, payload = self.live_lease("alpha", ["path:src/a"], task) child_fd = os.pidfd_open(payload["pid"]) @@ -533,6 +535,8 @@ class AgentctlTests(unittest.TestCase): self.assertEqual(self.git("rev-parse", "dev"), original) def test_interrupted_validation_stops_child_before_releasing_scope(self): + if not hasattr(os, "pidfd_open"): + self.skipTest("pidfd_open is Linux-only") task = self.initialized_task() listener = socket.socket(socket.AF_UNIX) endpoint = str(pathlib.Path(self.temp.name) / "validate.sock") @@ -650,5 +654,101 @@ class AgentctlTests(unittest.TestCase): self.assertEqual(self.git("status", "--porcelain", cwd=task), "") +class ProcessPlatformTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + import importlib.util + spec = importlib.util.spec_from_file_location("agentctl_under_test", SOURCE) + cls.mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cls.mod) + + def test_boot_and_self_proc_are_observable(self): + boot = self.mod.boot_epoch() + self.assertTrue(boot) + lookup, info = self.mod.read_proc(os.getpid()) + self.assertEqual(lookup, "ok") + self.assertIsNotNone(info) + self.assertNotEqual(info.state, "Z") + self.assertTrue(info.start) + self.assertEqual(self.mod.alive_status(os.getpid(), info.start, boot), "alive") + self.assertEqual(self.mod.alive_status(os.getpid(), info.start, "other-boot"), "dead") + self.assertEqual(self.mod.descent_status(os.getpid()), "yes") + + def test_gone_pid_is_dead(self): + child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"]) + pid = child.pid + lookup, info = self.mod.read_proc(pid) + self.assertEqual(lookup, "ok") + self.assertIsNotNone(info) + child.kill() + child.wait(timeout=5) + self.assertEqual(self.mod.read_proc(pid)[0], "gone") + self.assertEqual(self.mod.alive_status(pid, info.start, self.mod.boot_epoch()), "dead") + + def test_zombie_is_not_alive(self): + info = self.mod.ProcInfo(pid=9, ppid=1, pgid=9, state="Z", start="token") + from unittest import mock + with mock.patch.object(self.mod, "read_proc", return_value=("ok", info)), \ + mock.patch.object(self.mod, "boot_epoch", return_value="boot"): + self.assertEqual(self.mod.alive_status(9, "token", "boot"), "dead") + + def test_unreadable_proc_is_unknown_not_dead(self): + from unittest import mock + with mock.patch.object(self.mod, "read_proc", return_value=("unreadable", None)), \ + mock.patch.object(self.mod, "boot_epoch", return_value="boot"): + self.assertEqual(self.mod.alive_status(9, "start", "boot"), "unknown") + self.assertTrue(self.mod.alive(9, "start", "boot")) + with mock.patch.object(self.mod, "_enumerate_group", return_value=(False, "unknown")): + self.assertEqual(self.mod.group_status(42), "unknown") + self.assertTrue(self.mod.group_alive(42)) + with mock.patch.object(self.mod, "_enumerate_group", return_value=(False, "ok")): + self.assertEqual(self.mod.group_status(42), "dead") + with mock.patch.object(self.mod, "_enumerate_group", return_value=(True, "unknown")): + self.assertEqual(self.mod.group_status(42), "alive") + + def test_descent_unreadable_is_unknown(self): + from unittest import mock + with mock.patch.object(self.mod, "read_proc", return_value=("unreadable", None)): + self.assertEqual(self.mod.descent_status(1), "unknown") + self.assertFalse(self.mod.descendants_of(1)) + + def test_lease_denies_when_supervisor_proc_unreadable(self): + from unittest import mock + temp = tempfile.TemporaryDirectory(prefix="agentctl-proc-") + self.addCleanup(temp.cleanup) + root = pathlib.Path(temp.name) / "repo" + root.mkdir() + subprocess.run(["git", "init", "-b", "dev"], cwd=root, check=True, capture_output=True) + subprocess.run(["git", "config", "user.name", "t"], cwd=root, check=True, capture_output=True) + subprocess.run(["git", "config", "user.email", "t@example.invalid"], cwd=root, check=True, capture_output=True) + (root / "scripts").mkdir() + shutil.copy2(SOURCE, root / "scripts" / "agentctl.py") + (root / ".gitignore").write_text(".locks/\n.worktrees/\n", encoding="utf-8") + (root / ".doc/ops").mkdir(parents=True) + (root / ".doc/ops/CURRENT.md").write_text("# c\n", encoding="utf-8") + (root / ".doc/status").mkdir(parents=True) + (root / ".doc/status/FEATURE-MATRIX.md").write_text("# f\n", encoding="utf-8") + (root / "src/a").mkdir(parents=True) + subprocess.run(["git", "add", "."], cwd=root, check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "fixture"], cwd=root, check=True, capture_output=True) + cli = [sys.executable, str(root / "scripts/agentctl.py")] + subprocess.run(cli + ["init"], cwd=root, check=True, capture_output=True) + task = root / ".worktrees" / "alpha" + subprocess.run(["git", "worktree", "add", "-b", "feature/alpha", str(task), "dev"], + cwd=root, check=True, capture_output=True) + subprocess.run(cli + ["task", "register", "--id", "alpha", "--owner", "t", "--scope", "src/a"], + cwd=task, check=True, capture_output=True) + import importlib.util + spec = importlib.util.spec_from_file_location("agentctl_lease", root / "scripts/agentctl.py") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + repo = mod.Repo(task) + task_row = mod.task_row(repo, "alpha") + with mock.patch.object(mod, "read_proc", return_value=("unreadable", None)): + with self.assertRaises(mod.Denied) as raised: + mod.Lease(repo, task_row, ["path:src/a"], None, "fp", 0).__enter__() + self.assertEqual(raised.exception.code, "PROCESS_UNREADABLE") + + if __name__ == "__main__": unittest.main() diff --git a/scripts/tests/test_herdr_agents.py b/scripts/tests/test_herdr_agents.py index 2bf2574c..f3153da8 100644 --- a/scripts/tests/test_herdr_agents.py +++ b/scripts/tests/test_herdr_agents.py @@ -22,8 +22,10 @@ class HerdrTests(unittest.TestCase): self.task = self.initialized_task() self.bin = Path(self.temp.name) / 'bin'; self.bin.mkdir() self.log = Path(self.temp.name) / 'calls.jsonl' + # Resolve so macOS /var → /private/var matches agentctl Repo.current strings. + task_cwd = str(self.task.resolve()) self.env = dict(os.environ, HERDR_ENV='1', PATH=str(self.bin)+os.pathsep+os.environ['PATH'], - MOCK_LOG=str(self.log), MOCK_CWD=str(self.task), MOCK_MODE='ok') + MOCK_LOG=str(self.log), MOCK_CWD=task_cwd, MOCK_MODE='ok') self.env['MOCK_AGENTS'] = json.dumps(['qwen_max__alpha','qwen_flash__alpha','context_kimi__alpha','implementer_cursor__alpha','reviewer_glm__alpha']) fake = self.bin / 'herdr' fake.write_text("#!/usr/bin/env python3\n" + r'''import json,os,re,sys