The first version of an AI task runner usually has one feature: run a command and stream whatever it prints.
That is enough for a demo. It is not enough to answer the questions that arrive after the demo: Did it finish? Did it time out? Which command actually ran? Was the empty output a success, a crash, or a canceled process?
Before adding a queue, dashboard, or agent framework, I want a tiny contract around the failure path. The wrapper below runs any command, enforces a deadline, preserves a bounded output tail, and writes one JSON record that another tool can inspect.
The contract
The runner should make these outcomes different:
| Outcome | Process exit | Record |
|---|---|---|
| command succeeded | 0 |
exitCode: 0, timedOut: false
|
| command failed | command's code | stderr tail and code |
| deadline expired | 124 |
timedOut: true, terminating signal |
| invalid runner usage | 64 |
usage message |
Exit 124 follows the convention used by GNU timeout. The JSON file is not a complete audit log; it is the smallest durable handoff from a local process to CI, a UI, or a future scheduler.
A 50-line Node.js wrapper
Save this as run-task.mjs:
import { spawn } from "node:child_process";
import { writeFile } from "node:fs/promises";
const [timeoutText, outputPath, separator, command, ...args] = process.argv.slice(2);
const timeoutMs = Number(timeoutText);
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || separator !== "--" || !command) {
console.error("usage: node run-task.mjs <timeout-ms> <record.json> -- <command> [args...]");
process.exit(64);
}
const startedAt = new Date();
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
let timedOut = false;
child.stdout.on("data", (chunk) => (stdout += chunk));
child.stderr.on("data", (chunk) => (stderr += chunk));
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
}, timeoutMs);
const result = await new Promise((resolve, reject) => {
child.once("error", reject);
child.once("close", (code, signal) => resolve({ code, signal }));
});
clearTimeout(timer);
const record = {
command: [command, ...args],
startedAt: startedAt.toISOString(),
durationMs: Date.now() - startedAt.getTime(),
timedOut,
exitCode: result.code,
signal: result.signal,
stdoutTail: stdout.slice(-2000),
stderrTail: stderr.slice(-2000),
};
await writeFile(outputPath, `${JSON.stringify(record, null, 2)}\n`);
console.log(JSON.stringify({ outputPath, timedOut, exitCode: result.code }));
process.exit(timedOut ? 124 : (result.code ?? 1));
The command and arguments go through spawn() without a shell. That avoids a second round of shell parsing. It does not make an untrusted command safe; deciding which executable may run is a separate policy.
Exercise all three paths
Success:
node run-task.mjs 2000 success.json -- \
node -e 'console.log("tests passed")'
Failure:
node run-task.mjs 2000 failure.json -- \
node -e 'console.error("tests failed"); process.exit(7)'
Timeout:
node run-task.mjs 100 timeout.json -- \
node -e 'setTimeout(() => console.log("too late"), 2000)'
On macOS with Node.js v22.22.3, I verified exit codes 0, 7, and 124. The timeout record looked like this, with timestamps and duration omitted here because they vary:
{
"timedOut": true,
"exitCode": null,
"signal": "SIGTERM",
"stdoutTail": "",
"stderrTail": ""
}
The deliberately missing features
This wrapper has no process-tree cleanup. A child that starts detached grandchildren can outlive it. It also buffers output in memory before keeping the final 2,000 characters, so it is unsuitable for unbounded logs. Production improvements should include:
- run the task in a container or process group with a real kill boundary;
- stream logs to bounded storage rather than accumulating them;
- redact secrets before persistence;
- write records atomically through a temporary file and rename;
- add a task ID, source revision, working-directory identity, and policy version;
- distinguish user cancellation from deadline expiration;
- emit a heartbeat for tasks expected to run for minutes.
That list is also a useful stopping rule. Once I need shared workspaces, identity, concurrent tasks, previews, and team-visible history, the “small wrapper” is becoming a platform.
Where MonkeyCode enters the decision
MonkeyCode documents managed development environments and AI task management for teams, including a self-hosted option. That makes it relevant when the requirement moves from “wrap my local command” to “coordinate development tasks across people and controlled environments.” The little runner above is not a MonkeyCode integration or benchmark; it is a way to make the operational questions visible before evaluating a larger system.
Disclosure: I contribute to the MonkeyCode project. The product description above is based on its public repository documentation; the runnable CLI test is independent.
If that team-workspace threshold sounds familiar, the MonkeyCode Discord is the direct place to discuss fit and self-hosting. Interested readers can also ask the team about currently available free model credits and confirm the applicable eligibility and limits.
The wrapper is intentionally boring. That is the point. A durable failure record is more useful than a clever success animation when the task that mattered disappears at 2 a.m.
Top comments (0)