02 · DESIGN / Agents/Tester Agent

Tester Agent · 完整 Skill 规范

L1 的对手方。读 Coder 的 code/ + spec + contract.json,写 vitest 单测,跑、收失败、产给 Coder 反思的 hint。同时桥接到 bugs.ndjson 做 bug 生命周期。

T

Tester Agent

写测试 · 跑测试 · 翻译失败原因 · 桥接 bugs.ndjson

Loop L1
Model Sonnet 4.6
Modes test · retry · resume
沙箱 units/{unitId}/ 内 tests/ + runs/{runId}/
会话复用 是(SessionPool key = tester.cid.pid.unitId)

① 功能需求

  1. 三种模式:test(Coder 完成后首次)/ retry(Coder 改过重跑)/ resume。
  2. 覆盖来源:
    • spec.json.acceptance_criteria 每条 ≥ 1 case
    • contract.json.exports.http.responses 每个状态码 ≥ 1 case
  3. 真跑:后端用 supertest 起真实 Express + better-sqlite3 :memory:;前端用 @vue/test-utils,不渲染浏览器。
  4. 不允许 mock 整个被测模块。可 mock 外部 API / DB / 时间。
  5. 失败必含 hint:给 Coder 下次反思的 1-2 句人话提示。禁止「fix the bug」「expected 401 got 500」式无信息废话。
  6. 桥接 bugs.ndjson:失败 → bug_open append 一条 status=open;下次绿 → bug_transition(to='fixed')。
  7. 幂等:同一代码反复跑结果一致。依赖时间 / 随机数 / 网络必须 stub。
  8. 不写代码:Coder 的活。Tester 写 tests/ 才能算干活。

② Skill 设计

2.1 输出沙箱

data/companies/{cid}/projects/{pid}/units/{unitId}/
├── code/                       # 只读 (Coder 写)
├── contract.json               # 只读 (Coder 写)
├── spec.json                   # 只读 (Architect 写)
├── coder.log                   # 只读 (Coder 写)
├── tests/                      # Tester 写 ★
│   ├── *.spec.ts               # vitest 测试
│   └── helpers/                # 共享 fixture (可选)
└── runs/                       # Tester 写 ★
    └── {runId}/
        ├── result.json         # 结构化结果
        ├── stdout.txt          # vitest 原始输出
        └── stderr.txt          # crash 时

# 还要写 (跨项目):
../../bugs.ndjson               # bug_open / bug_transition 工具 append

2.2 完整 System Prompt(可拷贝直用)

落 system/agents/devloop-tester/prompt.md。LoopController 注入时头部补 ## Memory + ## Unit Code + Contract(节选 code/ 的 export 接口 + contract.json)。

# ROLE
你是 LemonBoss DevLoop 的 Tester Agent。本次任务:测试 Unit {unitId}。
读 Coder 的 code/ + Architect 的 spec.json,写 vitest 单测,跑测试,
把失败翻译成给 Coder 下次反思的 hint。

# MODES
- "test"      Coder 首次完成,Tester 首次跑
- "retry"     Coder 改了 code,重跑 (如有旧 tests 复用)
- "resume"    从 resume_token 继续

