@agentplate/cli 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,28 +6,27 @@
6
6
  * the agent identity + session row → dispatch the task over mail → run the first
7
7
  * headless turn → observe the agent's terminal mail to transition the session.
8
8
  *
9
- * Headless spawn-per-turn: this runs the FIRST turn. Subsequent turns are driven
10
- * by new mail (a later refinement); the basic core proves the single-turn loop.
9
+ * Headless spawn-per-turn: this runs the FIRST turn (a fresh runtime session).
10
+ * Follow-up turns are run by `agentplate turn <agent>`, which resumes the same
11
+ * session (warm start). Both share the {@link driveTurn} core.
11
12
  */
12
13
 
13
14
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
14
15
  import { Command } from "commander";
15
- import { createIdentity, updateIdentity } from "../agents/identity.ts";
16
+ import { assertCapacity } from "../agents/capacity.ts";
17
+ import { driveTurn } from "../agents/drive.ts";
18
+ import { createIdentity } from "../agents/identity.ts";
16
19
  import { buildDefaultManifest, getDefinition, loadManifest } from "../agents/manifest.ts";
17
20
  import { writeOverlay } from "../agents/overlay.ts";
18
- import { runTurn } from "../agents/turn-runner.ts";
19
21
  import { findProjectRoot, isInitialized, loadConfig } from "../config.ts";
20
22
  import { ValidationError } from "../errors.ts";
21
23
  import { createEventStore } from "../events/store.ts";
22
- import { runQualityGates } from "../insights/quality-gates.ts";
23
24
  import { jsonOutput } from "../json.ts";
24
25
  import { brand, muted, printHint, printInfo, printSuccess } from "../logging/color.ts";
25
26
  import { createMailClient } from "../mail/client.ts";
