Files
stack/agents/researcher/work/queue-84/r1/mutants.py
T
jason.woltjeandClaude Opus 5.5 7198d336cd researcher: row 84 round 1 packet on ac6b56ee, business 67/0, nine mutants killed (#1578)
Haiku subagent build under decision 129. Candidate manifest f72730de
names roles/chief-of-staff.json and .md, the business README and
packages/business/tests/chief-of-staff.test.mjs. Gate and mutants ran
in Sage's slot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 09:56:33 -05:00

241 lines
8.9 KiB
Python

#!/usr/bin/env python3
# Row 84 (#1578) mutation runner, chief-of-staff role. Written in Phase A; run once in Phase B (see BUILD.md).
#
# Phase B usage, from the worktree root, after Sage's "slot clear" and after the
# rebase onto the current refactor HEAD:
# python3 -I <packet>/mutants.py <worktree> <packet>
#
# Nine mutants. M01 to M08 rewrite one exact-once anchor in roles/chief-of-staff.json.
# M09 rewrites one exact-once anchor in packages/business/src/resolve.mjs, which is
# NOT in the candidate manifest; its original sha256 is pinned below instead. Each
# mutant runs packages/business/tests/chief-of-staff.test.mjs in its own process group
# under a timeout, and restores the original bytes of its file in a finally block.
# The candidate manifest is checked before the baseline and after the last restore.
# resolve.mjs is checked against its pinned sha256 before the baseline, after each
# restore of M09, and at the end. A red baseline aborts before any mutant. A timeout
# counts as a failure.
#
# Exit codes: 0 every mutant killed; 1 a mutant survived or a run timed out;
# 2 baseline red, timed out, manifest or pinned-hash mismatch, or leftover processes;
# 3 an anchor was not found exactly once.
import hashlib
import os
import signal
import subprocess
import sys
TIMEOUT_S = 180
ROLE = 'roles/chief-of-staff.json'
RESOLVE = 'packages/business/src/resolve.mjs'
# sha256 of resolve.mjs at BASE 58a3766d (checked: identical to base). Not in the candidate.
RESOLVE_SHA256 = 'c50340af650aaa2d0eb15224e823525ba942a91f475ed62bd7e7ad42055f182b'
SUITE = ['node', '--test', 'packages/business/tests/chief-of-staff.test.mjs']
# (id, file, old exact text, new exact text, what the test that must kill it checks)
MUTANTS = [
('M01', ROLE, '"tools": ["read", "grep", "find", "ls"],',
'"tools": ["read", "grep", "find", "ls", "bash"],',
'widen tools: bash added; shipped-file equality test fails'),
('M02', ROLE, '"tools": ["read", "grep", "find", "ls"],',
'"tools": ["read", "write", "grep", "find", "ls"],',
'widen tools: write added (a write tool, which waits for I4); equality test fails'),
('M03', ROLE, '"network": "api-only",',
'"network": "open",',
'widen network: api-only to open; equality test fails'),
('M04', ROLE, '"withinRole": ["message.send"],',
'"withinRole": ["message.send", "role.launch"],',
'widen withinRole: role.launch within-role; equality test fails'),
('M05', ROLE, '"withinRole": ["message.send"],',
'"withinRole": ["message.send", "task.create"],',
'widen withinRole: task.create within-role; equality test and classify test fail'),
('M06', ROLE, '"crossRole": []',
'"crossRole": ["task.scope.change"]',
'widen crossRole: task.scope.change; equality test fails'),
('M07', ROLE, '"credentials": []',
'"credentials": [{ "service": "gitea", "scopes": ["read:issue"] }]',
'widen credentials: gitea scope; business fixture refuses the instance (no matching credential ref) and equality test fails'),
('M08', ROLE, '"withinRole": ["message.send"],',
'"withinRole": [],',
'narrow withinRole: message.send dropped; equality and classify (within for message.send) tests fail'),
('M09', RESOLVE, 'withinRole = withinRole.filter((a) => vars["limits.authority"].includes(a));',
'withinRole = [...new Set([...withinRole, ...vars["limits.authority"]])];',
'widen through limits: a limits.authority grant adds gated actions to withinRole; the limits-grant test (task.create, role.launch stay gated) must fail'),
]
def sha256_of(path):
digest = hashlib.sha256()
with open(path, 'rb') as handle:
for block in iter(lambda: handle.read(65536), b''):
digest.update(block)
return digest.hexdigest()
def read_manifest(packet):
entries = []
with open(os.path.join(packet, 'candidate-manifest.sha256'), encoding='utf-8') as handle:
for line in handle:
line = line.rstrip('\n')
if line:
digest, rel = line.split(' ', 1)
entries.append((digest, rel))
return entries
def manifest_mismatches(wt, entries):
return [rel for digest, rel in entries if sha256_of(os.path.join(wt, rel)) != digest]
def resolve_mismatch(wt):
return sha256_of(os.path.join(wt, RESOLVE)) != RESOLVE_SHA256
def kill_group(pgid):
try:
os.killpg(pgid, signal.SIGKILL)
except ProcessLookupError:
pass
def run_suite(wt, log_path):
with open(log_path, 'wb') as log:
proc = subprocess.Popen(
SUITE,
cwd=wt,
env=os.environ.copy(),
stdout=log,
stderr=subprocess.STDOUT,
start_new_session=True,
)
try:
return proc.wait(timeout=TIMEOUT_S), 'exit'
except subprocess.TimeoutExpired:
kill_group(proc.pid)
proc.wait()
return None, 'timeout'
finally:
kill_group(proc.pid)
def ancestors():
# This process and its parents: the runner and the shell that started it.
chain = set()
pid = os.getpid()
while pid > 1 and pid not in chain:
chain.add(pid)
try:
with open(os.path.join('/proc', str(pid), 'stat'), encoding='utf-8') as handle:
stat = handle.read()
except OSError:
break
pid = int(stat.rsplit(')', 1)[1].split()[1])
return chain
def worktree_leftovers(wt):
# Reads /proc cwd links only. Reports; kills nothing. Skips this runner and its parents.
skip = ancestors()
found = []
for pid in filter(str.isdigit, os.listdir('/proc')):
if int(pid) in skip:
continue
try:
cwd = os.readlink(os.path.join('/proc', pid, 'cwd'))
except OSError:
continue
if cwd == wt or cwd.startswith(wt + os.sep):
found.append((pid, cwd))
return found
def main():
if len(sys.argv) != 3:
sys.stderr.write('usage: mutants.py <worktree> <packet_dir>\n')
return 2
wt = os.path.abspath(sys.argv[1])
packet = os.path.abspath(sys.argv[2])
out = os.path.join(packet, 'out')
os.makedirs(out, exist_ok=True)
entries = read_manifest(packet)
broken = manifest_mismatches(wt, entries)
if broken:
print('manifest mismatch before baseline:', broken)
return 2
if resolve_mismatch(wt):
print(f'{RESOLVE} differs from the pinned base sha256 before baseline')
return 2
rc, status = run_suite(wt, os.path.join(out, 'baseline.log'))
if status != 'exit' or rc != 0:
print('baseline red or timed out:', status, rc)
return 2
# Original bytes of every file a mutant touches, read once.
originals = {}
for _, rel, _, _, _ in MUTANTS:
if rel not in originals:
with open(os.path.join(wt, rel), 'rb') as handle:
originals[rel] = handle.read()
# Check every anchor before the first mutant runs.
for mid, rel, old, _, _ in MUTANTS:
count = originals[rel].decode('utf-8').count(old)
if count != 1:
print(f'{mid} ANCHOR count={count} in {rel}; aborting before any mutant')
return 3
summary = ['baseline: green']
failures = []
for mid, rel, old, new, what in MUTANTS:
path = os.path.join(wt, rel)
original = originals[rel]
text = original.decode('utf-8')
log_path = os.path.join(out, f'{mid}.log')
try:
with open(path, 'wb') as handle:
handle.write(text.replace(old, new, 1).encode('utf-8'))
rc, status = run_suite(wt, log_path)
finally:
with open(path, 'wb') as handle:
handle.write(original)
if status == 'timeout':
verdict = 'TIMEOUT (counted as failure)'
failures.append(mid)
elif rc == 0:
verdict = 'SURVIVED'
failures.append(mid)
else:
verdict = 'killed'
summary.append(f'{mid} {verdict} {what}')
if rel == RESOLVE and resolve_mismatch(wt):
summary.append(f'{RESOLVE} differs from the pinned sha256 after restoring {mid}')
failures.append('restore')
# The restore must leave the candidate byte-identical to the manifest.
after = manifest_mismatches(wt, entries)
if after:
summary.append(f'manifest mismatch after restore: {after}')
failures.append('restore')
if resolve_mismatch(wt):
summary.append(f'{RESOLVE} differs from the pinned sha256 after the last restore')
failures.append('restore')
left = worktree_leftovers(wt)
if left:
summary.append(f'leftover processes with cwd in worktree: {left}')
failures.append('leftovers')
with open(os.path.join(out, 'summary.txt'), 'w', encoding='utf-8') as handle:
handle.write('\n'.join(summary) + '\n')
print('\n'.join(summary))
if 'restore' in failures or 'leftovers' in failures:
return 2
return 1 if failures else 0
if __name__ == '__main__':
sys.exit(main())