From e7b01612c84f43fc394beecfcdbe2a2e92862a9b Mon Sep 17 00:00:00 2001 From: Stefano Fiorini Date: Tue, 19 May 2026 20:29:35 -0500 Subject: [PATCH] feat(M2): Background Job Manager --- tools/ai-cli-dispatch/SKILL.md | 29 +++++++++ tools/ai-cli-dispatch/src/jobs.ts | 4 +- tools/ai-cli-dispatch/tests/jobs.test.ts | 75 ++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 1 deletion(-) diff --git a/tools/ai-cli-dispatch/SKILL.md b/tools/ai-cli-dispatch/SKILL.md index 4d7efa4..db86cee 100644 --- a/tools/ai-cli-dispatch/SKILL.md +++ b/tools/ai-cli-dispatch/SKILL.md @@ -45,6 +45,35 @@ The skill searches for the following clients in order: Run `list` to see which clients are installed and their resolved versions. +## Background Jobs + +For long-running or fire-and-forget tasks, use the programmatic job API instead of `exec`: + +```typescript +import { startJob, getJob, cancelJob, listJobs, cleanupJobs } from "./src/jobs.js"; + +// Start a detached job +const job = await startJob("codex", "refactor auth module", { timeoutMs: 300_000 }); +console.log(job.id); // e.g. "a1b2c3d4..." +console.log(job.status); // "running" + +// Poll for completion +const latest = getJob(job.id); +console.log(latest.status); // "running" | "completed" | "failed" | "timed_out" | "cancelled" + +// Cancel a running job +cancelJob(job.id); + +// List all jobs (newest first) +const jobs = listJobs(); // Job[] +const running = listJobs({ filter: "running" }); + +// Clean up job files older than 24 hours (default) +cleanupJobs({ maxAgeMs: 24 * 60 * 60 * 1000 }); +``` + +Job files are stored under `~/.openclaw/ai-cli-dispatch/jobs/.json` and include stdout, stderr, exit code, and timing. + ## Output Rules - Normal JSON output redacts local file paths and credential metadata. diff --git a/tools/ai-cli-dispatch/src/jobs.ts b/tools/ai-cli-dispatch/src/jobs.ts index e6c8792..a027cff 100644 --- a/tools/ai-cli-dispatch/src/jobs.ts +++ b/tools/ai-cli-dispatch/src/jobs.ts @@ -87,7 +87,9 @@ export async function startJob( error: `Unknown client: ${client}`, }; writeJobFile(jobDir, errRecord, fs); - return { id: jobId, client, prompt, status: "failed", startedAt, error: errRecord.error }; + return new Promise((resolve) => + resolve({ id: jobId, client, prompt, status: "failed", startedAt, error: errRecord.error }) + ); } const args = argBuilder(prompt); diff --git a/tools/ai-cli-dispatch/tests/jobs.test.ts b/tools/ai-cli-dispatch/tests/jobs.test.ts index ad7efa6..96999ad 100644 --- a/tools/ai-cli-dispatch/tests/jobs.test.ts +++ b/tools/ai-cli-dispatch/tests/jobs.test.ts @@ -394,6 +394,37 @@ describe("cancelJob", () => { (err: unknown) => err instanceof JobNotFoundError ); }); + + it("is a no-op when job is not running", () => { + const fs = createMockFs(); + const jobDir = "/tmp/jobs"; + const now = new Date().toISOString(); + const completedRecord: JobRecord = { + id: "job-completed", + client: "codex", + prompt: "done", + status: "completed", + startedAt: now, + completedAt: now, + stdout: "ok", + stderr: "", + result: { + stdout: "ok", + stderr: "", + exitCode: 0, + client: "codex", + durationMs: 100, + }, + }; + + fs.mkdirSync(jobDir, { recursive: true }); + fs.writeFileSync(`${jobDir}/job-completed.json`, JSON.stringify(completedRecord)); + + cancelJob("job-completed", { jobDir, fs }); + + const record = readJobRecord(fs, `${jobDir}/job-completed.json`); + assert.strictEqual(record.status, "completed"); + }); }); describe("listJobs", () => { @@ -425,6 +456,43 @@ describe("listJobs", () => { assert.strictEqual(jobs[1].id, job1.id); }); + it("returns empty array when jobDir does not exist", () => { + const fs = createMockFs(); + const jobs = listJobs({ jobDir: "/tmp/jobs", fs }); + assert.deepStrictEqual(jobs, []); + }); + + it("ignores corrupt or unreadable job files", () => { + const fs = createMockFs(); + const jobDir = "/tmp/jobs"; + const now = new Date().toISOString(); + const validRecord: JobRecord = { + id: "job-valid", + client: "codex", + prompt: "ok", + status: "completed", + startedAt: now, + completedAt: now, + stdout: "ok", + stderr: "", + result: { + stdout: "ok", + stderr: "", + exitCode: 0, + client: "codex", + durationMs: 100, + }, + }; + + fs.mkdirSync(jobDir, { recursive: true }); + fs.writeFileSync(`${jobDir}/job-valid.json`, JSON.stringify(validRecord)); + fs.writeFileSync(`${jobDir}/job-corrupt.json`, "not-json"); + + const jobs = listJobs({ jobDir, fs }); + assert.strictEqual(jobs.length, 1); + assert.strictEqual(jobs[0].id, "job-valid"); + }); + it("filters jobs by status when filter is provided", () => { const fs = createMockFs(); const jobDir = "/tmp/jobs"; @@ -555,4 +623,11 @@ describe("cleanupJobs", () => { assert.strictEqual(fs.existsSync(path), false); }); + + it("is a no-op when jobDir does not exist", () => { + const fs = createMockFs(); + // Should not throw + cleanupJobs({ jobDir: "/tmp/jobs", fs }); + assert.strictEqual(fs.existsSync("/tmp/jobs"), false); + }); });