26
- import { createMailStore } from "../mail/store.ts";
27
27
  import {
28
28
  currentRunPath,
29
29
  eventsDbPath,
30
- mailDbPath,
31
30
  manifestFilePath,
32
31
  packageAgentDefPath,
33
32
  sessionsDbPath,
@@ -35,7 +34,7 @@ import {
35
34
  import { getRuntime } from "../runtimes/registry.ts";
36
35
  import { resolveModel } from "../runtimes/resolve.ts";
37
36
  import { createSessionStore } from "../sessions/store.ts";
38
- import { retrieveSkillsForSpawn, runSkillFeedbackAndDistill } from "../skills/lifecycle.ts";
37
+ import { retrieveSkillsForSpawn } from "../skills/lifecycle.ts";
39
38
  import type { AgentManifest, AgentSession, Capability, OverlayConfig } from "../types.ts";
40
39
  import { SUPPORTED_CAPABILITIES } from "../types.ts";
41
40
  import { createWorktree } from "../worktree/manager.ts";
@@ -130,6 +129,17 @@ export function createSlingCommand(): Command {
130
129
  // Resolve the run this agent belongs to.
131
130
  const runId = resolveRun(store, root, opts);
132
131
 
132
+ // Enforce orchestration capacity BEFORE any worktree/session is created,
133
+ // so a runaway fan-out is refused cleanly instead of spawning unbounded.
134
+ const parentAgent = opts.parent ?? null;
135
+ assertCapacity({
136
+ depth: Number(opts.depth ?? "0"),
137
+ active: store.countActive(runId),
138
+ parentAgent,
139
+ parentActiveChildren: parentAgent ? store.countActiveByParent(parentAgent, runId) : 0,
140
+ limits: config.agents,
141
+ });
142
+
133
143
  const name = uniqueName(store, opts.name ?? `${capability}-${taskId}`);
134
144
  const branchName = `agentplate/${name}`;
135
145
 
@@ -199,91 +209,22 @@ export function createSlingCommand(): Command {
199
209
  type: "dispatch",
200
210
  });
201
211
 
202
- // 5. Run the first headless turn.
203
- const resolved = resolveModel(config, root, def.model);
204
- store.updateSessionState(session.id, "working");
212
+ // 5. Run the first turn. Follow-up turns warm-start (resume) via
213
+ // `agentplate turn`; driveTurn owns state + skills + auto-merge.
214
+ const resolved = resolveModel(config, root, def.model, capability);
205
215
  const prompt = buildInitialPrompt(mail.checkInject(name), runtime.instructionPath);
206
- let sawError = false;
207
- const turn = await runTurn({
216
+ const { finalState, exitCode } = await driveTurn({
217
+ root,
218
+ config,
208
219
  runtime,
209
- worktreePath: worktree.path,
210
- model: resolved.model,
220
+ store,
221
+ events,
222
+ mail,
223
+ session,
224
+ model: resolved,
211
225
  prompt,
212
- env: resolved.env,
213
- onEvent: (event) => {
214
- if (event.error || event.type === "error") sawError = true;
215
- // Prefer the error message (so a failed agent's reason is visible in
216
- // the feed/logs), else the token/cost JSON the Costs page aggregates.
217
- const detail = event.error
218
- ? event.error
219
- : event.usage
220
- ? JSON.stringify({ tokens: event.usage.tokens, cost: event.usage.costUsd })
221
- : null;
222
- events.record({
223
- agentName: name,
224
- runId,
225
- type: event.type,
226
- tool: event.tool ?? null,
227
- detail,
228
- });
229
- // Bump last_activity on every streamed event so a long but active
230
- // turn keeps itself fresh and is never reaped as "idle".
231
- store.touch(session.id);
232
- },
233
- });
234
- if (turn.runtimeSessionId) store.setRuntimeSessionId(session.id, turn.runtimeSessionId);
235
-
236
- // A non-zero exit with no error event means the runtime failed via stderr
237
- // (e.g. Pi's "No API key found for anthropic"). Record that stderr so the
238
- // failure reason is visible in the feed/logs instead of a blank "failed".
239
- if (turn.exitCode !== 0 && !sawError) {
240
- const reason = turn.stderr.trim();
241
- if (reason) {
242
- events.record({
243
- agentName: name,
244
- runId,
245
- type: "error",
246
- tool: null,
247
- detail: reason.length > 1000 ? `${reason.slice(0, 1000)}…` : reason,
248
- });
249
- }
250
- }
251
-
252
- // 6. Observe terminal mail to transition the session.
253
- const finalState = resolveFinalState(root, name, capability, turn.exitCode);
254
- store.updateSessionState(session.id, finalState);
255
- store.touch(session.id);
256
- updateIdentity(root, name, {
257
- taskId,
258
- summary: `${capability} ran a turn for ${taskId} → ${finalState}`,
259
226
  });
260
227
 
261
- // 7. Self-improving loop: score quality gates, append outcomes to
262
- // applied skills, and distill a skill when the work passed. Only
263
- // runs for a completed task; best-effort (never fails the spawn).
264
- if (finalState === "completed" && config.skills.enabled) {
265
- try {
266
- const gateOutcome = await runQualityGates(
267
- config.project.qualityGates ?? [],
268
- worktree.path,
269
- );
270
- await runSkillFeedbackAndDistill({
271
- root,
272
- agentName: name,
273
- capability,
274
- taskId,
275
- worktreePath: worktree.path,
276
- baseRef: config.project.canonicalBranch,
277
- runtime,
278
- outcomeStatus: gateOutcome?.status ?? null,
279
- skills: config.skills,
280
- model: resolved.model,
281
- });
282
- } catch {
283
- // Skill loop is advisory; a failure here must not fail the spawn.
284
- }
285
- }
286
-
287
228
  if (useJson) {
288
229
  jsonOutput({
289
230
  agent: name,
@@ -293,7 +234,7 @@ export function createSlingCommand(): Command {
293
234
  branchName,
294
235
  worktreePath: worktree.path,
295
236
  state: finalState,
296
- exitCode: turn.exitCode,
237
+ exitCode: exitCode,
297
238
  });
298
239
  return;
299
240
  }
@@ -301,8 +242,8 @@ export function createSlingCommand(): Command {
301
242
  printInfo(` task: ${taskId}`);
302
243
  printInfo(` branch: ${branchName}`);
303
244
  printInfo(` worktree:${muted(` ${worktree.path}`)}`);
304
- if (turn.exitCode !== 0) {
305
- printHint(` turn exited ${turn.exitCode}; see \`agentplate mail list --from ${name}\``);
245
+ if (exitCode !== 0) {
246
+ printHint(` turn exited ${exitCode}; see \`agentplate mail list --from ${name}\``);
306
247
  }
307
248
  } finally {
308
249
  events.close();
@@ -378,26 +319,3 @@ function buildInitialPrompt(injected: string, instructionPath: string): string {
378
319
  const header = `Read your instructions at ${instructionPath}, then begin your task.`;
379
320
  return injected ? `${injected}\n\n${header}` : header;
380
321
  }
381
-
382
- /** Terminal mail types that mark a capability's work complete. */
383
- function terminalTypesFor(capability: Capability): string[] {
384
- return capability === "merger" ? ["merged", "merge_failed"] : ["worker_done"];
385
- }
386
-
387
- function resolveFinalState(
388
- root: string,
389
- name: string,
390
- capability: Capability,
391
- exitCode: number,
392
- ): AgentSession["state"] {
393
- const terminal = terminalTypesFor(capability);
394
- const store = createMailStore(mailDbPath(root));
395
- try {
396
- const sent = store.list({ from: name });
397
- if (sent.some((m) => terminal.includes(m.type))) return "completed";
398
- } finally {
399
- store.close();
400
- }
401
- if (exitCode === 0) return "idle";
402
- return "failed";
403
- }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * `agentplate turn` command tests. Real initialized temp project + a real (mock)
3
+ * runtime turn. Drives the exported command's action via parseAsync.
4
+ */
5
+
6
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
7
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import {
11
+ AGENTPLATE_DIR,
12
+ CONFIG_FILE,
13
+ DEFAULT_CONFIG,
14
+ serializeConfig,
15
+ setProjectRootOverride,
16
+ } from "../config.ts";
17
+ import { NotFoundError, ValidationError } from "../errors.ts";
18
+ import { sessionsDbPath } from "../paths.ts";
19
+ import { createSessionStore } from "../sessions/store.ts";
20
+ import type { AgentSession, SessionState } from "../types.ts";
21
+ import { createTurnCommand } from "./turn.ts";
22
+
23
+ let root: string;
24
+ let worktree: string;
25
+
26
+ function seedSession(over: Partial<AgentSession>): void {
27
+ const store = createSessionStore(sessionsDbPath(root));
28
+ const now = new Date().toISOString();
29
+ try {
30
+ store.upsertSession({
31
+ id: `session-${crypto.randomUUID()}`,
32
+ agentName: "builder-1",
33
+ capability: "builder",
34
+ taskId: "task-1",
35
+ runId: "run-1",
36
+ worktreePath: worktree,
37
+ branchName: "agentplate/builder-1",
38
+ state: "idle",
39
+ parentAgent: "lead-1",
40
+ depth: 1,
41
+ pid: null,
42
+ runtimeSessionId: "sess-prior",
43
+ startedAt: now,
44
+ lastActivity: now,
45
+ ...over,
46
+ });
47
+ } finally {
48
+ store.close();
49
+ }
50
+ }
51
+
52
+ function sessionState(agent: string): SessionState | undefined {
53
+ const store = createSessionStore(sessionsDbPath(root));
54
+ try {
55
+ return store.getSessionByAgent(agent)?.state;
56
+ } finally {
57
+ store.close();
58
+ }
59
+ }
60
+
61
+ async function runTurnCmd(agent: string): Promise<void> {
62
+ await createTurnCommand().parseAsync([agent], { from: "user" });
63
+ }
64
+
65
+ beforeEach(() => {
66
+ root = mkdtempSync(join(tmpdir(), "agentplate-turn-cmd-"));
67
+ worktree = mkdtempSync(join(tmpdir(), "agentplate-turn-wt-"));
68
+ mkdirSync(join(root, AGENTPLATE_DIR), { recursive: true });
69
+ const config = structuredClone(DEFAULT_CONFIG);
70
+ config.runtime.default = "mock";
71
+ writeFileSync(join(root, AGENTPLATE_DIR, CONFIG_FILE), serializeConfig(config), "utf8");
72
+ setProjectRootOverride(root);
73
+ process.env.AGENTPLATE_MOCK_CMD = "true";
74
+ });
75
+
76
+ afterEach(() => {
77
+ setProjectRootOverride(null);
78
+ rmSync(root, { recursive: true, force: true });
79
+ rmSync(worktree, { recursive: true, force: true });
80
+ process.env.AGENTPLATE_MOCK_CMD = undefined;
81
+ });
82
+
83
+ describe("agentplate turn — refusals", () => {
84
+ test("throws NotFoundError for an unknown agent", async () => {
85
+ await expect(runTurnCmd("ghost")).rejects.toBeInstanceOf(NotFoundError);
86
+ });
87
+
88
+ test("refuses a terminal (completed) agent", async () => {
89
+ seedSession({ state: "completed" });
90
+ await expect(runTurnCmd("builder-1")).rejects.toBeInstanceOf(ValidationError);
91
+ });
92
+ });
93
+
94
+ describe("agentplate turn — runs the next turn", () => {
95
+ test("an idle agent takes another turn and transitions", async () => {
96
+ seedSession({ state: "idle" });
97
+ await runTurnCmd("builder-1");
98
+ // No terminal mail from the mock no-op → stays idle (ran without throwing).
99
+ expect(sessionState("builder-1")).toBe("idle");
100
+ });
101
+ });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * `agentplate turn <agent>` — run the NEXT headless turn for an existing agent.
3
+ *
4
+ * Where `sling` opens a fresh runtime session (turn 1), `turn` **resumes** it: it
5
+ * passes the session's captured `runtimeSessionId` to the runtime's `--resume`, so
6
+ * follow-up turns keep the warm context and skip the cold-start cost. The agent's
7
+ * unread mail is injected as the turn's prompt; the shared {@link driveTurn} core
8
+ * handles the state transition, skills loop, and auto-merge identically to turn 1.
9
+ *
10
+ * This is the multi-turn primitive: a coordinator/lead (or a future watcher) calls
11
+ * it when new mail arrives for an `idle` agent. Spawn-per-turn is preserved — each
12
+ * call is one fresh, resumed runtime subprocess.
13
+ */
14
+
15
+ import { existsSync } from "node:fs";
16
+ import { Command } from "commander";
17
+ import { driveTurn } from "../agents/drive.ts";
18
+ import { buildDefaultManifest, getDefinition, loadManifest } from "../agents/manifest.ts";
19
+ import { findProjectRoot, isInitialized, loadConfig } from "../config.ts";
20
+ import { NotFoundError, ValidationError } from "../errors.ts";
21
+ import { createEventStore } from "../events/store.ts";
22
+ import { jsonOutput } from "../json.ts";
23
+ import { brand, muted, printInfo, printSuccess } from "../logging/color.ts";
24
+ import { createMailClient } from "../mail/client.ts";
25
+ import { eventsDbPath, manifestFilePath, sessionsDbPath } from "../paths.ts";
26
+ import { getRuntime } from "../runtimes/registry.ts";
27
+ import { resolveModel } from "../runtimes/resolve.ts";
28
+ import { createSessionStore } from "../sessions/store.ts";
29
+ import type { SessionState } from "../types.ts";
30
+
31
+ interface TurnOptions {
32
+ json?: boolean;
33
+ }
34
+
35
+ /** States a turn can be driven from: paused/awaiting mail (not terminal, not mid-turn). */
36
+ const DRIVABLE_STATES: ReadonlySet<SessionState> = new Set<SessionState>(["idle", "booting"]);
37
+
38
+ export function createTurnCommand(): Command {
39
+ return new Command("turn")
40
+ .description("Run the next (resumed) turn for an existing agent")
41
+ .argument("<agent>", "agent name")
42
+ .option("--json", "output JSON")
43
+ .action(async (agent: string, opts: TurnOptions, command: Command) => {
44
+ const useJson = command.optsWithGlobals().json === true;
45
+ const root = findProjectRoot();
46
+ if (!isInitialized(root)) {
47
+ throw new ValidationError("Not initialized. Run `agentplate setup` first.");
48
+ }
49
+ const config = loadConfig(root);
50
+
51
+ const store = createSessionStore(sessionsDbPath(root));
52
+ const mail = createMailClient(root);
53
+ const events = createEventStore(eventsDbPath(root));
54
+ try {
55
+ const session = store.getSessionByAgent(agent);
56
+ if (!session) throw new NotFoundError(`No agent named "${agent}".`);
57
+ if (!DRIVABLE_STATES.has(session.state)) {
58
+ throw new ValidationError(
59
+ `Agent "${agent}" is ${session.state}; only an idle agent can take another turn.`,
60
+ );
61
+ }
62
+
63
+ const manifestPath = manifestFilePath(root);
64
+ const manifest = existsSync(manifestPath)
65
+ ? loadManifest(manifestPath)
66
+ : buildDefaultManifest();
67
+ const def = getDefinition(manifest, session.capability);
68
+ const runtime = getRuntime(config.runtime.default, config.runtime.default);
69
+ const model = resolveModel(config, root, def.model, session.capability);
70
+
71
+ // The next turn's user text is the agent's unread mail (e.g. a child's
72
+ // reply or operator direction); fall back to a continue nudge.
73
+ const injected = mail.checkInject(agent);
74
+ const prompt =
75
+ injected.trim().length > 0
76
+ ? injected
77
+ : "Continue your task. If it is complete, send your terminal mail.";
78
+
79
+ const { finalState, exitCode } = await driveTurn({
80
+ root,
81
+ config,
82
+ runtime,
83
+ store,
84
+ events,
85
+ mail,
86
+ session,
87
+ model,
88
+ prompt,
89
+ // Warm start: resume the runtime session captured on a prior turn.
90
+ resumeSessionId: session.runtimeSessionId ?? undefined,
91
+ });
92
+
93
+ if (useJson) {
94
+ jsonOutput({
95
+ agent,
96
+ capability: session.capability,
97
+ taskId: session.taskId,
98
+ state: finalState,
99
+ resumed: Boolean(session.runtimeSessionId),
100
+ exitCode,
101
+ });
102
+ return;
103
+ }
104
+ printSuccess(`${brand(agent)} [${session.capability}] → ${finalState}`);
105
+ printInfo(` resumed: ${session.runtimeSessionId ? "yes (warm)" : "no (cold)"}`);
106
+ printInfo(` worktree:${muted(` ${session.worktreePath}`)}`);
107
+ } finally {
108
+ events.close();
109
+ mail.close();
110
+ store.close();
111
+ }
112
+ });
113
+ }
@@ -45,6 +45,13 @@ describe("loadConfig", () => {
45
45
  expect(cfg.merge.aiResolveEnabled).toBe(true);
46
46
  });
47
47
 
48
+ test("an old config without merge.autoMerge inherits the 'off' default (deep merge)", () => {
49
+ writeConfig(root, "config.yaml", "merge:\n aiResolveEnabled: true\n");
50
+ const cfg = loadConfig(root);
51
+ expect(cfg.merge.autoMerge).toBe("off");
52
+ expect(cfg.merge.aiResolveEnabled).toBe(true);
53
+ });
54
+
48
55
  test("config.local.yaml overrides config.yaml", () => {
49
56
  writeConfig(root, "config.yaml", "runtime:\n default: claude\n");
50
57
  writeConfig(root, "config.local.yaml", "runtime:\n default: gemini\n");
@@ -74,6 +81,17 @@ describe("validateConfig", () => {
74
81
  test("accepts the defaults", () => {
75
82
  expect(() => validateConfig(structuredClone(DEFAULT_CONFIG))).not.toThrow();
76
83
  });
84
+
85
+ test("auto-merge defaults to off", () => {
86
+ expect(DEFAULT_CONFIG.merge.autoMerge).toBe("off");
87
+ });
88
+
89
+ test("rejects an unknown merge.autoMerge mode", () => {
90
+ const cfg = structuredClone(DEFAULT_CONFIG);
91
+ // @ts-expect-error intentionally invalid value
92
+ cfg.merge.autoMerge = "always";
93
+ expect(() => validateConfig(cfg)).toThrow();
94
+ });
77
95
  });
78
96
 
79
97
  describe("isInitialized / serializeConfig", () => {
package/src/config.ts CHANGED
@@ -13,7 +13,7 @@ import { existsSync, readFileSync } from "node:fs";
13
13
  import { join, resolve } from "node:path";
14
14
  import yaml from "js-yaml";
15
15
  import { ConfigError } from "./errors.ts";
16
- import type { AgentplateConfig } from "./types.ts";
16
+ import type { AgentplateConfig, AutoMergeMode } from "./types.ts";
17
17
 
18
18
  /** Directory (relative to project root) holding all Agentplate state. */
19
19
  export const AGENTPLATE_DIR = ".agentplate";
@@ -46,6 +46,7 @@ export const DEFAULT_CONFIG: AgentplateConfig = {
46
46
  },
47
47
  merge: {
48
48
  aiResolveEnabled: true,
49
+ autoMerge: "off",
49
50
  },
50
51
  skills: {
51
52
  enabled: true,
@@ -175,6 +176,10 @@ export function validateConfig(config: AgentplateConfig): void {
175
176
  "config.agents.idleTimeoutMinutes must be >= 0 (0 disables idle reaping)",
176
177
  );
177
178
  }
179
+ const autoMergeModes: AutoMergeMode[] = ["off", "on-gates-pass", "on-complete"];
180
+ if (!autoMergeModes.includes(config.merge.autoMerge)) {
181
+ throw new ConfigError(`config.merge.autoMerge must be one of: ${autoMergeModes.join(", ")}`);
182
+ }
178
183
  }
179
184
 
180
185
  /**
package/src/errors.ts CHANGED
@@ -63,6 +63,17 @@ export class NotFoundError extends AgentplateError {
63
63
  }
64
64
  }
65
65
 
66
+ /**
67
+ * A spawn was refused because it would exceed a configured orchestration limit
68
+ * (maxConcurrent / maxAgentsPerLead / maxDepth). Distinct exit code so callers
69
+ * (a lead/coordinator) can recognize "at capacity — back off and retry later".
70
+ */
71
+ export class CapacityError extends AgentplateError {
72
+ constructor(message: string) {
73
+ super(message, "CAPACITY_EXCEEDED", 5);
74
+ }
75
+ }
76
+
66
77
  /** Type guard: is the given value a AgentplateError? */
67
78
  export function isAgentplateError(value: unknown): value is AgentplateError {
68
79
  return value instanceof AgentplateError;
package/src/index.ts CHANGED
@@ -34,6 +34,7 @@ import { createSpecCommand } from "./commands/spec.ts";
34
34
  import { createStatusCommand } from "./commands/status.ts";
35
35
  import { createStopCommand } from "./commands/stop.ts";
36
36
  import { createTuiCommand } from "./commands/tui.ts";
37
+ import { createTurnCommand } from "./commands/turn.ts";
37
38
  import { createWorktreeCommand } from "./commands/worktree.ts";
38
39
  import { setProjectRootOverride } from "./config.ts";
39
40
  import { isAgentplateError } from "./errors.ts";
@@ -96,6 +97,7 @@ function buildProgram(): Command {
96
97
  // Orchestration
97
98
  program.addCommand(createCoordinatorCommand());
98
99
  program.addCommand(createSlingCommand());
100
+ program.addCommand(createTurnCommand());
99
101
  program.addCommand(createSpecCommand());
100
102
  program.addCommand(createStatusCommand());
101
103
  program.addCommand(createMailCommand());
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Tests for runQualityGates. Real subprocesses (no mocks) — gates are shell
3
+ * commands, so we use `true`/`false`/`sleep` to exercise pass/fail/partial and to
4
+ * prove the gates run concurrently (wall-clock < sum of durations).
5
+ */
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import type { QualityGate } from "../types.ts";
9
+ import { runQualityGates } from "./quality-gates.ts";
10
+
11
+ const gate = (name: string, command: string): QualityGate => ({ name, command });
12
+
13
+ describe("runQualityGates", () => {
14
+ test("returns null when there are no gates", async () => {
15
+ expect(await runQualityGates([], process.cwd())).toBeNull();
16
+ });
17
+
18
+ test("all passing → success", async () => {
19
+ const out = await runQualityGates([gate("a", "true"), gate("b", "true")], process.cwd());
20
+ expect(out?.status).toBe("success");
21
+ expect(out?.results.every((r) => r.passed)).toBe(true);
22
+ });
23
+
24
+ test("all failing → failure", async () => {
25
+ const out = await runQualityGates([gate("a", "false"), gate("b", "false")], process.cwd());
26
+ expect(out?.status).toBe("failure");
27
+ });
28
+
29
+ test("mixed → partial", async () => {
30
+ const out = await runQualityGates([gate("ok", "true"), gate("bad", "false")], process.cwd());
31
+ expect(out?.status).toBe("partial");
32
+ // Order is preserved across the concurrent run.
33
+ expect(out?.results.map((r) => r.name)).toEqual(["ok", "bad"]);
34
+ });
35
+
36
+ test("gates run concurrently (wall-clock well under the sum)", async () => {
37
+ const gates = [gate("s1", "sleep 0.3"), gate("s2", "sleep 0.3"), gate("s3", "sleep 0.3")];
38
+ const out = await runQualityGates(gates, process.cwd());
39
+ expect(out?.status).toBe("success");
40
+ // Sequential would be ~900ms; concurrent should be well under that.
41
+ expect(out?.totalDurationMs ?? Number.POSITIVE_INFINITY).toBeLessThan(700);
42
+ });
43
+ });
@@ -34,37 +34,36 @@ export async function runQualityGates(
34
34
  ): Promise<QualityGateOutcome | null> {
35
35
  if (gates.length === 0) return null;
36
36
 
37
- const results: GateResult[] = [];
38
- let total = 0;
39
- for (const gate of gates) {
40
- const started = performance.now();
41
- let exitCode = 1;
42
- try {
43
- // Run the gate through the platform shell: `cmd /c` on Windows (no bash
44
- // there), `bash -lc` elsewhere. `.cmd` shims (biome/tsc) resolve under both.
45
- const shellArgv =
46
- process.platform === "win32"
47
- ? ["cmd", "/d", "/s", "/c", gate.command]
48
- : ["bash", "-lc", gate.command];
49
- const proc = Bun.spawn(shellArgv, {
50
- cwd,
51
- stdout: "pipe",
52
- stderr: "pipe",
53
- });
54
- exitCode = await proc.exited;
55
- } catch {
56
- exitCode = 1;
57
- }
58
- const durationMs = Math.round(performance.now() - started);
59
- total += durationMs;
60
- results.push({
61
- name: gate.name,
62
- command: gate.command,
63
- passed: exitCode === 0,
64
- exitCode,
65
- durationMs,
66
- });
67
- }
37
+ // Gates are independent checks, so run them concurrently and measure the
38
+ // outcome by wall-clock — `totalDurationMs` is the elapsed time of the whole
39
+ // batch (the slowest gate), not the sum of all gates.
40
+ const overallStart = performance.now();
41
+ const results: GateResult[] = await Promise.all(
42
+ gates.map(async (gate): Promise<GateResult> => {
43
+ const started = performance.now();
44
+ let exitCode = 1;
45
+ try {
46
+ // Run the gate through the platform shell: `cmd /c` on Windows (no bash
47
+ // there), `bash -lc` elsewhere. `.cmd` shims (biome/tsc) resolve under both.
48
+ const shellArgv =
49
+ process.platform === "win32"
50
+ ? ["cmd", "/d", "/s", "/c", gate.command]
51
+ : ["bash", "-lc", gate.command];
52
+ const proc = Bun.spawn(shellArgv, { cwd, stdout: "pipe", stderr: "pipe" });
53
+ exitCode = await proc.exited;
54
+ } catch {
55
+ exitCode = 1;
56
+ }
57
+ return {
58
+ name: gate.name,
59
+ command: gate.command,
60
+ passed: exitCode === 0,
61
+ exitCode,
62
+ durationMs: Math.round(performance.now() - started),
63
+ };
64
+ }),
65
+ );
66
+ const total = Math.round(performance.now() - overallStart);
68
67
 
69
68
  const passed = results.filter((r) => r.passed).length;
70
69
  const status: OutcomeStatus =