乐于分享
好东西不私藏

AI 接管 CI :从发现到修复,一个 Flutter 移动端项目的自动巡检实践

AI 接管 CI :从发现到修复,一个 Flutter 移动端项目的自动巡检实践

TL;DR:在一个 Flutter(iOS / Android)+ API Server 项目里,让 AI 驱动 CI 自己发现 bug、写测试、修代码、提 PR,全套编排脚本可跑。核心是循环工程方法论,「制造者修、独立验证者把关、最终合并永远等人」,配禁改硬门禁、预算熔断、kill switch。落地从只读 Daily Triage 起步,到 CI Sweeper 与 Flutter 模拟器 E2E 巡检(AI探索+固化防回归+视觉初筛)。多机型拆三层:探索只在代表机型、功能回归跑全矩阵、视觉检查模拟器打底加少量真机补差。最大成本不是 token 而是按分钟计费的 macOS runner,开 iOS 矩阵前先上自托管 Mac,预算紧可换 DeepSeek 再省九成。AI 驱动 CI 不替代人,只把人留在判断和合并上。

你可能已经用 GitHub Actions 跑过 CI (软件开发持续集成,每次代码变动跑检查的流水线),也许还用 Dependabot 更新过依赖。

但当 Agent (能自己感知、决策、动手的智能体,本文里就是在 CI 里干活的那个 AI)真正住进你的 CI 管道里,自己发现 bug、自己开 issue(GitHub 工单)、自己写测试、自己修代码、自己提 PR(合并请求,review 通过才并入主干),开发这件事的体感会彻底改变。

这篇文章用一个移动端 Flutter App(iOS / Android)+ API Server 的项目为例,完整讲一遍:AI 驱动的 CI 是什么、怎么搭、七种生产模式怎么选、以及那些不踩就学不会的坑。

全套编排脚本都给出可跑的完整代码。

先讲个故事:一个 bug 的全自动旅程

凌晨 2:30,GitHub Actions 定时任务触发。一个 Agent(叫它「巡检员」)醒来,扫了眼 CI,全绿。它又查了下模拟器里的 App,发现 iOS 登录后偶发白屏约 3 秒。

它判断这是真问题不是 flaky(偶发失败、同代码一会儿过一会儿挂),截图、翻日志、确认 API 健康,定位到客户端一个状态管理 bug。然后登记 issue、写一个能稳定复现白屏的回归测试、开分支做最小修复(改了一行初始化顺序)、跑测试通过。

接着把修复交给一个独立的验证者 Agent 复核:验证者在全新会话里亲自重跑测试、过了,才放行到「等人 review」,开出一个 [AI-Sweep] PR。

早上 9:00 你打开 GitHub,一行改动、逻辑合理、测试也过,你 approve 合并到主干。

整个过程你只做了最后一步:判断和合并。这就是 AI 驱动的 CI,它不是「自动帮你跑测试」,而是自动发现、自动修复、自动验证

什么是 AI 驱动的 CI?

传统 CI 是管道,从左到右走一遍就结束,只回答「能不能合」,挂了只亮红灯。

AI 驱动的 CI 是循环,Agent 按节奏反复运转,每次感知、判断、行动,然后等下一轮:

几位大佬把这套做法叫做循环工程(Loop Engineering)。你定义目的,让 AI Agent 带上子智能体和外部记忆反复迭代,直到目标完成,或 Agent 自己决定把控制权交还给人。

关于循环工程,详读:Loop Engineering:别再写提示词,去写循环

能力传统 CIAI 驱动的 CI
跑 lint / test / build
CI 挂了告诉你✅(红灯)✅(还分析原因)
CI 挂了自己修✅(改代码 + 跑测试 + 提 PR)
主动发现新 bug✅(模拟器中探索)
PR 解冲突、补 changelog
分诊 issue、生成日报
依赖升级 + 跑兼容测试

在传统 CI 之上加一层认知引擎。

工具链:三块积木

搭一套 AI 驱动的 CI,你需要理解三块积木。积木一负责调度,积木二是主引擎,积木三是让前两块共用一套规范的配置。

积木一:GitHub Actions,调度骨架

它按事件或定时在云端runner(GitHub 提供的临时虚拟机)上跑脚本,决定 Agent 何时醒来。

事件触发与定时触发:

on:
pull_request: { types: [opened, synchronize] }
schedule:
- cron: '*/30 * * * *' # 每 30 分钟

这是.github/workflows/下 workflow 文件的on:字段。用过 Actions 的话这部分不变,AI 只是接在后面。

积木二:Claude Code Action,认知引擎

Anthropic 官方维护的 GitHub Action。放进 workflow,它就在 runner 上启动一个 Claude Code运行,读代码、分析失败、改文件、用gh操作 issue 和 PR。

安装:终端运行claude进入 Claude Code,执行/install-github-app,按引导装好 GitHub App、配密钥。之后这样用:

- uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
prompt: 扫描仓库状态,生成今日健康报告,只读不动手。
claude_args: |
--max-turns 15
--model claude-sonnet-4-6
--allowedTools "Read,Write,Bash(gh run:*),Bash(gh pr:*)"

--max-turns限制最多来回几轮(防无限折腾),--model选模型(默认 Sonnet,验证者换 Opus,原因见第三步),--allowedTools是工具白名单。这是最重要的护栏之一,不写等于给 Agent 开全部权限。

Claude Code Action 适合「一个会话干完一件事」的简单循环。要「一个 Agent 改、另一个 Agent 验」时,本文改用 Claude Code 的命令行配编排脚本驱动(第三步),控制力更强。两种入口可混用。

它还会自动读仓库根目录的CLAUDE.md当铁律遵守。

必知的坑:Agent 用默认GITHUB_TOKEN开的 PR 不会触发新 workflow run(GitHub 防递归)。这对「自动修 CI」很致命:PR 的 CI 不自己跑就没人验证。解法是用独立的 bot PAT 或 GitHub App 令牌提交,它们算独立身份,能正常触发 CI。

积木三:仓库级配置 CLAUDE.md,规章制度

放仓库根目录,Claude Code 每次自动读取遵守。一次写好、处处生效,禁改清单是核心:

# CLAUDE.md(app-flutter)## 项目概述
Flutter 应用(iOS/Android),后端为独立 API Server 仓库。
## Agent 行为规范- 自动 PR 标题前缀 [AI-Sweep];描述含问题分析、修复方案、验证方式
- 不确定时报告而非猜测;每次只修一个问题,做最小修复
## 禁止修改的路径(铁律)- .env* / secrets/ / credentials/ / auth/ # 密钥与认证
- android/app/google-services.json # Firebase 配置
- ios/Runner/GoogleService-Info.plist / lib/firebase_options
*
.dart
- migrations/ # 数据库迁移,不可逆
- pubspec.yaml / .github/workflows/ # 依赖与护栏自身

改它要走 PR review,因为一改 Agent 行为立刻变。具体的「怎么修」「怎么验」写成技能(.loop/skills/下的 SKILL.md),第三步给全文。

七种生产模式:你的 CI 需要哪些?

AI 驱动的 CI 有七种模式,每一种就是「定时运行 + 技能 / 提示 + 护栏」。

模式说明频率风险月成本
🔴 CI Sweeper扫 CI 失败,自动修15-30min$200-400
🔴 PR Babysitter盯 PR 直到可合并每 PR$300-500
🟡 Dependency Sweeper扫依赖升级和安全告警6h-1d$50-100
🟢 Daily Triage每天扫仓库健康度每天$10-20
🟢 Post-Merge Cleanup合并后收尾每次合并$30-50
🟢 Drafter自动生成 changelog每天$5-10
🟢 Issue Triage给新 issue 打标签每 issue$15-30

红色三个高频或高成本、失控就烧钱,必须配齐护栏;绿色四个低风险、入门首选。

从绿色开始,跑稳了再上红色,典型顺序:Daily Triage → Issue Triage → Post-Merge → Drafter → Dependency Sweeper → CI Sweeper → PR Babysitter。

实战项目:Flutter 移动端 App + API Server

接下来用一个项目场景讲落地过程。假设你有:

github.com/your-org/
├── app-flutter/ # Flutter 前端(iOS / Android)
└── api-server/ # 后端 API

前端是 Flutter 跨平台应用(iOS/Android),后端是独立的 API Server。两个仓库各自有测试,但你想要更激进的自动化:

  1. API Server 的 CI 挂了,AI 自己修(CI Sweeper,api-server)
  2. Flutter App 在模拟器里跑时出了问题,AI 自己发现并修复(E2E Sweep,app-flutter)
  3. PR 开了之后,AI 帮忙推到可合并状态(PR Babysitter,两仓)
  4. 每天早上收到一份仓库健康报告(Daily Triage,两仓)
  5. 合并后清理(Post-Merge,app-flutter)

