@redwoodjs/agent-ci 0.8.1 → 0.8.2

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
@@ -12,7 +12,7 @@ import { resolveJobOutputs } from "./runner/result-builder.js";
12
12
  import { createConcurrencyLimiter, getDefaultMaxConcurrentJobs } from "./output/concurrency.js";
13
13
  import { isWarmNodeModules, computeLockfileHash } from "./output/cleanup.js";
14
14
  import { getWorkingDirectory } from "./output/working-directory.js";
15
- import { pruneOrphanedDockerResources } from "./docker/shutdown.js";
15
+ import { pruneOrphanedDockerResources, killOrphanedContainers, pruneStaleWorkspaces, } from "./docker/shutdown.js";
16
16
  import { topoSort } from "./workflow/job-scheduler.js";
17
17
  import { expandReusableJobs } from "./workflow/reusable-workflow.js";
18
18
  import { prefetchRemoteWorkflows } from "./workflow/remote-workflow-fetch.js";
@@ -692,6 +692,8 @@ async function handleWorkflow(options) {
692
692
  return result;
693
693
  };
694
694
  pruneOrphanedDockerResources();
695
+ killOrphanedContainers();
696
+ pruneStaleWorkspaces(getWorkingDirectory(), 24 * 60 * 60 * 1000);
695
697
  const limiter = createConcurrencyLimiter(maxJobs);
696
698
  const allResults = [];
697
699
  // Accumulate job outputs across waves for needs.*.outputs.* resolution
