1fcd811c12
8 章设计文档 HTML(架构决策/状态设计/DB Schema/记忆注入/Agent 规格/ 运维护栏/安全沙箱/前端 API 契约),全图深色内联 SVG,附 docs/index.html 索引。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1151 lines
40 KiB
Markdown
1151 lines
40 KiB
Markdown
# Maestro Refactor Phase 1 Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Upgrade Task model (taskType/ownedFiles/expectedOutput), CAS claim scheduling, CPM-based scoring with agingBonus + couplingPenalty, fable-5 model tiers, planner decompose JSON extension, and pipeline hardgates (checks分项/diff体量).
|
||
|
||
**Architecture:** Bottom-up: schema + types first (Task 1), then store correctness (Task 2), then scheduling intelligence (Task 3), model upgrades (Task 4), planner output enrichment (Task 5), pipeline hardgates (Task 6). Each task produces independently testable changes. Later tasks reference types from earlier ones.
|
||
|
||
**Tech Stack:** TypeScript 5, better-sqlite3, Node.js ≥22, tsx (dev runner), `node --test` via `npm test`
|
||
|
||
## Global Constraints
|
||
|
||
- Run all tests: `npm test`
|
||
- Run single test file: `tsx --test test/<file>.test.ts`
|
||
- Type-check (no build): `npm run typecheck`
|
||
- Build: `npm run build`
|
||
- Tests use real in-memory SQLite (`new Store(':memory:')`) — no SQLite mocking
|
||
- Test imports: `import { test } from 'node:test'; import assert from 'node:assert/strict';`
|
||
- Store imported as: `import { Store } from '../src/store/index.js'`
|
||
- Do NOT push to remote or deploy
|
||
- fable-5 model ID: `'claude-fable-5'`
|
||
|
||
## Pre-flight: What's already done
|
||
|
||
The following were already implemented — do NOT re-implement:
|
||
- `verdict → hardgate` in `/Users/wangjia/code/maestro/src/daemon/ingest.ts` (lines 149–165)
|
||
- Parallel `Promise.all` reviews in `/Users/wangjia/code/maestro/src/executor/pipeline.ts` (line 154)
|
||
- `syncMain` before execution in `/Users/wangjia/code/maestro/src/executor/pipeline.ts` (lines 121–127)
|
||
- `conflict` pipeline in `/Users/wangjia/code/maestro/src/executor/pipeline.ts` (lines 211–273)
|
||
- Planner `maxTurns` by complexity (90/60/40) in `/Users/wangjia/code/maestro/src/executor/runner.ts` (line 168)
|
||
- Planner `depth` injection in `buildPlannerPrompt` decompose branch (line 133)
|
||
- Planner git read-only tools (log/diff/show) in `runPlanner` (line 171)
|
||
- Decompose `deps` sequence→taskId mapping in `/Users/wangjia/code/maestro/src/daemon/ingest.ts` (lines 103–116)
|
||
- `checks` + `auto_approve_plan`/`auto_approve_exec` columns in projects schema
|
||
|
||
---
|
||
|
||
## Task 1: Schema Migration + Task Model Types
|
||
|
||
**Files:**
|
||
- Modify: `/Users/wangjia/code/maestro/src/store/schema.sql`
|
||
- Modify: `/Users/wangjia/code/maestro/src/store/db.ts`
|
||
- Modify: `/Users/wangjia/code/maestro/src/model/types.ts`
|
||
- Modify: `/Users/wangjia/code/maestro/src/store/mappers.ts`
|
||
- Test: `/Users/wangjia/code/maestro/test/store.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces: `TaskType = 'feature' | 'bugfix' | 'refactor' | 'chore' | 'docs'`
|
||
- Produces: `TaskScope = 'file' | 'module' | 'service' | 'cross-service'`
|
||
- Produces: `Task.taskType`, `Task.scope`, `Task.ownedFiles: string[]`, `Task.expectedOutput: string | null`, `Task.parentVersionId: string | null`, `Task.version: number`
|
||
- Consumed by: Tasks 2, 3, 5
|
||
|
||
- [ ] **Step 1: Write failing test for new Task fields**
|
||
|
||
Add this test to `/Users/wangjia/code/maestro/test/store.test.ts`:
|
||
|
||
```typescript
|
||
test('Task 新字段默认值', () => {
|
||
const store = new Store(':memory:');
|
||
const p = store.createProject({ name: 'p', repoPath: '/tmp/t1-' + Math.random(), autonomy: 'manual' });
|
||
const t = store.createTask({ projectId: p.id, title: 'feat', complexity: 'easy' });
|
||
assert.strictEqual(t.taskType, null);
|
||
assert.strictEqual(t.scope, null);
|
||
assert.deepStrictEqual(t.ownedFiles, []);
|
||
assert.strictEqual(t.expectedOutput, null);
|
||
assert.strictEqual(t.parentVersionId, null);
|
||
assert.strictEqual(t.version, 1);
|
||
});
|
||
|
||
test('patchTask 更新 ownedFiles 与 taskType', () => {
|
||
const store = new Store(':memory:');
|
||
const p = store.createProject({ name: 'p', repoPath: '/tmp/t1b-' + Math.random(), autonomy: 'manual' });
|
||
const t = store.createTask({ projectId: p.id, title: 'feat', complexity: 'easy' });
|
||
const updated = store.patchTask(t.id, {
|
||
taskType: 'feature',
|
||
scope: 'module',
|
||
ownedFiles: ['src/foo.ts', 'src/bar.ts'],
|
||
expectedOutput: 'all tests pass',
|
||
});
|
||
assert.strictEqual(updated.taskType, 'feature');
|
||
assert.strictEqual(updated.scope, 'module');
|
||
assert.deepStrictEqual(updated.ownedFiles, ['src/foo.ts', 'src/bar.ts']);
|
||
assert.strictEqual(updated.expectedOutput, 'all tests pass');
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
```bash
|
||
tsx --test test/store.test.ts 2>&1 | grep -E "fail|error|FAIL|Error" | head -5
|
||
```
|
||
|
||
Expected: fails with `t.taskType is not a property` or similar.
|
||
|
||
- [ ] **Step 3: Update schema.sql — add new columns to tasks CREATE TABLE**
|
||
|
||
In `/Users/wangjia/code/maestro/src/store/schema.sql`, find the `CREATE TABLE IF NOT EXISTS tasks` block and add 6 new columns before the closing `)`:
|
||
|
||
```sql
|
||
task_type TEXT, -- feature|bugfix|refactor|chore|docs
|
||
scope TEXT, -- file|module|service|cross-service
|
||
owned_files TEXT NOT NULL DEFAULT '[]', -- JSON string[] 声明的文件所有权
|
||
expected_output TEXT, -- "done" 的可验证描述(exec_review 验收用)
|
||
parent_version_id TEXT, -- 版本链:reject 后新建版指向前一版
|
||
version INTEGER NOT NULL DEFAULT 1 -- 任务版本号(每次 reject 递增)
|
||
```
|
||
|
||
Also add to runs table before closing `)`:
|
||
|
||
```sql
|
||
trace_id TEXT -- Trace ID(跨进程可观测性)
|
||
```
|
||
|
||
- [ ] **Step 4: Update db.ts — add ensureColumn migrations**
|
||
|
||
In `/Users/wangjia/code/maestro/src/store/db.ts`, after line 34 (`ensureColumn(db, 'runs', 'last_seq', ...)`), add:
|
||
|
||
```typescript
|
||
ensureColumn(db, 'tasks', 'task_type', 'task_type TEXT');
|
||
ensureColumn(db, 'tasks', 'scope', 'scope TEXT');
|
||
ensureColumn(db, 'tasks', 'owned_files', "owned_files TEXT NOT NULL DEFAULT '[]'");
|
||
ensureColumn(db, 'tasks', 'expected_output', 'expected_output TEXT');
|
||
ensureColumn(db, 'tasks', 'parent_version_id', 'parent_version_id TEXT');
|
||
ensureColumn(db, 'tasks', 'version', 'version INTEGER NOT NULL DEFAULT 1');
|
||
ensureColumn(db, 'tasks', 'claimed_at', 'claimed_at TEXT'); // CAS claim(Task 2 用)
|
||
ensureColumn(db, 'runs', 'trace_id', 'trace_id TEXT');
|
||
```
|
||
|
||
- [ ] **Step 5: Update types.ts — add TaskType, TaskScope and Task fields**
|
||
|
||
In `/Users/wangjia/code/maestro/src/model/types.ts`, add after the imports block:
|
||
|
||
```typescript
|
||
export type TaskType = 'feature' | 'bugfix' | 'refactor' | 'chore' | 'docs';
|
||
export type TaskScope = 'file' | 'module' | 'service' | 'cross-service';
|
||
```
|
||
|
||
In the `Task` interface, add after `lastRunError`:
|
||
|
||
```typescript
|
||
taskType: TaskType | null;
|
||
scope: TaskScope | null;
|
||
ownedFiles: string[];
|
||
expectedOutput: string | null;
|
||
parentVersionId: string | null;
|
||
version: number;
|
||
```
|
||
|
||
- [ ] **Step 6: Update mappers.ts — TaskRow + rowToTask**
|
||
|
||
In `/Users/wangjia/code/maestro/src/store/mappers.ts`, add to `TaskRow` interface (after `last_run_error`):
|
||
|
||
```typescript
|
||
task_type: string | null;
|
||
scope: string | null;
|
||
owned_files: string;
|
||
expected_output: string | null;
|
||
parent_version_id: string | null;
|
||
version: number;
|
||
claimed_at: string | null; // CAS claim(Task 2 用)
|
||
```
|
||
|
||
Add to `RunRow` interface (after `last_seq`):
|
||
|
||
```typescript
|
||
trace_id: string | null;
|
||
```
|
||
|
||
Update `rowToTask` function body (add before closing `}`):
|
||
|
||
```typescript
|
||
taskType: (r.task_type as TaskType | null) ?? null,
|
||
scope: (r.scope as TaskScope | null) ?? null,
|
||
ownedFiles: r.owned_files ? (JSON.parse(r.owned_files) as string[]) : [],
|
||
expectedOutput: r.expected_output ?? null,
|
||
parentVersionId: r.parent_version_id ?? null,
|
||
version: r.version ?? 1,
|
||
```
|
||
|
||
Also add import at top of mappers.ts: `import type { TaskType, TaskScope } from '../model/types.js';`
|
||
|
||
- [ ] **Step 7: Update store.ts — patchTask to handle new fields**
|
||
|
||
In `/Users/wangjia/code/maestro/src/store/store.ts`, find the `patchTask` method. Update its accepted fields to include the new ones. Find the `PatchTaskInput` type (or wherever patchTask's input type is declared) and add:
|
||
|
||
```typescript
|
||
taskType?: TaskType | null;
|
||
scope?: TaskScope | null;
|
||
ownedFiles?: string[];
|
||
expectedOutput?: string | null;
|
||
```
|
||
|
||
In the `patchTask` implementation, map these new fields into the SQL UPDATE. The method needs to build the SET clause dynamically — add handling for each new field that serializes `ownedFiles` as JSON:
|
||
|
||
```typescript
|
||
if ('taskType' in patch) sets.push(`task_type = '${patch.taskType ?? null}'`) // use parameterized query
|
||
if ('ownedFiles' in patch) sets.push(/* JSON.stringify(patch.ownedFiles) */)
|
||
```
|
||
|
||
> **Note:** Look at how patchTask currently handles existing optional fields (title, priority, deps, etc.) and follow the same pattern for parameterized queries. Do NOT use string interpolation for values.
|
||
|
||
- [ ] **Step 8: Run tests**
|
||
|
||
```bash
|
||
npm test 2>&1 | tail -20
|
||
```
|
||
|
||
Expected: the two new store tests pass; all existing tests still pass.
|
||
|
||
- [ ] **Step 9: Typecheck**
|
||
|
||
```bash
|
||
npm run typecheck
|
||
```
|
||
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add src/store/schema.sql src/store/db.ts src/model/types.ts src/store/mappers.ts src/store/store.ts test/store.test.ts
|
||
git commit -m "feat(model): add taskType/scope/ownedFiles/expectedOutput/version fields + schema migration"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: CAS Claim + Execution Lock
|
||
|
||
**Files:**
|
||
- Modify: `/Users/wangjia/code/maestro/src/store/store.ts`
|
||
- Modify: `/Users/wangjia/code/maestro/src/daemon/orchestrator.ts`
|
||
- Test: `/Users/wangjia/code/maestro/test/orchestrator.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `claimed_at` column from Task 1
|
||
- Produces: `store.claimTask(taskId, toStatus)` — CAS claim that sets `claimed_at`; `→ ready` transition clears `claimed_at = NULL`
|
||
- Produces: execution lock — any task with a `started` run rejects mutations on title/spec/plan/deps/complexity
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
Add to `/Users/wangjia/code/maestro/test/orchestrator.test.ts`:
|
||
|
||
```typescript
|
||
test('CAS claim — 同一任务不能被双重领取', (t) => {
|
||
const store = new Store(':memory:');
|
||
const p = store.createProject({ name: 'cas', repoPath: '/tmp/cas-' + Math.random(), autonomy: 'auto-easy' });
|
||
const task = store.createTask({ projectId: p.id, title: 'x', complexity: 'easy' });
|
||
store.setOperations(task.id, 'op');
|
||
store.transition(task.id, 'queued', { by: 'test' });
|
||
store.transition(task.id, 'executing', { by: 'test' });
|
||
|
||
// 第一次 claim 成功(CAS)
|
||
const ok1 = store.casClaimTask(task.id, 'run-001');
|
||
assert.ok(ok1, '第一次 claim 应成功');
|
||
|
||
// 第二次 claim 同一任务失败
|
||
const ok2 = store.casClaimTask(task.id, 'run-002');
|
||
assert.ok(!ok2, '双重 claim 应失败');
|
||
});
|
||
|
||
test('transition → ready 清空 claimed_at', (t) => {
|
||
const store = new Store(':memory:');
|
||
const p = store.createProject({ name: 'cas2', repoPath: '/tmp/cas2-' + Math.random(), autonomy: 'auto-easy' });
|
||
const task = store.createTask({ projectId: p.id, title: 'y', complexity: 'easy' });
|
||
store.setOperations(task.id, 'op');
|
||
store.transition(task.id, 'queued', { by: 'test' });
|
||
store.transition(task.id, 'executing', { by: 'test' });
|
||
store.casClaimTask(task.id, 'run-x');
|
||
|
||
// reject → ready 应清空 claimed_at,使下次 CAS 可成功
|
||
store.transition(task.id, 'exec_review', { by: 'test' });
|
||
store.decide(task.id, 'reject', 'human', '需要修改');
|
||
const ok = store.casClaimTask(task.id, 'run-y');
|
||
assert.ok(ok, 'ready 后应可再次 claim');
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
tsx --test test/orchestrator.test.ts 2>&1 | grep -E "fail|Error|casClaimTask" | head -5
|
||
```
|
||
|
||
Expected: `store.casClaimTask is not a function`
|
||
|
||
- [ ] **Step 3: Add casClaimTask to store.ts**
|
||
|
||
In `/Users/wangjia/code/maestro/src/store/store.ts`, add this new method to the `Store` class (near the `claimTick`-adjacent logic):
|
||
|
||
```typescript
|
||
/**
|
||
* CAS 领取:仅当 claimed_at IS NULL 时才 SET claimed_at=now(),防止双重领取。
|
||
* 返回 true=领取成功,false=已被其他线程/进程抢先。
|
||
*/
|
||
casClaimTask(taskId: string, runId: string): boolean {
|
||
const result = this.db
|
||
.prepare(`UPDATE tasks SET claimed_at = ?, updated_at = ? WHERE id = ? AND claimed_at IS NULL`)
|
||
.run(now(), now(), taskId);
|
||
return result.changes === 1;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Clear claimed_at when transitioning to ready**
|
||
|
||
In `/Users/wangjia/code/maestro/src/store/store.ts`, find the `transition` method (around line 899 or `UPDATE tasks SET status = ?, updated_at = ?`). When the target status is `'ready'`, also clear `claimed_at`:
|
||
|
||
```typescript
|
||
// In transition() — when to === 'ready', clear claimed_at
|
||
if (to === 'ready') {
|
||
this.db.prepare(`UPDATE tasks SET status = ?, claimed_at = NULL, updated_at = ? WHERE id = ?`).run(to, now(), taskId);
|
||
} else {
|
||
this.db.prepare(`UPDATE tasks SET status = ?, updated_at = ? WHERE id = ?`).run(to, now(), taskId);
|
||
}
|
||
```
|
||
|
||
Also clear in `decide()` when transitioning back to `ready` (exec reject), `analyzing` (plan reject), `speccing` (spec reject) — all these pass through `transition()` already, so the check above covers them.
|
||
|
||
Also ensure `requeueTask()` (lines 967–979) clears `claimed_at`:
|
||
|
||
```typescript
|
||
// After updating retry_baseline in requeueTask:
|
||
this.db.prepare(`UPDATE tasks SET claimed_at = NULL, updated_at = ? WHERE id = ?`).run(now(), taskId);
|
||
```
|
||
|
||
- [ ] **Step 5: Use casClaimTask in orchestrator.claimOne**
|
||
|
||
In `/Users/wangjia/code/maestro/src/daemon/orchestrator.ts`, in `claimOne()` (around line 168), after `store.transition(task.id, 'executing', ...)` succeeds, add a CAS check:
|
||
|
||
```typescript
|
||
// After both transitions (queued → executing), verify CAS claim
|
||
if (!store.casClaimTask(task.id, run.id)) {
|
||
// Another process already claimed this task — abort and clean up
|
||
log.error(`任务 ${task.id} CAS claim 失败(并发冲突),放弃本次领取`);
|
||
return;
|
||
}
|
||
```
|
||
|
||
> Place this after `store.startRun()` and before `d.writeJobSpec()`.
|
||
|
||
- [ ] **Step 6: Run tests**
|
||
|
||
```bash
|
||
npm test 2>&1 | tail -20
|
||
```
|
||
|
||
Expected: new CAS tests pass; all existing tests still pass.
|
||
|
||
- [ ] **Step 7: Typecheck**
|
||
|
||
```bash
|
||
npm run typecheck
|
||
```
|
||
|
||
- [ ] **Step 8: Add execution lock to mutation methods in store.ts**
|
||
|
||
在途任务(有 `status=started` 的 run)应拒绝修改 title/spec/plan/complexity/deps。在 store.ts 中新增内部校验辅助方法:
|
||
|
||
```typescript
|
||
/** 若任务有在途 run(status=started),抛出 StoreError(执行期锁)。 */
|
||
private assertNoActiveRun(taskId: string): void {
|
||
const row = this.db.prepare(
|
||
`SELECT COUNT(*) AS n FROM runs WHERE task_id = ? AND status = 'started'`,
|
||
).get(taskId) as { n: number };
|
||
if (row.n > 0) throw new StoreError('任务执行中,禁止修改(等 run 结束后再操作)');
|
||
}
|
||
```
|
||
|
||
在以下方法开头加 `this.assertNoActiveRun(taskId)` 调用:
|
||
- `setSpec(taskId, ...)`
|
||
- `setPlan(taskId, ...)`
|
||
- `setOperations(taskId, ...)`
|
||
- `patchTask(taskId, patch)` — 仅当 patch 含 title/complexity/deps/priority 时
|
||
|
||
在 `patchField` 私有方法里统一加也可以(因为 setSpec/setPlan/setOperations 都走它)。
|
||
|
||
- [ ] **Step 9: Write test for execution lock**
|
||
|
||
```typescript
|
||
test('执行期间禁止修改 spec(执行期锁)', () => {
|
||
const store = new Store(':memory:');
|
||
const p = store.createProject({ name: 'lock', repoPath: '/tmp/lock-' + Math.random(), autonomy: 'manual' });
|
||
const t = store.createTask({ projectId: p.id, title: 'x', complexity: 'medium' });
|
||
store.transition(t.id, 'speccing', { by: 'test' });
|
||
const run = store.startRun(t.id, 'planner');
|
||
|
||
// 有在途 run 时修改 spec 应抛出
|
||
assert.throws(() => store.setSpec(t.id, 'new spec'), /执行中/);
|
||
|
||
// run 结束后可以修改
|
||
store.finishRun(run.id, 'failed', { error: 'test' });
|
||
assert.doesNotThrow(() => store.setSpec(t.id, 'new spec'));
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add src/store/store.ts src/daemon/orchestrator.ts test/orchestrator.test.ts
|
||
git commit -m "feat(store): CAS claimed_at 防双重 claim + transition→ready 清零 + 执行期锁"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Scheduling Upgrade (rankU + agingBonus + couplingPenalty)
|
||
|
||
**Files:**
|
||
- Modify: `/Users/wangjia/code/maestro/src/model/scoring.ts`
|
||
- Modify: `/Users/wangjia/code/maestro/src/daemon/orchestrator.ts`
|
||
- Test: `/Users/wangjia/code/maestro/test/orchestrator.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Task.ownedFiles` from Task 1; `Task.createdAt`
|
||
- Produces: `rankU(taskId, allByIdMap, dependentsMap, cache): number` — recursive CPM unlock value
|
||
- Produces: `agingBonus(task, nowMs): number`
|
||
- Produces: `couplingPenalty(task, inFlight): number`
|
||
- Produces: new `rankByScore(candidates, all, inFlight, nowMs)` signature (adds `inFlight` and `nowMs`)
|
||
- Consumed by: `orchestrator.claimable()`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
Add to `/Users/wangjia/code/maestro/test/orchestrator.test.ts`:
|
||
|
||
```typescript
|
||
import { rankByScore, agingBonus, couplingPenalty } from '../src/model/scoring.js';
|
||
|
||
test('agingBonus — 新建任务无加成', () => {
|
||
const task = { createdAt: new Date().toISOString(), priority: 1 } as Task;
|
||
assert.strictEqual(agingBonus(task, Date.now()), 0);
|
||
});
|
||
|
||
test('agingBonus — 等待 48h 达到 baseScore 上限', () => {
|
||
const fortyEightHoursAgo = new Date(Date.now() - 48 * 3600 * 1000).toISOString();
|
||
const task = { createdAt: fortyEightHoursAgo, priority: 1 } as Task;
|
||
// P1 baseScore = 2, bonus after 48h = min(2, 48/48) = min(2,1) = 1
|
||
assert.ok(agingBonus(task, Date.now()) >= 0.99);
|
||
assert.ok(agingBonus(task, Date.now()) <= 1.01);
|
||
});
|
||
|
||
test('couplingPenalty — 无重叠文件时为 0', () => {
|
||
const candidate = { ownedFiles: ['src/a.ts'] } as Task;
|
||
const inFlight = [{ ownedFiles: ['src/b.ts'] } as Task];
|
||
assert.strictEqual(couplingPenalty(candidate, inFlight), 0);
|
||
});
|
||
|
||
test('couplingPenalty — 每个重叠文件 -0.5', () => {
|
||
const candidate = { ownedFiles: ['src/a.ts', 'src/b.ts', 'src/c.ts'] } as Task;
|
||
const inFlight = [{ ownedFiles: ['src/a.ts', 'src/b.ts'] } as Task];
|
||
assert.strictEqual(couplingPenalty(candidate, inFlight), 1.0);
|
||
});
|
||
|
||
test('rankByScore — agingBonus 提升得分', () => {
|
||
const old = { id: 'old', priority: 2, createdAt: new Date(Date.now() - 96 * 3600 * 1000).toISOString(),
|
||
ownedFiles: [], deps: [], status: 'ready' } as unknown as Task;
|
||
const fresh = { id: 'fresh', priority: 2, createdAt: new Date().toISOString(),
|
||
ownedFiles: [], deps: [], status: 'ready' } as unknown as Task;
|
||
const ranked = rankByScore([old, fresh], [old, fresh], [], Date.now());
|
||
assert.strictEqual(ranked[0].task.id, 'old', '等待更久的应排前');
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
tsx --test test/orchestrator.test.ts 2>&1 | grep -E "agingBonus|couplingPenalty" | head -5
|
||
```
|
||
|
||
Expected: `agingBonus is not exported`
|
||
|
||
- [ ] **Step 3: Update scoring.ts**
|
||
|
||
Replace `/Users/wangjia/code/maestro/src/model/scoring.ts` content:
|
||
|
||
```typescript
|
||
import type { Task } from './types.js';
|
||
|
||
/** P0=3 / P1=2 / P2=1 */
|
||
export function baseScore(priority: number): number {
|
||
return Math.max(1, 3 - priority);
|
||
}
|
||
|
||
/** 反向索引:taskId → 直接依赖它的任务列表 */
|
||
export function buildDependentsIndex(tasks: Task[]): Map<string, Task[]> {
|
||
const idx = new Map<string, Task[]>();
|
||
for (const t of tasks) {
|
||
for (const d of t.deps) {
|
||
const list = idx.get(d) ?? [];
|
||
list.push(t);
|
||
idx.set(d, list);
|
||
}
|
||
}
|
||
return idx;
|
||
}
|
||
|
||
/**
|
||
* CPM 后向传播:递归累计"解锁价值"。
|
||
* cache 防止 DAG 中重复计算(deps 无环保证终止)。
|
||
*/
|
||
export function rankU(
|
||
taskId: string,
|
||
byId: Map<string, Task>,
|
||
dependents: Map<string, Task[]>,
|
||
cache: Map<string, number>,
|
||
): number {
|
||
if (cache.has(taskId)) return cache.get(taskId)!;
|
||
const task = byId.get(taskId);
|
||
if (!task) { cache.set(taskId, 0); return 0; }
|
||
const base = baseScore(task.priority);
|
||
const unlockValue = (dependents.get(taskId) ?? [])
|
||
.reduce((sum, dep) => sum + rankU(dep.id, byId, dependents, cache), 0);
|
||
const result = base + unlockValue;
|
||
cache.set(taskId, result);
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 老化加成:防止低优先级任务因文件耦合被长期回避。
|
||
* waitHours / 48 线性增长,上限 = baseScore(priority)(即 P0 最多 +3 点,P2 最多 +1 点)。
|
||
*/
|
||
export function agingBonus(task: Task, nowMs: number): number {
|
||
const base = baseScore(task.priority);
|
||
const waitHours = (nowMs - Date.parse(task.createdAt)) / 3_600_000;
|
||
return Math.min(base, waitHours / 48);
|
||
}
|
||
|
||
/**
|
||
* 耦合惩罚:ownedFiles 与在途任务有交集时,每个重叠文件 -0.5。
|
||
* 不硬 block(防饥饿),agingBonus 会最终覆盖惩罚。
|
||
*/
|
||
export function couplingPenalty(task: Task, inFlight: Task[]): number {
|
||
const files = new Set(task.ownedFiles ?? []);
|
||
if (files.size === 0) return 0;
|
||
let overlaps = 0;
|
||
for (const t of inFlight) {
|
||
for (const f of (t.ownedFiles ?? [])) {
|
||
if (files.has(f)) overlaps++;
|
||
}
|
||
}
|
||
return overlaps * 0.5;
|
||
}
|
||
|
||
/**
|
||
* 综合调度得分(在 claim 时现算,不落库):
|
||
* rankU(自身 + 递归解锁效应)
|
||
* + chainInertia(已完成依赖的 baseScore 之和,链条惯性)
|
||
* + agingBonus(等待越久加分越多)
|
||
* - couplingPenalty(文件重叠减分,防并发冲突)
|
||
*/
|
||
export function scoreTask(
|
||
t: Task,
|
||
byId: Map<string, Task>,
|
||
dependents: Map<string, Task[]>,
|
||
inFlight: Task[],
|
||
nowMs: number,
|
||
rankUCache: Map<string, number>,
|
||
): number {
|
||
const chainInertia = t.deps.reduce((s, d) => {
|
||
const dep = byId.get(d);
|
||
return dep?.status === 'done' ? s + baseScore(dep.priority) : s;
|
||
}, 0);
|
||
return rankU(t.id, byId, dependents, rankUCache)
|
||
+ chainInertia
|
||
+ agingBonus(t, nowMs)
|
||
- couplingPenalty(t, inFlight);
|
||
}
|
||
|
||
/**
|
||
* 候选任务按综合得分降序排(同分则创建时间早优先)。
|
||
* inFlight: 当前在途任务列表(用于耦合惩罚)。
|
||
* nowMs: 当前时间(便于测试注入可控时钟)。
|
||
*/
|
||
export function rankByScore(
|
||
candidates: Task[],
|
||
all: Task[],
|
||
inFlight: Task[],
|
||
nowMs: number = Date.now(),
|
||
): Array<{ task: Task; score: number }> {
|
||
const byId = new Map(all.map((t) => [t.id, t]));
|
||
const dependents = buildDependentsIndex(all);
|
||
const cache = new Map<string, number>();
|
||
return candidates
|
||
.map((task) => ({ task, score: scoreTask(task, byId, dependents, inFlight, nowMs, cache) }))
|
||
.sort((a, b) => b.score - a.score || a.task.createdAt.localeCompare(b.task.createdAt));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Update orchestrator.ts to pass inFlight to rankByScore**
|
||
|
||
In `/Users/wangjia/code/maestro/src/daemon/orchestrator.ts`, in the `claimable()` function (around line 159), change:
|
||
|
||
```typescript
|
||
return rankByScore(candidates, tasks);
|
||
```
|
||
|
||
to:
|
||
|
||
```typescript
|
||
const inflightList = tasks.filter((t) => inflight.has(t.id));
|
||
return rankByScore(candidates, tasks, inflightList, d.nowMs());
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests**
|
||
|
||
```bash
|
||
npm test 2>&1 | tail -20
|
||
```
|
||
|
||
Expected: new scoring tests pass; all existing tests pass.
|
||
|
||
- [ ] **Step 6: Typecheck**
|
||
|
||
```bash
|
||
npm run typecheck
|
||
```
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/model/scoring.ts src/daemon/orchestrator.ts test/orchestrator.test.ts
|
||
git commit -m "feat(scoring): CPM rankU + agingBonus + couplingPenalty 调度算法升级"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Model Tier Upgrade (fable-5)
|
||
|
||
**Files:**
|
||
- Modify: `/Users/wangjia/code/maestro/src/executor/models.ts`
|
||
- Test: `/Users/wangjia/code/maestro/test/models.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces: `MODEL_FALLBACK_CHAIN = ['claude-fable-5', 'claude-opus-4-8', 'claude-sonnet-4-6']`
|
||
- Produces: planner hard → `'claude-fable-5'`; reviewer all → `'claude-fable-5'`; conflict all → `'claude-fable-5'`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
Add to `/Users/wangjia/code/maestro/test/models.test.ts`:
|
||
|
||
```typescript
|
||
import { pickModel, MODEL_FALLBACK_CHAIN, pickFallbackModel } from '../src/executor/models.js';
|
||
|
||
test('planner hard 用 fable-5', () => {
|
||
const task = { complexity: 'hard' } as Task;
|
||
const project = { model: null } as Project;
|
||
assert.strictEqual(pickModel(task, project, 'planner'), 'claude-fable-5');
|
||
});
|
||
|
||
test('reviewer 不受 project.model 影响,固定 fable-5', () => {
|
||
const task = { complexity: 'easy' } as Task;
|
||
const project = { model: 'claude-sonnet-4-6' } as Project;
|
||
assert.strictEqual(pickModel(task, project, 'reviewer'), 'claude-fable-5');
|
||
});
|
||
|
||
test('conflict 固定 fable-5', () => {
|
||
const task = { complexity: 'easy' } as Task;
|
||
const project = { model: null } as Project;
|
||
assert.strictEqual(pickModel(task, project, 'conflict'), 'claude-fable-5');
|
||
});
|
||
|
||
test('MODEL_FALLBACK_CHAIN 首档为 fable-5', () => {
|
||
assert.strictEqual(MODEL_FALLBACK_CHAIN[0], 'claude-fable-5');
|
||
});
|
||
|
||
test('pickFallbackModel — fable-5 回退到 opus-4-8', () => {
|
||
assert.strictEqual(pickFallbackModel('claude-fable-5'), 'claude-opus-4-8');
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
tsx --test test/models.test.ts 2>&1 | grep -E "fable|fail" | head -5
|
||
```
|
||
|
||
Expected: assertions fail (current hard=opus, reviewer=opus).
|
||
|
||
- [ ] **Step 3: Update models.ts**
|
||
|
||
In `/Users/wangjia/code/maestro/src/executor/models.ts`:
|
||
|
||
1. Change `MODEL_FALLBACK_CHAIN`:
|
||
```typescript
|
||
export const MODEL_FALLBACK_CHAIN = ['claude-fable-5', 'claude-opus-4-8', 'claude-sonnet-4-6'] as const;
|
||
```
|
||
|
||
2. Change `MODEL_TABLE.planner.hard`:
|
||
```typescript
|
||
hard: ['MAESTRO_MODEL_PLAN_HARD', 'claude-fable-5'],
|
||
```
|
||
|
||
3. Change all three `MODEL_TABLE.reviewer` entries to fable-5:
|
||
```typescript
|
||
reviewer: {
|
||
easy: ['MAESTRO_MODEL_REVIEW_EASY', 'claude-fable-5'],
|
||
medium: ['MAESTRO_MODEL_REVIEW_MEDIUM', 'claude-fable-5'],
|
||
hard: ['MAESTRO_MODEL_REVIEW_HARD', 'claude-fable-5'],
|
||
},
|
||
```
|
||
|
||
4. Change `MODEL_TABLE.conflict` and the `pickModel` conflict early return:
|
||
```typescript
|
||
// In MODEL_TABLE.conflict (占位,实际由 pickModel 直接返回 fable-5)
|
||
conflict: {
|
||
easy: ['', 'claude-fable-5'],
|
||
medium: ['', 'claude-fable-5'],
|
||
hard: ['', 'claude-fable-5'],
|
||
},
|
||
```
|
||
|
||
5. Update `pickModel` conflict return:
|
||
```typescript
|
||
if (role === 'conflict') return 'claude-fable-5';
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests**
|
||
|
||
```bash
|
||
npm test 2>&1 | tail -20
|
||
```
|
||
|
||
Expected: new model tests pass; all existing tests pass. Note: existing tests that assert `'claude-opus-4-8'` for reviewer/conflict/planner-hard will need to be updated to `'claude-fable-5'`.
|
||
|
||
- [ ] **Step 5: Typecheck**
|
||
|
||
```bash
|
||
npm run typecheck
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/executor/models.ts test/models.test.ts
|
||
git commit -m "feat(models): fable-5 为 planner-hard/reviewer/conflict 最强档;回退链首档改 fable-5"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Planner Decompose JSON Extension (ownedFiles + expectedOutput)
|
||
|
||
**Files:**
|
||
- Modify: `/Users/wangjia/code/maestro/src/executor/runner.ts`
|
||
- Modify: `/Users/wangjia/code/maestro/src/daemon/ingest.ts`
|
||
- Test: `/Users/wangjia/code/maestro/test/ingest.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Task.ownedFiles`, `Task.expectedOutput` from Task 1
|
||
- Produces: `buildPlannerPrompt` decompose branch requires `ownedFiles` and `expectedOutput` in subtask JSON
|
||
- Produces: `ingest.ts` decompose-result handler maps subtask `ownedFiles`/`expectedOutput` via `store.patchTask`
|
||
|
||
- [ ] **Step 1: Write failing test**
|
||
|
||
Add to `/Users/wangjia/code/maestro/test/ingest.test.ts`:
|
||
|
||
```typescript
|
||
test('decompose-result — ownedFiles/expectedOutput 落入子任务', () => {
|
||
withTmpDataDir(() => {
|
||
const store = new Store(':memory:');
|
||
const p = store.createProject({
|
||
name: 'p', repoPath: '/tmp/dr-' + Math.random(), autonomy: 'auto-approved',
|
||
});
|
||
const parent = store.createTask({ projectId: p.id, title: 'big feat', complexity: 'hard' });
|
||
store.transition(parent.id, 'analyzing', { by: 'test' });
|
||
const run = store.startRun(parent.id, 'planner', { worktree: p.repoPath });
|
||
|
||
const dir = runDir(run.id);
|
||
mkdirSync(dir, { recursive: true });
|
||
appendOutbox(run.id, {
|
||
type: 'decompose-result',
|
||
plan: 'big feature',
|
||
subtasks: [
|
||
{
|
||
title: '类型定义',
|
||
complexity: 'easy' as const,
|
||
priority: 0,
|
||
deps: [],
|
||
ownedFiles: ['src/model/types.ts'],
|
||
expectedOutput: 'typecheck 通过',
|
||
},
|
||
{
|
||
title: '实现层',
|
||
complexity: 'medium' as const,
|
||
priority: 1,
|
||
deps: [0],
|
||
ownedFiles: ['src/store/taskRepo.ts'],
|
||
expectedOutput: '单测通过',
|
||
},
|
||
],
|
||
transcriptRef: null,
|
||
sessionId: null,
|
||
});
|
||
ingestRun(store, noopLog, run.id);
|
||
|
||
const children = store.childrenOf(parent.id);
|
||
assert.strictEqual(children.length, 2);
|
||
assert.deepStrictEqual(children[0].ownedFiles, ['src/model/types.ts']);
|
||
assert.strictEqual(children[0].expectedOutput, 'typecheck 通过');
|
||
assert.deepStrictEqual(children[1].ownedFiles, ['src/store/taskRepo.ts']);
|
||
assert.strictEqual(children[1].expectedOutput, '单测通过');
|
||
});
|
||
});
|
||
```
|
||
|
||
> Note: you'll need to import `runDir` from protocol.ts and `mkdirSync` from fs. Check existing ingest.test.ts imports to see what's already imported.
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
```bash
|
||
tsx --test test/ingest.test.ts 2>&1 | grep -E "ownedFiles|expectedOutput|fail" | head -5
|
||
```
|
||
|
||
Expected: assertion fails (ownedFiles is `[]`, expectedOutput is `null`).
|
||
|
||
- [ ] **Step 3: Update buildPlannerPrompt in runner.ts**
|
||
|
||
In `/Users/wangjia/code/maestro/src/executor/runner.ts`, find `buildPlannerPrompt` (line 114), in the decompose branch, update the JSON example in the prompt:
|
||
|
||
Change this line (around line 150):
|
||
```typescript
|
||
'{"plan":"一句话分析与拆解理由","subtasks":[{"title":"子任务标题","complexity":"easy","priority":1,"deps":[]}]}',
|
||
```
|
||
|
||
To:
|
||
```typescript
|
||
'{"plan":"一句话分析与拆解理由","subtasks":[{"title":"子任务标题","complexity":"easy","priority":1,"deps":[],"ownedFiles":["src/example.ts"],"expectedOutput":"相关测试通过"}]}',
|
||
```
|
||
|
||
Also update the subtasks description bullet points (around line 138–139) to add:
|
||
```typescript
|
||
'- ownedFiles:该子任务主要修改的文件路径列表(JSON string[],无则填 [])',
|
||
'- expectedOutput:一句话描述该子任务"完成"的可验证标准(例:"typecheck 通过")',
|
||
```
|
||
|
||
Also update the table header line to add columns:
|
||
```typescript
|
||
'| # | 子任务标题 | 复杂度 | 优先级 | 依赖序号 | 主要文件 | 验收标准 |',
|
||
'|---|---|---|---|---|---|---|',
|
||
```
|
||
|
||
- [ ] **Step 4: Update ingest.ts decompose-result handler**
|
||
|
||
In `/Users/wangjia/code/maestro/src/daemon/ingest.ts`, in the `decompose-result` case (around line 83), update the subtask creation loop. After the `store.patchTask(createdIds[i], { deps: depIds })` call for deps, add ownedFiles/expectedOutput mapping:
|
||
|
||
```typescript
|
||
// After the deps mapping loop (around line 116), add:
|
||
for (let i = 0; i < rec.subtasks.length; i++) {
|
||
const sub = rec.subtasks[i] as {
|
||
ownedFiles?: string[];
|
||
expectedOutput?: string;
|
||
deps?: number[];
|
||
};
|
||
if (!createdIds[i]) continue;
|
||
const patch: Record<string, unknown> = {};
|
||
if (Array.isArray(sub.ownedFiles) && sub.ownedFiles.length > 0) {
|
||
patch.ownedFiles = sub.ownedFiles;
|
||
}
|
||
if (typeof sub.expectedOutput === 'string' && sub.expectedOutput.trim()) {
|
||
patch.expectedOutput = sub.expectedOutput.trim();
|
||
}
|
||
if (Object.keys(patch).length > 0) {
|
||
try {
|
||
store.patchTask(createdIds[i], patch);
|
||
} catch (e) {
|
||
log.error(`decompose: 更新子任务「${rec.subtasks[i].title}」元数据失败:${(e as Error).message}`);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
> **Important:** Place this loop AFTER the deps mapping loop (not inside it). Both loops iterate the same `rec.subtasks` array independently.
|
||
|
||
- [ ] **Step 5: Update OutboxPayload type in protocol.ts**
|
||
|
||
In `/Users/wangjia/code/maestro/src/executor/protocol.ts`, find the `decompose-result` payload type. The `subtasks` array currently has `{ title: string; complexity: Complexity }`. Extend it:
|
||
|
||
```typescript
|
||
// In the decompose-result payload subtasks item type:
|
||
subtasks: Array<{
|
||
title: string;
|
||
complexity: Complexity;
|
||
priority?: number;
|
||
deps?: number[];
|
||
ownedFiles?: string[]; // 新增
|
||
expectedOutput?: string; // 新增
|
||
}>;
|
||
```
|
||
|
||
- [ ] **Step 6: Run tests**
|
||
|
||
```bash
|
||
npm test 2>&1 | tail -20
|
||
```
|
||
|
||
Expected: new ingest test passes; all existing tests pass.
|
||
|
||
- [ ] **Step 7: Typecheck**
|
||
|
||
```bash
|
||
npm run typecheck
|
||
```
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add src/executor/runner.ts src/executor/protocol.ts src/daemon/ingest.ts test/ingest.test.ts
|
||
git commit -m "feat(planner): decompose JSON 扩展 ownedFiles/expectedOutput,落入子任务"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Checks分项闸 + Diff体量闸
|
||
|
||
**Files:**
|
||
- Modify: `/Users/wangjia/code/maestro/src/executor/pipeline.ts`
|
||
- Modify: `/Users/wangjia/code/maestro/src/store/schema.sql`
|
||
- Modify: `/Users/wangjia/code/maestro/src/store/db.ts`
|
||
- Modify: `/Users/wangjia/code/maestro/src/model/types.ts`
|
||
- Modify: `/Users/wangjia/code/maestro/src/store/mappers.ts`
|
||
- Test: `/Users/wangjia/code/maestro/test/pipeline.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `project.checks: string | null` (existing field, JSON `{lint:"...",typecheck:"..."}`)
|
||
- Produces: new `Project.diffMaxFiles: number` (default 100)
|
||
- Produces: `runPipeline` emits `failed` if any check has non-zero exit OR if changed file count > `diffMaxFiles`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
Add to `/Users/wangjia/code/maestro/test/pipeline.test.ts`:
|
||
|
||
```typescript
|
||
test('checks分项闸 — lint 失败阻止进入 exec_review', async () => {
|
||
const job = makeJob({ complexity: 'easy' }); // use existing test helper
|
||
job.project.checks = JSON.stringify({ lint: 'exit 1' }); // always-fail lint
|
||
|
||
const emitted: OutboxPayload[] = [];
|
||
const mockDeps = makeMockDeps({
|
||
// runTask succeeds and commits
|
||
runTask: async () => ({ ok: true, transcriptRef: null, sessionId: null, finalText: 'ok' }),
|
||
// verify passes
|
||
verify: async () => ({ ok: true }),
|
||
// no reviews needed — should not reach them
|
||
reviewCode: async () => { throw new Error('should not reach review'); },
|
||
reviewSecurity: async () => { throw new Error('should not reach review'); },
|
||
});
|
||
await runPipeline(job, mockDeps, (p) => emitted.push(p));
|
||
|
||
const failed = emitted.find((e) => e.type === 'failed');
|
||
assert.ok(failed, '应 emit failed');
|
||
assert.ok((failed as { type: 'failed'; error: string }).error.includes('lint'), 'error 应说明 lint 失败');
|
||
});
|
||
|
||
test('diff体量闸 — 超过 diffMaxFiles 时阻止进入 exec_review', async () => {
|
||
const job = makeJob({ complexity: 'easy' });
|
||
job.project.diffMaxFiles = 2; // 限制 2 个文件
|
||
|
||
const emitted: OutboxPayload[] = [];
|
||
const mockDeps = makeMockDeps({
|
||
runTask: async () => ({ ok: true, transcriptRef: null, sessionId: null, finalText: 'ok' }),
|
||
verify: async () => ({ ok: true }),
|
||
// worktreeDiff returns a summary with 5 files changed
|
||
worktreeDiff: async () => ({
|
||
diffSummary: ' a.ts | 1+\n b.ts | 1+\n c.ts | 1+\n d.ts | 1+\n e.ts | 1+\n 5 files changed, 5 insertions(+)',
|
||
commits: ['abc foo'],
|
||
}),
|
||
reviewCode: async () => { throw new Error('should not reach'); },
|
||
reviewSecurity: async () => { throw new Error('should not reach'); },
|
||
});
|
||
await runPipeline(job, mockDeps, (p) => emitted.push(p));
|
||
|
||
const failed = emitted.find((e) => e.type === 'failed');
|
||
assert.ok(failed, '应 emit failed');
|
||
assert.ok((failed as { error: string }).error.includes('diff'), 'error 应说明 diff 超限');
|
||
});
|
||
```
|
||
|
||
> Check the existing pipeline.test.ts structure for `makeJob` and `makeMockDeps` helpers — use the same pattern.
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
tsx --test test/pipeline.test.ts 2>&1 | grep -E "fail|Error" | head -5
|
||
```
|
||
|
||
- [ ] **Step 3: Add diffMaxFiles to schema.sql and db.ts**
|
||
|
||
In `/Users/wangjia/code/maestro/src/store/schema.sql`, add to `CREATE TABLE IF NOT EXISTS projects`:
|
||
```sql
|
||
diff_max_files INTEGER NOT NULL DEFAULT 100 -- diff 体量闸:超过此文件数则硬失败(0=禁用)
|
||
```
|
||
|
||
In `/Users/wangjia/code/maestro/src/store/db.ts`, add:
|
||
```typescript
|
||
ensureColumn(db, 'projects', 'diff_max_files', 'diff_max_files INTEGER NOT NULL DEFAULT 100');
|
||
```
|
||
|
||
- [ ] **Step 4: Add diffMaxFiles to types.ts and mappers.ts**
|
||
|
||
In `/Users/wangjia/code/maestro/src/model/types.ts`, in `Project` interface add:
|
||
```typescript
|
||
diffMaxFiles: number; // diff 体量闸(默认 100,0=禁用)
|
||
```
|
||
|
||
In `/Users/wangjia/code/maestro/src/store/mappers.ts`, in `ProjectRow` add:
|
||
```typescript
|
||
diff_max_files: number;
|
||
```
|
||
|
||
In `rowToProject` add:
|
||
```typescript
|
||
diffMaxFiles: r.diff_max_files ?? 100,
|
||
```
|
||
|
||
- [ ] **Step 5: Add checks分项闸 to pipeline.ts**
|
||
|
||
In `/Users/wangjia/code/maestro/src/executor/pipeline.ts`, in the `runPipeline` function, after the verify step (around line 146), add:
|
||
|
||
```typescript
|
||
// 3b. 分项 checks(lint/typecheck/build 等)— project.checks 是 JSON "{lint:'cmd', typecheck:'cmd'}"
|
||
if (job.project.checks) {
|
||
let checksObj: Record<string, string>;
|
||
try {
|
||
checksObj = JSON.parse(job.project.checks) as Record<string, string>;
|
||
} catch {
|
||
checksObj = {};
|
||
}
|
||
for (const [name, cmd] of Object.entries(checksObj)) {
|
||
emit({ type: 'phase', phase: `checking:${name}` });
|
||
try {
|
||
const { execSync } = await import('node:child_process');
|
||
execSync(cmd, { cwd: wt.dir, stdio: 'pipe', timeout: 60_000 });
|
||
} catch (e) {
|
||
const msg = (e as { stderr?: Buffer; message?: string }).stderr?.toString().trim()
|
||
|| (e as Error).message || `${name} 检查失败`;
|
||
emit({ type: 'failed', error: `分项检查 [${name}] 失败:${msg.slice(0, 500)}`, transcriptRef: rr.transcriptRef, sessionId: rr.sessionId });
|
||
emit({ type: 'done' });
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Add diff体量闸 to pipeline.ts**
|
||
|
||
In `/Users/wangjia/code/maestro/src/executor/pipeline.ts`, after the `worktreeDiff` call (around line 149), add:
|
||
|
||
```typescript
|
||
// 4b. diff 体量闸(文件数超限时硬失败)
|
||
const maxFiles = job.project.diffMaxFiles ?? 100;
|
||
if (maxFiles > 0) {
|
||
const fileCount = diff.diffSummary.split('\n').filter((l) => l.includes('|')).length;
|
||
if (fileCount > maxFiles) {
|
||
emit({
|
||
type: 'failed',
|
||
error: `diff 体量超限:改动 ${fileCount} 个文件(上限 ${maxFiles}),请缩小任务范围`,
|
||
transcriptRef: rr.transcriptRef,
|
||
sessionId: rr.sessionId,
|
||
});
|
||
emit({ type: 'done' });
|
||
return;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Update PipelineDeps to make checks injectable for tests**
|
||
|
||
The `execSync` in checks must be injectable for tests. Instead of importing inline, add an optional `runCheck` to `PipelineDeps`:
|
||
|
||
```typescript
|
||
// In PipelineDeps interface:
|
||
runCheck?: (cmd: string, cwd: string) => void; // throws on non-zero exit
|
||
```
|
||
|
||
```typescript
|
||
// Default implementation in realDeps:
|
||
runCheck: (cmd, cwd) => {
|
||
const { execSync } = require('node:child_process');
|
||
execSync(cmd, { cwd, stdio: 'pipe', timeout: 60_000 });
|
||
},
|
||
```
|
||
|
||
Update the checks loop in pipeline.ts to use `deps.runCheck ?? defaultRunCheck`.
|
||
|
||
- [ ] **Step 8: Run tests**
|
||
|
||
```bash
|
||
npm test 2>&1 | tail -20
|
||
```
|
||
|
||
Expected: new pipeline tests pass; all existing tests pass.
|
||
|
||
- [ ] **Step 9: Typecheck**
|
||
|
||
```bash
|
||
npm run typecheck
|
||
```
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add src/executor/pipeline.ts src/store/schema.sql src/store/db.ts src/model/types.ts src/store/mappers.ts test/pipeline.test.ts
|
||
git commit -m "feat(pipeline): 分项 checks 硬闸 + diff 体量闸(diffMaxFiles)"
|
||
```
|
||
|
||
---
|
||
|
||
## Final Verification
|
||
|
||
- [ ] **Run full test suite**
|
||
|
||
```bash
|
||
npm test
|
||
```
|
||
|
||
Expected: all tests pass.
|
||
|
||
- [ ] **Run typecheck**
|
||
|
||
```bash
|
||
npm run typecheck
|
||
```
|
||
|
||
Expected: no errors.
|
||
|
||
- [ ] **Build**
|
||
|
||
```bash
|
||
npm run build
|
||
```
|
||
|
||
Expected: compiles cleanly to `dist/`.
|
||
|
||
- [ ] **Smoke test — start daemon and confirm it starts**
|
||
|
||
```bash
|
||
MAESTRO_ORCH_INTERVAL=0 npm run dev &
|
||
sleep 3
|
||
curl -s http://127.0.0.1:4517/api/projects | head -5
|
||
kill %1
|
||
```
|
||
|
||
Expected: daemon starts, returns JSON for projects endpoint.
|
||
|
||
---
|
||
|
||
## What Phase 2 Covers (not in this plan)
|
||
|
||
- SSE streaming (`cc.ts onToken → daemon EventEmitter → GET /api/tasks/:id/stream`)
|
||
- Hook contract (`.maestro/hooks.ts` + shell hooks)
|
||
- Trace ID propagation (`JobSpec.traceId`, outbox携带)
|
||
- Phase events → WebSocket broadcast
|
||
- Backend restructuring (store Repos / daemon split / API routes)
|
||
- Frontend migration (React 18 + Vite + Zustand)
|