@redwoodjs/agent-ci 0.6.0 → 0.7.1

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.
package/dist/cli.js CHANGED
@@ -130,9 +130,11 @@ async function run() {
130
130
  pauseOnFailure,
131
131
  noMatrix,
132
132
  });
133
- printSummary(results);
133
+ if (results.length > 0) {
134
+ printSummary(results);
135
+ }
134
136
  postCommitStatus(results, sha);
135
- const anyFailed = results.some((r) => !r.succeeded);
137
+ const anyFailed = results.length === 0 || results.some((r) => !r.succeeded);
136
138
  process.exit(anyFailed ? 1 : 0);
137
139
  }
138
140
  if (!workflow) {
@@ -163,9 +165,11 @@ async function run() {
163
165
  pauseOnFailure,
164
166
  noMatrix,
165
167
  });
166
- printSummary(results);
168
+ if (results.length > 0) {
169
+ printSummary(results);
170
+ }
167
171
  postCommitStatus(results, sha);
168
- if (results.some((r) => !r.succeeded)) {
172
+ if (results.length === 0 || results.some((r) => !r.succeeded)) {
169
173
  process.exit(1);
170
174
  }
171
175
  process.exit(0);
@@ -241,6 +245,9 @@ async function run() {
241
245
  // One workflow = --all with a single entry.
242
246
  async function runWorkflows(options) {
243
247
  const { workflowPaths, sha, pauseOnFailure, noMatrix = false } = options;
248
+ // Suppress EventEmitter MaxListenersExceeded warnings when running many
249
+ // parallel jobs (each job adds SIGINT/SIGTERM listeners).
250
+ process.setMaxListeners(0);
244
251
  // Create the run state store — single source of truth for all progress
245
252
  const runId = `run-${Date.now()}`;
246
253
  const storeFilePath = path.join(getWorkingDirectory(), "runs", runId, "run-state.json");
@@ -318,6 +325,15 @@ async function runWorkflows(options) {
318
325
  }
319
326
  }, 80);
320
327
  }