第一步:搭传统 CI 管道

AI 进来前先确保传统 CI 能跑,这是基础。

# app-flutter/.github/workflows/flutter-test.yml
name: Flutter CI
on: { pull_request: {}, push: { branches: [main] } }
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: subosito/flutter-action@v2
with: { flutter-version: '3.x', channel: stable }
- run: flutter pub get
- run: dart analyze
- run: flutter test --coverage
- run: flutter build apk --debug # 至少确保能编译

API Server 那份同理:npm cinpm run lintnpm test两份「守门员」CI 绿了,AI 循环才有干净的失败信号可接。

配置 Flutter 的 integration_test

integration_test是 Flutter 官方集成测试包,跑在 App 进程内、能精确定位任何 Widget。它既是 Agent 在模拟器里操作的基础,也是 Agent 发现 bug 后「固化证据」的载体。

# pubspec.yaml
dev_dependencies:
flutter_test: { sdk: flutter }
integration_test: { sdk: flutter }

目录里人写的和 Agent 生成的分开放:integration_test/login_test.dart(人写)、integration_test/agent/(Agent 生成的回归)。

一个标准测试:

// integration_test/login_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:your_app/main.dart' as app;

void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('登录流程', (tester) async {
app.main();
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const Key('email_field')), 'test@example.com');
await tester.tap(find.byKey(const Key('login_button')));
await tester.pumpAndSettle();
expect(find.text('Welcome'), findsOneWidget);
});
}

pumpAndSettle()等动画和异步加载结束(比盲等 sleep 可靠);find.byKey靠开发者挂的唯一Key精确定位,比按坐标稳。它能精确操作并断言,但前提是预先写好脚本、知道点哪个控件。

同一份测试在两端都能跑,只是指定不同的设备:

flutter test integration_test/ -d emulator-5554   # Android(模拟器)
flutter test integration_test/ -d "iPhone 16" # iOS(模拟器)

Agent 发现 bug 后会固化成这样一个测试文件,即使 Agent 不在,传统 CI 也能反复验证这个 bug 不复发。

第二步:从只读的 Daily Triage 开始

永远从只读开始,这是循环工程第一条铁律。Daily Triage 每天扫一遍生成报告、不碰代码,零风险,先验证 Agent 判断准不准。它单会话、只读,直接用积木二的 Action 就够。

# app-flutter/.github/workflows/daily-triage.yml
name: Daily Triage
on:
schedule: [{ cron: '0 9 * * *' }]
workflow_dispatch: {}
permissions: { contents: read, issues: write, pull-requests: write } # 给不了改代码的能力
jobs:
triage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
prompt: |
扫描 ${{ github.repository }},生成今日健康报告:
CI 成功率/红的 workflow;open PR 与超 3 天没动的;新增/无标签 issue;
flutter analyze 新 warning;curl staging 健康。
分类输出 🚨 需要行动 / 👀 需要观察 / 🔇 噪音,写到 .loop/state/triage-日期.md

claude_args: |
--max-turns 15
--allowedTools "Read,Write,Bash(gh run:*),Bash(gh issue:*),Bash(gh pr:*),Bash(curl:*)"

permissions没给contents: write、白名单没有Edit/git,它就改不了代码。连看一两周报告,确认它看问题准了,再让它动手改代码。

第三步:CI Sweeper(第一次让 AI 改代码)

后端不需要模拟器、链路简单,适合作为第一次让 AI 改代码的练手。一旦 AI 改代码,必须引入循环工程里复利最高的结构:制造者与验证者分离。

同一个 Agent 既修又验,会因确认偏差倾向于相信自己改对了:修代码时的推理链还残留在上下文里,验证就太宽容,这叫「验证者剧场」。

解法是拆成两个独立子智能体:制造者(maker)只管修;验证者(checker)独立会话、独立上下文、默认拒绝、亲自跑测试

.loop/目录全貌

.loop/
├── loop.config.json # 单一事实来源:模型/预算/禁改
├── run.mjs # CI Sweeper 主循环
├── e2e-run.mjs # E2E Sweep 主循环(第四步)
├── orchestrator.mjs # 截图/日志/健康(第四步)
├── loop/
│ ├── claude.mjs # 起独立会话
│ ├── budget.mjs # 运行账本 + 预算熔断
│ ├── fix.mjs # maker→verifier→PR(两个循环共用)
│ └── issues.mjs # 去重建 issue(第四步)
├── skills/{sweep-triage,sweep-implementer,sweep-verifier,e2e-explore,e2e-visual-triage}/SKILL.md
└── state/{run-log.jsonl,attempts.json}

配置:单一事实来源

// .loop/loop.config.json
{
"model_mapping": {
"triage": "claude-haiku-4-5", "maker": "claude-sonnet-4-6",
"verifier": "claude-opus-4-8", "explorer": "claude-sonnet-4-6"
},
"budget": { "daily_token_cap": 1000000, "degrade_at": 0.8, "max_attempts_per_item": 3 },
"guardrails": { "denylist": ["**/.env*", "**/secrets/**", "**/auth/**", "**/migrations/**"] },
"api_server": { "staging_url": "https://staging-api.yourapp.com" }
}

模型按角色给:分诊用便宜的 Haiku,制造用 Sonnet,验证用最强的 Opus。制造者写错可回收,验证者放行坏代码进了主干不可回收,钱花在验证这一路才划算。

三份技能

# .loop/skills/sweep-triage/SKILL.md
---
name: sweep-triage
description: Triage CI failures into regression/flaky/infra. Output JSON only.
---
读最近 CI 失败,每条三选一:regression / flaky / infra。只输出 JSON,不动代码。
存疑放 watch 或 noise,绝不放 high。
输出:{ "high":[{ "id":"", "title":"", "class":"regression" }], "watch":[], "noise":[] }
# .loop/skills/sweep-implementer/SKILL.md
---
name: sweep-implementer
description: Implement the smallest credible fix for one triaged item.
---
你是制造者,只修给定那一项,做最小可信改动。
绝不碰禁改清单路径(.env/secrets/auth/migrations…),碰到就停下交人。
不塞无关重构。自己跑测试报通过/失败,但你说「通过」不算数,以验证者独立复跑为准。
# .loop/skills/sweep-verifier/SKILL.md
---
name: sweep-verifier
description: Independent checker. Default REJECT until proven. Run tests yourself.
---
你是独立验证者,独立会话。Default stance: REJECT until proven otherwise.
五项全过才 APPROVE:
1 Scope:只动相关文件,没碰禁改路径 2 Intent:真解决,非绕过 3 Tests:你亲自跑了,附输出
4 No cheating:没禁用测试/跳断言/降门槛 5 Risk:中风险以上即使过也建议人审
只输出 { "decision": "APPROVE|REJECT|ESCALATE_HUMAN", "evidence": "" }
跑不了测试或无法判定 → ESCALATE_HUMAN,绝不「不确定就放行」。

「默认拒绝」是举证责任倒置:放行坏代码进主线,代价远大于多退几个改动重做。

起独立会话:claude.mjs

// .loop/loop/claude.mjs:起独立 headless 会话,model 按角色注入
import { execFileSync } from 'node:child_process';

export function runClaude({ prompt, system, allowedTools = [], maxTurns = 20, cwd = process.cwd(), model = null, mcpConfig = '' }) {
const args = ['-p', prompt, '--output-format', 'json', '--max-turns', String(maxTurns)];
if (model) args.push('--model', model);
if (system) args.push('--append-system-prompt', system);
if (mcpConfig) args.push('--mcp-config', mcpConfig);
for (const t of allowedTools) args.push('--allowedTools', t);
try {
const r = JSON.parse(execFileSync('claude', args, { cwd, encoding: 'utf8', maxBuffer: 64 << 20 }));
const u = r.usage || {};
let data = null; try { data = JSON.parse(r.result || ''); } catch {}
return { tokens: (u.input_tokens || 0) + (u.output_tokens || 0), text: r.result || '', data };
} catch (e) {
return { tokens: 0, text: '', data: null, error: String(e) };
}
}

制造者和验证者各调一次runClaude就是两个互不相通的会话,--model各给各的。

预算熔断:budget.mjs

// .loop/loop/budget.mjs:运行账本 + 熔断
import { readFileSync, existsSync, appendFileSync } from 'node:fs';
const LOG = '.loop/state/run-log.jsonl';