# SANDBOX
你只能写:
  ./tests/**
  ./runs/{runId}/**
禁止写:
  ./code/**                (Coder 的领地)
  ./contract.json          (Coder 的领地)
  ./coder.log              (Coder 的领地)
  ../{别的 unit}/**        (其他 Unit)

你可读:
  ./code/**                                    (你测的代码)
  ./contract.json                              (推导期望响应)
  ./spec.json                                  (acceptance_criteria)
  ../{depends_on}/contract.json                (跨 Unit 不读其 code)
  ./tests/**                                   (复用旧 spec)

# MANDATORY WORKFLOW

## step 0 · 读上下文
  - 读 spec.json acceptance_criteria 全条
  - 读 contract.json exports.http (每个 status code 必测)
  - 读 code/ 入口文件 (找 export 接口)
  - retry: 读 ./tests/* 看哪些已存在 (可保留 + 重跑)

## step 1 · write_todolist

### test 典型 (5 项):
  step-001 写 tests/{unit}.spec.ts
           - 后端: supertest + 内存 sqlite
           - 前端: @vue/test-utils mount
  step-002 覆盖 acceptance 全条 + contract 全 status code
  step-003 跑 vitest --reporter=json > runs/{runId}/raw.json
  step-004 parse_vitest → runs/{runId}/result.json + 生成 hint
  step-005 bug_open / bug_transition (按结果)

### retry 典型 (3 项):
  step-001 跑现有 tests (代码改了,测试可能不需要改)
  step-002 parse_vitest → result.json
  step-003 bug_transition (如之前 open 现绿,转 fixed)

# RULES (硬约束)

1. 后端测试模板:
     import request from 'supertest';
     import { describe, it, expect, beforeEach } from 'vitest';
     import app from '../code/server';
     // 用 :memory: sqlite 起干净状态

2. 前端测试模板:
     import { mount } from '@vue/test-utils';
     import { describe, it, expect } from 'vitest';
     import Component from '../code/Component.vue';

3. 禁止 mock 整个被测模块。可以 mock:
     - 外部 API (fetch / axios)
     - 第三方服务 (S3 / 邮件)
     - 时间 (vi.useFakeTimers)
     - 随机 (vi.spyOn(Math, 'random'))

4. 每个 acceptance_criterion → 至少 1 个 it() block。
   不允许把多条揉进一个 it()。

5. 每个 contract.responses 状态码 → 至少 1 个 negative + positive case。
   401 → 测一个返 401 的输入
   500 → 不主动测 (500 是 bug 不是 feature)

6. 失败 hint 必须是给 Coder 的 1-2 句人话提示:
     ✓ "user 不存在时不要 bcrypt.compare(null,...);先查再比"
     ✓ "title 字段缺失 req.body.title 是 undefined,先做存在性判断"
     ✗ "fix the bug"
     ✗ "expected 401 got 500"  (没有信息量)
   由 hint_builder 工具帮你从 stack + expected/got 生成 (但你要审一遍)。

7. bugs.ndjson 同步:
     失败:调 bug_open({source:'unit_test', unit_id, run_id, test_name, ...})
     如该 unit 之前有 status=open 的同 test bug → 不重复 open (用 dedupKey)
     绿:之前有 status=open/fixing → bug_transition(to='fixed')

8. 任何 Bash 带 JSON body → /tmp/*.json + -d @file (CLAUDE.md L91-99)。

9. 单次 vitest 跑总时长上限 60s。超时 → kill + 标 result.crashed=true。

10. 不写 ./code/, 不调 call_sub_agent。

# SELF-CHECK
1. tests/ 至少有 acceptance_criteria 数量 + contract status code 数量个 it() block
2. result.json 通过 typebox 校验
3. result.json.failures 每条有非空 hint
4. bugs.ndjson 新增/转换条目都在
5. todolist 全 done
6. 没写出沙箱

2.3 工具集

工具签名用途访问边界
Read(path)读 code / contract / spec / 上游 contract / 历史 tests沙箱白名单
Write(path, content)写 tests/ + runs/{runId}/tests/** + runs/**
Bash(cmd)跑 vitest run --reporter=json; npm i (限定包)cwd 锁 units/{unitId}/, jsonGuard 强制, 60s 超时
parse_vitest NEW(rawJsonPath) → RunResultvitest JSON reporter → 结构化 result.json + 自动生成 hint—
hint_builder NEW(failure) → string从 stack/expected/got 启发式生成 hint(Tester 可再编辑)—
bug_open NEW(pid, bug) → bugIdappend bugs.ndjson · dedupKey 防重bugs.ndjson
bug_transition NEW(pid, bugId, to, opts)状态机转换bugs.ndjson
bug_list NEW(pid, filter)查当前 open/fixing 的 bug(dedup 时用)—
write_todolist(items)同上agent-state/
update_todo_item(id, status)同上—
update_runtime_state(step, total)同上—
timeline_append(kind, ref, summary)同上 · kind: unit_tested · test_failed · test_passed—
record_memory(topic, content, relevance)memory/devloop/tester/memory/devloop/tester/

2.4 禁用

  • 不允许 Edit / Write code/ — Coder 领地
  • 不允许 mock 整个被测模块 — 否则测试永远绿
  • 不允许 inline JSON Bash — jsonGuard 强制
  • 不允许 call_sub_agent
  • 不允许跳过 hint 字段 — failure 必含可执行提示

③ 持久化集成

同 Coder 模式 + 加 bugs.ndjson 桥接。失败时 bug_open;绿时 bug_transition;中间状态由 LoopController 转 fixing。

Tester memory 典型话题(很少):

  • "vitest 启动慢 (Vite cold start),固定用 --pool=threads 加速"
  • "better-sqlite3 :memory: 跨 it() 状态隔离需 beforeEach 重建"
  • memory 在 Tester 这层很少有可复用洞见,注意不要乱写

④ I/O 契约

const TesterInput = Type.Object({
  pid:        Type.String(),
  unit_id:    Type.String(),
  mode:       Type.Union([Type.Literal('test'),
                          Type.Literal('retry'),
                          Type.Literal('resume')]),
  resume_token: Type.Optional(Type.String()),
  call_id:    Type.String(),
  company_id: Type.String(),
});

const TesterOutput = Type.Object({
  ok:           Type.Boolean(),         // 总判:全绿才 true
  mode:         Type.String(),
  unit_id:      Type.String(),
  run_id:       Type.String(),
  result_ref:   Type.String(),         // path to result.json

  summary:      Type.Object({
    total:   Type.Number(),
    passed:  Type.Number(),
    failed:  Type.Number(),
    skipped: Type.Number(),
  }),

  failures:     Type.Array(Type.Object({
    test:     Type.String(),
    expected: Type.Any(),
    got:      Type.Any(),
    stack:    Type.String(),
    hint:     Type.String(),        // 非空,给 Coder
    bug_id:   Type.String(),       // 关联 bugs.ndjson
  })),

  bugs_opened:      Type.Array(Type.String()),  // 新开 bug id 列表
  bugs_transitioned: Type.Array(Type.Object({ id, from, to })),

  acceptance_coverage: Type.Number({ minimum:0, maximum:1 }),
  contract_coverage:   Type.Number({ minimum:0, maximum:1 }),

  crashed:      Type.Boolean(),         // vitest 自身崩
  duration_ms:  Type.Number(),
  tokens_used:  Type.Number(),
});

⑤ 上下游传递

⬆ 上游

  • Coder · 通过 code/ + contract.json
  • Architect · 通过 spec.json acceptance + sdd tests 列表
  • LoopController · 注入 input + memory

⬇ 下游

  • Coder(retry 时)· 读 result.json.failures.hint 反思
  • LoopController · 读 result.ok 决定 pass/retry/block
  • UnitRegistry · 接 status: testing → passed | failed
  • BugTracker / bugs.ndjson · 接 bug_open / bug_transition
  • Integrator(passed 后)· 间接:tests/ 可作 e2e 回归基础
  • Dashboard · SSE stream 看 stdout

⑥ 完整执行样例

# ─────────── INPUT ───────────
{
  "pid": "p-test001", "unit_id": "todo-be",
  "mode": "test", "call_id": "call-t-001", ...
}

# INJECTED Unit Code Excerpt (LoopController 拼)
## Unit Code: todo-be
exports from code/server.js:    app (Express)
exports from code/service.js:   create(title), list()

## Contract (contract.json)
POST /api/todos { title } → 201 | 400
GET  /api/todos          → 200 { items }

## Acceptance Criteria (spec.json)
- 创建后能列出
- 空 title 返 400
- 200+ 字符 title 返 400

# step 1 · write_todolist (5 项 test 模板)

# step 2 · 写 tests/todo-be.spec.ts
import request from 'supertest';
import { describe, it, expect, beforeEach } from 'vitest';

// 注意:Coder 实现里 db 是 module 级 :memory:,跨 it() 状态会污染
// 这里 fresh app/db 每个 it() (后面 hint 会指出 Coder 该改)

describe('todo-be POST /api/todos', () => {
  let app;
  beforeEach(() => {
    delete require.cache[require.resolve('../code/server')];
    app = require('../code/server');
  });
  it('有效 title → 201 + 返 id', async () => {
    const r = await request(app).post('/api/todos').send({ title: 'buy milk' });
    expect(r.status).toBe(201);
    expect(r.body.id).toBeTypeOf('number');
  });
  it('空 title → 400 title_required', async () => {
    const r = await request(app).post('/api/todos').send({ title: '' });
    expect(r.status).toBe(400);
    expect(r.body.error).toBe('title_required');
  });
  it('缺 body title → 400', async () => {
    const r = await request(app).post('/api/todos').send({});
    expect(r.status).toBe(400);
  });
  it('200+ title → 400 title_too_long', async () => {
    const r = await request(app).post('/api/todos')
      .send({ title: 'x'.repeat(201) });
    expect(r.status).toBe(400);
  });
});

describe('todo-be GET /api/todos', () => {
  it('创建后能列出', async () => {
    const app = require('../code/server');
    await request(app).post('/api/todos').send({ title: 'a' });
    const r = await request(app).get('/api/todos');
    expect(r.status).toBe(200);
    expect(r.body.items.some(t => t.title === 'a')).toBe(true);
  });
});

# step 3 · Bash: vitest run --reporter=json > runs/run-20260513-1031/raw.json

# step 4 · parse_vitest
parse_vitest("runs/run-20260513-1031/raw.json")
// 结果: 5 个 test, 4 passed, 1 failed
// 失败: "缺 body title → 400" 实际收 500 TypeError

# hint_builder 看 stack: TypeError on req.body.title.length
hint_builder({
  expected: 400, got: 500,
  stack: "TypeError: cannot read properties of undefined (reading 'length') at create..."
})
// → "缺 body 字段时 req.body.title 是 undefined,先做存在性判断再 .length"

# step 5 · bug_open
bug_open({
  source: 'unit_test', unit_id: 'todo-be', run_id: 'run-20260513-1031',
  test_name: '缺 body title → 400',
  category: 'unknown', severity: 'high',
  title: '缺 body title 时 500 而非 400',
  detail: 'TypeError: ...', affected_units: ['todo-be']
})
// → bug-20260513-1031-c9f2  (新开)

# 写 result.json
{
  "run_id": "run-20260513-1031", "loop": "L1",
  "unit_id": "todo-be", "agent": "tester", "attempt": 1,
  "ok": false,
  "summary": {"total":5,"passed":4,"failed":1},
  "failures": [{
    "test": "缺 body title → 400",
    "expected": 400, "got": 500,
    "stack": "TypeError: ...",
    "hint": "缺 body 字段时 req.body.title 是 undefined,先做存在性判断再 .length",
    "bug_id": "bug-20260513-1031-c9f2"
  }],
  "next_action": "retry_coder",
  "retry_budget_left": 4,
  "duration_ms": 1_245
}

# OUTPUT
{
  "ok": false, "mode": "test", "unit_id": "todo-be",
  "run_id": "run-20260513-1031",
  "summary": {"total":5,"passed":4,"failed":1,"skipped":0},
  "failures": [{ "hint": "...", "bug_id": "bug-..." }],
  "bugs_opened": ["bug-20260513-1031-c9f2"],
  "bugs_transitioned": [],
  "acceptance_coverage": 1.0,   // 3/3
  "contract_coverage": 1.0,
  "crashed": false,
  "duration_ms": 12_410, "tokens_used": 4_120
}

# LoopController 接 ok=false → 触发 Coder retry (带 history)

⑦ 助手工具源码

7.1 parse-vitest-json.js · vitest JSON reporter 解析

// server/services/agent-tools/parse-vitest-json.js · ~80 行
const fs   = require('fs');
const path = require('path');
const { buildHint } = require('./hint-builder');

function parseVitest(rawJsonPath, opts = {}) {
  if (!fs.existsSync(rawJsonPath)) {
    return {
      ok: false, crashed: true,
      crash_reason: 'vitest raw json missing - process likely crashed early',
      summary: { total:0, passed:0, failed:0, skipped:0 },
      failures: [],
    };
  }

  let raw;
  try {
    raw = JSON.parse(fs.readFileSync(rawJsonPath, 'utf8'));
  } catch (e) {
    return { ok:false, crashed:true, crash_reason: 'invalid vitest json', summary:{}, failures:[] };
  }

  const summary = { total:0, passed:0, failed:0, skipped:0 };
  const failures = [];

  // vitest 1.x JSON reporter 结构:
  //   { testResults: [{ assertionResults: [{ status, fullName, failureMessages, ... }] }] }
  for (const file of (raw.testResults || [])) {
    for (const a of (file.assertionResults || [])) {
      summary.total++;
      if (a.status === 'passed')  summary.passed++;
      else if (a.status === 'failed') {
        summary.failed++;
        const stack = (a.failureMessages || []).join('\n');
        const { expected, got } = extractAssertion(stack);
        failures.push({
          test: a.fullName,
          expected, got, stack,
          hint: buildHint({ testName: a.fullName, expected, got, stack }),
        });
      }
      else summary.skipped++;
    }
  }

  return {
    ok: summary.failed === 0 && summary.total > 0,
    crashed: false,
    summary, failures,
    duration_ms: raw.testResults?.[0]?.perfStats?.runtime || 0,
  };
}

function extractAssertion(stack) {
  const exp = stack.match(/Expected: ([^\n]+)/);
  const got = stack.match(/Received: ([^\n]+)/);
  return {
    expected: exp ? exp[1].trim() : null,
    got:      got ? got[1].trim() : null,
  };
}

module.exports = { parseVitest };

7.2 hint-builder.js · 失败 → 人话 hint

// server/services/agent-tools/hint-builder.js · ~60 行

const PATTERNS = [
  {
    match: (s) => /cannot read.*?of undefined/i.test(s),
    hint:  (ctx) => '某个对象/字段是 undefined,先做存在性判断再访问',
  },
  {
    match: (s) => /expected (\d+).*received (\d+)/i.test(s),
    hint:  (ctx) => {
      const m = ctx.stack.match(/expected (\d+).*received (\d+)/i);
      const exp = +m[1], got = +m[2];
      if (exp === 401 && got === 500)
        return '凭据错误应返 401 不是 500;检查认证路径是否 try/catch 兜底';
      if (exp === 400 && got === 500)
        return '参数校验错应返 400 不是 500;在 route 层检查后再传 service';
      if (exp === 404 && got === 200)
        return '不存在的资源不应返 200;返回前判断查询结果';
      return `期望 ${exp} 收 ${got},检查对应分支处理`;
    },
  },
  {
    match: (s) => /TypeError.*compare/i.test(s),
    hint:  (ctx) => '调用 bcrypt.compare 前先确认 user 存在;否则 null 参数会抛 TypeError',
  },
  {
    match: (s) => /SQLITE_CONSTRAINT/i.test(s),
    hint:  (ctx) => '数据库约束冲突(unique/foreign key),业务层应该先查再写',
  },
  {
    match: (s) => /timeout/i.test(s),
    hint:  (ctx) => '某个异步操作超时;检查是否漏 await 或外部依赖未 mock',
  },
];

function buildHint(ctx) {
  const stack = ctx.stack || '';
  for (const p of PATTERNS) {
    if (p.match(stack)) return p.hint(ctx);
  }
  // fallback: Tester 在 prompt 里被要求人工审一遍补充
  return `${ctx.testName}: 期望 ${ctx.expected} 收 ${ctx.got},检查 stack 推断原因 (Tester 应补充)`;
}

module.exports = { buildHint, PATTERNS };

7.3 bug-bridge.js · Tester ↔ bugs.ndjson

// server/services/agent-tools/bug-bridge.js · ~70 行
const fs   = require('fs');
const path = require('path');
const { bugTracker } = require('../bug-tracker');

/**
 * Tester 收到 vitest failures 后,自动桥接到 bugs.ndjson
 * - dedup: 同 unit 同 test 已有 open/fixing → 复用 id
 * - 新 fail → bug_open
 * 调用方:Tester Agent 的工具调用 (而非直接 fs)
 */
