DEV Community

Sam Rivera
Sam Rivera

Posted on

Build the Failure Path Before You Trust an AI Task CLI

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));
Enter fullscreen mode Exit fullscreen mode

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")'
Enter fullscreen mode Exit fullscreen mode

Failure:

node run-task.mjs 2000 failure.json -- \
  node -e 'console.error("tests failed"); process.exit(7)'
Enter fullscreen mode Exit fullscreen mode

Timeout:

node run-task.mjs 100 timeout.json -- \
  node -e 'setTimeout(() => console.log("too late"), 2000)'
Enter fullscreen mode Exit fullscreen mode

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": ""
}
Enter fullscreen mode Exit fullscreen mode

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)