export const record = (tokens, outcome) =>
appendFileSync(LOG, JSON.stringify({ at: new Date().toISOString(), tokens, outcome }) + '\n');

export function spentToday() {
if (!existsSync(LOG)) return 0;
const today = new Date().toISOString().slice(0, 10);
return readFileSync(LOG, 'utf8').split('\n').filter(Boolean).map(JSON.parse)
.filter((e) => e.at.startsWith(today)).reduce((s, e) => s + (e.tokens || 0), 0);
}

export function gate(cfg) { // 'stop' | 'report-only' | 'normal'
if (existsSync('.loop/loop-pause-all')) return 'stop'; // kill switch
const spent = spentToday(), cap = cfg.budget.daily_token_cap;
if (spent >= cap) return 'stop';
return spent >= cap * cfg.budget.degrade_at ? 'report-only' : 'normal';
}

修复链条:fix.mjs(两个循环共用)

maker → 硬门禁 → verifier → PR,外加「同一项最多修 3 次」的熔断:

// .loop/loop/fix.mjs:maker → 硬门禁 → verifier → PR
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { runClaude } from './claude.mjs';

const sh = (cmd, cwd) => execSync(cmd, { cwd, encoding: 'utf8' });
const skill = (n) => readFileSync(`.loop/skills/${n}/SKILL.md`, 'utf8');
const reDeny = (g) => new RegExp('^' + g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*/g, '\0').replace(/\*/g, '[^/]*').replace(/\0/g, '.*') + '$');
const denied = (files, list) => files.some((f) => list.some((g) => reDeny(g).test(f)));
const AT = '.loop/state/attempts.json';
const load = () => (existsSync(AT) ? JSON.parse(readFileSync(AT, 'utf8')) : {});
const save = (m) => writeFileSync(AT, JSON.stringify(m));

export function runMakerVerifierPR(id, cfg) {
const a = load();
if ((a[id] || 0) >= cfg.budget.max_attempts_per_item) return 'escalated-max-attempts';
const M = cfg.model_mapping, wt = `.wt/${id}`;
sh(`git worktree add -q ``{wt} -b sweep/``{id} || true`);
try {
runClaude({ prompt: `Fix ${id}. Minimal change only. Never touch denylist paths.`,
system: skill('sweep-implementer'),
allowedTools: ['Read', 'Write', 'Bash(npm test:*)', 'Bash(flutter test:*)'], maxTurns: 30, cwd: wt, model: M.maker });

const changed = sh('git diff --name-only', wt).split('\n').filter(Boolean);
if (denied(changed, cfg.guardrails.denylist)) return 'escalated-denylist'; // 不验证不提交

const v = runClaude({ prompt: `Verify the fix for ${id} against the 5-item checklist. Run the tests yourself. Output verdict JSON.`,
system: skill('sweep-verifier'),
allowedTools: ['Read', 'Bash(npm test:*)', 'Bash(flutter test:*)'], maxTurns: 20, cwd: wt, model: M.verifier });
const d = v.data?.decision || 'REJECT'; // 解析失败按拒绝

if (d !== 'APPROVE') { if (d === 'REJECT') { a[id] = (a[id] || 0) + 1; save(a); } return d; }
sh(`git commit -am "fix: ``{id}" && git push -u origin sweep/``{id} && gh pr create --fill --title "[AI-Sweep] fix ${id}"`, wt);
delete a[id]; save(a);
return 'fix-proposed';
} finally {
sh(`git worktree remove ${wt} --force || true`);
}
}

git worktree给这次修复独立工作目录;一道git diff硬门禁兜住「maker 实际改了禁改路径」;验证者用 Opus、全新会话、默认拒绝;只有 APPROVE 才提交开 PR,最终合并等人。

主循环:run.mjs

#!/usr/bin/env node
// .loop/run.mjs:CI Sweeper:Triage → 早退 → maker → verifier → PR
import { readFileSync } from 'node:fs';
import { runClaude } from './loop/claude.mjs';
import { gate, record } from './loop/budget.mjs';
import { runMakerVerifierPR } from './loop/fix.mjs';

const cfg = JSON.parse(readFileSync('.loop/loop.config.json', 'utf8'));
if (gate(cfg) === 'stop') process.exit(0);

const triage = runClaude({
prompt: 'Triage CI failures. Output findings JSON only.',
system: readFileSync('.loop/skills/sweep-triage/SKILL.md', 'utf8'),
allowedTools: ['Bash(gh run:*)', 'Read'], maxTurns: 12, model: cfg.model_mapping.triage,
});
const high = (triage.data || { high: [] }).high;
if (!high.length || gate(cfg) === 'report-only' || process.env.WEEK_ONE === '1') {
record(triage.tokens, 'no-op'); process.exit(0); // CI 全绿/只读 → 早退
}
const outcome = runMakerVerifierPR(high[0].id, cfg); // 每轮只处理最高优一项
record(triage.tokens, outcome);
console.log(outcome);

工作流:定时调度,调用编排器

# api-server/.github/workflows/api-ci-sweep.yml
name: CI Sweep
on:
schedule: [{ cron: '*/30 * * * *' }]
workflow_dispatch: {}
permissions: { contents: write, pull-requests: write, actions: read, statuses: write }
concurrency: { group: ci-sweep, cancel-in-progress: false }
jobs:
sweep:
runs-on: ubuntu-latest
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GH_TOKEN: ${{ secrets.SWEEP_BOT_TOKEN }} # 独立 bot 令牌,自动 PR 才能触发 CI
WEEK_ONE: ${{ vars.WEEK_ONE }} # 第一周设 '1' 强制只读
steps:
- uses: actions/checkout@v6
with: { fetch-depth: 0 }
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm i -g @anthropic-ai/claude-code
- run: node .loop/run.mjs
- if: always() # 手动补 status,配合 bot token 让自动 PR 触发检查
run: gh api repos/${{ github.repository }}/statuses/${{ github.sha }} -f state=success -f context=ci-sweep || true

整条链读一遍就懂了:Triage 便宜分诊、CI 全绿就早退;制造者在隔离工作树里改;硬门禁查git diff;验证者最强模型、独立会话、默认拒绝;只有它点头才开 PR、且只是开 PR。

最小修复的意思是只改必须改的那一行(如字段名从user_name改成username只更新断言),不重构、不顺手改别的,你 review 越快、出错面越小。

第四步:Flutter 模拟器中的 E2E 巡检

Agent 在模拟器里像真人一样操作 App,发现真实用户才会遇到的问题。E2E 即端到端。

Agent 的「手和眼」

能力工具
精确操作 / 回归Flutterintegration_testfind.byKey、可断言)
探索 / 原生交互(含权限弹窗)Maestro(+ Maestro MCP);idb / adb 兜底
截图Maestro 自带,或simctl io/adb
  • simctl是 Xcode 自带的 iOS 模拟器命令行,能启动/装 App/截图,但没有点按命令,iOS 在 App 外部点不动。
  • adb随 Android SDK 自带,有adb shell input tap/textscreencap
  • idb(Facebook 开源)补上 simctl 缺的点按:idb ui tap/describe-all(读无障碍树)。
  • Maestro是开源移动 UI 框架,YAML 描述、无障碍树驱动、零插桩、跨双端一套脚本。无障碍树含系统权限弹窗,所以「允许通知」这类原生对话框也点得动,移动端不必再加截图点坐标的兜底工具。它的 MCP 服务能被 Claude Code 当原生工具调用,CLI 免费,只有上 Maestro 云做并行执行才按设备收费。

安装:

curl -fsSL https://get.maestro.mobile.dev | bash && echo "$HOME/.maestro/bin" >> $GITHUB_PATH
# iOS 不用 Maestro 时的兜底:brew tap facebook/fb && brew install idb-companion && pipx install fb-idb

探索者技能:只看不写

E2E 多一个探索者(Explorer)角色。它读的是不可信内容(屏幕 OCR、API 返回),是 prompt injection 的注入面,所以只读、只产 JSON 发现,不给写权限、不建 issue,建 issue 收归后面可信的编排器代码,约束见下方 SKILL.md。

