跳到主要内容

任务图的调度执行:队列、依赖、失败级联、重试

30 秒导读: 上一章 Coordinator 把目标拆成了一张任务 DAG。这一章讲拿到这张图之后怎么把它跑完——一个不碰 LLM 的确定性执行引擎:哪些任务能立刻并行开跑、哪些得等依赖、某个任务失败或被跳过时下游怎么处理、失败了要不要重试。核心是一句主不变量:独立任务并行、依赖任务等待、单点失败只砍掉它的下游,不阻断无关任务。


1. 这是什么(零基础也能懂)

一句话定义: 这是一个任务图调度器——你给它一堆任务,每个任务可以声明"我依赖谁",它负责按正确顺序、尽可能并行地把它们全跑完,并妥善处理失败。

它解决什么问题: 假设你让一个多 agent 系统"调研市场 → 写报告 → 校对报告"。调研没做完就不能写报告(有依赖);但如果你同时要"调研市场"和"整理竞品 logo"(互不相关),它们就该同时开跑别互相等。手写这套"谁等谁、谁能并行"的调度逻辑很容易出 bug——这个引擎把它做成了确定性的、可复现的机制。

它管什么、不管什么:

它负责它不负责
任务的生命周期状态流转(pending→completed…)任务内容怎么来的(那是 01 章 Coordinator 的事)
依赖解析:谁能跑、谁得等单个 agent 怎么跟模型对话(见 03 章)
失败/跳过的级联传播结果怎么存进共享内存(见 05 章)
并发上限、每 agent 串行、重试退避

一句话直觉: 把它想成 make 或 CI 里的任务流水线——声明依赖,引擎自己算出拓扑顺序,能并行的并行,一个目标挂了不影响别的目标。区别只是这里的"任务"最终交给 AI agent 去执行。

用起来什么样: 显式用法就是 runTasks,你自己给出 dependsOn:

// 示意,非源码 —— 展示调用方视角
const result = await orchestrator.runTasks(team, [
{ title: '调研市场', description: '...', assignee: 'researcher' },
{ title: '整理竞品', description: '...', assignee: 'analyst' }, // 与调研并行
{ title: '写报告', description: '...', assignee: 'writer',
dependsOn: ['调研市场', '整理竞品'] }, // 等前两个都完成
])

runTeam 则是 Coordinator 先生成这张任务数组,再喂进同一个引擎——所以这一章讲的机制,两种模式共用。


2. 顶层全景(它大概怎么转)

整个引擎分两块:TaskQueue(状态机 + 事件) 管"每个任务现在是什么状态、状态变了通知谁";executeQueue(调度循环) 管"每一轮挑出能跑的任务、并行派发给 agent 池"。

部件一句话职责:

部件干什么文件
createTask / isTaskReady / validateTaskDependencies纯函数:造任务、判断能否开跑、校验依赖图合法(未知/自依赖/环)task/task.ts
TaskQueue有状态的队列:持有所有任务、驱动状态流转、完成时自动解锁下游、失败时级联、发事件task/queue.ts
executeQueue分轮调度循环:每轮 autoAssign→取 pending→并行 dispatch→重试→写回队列orchestrator/orchestrator.ts
executeWithRetry包住单个任务的执行,按 maxRetries/退避重试orchestrator/orchestrator.ts
AgentPool并发闸门:全局 semaphore 限并发 + 每 agent 一把锁防同 agent 并发跑串状态agent/pool.ts

主线走一遍(不进代码):

┌─────────────────────────────────────────────┐
任务数组 │ executeQueue 主循环 │
(带 dependsOn) │ (orchestrator.ts:798) │
│ │ │
▼ │ 每一轮 (while true): │
┌──────────┐ │ ① scheduler.autoAssign 给没主的任务派 agent │
│ TaskQueue │◀──────┤ ② queue.getByStatus('pending') 取能跑的 │
│ 状态机 │ │ ③ 并行 dispatch(Promise.all) 每个→agent 池 │
│ │──────▶│ ④ 成功→queue.complete / 失败→queue.fail │
└──────────┘ 事件 │ ⑤ 没有 pending 了就退出 │
▲ │ task:ready └─────────────────────────────────────────────┘
│ │ 自动解锁下游 │
│ └──────────────────────────┘
│ complete 触发 unblockDependents / fail 触发 cascadeFailure

