/** * v2 MVP — leader fan-out composition layer ([ref] §3, V2-MVP-PLAN.md M1, core seam). * * Runs N sub-task workers in parallel over `runner.runTaskStream` sharing ONE AbortController, bounded by a * Semaphore, with an overall deadline (no infinite wait — council#5), and a * `cancelAll` that ABORTS first (stopping not-yet-started workers + aborting running ones) then interrupts the * snapshot — closing the late-registration race (council round-2). Returns a typed WorkerReport per worker (council#6 — the * Coordinator needs a defined shape to route diffs in merge(b)). * * ── 重复 spawn 的去重由谁负责([ref] 车4 件1,台账 P1-9③ 更正)──────────────────────────────────── * 这段头注**曾经**写着 fan-out 带一道「concurrency claim(run-store createRun unique-key CAS)」并称 * 「the rule-5 dual-session tax solved at worker scale」。亲读后裁定:那道 claim 全链路无人传,而且 * **由构造即无效** —— 它的 CAS 键是 `(workerId, sessionId)`,而 sessionId 是 `wire.ts` 每次现铸的 * `leader--`;一次重投的 leader run 铸出来的是**不同的** sessionId,claim 永远 * 不会输。真正需要去重的那件事(同一逻辑请求被重投两次 ⇒ 两次真 git push)只能在**准入**处判,它的 * 属主是 `leader_run.idem_key` UNIQUE(`plugins/leader-run-store-sql.ts` 的原子赢或观察)。 * ⇒ 那个可选字段、它的函数类型别名、以及只有它才产得出的 `status:"rejected"`,三者同批删除(留一个 * 没人传的字段 + 一句「已解决」的头注,比没有这道防线更坏:它让读者以为这件事有人管)。 * 常驻门:`test/leader-failclosed-193.test.ts` 的 P1-9③(声明了就必须有生产者)。 * * cancelAll uses `TaskStream.destroy()` (core 1.71): abort + await run-settle + CAS-expire the checkpoint * (fencing any concurrent resume) + reap a SUSPENDED worker's paused container — closing the interim-`interrupt()` * gap (a cancel hitting a suspended worker no longer leaks a paused container or an orphan checkpoint). Per core's * contract, `destroy()` reaps the CHECKPOINT; the leader (leader.ts) still owns + destroys each worker's static env * in its own finally (the two are disjoint). Verified on real E2B+TiDB by the suspended-cancel dogfood. * * MVP scope: the COMPOSITION mechanics, unit-tested with a mock runner. Real E2B workers + branch/baseSha from * a worker's actual git state are wired at M4/M5 (joint dogfood). */ import { type TaskSpec, type TaskResult, type TaskStream, type RepairTerminal, type CheckpointGate } from "@sema-agent/core"; /** What the Coordinator gets back per worker (council#6 — merge(b) routes on this). */ export interface WorkerReport { workerId: string; sessionId: string; /** completed = mergeable; failed/suspended/timeout = not merged (quarantined). * (`rejected` 随那道并发去重面一并删除 —— 那面是它的唯一生产者,留下就是一个永不出现的闭集成员。) */ status: "completed" | "failed" | "suspended" | "timeout"; /** Worker's branch + the base it forked from — drives the merge(b) `git format-patch ` diff-out. */ branch?: string; baseSha?: string; error?: string; /** The worker's TaskResult.stats (tokens/cost/turns) — for the value-campaign cost accounting. */ stats?: TaskResult["stats"]; /** [ref] L3a module sub-gate: did this worker's module pass the per-module judge (after any repair)? */ moduleJudge?: "pass" | "fail"; /** Number of bounded repairs the module sub-gate spent on this worker (0 = passed first try). */ repairs?: number; /** [ref] Slice 6: the terminal checkpoint gate when a worker is still SUSPENDED (the leader auto-resumed * it up to its bound but it didn't fully complete — e.g. the overall deadline hit or the slice cap). Present * ⇒ resumable: the operator/leader can escalate (more budget) or accept the belt-diff partial progress. */ checkpointGate?: CheckpointGate; /** [ref] Slice 6: the worker's terminal failure code (e.g. `limits.max_cost_exceeded` on an * exhausted resume, `limits.max_turns_exceeded` — core 5.8.0 码名轴) — surfaced for /v1/leader observability. * S-136:源从 `TaskResult.errorCode` 换成因由 `failed` 臂的 `code`(本报告面的键名与形不变)。 */ errorCode?: string; /** LEADER-REPAIRLOOP-INTEGRATION §10.6 (THE push chokepoint): the `runRepairLoop` terminal a single-agent * worker resolved to, when the repair loop ran on this worker (LEADER_REPAIR_LOOP on). It MUST ride on the * report because a SAFE-tier `candidate_only` maps to core verdict "PASS" → this report's `status` collapses * to "completed" (there is no `candidate_only` status member) → the candidate state would otherwise be LOST, * and `merge.ts:226` would push `testCmd`-green-but-unconfirmed work. The merge push chokepoint gates on this * field (fires only when no mergeable report carries `repairTerminal ∉ {undefined, "fixed"}`), covering the * collapse-to-single and N=1 paths by construction. `status` stays "completed" for back-compat; this is the * WHY. Undefined ⇒ the repair loop did not run (the default, byte-identical path). */ repairTerminal?: RepairTerminal; } export interface FanOutSubtask { workerId: string; sessionId: string; baseSha?: string; branch?: string; /** The sub-task spec MINUS the fields fan-out owns (sessionId + signal are injected per worker). */ spec: Omit; } /** Only the two TaskStream methods fan-out consumes (the real core TaskStream satisfies this). */ export type FanOutStream = Pick; export interface RunnerLike { runTaskStream(spec: TaskSpec): FanOutStream; } export interface FanOutOptions { runner: RunnerLike; /** Overall fan-out deadline (ms). On expiry → cancelAll, the still-running workers are cancelled. */ timeoutMs: number; /** Grace (ms) after cancelAll before fanOut returns regardless — HARD-bounds a worker that ignores the abort. * Default {@link CANCEL_GRACE_MS}. The un-settled workers are reported `timeout`. */ cancelGraceMs?: number; /** Semaphore cap on concurrent workers (MVP = 2; structure scales to N). */ maxConcurrency: number; logger?: { warn?: (msg: string, meta?: Record) => void; info?: (msg: string, meta?: Record) => void; }; /** Optional per-worker run override (e.g. runWithVerification). When set, runOne routes through THIS instead of * the default runTaskStream-and-map — still bounded by the semaphore and the overall * timeout→cancelAll. Lets the verify path reuse fan-out's concurrency/timeout/cancellation rather than an unbounded * Promise.all (council). The override receives the shared abort signal; pass it into the run so a hung or * cancelled worker actually aborts (else the overall-timeout's `await work` can never unblock). Like the default * path it need not catch — runOne maps a throw to failed/timeout. */ runWorker?: (st: FanOutSubtask, signal: AbortSignal) => Promise; /** Replan-lite C4 ([ref] §6): when a worker settles SUSPENDED, immediately cancelAll the rest — the * suspended worker itself has already settled (its stream is deregistered), so the cancel sweep cannot * touch its checkpoint/paused env (the destroy-exemption falls out of the registration lifecycle). */ cancelOnSuspend?: boolean; } export interface FanOutResult { reports: WorkerReport[]; /** True if the overall deadline fired and cancelAll ran. */ cancelled: boolean; } /** * Fan out `subtasks` in parallel. Never throws — every worker resolves to a WorkerReport (failures captured). * The caller (Coordinator) merges only `status==="completed"` reports. */ export declare function fanOut(subtasks: FanOutSubtask[], opts: FanOutOptions): Promise; //# sourceMappingURL=fanout.d.ts.map