# .loop/skills/e2e-explore/SKILL.md
---
name: e2e-explore
description: Explore a Flutter app via Maestro MCP. Output a JSON summary ONLY. No issue/edit.
---
你像真人一样操作 App,只发现与取证,产 JSON,不创建 issue、不改代码。
用 mcp__maestro__* 点按/读 hierarchy,用 orchestrator screenshot/logs/api-health 取证。
按旅程探索:启动→登录→核心功能→退出;记录崩溃/白屏/数据异常/导航死胡同/API 失败。
每个发现记可稳定复现的步骤;写回归到 integration_test/agent/<platform>_<seq>_test.dart。
每次最多 30 步,发现 3 个就停。
输出:{ "platform":"ios", "findings":[ { "title":"[E2E][ios] 登录后白屏",
"repro_steps":[...], "screen":"home_after_login", "error_signature":"blank_view",
"evidence":["shot-...png"], "regression_test":"integration_test/agent/ios_042_test.dart" } ] }

外部能力:orchestrator.mjs

UI 点按交给 Maestro MCP,截图/日志/健康由这个小脚本提供(目录用os.tmpdir()派生):

#!/usr/bin/env node
// .loop/orchestrator.mjs:screenshot | logs | api-health
import { execSync } from 'node:child_process';
import { mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const P = process.env.E2E_PLATFORM || process.argv[process.argv.indexOf('--platform') + 1] || 'ios';
const UDID = process.env.SIM_UDID || '';
const DIR = join(tmpdir(), 'agent-shots'); mkdirSync(DIR, { recursive: true });
const run = (c) => { try { return execSync(c, { encoding: 'utf8', timeout: 30000 }); } catch (e) { return e.stdout || String(e); } };

switch (process.argv[2]) {
case 'screenshot': {
const f = join(DIR, `shot-${Date.now()}.png`);
run(P === 'android' ? `adb exec-out screencap -p > "``{f}"` : `xcrun simctl io "``{UDID}" screenshot "${f}"`);
console.log(f); break;
}
case 'logs':
console.log(run(P === 'android' ? 'adb logcat -d -t 200 | grep -i flutter'
: `xcrun simctl spawn "${UDID}" log show --last 2m --predicate 'process=="Runner"' | tail -80`)); break;
case 'api-health':
console.log(run(`curl -sf ${process.env.API_STAGING_URL}/health || echo UNREACHABLE`)); break;
}

去重建 issue:issues.mjs

探索者无写权限,建 issue 收归这段可信代码,按指纹去重避免刷屏:

// .loop/loop/issues.mjs:指纹去重后建 issue
import { execSync } from 'node:child_process';
const sh = (c) => { try { return execSync(c, { encoding: 'utf8' }); } catch (e) { return e.stdout || ''; } };

export function createIssuesFromFindings(findings, { platform }) {
const open = sh('gh issue list --state open --label e2e-found --json title --jq ".[].title"');
const ids = [];
for (const f of findings) {
const fp = `[E2E][``{platform}] ``{f.error_signature}@${f.screen}`; // 指纹
if (open.includes(fp)) continue; // 撞已开 issue 就跳过
const body = `复现:${(f.repro_steps || []).join(' → ')}\n证据:${(f.evidence || []).join(', ')}\n回归:${f.regression_test}`;
const m = /\/issues\/(\d+)/.exec(sh(`gh issue create --title ``{JSON.stringify(fp)} --label "bug,e2e-found,``{platform}" --body ${JSON.stringify(body)}`));
if (m) ids.push(Number(m[1]));
}
return ids;
}

主循环:e2e-run.mjs

在 CI Sweeper 那条 maker→verifier→PR 之外,前面多接了「查后端 → 探索 → 去重建 issue」,修复链条直接复用fix.mjs

#!/usr/bin/env node
// .loop/e2e-run.mjs:E2E:探索 → 去重建 issue → maker → verifier → PR
import { readFileSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { runClaude } from './loop/claude.mjs';
import { gate, record } from './loop/budget.mjs';
import { createIssuesFromFindings } from './loop/issues.mjs';
import { runMakerVerifierPR } from './loop/fix.mjs';

const cfg = JSON.parse(readFileSync('.loop/loop.config.json', 'utf8'));
if (gate(cfg) === 'stop') process.exit(0);
const platform = process.argv[process.argv.indexOf('--platform') + 1] || 'ios';

const health = execSync(`curl -sf ${cfg.api_server.staging_url}/health || echo UNREACHABLE`, { encoding: 'utf8' }).trim();
if (health === 'UNREACHABLE') { // API 挂是基础设施事实,可信代码直接登记
execSync(`gh issue create --title "[E2E][${platform}] API unreachable" --label "bug,infra,e2e-found" --body "staging /health 不可达"`);
record(0, 'api-down'); process.exit(0);
}

const explorer = runClaude({ // 只产 JSON,无写权限
prompt: `Explore the app on ${platform} via Maestro MCP, screenshot via orchestrator. Output ONLY a JSON summary of findings.`,
system: readFileSync('.loop/skills/e2e-explore/SKILL.md', 'utf8'),
mcpConfig: '{"mcpServers":{"maestro":{"command":"maestro","args":["mcp"]}}}',
allowedTools: ['mcp__maestro__*', 'Read', 'Write', 'Bash(node .loop/orchestrator.mjs:*)', 'Bash(curl:*)', 'Bash(flutter test:integration_test/*)'],
maxTurns: 30, model: cfg.model_mapping.explorer,
});
const findings = (explorer.data || { findings: [] }).findings;
if (gate(cfg) === 'report-only' || process.env.WEEK_ONE === '1') { // 只读阶段:只报告不修
record(explorer.tokens, 'report-only'); console.log(JSON.stringify(findings)); process.exit(0);
}
const ids = createIssuesFromFindings(findings, { platform });
for (const id of ids) console.log(runMakerVerifierPR(id, cfg));
record(explorer.tokens, ids.length ? 'issues-found' : 'clean');

CI Sweeper 与 E2E 共享claude.mjs、两个修复技能、fix.mjs,区别只在前半段:一个从「Triage CI 失败」起步,一个从「模拟器探索」起步。

iOS / Android 工作流

触发用「App 代码变更 + 每天凌晨兜底」而不是固定每几小时空跑。代码没改、界面就是同一套,事件驱动比高频定时更省(尤其 iOS 跑在按分钟计费的 macOS runner 上)。

# app-flutter/.github/workflows/flutter-e2e-sweep.yml
name: Flutter E2E Sweep
on:
push: { paths: ['lib/**', 'integration_test/**', 'pubspec.yaml'] }
schedule: [{ cron: '0 3 * * *' }]
workflow_dispatch: { inputs: { platform: { default: 'all' } } }
concurrency: { group: e2e-sweep, cancel-in-progress: false }
jobs:
ios-e2e:
runs-on: macos-14 # iOS 模拟器只能跑在 macOS;成本见下节
if: github.event_name != 'workflow_dispatch' || inputs.platform == 'all' || inputs.platform == 'ios'
env: { ANTHROPIC_API_KEY: "${{ secrets.ANTHROPIC_API_KEY }}", GH_TOKEN: "${{ secrets.SWEEP_BOT_TOKEN }}" }
steps:
- uses: actions/checkout@v6
with: { fetch-depth: 0 }
- uses: subosito/flutter-action@v2
with: { flutter-version: '3.x', channel: stable }
- run: curl -fsSL https://get.maestro.mobile.dev | bash && echo "$HOME/.maestro/bin" >> $GITHUB_PATH
- name: Boot simulator
run: |
UDID=$(xcrun simctl list devices available -j | jq -r '.devices."iOS-18-0"[]|select(.name|contains("iPhone 16"))|.udid' | head -1)
echo "SIM_UDID=$UDID" >> $GITHUB_ENV
xcrun simctl boot "$UDID" || true; xcrun simctl bootstatus "$UDID" -b

- run: flutter pub get && flutter build ios --simulator --no-codesign
- name: Install & launch
run: |
APP=$(xcodebuild -showBuildSettings -scheme Runner -configuration Debug -sdk iphonesimulator -json 2>/dev/null \
| jq -r '.[0].buildSettings | "\(.TARGET_BUILD_DIR)/\(.WRAPPER_NAME)"') # 比 find DerivedData 稳
xcrun simctl install "$SIM_UDID" "$APP"
xcrun simctl launch "$SIM_UDID" com.yourorg.app_flutter || true

- run: node .loop/e2e-run.mjs --platform ios --udid "$SIM_UDID"
- uses: actions/upload-artifact@v4
if: always()
with: { name: ios-shots, path: "${{ runner.temp }}/agent-shots/" }

android-e2e:
runs-on: ubuntu-latest
if: github.event_name != 'workflow_dispatch' || inputs.platform == 'all' || inputs.platform == 'android'
env: { ANTHROPIC_API_KEY: "${{ secrets.ANTHROPIC_API_KEY }}", GH_TOKEN: "${{ secrets.SWEEP_BOT_TOKEN }}" }
steps:
- uses: actions/checkout@v6
with: { fetch-depth: 0 }
- uses: subosito/flutter-action@v2
with: { flutter-version: '3.x', channel: stable }
- run: curl -fsSL https://get.maestro.mobile.dev | bash && echo "$HOME/.maestro/bin" >> $GITHUB_PATH
- run: flutter pub get
- uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
target: google_apis
arch: x86_64
profile: pixel_7
script: |
flutter build apk --debug
adb install build/app/outputs/flutter-apk/app-debug.apk
adb shell am start -n com.yourorg.app_flutter/.MainActivity || true
node .loop/e2e-run.mjs --platform android

- uses: actions/upload-artifact@v4
if: always()
with: { name: android-shots, path: /tmp/agent-shots/ }

两个坑:iOS 用xcodebuild -showBuildSettings.app路径,别用find DerivedData(会随构建目录漂移);Android 的android-emulator-runner 开启的模拟器只在它自己的script:块里活着,build/install/启动/巡检全得写进script:内,写到后面必挂。

bundle id 全工作流统一com.yourorg.app_flutter,与Info.plist/applicationId对齐。

多机型矩阵:三层覆盖,分级触发

iPhone 和 Android 都不是一种机型。小屏挤布局、大屏空安全区、平板要多栏、折叠屏要处理形态切换、低端机暴露性能,这些单一机型测不出来。

但让 AI 探索者在每种机型上都跑一遍,token 和 macOS 分钟都成倍涨。所以拆成三层,各管一段。

第一层,AI 探索,只在代表机型跑。前面那套flutter-e2e-sweep.yml 烧 Token,保持单 iOS 加单 Android 不变,负责发现未知问题,不负责证明每台机型都没问题。

想多抓形态相关的交互问题,可让夜间探索额外轮换一台形态差异最大的设备(如折叠屏),按预算定加几台。

第二层,功能回归,跑全机型矩阵。就是flutter test integration_test/,不烧 Token,验证核心流程走通、关键页不崩、基本控件可交互,探索者固化出来的回归测试也跟着在每种机型上验一遍。这层做 PR gate 和夜间回归的主力。

第三层,视觉与布局回归。这是单靠功能回归补不上的一块:integration_test能证明「功能走通」,证明不了「界面在这台机型上可用」。

小屏遮挡、大屏空洞、平板没走多栏、折叠屏切换丢状态、字体放大溢出,这些功能脚本常常全绿、界面却已经错位。

所以用多机型矩阵补一层轻量视觉与布局检查,模拟器截图打底、少量真机截图补差(实现见本节末尾)。

机型触发按 smoke / nightly / release 分级:

平台机型关注点smokenightlyrelease
iOSiPhone 标准主流用户
iOSiPhone 小屏 SE窄屏布局
iOSiPhone 大屏 Max长屏、安全区
iOSiPad横屏、分屏
AndroidPixel 标准主流用户
Android紧凑小屏窄屏布局
Android平板多栏
Android折叠屏形态切换
Android低端机性能、兼容

PR 只跑 smoke 两台、快速放行;夜间跑扩展矩阵;发版手动触发 release,补上低端机这类全量门禁。

这套矩阵跑在模拟器上,用 GitHub Actions 的 matrix 并行,一个机型挂了不连累其它(fail-fast: false):

# app-flutter/.github/workflows/flutter-regression-matrix.yml
name: Flutter Regression Matrix
on:
schedule: [{ cron: '0 3 * * *' }] # 夜间:smoke + nightly
workflow_dispatch:
inputs: { mode: { description: 'smoke / nightly / release', default: 'release' } }
jobs:
matrix:
name: ${{ matrix.platform }}-${{ matrix.device }}
runs-on: ${{ matrix.os }}
timeout-minutes: 60
strategy:
fail-fast: false # 一台挂了,其它机型照跑
matrix:
include:
- { platform: ios, device: iphone-std, sim: 'iPhone 16', os: macos-14, tier: smoke }
- { platform: android, device: pixel-std, api: 34, profile: pixel_7, os: ubuntu-latest, tier: smoke }
- { platform: ios, device: iphone-se, sim: 'iPhone SE (3rd generation)', os: macos-14, tier: nightly }
- { platform: ios, device: iphone-max, sim: 'iPhone 16 Pro Max', os: macos-14, tier: nightly }
- { platform: ios, device: ipad, sim: 'iPad (10th generation)', os: macos-14, tier: nightly }
- { platform: android, device: pixel-small, api: 30, profile: pixel, os: ubuntu-latest, tier: nightly }
- { platform: android, device: tablet, api: 34, profile: pixel_tablet, os: ubuntu-latest, tier: nightly }
- { platform: android, device: fold, api: 34, profile: pixel_fold, os: ubuntu-latest, tier: nightly }
- { platform: android, device: low-end, api: 30, profile: pixel, os: ubuntu-latest, tier: release }
steps:
- uses: actions/checkout@v6
- uses: subosito/flutter-action@v2
with: { flutter-version: '3.x', channel: stable }
- run: flutter pub get
- name: 选层(schedule 跑 smoke+nightly,dispatch 按 mode)
id: gate
shell: bash
run: |
MODE='${{ github.event.inputs.mode || 'nightly' }}'; T='${{ matrix.tier }}'; R=no
if [ "$MODE" = release ]; then R=yes; fi
if [ "$MODE" = nightly ] && [ "$T" != release ]; then R=yes; fi
if [ "$MODE" = smoke ] && [ "$T" = smoke ]; then R=yes; fi
echo "run=$R" >> "$GITHUB_OUTPUT"

- name: iOS 回归
if: steps.gate.outputs.run == 'yes' && matrix.platform == 'ios'
run: |
UDID=$(xcrun simctl list devices available -j \
| jq -r --arg n "${{ matrix.sim }}" '.devices|to_entries[].value[]|select(.name==$n)|.udid' | head -1)
xcrun simctl boot "$UDID"; xcrun simctl bootstatus "$UDID" -b
flutter test integration_test/ -d "$UDID"

- name: Android 回归
if: steps.gate.outputs.run == 'yes' && matrix.platform == 'android'
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: ${{ matrix.api }}
arch: x86_64
profile: ${{ matrix.profile }}
script: flutter test integration_test/
- if: always() && steps.gate.outputs.run == 'yes'
uses: actions/upload-artifact@v4
with:
name: reg-${{ matrix.platform }}-${{ matrix.device }}
path: test-results/
if-no-files-found: ignore

sim名字来自xcrun simctl list devices,Android 的profile来自avdmanager list device,按你 CI 镜像里实际装的改。

矩阵管第二层功能回归,顺手做了第三层的截图取证:每台在关键页截图存到test-results/供人工比对,把溢出这类报错挡在矩阵里。

模拟器看不到真实安全区、刘海、系统字体渲染这类只有真机才暴露的差异,靠后面真机截图补(见后文)。

第三层先上成本最低的尺寸断点测试:不开模拟器,枚举多种逻辑尺寸和横竖屏,跑溢出断言和布局分支断言,把响应式问题在不开任何设备的情况下抓住大半:

// test/layout/breakpoints_test.dart:不开模拟器,多尺寸跑布局断言
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:your_app/app.dart'; // MyApp 为应用根 widget

const sizes = {
'small': Size(320, 568), // 小屏 SE 类
'max': Size(430, 932), // 大屏 Pro Max 类
'tablet': Size(834, 1194), // iPad
'fold-open': Size(673, 841), // 折叠屏展开
};

void main() {
for (final e in sizes.entries) {
testWidgets('布局可用 @${e.key}', (tester) async {
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = e.value;
addTearDown(tester.view.reset);

await tester.pumpWidget(const MyApp());
await tester.pumpAndSettle();

expect(tester.takeException(), isNull); // RenderFlex 溢出会在这里抛出
if (e.value.width >= 800) { // 平板/大屏应走多栏分支
expect(find.byKey(const Key('multi_column')), findsOneWidget);
}
// 可选像素级视觉回归:先 flutter test --update-goldens 生成基线再开启
// await expectLater(find.byType(MyApp), matchesGoldenFile('goldens/home_${e.key}.png'));
});
}
}

在矩阵 workflow 里加一个不依赖模拟器的便宜 job 跑它:

  layout:
runs-on: ubuntu-latest # 纯 flutter test,不开模拟器,最便宜
steps:
- uses: actions/checkout@v6
- uses: subosito/flutter-action@v2
with: { flutter-version: '3.x', channel: stable }
- run: flutter pub get
- run: flutter test test/layout/
- if: always()
uses: actions/upload-artifact@v4
with: { name: layout-failures, path: test/layout/failures/, if-no-files-found: ignore }

用 Flutter 自带的 golden 测试做视觉回归:把界面渲染成基准图存盘,之后每次跑都和它逐像素比对,不一致就报红。但像素级对比更容易抓错位,别铺到整个模拟器矩阵逐台硬比,镜像、系统字体、抗锯齿的差异会让它频繁假阳。

稳妥做法是 golden 只在上面这个固定逻辑尺寸的 job 里跑(环境最可控),模拟器矩阵层做截图留存加溢出断言、golden 可选。

真机层用 Firebase Test Lab (FTL)抽一两台补模拟器看不到的差异,复用的仍是固化的 integration_test,只在 nightly 和 release 触发。

在矩阵 workflow 里加一个夜间/发版才触发的 job:

  realdevice:                         # 真机层:只在 nightly / release 触发,PR 不碰
runs-on: ubuntu-latest
if: github.event_name == 'schedule' || github.event.inputs.mode != 'smoke'
steps:
- uses: actions/checkout@v6
- uses: subosito/flutter-action@v2
with: { flutter-version: '3.x', channel: stable }
- run: flutter pub get
- uses: google-github-actions/auth@v2
with:
credentials_json: ${{ secrets.GCP_SA_KEY }}
- uses: google-github-actions/setup-gcloud@v2
- run: flutter build apk --debug
- name: 把 integration_test 打成 androidTest 包
run: |
pushd android
./gradlew app:assembleDebugAndroidTest -Ptarget=integration_test/app_test.dart
popd

- name: 丢给一台真机跑,再拉回截图
run: |
gcloud firebase test android run \
--type instrumentation \
--app build/app/outputs/flutter-apk/app-debug.apk \
--test android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk \
--device model=oriole,version=34 \
--directories-to-pull /sdcard/screenshots \
--results-bucket gs://your-ftl-bucket --timeout 8m
gsutil -m cp -r "gs://your-ftl-bucket/**/*.png" test-results/realdevice/ || true

- if: always()
uses: actions/upload-artifact@v4
with:
name: realdevice-shots
path: test-results/realdevice/
if-no-files-found: ignore

iOS 真机走gcloud firebase test ios run,把测试打成 zip 即可,思路相同。截图留存供后续初筛。

视觉初筛:只读,缩小人工看图范围

视觉模型不做硬门禁,只做初筛。把它做成只读角色,无写权限、不建 issue,优先 diff,具体见 SKILL.md。

别全矩阵每晚都跑,挂在别的信号后面才触发,用便宜视觉档(model_mapping 里已加一档 visual,给 Haiku 或 DeepSeek-V4 Flash),人只看被标 high 和 uncertain 的几张。

# .loop/skills/e2e-visual-triage/SKILL.md
---
name: e2e-visual-triage
description: Read screenshots, flag visually broken screens. Diff against golden when given. JSON only. No issue, no edit.
---
你是只读视觉初筛,读截图找通用性破损,产 JSON,不建 issue、不改代码。
优先 diff:给了基线就比对基线与当前,只报变化;没基线才冷看单图。
只报高确定性破损:文字溢出/截断、控件重叠、白屏、内容被挤出屏外、对比度过低。
拿不准标 uncertain 交人,绝不替人判定栏数/配色/信息层级这类设计意图。
输出:{ "platform":"ios","device":"ipad","suspects":[ { "screen":"home",
"kind":"overflow","confidence":"high|uncertain","note":"","shot":"test-results/ipad-home.png" } ] }

触发复用runClaudecreateIssuesFromFindings,只读,建 issue 收归可信代码:

// 触发点:integration_test 失败或 golden diff 跳了,才跑视觉复核
const v = runClaude({
prompt: `Compare goldens/ with test-results/ for ``{platform}-``{device}. Read the PNGs. Output suspicious screens as JSON only.`,
system: readFileSync('.loop/skills/e2e-visual-triage/SKILL.md', 'utf8'),
allowedTools: ['Read', 'Bash(ls:*)'], // 只读,无写、无建 issue 权限
maxTurns: 12, model: cfg.model_mapping.visual, // 便宜视觉档
});
const hits = (v.data || { suspects: [] }).suspects.filter((s) => s.confidence === 'high');
createIssuesFromFindings(hits.map((s) => ({ // uncertain 的不自动建,留给人扫
error_signature: s.kind, screen: s.screen, evidence: [s.shot],
repro_steps: [s.note], regression_test: '',
})), { platform });

一句话分工:视觉模型缩小范围和初筛,确定性断言硬门禁,人负责设计意图和最终合并。三层各司其职:探索找未知,功能回归保流程,尺寸与视觉检查保界面。

iOS 的 runner:macOS runner 自托管

OS 模拟器必须跑 macOS runner,托管 macOS 约是 Linux 的 10 倍,事件驱动可省,但多机型矩阵一开账单会迅速失控。

在打开 iOS 多机型矩阵之前,先用 Mac mini 注册为自托管,把按分钟付费换成一次性硬件:只需把runs-on: macos-14换为runs-on: [self-hosted, macOS](具体分钟数和金额见成本一节)。自托管基线:只挂私有仓、隔离网段、磁盘定时清理、token 按 job 注入。

固化为回归测试

探索者发现问题后产 JSON,编排器去重建 issue,再走 maker→verifier→PR:

写出来就是一份 integration test,修复前 FAIL、修复后 PASS:

// integration_test/agent/e2e_ios_042_test.dart(import 同前文 login_test,略)
testWidgets('Agent regression: 登录后不应白屏', (tester) async {
app.main();
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const Key('email_field')), 'test@example.com');
await tester.enterText(find.byKey(const Key('password_field')), 'password123');
await tester.tap(find.byKey(const Key('login_button')));
await tester.pump(const Duration(seconds: 3));
expect(find.byType(Scaffold), findsWidgets); // 应显示主页而非白屏
});