怎么读这张图: 左边 TaskQueue 是"真相之源"(每个任务的状态都存这);右边 executeQueue 是"发动机",一轮一轮地问队列"现在谁能跑",把它们并行发出去,拿到结果再写回队列。写回时队列自己会把下游任务从"堵塞"改成"就绪"(或级联失败),这样下一轮就能捞到新解锁的任务。循环直到没有 pending 为止。


3. 核心原理(逐个机制,由浅入深)

3.1 任务的状态机:六种状态,三个终态

它要解决的小问题: 一个任务在生命周期里会经历好几种情况——排队、被依赖堵住、正在跑、跑成了、跑挂了、被跳过。得有个明确的状态集合,否则调度逻辑没法判断"谁现在能跑"。

TaskStatus 一共六种,可以分成"活跃态"和"终态":

状态含义类别
pending就绪,依赖都满足了,等着被派发活跃
blocked有依赖还没完成,暂时不能跑活跃
in_progress正在被某个 agent 执行活跃
completed成功完成终态
failed失败(自己挂了,或上游挂了级联下来)终态
skipped被跳过(审批拒绝、预算超限、上游被跳过)终态

"终态"的定义直接写在 isComplete() 里:队列里每个任务都进入 completed/failed/skipped 时,整个队列就算跑完了(queue.ts:348 isComplete)。

状态流转图(怎么读:实线是引擎自动触发的转移,箭头上是触发它的调用):

add()

有未满足依赖? ├── 否 ──▶ pending ──dispatch──▶ in_progress ──┬─ 成功 ─▶ completed
│ ▲ │
└─ 是 ──▶ blocked └─ 失败 ─▶ failed
│ │
上游 complete │ unblockDependents │ cascadeFailure
▼ ▼
pending 下游 blocked/pending ─▶ failed
(skip 同理 ─▶ skipped)

关键点:任务进队列时并不是无脑 pendingadd() 会先调 resolveInitialStatus,若有未满足的依赖就直接标成 blocked(queue.ts:74 addqueue.ts:454 resolveInitialStatus),只有真正就绪的任务进来才 pending 并立刻发 task:ready 事件。

3.2 依赖判定:isTaskReady 这把尺子

它要解决的小问题: "这个任务现在能跑吗?"这个判断在整个引擎里被问无数次——进队列时问、上游完成时问、崩溃恢复时问。它必须是一个纯函数,答案只取决于当前任务集合的状态,不依赖任何隐藏状态。

规则只有两条(task/task.ts:84 isTaskReady):

  1. 任务自己得是 pending;
  2. dependsOn 里列的每个任务,都必须已经 completed
// 示意,非源码 —— isTaskReady 的核心逻辑
function isTaskReady(task, allTasks) {
if (task.status !== 'pending') return false // 只有 pending 才谈得上"能跑"
if (!task.dependsOn?.length) return true // 无依赖,直接就绪
for (const depId of task.dependsOn) {
const dep = lookup(depId, allTasks)
if (!dep || dep.status !== 'completed') return false // 依赖缺失或没完成 → 不就绪
}
return true
}

一个容易忽略的细节:依赖如果在任务集合里根本找不到,视为永不满足(task.ts:95 那句 if (!dep …) return false)。这防止了"依赖了一个不存在的任务 id 却傻等"。真实实现还支持传入预先建好的 taskById 映射表,把循环里反复建表的 O(n²) 降成 O(n)(task.ts:84taskById? 参数)。

3.3 完成 → 自动解锁下游(auto-unblock)

它要解决的小问题: A 完成了,依赖 A 的 B 应该从 blocked 变回 pending,好让下一轮调度能捞到它。谁来做这件事?——队列自己,在 complete() 里顺手做,调用方不用轮询。

complete() 的动作序列(queue.ts:178 complete):

  1. 把任务标成 completed,记下 result;
  2. task:complete 事件;
  3. unblockDependents(taskId) —— 扫一遍所有 blocked 任务,把现在满足了的提升为 pending 并发 task:ready;
  4. 若整个队列都终态了,发 all:complete

unblockDependents 的巧妙处在于只扫一次、用一张共享的 id→task 映射表,避免每个候选任务都重建表(queue.ts:471)。而且它在循环里边改边更新那张表(taskById.set(...)),这样同一轮里"A 完成同时解锁了 B、C,而 C 又依赖 B"这种情况也能在一次调用内正确判定:

