Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,4 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Native helper regression tests
run: npx vitest run tests/scripts/hide-readiness.test.ts tests/scripts/macos-ops.test.ts --maxWorkers=1
run: npm test -- tests/scripts/hide-readiness.test.ts tests/scripts/macos-ops.test.ts --maxWorkers=1
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# 에이전트 개발 규칙

## 로컬 메모리 및 브라우저 보호

- 테스트는 `npm test`, `npm run test:coverage`, `npm run mcp:smoke`, `npm run cli:smoke`의 자원 보호 실행기를 사용합니다. 직접 Vitest나 여러 검사 실행기로 공용 잠금을 우회하지 않습니다. Mac 기본 워커는 1개이며 환경 변수로 상한을 올리지 않습니다.
- 시작 전·진행 중 메모리 여유, CPU, Chrome 본체 수를 관측합니다. 보호 실행기의 자원 거절은 실패이며 검사를 통과한 것으로 처리하지 않습니다. 압박 상태에서 즉시 재시작하지 말고 원인을 확인합니다.
- 이 Mac의 전체 회귀·커버리지·self-hosted CI·fresh 운영 조회·브라우저 실험을 겹치지 않습니다. 기존 CI 결과를 활용하고 필요한 최소 검증을 순서대로 실행합니다.
- 브라우저 실험은 소유 세션 1개만 사용하며 성공·실패·중단 모두 finally에서 탭과 세션을 회수합니다. OY 운영 브라우저 외에 추가 본체를 계속 생성하지 않습니다. 종료 후 세션 목록과 프로세스 수를 확인합니다.
- 공용 Codex `cua_node`나 사용자 브라우저를 임의 종료하지 않습니다. 공유 보조 프로세스가 누적되면 신규 브라우저 작업을 멈추고 상태를 보고합니다. 단일 RSS나 swap 값만으로 누수로 단정하지 않습니다.

## 최우선: 공개 MCP 도구는 10개 고정

공개 `tools/list`의 도구는 정확히 **10개**이며 **절대 늘리지 않습니다**. 새 서비스와 기능은 기존 도구의 `service`/`operation`/`requestType`에 통합합니다. 기존 이름은 deprecated 호출 별칭으로만 보존하며 공개 목록에는 추가하지 않습니다. 일반 MCP와 PlayMCP에 동일하게 적용합니다. [공개 도구 계약](docs/mcp-tools.md)과 `tests/core/compact`, `tests/app/compact-contract.test.ts` 검사를 반드시 유지합니다.
Expand Down
12 changes: 12 additions & 0 deletions docs/superpowers/plans/2026-10-27-resource-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# 로컬 자원 보호 및 중계 복귀 계획

사용자 승인 범위: 메모리와 브라우저 과다 실행 방지, 로컬 CI 및 OY 감시 복귀.

1. 테스트·커버리지·CLI/MCP smoke를 공용 파일 잠금으로 직렬 실행한다. Mac 기본 워커는 1개, 다른 환경은 2개로 제한한다.
2. 시작 전과 실행 중 메모리 여유, CPU 합계, Chrome 본체 수를 관측한다. 시작 시 메모리 20% 미만, CPU 85% 초과, Chrome 본체 2개 초과면 실행을 거절한다. 실행 중 메모리 12% 미만 또는 Chrome 본체 2개 초과는 3회, CPU 90% 초과는 15회 연속 관측 시 소유 테스트 그룹만 종료한다. 2초 간격으로 관측하며 실패를 정상으로 처리하지 않는다.
3. 상태 수집 실패도 검사 실패로 처리한다. 사용자 브라우저, 운영 중계, 공용 Codex 프로세스는 종료하지 않는다. 새 종속성은 추가하지 않는다.
4. 저부하 Python 회귀로 잠금 충돌, 관측 실패, 임계값, 소유 자식 종료를 먼저 검증한다. 기존 Vitest 전체 회귀는 1워커로 한 번만 실행한다.
5. OY의 기존 단일 소유권·추가 페이지 종료·1GB RSS/30분/200요청 교체를 유지한다. CI 실행기, OY 중계, ready 확인, 감시·숨김 순으로 복귀한다. 안전한 상태와 실제 fresh 재고를 순차 검증한다.
6. AGENTS.md와 운영 문서에 브라우저 실험 1세션, finally 회수, 부하 중 작업 중단, 운영 검증과 테스트 직렬화를 명시한다.

