Skip to content

Commit e0a5884

Browse files
authored
Merge pull request #1 from egavrin/codex/mar10-cleanup
fix(runner): validate execution artifacts and standardize tooling
2 parents 879d301 + a2629db commit e0a5884

12 files changed

Lines changed: 677 additions & 85 deletions

File tree

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 DevAgent Hub Contributors
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

bun.lock

Lines changed: 400 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

eslint.config.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import js from "@eslint/js";
2+
import tseslint from "typescript-eslint";
3+
4+
export default tseslint.config(
5+
js.configs.recommended,
6+
...tseslint.configs.recommended,
7+
{
8+
languageOptions: {
9+
globals: {
10+
Buffer: "readonly",
11+
clearTimeout: "readonly",
12+
console: "readonly",
13+
process: "readonly",
14+
setTimeout: "readonly",
15+
},
16+
},
17+
},
18+
{
19+
rules: {
20+
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
21+
"@typescript-eslint/no-explicit-any": "warn",
22+
"no-console": ["warn", { allow: ["error", "warn"] }],
23+
},
24+
},
25+
{
26+
ignores: ["**/dist/**", "**/node_modules/**", "*.config.js"],
27+
},
28+
);

package.json

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,19 @@
99
"scripts": {
1010
"build": "bunx tsc -b packages/core packages/local-runner packages/adapters packages/cli",
1111
"typecheck": "bunx tsc -b --pretty false packages/core packages/local-runner packages/adapters packages/cli",
12-
"test": "bun run build && bun test ./packages/*/dist/*.test.js"
12+
"test": "bun run build && node ./node_modules/vitest/vitest.mjs run --config vitest.config.ts",
13+
"lint": "bunx eslint packages/"
1314
},
1415
"devDependencies": {
16+
"@devagent-sdk/schema": "file:../devagent-sdk/packages/schema",
17+
"@devagent-sdk/types": "file:../devagent-sdk/packages/types",
18+
"@devagent-sdk/validation": "file:../devagent-sdk/packages/validation",
19+
"@eslint/js": "^10.0.1",
1520
"@types/bun": "^1.3.10",
1621
"@types/node": "^24.3.0",
17-
"typescript": "^5.9.3"
22+
"eslint": "^10.0.3",
23+
"typescript": "^5.9.3",
24+
"typescript-eslint": "^8.56.1",
25+
"vitest": "^3.2.4"
1826
}
1927
}