// 示意,非源码 —— unblockDependents 的要点
for (const task of allBlocked) {
if (!task.dependsOn?.includes(completedId)) continue // 只看依赖了刚完成任务的
if (isTaskReady({ ...task, status: 'pending' }, allTasks, taskById)) {
task.status = 'pending'
taskById.set(task.id, task) // 关键:更新映射,让后续迭代看到最新状态
emit('task:ready', task)
}
}

这一条是"依赖任务等待"不变量的正向实现:依赖没满足前它一直 blocked,不会被调度;满足的瞬间被精确唤醒。

3.4 失败级联:砍掉下游,但不碰无关任务

它要解决的小问题: A 失败了,依赖 A 的 B 永远等不到 A 完成——如果不管它,B 会一直 blocked 卡死,整个队列 isComplete() 永远为 false,循环退不出去。所以失败必须沿依赖边向下传播,把回不了头的下游也标成终态。

fail() 做三件事(queue.ts:197 fail):标 failed + 记错误 → 发 task:failed → 调 cascadeFailure

cascadeFailure递归的(queue.ts:259):遍历所有还在 blocked/pending 的任务,凡是 dependsOn 里含了刚失败任务的,标成 failed(错误信息写成 Cancelled: dependency "X" failed.),然后对它继续递归处理它自己的下游。

这里是主不变量最关键的一句:级联只碰"下游",即 dependsOn 里真的引用了失败任务的那些(queue.ts:262 那句 if (!task.dependsOn?.includes(failedTaskId)) continue)。跟失败任务没有依赖关系的任务,一根汗毛都不动——它们照常跑。这就是"失败不阻断无关任务"。

用一张图看清"砍下游 vs 留无关":

A ──▶ B ──▶ C A 失败:
(失败) · B 依赖 A → 级联 failed
· C 依赖 B → 递归级联 failed
D ──▶ E · D、E 与 A 无依赖关系 → 完全不受影响,继续跑
(无关支线)

skip()fail()镜像的(queue.ts:217 skipcascadeSkip):被跳过的上游同样被当成"永远无法满足",下游级联成 skipped,只是终态标签不同。文档字符串明确说明:即便下游还有别的依赖是 pending/completed,只要有一个上游被跳过,它就跳过(queue.ts:207-216 注释)。

3.5 事件订阅:队列不轮询,靠推

它要解决的小问题: executeQueue 想知道"有新任务就绪了""某任务被跳过了",但它不想每毫秒去问队列。队列改用发布/订阅主动推。

TaskQueue 暴露 on(event, handler)(queue.ts:430),返回一个幂等的取消订阅函数。事件类型见 queue.ts:17 TaskQueueEvent:

事件何时发
task:ready任务进队列即就绪,或被 unblock 时
task:completecomplete()
task:failedfail() 及级联的每个下游
task:skippedskip()/skipRemaining() 及级联
all:complete队列全部进入终态时

实际用法很克制:executeQueue 只订阅了 task:skipped,把它转发给调用方的 onProgress 回调(orchestrator.ts:805),用完在循环末尾 unsubSkipped?.() 退订(orchestrator.ts:1104)。调度主体并不靠事件驱动,而是每轮主动 getByStatus('pending') 拉——事件在这里主要服务观测,不是控制流。

3.6 快照与恢复:让崩溃后能续跑

它要解决的小问题: 长任务图跑到一半进程挂了,不想从头再来。队列得能序列化成快照、之后重建,并且把"崩溃时正在跑的任务"正确复位成可重跑。

snapshot() 把所有任务连同按状态分好的 id 分区(pending/inProgress/completed/…)一起导出(queue.ts:101),version: 1 打头做版本校验。

fromSnapshot()静态工厂(queue.ts:123),默认是精确往返(连 in_progress 都原样恢复)。但恢复要续跑时,应传 { resetInProgress: true }:

  • 崩溃时正在跑(in_progress)的任务被复位成 pending(queue.ts:562 resetRestoredInProgress);
  • 然后重新跑一遍 isTaskReady 校准:该 blocked 的复位成 blocked、该 pending 的确认 pending——因为恢复后的世界里,某些依赖可能已经不满足了。
// 示意,非源码 —— resetRestoredInProgress 的两步校准
const initial = tasks.map(t => t.status === 'in_progress'
? { ...t, status: 'pending' } : t) // 第一步:in_progress → pending
return initial.map(t => { // 第二步:按依赖重新判定 blocked / pending
const ready = isTaskReady({ ...t, status: 'pending' }, initial)
if (ready && t.status === 'blocked') return { ...t, status: 'pending' }
if (!ready && t.status === 'pending') return { ...t, status: 'blocked' }
return t
})

