Skip to content

Commit dc54c67

Browse files
committed
Harden runner baseline validation
1 parent 8ad1b3c commit dc54c67

3 files changed

Lines changed: 119 additions & 6 deletions

File tree

.github/workflows/ci.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
ci:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
with:
15+
path: devagent-runner
16+
17+
- uses: actions/checkout@v4
18+
with:
19+
repository: egavrin/devagent-sdk
20+
ref: main
21+
path: devagent-sdk
22+
23+
- uses: oven-sh/setup-bun@v2
24+
with:
25+
bun-version: "1.3.10"
26+
27+
- name: Install SDK dependencies
28+
working-directory: devagent-sdk
29+
run: bun install
30+
31+
- name: Build SDK packages
32+
working-directory: devagent-sdk
33+
run: bun run build
34+
35+
- name: Install runner dependencies
36+
working-directory: devagent-runner
37+
run: bun install
38+
39+
- name: Typecheck
40+
working-directory: devagent-runner
41+
run: bun run typecheck
42+
43+
- name: Test
44+
working-directory: devagent-runner
45+
run: bun run test

packages/adapters/src/index.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,44 @@ process.stdout.write(JSON.stringify({ protocolVersion: "0.1", type: "completed",
9292
assert.deepEqual(events.map((event) => event.type), ["started", "artifact", "completed"]);
9393
});
9494

95+
test("DevAgentAdapter waits for close before reading result", async () => {
96+
const { root, artifactDir, workspacePath } = await createWorkspace();
97+
const stubPath = join(root, "devagent-close-stub.js");
98+
await createStub(stubPath, `#!/usr/bin/env node
99+
const fs = require("fs");
100+
const path = require("path");
101+
const args = process.argv.slice(2);
102+
const requestPath = args[args.indexOf("--request") + 1];
103+
const artifactDir = args[args.indexOf("--artifact-dir") + 1];
104+
const request = JSON.parse(fs.readFileSync(requestPath, "utf8"));
105+
const artifactPath = path.join(artifactDir, "triage-report.md");
106+
const resultPath = path.join(artifactDir, "result.json");
107+
process.on("beforeExit", () => {
108+
fs.writeFileSync(artifactPath, "# Triage\\n\\nLate output\\n");
109+
fs.writeFileSync(resultPath, JSON.stringify({
110+
protocolVersion: "0.1",
111+
taskId: request.taskId,
112+
status: "success",
113+
artifacts: [{ kind: "triage-report", path: artifactPath, createdAt: new Date().toISOString() }],
114+
metrics: { startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(), durationMs: 1 }
115+
}, null, 2));
116+
process.stdout.write(JSON.stringify({ protocolVersion: "0.1", type: "started", at: new Date().toISOString(), taskId: request.taskId }) + "\\n");
117+
process.stdout.write(JSON.stringify({ protocolVersion: "0.1", type: "artifact", at: new Date().toISOString(), taskId: request.taskId, artifact: { kind: "triage-report", path: artifactPath, createdAt: new Date().toISOString() } }) + "\\n");
118+
process.stdout.write(JSON.stringify({ protocolVersion: "0.1", type: "completed", at: new Date().toISOString(), taskId: request.taskId, status: "success" }) + "\\n");
119+
});
120+
`);
121+
122+
const { events, result } = await collectEvents(
123+
new DevAgentAdapter(`${process.execPath} ${stubPath}`),
124+
createRequest("devagent"),
125+
workspacePath,
126+
artifactDir,
127+
);
128+
129+
assert.equal(result.status, "success");
130+
assert.deepEqual(events.map((event) => event.type), ["started", "artifact", "completed"]);
131+
});
132+
95133
test("DevAgentAdapter reports cancelled runs as cancelled", async () => {
96134
const { root, artifactDir, workspacePath } = await createWorkspace();
97135
const stubPath = join(root, "devagent-cancel-stub.js");

packages/adapters/src/index.ts

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,17 @@ import type {
1616
} from "@devagent-sdk/types";
1717
import { PROTOCOL_VERSION } from "@devagent-sdk/types";
1818

19+
async function waitForFile(path: string, timeoutMs = 500): Promise<boolean> {
20+
const deadline = Date.now() + timeoutMs;
21+
while (Date.now() <= deadline) {
22+
if (existsSync(path)) {
23+
return true;
24+
}
25+
await new Promise((resolve) => setTimeout(resolve, 25));
26+
}
27+
return existsSync(path);
28+
}
29+
1930
function artifactFileName(kind: ArtifactKind): string {
2031
switch (kind) {
2132
case "triage-report":
@@ -243,12 +254,20 @@ class CliPromptAdapter implements ExecutorAdapter {
243254
reject(new RunnerError("PROCESS_LAUNCH_FAILED", error.message));
244255
});
245256

246-
child.once("exit", async (code: number | null, signal: NodeJS.Signals | null) => {
257+
let exitCode: number | null = null;
258+
let exitSignal: NodeJS.Signals | null = null;
259+
260+
child.once("exit", (code: number | null, signal: NodeJS.Signals | null) => {
261+
exitCode = code;
262+
exitSignal = signal;
263+
});
264+
265+
child.once("close", async () => {
247266
try {
248267
const body = this.config.parseOutput
249268
? await this.config.parseOutput(stdout, artifactDir)
250269
: stdout || stderr || `Executor ${this.id} completed without output.`;
251-
const status = signal === "SIGTERM" ? "cancelled" : code === 0 ? "success" : "failed";
270+
const status = exitSignal === "SIGTERM" ? "cancelled" : exitCode === 0 ? "success" : "failed";
252271
const result = await createFallbackResult(
253272
request,
254273
artifactDir,
@@ -345,18 +364,29 @@ export class DevAgentAdapter implements ExecutorAdapter {
345364

346365
const resultPromise = new Promise<TaskExecutionResult>((resolve, reject) => {
347366
child.once("error", (error: Error) => reject(new RunnerError("PROCESS_LAUNCH_FAILED", error.message)));
348-
child.once("exit", async (code: number | null, signal: NodeJS.Signals | null) => {
367+
let exitCode: number | null = null;
368+
let exitSignal: NodeJS.Signals | null = null;
369+
370+
child.once("exit", (code: number | null, signal: NodeJS.Signals | null) => {
371+
exitCode = code;
372+
exitSignal = signal;
373+
});
374+
375+
child.once("close", async () => {
349376
try {
350377
const resultPath = join(artifactDir, "result.json");
351-
if (!existsSync(resultPath)) {
352-
const status = signal === "SIGTERM" ? "cancelled" : "failed";
378+
if (!await waitForFile(resultPath)) {
379+
const status = exitSignal === "SIGTERM" ? "cancelled" : "failed";
353380
const fallback = await createFallbackResult(
354381
request,
355382
artifactDir,
356383
status,
357384
startedAt,
358385
stderr || "DevAgent did not emit a result file.",
359-
errorForStatus(status, signal === "SIGTERM" ? "Cancelled by operator" : (stderr || "Missing result.json")),
386+
errorForStatus(
387+
status,
388+
exitSignal === "SIGTERM" ? "Cancelled by operator" : (stderr || "Missing result.json"),
389+
),
360390
);
361391
resolve(fallback);
362392
return;

0 commit comments

Comments
 (0)