Skip to content

Commit 6936e97

Browse files
Pigbibicodex
andcommitted
feat: add health-driven automation decisions
Co-Authored-By: Codex <noreply@openai.com>
1 parent ea5ca89 commit 6936e97

9 files changed

Lines changed: 466 additions & 6 deletions

docs/ai_autonomy_architecture.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,15 @@ AIAuditBridge 是 QuantStrategyLab 的 AI 审计控制面,负责:
337337
- 健康驱动的执行降级策略;
338338
- repo 级别自治阈值。
339339

340+
首批落地边界:
341+
342+
- `/v1/ai/automation/control` 输出 `execution` 决策快照;
343+
- health degraded / runtime pause 时,执行模式降级到 `review_only`
344+
- quota low 时给出低成本模型建议,quota exhausted / blocked 时建议 defer;
345+
- 连续失败达到 repo 阈值时强制 human review;
346+
- repo 级 `max_autonomy` / `max_consecutive_failures` 从服务端受控 policy 文件读取,不信任被审仓库 checkout;
347+
- 该阶段只影响调度建议和控制面输出,不自动放宽 merge / deploy 权限。
348+
340349
### Phase 4:扩大自动修复,但只扩大低风险面
341350

342351
目标:提升无人值守覆盖率,但不放松安全门。