这套快照被 checkpoint 机制包在外面持久化(见 05 章 的检查点部分),这里只讲队列这一层的序列化契约。


4. 深入实现:executeQueue 调度循环

这节走读发动机本体——executeQueue(orchestrator/orchestrator.ts:798),它把上面的队列机制串成一个真正的执行流程。

4.1 上跑之前:依赖怎么被校验(两条路径不一样)

进队列之前对依赖图的处理,runTasks 和"计划工件重放"两条路径做法不同,别混为一谈。

runTasks 路径:没有环前置闸门,悬空依赖靠入队时失败兜底。 runTasks(orchestrator.ts:2199)把任务交给 loadSpecsIntoQueue(:2819)、再直接 executeExplicitTaskQueue(:2225)——全程不调用 validateTaskDependencies。它对坏依赖的兜底在 loadSpecsIntoQueue 内部:一个引用解析不出来(悬空/歧义重名)的任务照样入队,但立刻 queue.fail 并写明 Unresolved dependency reference(s): …(:2897-2900)——让它显式失败,而不是永远卡 blocked(这层解析细节见 01 章 §5.2)。换言之,runTasks 没有针对"依赖环"的开跑前校验闸门;真出现环,只会在运行时体现为"谁都等不到、始终 blocked",不会被提前抛错拦下。

计划工件路径:才有 validateTaskDependencies 这道闸门。 只有 runFromPlan(orchestrator.ts:2034,重放一张固化的 PlanArtifact)在入队前跑 validateTaskDependencies(tasks)(:2045),不合法直接抛错、不开跑。这个纯函数(task/task.ts:192)查三类问题:

  • 未知依赖:dependsOn 引用了不存在的任务 id(Pass 1);
  • 自依赖:任务依赖自己(Pass 1);
  • :A→B→C→A 这种循环依赖,Pass 2 用 DFS 三色染色法检测(task.ts:216 白/灰/黑)。

getTaskDependencyOrder(task.ts:123)则是 Kahn 拓扑排序,用于需要预排序的场景,与执行调度独立:执行时的顺序并不靠任何静态排序,而是靠 3.2/3.3 的动态就绪判定——这更灵活,因为并行完成的顺序是运行时才知道的。

4.2 分轮并行:一轮的生命周期

主循环是个 while (true)(orchestrator.ts:815),每一轮:

  1. 检查中止:ctx.abortSignal?.aborted 为真 → queue.skipRemaining('run aborted') 把所有非终态任务标 skipped,退出(orchestrator.ts:817)。这是外部取消的入口。
  2. 重新自动分配:scheduler.autoAssign(queue, team.getAgents())(orchestrator.ts:824)。每轮都跑,因为上一轮 unblock 出来的新任务可能还没主;默认策略 dependency-first(见 01 章调度策略,这里不重复)。
  3. 取 pending:queue.getByStatus('pending')为空就退出——要么全跑完了,要么剩下的都 blocked/failed 卡住了(orchestrator.ts:826)。
  4. 并行 dispatch:把这一轮所有 pending 任务 .map 成 promise,await Promise.all(...) 等整批跑完(orchestrator.ts:838:1074)。这就是"独立任务并行"的落点——同一轮里所有就绪任务同时发出去,受 AgentPool 的并发上限约束。

单个任务在 dispatch 里的处理(orchestrator.ts:838 起):标 in_progress → 校验有 assignee、agent 存在于 team 和 pool(任一缺失就 queue.fail)→ 构造 prompt(注入依赖结果)→ 经 executeWithRetry 交给池执行 → 成功 queue.complete(task.id, output)、失败 queue.fail(task.id, output)

为什么要"等整批 Promise.all 完再进下一轮"? 因为只有一批都结束,才好统一检查:新解锁了哪些任务、要不要触发审批门、预算是否超了。代价是一轮的耗时被这批里最慢的任务拖住(队头阻塞),但换来了调度逻辑的简单和确定性。

4.3 每轮之后:预算门与审批门