플랫폼 공용 cua_node 누적은 프로젝트에서 임의 회수하지 않는다. 이 변경은 그 누수를 해결했다고 주장하지 않는다. 기존 미커밋 웹 실험은 별도로 보존한다.
11 changes: 6 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@
"build": "tsc && npx tsx scripts/ops/generate-openapi.ts",
"build:openapi": "npx tsx scripts/ops/generate-openapi.ts",
"build:discovery": "npx tsx scripts/ops/generate-discovery.ts",
"cli:smoke": "npm run build && npx tsx scripts/ops/cli-smoke.ts",
"mcp:smoke": "npx tsx scripts/ops/mcp-smoke.ts",
"cli:smoke": "python3 scripts/ops/resource-guard.py -- sh -c 'npm run build && node --import tsx scripts/ops/cli-smoke.ts'",
"mcp:smoke": "python3 scripts/ops/resource-guard.py -- node --import tsx scripts/ops/mcp-smoke.ts",
"ops:resources": "python3 scripts/ops/resource-guard.py --status",
"update:workers-chart": "npx tsx scripts/ops/update-workers-invocations-chart.ts",
"prepack": "npm run build",
"prepublishOnly": "npm run check && npm run build",
Expand All @@ -38,9 +39,9 @@
"format": "prettier .github/**/*.md .github/**/*.yml README.md SECURITY.md AGENTS.md CONTRIBUTING.md package.json tsconfig.json .prettierrc.json .mcp.json eslint.config.ts --write",
"format:check": "prettier .github/**/*.md .github/**/*.yml README.md SECURITY.md AGENTS.md CONTRIBUTING.md package.json tsconfig.json .prettierrc.json .mcp.json eslint.config.ts --check",
"cf-typegen": "wrangler types",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test": "python3 scripts/ops/resource-guard.py -- node node_modules/vitest/vitest.mjs run",
"test:watch": "python3 scripts/ops/resource-guard.py -- node node_modules/vitest/vitest.mjs",
"test:coverage": "python3 scripts/ops/resource-guard.py -- node node_modules/vitest/vitest.mjs run --coverage",
"check": "npm run format:check && npm run lint && npm run lint:biome && npm run typecheck && npm run check:source-lines && npm test",
"lint:biome": "biome lint"
},
Expand Down
8 changes: 8 additions & 0 deletions scripts/ops/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@

운영 스크립트는 `npm run` 명령에서 참조되며, 입력과 실패 메시지는 자동화 로그만으로 원인 추적이 가능해야 합니다.

## 로컬 자원 보호

`npm run ops:resources`로 메모리 여유·전체 CPU 비율·Chrome 본체 수를 확인합니다. 테스트·커버리지·CLI/MCP smoke는 Python 3 기반 `resource-guard.py`로 같은 사용자 계정에서 한 번에 하나만 실행합니다. Mac 워커 상한은 1개, 그 외 Unix 환경은 2개입니다. Python 3와 Unix 프로세스 그룹·파일 잠금이 필요합니다.

시작 시 메모리 여유 20% 미만, CPU 85% 초과 또는 Chrome 본체 2개 초과면 exit 75로 거절합니다. 실행 중 2초 간격으로 확인하며 메모리 12% 미만·브라우저 2개 초과가 3회 연속이거나 CPU 90% 초과가 15회 연속이면 소유 검사 그룹만 종료합니다. 관측 실패도 exit 75입니다. 로그에는 수치와 사유만 남깁니다. 기존 검사 결과나 품질 기준을 생략하지 않습니다.

OY는 기존 단일 소유권 잠금, 페이지 1개 재사용, 팝업 회수, 1GB RSS·30분·200요청 세션 교체를 유지합니다. 브라우저 실험은 별도 세션 1개만 허용하고 finally에서 종료합니다. 공용 Codex 보조 프로세스 누적은 이 실행기가 해결하거나 강제 종료하지 않습니다. 메모리 압박 중 검사와 운영 조회를 중첩하거나 재시작하지 않습니다.

## Workers 호출량 차트