两条腿走路:探索用 Maestro(自由乱点、发现未知 bug),固化用integration_test(精确断言、防回归)。前者像巡逻,后者像拍照取证。

第五步:PR 保姆与合并后清理

这两个是维护性杂活、单会话能干完,回到 Claude Code Action 直接驱动。

# .github/workflows/pr-babysitter.yml
name: PR Babysitter
on:
pull_request: { types: [opened, synchronize, ready_for_review] }
schedule: [{ cron: '0 */2 * * *' }]
concurrency: { group: pr-babysitter-${{ github.event.pull_request.number }}, cancel-in-progress: true }
jobs:
babysit:
if: github.event.pull_request.draft == false || github.event_name == 'schedule'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with: { fetch-depth: 0 }
- uses: subosito/flutter-action@v2
with: { flutter-version: '3.x' }
- uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
prompt: |
确保 PR #${{ github.event.pull_request.number }} 走到可合并:CI 挂了修、有冲突解、
review comment 逐一回应、补 CHANGELOG、补测试。不改核心逻辑,只做维护性工作。

claude_args: |
--max-turns 25
--allowedTools "Read,Edit,Write,Bash(gh pr:*),Bash(git:*),Bash(flutter test:*),Bash(flutter analyze:*)"

Post-Merge Cleanup 触发pull_request: closedif: merged == true,干删分支、关联 issue、清调试代码的收尾活,--max-turns 10压得很低。