docs/async_service_deployment.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,26 @@ bash scripts/deploy_codex_audit_service.sh deploy
7171
```
7272

7373
The job directory should be owned by the service user and mode `0700`.
74+
The deploy script points `CODEX_AUDIT_SERVICE_EXECUTION_POLICY_PATH` to
75+
`${CODEX_AUDIT_SERVICE_JOB_DIR}/execution_policy.json`. If present, this
76+
service-owned file can cap repo autonomy without trusting the reviewed checkout:
77+
78+
```json
79+
{
80+
"default": {
81+
"max_autonomy": "auto_pr",
82+
"max_consecutive_failures": 3,
83+
"low_cost_model": "gpt-5.4-mini"
84+
},
85+
"repositories": {
86+
"QuantStrategyLab/CryptoLivePoolPipelines": {
87+
"max_autonomy": "review_only",
88+
"max_consecutive_failures": 2
89+
}
90+
}
91+
}
92+
```
93+
7494
The service should rely on an authenticated Codex CLI session and must not
7595
inject OpenAI/Codex API keys into the Codex subprocess.
7696
With `CODEX_AUDIT_SERVICE_CODEX_ACCOUNT_USAGE=1`, `/v1/ai/quota` includes a

scripts/deploy_codex_audit_service.sh

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ systemctl_environment_brief() {
6060
| sed 's/^Environment=//' \
6161
| tr ' ' '\n' \
6262
| sed -E "s/^[\"']//; s/[\"']$//" \
63-
| grep -E '^CODEX_AUDIT_SERVICE_(ALLOWED_|AUDIENCE=|HOST=|PORT=|JOB_DIR=|QUOTA_STORE=|CODEX_ACCOUNT_USAGE=|OPENAI_USAGE_WINDOW_DAYS=|ANTHROPIC_USAGE_WINDOW_DAYS=|SANDBOX=|MODEL=|REASONING_EFFORT=)' \
63+
| grep -E '^CODEX_AUDIT_SERVICE_(ALLOWED_|AUDIENCE=|HOST=|PORT=|JOB_DIR=|QUOTA_STORE=|EXECUTION_POLICY_PATH=|CODEX_ACCOUNT_USAGE=|OPENAI_USAGE_WINDOW_DAYS=|ANTHROPIC_USAGE_WINDOW_DAYS=|SANDBOX=|MODEL=|REASONING_EFFORT=)' \
6464
| mask_infra || true
6565
fi
6666
}
@@ -252,6 +252,7 @@ Environment=CODEX_AUDIT_SERVICE_ALLOWED_REPOSITORY_VISIBILITIES=${ALLOWED_REPOSI
252252
Environment=CODEX_AUDIT_SERVICE_ALLOWED_SOURCE_REPOSITORIES=${ALLOWED_SOURCE_REPOSITORIES}
253253
Environment=CODEX_AUDIT_SERVICE_JOB_DIR=${JOB_DIR}
254254
Environment=CODEX_AUDIT_SERVICE_QUOTA_STORE=${JOB_DIR}/quota.json
255+
Environment=CODEX_AUDIT_SERVICE_EXECUTION_POLICY_PATH=${JOB_DIR}/execution_policy.json
255256
Environment=CODEX_AUDIT_SERVICE_CODEX_ACCOUNT_USAGE=${CODEX_ACCOUNT_USAGE}
256257
Environment=CODEX_AUDIT_SERVICE_OPENAI_USAGE_WINDOW_DAYS=${OPENAI_USAGE_WINDOW_DAYS}
257258
Environment=CODEX_AUDIT_SERVICE_ANTHROPIC_USAGE_WINDOW_DAYS=${ANTHROPIC_USAGE_WINDOW_DAYS}
@@ -281,6 +282,7 @@ Environment="CODEX_AUDIT_SERVICE_ALLOWED_WORKFLOW_REFS=${ALLOWED_WORKFLOW_REFS}"
281282
Environment="CODEX_AUDIT_SERVICE_ALLOWED_REFS=${ALLOWED_REFS}"
282283
Environment="CODEX_AUDIT_SERVICE_ALLOWED_REPOSITORY_VISIBILITIES=${ALLOWED_REPOSITORY_VISIBILITIES}"
283284
Environment="CODEX_AUDIT_SERVICE_ALLOWED_SOURCE_REPOSITORIES=${ALLOWED_SOURCE_REPOSITORIES}"
285+
Environment="CODEX_AUDIT_SERVICE_EXECUTION_POLICY_PATH=${JOB_DIR}/execution_policy.json"
284286
EOF_DROPIN
285287
}
286288

service/ai_gateway_service.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
get_automation_run_ledger,
7373
suggest_control_action,
7474
)
75+
from service.automation_decision import decide_automation_execution, load_execution_policy
7576
from service.strategy_automation_registry import (
7677
apply_strategy_registry_guard,
7778
summarize_strategy_registry_context,
@@ -507,7 +508,7 @@ def _public_job_payload(job: dict[str, Any]) -> dict[str, object]:
507508
return payload
508509

509510

510-
def _automation_control_snapshot(repo: str) -> dict[str, Any]:
511+
def _automation_control_snapshot(repo: str, *, task_name: str = "", requested_mode: str = "review_and_fix") -> dict[str, Any]:
511512
try:
512513
org_health = read_org_health()
513514
except Exception:
@@ -516,7 +517,23 @@ def _automation_control_snapshot(repo: str) -> dict[str, Any]:
516517
quota_status = get_quota_manager().runtime_status(repo or "unknown")
517518
except Exception:
518519
quota_status = {"status": "unavailable"}
519-
return suggest_control_action(get_health_monitor().status, quota_status, org_health)
520+
control = suggest_control_action(get_health_monitor().status, quota_status, org_health)
521+
try:
522+
recent_runs = get_automation_run_ledger().snapshot(limit=20)["runs"]
523+
except Exception:
524+
recent_runs = []
525+
control["execution"] = decide_automation_execution(
526+
repo=repo or "unknown",
527+
task_name=task_name,
528+
requested_mode=requested_mode,
529+
control_action=str(control.get("action") or CONTROL_REVIEW_ONLY),
530+
service_health=control.get("service_health"),
531+
quota_status=quota_status,
532+
org_health_status=control.get("org_health_status"),
533+
recent_runs=recent_runs,
534+
policy=load_execution_policy(),
535+
)
536+
return control
520537

521538

522539
def _highest_changed_path_risk(changed_paths: list[str], policy: dict[str, Any]) -> str:
@@ -540,7 +557,7 @@ def _automation_triage_snapshot(
540557
changed_paths: list[str] | None = None,
541558
run_id: str = "",
542559
) -> dict[str, Any]:
543-
control = _automation_control_snapshot(repo)
560+
control = _automation_control_snapshot(repo, task_name=task)
544561
policy = load_autonomy_policy()
545562
normalized_paths = [
546563
normalized
@@ -648,7 +665,7 @@ def _automation_triage_snapshot(
648665
def _record_job_automation_run(job: dict[str, Any]) -> None:
649666
try:
650667
repo = str(job.get("source_repository") or job.get("repository") or "unknown")
651-
control = _automation_control_snapshot(repo)
668+
control = _automation_control_snapshot(repo, task_name=str(job.get("task") or ""), requested_mode=str(job.get("mode") or "review_and_fix"))
652669
get_automation_run_ledger().record(
653670
str(job.get("job_id") or ""),
654671
job_task_state(job),
@@ -1504,7 +1521,7 @@ def _handle_record_automation_run(self, claims: dict[str, Any], payload: dict[st
15041521
_validate_source_repo_org(claims, source_repo)
15051522
_assert_source_repository_owner_or_operator(claims, source_repo)
15061523
repo = source_repo or str(claims.get("repository") or "unknown")
1507-
control = _automation_control_snapshot(repo)
1524+
control = _automation_control_snapshot(repo, task_name=str(payload.get("task") or payload.get("task_name") or ""))
15081525
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
15091526
ledger = get_automation_run_ledger()
15101527
run_id = str(payload.get("run_id") or payload.get("job_id") or "")

service/automation_decision.py

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
"""Health-driven execution decisions for automation scheduling."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import os
7+
from pathlib import Path
8+
from typing import Any
9+
10+
from service.automation_run_ledger import CONTROL_ESCALATE, CONTROL_PAUSE_AUTO_FIX, CONTROL_REVIEW_ONLY
11+
from service.quota import recommend_model
12+
13+
EXECUTION_RUN = "run"
14+
EXECUTION_REVIEW_ONLY = "review_only"
15+
EXECUTION_DEFER = "defer"
16+
EXECUTION_HUMAN_REVIEW = "human_review"
17+
18+
MODE_REVIEW_AND_FIX = "review_and_fix"
19+
MODE_REVIEW_ONLY = "review_only"
20+
21+
AUTONOMY_MANUAL = "manual"
22+
AUTONOMY_REVIEW_ONLY = "review_only"
23+
AUTONOMY_AUTO_PR = "auto_pr"
24+
AUTONOMY_AUTO_MERGE = "auto_merge"
25+
AUTONOMY_ORDER = (AUTONOMY_MANUAL, AUTONOMY_REVIEW_ONLY, AUTONOMY_AUTO_PR, AUTONOMY_AUTO_MERGE)
26+
AUTONOMY_RANK = {level: index for index, level in enumerate(AUTONOMY_ORDER)}
27+
28+
DEFAULT_MAX_CONSECUTIVE_FAILURES = 3
29+
DEFAULT_LOW_COST_MODEL = "gpt-5.4-mini"
30+
EXECUTION_POLICY_PATH_ENV = "CODEX_AUDIT_SERVICE_EXECUTION_POLICY_PATH"
31+
QUOTA_STATUS_SEVERITY = {
32+
"ok": 0,
33+
"healthy": 0,
34+
"unknown": 1,
35+
"unavailable": 1,
36+
"low": 2,
37+
"constrained": 2,
38+
"exhausted": 3,
39+
"blocked": 3,
40+
}
41+
42+
43+
def _normalize_status(value: Any, default: str = "unknown") -> str:
44+
if isinstance(value, dict):
45+
value = value.get("status", default)
46+
return str(value or default).strip().lower()
47+
48+
49+
def _normalize_quota_status(value: Any, default: str = "unknown") -> str:
50+
statuses = [_normalize_status(value, "")]
51+
if isinstance(value, dict) and isinstance(value.get("quota"), dict):
52+
statuses.append(_normalize_status(value["quota"], ""))
53+
normalized = [status for status in statuses if status]
54+
if not normalized:
55+
return default
56+
return max(normalized, key=lambda status: QUOTA_STATUS_SEVERITY.get(status, 1))
57+
58+
59+
def _normalize_mode(value: str) -> str:
60+
return MODE_REVIEW_AND_FIX if str(value or "").strip() == MODE_REVIEW_AND_FIX else MODE_REVIEW_ONLY
61+
62+
63+
def _normalize_autonomy(value: Any, default: str = AUTONOMY_AUTO_PR) -> str:
64+
level = str(value or default).strip().lower()
65+
return level if level in AUTONOMY_RANK else default
66+
67+
68+
def _repo_from_run(run: dict[str, Any]) -> str:
69+
metadata = run.get("metadata") if isinstance(run.get("metadata"), dict) else {}
70+
return str(metadata.get("source_repository") or metadata.get("repository") or "")
71+
72+
73+
def load_execution_policy(path: Path | None = None) -> dict[str, Any]:
74+
"""Load service-owned execution policy for repo autonomy thresholds."""
75+
if path is None:
76+
configured = os.environ.get(EXECUTION_POLICY_PATH_ENV, "").strip()
77+
if not configured:
78+
return {}
79+
path = Path(configured).expanduser()
80+
try:
81+
payload = json.loads(path.read_text(encoding="utf-8"))
82+
except (OSError, json.JSONDecodeError):
83+
return {}
84+
return payload if isinstance(payload, dict) else {}
85+
86+
87+
def repo_execution_policy(repo: str, policy: dict[str, Any] | None = None) -> dict[str, Any]:
88+
"""Merge default and repo-specific execution policy without trusting repo checkouts."""
89+
raw = policy if isinstance(policy, dict) else {}
90+
defaults = raw.get("default") if isinstance(raw.get("default"), dict) else {}
91+
repositories = raw.get("repositories") if isinstance(raw.get("repositories"), dict) else {}
92+
override = repositories.get(repo) if isinstance(repositories.get(repo), dict) else {}
93+
return {**defaults, **override}
94+
95+
96+
def consecutive_failure_count(
97+
runs: list[dict[str, Any]],
98+
*,
99+
repo: str,
100+
task_name: str = "",
101+
) -> int:
102+
"""Count latest consecutive failed runs for one repo/task from newest-first runs."""
103+
count = 0
104+
for run in runs:
105+
if not isinstance(run, dict):
106+
continue
107+
if repo and _repo_from_run(run) != repo:
108+
continue
109+
if task_name and str(run.get("task_name") or "") != task_name:
110+
continue
111+
state = str(run.get("task_state") or "").strip().lower()
112+
if state == "failed":
113+
count += 1
114+
continue
115+
if state:
116+
break
117+
return count
118+
119+
120+
def decide_automation_execution(
121+
*,
122+
repo: str,
123+
task_name: str = "",
124+
requested_mode: str = MODE_REVIEW_AND_FIX,
125+
requested_provider: str = "auto",
126+
requested_model: str = "",
127+
control_action: str = CONTROL_REVIEW_ONLY,
128+
service_health: Any = "",
129+
quota_status: Any = "",
130+
org_health_status: Any = "",
131+
recent_runs: list[dict[str, Any]] | None = None,
132+
policy: dict[str, Any] | None = None,
133+
) -> dict[str, Any]:
134+
"""Produce a safe execution decision from health, quota, failures, and repo policy."""
135+
repo_policy = repo_execution_policy(repo, policy)
136+
max_autonomy = _normalize_autonomy(repo_policy.get("max_autonomy"), AUTONOMY_AUTO_PR)
137+
max_failures = int(repo_policy.get("max_consecutive_failures") or DEFAULT_MAX_CONSECUTIVE_FAILURES)
138+
low_cost_model = str(repo_policy.get("low_cost_model") or DEFAULT_LOW_COST_MODEL)
139+
quota_low_behavior = str(repo_policy.get("quota_low_behavior") or "low_cost_model").strip().lower()
140+
141+
effective_mode = _normalize_mode(requested_mode)
142+
effective_provider = str(requested_provider or "auto").strip().lower() or "auto"
143+
effective_model = str(requested_model or "").strip()
144+
action = EXECUTION_RUN
145+
reasons: list[str] = []
146+
human_review_required = False
147+
defer = False
148+
149+
service = _normalize_status(service_health)
150+
quota = _normalize_quota_status(quota_status)
151+
org_health = _normalize_status(org_health_status)
152+
failures = consecutive_failure_count(recent_runs or [], repo=repo, task_name=task_name)
153+
154+
if AUTONOMY_RANK[max_autonomy] <= AUTONOMY_RANK[AUTONOMY_REVIEW_ONLY]:
155+
effective_mode = MODE_REVIEW_ONLY
156+
human_review_required = True
157+
reasons.append(f"repo max autonomy is {max_autonomy}")
158+
if max_autonomy == AUTONOMY_MANUAL:
159+
action = EXECUTION_HUMAN_REVIEW
160+
161+
if failures >= max_failures:
162+
action = EXECUTION_HUMAN_REVIEW
163+
effective_mode = MODE_REVIEW_ONLY
164+
human_review_required = True
165+
reasons.append(f"consecutive failures reached {failures}/{max_failures}")
166+
167+
if control_action in {CONTROL_REVIEW_ONLY, CONTROL_PAUSE_AUTO_FIX, CONTROL_ESCALATE}:
168+
effective_mode = MODE_REVIEW_ONLY
169+
human_review_required = True
170+
reasons.append(f"runtime control action is {control_action}")
171+
if control_action == CONTROL_ESCALATE:
172+
action = EXECUTION_HUMAN_REVIEW
173+
174+
if service == "degraded" or org_health == "degraded":
175+
effective_mode = MODE_REVIEW_ONLY
176+
human_review_required = True
177+
reasons.append("health degraded; forcing review_only")
178+
if service == "unhealthy" or org_health == "unhealthy":
179+
action = EXECUTION_HUMAN_REVIEW
180+
effective_mode = MODE_REVIEW_ONLY
181+
human_review_required = True
182+
reasons.append("health unhealthy; forcing human review")
183+
184+
if quota in {"low", "constrained"}:
185+
effective_model = effective_model or low_cost_model or recommend_model(0.0)
186+
if quota_low_behavior == "defer":
187+
action = EXECUTION_DEFER
188+
defer = True
189+
effective_mode = MODE_REVIEW_ONLY
190+
human_review_required = True
191+
reasons.append(f"quota status is {quota}; deferring automation")
192+
else:
193+
reasons.append(f"quota status is {quota}; recommending low-cost model")
194+
elif quota in {"exhausted", "blocked"}:
195+
action = EXECUTION_DEFER
196+
defer = True
197+
effective_mode = MODE_REVIEW_ONLY
198+
human_review_required = True
199+
reasons.append(f"quota status is {quota}; deferring automation")
200+
201+
return {
202+
"action": action,
203+
"repo": repo,
204+
"task_name": task_name,
205+
"requested_mode": _normalize_mode(requested_mode),
206+
"effective_mode": effective_mode,
207+
"requested_provider": requested_provider,
208+
"effective_provider": effective_provider,
209+
"requested_model": requested_model,
210+
"effective_model": effective_model,
211+
"max_autonomy": max_autonomy,
212+
"consecutive_failures": failures,
213+
"max_consecutive_failures": max_failures,
214+
"human_review_required": human_review_required,
215+
"auto_fix_allowed": action == EXECUTION_RUN and effective_mode == MODE_REVIEW_AND_FIX and not human_review_required,
216+
"defer": defer,
217+
"reasons": reasons or ["execution allowed"],
218+
}

0 commit comments

Comments
 (0)