새 데이터를 가져와 `scripts/ops/README.md`와 `assets/analytics/` 산출물을 갱신하려면 다음 환경 변수가 필요합니다.
Expand Down
179 changes: 179 additions & 0 deletions scripts/ops/resource-guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""검사 중복과 자원 고갈을 막고 자신이 시작한 프로세스 그룹만 회수합니다."""
from contextlib import contextmanager
import fcntl
import json
import math
import os
from pathlib import Path
import re
import signal
import stat
import subprocess
import sys
import time


def parse_memory(text):
match = re.search(r'System-wide memory free percentage:\s*(\d+)%', text)
if not match or not 0 <= int(match[1]) <= 100:
raise ValueError('Memory observation unavailable')
return int(match[1])


def parse_processes(text, cores):
cpu, browsers, count = 0.0, {}, 0
for line in text.splitlines():
row = line.strip().split(None, 3)
if len(row) != 4:
raise ValueError('Process observation unavailable')
value = float(row[2])
if not math.isfinite(value) or value < 0:
raise ValueError('CPU observation unavailable')
cpu += value
# 헬퍼·렌더러 수를 Chrome 본체 수로 잘못 계산하지 않습니다.
name = row[3].rsplit('/', 1)[-1]
if name in ['Google Chrome', 'Google Chrome for Testing', 'Google Chrome Canary',
'Chromium', 'chrome', 'headless_shell', 'chrome-headless-shell']:
browsers[int(row[0])] = int(row[1])
count += 1
if not count:
raise ValueError('Process observation unavailable')
# Linux 자식 렌더러도 같은 실행파일 이름을 쓰므로 브라우저 부모가 있는 자식은 제외합니다.
return round(cpu / max(1, cores), 1), sum(parent not in browsers for parent in browsers.values())


def snapshot():
if sys.platform == 'darwin':
free = parse_memory(subprocess.check_output(['memory_pressure', '-Q'], text=True, timeout=3))
else:
values = dict(re.findall(r'^(MemTotal|MemAvailable):\s+(\d+)', Path('/proc/meminfo').read_text(), re.M))
free = round(int(values['MemAvailable']) / int(values['MemTotal']) * 100, 1)
cpu, browsers = parse_processes(subprocess.check_output(
['ps', '-axo', 'pid=,ppid=,%cpu=,comm='], text=True, timeout=3), os.cpu_count() or 1)
return {'freePercent': free, 'cpuPercent': cpu, 'browsers': browsers}


def conditions(value, starting=False):
return {'memory': value['freePercent'] < (20 if starting else 12),
'browsers': value['browsers'] > 2,
'cpu': value['cpuPercent'] > (85 if starting else 90)}


def reason(value, starting=False):
return next((key for key, failed in conditions(value, starting).items() if failed), None)


def emit(event, **fields):
print(json.dumps({'resourceGuard': event, **fields}), file=sys.stderr, flush=True)


@contextmanager
def lease(path):
fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
try:
info = os.fstat(fd)
if info.st_uid != os.getuid() or not stat.S_ISREG(info.st_mode) or info.st_mode & 0o077:
raise ValueError('Unsafe lock file')
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
yield
finally:
os.close(fd)


def stop(child):
# start_new_session으로 생성한 그룹만 대상으로 하며 공용 브라우저는 건드리지 않습니다.
if child.poll() is None:
try:
if os.getpgid(child.pid) != child.pid:
raise ValueError('Owned process group mismatch')
except ProcessLookupError:
pass
def send(sig):
try:
os.killpg(child.pid, sig)
except ProcessLookupError:
pass
except PermissionError:
# macOS는 좀비만 남은 그룹에도 EPERM을 반환할 수 있습니다.
rows = subprocess.check_output(['ps', '-axo', 'pgid=,stat='], text=True, timeout=3)
if any(row.split()[0] == str(child.pid) and not row.split()[1].startswith('Z')
for row in rows.splitlines() if len(row.split()) == 2):
raise
send(signal.SIGTERM)
try:
child.wait(timeout=5)
except subprocess.TimeoutExpired:
pass
send(signal.SIGKILL)
child.wait(timeout=3)


def watch(child, probe=snapshot, interval=2):
streaks, ticks = {'memory': 0, 'browsers': 0, 'cpu': 0}, 0
try:
while child.poll() is None:
try:
value = probe()
except Exception:
emit('observation-failed')
stop(child)
return 75
for key, failed in conditions(value).items():
streaks[key] = streaks[key] + 1 if failed else 0
failure = next((key for key, streak in streaks.items()
if streak >= (15 if key == 'cpu' else 3)), None)
if ticks % 15 == 0:
emit('sample', **value)
if failure:
emit('stopped', reason=failure, **value)
stop(child)
return 75
ticks += 1
time.sleep(interval)
code = child.returncode if child.returncode >= 0 else 128 - child.returncode
stop(child)
return code
except BaseException:
stop(child)
raise


def main(args):
if args == ['--status']:
print(json.dumps(snapshot()))
return 0
if not args or args[0] != '--' or len(args) < 2:
emit('usage', usage='resource-guard.py --status | -- command [args]')
return 64
# Actions와 GUI 셸의 TMPDIR이 달라도 같은 사용자 잠금을 공유합니다.
lock = Path('/tmp') / ('daiso-resource-guard-%s.lock' % os.getuid())
try:
with lease(lock):
value = snapshot()
failure = reason(value, True)
if failure:
emit('refused', reason=failure, **value)
return 75
# 환경 지정으로 병렬 수가 상한을 넘지 않도록 제한합니다.
env = os.environ.copy()
cap = 1 if sys.platform == 'darwin' else 2
requested = int(env.get('VITEST_MAX_WORKERS', cap))
env['VITEST_MAX_WORKERS'] = str(max(1, min(requested, cap)))
emit('started', workers=int(env['VITEST_MAX_WORKERS']), **value)
child = subprocess.Popen(args[1:], env=env, start_new_session=True)
return watch(child)
except BlockingIOError:
emit('refused', reason='another-check-running')
return 75
except (ValueError, OSError, subprocess.SubprocessError, KeyError):
emit('observation-or-launch-failed')
return 75


if __name__ == '__main__':
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt()))
try:
sys.exit(main(sys.argv[1:]))
except KeyboardInterrupt:
sys.exit(130)
Loading
Loading