import { test } from 'node:test'; import assert from 'node:assert/strict'; import { Store, StoreError } from '../src/store/index.js'; function freshStore(): Store { return new Store(':memory:'); } test('project + task creation, complexity routes initial status', () => { const s = freshStore(); const p = s.createProject({ name: 'demo', repoPath: '/tmp/demo-' + Math.random() }); assert.equal(p.status, 'active'); const hard = s.createTask({ projectId: p.id, title: 'big', complexity: 'hard' }); const medium = s.createTask({ projectId: p.id, title: 'mid', complexity: 'medium' }); const easy = s.createTask({ projectId: p.id, title: 'small', complexity: 'easy' }); assert.equal(hard.status, 'analyzing'); // Hard 强制分析 assert.equal(medium.status, 'speccing'); // Medium 写方案 assert.equal(easy.status, 'ready'); // Easy 直接可执行 s.close(); }); test('patchProject models:持久化 + 读回(字符串/对象形式,非法值丢弃,null 清空)', () => { const s = freshStore(); const p = s.createProject({ name: 'mdl', repoPath: '/tmp/mdl-' + Math.random() }); assert.equal(p.models, null); const up = s.patchProject(p.id, { models: { executor: { easy: 'claude-sonnet-4-6', hard: 'claude-opus-4-8' }, reviewer: 'claude-fable-5', // @ts-expect-error 测试非法角色被丢弃 bogus: 'x', }, }); assert.deepEqual(up.models, { executor: { easy: 'claude-sonnet-4-6', hard: 'claude-opus-4-8' }, reviewer: 'claude-fable-5', }); // 读回(走 mapper)一致 assert.deepEqual(s.getProject(p.id)!.models, up.models); // null 清空 const cleared = s.patchProject(p.id, { models: null }); assert.equal(cleared.models, null); s.close(); }); test('task scopeFiles:create + patch 持久化与读回,去空白/空数组→null', () => { const s = freshStore(); const p = s.createProject({ name: 'scp', repoPath: '/tmp/scp-' + Math.random() }); const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy', scopeFiles: [' src/** ', '', 'lib/a.ts'] }); assert.deepEqual(t.scopeFiles, ['src/**', 'lib/a.ts']); // 去空白 + 丢空项 assert.deepEqual(s.getTask(t.id)!.scopeFiles, ['src/**', 'lib/a.ts']); const patched = s.patchTask(t.id, { scopeFiles: ['only.ts'] }); assert.deepEqual(patched.scopeFiles, ['only.ts']); const emptied = s.patchTask(t.id, { scopeFiles: [] }); assert.equal(emptied.scopeFiles, null); // 空数组 → null(不限范围) s.close(); }); test('Easy 路径:写操作 → ready → nextExecutable 领取', () => { const s = freshStore(); const p = s.createProject({ name: 'e', repoPath: '/tmp/e-' + Math.random() }); const t = s.createTask({ projectId: p.id, title: 'tweak', complexity: 'easy' }); s.setOperations(t.id, '改 README 一行'); const next = s.nextExecutable(p.id); assert.equal(next?.id, t.id); s.close(); }); test('Medium 闸:spec_review accept → ready;reject 必带意见', () => { const s = freshStore(); const p = s.createProject({ name: 'm', repoPath: '/tmp/m-' + Math.random() }); const t = s.createTask({ projectId: p.id, title: 'feat', complexity: 'medium' }); s.setSpec(t.id, '加一个接口,因为前端需要'); const inReview = s.transition(t.id, 'spec_review'); assert.equal(inReview.status, 'spec_review'); // reject 无意见 → 报错 assert.throws(() => s.decide(t.id, 'reject', 'user'), StoreError); // reject 带意见 → 回到 speccing const back = s.decide(t.id, 'reject', 'user', '方案漏了鉴权'); assert.equal(back.status, 'speccing'); assert.equal(back.approvals.at(-1)?.reason, '方案漏了鉴权'); // 重新评审 → accept → ready s.transition(t.id, 'spec_review'); const ok = s.decide(t.id, 'accept', 'user'); assert.equal(ok.status, 'ready'); s.close(); }); test('Hard 闸:plan_review accept → decomposed,子任务汇总', () => { const s = freshStore(); const p = s.createProject({ name: 'h', repoPath: '/tmp/h-' + Math.random() }); const t = s.createTask({ projectId: p.id, title: 'epic', complexity: 'hard' }); s.setPlan(t.id, '分析…拆 2 个子任务'); s.transition(t.id, 'plan_review'); const decomposed = s.decide(t.id, 'accept', 'user'); assert.equal(decomposed.status, 'decomposed'); const c1 = s.createTask({ projectId: p.id, parentId: t.id, title: '子1', complexity: 'easy' }); assert.equal(c1.depth, 2); // 拆解后容器不应被 nextExecutable 领取(它有子任务且非 ready) s.setOperations(c1.id, '做子1'); const next = s.nextExecutable(p.id); assert.equal(next?.id, c1.id); // 取到叶子,不是容器 s.close(); }); test('层级上限:medium 默认 3 层,第 4 层拒绝', () => { const s = freshStore(); const p = s.createProject({ name: 'd', repoPath: '/tmp/d-' + Math.random() }); const l1 = s.createTask({ projectId: p.id, title: 'l1', complexity: 'medium' }); const l2 = s.createTask({ projectId: p.id, parentId: l1.id, title: 'l2', complexity: 'medium' }); const l3 = s.createTask({ projectId: p.id, parentId: l2.id, title: 'l3', complexity: 'medium' }); assert.equal(l3.depth, 3); assert.throws(() => s.createTask({ projectId: p.id, parentId: l3.id, title: 'l4', complexity: 'medium' }), StoreError); s.close(); }); test('非法状态流转被守卫拒绝', () => { const s = freshStore(); const p = s.createProject({ name: 'g', repoPath: '/tmp/g-' + Math.random() }); const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy' }); // ready assert.throws(() => s.transition(t.id, 'done'), StoreError); // ready→done 非法 s.close(); }); test('exec_review 结果闸:accept → done', () => { const s = freshStore(); const p = s.createProject({ name: 'x', repoPath: '/tmp/x-' + Math.random() }); const t = s.createTask({ projectId: p.id, title: 'run', complexity: 'easy' }); s.setOperations(t.id, 'op'); s.transition(t.id, 'queued'); s.transition(t.id, 'executing'); s.setResult(t.id, { branch: 'maestro/run', worktree: '/wt', diffSummary: '+1 -0', commits: ['abc'], prUrl: null, summary: null, verdict: null, securitySummary: null, securityVerdict: null }); s.transition(t.id, 'exec_review'); const done = s.decide(t.id, 'accept', 'user'); assert.equal(done.status, 'done'); s.close(); }); test('markReeval(规则13):executing → needs_attention,收尾 run 为 cancelled,不计执行失败次数', () => { const s = freshStore(); const p = s.createProject({ name: 'rev', repoPath: '/tmp/rev-' + Math.random() }); const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy', scopeFiles: ['src/**'] }); s.setOperations(t.id, 'op'); s.transition(t.id, 'queued'); s.transition(t.id, 'executing'); const run = s.startRun(t.id, 'executor'); const after = s.markReeval(t.id, run.id, '执行前同步发现 main 已改动声明范围内文件'); assert.equal(after.status, 'needs_attention', '应直接转 needs_attention'); assert.equal(s.getRun(run.id)!.status, 'cancelled', 'run 收尾为 cancelled(非 failed)'); // 没有任何 executor run 被标 failed(重评估不计失败次数 / 不进重试链) const failedRuns = s.listRuns(t.id).filter((r) => r.kind === 'executor' && r.status === 'failed'); assert.equal(failedRuns.length, 0, 'markReeval 不应产生 failed 的 executor run'); s.close(); }); test('failScopeReject(范围型驳回):直接 needs_attention、不进重试链、清退避,记一条 failed run 承载原因', () => { const s = freshStore(); const p = s.createProject({ name: 'scoperej', repoPath: '/tmp/scoperej-' + Math.random() }); const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy', scopeFiles: ['design/**'] }); s.setOperations(t.id, 'op'); s.transition(t.id, 'queued'); s.transition(t.id, 'executing'); const after = s.failScopeReject(t.id, '复审因范围不足驳回(停止重试):Scope A entirely undone'); assert.equal(after.status, 'needs_attention', '范围型驳回应直接转 needs_attention(不重试)'); assert.equal(after.nextEligibleAt, null, '应清退避、不进重试链'); // 补记一条 failed executor run 承载原因(供 UI lastRunError 兜底) const failed = s.listRuns(t.id).filter((r) => r.kind === 'executor' && r.status === 'failed'); assert.equal(failed.length, 1, '应补记一条 failed executor run'); assert.match(failed[0].error ?? '', /范围不足/); s.close(); }); test('reconcileInterrupted:执行中被 cancel 的任务仍挂 started run → 仅收尾不崩(绝不非法 cancelled→failed)', () => { const s = freshStore(); const p = s.createProject({ name: 'recon', repoPath: '/tmp/recon-' + Math.random() }); const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy' }); s.setOperations(t.id, 'op'); s.transition(t.id, 'queued'); s.transition(t.id, 'executing'); const run = s.startRun(t.id, 'executor', { worktree: '/wt', branch: 'maestro/x' }); s.transition(t.id, 'cancelled', { by: 'user' }); // 执行中被取消(合法) // daemon 重启:worker 判死 → 终态任务仅收尾,绝不 fail*Attempt(否则 cancelled→failed 抛错会让 daemon 启动崩溃) assert.doesNotThrow(() => s.reconcileInterrupted(() => false)); assert.equal(s.getTask(t.id)!.status, 'cancelled'); // 任务仍 cancelled assert.equal(s.listRuns(t.id).find((x) => x.id === run.id)!.status, 'failed'); // 残留 run 已收尾 s.close(); }); test('依赖未满足时不可领取', () => { const s = freshStore(); const p = s.createProject({ name: 'dep', repoPath: '/tmp/dep-' + Math.random() }); const a = s.createTask({ projectId: p.id, title: 'A', complexity: 'easy' }); const b = s.createTask({ projectId: p.id, title: 'B', complexity: 'easy', deps: [a.id] }); s.setOperations(a.id, 'a'); s.setOperations(b.id, 'b'); // 优先级相同时 A 先建,但 B 依赖 A 未 done → 只能取到 A const next = s.nextExecutable(p.id); assert.equal(next?.id, a.id); s.close(); }); test('事件订阅:状态变更广播', () => { const s = freshStore(); const got: string[] = []; s.subscribe((e) => got.push(e.type)); const p = s.createProject({ name: 'ev', repoPath: '/tmp/ev-' + Math.random() }); const t = s.createTask({ projectId: p.id, title: 't', complexity: 'easy' }); s.transition(t.id, 'queued'); assert.ok(got.includes('task.created')); assert.ok(got.includes('status.changed')); s.close(); }); test('依赖自动落位:建任务带未完成依赖 → blocked;依赖 done → 自动放行 ready', () => { const s = freshStore(); const p = s.createProject({ name: 'ab', repoPath: '/tmp/ab-' + Math.random() }); const a = s.createTask({ projectId: p.id, title: 'A', complexity: 'easy' }); const b = s.createTask({ projectId: p.id, title: 'B', complexity: 'easy', deps: [a.id] }); assert.equal(b.status, 'blocked'); // 建即落位 blocked,而非假 ready // 手动把 blocked 拉成 ready 也会被按依赖弹回(仍 blocked) assert.equal(s.transition(b.id, 'ready').status, 'blocked'); // A 走完整闭环到 done → B 自动放行 const evts = []; s.subscribe((e) => evts.push(e)); s.transition(a.id, 'queued'); s.transition(a.id, 'executing'); s.transition(a.id, 'exec_review'); s.decide(a.id, 'accept', 'user'); assert.equal(s.getTask(b.id).status, 'ready'); // 自动 blocked→ready const auto = evts.find((e) => e.taskId === b.id && e.payload.auto === 'deps-met'); assert.ok(auto, '应有 deps-met 自动放行事件'); s.close(); }); test('reconcileDeps:存量 ready 但依赖未满足 → 纠正为 blocked(幂等)', () => { const s = freshStore(); const p = s.createProject({ name: 'rc', repoPath: '/tmp/rc-' + Math.random() }); const a = s.createTask({ projectId: p.id, title: 'A', complexity: 'easy' }); const b = s.createTask({ projectId: p.id, title: 'B', complexity: 'easy', deps: [a.id] }); // 模拟旧库脏数据:B 被直接写成 ready s.db.prepare(`UPDATE tasks SET status = 'ready' WHERE id = ?`).run(b.id); const r1 = s.reconcileDeps(p.id); assert.equal(r1.blocked, 1); assert.equal(s.getTask(b.id).status, 'blocked'); const r2 = s.reconcileDeps(p.id); // 幂等 assert.equal(r2.blocked + r2.released, 0); s.close(); }); test('score 调度:解锁加权 > 链条惯性 > 自身分;nextExecutable 取最高分', () => { const s = freshStore(); const p = s.createProject({ name: 'sc', repoPath: '/tmp/sc-' + Math.random() }); // A:P1 无依赖,但有两条 P0 blocked 任务等它 → score = 2 + 3 + 3 = 8 const a = s.createTask({ projectId: p.id, title: 'A', complexity: 'easy', priority: 1 }); s.createTask({ projectId: p.id, title: 'W1', complexity: 'easy', priority: 0, deps: [a.id] }); s.createTask({ projectId: p.id, title: 'W2', complexity: 'easy', priority: 0, deps: [a.id] }); // B:P0 无依赖无人等 → score = 3 s.createTask({ projectId: p.id, title: 'B', complexity: 'easy', priority: 0 }); const next = s.nextExecutable(p.id); assert.equal(next?.title, 'A'); // 解锁两条 P0 的 A(8) 压过孤立 P0 的 B(3) s.close(); }); test('createTask:deps 引用不存在/跨项目任务被拒绝', () => { const s = freshStore(); const p1 = s.createProject({ name: 'd1', repoPath: '/tmp/d1-' + Math.random() }); const p2 = s.createProject({ name: 'd2', repoPath: '/tmp/d2-' + Math.random() }); const other = s.createTask({ projectId: p2.id, title: 'x', complexity: 'easy' }); assert.throws(() => s.createTask({ projectId: p1.id, title: 'bad', complexity: 'easy', deps: ['tsk_nope'] }), StoreError); assert.throws(() => s.createTask({ projectId: p1.id, title: 'bad2', complexity: 'easy', deps: [other.id] }), StoreError); s.close(); }); test('deleteTask:删除叶子任务,不影响同项目其他任务', () => { const s = freshStore(); const p = s.createProject({ name: 'del1', repoPath: '/tmp/del1-' + Math.random() }); const a = s.createTask({ projectId: p.id, title: 'A', complexity: 'easy' }); const b = s.createTask({ projectId: p.id, title: 'B', complexity: 'easy' }); const { deleted } = s.deleteTask(a.id); assert.equal(deleted, 1); assert.equal(s.getTask(a.id), null); // 已删 assert.ok(s.getTask(b.id)); // 不受影响 s.close(); }); test('deleteTask:级联删除带子任务的父任务', () => { const s = freshStore(); const p = s.createProject({ name: 'del2', repoPath: '/tmp/del2-' + Math.random() }); const root = s.createTask({ projectId: p.id, title: 'root', complexity: 'hard' }); s.setPlan(root.id, '拆'); s.transition(root.id, 'plan_review'); s.decide(root.id, 'accept', 'user'); const c1 = s.createTask({ projectId: p.id, parentId: root.id, title: 'c1', complexity: 'easy' }); const c2 = s.createTask({ projectId: p.id, parentId: root.id, title: 'c2', complexity: 'easy' }); // c1 下再建孙子 const gc = s.createTask({ projectId: p.id, parentId: c1.id, title: 'gc', complexity: 'easy' }); const { deleted } = s.deleteTask(root.id); assert.equal(deleted, 4); // root + c1 + c2 + gc assert.equal(s.getTask(root.id), null); assert.equal(s.getTask(c1.id), null); assert.equal(s.getTask(c2.id), null); assert.equal(s.getTask(gc.id), null); s.close(); }); test('deleteTask:不存在的任务抛 StoreError', () => { const s = freshStore(); assert.throws(() => s.deleteTask('tsk_nonexistent'), (e: Error) => e.message.includes('任务不存在')); s.close(); }); test('transition → cancelled:合法的状态流转(init/ready/executing 等)', () => { const s = freshStore(); const p = s.createProject({ name: 'can', repoPath: '/tmp/can-' + Math.random() }); const t1 = s.createTask({ projectId: p.id, title: 'init task', complexity: 'easy' }); assert.equal(t1.status, 'ready'); const cancelled = s.transition(t1.id, 'cancelled', { by: 'user' }); assert.equal(cancelled.status, 'cancelled'); // 已取消的任务不可再次流转 assert.throws(() => s.transition(t1.id, 'ready'), (e: Error) => e.message.includes('非法状态流转')); s.close(); }); test('容器收口:已拆解 Hard 的子任务全 done → 容器自动 done(逐级向上)', () => { const s = freshStore(); const p = s.createProject({ name: 'cc', repoPath: '/tmp/cc-' + Math.random() }); const root = s.createTask({ projectId: p.id, title: 'epic', complexity: 'hard' }); s.setPlan(root.id, '拆 2 子'); s.transition(root.id, 'plan_review'); s.decide(root.id, 'accept', 'user'); // decomposed const c1 = s.createTask({ projectId: p.id, parentId: root.id, title: '子1', complexity: 'easy' }); const c2 = s.createTask({ projectId: p.id, parentId: root.id, title: '子2', complexity: 'easy' }); const finish = (tid) => { s.transition(tid, 'queued'); s.transition(tid, 'executing'); s.transition(tid, 'exec_review'); s.decide(tid, 'accept', 'user'); }; finish(c1.id); assert.equal(s.getTask(root.id).status, 'decomposed'); // 还有子没完,容器不动 finish(c2.id); assert.equal(s.getTask(root.id).status, 'done'); // 子全 done → 容器自动 done s.close(); }); test('合并失败补救:建最高优先级 easy 任务,且幂等不重复建', () => { const s = freshStore(); const p = s.createProject({ name: 'm', repoPath: '/tmp/m-' + Math.random() }); // 造一个进入 exec_review、带分支结果的原任务 const t = s.createTask({ projectId: p.id, title: '配置面板补 model', complexity: 'easy', priority: 1 }); s.setOperations(t.id, 'op'); s.transition(t.id, 'queued'); s.transition(t.id, 'executing'); s.setResult(t.id, { branch: 'maestro/' + t.id, worktree: '/tmp/wt', diffSummary: '', commits: [], prUrl: null, summary: null, verdict: null, securitySummary: null, securityVerdict: null, mergeTaskId: null, }); s.transition(t.id, 'exec_review'); const rem = s.ensureMergeRemediationTask(t.id, { branch: 'maestro/' + t.id, targetBranch: 'main', conflictError: '冲突文件:web/app.js', }); assert.equal(rem.complexity, 'easy'); assert.equal(rem.priority, 0, '补救任务应为最高优先级 P0'); assert.equal(rem.status, 'ready', 'easy 无依赖 → 直达 ready,自治项目可领'); assert.match(rem.title, /合并冲突待解决/); assert.match(rem.operations ?? '', /git merge maestro\//, 'operations 应含合并步骤'); assert.match(rem.operations ?? '', /web\/app\.js/, 'operations 应含冲突详情'); // 幂等标记写回原任务 assert.equal(s.getTask(t.id).result?.mergeTaskId, rem.id); // 再次调用 → 复用同一补救任务,不新建 const again = s.ensureMergeRemediationTask(t.id, { branch: 'maestro/' + t.id, targetBranch: 'main', conflictError: '再次冲突', }); assert.equal(again.id, rem.id, '已有未结束补救任务 → 复用'); const remCount = s.listTasks(p.id).filter((x) => x.title.includes('合并冲突待解决')).length; assert.equal(remCount, 1, '不应重复建补救任务'); // 补救任务结束(cancelled)后再调用 → 允许新建 s.transition(rem.id, 'cancelled'); const fresh = s.ensureMergeRemediationTask(t.id, { branch: 'maestro/' + t.id, targetBranch: 'main', conflictError: '又冲突', }); assert.notEqual(fresh.id, rem.id, '旧补救任务已结束 → 建新的'); s.close(); }); test('合并失败→补救→完成:事件流完整 + 补救合并后原任务自动 done', () => { const s = freshStore(); const p = s.createProject({ name: 'rem', repoPath: '/tmp/rem-' + Math.random() }); // 原任务进入 exec_review、带分支结果 const t = s.createTask({ projectId: p.id, title: '原任务', complexity: 'easy', priority: 1 }); s.setOperations(t.id, 'op'); s.transition(t.id, 'queued'); s.transition(t.id, 'executing'); s.setResult(t.id, { branch: 'maestro/' + t.id, worktree: '/tmp/wt', diffSummary: '', commits: [], prUrl: null, summary: null, verdict: null, securitySummary: null, securityVerdict: null, mergeTaskId: null, }); s.transition(t.id, 'exec_review'); // 合并失败 → 建补救任务(带结构化冲突文件) const rem = s.ensureMergeRemediationTask(t.id, { branch: 'maestro/' + t.id, targetBranch: 'main', conflictError: '冲突文件:web/app.js', conflictFiles: ['web/app.js', 'src/x.ts'], }); // 1) 原任务时间线出现 merge.failed 节点(带冲突文件 + 补救任务链接) const failed = s.listTaskEvents(t.id).filter((e) => e.type === 'merge.failed'); assert.equal(failed.length, 1, '应记一个合并失败事件'); assert.equal(failed[0].payload.remediationTaskId, rem.id); assert.deepEqual(failed[0].payload.conflictFiles, ['web/app.js', 'src/x.ts']); // 2) 反查:补救任务 → 原任务 assert.equal(s.findMergeOriginTask(rem.id)?.id, t.id); assert.equal(s.findMergeOriginTask('tsk_nope'), null); // 3) 补救任务跑到 exec_review,合并成功后自动收口原任务 s.transition(rem.id, 'queued'); s.transition(rem.id, 'executing'); s.transition(rem.id, 'exec_review'); s.decide(rem.id, 'accept', 'user'); // 补救任务自身合并(done) const closed = s.remediateOrigin(t.id, rem.id, 'abc123'); assert.equal(closed?.status, 'done', '补救合并后原任务自动 done'); assert.match(closed?.result?.prUrl ?? '', /^merged-via:/, '记录经补救任务并入'); // 4) 原任务时间线出现 merge.remediated 节点 const remediated = s.listTaskEvents(t.id).filter((e) => e.type === 'merge.remediated'); assert.equal(remediated.length, 1, '应记一个补救完成事件'); assert.equal(remediated[0].payload.remediationTaskId, rem.id); // 幂等:原任务已 done,再次收口不报错、不改状态 const again = s.remediateOrigin(t.id, rem.id, 'abc123'); assert.equal(again?.status, 'done'); assert.equal(s.listTaskEvents(t.id).filter((e) => e.type === 'merge.remediated').length, 1, '不重复记事件'); s.close(); }); test('patchProject:maxRetries/timeoutMs 可配 + 校验', () => { const s = freshStore(); const p = s.createProject({ name: 'cfg', repoPath: '/tmp/cfg-' + Math.random() }); // 默认值 assert.equal(p.maxRetries, 2); assert.equal(p.timeoutMs, 1_800_000); // 更新 maxRetries + timeoutMs const updated = s.patchProject(p.id, { maxRetries: 5, timeoutMs: 60_000 }); assert.equal(updated.maxRetries, 5); assert.equal(updated.timeoutMs, 60_000); // 校验:maxRetries 必须 >=0 的整数 assert.throws(() => s.patchProject(p.id, { maxRetries: -1 }), /maxRetries/); assert.throws(() => s.patchProject(p.id, { maxRetries: 1.5 }), /maxRetries/); // 校验:timeoutMs 必须 >=1000 assert.throws(() => s.patchProject(p.id, { timeoutMs: 500 }), /timeoutMs/); s.close(); }); test('createProject:maxRetries/timeoutMs 自定义初值', () => { const s = freshStore(); const p = s.createProject({ name: 'custom', repoPath: '/tmp/custom-' + Math.random(), maxRetries: 0, timeoutMs: 120_000 }); assert.equal(p.maxRetries, 0); assert.equal(p.timeoutMs, 120_000); s.close(); }); test('requeueTask:needs_attention → queued,重置 retryBaseline', () => { const s = freshStore(); const p = s.createProject({ name: 'rq', repoPath: '/tmp/rq-' + Math.random() }); const t = s.createTask({ projectId: p.id, title: 'flaky', complexity: 'easy' }); // 推进到 needs_attention(模拟 3 次失败 run) s.transition(t.id, 'queued'); s.transition(t.id, 'executing'); s.transition(t.id, 'failed'); s.transition(t.id, 'queued'); s.transition(t.id, 'executing'); s.transition(t.id, 'failed'); s.transition(t.id, 'queued'); s.transition(t.id, 'executing'); s.transition(t.id, 'failed'); s.transition(t.id, 'needs_attention'); // 模拟 3 条 failed executor run const r1 = s.startRun(t.id, 'executor'); s.finishRun(r1.id, 'failed', { error: 'boom1' }); const r2 = s.startRun(t.id, 'executor'); s.finishRun(r2.id, 'failed', { error: 'boom2' }); const r3 = s.startRun(t.id, 'executor'); s.finishRun(r3.id, 'failed', { error: 'boom3' }); assert.equal(s.getTask(t.id)!.retryBaseline, 0); // 一键重投 const requeued = s.requeueTask(t.id); assert.equal(requeued.status, 'queued'); assert.equal(requeued.retryBaseline, 3); // 基线设为当前失败 run 数 // 只有 needs_attention 状态才可重投 assert.throws(() => s.requeueTask(t.id), /needs_attention/); s.close(); }); test('deleteProject:连带删 tasks/runs/approvals/events,不可恢复', () => { const s = freshStore(); const p = s.createProject({ name: 'del', repoPath: '/tmp/del-' + Math.random() }); const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy' }); s.setOperations(t.id, 'op'); const r = s.startRun(t.id, 'executor'); s.finishRun(r.id, 'succeeded'); assert.ok(s.listEvents(p.id).length > 0, '删前有事件'); assert.ok(s.listTasks(p.id).length > 0, '删前有任务'); assert.ok(s.listRuns(t.id).length > 0, '删前有 run'); s.deleteProject(p.id); assert.equal(s.getProject(p.id), null, '项目已删'); assert.equal(s.listTasks(p.id).length, 0, 'tasks 连带清'); assert.equal(s.listRuns(t.id).length, 0, 'runs 连带清(经 tasks cascade)'); assert.equal(s.listEvents(p.id).length, 0, 'events 连带清'); assert.throws(() => s.deleteProject(p.id), /不存在/, '重复删 → 报错'); s.close(); });