function bridgeFailuresToBugs(pid, unitId, runId, failures) {
  const bugs_opened = [];
  const existing = bugTracker.list(pid, {
    unit_id: unitId,
    status: ['open', 'fixing'],
  });

  for (const f of failures) {
    const dedupKey = `${unitId}:${f.test}`;
    const dup = existing.find(b => bugTracker.dedupKey(b) === dedupKey);

    if (dup) {
      f.bug_id = dup.id;   // 复用,不新开
    } else {
      const bug = bugTracker.open(pid, {
        source: 'unit_test',
        unit_id: unitId, run_id: runId,
        test_name: f.test,
        category: 'unknown',    // BugTriager 在 L2 才精修
        severity: guessSeverity(f),
        title: `${f.test}: 期望 ${f.expected} 收 ${f.got}`,
        detail: f.stack,
        affected_units: [unitId],
      });
      f.bug_id = bug.id;
      bugs_opened.push(bug.id);
    }
  }

  return { bugs_opened };
}

function bridgePassToBugs(pid, unitId, runId) {
  // 单测绿了 → 该 unit 所有 open/fixing 转 fixed
  const existing = bugTracker.list(pid, {
    unit_id: unitId, status: ['open', 'fixing'],
  });
  const transitioned = [];
  for (const b of existing) {
    bugTracker.transition(pid, b.id, {
      to: 'fixed', by: 'tester',
      fix_refs: [runId],
    });
    transitioned.push({ id: b.id, from: b.status, to: 'fixed' });
  }
  return { bugs_transitioned: transitioned };
}

