feat(M2): Background Job Manager
This commit is contained in:
@@ -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.
|
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/<jobId>.json` and include stdout, stderr, exit code, and timing.
|
||||||
|
|
||||||
## Output Rules
|
## Output Rules
|
||||||
|
|
||||||
- Normal JSON output redacts local file paths and credential metadata.
|
- Normal JSON output redacts local file paths and credential metadata.
|
||||||
|
|||||||
@@ -87,7 +87,9 @@ export async function startJob(
|
|||||||
error: `Unknown client: ${client}`,
|
error: `Unknown client: ${client}`,
|
||||||
};
|
};
|
||||||
writeJobFile(jobDir, errRecord, fs);
|
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);
|
const args = argBuilder(prompt);
|
||||||
|
|||||||
@@ -394,6 +394,37 @@ describe("cancelJob", () => {
|
|||||||
(err: unknown) => err instanceof JobNotFoundError
|
(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", () => {
|
describe("listJobs", () => {
|
||||||
@@ -425,6 +456,43 @@ describe("listJobs", () => {
|
|||||||
assert.strictEqual(jobs[1].id, job1.id);
|
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", () => {
|
it("filters jobs by status when filter is provided", () => {
|
||||||
const fs = createMockFs();
|
const fs = createMockFs();
|
||||||
const jobDir = "/tmp/jobs";
|
const jobDir = "/tmp/jobs";
|
||||||
@@ -555,4 +623,11 @@ describe("cleanupJobs", () => {
|
|||||||
|
|
||||||
assert.strictEqual(fs.existsSync(path), false);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user