Files
maestro/test/orchestrator.test.ts
T
wangjia 7884182d2b feat: 编排器跨项目全局并发上限闸(tsk_erQHNYdn6i9g)
新增用户级设置项 globalConcurrency(settings 表,独立于新建项目默认值
来源 concurrency),缺省 0 = 不限,向后兼容无需 DB 迁移。

- store: UserSettings 加 globalConcurrency 字段 + 默认 0;新增
  globalInflightCount()(跨所有项目 started run 总数,与 inflightTaskIds 同口径)
- orchestrator.claimTick: 叠加全局闸,与 per-project 闸串联(都过才领);
  轮初查一次全局在途、轮内手动 ++,达上限即 return 跨所有项目停止领取
- api: PUT /api/settings 支持 globalConcurrency(空/null 归一为 0)
- web: 侧栏「全局设置」入口 + 模态,编辑/回显/校验(≥0 整数)
- test: 新增全局闸达上限跨项目阻止 / 未达上限正常领取 / 边界 / 轮初短路 用例

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 19:11:42 +08:00

523 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Store } from '../src/store/index.js';
import {
createOrchestrator, workerEntry, workerEnv, DEFAULT_MAX_RETRIES,
type OrchestratorDeps,
} from '../src/daemon/orchestrator.js';
import type { JobSpec } from '../src/executor/protocol.js';
import type { Autonomy } from '../src/model/types.js';
const noopLog = { info: (): void => undefined, error: (): void => undefined };
/** 可前进的测试时钟。 */
function makeClock(initialMs = Date.now()): { nowMs: () => number; advance: (ms: number) => void } {
let t = initialMs;
return { nowMs: () => t, advance: (ms) => { t += ms; } };
}
interface MockState {
spawned: string[]; // 被 spawn 的 runId 列表
jobs: JobSpec[]; // 被 writeJobSpec 的 job 列表
ingestCalls: number;
alive: boolean; // isWorkerAlive 的返回(reaper 用)
nextPid: number;
}
/** 全 mock 依赖:不真 spawn / 不真判活 / ingest no-op / job 不落盘。可按用例覆盖。 */
function mockDeps(state: MockState, overrides: Partial<OrchestratorDeps> = {}): { deps: OrchestratorDeps } {
const deps: OrchestratorDeps = {
spawnWorker: (runId: string): number => { state.spawned.push(runId); return state.nextPid++; },
isWorkerAlive: () => state.alive,
ingestAll: () => { state.ingestCalls++; },
writeJobSpec: (job: JobSpec) => { state.jobs.push(job); },
nowMs: Date.now,
...overrides,
};
return { deps };
}
function freshState(): MockState {
return { spawned: [], jobs: [], ingestCalls: 0, alive: true, nextPid: 1000 };
}
function setup(autonomy: Autonomy, concurrency = 1): { store: Store; projectId: string } {
const store = new Store(':memory:');
const p = store.createProject({
name: 'orch', repoPath: '/tmp/orch-repo-' + Math.random(), autonomy, concurrency,
});
return { store, projectId: p.id };
}
// ───────────────────────── workerEntry / workerEnv(可读测的小函数)─────────────────────────
test('workerEntry:默认指向 ../executor/worker.jsnode 运行)', () => {
const prev = process.env.MAESTRO_WORKER_CMD;
delete process.env.MAESTRO_WORKER_CMD;
const [cmd, scriptPath] = workerEntry();
assert.equal(cmd, 'node');
assert.match(scriptPath, /executor[/\\]worker\.js$/);
if (prev !== undefined) process.env.MAESTRO_WORKER_CMD = prev;
});
test('workerEntryMAESTRO_WORKER_CMD 覆盖整条命令(空格分隔)', () => {
const prev = process.env.MAESTRO_WORKER_CMD;
process.env.MAESTRO_WORKER_CMD = 'npx tsx src/executor/worker.ts';
assert.deepEqual(workerEntry(), ['npx', 'tsx', 'src/executor/worker.ts']);
if (prev === undefined) delete process.env.MAESTRO_WORKER_CMD;
else process.env.MAESTRO_WORKER_CMD = prev;
});
test('workerEnv:透传会话/系统变量(keychain 可用),剥离密钥,保留鉴权变量', () => {
const env = workerEnv({
PATH: '/usr/bin', HOME: '/home/u', LANG: 'en_US.UTF-8',
USER: 'wj', LOGNAME: 'wj', __CF_USER_TEXT_ENCODING: '0x1F5:0:0', TMPDIR: '/tmp', XPC_SERVICE_NAME: '0',
ANTHROPIC_API_KEY: 'sk-x', CLAUDE_CODE_OAUTH_TOKEN: 'oauth-x', CLAUDE_CODE_FOO: 'y',
MAESTRO_ORCH_INTERVAL: '15', MAESTRO_DATA_DIR: '/data',
SOME_SECRET: 'leak', AWS_SECRET_ACCESS_KEY: 'nope', GITHUB_TOKEN: 'gh', CF_API_TOKEN: 'cf',
});
// 会话/系统变量透传(macOS keychain/Security 访问需要它们)
for (const k of ['PATH', 'HOME', 'LANG', 'USER', 'LOGNAME', '__CF_USER_TEXT_ENCODING', 'TMPDIR', 'XPC_SERVICE_NAME']) {
assert.ok(env[k] !== undefined, `会话变量 ${k} 应透传`);
}
// 鉴权变量始终保留(即便名含 TOKEN/KEY)
assert.equal(env.ANTHROPIC_API_KEY, 'sk-x');
assert.equal(env.CLAUDE_CODE_OAUTH_TOKEN, 'oauth-x');
assert.equal(env.CLAUDE_CODE_FOO, 'y');
assert.equal(env.MAESTRO_DATA_DIR, '/data'); // worker 须看同一 runs/ 目录
assert.equal(env.MAESTRO_ORCH_INTERVAL, '15'); // 非密钥 → 透传(黑名单语义)
// 真正的密钥类被剥离
assert.equal(env.SOME_SECRET, undefined, '*_SECRET 剥离');
assert.equal(env.AWS_SECRET_ACCESS_KEY, undefined, 'AWS 密钥剥离');
assert.equal(env.GITHUB_TOKEN, undefined, 'GITHUB_TOKEN 剥离');
assert.equal(env.CF_API_TOKEN, undefined, 'CF token 剥离');
});
// ───────────────────────── tick 顺序:ingest + reaper 先于领取 ─────────────────────────
test('tick:每轮先 ingest 再 reaper 再领取', () => {
const { store, projectId } = setup('auto-easy');
store.createTask({ projectId, title: 'a', complexity: 'easy' });
const state = freshState();
const { deps } = mockDeps(state);
const orch = createOrchestrator(store, noopLog, deps);
orch.tick();
assert.equal(state.ingestCalls, 1, '每轮 ingest 一次');
assert.equal(state.spawned.length, 1, '领取并 spawn 一个');
});
// ───────────────────────── 领取(监工)─────────────────────────
test('autonomy=manual:不领取', () => {
const { store, projectId } = setup('manual');
const t = store.createTask({ projectId, title: 'easy', complexity: 'easy' });
const state = freshState();
const { deps } = mockDeps(state);
createOrchestrator(store, noopLog, deps).tick();
assert.equal(state.spawned.length, 0);
assert.equal(store.getTask(t.id)!.status, 'ready');
store.close();
});
test('project paused:不领取', () => {
const { store, projectId } = setup('auto-approved');
const t = store.createTask({ projectId, title: 'x', complexity: 'easy' });
store.patchProject(projectId, { status: 'paused' });
const state = freshState();
const { deps } = mockDeps(state);
createOrchestrator(store, noopLog, deps).tick();
assert.equal(state.spawned.length, 0);
assert.equal(store.getTask(t.id)!.status, 'ready');
store.close();
});
test('并发闸 concurrency=1:一轮只领一个;领取后 executing + 建 executor run + setWorkerPid + writeJobSpec', () => {
const { store, projectId } = setup('auto-approved', 1);
const t1 = store.createTask({ projectId, title: 'a', complexity: 'easy' });
const t2 = store.createTask({ projectId, title: 'b', complexity: 'easy' });
const state = freshState();
const { deps } = mockDeps(state);
const orch = createOrchestrator(store, noopLog, deps);
orch.claimTick();
// 并发=1:只领一个
assert.equal(state.spawned.length, 1);
assert.equal(state.jobs.length, 1);
// 被领的那个进 executing,另一个仍 ready
const a = store.getTask(t1.id)!;
const b = store.getTask(t2.id)!;
const executing = a.status === 'executing' ? a : b;
const stillReady = a.status === 'executing' ? b : a;
assert.equal(executing.status, 'executing');
assert.equal(stillReady.status, 'ready');
// 建了 executor run + setWorkerPid + writeJobSpec 内容正确
const runs = store.listRuns(executing.id);
assert.equal(runs.length, 1);
const run = runs[0];
assert.equal(run.kind, 'executor');
assert.equal(run.status, 'started');
assert.equal(run.branch, `maestro/${executing.id}`);
assert.ok(run.workerPid, 'setWorkerPid 已写 pid');
assert.equal(state.spawned[0], run.id, 'spawn 的 runId == 新建 executor run');
const job = state.jobs[0];
assert.equal(job.runId, run.id);
assert.equal(job.task.id, executing.id);
assert.equal(job.task.status, 'executing');
assert.equal(job.branch, `maestro/${executing.id}`);
assert.equal(job.worktreeDir, run.worktree);
// 槽位占满 → 下一轮不再领(仍只 spawn 过一个)
orch.claimTick();
assert.equal(state.spawned.length, 1, '并发=1executing 占满 → 不再领');
store.close();
});
test('并发 concurrency=2:一轮领满两个', () => {
const { store, projectId } = setup('auto-approved', 2);
store.createTask({ projectId, title: 'a', complexity: 'easy' });
store.createTask({ projectId, title: 'b', complexity: 'easy' });
store.createTask({ projectId, title: 'c', complexity: 'easy' });
const state = freshState();
const { deps } = mockDeps(state);
createOrchestrator(store, noopLog, deps).claimTick();
assert.equal(state.spawned.length, 2, '并发=2 一轮领两个');
assert.equal(store.listTasks(projectId).filter((t) => t.status === 'executing').length, 2);
store.close();
});
test('auto-easy:只领 easymedium ready 不动', () => {
const { store, projectId } = setup('auto-easy', 5);
const easy = store.createTask({ projectId, title: 'small', complexity: 'easy' });
const medium = store.createTask({ projectId, title: 'mid', complexity: 'medium' });
store.setSpec(medium.id, '方案');
store.transition(medium.id, 'spec_review');
store.decide(medium.id, 'accept', 'user'); // medium → ready
assert.equal(store.getTask(medium.id)!.status, 'ready');
const state = freshState();
const { deps } = mockDeps(state);
createOrchestrator(store, noopLog, deps).claimTick();
assert.equal(state.spawned.length, 1);
assert.equal(store.getTask(easy.id)!.status, 'executing');
assert.equal(store.getTask(medium.id)!.status, 'ready'); // 不领 medium
store.close();
});
test('auto-approved:领 ready(含 medium);依赖未满足/非叶子不领', () => {
const { store, projectId } = setup('auto-approved', 5);
const medium = store.createTask({ projectId, title: 'mid', complexity: 'medium' });
store.setSpec(medium.id, '方案');
store.transition(medium.id, 'spec_review');
store.decide(medium.id, 'accept', 'user');
const dep = store.createTask({ projectId, title: 'after-mid', complexity: 'easy', deps: [medium.id] });
assert.equal(store.getTask(dep.id)!.status, 'blocked');
const state = freshState();
const { deps } = mockDeps(state);
createOrchestrator(store, noopLog, deps).claimTick();
assert.equal(state.spawned.length, 1);
assert.equal(store.getTask(medium.id)!.status, 'executing');
assert.equal(store.getTask(dep.id)!.status, 'blocked');
store.close();
});
test('退避:nextEligibleAt 在未来 → 不领;过期后 → 领', () => {
const { store, projectId } = setup('auto-easy');
const t = store.createTask({ projectId, title: 'backoff', complexity: 'easy' });
// 任务已在 queued 且退避到未来
store.transition(t.id, 'queued', { by: 'test' });
const future = new Date(Date.now() + 60_000).toISOString();
store.setNextEligibleAt(t.id, future);
const clock = makeClock(Date.now());
const state = freshState();
const { deps } = mockDeps(state, { nowMs: clock.nowMs });
const orch = createOrchestrator(store, noopLog, deps);
orch.claimTick();
assert.equal(state.spawned.length, 0, '退避期内不领');
assert.equal(store.getTask(t.id)!.status, 'queued');
clock.advance(61_000); // 退避过期
orch.claimTick();
assert.equal(state.spawned.length, 1, '退避过期后领取');
assert.equal(store.getTask(t.id)!.status, 'executing');
store.close();
});
test('spawnWorker 抛错:兜底 failTaskAttempttask 转 queuedexecutor run failed', () => {
const { store, projectId } = setup('auto-easy');
const t = store.createTask({ projectId, title: 'boom-spawn', complexity: 'easy' });
const state = freshState();
const { deps } = mockDeps(state, {
spawnWorker: () => { throw new Error('spawn 失败:ENOENT'); },
});
createOrchestrator(store, noopLog, deps).claimTick();
const after = store.getTask(t.id)!;
assert.equal(after.status, 'queued', '默认 maxRetries=2,首次失败 → 重入队');
assert.ok(after.nextEligibleAt, '退避已持久化');
const executor = store.listRuns(t.id).find((r) => r.kind === 'executor')!;
assert.equal(executor.status, 'failed');
assert.match(executor.error ?? '', /spawn 失败/);
store.close();
});
// ───────────────────────── reaper(回收死 worker)─────────────────────────
test('reaperisWorkerAlive=false → failTaskAttemptexecuting→queuedexecutor run failed', () => {
const { store, projectId } = setup('auto-easy');
const t = store.createTask({ projectId, title: 'dead', complexity: 'easy' });
// 先让它进 executing + 建 run(用一次正常领取,alive=true 不会被回收)
const state = freshState();
const { deps } = mockDeps(state);
const orch = createOrchestrator(store, noopLog, deps);
orch.claimTick();
assert.equal(store.getTask(t.id)!.status, 'executing');
const runId = store.listRuns(t.id)[0].id;
// 标记 worker 已死 → reaper 回收
state.alive = false;
orch.reap();
const after = store.getTask(t.id)!;
assert.equal(after.status, 'queued', '默认 maxRetries=2,首次回收 → 重入队');
assert.ok(after.nextEligibleAt, '退避已持久化');
const executor = store.getRun(runId)!;
assert.equal(executor.status, 'failed');
assert.match(executor.error ?? '', /worker 异常退出/);
store.close();
});
test('reaperisWorkerAlive=true → 不回收(保持 executing', () => {
const { store, projectId } = setup('auto-easy');
const t = store.createTask({ projectId, title: 'alive', complexity: 'easy' });
const state = freshState();
const { deps } = mockDeps(state);
const orch = createOrchestrator(store, noopLog, deps);
orch.claimTick();
assert.equal(store.getTask(t.id)!.status, 'executing');
state.alive = true;
orch.reap();
assert.equal(store.getTask(t.id)!.status, 'executing', 'worker 活 → 不回收');
store.close();
});
test('reapermaxRetries=0 → 死 worker 直接 needs_attention', () => {
const store = new Store(':memory:');
const p = store.createProject({
name: 'orch', repoPath: '/tmp/orch-reap0-' + Math.random(), autonomy: 'auto-easy', maxRetries: 0,
});
const t = store.createTask({ projectId: p.id, title: 'x', complexity: 'easy' });
const state = freshState();
const { deps } = mockDeps(state);
const orch = createOrchestrator(store, noopLog, deps);
orch.claimTick();
state.alive = false;
orch.reap();
assert.equal(store.getTask(t.id)!.status, 'needs_attention');
store.close();
});
// ───────────────────────── 多轮:reaper 腾槽后重领(退避到期)─────────────────────────
test('死 worker 回收 + 退避到期后下一轮重新领取(监工闭环)', () => {
const { store, projectId } = setup('auto-easy', 1);
const t = store.createTask({ projectId, title: 'recycle', complexity: 'easy' });
const clock = makeClock(Date.now());
const state = freshState();
const { deps } = mockDeps(state, { nowMs: clock.nowMs });
const orch = createOrchestrator(store, noopLog, deps);
// 第一轮:领取 + spawnalive=true
orch.tick();
assert.equal(state.spawned.length, 1);
assert.equal(store.getTask(t.id)!.status, 'executing');
// worker 死 → 下一轮 reaper 回收(→ queued + 退避),退避期内不重领
state.alive = false;
orch.tick();
assert.equal(store.getTask(t.id)!.status, 'queued');
assert.equal(state.spawned.length, 1, '退避期内不重领');
// 退避到期 → 再下一轮重新领取
clock.advance(10 * 60_000);
orch.tick();
assert.equal(state.spawned.length, 2, '退避到期 → 重新领取 spawn');
assert.equal(store.getTask(t.id)!.status, 'executing');
store.close();
});
test(`reaper 反复回收:净失败 ${DEFAULT_MAX_RETRIES} 次后 → needs_attention`, () => {
const { store, projectId } = setup('auto-easy', 1);
const t = store.createTask({ projectId, title: 'flaky', complexity: 'easy' });
const clock = makeClock(Date.now());
const state = freshState();
const { deps } = mockDeps(state, { nowMs: clock.nowMs });
const orch = createOrchestrator(store, noopLog, deps);
// 反复:领取(alive=true 领取那刻)→ 标死 → reaper 回收
for (let i = 0; i <= DEFAULT_MAX_RETRIES; i++) {
clock.advance(10 * 60_000); // 跳过退避
state.alive = true;
orch.claimTick();
state.alive = false;
orch.reap();
}
assert.equal(store.getTask(t.id)!.status, 'needs_attention');
const failed = store.listRuns(t.id).filter((r) => r.kind === 'executor' && r.status === 'failed');
assert.equal(failed.length, DEFAULT_MAX_RETRIES + 1);
store.close();
});
// ───────────────────────── planneranalyzing / speccing 也被领取 ─────────────────────────
test('claimable 纳入 speccingmedium 任务派 planner-spec(不转状态、startRun=planner、job.runKind=planner-spec', () => {
const { store, projectId } = setup('auto-approved');
const t = store.createTask({ projectId, title: 'feat', complexity: 'medium' }); // → speccing
assert.equal(store.getTask(t.id)!.status, 'speccing');
const state = freshState();
const { deps } = mockDeps(state);
const orch = createOrchestrator(store, noopLog, deps);
orch.claimTick();
assert.equal(state.spawned.length, 1, '派了一个 planner worker');
assert.equal(store.getTask(t.id)!.status, 'speccing', 'planner 不转任务状态(留 speccing 作规划中)');
const runs = store.listRuns(t.id);
assert.equal(runs.length, 1);
assert.equal(runs[0].kind, 'planner', 'run kind=planner');
assert.equal(runs[0].status, 'started');
assert.equal(state.jobs[0].runKind, 'planner-spec');
// 已在途(有 started planner run)→ 下一轮不重领
orch.claimTick();
assert.equal(state.spawned.length, 1, 'inflight 任务不被重复领取');
store.close();
});
test('claimable 纳入 analyzinghard 任务派 planner-decompose', () => {
const { store, projectId } = setup('auto-approved');
const t = store.createTask({ projectId, title: 'epic', complexity: 'hard' }); // → analyzing
assert.equal(store.getTask(t.id)!.status, 'analyzing');
const state = freshState();
const { deps } = mockDeps(state);
createOrchestrator(store, noopLog, deps).claimTick();
assert.equal(state.spawned.length, 1);
assert.equal(store.getTask(t.id)!.status, 'analyzing', 'planner 不转状态');
assert.equal(store.listRuns(t.id)[0].kind, 'planner');
assert.equal(state.jobs[0].runKind, 'planner-decompose');
store.close();
});
test('子任务在父任务 plan_review 期间不可领取,拆解批准(decomposed)后放行', () => {
const { store, projectId } = setup('auto-approved');
const parent = store.createTask({ projectId, title: 'epic', complexity: 'hard' }); // → analyzing
const child = store.createTask({ projectId, parentId: parent.id, title: 'sub', complexity: 'easy' }); // → ready
store.transition(parent.id, 'plan_review', { by: 'test' }); // 模拟拆解完成待审
const state = freshState();
const { deps } = mockDeps(state);
const orch = createOrchestrator(store, noopLog, deps);
orch.claimTick();
assert.equal(state.spawned.length, 0, '父任务 plan_review(拆解待审)期间,子任务不应被领取');
store.transition(parent.id, 'decomposed', { by: 'test' }); // 批准拆解
orch.claimTick();
assert.equal(state.spawned.length, 1, '拆解批准后子任务放行可领取');
assert.equal(store.listRuns(child.id).length, 1, '被领取的是该子任务');
store.close();
});
test('auto-easy 不做规划:medium(speccing) 不被领取', () => {
const { store, projectId } = setup('auto-easy');
const t = store.createTask({ projectId, title: 'feat', complexity: 'medium' }); // → speccing
const state = freshState();
const { deps } = mockDeps(state);
createOrchestrator(store, noopLog, deps).claimTick();
assert.equal(state.spawned.length, 0, 'auto-easy 只做 easy 执行,不规划 medium/hard');
assert.equal(store.getTask(t.id)!.status, 'speccing');
store.close();
});
// ───────────────────────── 全局并发闸(跨项目在途总数上限)─────────────────────────
/** 单 store 建两个 active / auto-easy 项目,各塞 n 个 easy 任务,per-project 并发足够大(不成为瓶颈)。 */
function setupTwoProjects(n = 2, concurrency = 5): { store: Store; p1: string; p2: string } {
const store = new Store(':memory:');
const p1 = store.createProject({ name: 'g1', repoPath: '/tmp/g1-' + Math.random(), autonomy: 'auto-easy', concurrency });
const p2 = store.createProject({ name: 'g2', repoPath: '/tmp/g2-' + Math.random(), autonomy: 'auto-easy', concurrency });
for (let i = 0; i < n; i++) {
store.createTask({ projectId: p1.id, title: `p1-${i}`, complexity: 'easy' });
store.createTask({ projectId: p2.id, title: `p2-${i}`, complexity: 'easy' });
}
return { store, p1: p1.id, p2: p2.id };
}
test('全局闸:globalConcurrency=1 达上限 → 跨所有项目只领一个(per-project 闸都没满也阻止)', () => {
const { store } = setupTwoProjects(2, 5);
store.putSettings({ globalConcurrency: 1 });
const state = freshState();
const { deps } = mockDeps(state);
createOrchestrator(store, noopLog, deps).claimTick();
assert.equal(state.spawned.length, 1, '全局闸=1:两项目共 4 个可领任务,本轮只放一个');
store.close();
});
test('全局闸:globalConcurrency=0(缺省)不限 → 两项目任务全领(不误伤)', () => {
const { store, p1, p2 } = setupTwoProjects(2, 5);
// 不设 globalConcurrency(默认 0
assert.equal(store.getSettings().globalConcurrency, 0);
const state = freshState();
const { deps } = mockDeps(state);
createOrchestrator(store, noopLog, deps).claimTick();
assert.equal(state.spawned.length, 4, '不限:两项目各 2 个共 4 个全部领取');
assert.equal(store.listTasks(p1).filter((t) => t.status === 'executing').length, 2);
assert.equal(store.listTasks(p2).filter((t) => t.status === 'executing').length, 2);
store.close();
});
test('全局闸:globalConcurrency=3 两项目共 4 任务 → 一轮恰好领 3(边界)', () => {
const { store } = setupTwoProjects(2, 5);
store.putSettings({ globalConcurrency: 3 });
const state = freshState();
const { deps } = mockDeps(state);
createOrchestrator(store, noopLog, deps).claimTick();
assert.equal(state.spawned.length, 3, '全局闸=3:达上限即跨项目停止,恰好领 3 个');
store.close();
});
test('全局闸:在途已达上限 → 整轮直接不领(轮初短路)', () => {
const { store, p1 } = setupTwoProjects(2, 5);
store.putSettings({ globalConcurrency: 1 });
const state = freshState();
const { deps } = mockDeps(state);
const orch = createOrchestrator(store, noopLog, deps);
orch.claimTick();
assert.equal(state.spawned.length, 1);
assert.equal(store.globalInflightCount(), 1, '已有 1 个在途 started run');
// 再来一轮:全局在途已=1=cap → 不再领
orch.claimTick();
assert.equal(state.spawned.length, 1, '全局在途已达上限 → 整轮不领');
assert.ok(store.listTasks(p1).length >= 0);
store.close();
});
test('并发闸用 inflight1 个 executing + 1 个 planner 占满 concurrency=2', () => {
const { store, projectId } = setup('auto-approved', 2);
const e = store.createTask({ projectId, title: 'easy', complexity: 'easy' }); // → ready
const m = store.createTask({ projectId, title: 'med', complexity: 'medium' }); // → speccing
store.createTask({ projectId, title: 'easy2', complexity: 'easy' }); // → ready(第三个)
const state = freshState();
const { deps } = mockDeps(state);
createOrchestrator(store, noopLog, deps).claimTick();
// concurrency=2:一个 executor(easy) + 一个 planner(medium) 占满,第三个不领
assert.equal(state.spawned.length, 2);
assert.equal(store.getTask(e.id)!.status, 'executing');
assert.equal(store.getTask(m.id)!.status, 'speccing');
store.close();
});