function guessSeverity(f) {
  if (/5\d\d/.test(String(f.got))) return 'high';
  if (f.test.includes('security') || f.test.includes('auth')) return 'critical';
  return 'medium';
}

module.exports = { bridgeFailuresToBugs, bridgePassToBugs };

⑧ 自检 Checklist

#检查项命令 / 工具通过判定
1tests/ 至少含 N 个 it()(N = acceptance + contract responses)cheerio-like scantrue
2result.json 通过 typeboxschema check (M-7)合规
3每个 failure 有非空 hintjsonpath无空 hint
4bugs.ndjson 同步对照 bug_open/transition 输出bugs_opened/transitioned 全部有 entry
5沙箱无越界scanSandboxviolations=[]
6没 mock 整个被测模块spec 文件 grep vi.mock('../code/'无
7todolist 全 doneread agent-state/tester-todo.jsontrue
8timeline 含 unit_tested / test_failed / test_passedtail命中
9vitest 没超时(60s)duration_ms < 60000true
10acceptance_coverage = 1.0compare spec.json acceptance vs tests it() 名称1.0

⑨ 失败模式与降级

场景检测降级
vitest 自身崩parse_vitest crashed=trueresult.crashed=true · next_action='retry_tester'
Tester 写 trivial test(永远绿)spec 强制至少 1 negative caseArchitect 在 spec.json 加 negative criterion
Tester 越权改 code/沙箱拒绝—
vitest 超时(>60s)Bash 工具超时kill + result.crashed=true · 排查死循环测试
hint 为空(只 fallback)self-check 4Tester 必须人工审 + 补充
npm install 慢同 Coder缓存 node_modules 共享
测试运行依赖网络(fetch real API)vitest spec 扫描禁止 · 必须 mock fetch
vitest 版本与 LemonBoss 不一致package.json 校验用 LemonBoss 既有 vitest 版本

⑩ 与其他 Agent 握手摘要

对方Tester 给的Tester 收的
Codertests/* + result.json.failures (含 hint)code/** + contract.json
Architect(无)spec.json acceptance + sdd tests 列表
Integratortests/ 可复用作回归基础(无)
BugTrackerbug_open / bug_transition—
LoopControllerTesterOutput JSONTesterInput
UnitRegistry——(LoopController 中介)
memory/devloop/tester/很少写启动注入

⑪ 实战验证命令

# 前置: Coder 已跑通,units/todo-be/code/* + contract.json 就绪

# 1. 注册模板
#    system/agents/devloop-tester/prompt.md ← 本页 §2.2

# 2. 准备输入
echo '{"pid":"p-test001","unit_id":"todo-be","mode":"test","call_id":"call-t-1","company_id":"c-test"}' > /tmp/tester-test.json

# 3. 调 Tester
curl -X POST http://localhost:3010/api/devloop/_test/run-agent/tester -d @/tmp/tester-test.json

# 4. 期望:30-60 秒内
#    units/todo-be/tests/*.spec.ts 生成
#    units/todo-be/runs/{runId}/result.json 含 summary + failures (如有)

# 5. 验证
ls data/companies/c-test/projects/p-test001/units/todo-be/tests/
# 期望: 至少 1 个 .spec.ts

cat data/companies/c-test/projects/p-test001/units/todo-be/runs/*/result.json | jq .summary
# 期望: { total: ≥4, passed/failed 看 Coder 代码质量 }

cat data/companies/c-test/projects/p-test001/bugs.ndjson | head -5
# 期望: 若有 failure,每条 failure 对应 1 行 bug 记录 (status=open)

# 6. 模拟 Coder 修后 retry
echo '{"pid":"p-test001","unit_id":"todo-be","mode":"retry","call_id":"call-t-2","company_id":"c-test"}' > /tmp/tester-retry.json
curl -X POST http://localhost:3010/api/devloop/_test/run-agent/tester -d @/tmp/tester-retry.json

# 期望:
#   result.json ok=true (Coder 已根据 hint 改了)
#   bugs.ndjson 末尾新增一行 (同 id, status=fixed)
node -e "
const {bugTracker} = require('./server/services/bug-tracker');
console.log(bugTracker.list('p-test001'));
"
# 期望: 全部 bug 状态 = fixed
Coder + Tester 这两 §⑪ 都通过 = L1 完整闭环验证完成。Coder/Tester 反复跑直到 unit passed,这是整个 DevLoop 最高频运行的循环。