批处理结束后、进下一轮前,有两道可选的闸门:

  • 预算门:若累计 token 超了 maxTokenBudget,budgetExceededTriggered 置真,queue.skipRemaining('token budget exceeded') 跳过所有剩余任务并退出(orchestrator.ts:1075)。
  • 审批门:若配了 onApproval 且这一轮有成功完成的任务,把"本轮完成的"和"下一批待办的"交给回调,回调返回 false(或抛错)就 skipRemaining 停下(orchestrator.ts:1083)。这让人可以在每一"波"之间卡一道人工确认。

注意 skipRemaining 的调用时机有讲究:它的文档字符串警告只能在没有任务在跑时调(queue.ts:233-237),而 executeQueue 恰恰都在 await Promise.all() 之后才调它——此刻没有 in-flight 任务,安全。

4.4 executeWithRetry:重试与指数退避

每个任务的执行都包在 executeWithRetry 里(orchestrator.ts:307)。它从任务字段读重试配置:

字段默认作用
maxRetries0(即只跑 1 次)额外重试次数;maxAttempts = maxRetries + 1
retryDelayMs1000首次重试前的基础延迟
retryBackoff2退避倍数

每次失败后的延迟由 computeRetryDelay(base, backoff, attempt) 算(orchestrator.ts:288):base * backoff^(attempt-1),并封顶 30 秒(MAX_RETRY_DELAY_MS,orchestrator.ts:283)防止退避失控。

两个容易被漏掉的正确性细节:

  • token 用量跨所有尝试累加(orchestrator.ts:321 totalUsage),这样计费/观测反映重试的真实成本,而不只是最后一次。
  • 两类失败都触发重试:result.success === false(agent 正常返回但没成)和抛异常(orchestrator.ts:331/:345),重试耗尽后统一返回一个 success: false 的结果,由调度循环转成 queue.fail

重试全程在任务级别,失败任务把整个"跑 + 重试"耗尽后才算最终失败,再触发 3.4 的级联——所以"重试"和"级联"是两个不同层次:先在任务内重试,救不回来才向下游级联。

4.5 AgentPool:并发闸门 + 每 agent 串行锁