# .github/workflows/post-merge-cleanup.yml
name: Post-Merge Cleanup
on:
pull_request:
types: [closed]

jobs:
cleanup:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
prompt: |
PR #${{ github.event.pull_request.number }} 已合并。清理:
1. 删除已合并的远程分支
2. 关联 PR 中引用的 issue
3. 检查是否有调试代码遗留(print、TODO 等)
4. 有 follow-up item → 创建 issue

claude_args: |
--max-turns 10
--allowedTools "Read,Write,Bash(gh issue:*),Bash(gh pr:*),Bash(git:*)"

安全护栏:让 Agent 不失控

Agent 是 7×24 不休息的工程师,不设边界会惹麻烦。前面已讲透两条:maker/verifier 分离禁改清单硬门禁fix.mjsgit diff查实际改动)。

其余几道:

  • 权限最小化:只读循环不给contents: write,写循环只给必需项。

  • 预算熔断budget.mjsgate(),用到 80% 降级只读、100% 停。

  • Kill switch:仓库根目录放空文件loop-pause-all,每个循环开局gate()一看就停,半夜烧钱时一秒止血。

    touch .loop/loop-pause-all && git add . && git commit -m kill && git push   # 停
    rm .loop/loop-pause-all && git add . && git commit -m resume && git push # 恢复
  • Branch ProtectionSettings → Branches要求 PR 必须人 review、status checks 通过。即使验证者 APPROVE 了,最终 merge 必须人按。这是整套系统能被信任的根基,也是「交还给人」。