328
+ // Top-level signal handler: exit the process after per-job handlers have
329
+ // cleaned up their containers. All listeners fire synchronously in
330
+ // registration order, so we defer the exit to let per-job handlers
331
+ // (registered later in local-job.ts) run first.
332
+ const exitOnSignal = () => {
333
+ setTimeout(() => process.exit(1), 0);
334
+ };
335
+ process.on("SIGINT", exitOnSignal);
336
+ process.on("SIGTERM", exitOnSignal);
321
337
  try {
322
338
  const allResults = [];
323
339
  if (workflowPaths.length === 1) {
@@ -343,6 +359,11 @@ async function runWorkflows(options) {
343
359
  catch { }
344
360
  const warmModulesDir = path.resolve(getWorkingDirectory(), "cache", "warm-modules", repoSlug, lockfileHash);
345
361
  const warm = isWarmNodeModules(warmModulesDir);
362
+ // Pre-allocate unique run numbers so parallel workflows don't collide.
363
+ // Each workflow gets its own baseRunNum (e.g. 306, 307, 308) so their
364
+ // job suffixes (-j1, -j2, -j3) never produce duplicate container names.
365
+ const baseRunNum = getNextLogNum("agent-ci");
366
+ const runNums = workflowPaths.map((_, i) => baseRunNum + i);
346
367
  if (!warm && workflowPaths.length > 1) {
347
368
  // Cold cache — run first workflow serially to populate warm modules,
348
369
  // then launch the rest in parallel.
@@ -352,11 +373,17 @@ async function runWorkflows(options) {
352
373
  pauseOnFailure,
353
374
  noMatrix,
354
375
  store,
376
+ baseRunNum: runNums[0],
355
377
  });
356
378
  allResults.push(...firstResults);
357
- const settled = await Promise.allSettled(workflowPaths
358
- .slice(1)
359
- .map((wf) => handleWorkflow({ workflowPath: wf, sha, pauseOnFailure, noMatrix, store })));
379
+ const settled = await Promise.allSettled(workflowPaths.slice(1).map((wf, i) => handleWorkflow({
380
+ workflowPath: wf,
381
+ sha,
382
+ pauseOnFailure,
383
+ noMatrix,
384
+ store,
385
+ baseRunNum: runNums[i + 1],
386
+ })));
360
387
  for (const s of settled) {
361
388
  if (s.status === "fulfilled") {
362
389
  allResults.push(...s.value);
@@ -367,7 +394,14 @@ async function runWorkflows(options) {
367
394
  }
368
395
  }
369
396
  else {
370
- const settled = await Promise.allSettled(workflowPaths.map((wf) => handleWorkflow({ workflowPath: wf, sha, pauseOnFailure, noMatrix, store })));
397
+ const settled = await Promise.allSettled(workflowPaths.map((wf, i) => handleWorkflow({
398
+ workflowPath: wf,
399
+ sha,
400
+ pauseOnFailure,
401
+ noMatrix,
402
+ store,
403
+ baseRunNum: runNums[i],
404
+ })));
371
405
  for (const s of settled) {
372
406
  if (s.status === "fulfilled") {
373
407
  allResults.push(...s.value);
@@ -382,6 +416,8 @@ async function runWorkflows(options) {
382
416
  return allResults;
383
417
  }
384
418
  finally {
419
+ process.removeListener("SIGINT", exitOnSignal);
420
+ process.removeListener("SIGTERM", exitOnSignal);
385
421
  if (renderInterval) {
386
422
  clearInterval(renderInterval);
387
423
  }
@@ -492,7 +528,7 @@ async function handleWorkflow(options) {
492
528
  const warmModulesDir = path.resolve(getWorkingDirectory(), "cache", "warm-modules", repoSlug, lockfileHash);
493
529
  let warm = isWarmNodeModules(warmModulesDir);
494
530
  // Naming convention: agent-ci-<N>[-j<idx>][-m<shardIdx>]
495
- const baseRunNum = getNextLogNum("agent-ci");
531
+ const baseRunNum = options.baseRunNum ?? getNextLogNum("agent-ci");
496
532
  let globalIdx = 0;
497
533
  const buildJob = (ej) => {
498
534
  const secrets = loadMachineSecrets(repoRoot);
@@ -699,8 +735,8 @@ async function handleWorkflow(options) {
699
735
  return allResults;
700
736
  }
701
737
  catch (error) {
702
- console.error(`[Agent CI] Failed to trigger run: ${error.message}`);
703
- return [];
738
+ console.error(`[Agent CI] Failed to run workflow ${path.basename(workflowPath)}: ${error.message}`);
739
+ throw error;
704
740
  }
705
741
  }
706
742
  // ─── Utilities ────────────────────────────────────────────────────────────────
@@ -11,6 +11,7 @@ export function buildContainerEnv(opts) {
11
11
  `GITHUB_API_URL=${dockerApiUrl}`,
12
12
  `GITHUB_SERVER_URL=${repoUrl}`,
13
13
  `GITHUB_REPOSITORY=${githubRepo}`,
14
+ `AGENT_CI_LOCAL=true`,
14
15
  `AGENT_CI_LOCAL_SYNC=true`,
15
16
  `AGENT_CI_HEAD_SHA=${headSha || "HEAD"}`,
16
17
  `AGENT_CI_DTU_HOST=${dtuHost}`,
@@ -44,10 +45,10 @@ export function buildContainerBinds(opts) {
44
45
  ...(signalsDir ? [`${h(signalsDir)}:/tmp/agent-ci-signals`] : []),
45
46
  `${h(diagDir)}:/home/runner/_diag`,
46
47
  `${h(toolCacheDir)}:/opt/hostedtoolcache`,
47
- // Package manager caches (persist across runs)
48
- `${h(pnpmStoreDir)}:/home/runner/_work/.pnpm-store`,
49
- `${h(npmCacheDir)}:/home/runner/.npm`,
50
- `${h(bunCacheDir)}:/home/runner/.bun/install/cache`,
48
+ // Package manager caches (persist across runs, only for detected PM)
49
+ ...(pnpmStoreDir ? [`${h(pnpmStoreDir)}:/home/runner/_work/.pnpm-store`] : []),
50
+ ...(npmCacheDir ? [`${h(npmCacheDir)}:/home/runner/.npm`] : []),
51
+ ...(bunCacheDir ? [`${h(bunCacheDir)}:/home/runner/.bun/install/cache`] : []),
51
52
  `${h(playwrightCacheDir)}:/home/runner/.cache/ms-playwright`,
52
53
  // Warm node_modules: mounted outside the workspace so actions/checkout can
53
54
  // delete the symlink without EBUSY. A symlink in the entrypoint points
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, afterEach, vi } from "vitest";
1
+ import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
2
2
  import fs from "node:fs";
3
3
  afterEach(() => {
4
4
  vi.restoreAllMocks();
@@ -19,6 +19,7 @@ describe("buildContainerEnv", () => {
19
19
  });
20
20
  expect(env).toContain("RUNNER_NAME=runner-1");
21
21
  expect(env).toContain("GITHUB_REPOSITORY=org/repo");
22
+ expect(env).toContain("AGENT_CI_LOCAL=true");
22
23
  expect(env).toContain("AGENT_CI_HEAD_SHA=abc123");
23
24
  expect(env).toContain("FORCE_COLOR=1");
24
25
  // Should NOT include root-mode vars for standard container
@@ -41,7 +42,7 @@ describe("buildContainerEnv", () => {
41
42
  });
42
43
  // ── buildContainerBinds ───────────────────────────────────────────────────────
43
44
  describe("buildContainerBinds", () => {
44
- it("builds standard bind mounts", async () => {
45
+ it("builds standard bind mounts with all PM caches", async () => {
45
46
  const { buildContainerBinds } = await import("./container-config.js");
46
47
  const binds = buildContainerBinds({
47
48
  hostWorkDir: "/tmp/work",
@@ -60,9 +61,46 @@ describe("buildContainerBinds", () => {
60
61
  expect(binds).toContain("/var/run/docker.sock:/var/run/docker.sock");
61
62
  expect(binds).toContain("/tmp/shims:/tmp/agent-ci-shims");
62
63
  expect(binds).toContain("/tmp/warm:/tmp/warm-modules");
64
+ expect(binds).toContain("/tmp/pnpm:/home/runner/_work/.pnpm-store");
65
+ expect(binds).toContain("/tmp/npm:/home/runner/.npm");
66
+ expect(binds).toContain("/tmp/bun:/home/runner/.bun/install/cache");
63
67
  // Standard mode should NOT include runner home bind (but _work bind is expected)
64
68
  expect(binds.some((b) => b.endsWith(":/home/runner"))).toBe(false);
65
69
  });
70
+ it("omits PM bind mounts when cache dirs are not provided", async () => {
71
+ const { buildContainerBinds } = await import("./container-config.js");
72
+ const binds = buildContainerBinds({
73
+ hostWorkDir: "/tmp/work",
74
+ shimsDir: "/tmp/shims",
75
+ diagDir: "/tmp/diag",
76
+ toolCacheDir: "/tmp/toolcache",
77
+ playwrightCacheDir: "/tmp/playwright",
78
+ warmModulesDir: "/tmp/warm",
79
+ hostRunnerDir: "/tmp/runner",
80
+ useDirectContainer: false,
81
+ });
82
+ expect(binds).toContain("/tmp/work:/home/runner/_work");
83
+ expect(binds.some((b) => b.includes(".pnpm-store"))).toBe(false);
84
+ expect(binds.some((b) => b.includes("/.npm"))).toBe(false);
85
+ expect(binds.some((b) => b.includes(".bun"))).toBe(false);
86
+ });
87
+ it("includes only the npm bind mount when only npmCacheDir is provided", async () => {
88
+ const { buildContainerBinds } = await import("./container-config.js");
89
+ const binds = buildContainerBinds({
90
+ hostWorkDir: "/tmp/work",
91
+ shimsDir: "/tmp/shims",
92
+ diagDir: "/tmp/diag",
93
+ toolCacheDir: "/tmp/toolcache",
94
+ npmCacheDir: "/tmp/npm",
95
+ playwrightCacheDir: "/tmp/playwright",
96
+ warmModulesDir: "/tmp/warm",
97
+ hostRunnerDir: "/tmp/runner",
98
+ useDirectContainer: false,
99
+ });
100
+ expect(binds).toContain("/tmp/npm:/home/runner/.npm");
101
+ expect(binds.some((b) => b.includes(".pnpm-store"))).toBe(false);
102
+ expect(binds.some((b) => b.includes(".bun"))).toBe(false);
103
+ });
66
104
  it("includes runner bind mount for direct container", async () => {
67
105
  const { buildContainerBinds } = await import("./container-config.js");
68
106
  const binds = buildContainerBinds({
@@ -146,6 +184,10 @@ describe("resolveDockerApiUrl", () => {
146
184
  });
147
185
  describe("resolveDtuHost", () => {
148
186
  const originalBridgeGateway = process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY;
187
+ const originalDtuHost = process.env.AGENT_CI_DTU_HOST;
188
+ beforeEach(() => {
189
+ delete process.env.AGENT_CI_DTU_HOST;
190
+ });
149
191
  afterEach(() => {
150
192
  if (originalBridgeGateway === undefined) {
151
193
  delete process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY;
@@ -153,8 +195,15 @@ describe("resolveDtuHost", () => {
153
195
  else {
154
196
  process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY = originalBridgeGateway;
155
197
  }
198
+ if (originalDtuHost === undefined) {
199
+ delete process.env.AGENT_CI_DTU_HOST;
200
+ }
201
+ else {
202
+ process.env.AGENT_CI_DTU_HOST = originalDtuHost;
203
+ }
156
204
  });
157
205
  it("uses host alias when available outside Docker", async () => {
206
+ delete process.env.AGENT_CI_DTU_HOST;
158
207
  const { resolveDtuHost } = await import("./container-config.js");
159
208
  const originalExistsSync = fs.existsSync;
160
209
  vi.spyOn(fs, "existsSync").mockImplementation((filePath) => {
@@ -166,6 +215,7 @@ describe("resolveDtuHost", () => {
166
215
  await expect(resolveDtuHost()).resolves.toBe("host.docker.internal");
167
216
  });
168
217
  it("uses configured bridge gateway outside Docker when provided", async () => {
218
+ delete process.env.AGENT_CI_DTU_HOST;
169
219
  const { resolveDtuHost } = await import("./container-config.js");
170
220
  const originalExistsSync = fs.existsSync;
171
221
  vi.spyOn(fs, "existsSync").mockImplementation((filePath) => {
@@ -178,6 +228,7 @@ describe("resolveDtuHost", () => {
178
228
  await expect(resolveDtuHost()).resolves.toBe("10.10.0.1");
179
229
  });
180
230
  it("uses host alias outside Docker when no gateway override is configured", async () => {
231
+ delete process.env.AGENT_CI_DTU_HOST;
181
232
  const { resolveDtuHost } = await import("./container-config.js");
182
233
  const originalExistsSync = fs.existsSync;
183
234
  vi.spyOn(fs, "existsSync").mockImplementation((filePath) => {
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Reproduction for https://github.com/redwoodjs/agent-ci/issues/126
3
+ *
4
+ * When running inside Docker (via agent-ci), AGENT_CI_DTU_HOST is set in the
5
+ * container environment. The resolveDtuHost tests delete it, but this test
6
+ * verifies the mock and env var interaction actually works in that scenario.
7
+ */
8
+ import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
9
+ import fs from "node:fs";
10
+ afterEach(() => {
11
+ vi.restoreAllMocks();
12
+ });
13
+ describe("issue-126 reproduction: resolveDtuHost inside Docker", () => {
14
+ const originalBridgeGateway = process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY;
15
+ const originalDtuHost = process.env.AGENT_CI_DTU_HOST;
16
+ beforeEach(() => {
17
+ delete process.env.AGENT_CI_DTU_HOST;
18
+ });
19
+ afterEach(() => {
20
+ if (originalBridgeGateway === undefined) {
21
+ delete process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY;
22
+ }
23
+ else {
24
+ process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY = originalBridgeGateway;
25
+ }
26
+ if (originalDtuHost === undefined) {
27
+ delete process.env.AGENT_CI_DTU_HOST;
28
+ }
29
+ else {
30
+ process.env.AGENT_CI_DTU_HOST = originalDtuHost;
31
+ }
32
+ });
33
+ it("reports environment state", async () => {
34
+ console.log("--- issue-126 repro diagnostics ---");
35
+ console.log("/.dockerenv exists:", fs.existsSync("/.dockerenv"));
36
+ console.log("AGENT_CI_DTU_HOST:", process.env.AGENT_CI_DTU_HOST ?? "(unset)");
37
+ console.log("AGENT_CI_DOCKER_BRIDGE_GATEWAY:", process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY ?? "(unset)");
38
+ expect(true).toBe(true);
39
+ });
40
+ it("mock fs.existsSync intercepts /.dockerenv check", async () => {
41
+ const realResult = fs.existsSync("/.dockerenv");
42
+ console.log("Real fs.existsSync('/.dockerenv'):", realResult);
43
+ const originalExistsSync = fs.existsSync;
44
+ vi.spyOn(fs, "existsSync").mockImplementation((filePath) => {
45
+ if (filePath === "/.dockerenv") {
46
+ return false;
47
+ }
48
+ return originalExistsSync(filePath);
49
+ });
50
+ const mockedResult = fs.existsSync("/.dockerenv");
51
+ console.log("Mocked fs.existsSync('/.dockerenv'):", mockedResult);
52
+ expect(mockedResult).toBe(false);
53
+ });
54
+ it("resolveDtuHost uses bridge gateway when mock is active (the failing test)", async () => {
55
+ // Simulate agent-ci Docker env: AGENT_CI_DTU_HOST was set but we deleted it
56
+ delete process.env.AGENT_CI_DTU_HOST;
57
+ const { resolveDtuHost } = await import("./container-config.js");
58
+ const originalExistsSync = fs.existsSync;
59
+ vi.spyOn(fs, "existsSync").mockImplementation((filePath) => {
60
+ if (filePath === "/.dockerenv") {
61
+ return false;
62
+ }
63
+ return originalExistsSync(filePath);
64
+ });
65
+ process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY = "10.10.0.1";
66
+ // This is the assertion that was failing: expected '10.10.0.1' but got 'host.docker.internal'
67
+ const result = await resolveDtuHost();
68
+ console.log("resolveDtuHost() returned:", result);
69
+ console.log("Expected: 10.10.0.1");
70
+ expect(result).toBe("10.10.0.1");
71
+ });
72
+ });
@@ -43,14 +43,31 @@ export function killRunnerContainers(runnerName) {
43
43
  }
44
44
  /**
45
45
  * Remove orphaned Docker resources left behind by previous runs:
46
- * 1. `agent-ci-net-*` networks with no connected containers
47
- * 2. Dangling volumes (anonymous volumes from service containers like MySQL)
46
+ * 1. Stopped `agent-ci-*` containers (runners + sidecars)
47
+ * 2. `agent-ci-net-*` networks with no connected containers
48
+ * 3. Dangling volumes (anonymous volumes from service containers like MySQL)
49
+ *
50
+ * Stopped containers must be removed first so their network references are
51
+ * released, allowing the network prune in step 2 to reclaim address pool capacity.
48
52
  *
49
53
  * Call this proactively before creating new resources to prevent Docker from
50
54
  * exhausting its address pool ("all predefined address pools have been fully subnetted").
51
55
  */
52
56
  export function pruneOrphanedDockerResources() {
53
- // 1. Remove orphaned agent-ci-net-* networks
57
+ // 1. Remove stopped agent-ci-* containers (runners + sidecars) so their
58
+ // network references are released before we try to prune networks.
59
+ try {
60
+ const stoppedIds = execSync(`docker ps -aq --filter "name=agent-ci-" --filter "status=exited"`, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
61
+ if (stoppedIds) {
62
+ execSync(`docker rm -f ${stoppedIds.split("\n").join(" ")}`, {
63
+ stdio: ["pipe", "pipe", "pipe"],
64
+ });
65
+ }
66
+ }
67
+ catch {
68
+ // Docker not reachable or no stopped containers — skip
69
+ }
70
+ // 2. Remove orphaned agent-ci-net-* networks
54
71
  try {
55
72
  const nets = execSync(`docker network ls -q --filter "name=agent-ci-net-"`, {
56
73
  encoding: "utf8",
@@ -74,7 +91,7 @@ export function pruneOrphanedDockerResources() {
74
91
  catch {
75
92
  // Docker not reachable — skip
76
93
  }
77
- // 2. Remove dangling volumes (anonymous volumes from service containers)
94
+ // 3. Remove dangling volumes (anonymous volumes from service containers)
78
95
  try {
79
96
  execSync(`docker volume prune -f`, {
80
97
  stdio: ["pipe", "pipe", "pipe"],
@@ -18,6 +18,7 @@ export function copyWorkspace(repoRoot, dest) {
18
18
  const files = execSync("git ls-files --cached --others --exclude-standard -z", {
19
19
  stdio: "pipe",
20
20
  cwd: repoRoot,
21
+ maxBuffer: 100 * 1024 * 1024, // 100MB — default 1MB overflows in large monorepos
21
22
  })
22
23
  .toString()
23
24
  .split("\0")
@@ -82,6 +83,25 @@ const LOCKFILE_NAMES = [
82
83
  "bun.lock",
83
84
  "bun.lockb",
84
85
  ];
86
+ const LOCKFILE_TO_PM = {
87
+ "pnpm-lock.yaml": "pnpm",
88
+ "package-lock.json": "npm",
89
+ "yarn.lock": "yarn",
90
+ "bun.lock": "bun",
91
+ "bun.lockb": "bun",
92
+ };
93
+ /**
94
+ * Detect the project's package manager by looking for lockfiles in the repo root.
95
+ * Returns the first match in priority order, or null if no lockfile is found.
96
+ */
97
+ export function detectPackageManager(repoRoot) {
98
+ for (const name of LOCKFILE_NAMES) {
99
+ if (fs.existsSync(path.join(repoRoot, name))) {
100
+ return LOCKFILE_TO_PM[name];
101
+ }
102
+ }
103
+ return null;
104
+ }
85
105
  /**
86
106
  * Compute a short SHA-256 hash of lockfiles tracked in the repo.
87
107
  * Searches for all known lockfile types (pnpm, npm, yarn, bun) and hashes
@@ -136,6 +136,51 @@ describe("computeLockfileHash", () => {
136
136
  expect(hash).toMatch(/^[a-f0-9]{16}$/);
137
137
  });
138
138
  });
139
+ // ── detectPackageManager tests ────────────────────────────────────────────────
140
+ describe("detectPackageManager", () => {
141
+ let repoDir;
142
+ beforeEach(() => {
143
+ repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "oa-pm-detect-"));
144
+ });
145
+ afterEach(() => {
146
+ fs.rmSync(repoDir, { recursive: true, force: true });
147
+ });
148
+ it("detects pnpm from pnpm-lock.yaml", async () => {
149
+ const { detectPackageManager } = await import("./cleanup.js");
150
+ fs.writeFileSync(path.join(repoDir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n");
151
+ expect(detectPackageManager(repoDir)).toBe("pnpm");
152
+ });
153
+ it("detects npm from package-lock.json", async () => {
154
+ const { detectPackageManager } = await import("./cleanup.js");
155
+ fs.writeFileSync(path.join(repoDir, "package-lock.json"), '{"lockfileVersion": 3}');
156
+ expect(detectPackageManager(repoDir)).toBe("npm");
157
+ });
158
+ it("detects yarn from yarn.lock", async () => {
159
+ const { detectPackageManager } = await import("./cleanup.js");
160
+ fs.writeFileSync(path.join(repoDir, "yarn.lock"), "# yarn lockfile v1");
161
+ expect(detectPackageManager(repoDir)).toBe("yarn");
162
+ });
163
+ it("detects bun from bun.lock", async () => {
164
+ const { detectPackageManager } = await import("./cleanup.js");
165
+ fs.writeFileSync(path.join(repoDir, "bun.lock"), '{"lockfileVersion": 0}');
166
+ expect(detectPackageManager(repoDir)).toBe("bun");
167
+ });
168
+ it("detects bun from bun.lockb", async () => {
169
+ const { detectPackageManager } = await import("./cleanup.js");
170
+ fs.writeFileSync(path.join(repoDir, "bun.lockb"), Buffer.from([0x00]));
171
+ expect(detectPackageManager(repoDir)).toBe("bun");
172
+ });
173
+ it("returns null when no lockfile exists", async () => {
174
+ const { detectPackageManager } = await import("./cleanup.js");
175
+ expect(detectPackageManager(repoDir)).toBeNull();
176
+ });
177
+ it("prefers pnpm over npm when both lockfiles exist", async () => {
178
+ const { detectPackageManager } = await import("./cleanup.js");
179
+ fs.writeFileSync(path.join(repoDir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n");
180
+ fs.writeFileSync(path.join(repoDir, "package-lock.json"), '{"lockfileVersion": 3}');
181
+ expect(detectPackageManager(repoDir)).toBe("pnpm");
182
+ });
183
+ });
139
184
  // ── isWarmNodeModules tests ───────────────────────────────────────────────────
140
185
  describe("isWarmNodeModules", () => {
141
186
  let tmpDir;
@@ -31,15 +31,44 @@ export function getNextLogNum(prefix) {
31
31
  });
32
32
  return nums.length > 0 ? Math.max(...nums) + 1 : 1;
33
33
  }
34
+ /**
35
+ * Atomically allocate a numbered run directory under `runs/`.
36
+ * Uses mkdirSync without `recursive` so that EEXIST signals a
37
+ * collision — the caller just increments and retries.
38
+ */
39
+ function allocateRunDir(prefix) {
40
+ const runsDir = getRunsDir();
41
+ let num = getNextLogNum(prefix);
42
+ for (;;) {
43
+ const name = `${prefix}-${num}`;
44
+ const runDir = path.join(runsDir, name);
45
+ try {
46
+ fs.mkdirSync(runDir); // atomic — fails with EEXIST on collision
47
+ return { num, name, runDir };
48
+ }
49
+ catch (err) {
50
+ if (err.code === "EEXIST") {
51
+ num++;
52
+ continue;
53
+ }
54
+ throw err;
55
+ }
56
+ }
57
+ }
34
58
  export function createLogContext(prefix, preferredName) {
35
59
  ensureLogDirs();
36
- let num = 0;
37
- let name = preferredName;
38
- if (!name) {
39
- num = getNextLogNum(prefix);
40
- name = `${prefix}-${num}`;
60
+ let num;
61
+ let name;
62
+ let runDir;
63
+ if (preferredName) {
64
+ num = 0;
65
+ name = preferredName;
66
+ runDir = path.join(getRunsDir(), name);
67
+ fs.mkdirSync(runDir, { recursive: true });
68
+ }
69
+ else {
70
+ ({ num, name, runDir } = allocateRunDir(prefix));
41
71
  }
42
- const runDir = path.join(getRunsDir(), name);
43
72
  const logDir = path.join(runDir, "logs");
44
73
  fs.mkdirSync(logDir, { recursive: true });
45
74
  return {
@@ -78,5 +78,45 @@ describe("Logger utilities", () => {
78
78
  const second = createLogContext("agent-ci");
79
79
  expect(second.num).toBe(first.num + 1);
80
80
  });
81
+ it("skips over a directory that already exists (race condition)", async () => {
82
+ const { setWorkingDirectory } = await import("./working-directory.js");
83
+ const { createLogContext } = await import("./logger.js");
84
+ setWorkingDirectory(tmpDir);
85
+ // Pre-create runs/agent-ci-1 to simulate another process winning the race
86
+ fs.mkdirSync(path.join(tmpDir, "runs", "agent-ci-1"), { recursive: true });
87
+ const ctx = createLogContext("agent-ci");
88
+ // Should have skipped 1 and landed on 2
89
+ expect(ctx.num).toBe(2);
90
+ expect(ctx.name).toBe("agent-ci-2");
91
+ expect(fs.existsSync(ctx.runDir)).toBe(true);
92
+ });
93
+ it("handles multiple consecutive collisions", async () => {
94
+ const { setWorkingDirectory } = await import("./working-directory.js");
95
+ const { createLogContext } = await import("./logger.js");
96
+ setWorkingDirectory(tmpDir);
97
+ // Pre-create 1, 2, and 3 to simulate several concurrent processes
98
+ fs.mkdirSync(path.join(tmpDir, "runs", "agent-ci-1"), { recursive: true });
99
+ fs.mkdirSync(path.join(tmpDir, "runs", "agent-ci-2"), { recursive: true });
100
+ fs.mkdirSync(path.join(tmpDir, "runs", "agent-ci-3"), { recursive: true });
101
+ const ctx = createLogContext("agent-ci");
102
+ expect(ctx.num).toBe(4);
103
+ expect(ctx.name).toBe("agent-ci-4");
104
+ expect(fs.existsSync(ctx.runDir)).toBe(true);
105
+ });
106
+ it("concurrent calls each get a unique directory", async () => {
107
+ const { setWorkingDirectory } = await import("./working-directory.js");
108
+ const { createLogContext } = await import("./logger.js");
109
+ setWorkingDirectory(tmpDir);
110
+ // Call createLogContext many times synchronously — each should get a unique dir
111
+ const results = Array.from({ length: 10 }, () => createLogContext("agent-ci"));
112
+ const names = results.map((r) => r.name);
113
+ const uniqueNames = new Set(names);
114
+ expect(uniqueNames.size).toBe(10);
115
+ // All directories should exist
116
+ for (const r of results) {
117
+ expect(fs.existsSync(r.runDir)).toBe(true);
118
+ expect(fs.existsSync(r.logDir)).toBe(true);
119
+ }
120
+ });
81
121
  });
82
122
  });
@@ -71,3 +71,39 @@ describe("printSummary", () => {
71
71
  expect(output).not.toContain("FAILURES");
72
72
  });
73
73
  });
74
+ // ── Empty results behavior (CLI exit logic) ──────────────────────────────────
75
+ // The CLI treats empty results as failure. These tests verify the logic pattern
76
+ // used in cli.ts: `results.length === 0 || results.some(r => !r.succeeded)`
77
+ describe("empty results exit logic", () => {
78
+ function shouldFail(results) {
79
+ return results.length === 0 || results.some((r) => !r.succeeded);
80
+ }
81
+ function shouldPrintSummary(results) {
82
+ return results.length > 0;
83
+ }
84
+ it("treats empty results as failure", () => {
85
+ expect(shouldFail([])).toBe(true);
86
+ });
87
+ it("treats results with a failure as failure", () => {
88
+ expect(shouldFail([makeResult({ succeeded: false })])).toBe(true);
89
+ });
90
+ it("treats all-passing results as success", () => {
91
+ const passing = [
92
+ {
93
+ name: "c1",
94
+ workflow: "ci.yml",
95
+ taskId: "test",
96
+ succeeded: true,
97
+ durationMs: 100,
98
+ debugLogPath: "/tmp/x",
99
+ },
100
+ ];
101
+ expect(shouldFail(passing)).toBe(false);
102
+ });
103
+ it("skips summary print for empty results", () => {
104
+ expect(shouldPrintSummary([])).toBe(false);
105
+ });
106
+ it("prints summary for non-empty results", () => {
107
+ expect(shouldPrintSummary([makeResult()])).toBe(true);
108
+ });
109
+ });
@@ -17,6 +17,18 @@ function getSpinnerFrame() {
17
17
  function fmtMs(ms) {
18
18
  return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
19
19
  }
20
+ function fmtBytes(bytes) {
21
+ if (bytes >= 1000000000) {
22
+ return `${(bytes / 1000000000).toFixed(1)} GB`;
23
+ }
24
+ if (bytes >= 1000000) {
25
+ return `${(bytes / 1000000).toFixed(1)} MB`;
26
+ }
27
+ if (bytes >= 1000) {
28
+ return `${(bytes / 1000).toFixed(1)} KB`;
29
+ }
30
+ return `${bytes} B`;
31
+ }
20
32
  // ─── Step node builder ────────────────────────────────────────────────────────
21
33
  function buildStepNode(step, job, padW) {
22
34
  const pad = (n) => String(n).padStart(padW);
@@ -72,8 +84,21 @@ function buildJobNodes(job, singleJobMode) {
72
84
  const bootNode = {
73
85
  label: `${getSpinnerFrame()} Starting runner ${job.runnerId} (${elapsed}s)`,
74
86
  };
87
+ const children = [];
88
+ if (job.pullProgress) {
89
+ const { phase, currentBytes, totalBytes } = job.pullProgress;
90
+ const pct = totalBytes > 0 ? Math.round((currentBytes / totalBytes) * 100) : 0;
91
+ const label = phase === "extracting" ? "Extracting" : "Downloading";
92
+ children.push({
93
+ label: `${DIM}${label}: ${fmtBytes(currentBytes)} / ${fmtBytes(totalBytes)} (${pct}%)${RESET}`,
94
+ });
95
+ }
75
96
  if (job.logDir) {
76
- bootNode.children = [{ label: `${DIM}Logs: ${job.logDir}${RESET}` }];
97
+ const shortLogDir = job.logDir.replace(/^.*?(agent-ci\/)/, "$1");
98
+ children.push({ label: `${DIM}Logs: ${shortLogDir}${RESET}` });
99
+ }
100
+ if (children.length > 0) {
101
+ bootNode.children = children;
77
102
  }
78
103
  return [bootNode];
79
104
  }