executeQueue 把任务交给 pool.run / pool.runEphemeral 执行(orchestrator.ts:951 起)。AgentPool(agent/pool.ts:58)加了两层锁:

  1. 全局 semaphore:构造时设 maxConcurrency(默认 5,pool.ts:76),限制整个池同时在跑的 agent 数。这就是"并行"有上限的原因——一轮里 pending 再多,也只有 maxConcurrency 个真正同时执行。
  2. 每 agent 互斥锁:每个 agent 一把 Semaphore(1)(pool.ts:68 agentLocks)。为什么需要?因为同一个 Agent 实例有可变状态(status/messages/tokenUsage),两个任务派给同一个 agent 若并发跑会串状态(issue #72,pool.ts:66)。

run() 的加锁顺序很讲究(pool.ts:158):先拿每 agent 锁,再拿全局 semaphore。这样第二个派给同 agent 的任务会卡在 agent 锁上、不占用全局 slot,不会白白堵住其他 agent 的并发额度。

// 示意,非源码 —— pool.run 的双锁顺序
await agentLock.acquire() // ① 先锁住这个 agent(同 agent 的第二个任务在此排队,不占 slot)
try {
await semaphore.acquire() // ② 再抢全局并发额度
try { return await agent.run(prompt) }
finally { semaphore.release() }
} finally { agentLock.release() }

runEphemeral(pool.ts:206)只抢全局 semaphore、不加 agent 锁——专给委派用:每次委派都构造一个全新的 Agent 实例(见 03 章委派),没有共享可变状态,加 agent 锁反而会造成"A 委派 B、B 委派 A 互相等对方 agent 锁"的死锁。availableRunSlots(pool.ts:86)让委派前能先探一眼还有没有空 slot,没有就直接拒绝、避免死锁。


5. 巧妙之处(可借鉴的技术)

  • 状态机内聚在队列里,调度器只管"挑与派"。 解锁下游、级联失败这些"图的传播逻辑"全在 TaskQueue.complete/fail 内部完成(queue.ts:178/:197),executeQueue 完全不碰依赖图,只反复问"谁 pending"。职责边界干净,队列可独立测试。

  • 级联的向下精确性 = 主不变量的实现。 cascadeFailure 里那句 if (!task.dependsOn?.includes(failedTaskId)) continue(queue.ts:262)是"失败不阻断无关任务"的字面保证——只有真依赖它的才被砍。

  • unblock 一次扫描 + 边改边更新映射表。 unblockDependents(queue.ts:471)避免了 O(n²),且在同一轮里让"C 依赖 B,B 刚被解锁"也能立即正确判定,靠的是循环内 taskById.set 同步最新状态。

  • 加锁顺序防"占坑饿死"。 先 agent 锁后 semaphore(pool.ts:158),让同 agent 的排队任务不吃掉全局并发额度——一个反直觉但关键的顺序。

  • 重试退避封顶。 computeRetryDelayMath.min(..., 30_000)(orchestrator.ts:293)避免高 backoff 下延迟指数爆炸。

  • 恢复时二次校准依赖。 resetRestoredInProgress(queue.ts:562)不只是把 in_progress 复位,还重跑 isTaskReady——因为崩溃恢复后的依赖满足情况可能已变,不校准会派发本该 blocked 的任务。


6. 边界与局限(诚实)

  • 队头阻塞(head-of-line blocking): 每轮 await Promise.all 要等整批最慢的任务(含其重试)结束才进下一轮(orchestrator.ts:1074)。一个慢任务会拖住同轮里本可提前解锁下游的快任务——引擎用这个代价换调度的简单与确定性。

  • getTaskDependencyOrder 遇环返回部分结果。 拓扑排序在有环时只返回能排的部分(task.ts:112-113 注释),不报错;真正拦环靠 validateTaskDependencies——但注意它只挂在计划工件路径(runFromPlan,§4.1),runTasks 路径没有这道环闸门。

  • skipRemaining 有并发前提。 它假设调用时没有任务在跑(queue.ts:233),若在 agent 执行中调用,可能把一个 in_progress 任务标 skipped 而其 agent 仍在跑。引擎自己只在 Promise.all 之后调,安全;但这是队列 API 的一个隐含契约,外部直接用要小心。

  • 同名 agent 并发结果覆盖。 pool.runParallel 若两个任务同名 agent,map 里只留最后一个结果(pool.ts:245-247 注释)。执行路径里 executeQueueassignee:taskId 作 key 规避了这点(orchestrator.ts:995),但 runParallel 这个通用 API 本身有此限制。

  • 失败任务不自动重排到别的 agent。 重试是"同一任务、同配置再跑"(orchestrator.ts:307),不会换 agent 或换模型;跨模型/降级要靠上层 modelRouting 或调用方逻辑。


7. 横向对比(同库其它章)

  • 任务图从哪来——Coordinator 如何把自然语言目标拆成这张 DAG、autoAssign 的四种调度策略:01-coordinator-and-scheduling.md
  • 被派发的任务具体怎么执行——单 agent 的对话循环、工具默认拒绝、沙箱、委派(runEphemeral 的消费方):03-agent-loop-and-tools.md
  • 完成结果存去哪、检查点如何把 snapshot() 持久化、观测事件:05-memory-consensus-observability.md。
  • 全景与阅读顺序:index.md

8. 代码地图(导航索引)

主题文件符号
队列状态机、add/complete/fail/skippackages/core/src/task/queue.tsTaskQueue
进队列时定初始状态(pending vs blocked)packages/core/src/task/queue.tsresolveInitialStatus
完成后自动解锁下游packages/core/src/task/queue.tsunblockDependents
失败向下游级联packages/core/src/task/queue.tscascadeFailure
跳过向下游级联packages/core/src/task/queue.tscascadeSkip
一次跳过所有剩余任务packages/core/src/task/queue.tsskipRemaining
事件订阅(返回退订函数)packages/core/src/task/queue.tson
序列化 / 反序列化恢复packages/core/src/task/queue.tssnapshotfromSnapshotresetRestoredInProgress
造任务(UUID + pending + 时间戳)packages/core/src/task/task.tscreateTask
就绪判定纯函数packages/core/src/task/task.tsisTaskReady
依赖图校验(未知/自依赖/环)packages/core/src/task/task.tsvalidateTaskDependencies
拓扑排序(Kahn)packages/core/src/task/task.tsgetTaskDependencyOrder
分轮并行调度主循环packages/core/src/orchestrator/orchestrator.tsexecuteQueue
重试 + 指数退避packages/core/src/orchestrator/orchestrator.tsexecuteWithRetry
退避延迟计算(封顶 30s)packages/core/src/orchestrator/orchestrator.tscomputeRetryDelay
并发池:全局 semaphore + 每 agent 锁packages/core/src/agent/pool.tsAgentPoolrunrunEphemeral
计数信号量packages/core/src/utils/semaphore.tsSemaphore