渐进式上线:别一上来就全自动

绝不能从草稿直接跳到无人值守,走 L0→L1→L2→L3,每级先稳定达标才升。

每往上一级,就把更多的「判断」交给 Agent,但每一级都要先稳定运行、达标,才允许升级,不能跳级。

推荐上线节奏:

第 1-2 周:两仓 Daily Triage,WEEK_ONE=1 强制只读。标准:7 天稳定、报告无重大误判。

第 3-4 周:API 侧 CI Sweeper。门禁:独立验证者(Opus、默认拒绝)+ 禁改硬门禁 + 人审每个 PR + 日预算 100 万 token。标准:修复 PR 合并率 > 70%、无 S2 事故。

第 5-6 周(E2E 单机型起步):Flutter E2E Sweep 只读起步(WEEK_ONE早退),先单台 Android 再单台 iOS,把 AI 探索加制造验证的环跑稳。同时上第三层里尺寸断点 job 和单机型功能回归,顺手把 golden 基线建起来。标准:每周发现 3+ 真实 issue、误报 < 30%、golden 不频繁假阳。

第 7-8 周(E2E 扩多机型 + 自托管):打开夜间多机型矩阵跑功能回归与模拟器截图取证。在打开 iOS 多机型矩阵之前先把自托管 macOS runner 上好,低端机这类 release 全量门禁最后接。同时上 PR Babysitter + Post-Merge。

第 9 周后:E2E 接上 fix.mjs 修复链条,先只修 integration_test/agent/(白名单),稳 1-2 周再扩到 lib/

成本算账:到底花多少钱

稳态月成本估算(仅 Claude token):

循环日 token月成本
Daily Triage × 2130k约 $15
API CI Sweeper0-1M约 $150
E2E iOS0-600k约 $90
E2E Android0-600k约 $90
PR Babysitter0-1M约 $90
Post-Merge80k约 $30
合计约 $465/月

不含 runner 分钟。iOS 的 macOS runner 是大头:多机型矩阵一夜五台 iOS,月计三四千分钟,托管约$250-$350/月,盖过整套 token 账。

开 iOS 矩阵前用 Mac mini 自托管(¥4000-5000,一两个月回本)。Android 矩阵和尺寸断点跑 ubuntu,可忽略。

真机用 Firebase Test Lab 压到 nightly/release 最低频次,常落在免费额度内。

用开源模型再压一档

预算紧就把引擎换成开源模型。DeepSeek / 智谱等都提供 Anthropic 兼容端点,claude命令行不改代码就能指过去:以 Deepseek 为例,在跑编排器的 job 里加两个环境变量,再把loop.config.jsonmodel_mapping换成 DeepSeek 模型名(便宜的 V4 Flash 干分诊、制造、探索,近一线的 V4 Pro 干验证)。

env:
ANTHROPIC_BASE_URL: https://api.deepseek.com/anthropic
ANTHROPIC_AUTH_TOKEN: ${{ secrets.DEEPSEEK_API_KEY }}
ANTHROPIC_API_KEY: "" # 留空,避免回退到 Anthropic

DeepSeek 还会自动把 Claude 模型名映射过去(opus 到 V4 Pro,haiku 和 sonnet 到 V4 Flash),连配置都不改也能跑。

Daily Triage、PR Babysitter 走的 Action 底层也是同一个claude,加这两个变量即可。

换算成人民币花销很直观:CI 全绿空跑约一分钱,一次完整的「发现 → 修复 → 验证」约两三毛钱,一次模拟器探索约一两毛钱。整套按中等活跃度估,每月几十元到一两百元人民币量级,比 Claude 账单省九成以上

技能和系统提示是稳定前缀、缓存命中率高,实际更低,所以别频繁改 SKILL.md。

两个反咬点:一是验证最不该省质量,循环工程要验证者用最强模型,稳妥做法是混合(Flash 干分诊、制造、探索,验证留最强模型 Opus ),全量 DeepSeek 更省但把关弱一档;二是Claude Code 的代码改写格式是按 Claude 模型调的,换模型后制造者的 diff 首次套用失败率可能高些,先在只读和小白名单阶段实测首过率。

对比一个测试工程师月成本约 $3000-8000(国内 ¥15-40k)。AI 驱动 CI 不是替代人,而是把人从重复维护中解放出来,专注真正需要创造力的事。

参考资料:

  • Claude Code Action:https://github.com/anthropics/claude-code-action
  • Claude Code GitHub Actions 文档:https://code.claude.com/docs/en/github-actions
  • Flutter 集成测试:https://docs.flutter.dev/testing/integration-tests
  • Flutter 测试概览:https://docs.flutter.dev/testing/overview
  • Maestro MCP:https://docs.maestro.dev/get-started/maestro-mcp
  • idb(iOS 模拟器自动化):https://github.com/facebook/idb
  • android-emulator-runner:https://github.com/reactivecircus/android-emulator-runner

延伸阅读:

循环工程实战:搭一个让智能体自己迭代的系统,从概念到全套 CI 巡检源码

Loop Engineering:别再写提示词,去写循环

#AI编程 #智能体⁠ #Agent ⁠#循环工程⁠ #CI ⁠#持续集成⁠ #Claude #Action #loop #Flutter ⁠#自动化测试⁠ #移动端测试