@@ -33,7 +33,8 @@ export function buildContainerEnv(opts) {
33
33
  * Build the Binds array for `docker.createContainer()`.
34
34
  */
35
35
  export function buildContainerBinds(opts) {
36
- const { hostWorkDir, shimsDir, signalsDir, diagDir, toolCacheDir, pnpmStoreDir, npmCacheDir, bunCacheDir, playwrightCacheDir, warmModulesDir, hostRunnerDir, useDirectContainer, dockerSocketPath = "/var/run/docker.sock", } = opts;
36
+ const { hostWorkDir, shimsDir, signalsDir, diagDir, toolCacheDir, pnpmStoreDir, npmCacheDir, bunCacheDir, playwrightCacheDir, warmModulesDir, hostRunnerDir, useDirectContainer, githubRepo, dockerSocketPath = "/var/run/docker.sock", } = opts;
37
+ const repoName = githubRepo.split("/").pop() || "repo";
37
38
  const h = toHostPath;
38
39
  return [
39
40
  // When using a custom container, bind-mount the extracted runner
@@ -50,12 +51,11 @@ export function buildContainerBinds(opts) {
50
51
  ...(npmCacheDir ? [`${h(npmCacheDir)}:/home/runner/.npm`] : []),
51
52
  ...(bunCacheDir ? [`${h(bunCacheDir)}:/home/runner/.bun/install/cache`] : []),
52
53
  `${h(playwrightCacheDir)}:/home/runner/.cache/ms-playwright`,
53
- // Warm node_modules: mounted outside the workspace so actions/checkout can
54
- // delete the symlink without EBUSY. A symlink in the entrypoint points
55
- // workspace/node_modules /tmp/node_modules.
56
- // Mounted at /tmp/node_modules (not /tmp/warm-modules) so that TypeScript's
57
- // upward @types walk from .pnpm realpath finds /tmp/node_modules/@types.
58
- `${h(warmModulesDir)}:/tmp/node_modules`,
54
+ // Warm node_modules: mounted directly at the workspace node_modules path
55
+ // so pnpm/esbuild path resolution sees a real directory (not a symlink).
56
+ // The git shim blocks `git clean` and checkout is patched with clean:false,
57
+ // so EBUSY on this bind mount is not a concern.
58
+ `${h(warmModulesDir)}:/home/runner/_work/${repoName}/${repoName}/node_modules`,
59
59
  ];
60
60
  }
61
61
  // ─── Container command ────────────────────────────────────────────────────────
@@ -89,7 +89,6 @@ export function buildContainerCmd(opts) {
89
89
  `REPO_NAME=$(basename $GITHUB_REPOSITORY)`,
90
90
  `WORKSPACE_PATH=/home/runner/_work/$REPO_NAME/$REPO_NAME`,
91
91
  `mkdir -p $WORKSPACE_PATH`,
92
- `ln -sfn /tmp/node_modules $WORKSPACE_PATH/node_modules`,
93
92
  T("workspace-setup"),
94
93
  `echo "[agent-ci:boot] total: $(($(date +%s%3N)-BOOT_T0))ms"`,
95
94
  `echo "[agent-ci:boot] starting run.sh --once"`,
@@ -56,11 +56,12 @@ describe("buildContainerBinds", () => {
56
56
  warmModulesDir: "/tmp/warm",
57
57
  hostRunnerDir: "/tmp/runner",
58
58
  useDirectContainer: false,
59
+ githubRepo: "org/repo",
59
60
  });
60
61
  expect(binds).toContain("/tmp/work:/home/runner/_work");
61
62
  expect(binds).toContain("/var/run/docker.sock:/var/run/docker.sock"); // default when dockerSocketPath is not set
62
63
  expect(binds).toContain("/tmp/shims:/tmp/agent-ci-shims");
63
- expect(binds).toContain("/tmp/warm:/tmp/node_modules");
64
+ expect(binds).toContain("/tmp/warm:/home/runner/_work/repo/repo/node_modules");
64
65
  expect(binds).toContain("/tmp/pnpm:/home/runner/_work/.pnpm-store");
65
66
  expect(binds).toContain("/tmp/npm:/home/runner/.npm");
66
67
  expect(binds).toContain("/tmp/bun:/home/runner/.bun/install/cache");
@@ -78,6 +79,7 @@ describe("buildContainerBinds", () => {
78
79
  warmModulesDir: "/tmp/warm",
79
80
  hostRunnerDir: "/tmp/runner",
80
81
  useDirectContainer: false,
82
+ githubRepo: "org/repo",
81
83
  });
82
84
  expect(binds).toContain("/tmp/work:/home/runner/_work");
83
85
  expect(binds.some((b) => b.includes(".pnpm-store"))).toBe(false);
@@ -96,6 +98,7 @@ describe("buildContainerBinds", () => {
96
98
  warmModulesDir: "/tmp/warm",
97
99
  hostRunnerDir: "/tmp/runner",
98
100
  useDirectContainer: false,
101
+ githubRepo: "org/repo",
99
102
  });
100
103
  expect(binds).toContain("/tmp/npm:/home/runner/.npm");
101
104
  expect(binds.some((b) => b.includes(".pnpm-store"))).toBe(false);
@@ -112,6 +115,7 @@ describe("buildContainerBinds", () => {
112
115
  warmModulesDir: "/tmp/warm",
113
116
  hostRunnerDir: "/tmp/runner",
114
117
  useDirectContainer: false,
118
+ githubRepo: "org/repo",
115
119
  dockerSocketPath: "/Users/test/.orbstack/run/docker.sock",
116
120
  });
117
121
  expect(binds).toContain("/Users/test/.orbstack/run/docker.sock:/var/run/docker.sock");
@@ -131,6 +135,7 @@ describe("buildContainerBinds", () => {
131
135
  warmModulesDir: "/tmp/warm",
132
136
  hostRunnerDir: "/tmp/runner",
133
137
  useDirectContainer: true,
138
+ githubRepo: "org/repo",
134
139
  });
135
140
  expect(binds).toContain("/tmp/runner:/home/runner");
136
141
  });
@@ -322,6 +327,7 @@ describe("buildContainerBinds with dockerSocketPath", () => {
322
327
  warmModulesDir: "/tmp/warm",
323
328
  hostRunnerDir: "/tmp/runner",
324
329
  useDirectContainer: false,
330
+ githubRepo: "org/repo",
325
331
  };
326
332
  it("uses default /var/run/docker.sock when no dockerSocketPath is provided", async () => {
327
333
  const { buildContainerBinds } = await import("./container-config.js");
@@ -352,6 +358,7 @@ describe("buildContainerBinds with signalsDir", () => {
352
358
  warmModulesDir: "/tmp/warm",
353
359
  hostRunnerDir: "/tmp/runner",
354
360
  useDirectContainer: false,
361
+ githubRepo: "org/repo",
355
362
  };
356
363
  it("includes signals bind-mount when signalsDir is provided", async () => {
357
364
  const { buildContainerBinds } = await import("./container-config.js");
@@ -105,13 +105,17 @@ export function resolveDockerSocket() {
105
105
  throw new Error(lines.join("\n"));
106
106
  }
107
107
  /**
108
- * If `socketPath` exists (following symlinks), return the real path.
109
- * Returns undefined otherwise.
108
+ * If `socketPath` exists (following symlinks) and is accessible, return the
109
+ * real path. Returns undefined otherwise so the caller can keep searching.
110
110
  */
111
111
  function resolveIfExists(socketPath) {
112
112
  try {
113
113
  // fs.realpathSync follows symlinks and throws if the target doesn't exist
114
- return fs.realpathSync(socketPath);
114
+ const resolved = fs.realpathSync(socketPath);
115
+ // Verify we can actually connect — the socket may exist but be owned by
116
+ // root:docker with 660 perms (common on Linux with Docker Desktop).
117
+ fs.accessSync(resolved, fs.constants.R_OK | fs.constants.W_OK);
118
+ return resolved;
115
119
  }
116
120
  catch {
117
121
  return undefined;
@@ -20,6 +20,7 @@ describe("resolveDockerSocket", () => {
20
20
  it("uses DOCKER_HOST when set to a unix socket that exists", async () => {
21
21
  process.env.DOCKER_HOST = "unix:///tmp/test-docker.sock";
22
22
  vi.spyOn(fs, "realpathSync").mockReturnValue("/tmp/test-docker.sock");
23
+ vi.spyOn(fs, "accessSync").mockReturnValue(undefined);
23
24
  const { resolveDockerSocket } = await importFresh();
24
25
  const result = resolveDockerSocket();
25
26
  expect(result.socketPath).toBe("/tmp/test-docker.sock");
@@ -41,11 +42,36 @@ describe("resolveDockerSocket", () => {
41
42
  }
42
43
  throw new Error("ENOENT");
43
44
  });
45
+ vi.spyOn(fs, "accessSync").mockReturnValue(undefined);
44
46
  const { resolveDockerSocket } = await importFresh();
45
47
  const result = resolveDockerSocket();
46
48
  expect(result.socketPath).toBe("/Users/test/.orbstack/run/docker.sock");
47
49
  expect(result.uri).toBe("unix:///Users/test/.orbstack/run/docker.sock");
48
50
  });
51
+ // ── EACCES fallthrough ─────────────────────────────────────────────────
52
+ it("falls through to docker context when default socket is not accessible", async () => {
53
+ delete process.env.DOCKER_HOST;
54
+ // Socket exists but is not accessible (e.g. root:docker 660 on Linux)
55
+ vi.spyOn(fs, "realpathSync").mockReturnValue("/var/run/docker.sock");
56
+ vi.spyOn(fs, "accessSync").mockImplementation(() => {
57
+ throw Object.assign(new Error("EACCES"), { code: "EACCES" });
58
+ });
59
+ vi.spyOn(fs, "existsSync").mockImplementation((p) => {
60
+ return String(p) === "/home/user/.docker/desktop/docker.sock";
61
+ });
62
+ mockedExecSync.mockReturnValue(JSON.stringify([
63
+ {
64
+ Endpoints: {
65
+ docker: {
66
+ Host: "unix:///home/user/.docker/desktop/docker.sock",
67
+ },
68
+ },
69
+ },
70
+ ]));
71
+ const { resolveDockerSocket } = await importFresh();
72
+ const result = resolveDockerSocket();
73
+ expect(result.socketPath).toBe("/home/user/.docker/desktop/docker.sock");
74
+ });
49
75
  // ── Docker context fallback ─────────────────────────────────────────────
50
76
  it("falls back to docker context inspect when default socket missing", async () => {
51
77
  delete process.env.DOCKER_HOST;
@@ -101,6 +101,49 @@ export function pruneOrphanedDockerResources() {
101
101
  // Docker not reachable — skip
102
102
  }
103
103
  }
104
+ // ─── Orphaned container cleanup ───────────────────────────────────────────────
105
+ /**
106
+ * Find and kill running `agent-ci-*` containers whose parent process is dead.
107
+ *
108
+ * Every container created by `executeLocalJob` is labelled with
109
+ * `agent-ci.pid=<PID>`. If the process that spawned the container is no
110
+ * longer alive, the container is an orphan and should be killed — along with
111
+ * its svc-* sidecars and bridge network (via `killRunnerContainers`).
112
+ *
113
+ * Containers without the label (created before this feature) are skipped.
114
+ */
115
+ export function killOrphanedContainers() {
116
+ let lines;
117
+ try {
118
+ // Format: "containerId containerName pid-label"
119
+ const raw = execSync(`docker ps --filter "name=agent-ci-" --filter "status=running" --format "{{.ID}} {{.Names}} {{.Label \\"agent-ci.pid\\"}}"`, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
120
+ if (!raw) {
121
+ return;
122
+ }
123
+ lines = raw.split("\n");
124
+ }
125
+ catch {
126
+ // Docker not reachable — skip
127
+ return;
128
+ }
129
+ for (const line of lines) {
130
+ const [, containerName, pidStr] = line.split(" ");
131
+ if (!containerName || !pidStr) {
132
+ continue;
133
+ }
134
+ const pid = Number(pidStr);
135
+ if (!Number.isFinite(pid) || pid <= 0) {
136
+ continue;
137
+ }
138
+ try {
139
+ process.kill(pid, 0); // signal 0 = liveness check, throws if dead
140
+ }
141
+ catch {
142
+ // Parent is dead — this container is an orphan.
143
+ killRunnerContainers(containerName);
144
+ }
145
+ }
146
+ }
104
147
  // ─── Workspace pruning ────────────────────────────────────────────────────────
105
148
  /**
106
149
  * Remove stale `agent-ci-*` run directories older than `maxAgeMs` from
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, beforeEach, afterEach } from "vitest";
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import os from "node:os";
@@ -92,6 +92,78 @@ describe("Stale workspace pruning", () => {
92
92
  expect(fs.existsSync(otherDir)).toBe(true);
93
93
  });
94
94
  });
95
+ // ── Orphaned container cleanup ────────────────────────────────────────────────
96
+ describe("killOrphanedContainers", () => {
97
+ const execSyncMock = vi.fn();
98
+ const killSpy = vi.spyOn(process, "kill");
99
+ beforeEach(() => {
100
+ vi.resetModules();
101
+ vi.doMock("node:child_process", () => ({
102
+ execSync: execSyncMock,
103
+ }));
104
+ execSyncMock.mockReset();
105
+ killSpy.mockReset();
106
+ });
107
+ afterEach(() => {
108
+ killSpy.mockRestore();
109
+ });
110
+ it("kills containers whose parent PID is dead", async () => {
111
+ execSyncMock.mockImplementation((cmd) => {
112
+ if (cmd.startsWith("docker ps")) {
113
+ return "abc123 agent-ci-runner-1 99999\n";
114
+ }
115
+ return "";
116
+ });
117
+ killSpy.mockImplementation(((pid, signal) => {
118
+ if (signal === 0 && pid === 99999) {
119
+ throw new Error("ESRCH");
120
+ }
121
+ return true;
122
+ }));
123
+ const { killOrphanedContainers } = await import("./shutdown.js");
124
+ killOrphanedContainers();
125
+ const rmCalls = execSyncMock.mock.calls.filter(([cmd]) => cmd.includes("docker rm -f agent-ci-runner-1"));
126
+ expect(rmCalls.length).toBeGreaterThan(0);
127
+ });
128
+ it("leaves containers whose parent PID is alive", async () => {
129
+ const myPid = process.pid;
130
+ execSyncMock.mockImplementation((cmd) => {
131
+ if (cmd.startsWith("docker ps")) {
132
+ return `abc123 agent-ci-runner-2 ${myPid}\n`;
133
+ }
134
+ return "";
135
+ });
136
+ killSpy.mockImplementation(((pid, signal) => {
137
+ if (signal === 0 && pid === myPid) {
138
+ return true;
139
+ }
140
+ throw new Error("ESRCH");
141
+ }));
142
+ const { killOrphanedContainers } = await import("./shutdown.js");
143
+ killOrphanedContainers();
144
+ const rmCalls = execSyncMock.mock.calls.filter(([cmd]) => cmd.includes("docker rm -f"));
145
+ expect(rmCalls).toEqual([]);
146
+ });
147
+ it("skips containers without a PID label", async () => {
148
+ execSyncMock.mockImplementation((cmd) => {
149
+ if (cmd.startsWith("docker ps")) {
150
+ return "abc123 agent-ci-runner-3 \n";
151
+ }
152
+ return "";
153
+ });
154
+ const { killOrphanedContainers } = await import("./shutdown.js");
155
+ killOrphanedContainers();
156
+ const rmCalls = execSyncMock.mock.calls.filter(([cmd]) => cmd.includes("docker rm -f"));
157
+ expect(rmCalls).toEqual([]);
158
+ });
159
+ it("handles Docker not reachable gracefully", async () => {
160
+ execSyncMock.mockImplementation(() => {
161
+ throw new Error("Cannot connect to Docker daemon");
162
+ });
163
+ const { killOrphanedContainers } = await import("./shutdown.js");
164
+ expect(() => killOrphanedContainers()).not.toThrow();
165
+ });
166
+ });
95
167
  describe("containerWorkDir cleanup on exit", () => {
96
168
  let tmpDir;
97
169
  beforeEach(() => {
@@ -149,6 +149,7 @@ describe("buildContainerBinds — PM-scoped mounts", () => {
149
149
  warmModulesDir: "/tmp/warm",
150
150
  hostRunnerDir: "/tmp/runner",
151
151
  useDirectContainer: false,
152
+ githubRepo: "org/repo",
152
153
  });
153
154
  expect(binds).toContain("/tmp/npm-cache:/home/runner/.npm");
154
155
  expect(binds.some((b) => b.includes(".pnpm-store"))).toBe(false);
@@ -167,6 +168,7 @@ describe("buildContainerBinds — PM-scoped mounts", () => {
167
168
  warmModulesDir: "/tmp/warm",
168
169
  hostRunnerDir: "/tmp/runner",
169
170
  useDirectContainer: false,
171
+ githubRepo: "org/repo",
170
172
  });
171
173
  expect(binds).toContain("/tmp/pnpm-store:/home/runner/_work/.pnpm-store");
172
174
  expect(binds.some((b) => b.includes("/.npm"))).toBe(false);
@@ -185,6 +187,7 @@ describe("buildContainerBinds — PM-scoped mounts", () => {
185
187
  warmModulesDir: "/tmp/warm",
186
188
  hostRunnerDir: "/tmp/runner",
187
189
  useDirectContainer: false,
190
+ githubRepo: "org/repo",
188
191
  });
189
192
  expect(binds).toContain("/tmp/bun-cache:/home/runner/.bun/install/cache");
190
193
  expect(binds.some((b) => b.includes(".pnpm-store"))).toBe(false);
@@ -202,6 +205,7 @@ describe("buildContainerBinds — PM-scoped mounts", () => {
202
205
  warmModulesDir: "/tmp/warm",
203
206
  hostRunnerDir: "/tmp/runner",
204
207
  useDirectContainer: false,
208
+ githubRepo: "org/repo",
205
209
  });
206
210
  expect(binds.some((b) => b.includes(".pnpm-store"))).toBe(false);
207
211
  expect(binds.some((b) => b.includes("/.npm"))).toBe(false);
@@ -1,13 +1,5 @@
1
1
  import path from "path";
2
2
  import fs from "fs";
3
- // ─── Fake SHA computation ─────────────────────────────────────────────────────
4
- /**
5
- * Resolve which SHA the git shim should return for ls-remote / rev-parse.
6
- * Uses the real SHA if provided, otherwise falls back to a deterministic fake.
7
- */
8
- export function computeFakeSha(headSha) {
9
- return headSha && headSha !== "HEAD" ? headSha : "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
10
- }
11
3
  // ─── Git shim script ──────────────────────────────────────────────────────────
12
4
  /**
13
5
  * Write the bash git shim to `<shimsDir>/git`.
@@ -2,21 +2,6 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import os from "node:os";
5
- // ── computeFakeSha ────────────────────────────────────────────────────────────
6
- describe("computeFakeSha", () => {
7
- it("returns the headSha when it is a real SHA", async () => {
8
- const { computeFakeSha } = await import("./git-shim.js");
9
- expect(computeFakeSha("abc123def456")).toBe("abc123def456");
10
- });
11
- it("returns the deterministic fake when headSha is HEAD", async () => {
12
- const { computeFakeSha } = await import("./git-shim.js");
13
- expect(computeFakeSha("HEAD")).toBe("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
14
- });
15
- it("returns the deterministic fake when headSha is undefined", async () => {
16
- const { computeFakeSha } = await import("./git-shim.js");
17
- expect(computeFakeSha(undefined)).toBe("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
18
- });
19
- });
20
5
  // ── writeGitShim ──────────────────────────────────────────────────────────────
21
6
  describe("writeGitShim", () => {
22
7
  let tmpDir;
@@ -12,7 +12,7 @@ import { killRunnerContainers } from "../docker/shutdown.js";
12
12
  import { startEphemeralDtu } from "dtu-github-actions/ephemeral";
13
13
  import { tailLogFile } from "../output/reporter.js";
14
14
  import { writeJobMetadata } from "./metadata.js";
15
- import { computeFakeSha, writeGitShim } from "./git-shim.js";
15
+ import { writeGitShim } from "./git-shim.js";
16
16
  import { prepareWorkspace } from "./workspace.js";
17
17
  import { createRunDirectories } from "./directory-setup.js";
18
18
  import { buildContainerEnv, buildContainerBinds, buildContainerCmd, resolveDtuHost, resolveDockerApiUrl, resolveDockerExtraHosts, } from "../docker/container-config.js";
@@ -184,13 +184,23 @@ export async function executeLocalJob(job, options) {
184
184
  writeJobMetadata({ logDir, containerName, job });
185
185
  // Open debug stream to capture raw container output
186
186
  const debugStream = fs.createWriteStream(debugLogPath);
187
+ // Hoisted for cleanup in `finally` — assigned inside the try block.
188
+ let container = null;
189
+ let serviceCtx;
190
+ const hostRunnerDir = path.resolve(runDir, "runner");
187
191
  // Signal handler: ensure cleanup runs even when killed.
188
192
  // Do NOT call process.exit() here — multiple jobs register handlers concurrently,
189
193
  // and an early exit would prevent other jobs' handlers from cleaning up their containers.
190
194
  // killRunnerContainers already handles the runner, its svc-* sidecars, and the network.
191
195
  const signalCleanup = () => {
192
196
  killRunnerContainers(containerName);
193
- for (const d of [dirs.containerWorkDir, dirs.shimsDir, dirs.signalsDir, dirs.diagDir]) {
197
+ for (const d of [
198
+ dirs.containerWorkDir,
199
+ dirs.shimsDir,
200
+ dirs.signalsDir,
201
+ dirs.diagDir,
202
+ hostRunnerDir,
203
+ ]) {
194
204
  try {
195
205
  fs.rmSync(d, { recursive: true, force: true });
196
206
  }
@@ -199,10 +209,6 @@ export async function executeLocalJob(job, options) {
199
209
  };
200
210
  process.on("SIGINT", signalCleanup);
201
211
  process.on("SIGTERM", signalCleanup);
202
- // Hoisted for cleanup in `finally` — assigned inside the try block.
203
- let container = null;
204
- let serviceCtx;
205
- const hostRunnerDir = path.resolve(runDir, "runner");
206
212
  try {
207
213
  // 1. Seed the job to Local DTU
208
214
  const [githubOwner, githubRepoName] = (job.githubRepo || "").split("/");
@@ -243,8 +249,7 @@ export async function executeLocalJob(job, options) {
243
249
  // 4. Write git shim BEFORE container start so the entrypoint can install it
244
250
  // immediately. On Linux, prepareWorkspace (rsync) is slow enough that the
245
251
  // container entrypoint would race ahead and find an empty shims dir.
246
- const fakeSha = computeFakeSha(job.headSha);
247
- writeGitShim(dirs.shimsDir, fakeSha);
252
+ writeGitShim(dirs.shimsDir, job.realHeadSha);
248
253
  // Prepare workspace files in parallel with container setup
249
254
  const workspacePrepStart = Date.now();
250
255
  const workspacePrepPromise = (async () => {
@@ -449,6 +454,7 @@ export async function executeLocalJob(job, options) {
449
454
  warmModulesDir: dirs.warmModulesDir,
450
455
  hostRunnerDir,
451
456
  useDirectContainer,
457
+ githubRepo,
452
458
  dockerSocketPath: getDockerSocket().socketPath || undefined,
453
459
  });
454
460
  const containerCmd = buildContainerCmd({
@@ -463,6 +469,9 @@ export async function executeLocalJob(job, options) {
463
469
  container = await getDocker().createContainer({
464
470
  Image: containerImage,
465
471
  name: containerName,
472
+ Labels: {
473
+ "agent-ci.pid": String(process.pid),
474
+ },
466
475
  Env: containerEnv,
467
476
  ...(useDirectContainer ? { Entrypoint: ["bash"] } : {}),
468
477
  Cmd: containerCmd,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redwoodjs/agent-ci",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "Local GitHub Actions runner — pause on failure, ~0ms cache, official runner binary. Built for AI coding agents.",
5
5
  "keywords": [
6
6
  "act-alternative",
@@ -40,7 +40,7 @@
40
40
  "log-update": "^7.2.0",
41
41
  "minimatch": "^10.2.1",
42
42
  "yaml": "^2.8.2",
43
- "dtu-github-actions": "0.8.1"
43
+ "dtu-github-actions": "0.8.2"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/dockerode": "^3.3.34",