packages/adapters/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
},
1313
"dependencies": {
1414
"@devagent-runner/core": "file:../core",
15+
"@devagent-sdk/validation": "file:../../../devagent-sdk/packages/validation",
1516
"@devagent-sdk/types": "file:../../../devagent-sdk/packages/types"
1617
},
1718
"scripts": {

packages/adapters/src/index.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import test from "node:test";
21
import assert from "node:assert/strict";
32
import { join } from "node:path";
43
import { chmod, mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
54
import { tmpdir } from "node:os";
5+
import { test } from "vitest";
66
import {
77
ClaudeAdapter,
88
CodexAdapter,
@@ -155,6 +155,28 @@ setTimeout(() => process.exit(0), 10000);
155155
assert.equal(events.length, 0);
156156
});
157157

158+
test("DevAgentAdapter emits failure events when result.json is missing", async () => {
159+
const { root, artifactDir, workspacePath } = await createWorkspace();
160+
const stubPath = join(root, "devagent-missing-result-stub.js");
161+
await createStub(stubPath, `#!/usr/bin/env node
162+
process.stderr.write("result file was never written\\n");
163+
process.exit(1);
164+
`);
165+
166+
const { events, result } = await collectEvents(
167+
new DevAgentAdapter(`${process.execPath} ${stubPath}`),
168+
createRequest("devagent"),
169+
workspacePath,
170+
artifactDir,
171+
);
172+
173+
assert.equal(result.status, "failed");
174+
assert.equal(result.error?.code, "EXECUTION_FAILED");
175+
assert.deepEqual(events.map((event) => event.type), ["log", "log", "artifact", "completed"]);
176+
assert.equal(events[0]?.type, "log");
177+
assert.match(events[0]?.type === "log" ? events[0].message : "", /result file was never written/);
178+
});
179+
158180
test("CodexAdapter smoke test with stub executable", async () => {
159181
const { root, artifactDir, workspacePath } = await createWorkspace();
160182
const stubPath = join(root, "codex-stub.js");

packages/adapters/src/index.ts

Lines changed: 49 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { spawn } from "node:child_process";
2-
import { existsSync } from "node:fs";
3-
import { mkdir, readFile, writeFile } from "node:fs/promises";
2+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
43
import { join } from "node:path";
54
import { randomUUID } from "node:crypto";
65
import type { ChildProcess } from "node:child_process";
7-
import type { ExecutorAdapter, RunHandle } from "@devagent-runner/core";
8-
import { RunnerError } from "@devagent-runner/core";
6+
import type { ExecutorAdapter, RunHandle, RunStatus } from "@devagent-runner/core";
7+
import { RunnerError, TrackedRunHandle } from "@devagent-runner/core";
8+
import { validateTaskExecutionEvent, validateTaskExecutionResult } from "@devagent-sdk/validation";
99
import type {
1010
ArtifactKind,
1111
ArtifactRef,
@@ -19,12 +19,21 @@ import { PROTOCOL_VERSION } from "@devagent-sdk/types";
1919
async function waitForFile(path: string, timeoutMs = 500): Promise<boolean> {
2020
const deadline = Date.now() + timeoutMs;
2121
while (Date.now() <= deadline) {
22-
if (existsSync(path)) {
22+
if (await fileExists(path)) {
2323
return true;
2424
}
2525
await new Promise((resolve) => setTimeout(resolve, 25));
2626
}
27-
return existsSync(path);
27+
return fileExists(path);
28+
}
29+
30+
async function fileExists(path: string): Promise<boolean> {
31+
try {
32+
await stat(path);
33+
return true;
34+
} catch {
35+
return false;
36+
}
2837
}
2938

3039
function artifactFileName(kind: ArtifactKind): string {
@@ -67,33 +76,17 @@ function artifactTitle(taskType: TaskExecutionRequest["taskType"]): string {
6776
return taskType[0]!.toUpperCase() + taskType.slice(1);
6877
}
6978

70-
class ProcessRunHandle implements RunHandle {
71-
private currentStatus: "running" | "success" | "failed" | "cancelled" = "running";
72-
readonly pid: number | undefined;
73-
79+
class ProcessRunHandle extends TrackedRunHandle {
7480
constructor(
7581
readonly id: string,
7682
private readonly child: ChildProcess,
77-
private readonly resultPromise: Promise<TaskExecutionResult>,
83+
resultPromise: Promise<TaskExecutionResult>,
7884
) {
79-
this.pid = child.pid ?? undefined;
80-
void this.resultPromise.then((result) => {
81-
this.currentStatus = result.status;
82-
}).catch(() => {
83-
this.currentStatus = "failed";
84-
});
85-
}
86-
87-
status(): "running" | "success" | "failed" | "cancelled" {
88-
return this.currentStatus;
89-
}
90-
91-
wait(): Promise<TaskExecutionResult> {
92-
return this.resultPromise;
85+
super(id, child.pid ?? undefined, resultPromise);
9386
}
9487

9588
async cancel(): Promise<void> {
96-
this.currentStatus = "cancelled";
89+
this.markCancelled();
9790
this.child.kill("SIGTERM");
9891
}
9992
}
@@ -155,7 +148,7 @@ async function createFallbackResult(
155148
}
156149

157150
function errorForStatus(
158-
status: TaskExecutionResult["status"],
151+
status: RunStatus,
159152
message: string,
160153
): TaskExecutionResult["error"] | undefined {
161154
if (status === "success") {
@@ -335,7 +328,7 @@ export class DevAgentAdapter implements ExecutorAdapter {
335328
const lines = chunk.toString().split("\n").filter((line: string) => line.trim());
336329
for (const line of lines) {
337330
try {
338-
onEvent(JSON.parse(line) as TaskExecutionEvent);
331+
onEvent(validateTaskExecutionEvent(JSON.parse(line)));
339332
} catch {
340333
onEvent({
341334
protocolVersion: PROTOCOL_VERSION,
@@ -364,19 +357,17 @@ export class DevAgentAdapter implements ExecutorAdapter {
364357

365358
const resultPromise = new Promise<TaskExecutionResult>((resolve, reject) => {
366359
child.once("error", (error: Error) => reject(new RunnerError("PROCESS_LAUNCH_FAILED", error.message)));
367-
let exitCode: number | null = null;
368360
let exitSignal: NodeJS.Signals | null = null;
369361

370-
child.once("exit", (code: number | null, signal: NodeJS.Signals | null) => {
371-
exitCode = code;
362+
child.once("exit", (_code: number | null, signal: NodeJS.Signals | null) => {
372363
exitSignal = signal;
373364
});
374365

375366
child.once("close", async () => {
376367
try {
377368
const resultPath = join(artifactDir, "result.json");
378369
if (!await waitForFile(resultPath)) {
379-
const status = exitSignal === "SIGTERM" ? "cancelled" : "failed";
370+
const status: RunStatus = exitSignal === "SIGTERM" ? "cancelled" : "failed";
380371
const fallback = await createFallbackResult(
381372
request,
382373
artifactDir,
@@ -388,10 +379,34 @@ export class DevAgentAdapter implements ExecutorAdapter {
388379
exitSignal === "SIGTERM" ? "Cancelled by operator" : (stderr || "Missing result.json"),
389380
),
390381
);
382+
if (status === "failed") {
383+
onEvent({
384+
protocolVersion: PROTOCOL_VERSION,
385+
type: "log",
386+
at: new Date().toISOString(),
387+
taskId: request.taskId,
388+
stream: "stderr",
389+
message: stderr || "DevAgent did not emit result.json",
390+
} as TaskExecutionEvent);
391+
onEvent({
392+
protocolVersion: PROTOCOL_VERSION,
393+
type: "artifact",
394+
at: new Date().toISOString(),
395+
taskId: request.taskId,
396+
artifact: fallback.artifacts[0]!,
397+
} as TaskExecutionEvent);
398+
onEvent({
399+
protocolVersion: PROTOCOL_VERSION,
400+
type: "completed",
401+
at: new Date().toISOString(),
402+
taskId: request.taskId,
403+
status,
404+
} as TaskExecutionEvent);
405+
}
391406
resolve(fallback);
392407
return;
393408
}
394-
const parsed = JSON.parse(await readFile(resultPath, "utf-8")) as TaskExecutionResult;
409+
const parsed = validateTaskExecutionResult(JSON.parse(await readFile(resultPath, "utf-8")));
395410
resolve(parsed);
396411
} catch (error) {
397412
reject(error);
@@ -421,7 +436,7 @@ export class CodexAdapter extends CliPromptAdapter {
421436
],
422437
parseOutput: async (stdout, artifactDir) => {
423438
const lastMessagePath = join(artifactDir, "last-message.txt");
424-
if (existsSync(lastMessagePath)) {
439+
if (await fileExists(lastMessagePath)) {
425440
return readFile(lastMessagePath, "utf-8");
426441
}
427442
return stdout;

packages/core/src/index.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,42 @@ export interface WorkspaceManager {
1515

1616
export interface RunHandle {
1717
readonly id: string;
18+
readonly pid?: number;
1819
status(): RunStatus;
1920
wait(): Promise<TaskExecutionResult>;
2021
cancel(): Promise<void>;
2122
}
2223

24+
export abstract class TrackedRunHandle implements RunHandle {
25+
private currentStatus: RunStatus = "running";
26+
27+
constructor(
28+
readonly id: string,
29+
readonly pid: number | undefined,
30+
private readonly resultPromise: Promise<TaskExecutionResult>,
31+
) {
32+
void this.resultPromise.then((result) => {
33+
this.currentStatus = result.status;
34+
}).catch(() => {
35+
this.currentStatus = "failed";
36+
});
37+
}
38+
39+
status(): RunStatus {
40+
return this.currentStatus;
41+
}
42+
43+
wait(): Promise<TaskExecutionResult> {
44+
return this.resultPromise;
45+
}
46+
47+
protected markCancelled(): void {
48+
this.currentStatus = "cancelled";
49+
}
50+
51+
abstract cancel(): Promise<void>;
52+
}
53+
2354
export interface ExecutorAdapter {
2455
executorId(): string;
2556
canHandle(spec: ExecutorSpec): boolean;

packages/local-runner/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
},
1313
"dependencies": {
1414
"@devagent-runner/core": "file:../core",
15+
"@devagent-sdk/validation": "file:../../../devagent-sdk/packages/validation",
1516
"@devagent-sdk/types": "file:../../../devagent-sdk/packages/types"
1617
},
1718
"scripts": {

0 commit comments

Comments
 (0)