@aiaiai-pt/martha-cli 0.29.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@ All notable changes to `@aiaiai-pt/martha-cli`. Format: [Keep a Changelog](https
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.30.0] — 2026-07-03
8
+
9
+ ### Added — #761 runner heartbeat + poll-based cancel
10
+ - `martha runner` now runs each task's goal **asynchronously with a heartbeat loop** (every `min(lease_timeout_s/3, 60)`s). Heartbeats renew the lease, so a task longer than its lease is no longer reclaimed by the lease reaper while still executing on the host (the Slice-0 double-execution hazard).
11
+ - Cancelling a claimed task (`martha tasks cancel <id>` or the admin route) now actually stops it: the heartbeat reply carries `signal: "cancel"`, the runner kills the goal's **entire process tree** (SIGTERM the group, SIGKILL after 5s), and reports the partial output with `status: "cancelled"` — accepted idempotently server-side. Worst-case kill latency is one heartbeat interval (≤60s).
12
+ - Heartbeat failures (network blips, 409s) never affect the running job — only an explicit `signal: "cancel"` kills.
13
+
14
+ ### Changed
15
+ - The runner no longer "backgrounds" goals after 20s (a Slice-0 artifact that reported long tasks as `completed` while they were still running) — it now waits for real completion, kept alive by the heartbeat loop.
16
+
17
+ <!-- 0.29.0 shipped 2026-07-02 (#751 asks + trigger-tasking) without a CHANGELOG section — see git history. -->
18
+
7
19
  ## [0.28.0] — 2026-06-30
8
20
 
9
21
  ### Added — #714 Slice 1 external-agent `event.emit` binding surfaces
package/dist/index.js CHANGED
@@ -11063,7 +11063,7 @@ init_config();
11063
11063
  init_errors();
11064
11064
 
11065
11065
  // src/version.ts
11066
- var CLI_VERSION = "0.29.0";
11066
+ var CLI_VERSION = "0.30.0";
11067
11067
 
11068
11068
  // src/commands/sessions.ts
11069
11069
  function relativeTime(iso) {
@@ -11421,6 +11421,68 @@ async function runHostCommand(command, toolCallId, opts) {
11421
11421
  function safeName(s) {
11422
11422
  return s.replace(/[^a-zA-Z0-9_.-]/g, "_").slice(0, 120) || "task";
11423
11423
  }
11424
+ async function startHostCommand(command, toolCallId, opts) {
11425
+ const dir = runnerDir(opts.cwd);
11426
+ await fs5.mkdir(dir, { recursive: true });
11427
+ const logPath = path4.join(dir, `${safeName(toolCallId)}.log`);
11428
+ await fs5.writeFile(logPath, "");
11429
+ const fd = openSync(logPath, "a");
11430
+ const cap = opts.maxOutputBytes ?? MAX_OUTPUT;
11431
+ const readLog = async () => {
11432
+ try {
11433
+ const buf = await fs5.readFile(logPath);
11434
+ return buf.subarray(-cap).toString("utf8");
11435
+ } catch {
11436
+ return "";
11437
+ }
11438
+ };
11439
+ const child = spawn(command, {
11440
+ shell: true,
11441
+ cwd: opts.cwd,
11442
+ detached: true,
11443
+ stdio: ["ignore", fd, fd]
11444
+ });
11445
+ closeSync(fd);
11446
+ let exited = false;
11447
+ const done = new Promise((resolve, reject) => {
11448
+ let settled = false;
11449
+ child.on("error", (e) => {
11450
+ if (settled)
11451
+ return;
11452
+ settled = true;
11453
+ exited = true;
11454
+ reject(e);
11455
+ });
11456
+ child.on("close", async (code) => {
11457
+ if (settled)
11458
+ return;
11459
+ settled = true;
11460
+ exited = true;
11461
+ resolve({
11462
+ stdout: await readLog(),
11463
+ stderr: "",
11464
+ exit_code: code ?? -1,
11465
+ log_path: logPath
11466
+ });
11467
+ });
11468
+ });
11469
+ const kill = (graceMs = 5000) => {
11470
+ if (exited || !child.pid)
11471
+ return;
11472
+ try {
11473
+ process.kill(-child.pid, "SIGTERM");
11474
+ } catch {}
11475
+ const escalate = setTimeout(() => {
11476
+ if (exited || !child.pid)
11477
+ return;
11478
+ try {
11479
+ process.kill(-child.pid, "SIGKILL");
11480
+ } catch {}
11481
+ }, graceMs);
11482
+ escalate.unref?.();
11483
+ };
11484
+ return { pid: child.pid, done, kill };
11485
+ }
11424
11486
  function mintExecSecret() {
11425
11487
  return randomBytes2(32).toString("hex");
11426
11488
  }
@@ -17152,6 +17214,17 @@ init_errors();
17152
17214
  function sleep5(ms) {
17153
17215
  return new Promise((r) => setTimeout(r, ms));
17154
17216
  }
17217
+ function sleepUnref(ms) {
17218
+ return new Promise((r) => {
17219
+ const t = setTimeout(r, ms);
17220
+ t.unref?.();
17221
+ });
17222
+ }
17223
+ var DEFAULT_LEASE_S = 300;
17224
+ function heartbeatIntervalMs(leaseTimeoutS) {
17225
+ const lease = typeof leaseTimeoutS === "number" && leaseTimeoutS > 0 ? leaseTimeoutS : DEFAULT_LEASE_S;
17226
+ return Math.min(lease / 3, 60) * 1000;
17227
+ }
17155
17228
  async function runOnce(ctx, opts, handled) {
17156
17229
  const tasks = await ctx.api.get("/api/tasks/poll", {
17157
17230
  params: { agent_id: opts.agent, limit: String(opts.pollLimit ?? 5) }
@@ -17169,20 +17242,55 @@ async function runOnce(ctx, opts, handled) {
17169
17242
  handled?.add(task.id);
17170
17243
  const claimed = await ctx.api.post(`/api/tasks/${encodeURIComponent(task.id)}/claim`, {});
17171
17244
  const goal = claimed.goal ?? task.goal ?? "";
17245
+ const intervalMs = heartbeatIntervalMs(claimed.lease_timeout_s ?? DEFAULT_LEASE_S);
17172
17246
  process.stderr.write(source_default.dim(`
17173
- [runner] claimed ${task.id} — running: ${goal}
17247
+ [runner] claimed ${task.id} — running: ${goal} (heartbeat every ${Math.round(intervalMs / 1000)}s)
17174
17248
  `));
17175
17249
  let result;
17250
+ let cancelled = false;
17176
17251
  try {
17177
- result = await runHostCommand(goal, `task-${task.id}`, { cwd: opts.cwd });
17252
+ const handle = await startHostCommand(goal, `task-${task.id}`, {
17253
+ cwd: opts.cwd
17254
+ });
17255
+ const done = handle.done.catch((e) => ({
17256
+ stdout: "",
17257
+ stderr: String(e),
17258
+ exit_code: -1
17259
+ }));
17260
+ for (;; ) {
17261
+ const raced = await Promise.race([
17262
+ done,
17263
+ sleepUnref(intervalMs).then(() => null)
17264
+ ]);
17265
+ if (raced !== null) {
17266
+ result = raced;
17267
+ break;
17268
+ }
17269
+ let reply;
17270
+ try {
17271
+ reply = await ctx.api.post(`/api/tasks/${encodeURIComponent(task.id)}/heartbeat`, {});
17272
+ } catch (e) {
17273
+ process.stderr.write(source_default.yellow(`[runner] heartbeat failed for ${task.id} (job unaffected): ${String(e)}
17274
+ `));
17275
+ continue;
17276
+ }
17277
+ if (reply && reply.signal === "cancel") {
17278
+ process.stderr.write(source_default.yellow(`[runner] cancel signal for ${task.id} — killing process tree
17279
+ `));
17280
+ handle.kill();
17281
+ result = await done;
17282
+ cancelled = true;
17283
+ break;
17284
+ }
17285
+ }
17178
17286
  } catch (e) {
17179
17287
  result = { stdout: "", stderr: String(e), exit_code: -1 };
17180
17288
  }
17181
17289
  const exit_code = result.exit_code;
17182
17290
  const stdout = result.stdout || result.stderr || "";
17183
- const status = exit_code === 0 ? "completed" : "failed";
17291
+ const status = cancelled ? "cancelled" : exit_code === 0 ? "completed" : "failed";
17184
17292
  await ctx.api.post(`/api/tasks/${encodeURIComponent(task.id)}/complete`, {
17185
- outcome: { stdout, exit_code },
17293
+ outcome: cancelled ? { stdout, exit_code, cancelled: true } : { stdout, exit_code },
17186
17294
  status
17187
17295
  });
17188
17296
  process.stderr.write(source_default.dim(`[runner] completed ${task.id} — ${status} (exit ${exit_code})
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiaiai-pt/martha-cli",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "Terminal-first client for the Martha AI platform",
5
5
  "homepage": "https://docs.martha.nomadriver.co",
6
6
  "repository": {