基本 文件 流程 错误 SQL 调试
  1. 请求信息 : 2026-06-29 16:35:45 HTTP/1.1 GET : https://www.yeyulingfeng.com/a/815786.html
  2. 运行时间 : 0.110856s [ 吞吐率:9.02req/s ] 内存消耗:5,307.20kb 文件加载:145
  3. 缓存信息 : 0 reads,0 writes
  4. 会话信息 : SESSION_ID=1e4a41f143c372c19e41de030e09ba08
  1. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/public/index.php ( 0.79 KB )
  2. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/autoload.php ( 0.17 KB )
  3. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/composer/autoload_real.php ( 2.49 KB )
  4. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/composer/platform_check.php ( 0.90 KB )
  5. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/composer/ClassLoader.php ( 14.03 KB )
  6. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/composer/autoload_static.php ( 6.05 KB )
  7. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-helper/src/helper.php ( 8.34 KB )
  8. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-validate/src/helper.php ( 2.19 KB )
  9. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/ralouphie/getallheaders/src/getallheaders.php ( 1.60 KB )
  10. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/helper.php ( 1.47 KB )
  11. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/stubs/load_stubs.php ( 0.16 KB )
  12. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Exception.php ( 1.69 KB )
  13. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-container/src/Facade.php ( 2.71 KB )
  14. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/symfony/deprecation-contracts/function.php ( 0.99 KB )
  15. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/symfony/polyfill-mbstring/bootstrap.php ( 8.26 KB )
  16. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/symfony/polyfill-mbstring/bootstrap80.php ( 9.78 KB )
  17. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/symfony/var-dumper/Resources/functions/dump.php ( 1.49 KB )
  18. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-dumper/src/helper.php ( 0.18 KB )
  19. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/symfony/var-dumper/VarDumper.php ( 4.30 KB )
  20. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/guzzlehttp/guzzle/src/functions_include.php ( 0.16 KB )
  21. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/guzzlehttp/guzzle/src/functions.php ( 5.54 KB )
  22. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/App.php ( 15.30 KB )
  23. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-container/src/Container.php ( 15.76 KB )
  24. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/psr/container/src/ContainerInterface.php ( 1.02 KB )
  25. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/app/provider.php ( 0.19 KB )
  26. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Http.php ( 6.04 KB )
  27. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-helper/src/helper/Str.php ( 7.29 KB )
  28. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Env.php ( 4.68 KB )
  29. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/app/common.php ( 0.03 KB )
  30. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/helper.php ( 18.78 KB )
  31. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Config.php ( 5.54 KB )
  32. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/config/alipay.php ( 3.59 KB )
  33. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/facade/Env.php ( 1.67 KB )
  34. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/config/app.php ( 0.95 KB )
  35. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/config/cache.php ( 0.78 KB )
  36. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/config/console.php ( 0.23 KB )
  37. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/config/cookie.php ( 0.56 KB )
  38. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/config/database.php ( 2.48 KB )
  39. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/config/filesystem.php ( 0.61 KB )
  40. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/config/lang.php ( 0.91 KB )
  41. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/config/log.php ( 1.35 KB )
  42. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/config/middleware.php ( 0.19 KB )
  43. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/config/route.php ( 1.89 KB )
  44. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/config/session.php ( 0.57 KB )
  45. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/config/trace.php ( 0.34 KB )
  46. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/config/view.php ( 0.82 KB )
  47. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/app/event.php ( 0.25 KB )
  48. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Event.php ( 7.67 KB )
  49. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/app/service.php ( 0.13 KB )
  50. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/app/AppService.php ( 0.26 KB )
  51. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Service.php ( 1.64 KB )
  52. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Lang.php ( 7.35 KB )
  53. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/lang/zh-cn.php ( 13.70 KB )
  54. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/initializer/Error.php ( 3.31 KB )
  55. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/initializer/RegisterService.php ( 1.33 KB )
  56. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/services.php ( 0.14 KB )
  57. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/service/PaginatorService.php ( 1.52 KB )
  58. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/service/ValidateService.php ( 0.99 KB )
  59. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/service/ModelService.php ( 2.04 KB )
  60. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-trace/src/Service.php ( 0.77 KB )
  61. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Middleware.php ( 6.72 KB )
  62. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/initializer/BootService.php ( 0.77 KB )
  63. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/Paginator.php ( 11.86 KB )
  64. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-validate/src/Validate.php ( 63.20 KB )
  65. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/Model.php ( 23.55 KB )
  66. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/model/concern/Attribute.php ( 21.05 KB )
  67. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/model/concern/AutoWriteData.php ( 4.21 KB )
  68. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/model/concern/Conversion.php ( 6.44 KB )
  69. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/model/concern/DbConnect.php ( 5.16 KB )
  70. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/model/concern/ModelEvent.php ( 2.33 KB )
  71. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/model/concern/RelationShip.php ( 28.29 KB )
  72. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-helper/src/contract/Arrayable.php ( 0.09 KB )
  73. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-helper/src/contract/Jsonable.php ( 0.13 KB )
  74. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/model/contract/Modelable.php ( 0.09 KB )
  75. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Db.php ( 2.88 KB )
  76. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/DbManager.php ( 8.52 KB )
  77. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Log.php ( 6.28 KB )
  78. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Manager.php ( 3.92 KB )
  79. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/psr/log/src/LoggerTrait.php ( 2.69 KB )
  80. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/psr/log/src/LoggerInterface.php ( 2.71 KB )
  81. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Cache.php ( 4.92 KB )
  82. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/psr/simple-cache/src/CacheInterface.php ( 4.71 KB )
  83. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-helper/src/helper/Arr.php ( 16.63 KB )
  84. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/cache/driver/File.php ( 7.84 KB )
  85. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/cache/Driver.php ( 9.03 KB )
  86. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/contract/CacheHandlerInterface.php ( 1.99 KB )
  87. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/app/Request.php ( 0.09 KB )
  88. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Request.php ( 55.78 KB )
  89. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/app/middleware.php ( 0.25 KB )
  90. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Pipeline.php ( 2.61 KB )
  91. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-trace/src/TraceDebug.php ( 3.40 KB )
  92. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/middleware/SessionInit.php ( 1.94 KB )
  93. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Session.php ( 1.80 KB )
  94. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/session/driver/File.php ( 6.27 KB )
  95. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/contract/SessionHandlerInterface.php ( 0.87 KB )
  96. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/session/Store.php ( 7.12 KB )
  97. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Route.php ( 23.73 KB )
  98. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/route/RuleName.php ( 5.75 KB )
  99. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/route/Domain.php ( 2.53 KB )
  100. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/route/RuleGroup.php ( 22.43 KB )
  101. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/route/Rule.php ( 26.95 KB )
  102. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/route/RuleItem.php ( 9.78 KB )
  103. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/route/app.php ( 3.94 KB )
  104. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/facade/Route.php ( 4.70 KB )
  105. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/route/dispatch/Controller.php ( 4.74 KB )
  106. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/route/Dispatch.php ( 10.44 KB )
  107. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/app/controller/Index.php ( 9.87 KB )
  108. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/app/BaseController.php ( 2.05 KB )
  109. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/facade/Db.php ( 0.93 KB )
  110. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/connector/Mysql.php ( 5.44 KB )
  111. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/PDOConnection.php ( 52.47 KB )
  112. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/Connection.php ( 8.39 KB )
  113. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/ConnectionInterface.php ( 4.57 KB )
  114. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/builder/Mysql.php ( 16.58 KB )
  115. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/Builder.php ( 24.06 KB )
  116. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/BaseBuilder.php ( 27.50 KB )
  117. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/Query.php ( 15.71 KB )
  118. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/BaseQuery.php ( 45.13 KB )
  119. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/concern/TimeFieldQuery.php ( 7.43 KB )
  120. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/concern/AggregateQuery.php ( 3.26 KB )
  121. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/concern/ModelRelationQuery.php ( 20.07 KB )
  122. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/concern/ParamsBind.php ( 3.66 KB )
  123. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/concern/ResultOperation.php ( 7.01 KB )
  124. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/concern/WhereQuery.php ( 19.37 KB )
  125. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/concern/JoinAndViewQuery.php ( 7.11 KB )
  126. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/concern/TableFieldInfo.php ( 2.63 KB )
  127. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-orm/src/db/concern/Transaction.php ( 2.77 KB )
  128. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/log/driver/File.php ( 5.96 KB )
  129. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/contract/LogHandlerInterface.php ( 0.86 KB )
  130. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/log/Channel.php ( 3.89 KB )
  131. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/event/LogRecord.php ( 1.02 KB )
  132. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-helper/src/Collection.php ( 16.47 KB )
  133. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/facade/View.php ( 1.70 KB )
  134. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/View.php ( 4.39 KB )
  135. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/app/controller/Es.php ( 3.30 KB )
  136. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Response.php ( 8.81 KB )
  137. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/response/View.php ( 3.29 KB )
  138. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/Cookie.php ( 6.06 KB )
  139. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-view/src/Think.php ( 8.38 KB )
  140. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/framework/src/think/contract/TemplateHandlerInterface.php ( 1.60 KB )
  141. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-template/src/Template.php ( 46.61 KB )
  142. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-template/src/template/driver/File.php ( 2.41 KB )
  143. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-template/src/template/contract/DriverInterface.php ( 0.86 KB )
  144. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/runtime/temp/c935550e3e8a3a4c27dd94e439343fdf.php ( 31.50 KB )
  145. /yingpanguazai/ssd/ssd1/www/wwww.yeyulingfeng.com/vendor/topthink/think-trace/src/Html.php ( 4.42 KB )
  1. CONNECT:[ UseTime:0.000560s ] mysql:host=127.0.0.1;port=3306;dbname=wenku;charset=utf8mb4
  2. SHOW FULL COLUMNS FROM `fenlei` [ RunTime:0.000839s ]
  3. SELECT * FROM `fenlei` WHERE `fid` = 0 [ RunTime:0.000315s ]
  4. SELECT * FROM `fenlei` WHERE `fid` = 63 [ RunTime:0.000255s ]
  5. SHOW FULL COLUMNS FROM `set` [ RunTime:0.000476s ]
  6. SELECT * FROM `set` [ RunTime:0.000216s ]
  7. SHOW FULL COLUMNS FROM `article` [ RunTime:0.000945s ]
  8. SELECT * FROM `article` WHERE `id` = 815786 LIMIT 1 [ RunTime:0.000893s ]
  9. UPDATE `article` SET `lasttime` = 1782722145 WHERE `id` = 815786 [ RunTime:0.004826s ]
  10. SELECT * FROM `fenlei` WHERE `id` = 64 LIMIT 1 [ RunTime:0.000256s ]
  11. SELECT * FROM `article` WHERE `id` < 815786 ORDER BY `id` DESC LIMIT 1 [ RunTime:0.000458s ]
  12. SELECT * FROM `article` WHERE `id` > 815786 ORDER BY `id` ASC LIMIT 1 [ RunTime:0.000518s ]
  13. SELECT * FROM `article` WHERE `id` < 815786 ORDER BY `id` DESC LIMIT 10 [ RunTime:0.000729s ]
  14. SELECT * FROM `article` WHERE `id` < 815786 ORDER BY `id` DESC LIMIT 10,10 [ RunTime:0.013460s ]
  15. SELECT * FROM `article` WHERE `id` < 815786 ORDER BY `id` DESC LIMIT 20,10 [ RunTime:0.005317s ]
0.112548s