@aiaiai-pt/martha-cli 0.29.0 → 0.31.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.
Files changed (3) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/index.js +163 -23
  3. package/package.json +1 -1
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
@@ -3528,6 +3528,7 @@ var require_stringify = __commonJS((exports) => {
3528
3528
  nullStr: "null",
3529
3529
  simpleKeys: false,
3530
3530
  singleQuote: null,
3531
+ trailingComma: false,
3531
3532
  trueStr: "true",
3532
3533
  verifyAliasOrder: true
3533
3534
  }, doc.schema.toStringOptions, options);
@@ -4036,13 +4037,20 @@ ${indent}${line}` : `
4036
4037
  if (comment)
4037
4038
  reqNewline = true;
4038
4039
  let str = stringify.stringify(item, itemCtx, () => comment = null);
4039
- if (i < items.length - 1)
4040
+ reqNewline || (reqNewline = lines.length > linesAtValue || str.includes(`
4041
+ `));
4042
+ if (i < items.length - 1) {
4040
4043
  str += ",";
4044
+ } else if (ctx.options.trailingComma) {
4045
+ if (ctx.options.lineWidth > 0) {
4046
+ reqNewline || (reqNewline = lines.reduce((sum, line) => sum + line.length + 2, 2) + (str.length + 2) > ctx.options.lineWidth);
4047
+ }
4048
+ if (reqNewline) {
4049
+ str += ",";
4050
+ }
4051
+ }
4041
4052
  if (comment)
4042
4053
  str += stringifyComment.lineComment(str, itemIndent, commentString(comment));
4043
- if (!reqNewline && (lines.length > linesAtValue || str.includes(`
4044
- `)))
4045
- reqNewline = true;
4046
4054
  lines.push(str);
4047
4055
  linesAtValue = lines.length;
4048
4056
  }
@@ -6837,17 +6845,22 @@ var require_compose_node = __commonJS((exports) => {
6837
6845
  case "block-map":
6838
6846
  case "block-seq":
6839
6847
  case "flow-collection":
6840
- node = composeCollection.composeCollection(CN, ctx, token, props, onError);
6841
- if (anchor)
6842
- node.anchor = anchor.source.substring(1);
6848
+ try {
6849
+ node = composeCollection.composeCollection(CN, ctx, token, props, onError);
6850
+ if (anchor)
6851
+ node.anchor = anchor.source.substring(1);
6852
+ } catch (error) {
6853
+ const message = error instanceof Error ? error.message : String(error);
6854
+ onError(token, "RESOURCE_EXHAUSTION", message);
6855
+ }
6843
6856
  break;
6844
6857
  default: {
6845
6858
  const message = token.type === "error" ? token.message : `Unsupported token (type: ${token.type})`;
6846
6859
  onError(token, "UNEXPECTED_TOKEN", message);
6847
- node = composeEmptyNode(ctx, token.offset, undefined, null, props, onError);
6848
6860
  isSrcToken = false;
6849
6861
  }
6850
6862
  }
6863
+ node ?? (node = composeEmptyNode(ctx, token.offset, undefined, null, props, onError));
6851
6864
  if (anchor && node.anchor === "")
6852
6865
  onError(anchor, "BAD_ALIAS", "Anchor cannot be an empty string");
6853
6866
  if (atKey && ctx.options.stringKeys && (!identity.isScalar(node) || typeof node.value !== "string" || node.tag && node.tag !== "tag:yaml.org,2002:str")) {
@@ -11063,7 +11076,7 @@ init_config();
11063
11076
  init_errors();
11064
11077
 
11065
11078
  // src/version.ts
11066
- var CLI_VERSION = "0.29.0";
11079
+ var CLI_VERSION = "0.31.0";
11067
11080
 
11068
11081
  // src/commands/sessions.ts
11069
11082
  function relativeTime(iso) {
@@ -11421,6 +11434,68 @@ async function runHostCommand(command, toolCallId, opts) {
11421
11434
  function safeName(s) {
11422
11435
  return s.replace(/[^a-zA-Z0-9_.-]/g, "_").slice(0, 120) || "task";
11423
11436
  }
11437
+ async function startHostCommand(command, toolCallId, opts) {
11438
+ const dir = runnerDir(opts.cwd);
11439
+ await fs5.mkdir(dir, { recursive: true });
11440
+ const logPath = path4.join(dir, `${safeName(toolCallId)}.log`);
11441
+ await fs5.writeFile(logPath, "");
11442
+ const fd = openSync(logPath, "a");
11443
+ const cap = opts.maxOutputBytes ?? MAX_OUTPUT;
11444
+ const readLog = async () => {
11445
+ try {
11446
+ const buf = await fs5.readFile(logPath);
11447
+ return buf.subarray(-cap).toString("utf8");
11448
+ } catch {
11449
+ return "";
11450
+ }
11451
+ };
11452
+ const child = spawn(command, {
11453
+ shell: true,
11454
+ cwd: opts.cwd,
11455
+ detached: true,
11456
+ stdio: ["ignore", fd, fd]
11457
+ });
11458
+ closeSync(fd);
11459
+ let exited = false;
11460
+ const done = new Promise((resolve, reject) => {
11461
+ let settled = false;
11462
+ child.on("error", (e) => {
11463
+ if (settled)
11464
+ return;
11465
+ settled = true;
11466
+ exited = true;
11467
+ reject(e);
11468
+ });
11469
+ child.on("close", async (code) => {
11470
+ if (settled)
11471
+ return;
11472
+ settled = true;
11473
+ exited = true;
11474
+ resolve({
11475
+ stdout: await readLog(),
11476
+ stderr: "",
11477
+ exit_code: code ?? -1,
11478
+ log_path: logPath
11479
+ });
11480
+ });
11481
+ });
11482
+ const kill = (graceMs = 5000) => {
11483
+ if (exited || !child.pid)
11484
+ return;
11485
+ try {
11486
+ process.kill(-child.pid, "SIGTERM");
11487
+ } catch {}
11488
+ const escalate = setTimeout(() => {
11489
+ if (exited || !child.pid)
11490
+ return;
11491
+ try {
11492
+ process.kill(-child.pid, "SIGKILL");
11493
+ } catch {}
11494
+ }, graceMs);
11495
+ escalate.unref?.();
11496
+ };
11497
+ return { pid: child.pid, done, kill };
11498
+ }
11424
11499
  function mintExecSecret() {
11425
11500
  return randomBytes2(32).toString("hex");
11426
11501
  }
@@ -16897,7 +16972,7 @@ ${source_default.red("Error:")}`);
16897
16972
  }
16898
16973
  console.log(`Cancelled task ${id}`);
16899
16974
  });
16900
- cmd.command("poll").description("Poll for open tasks available for claiming").option("--agent <id>", "Filter by agent definition ID").option("--limit <n>", "Max results", "10").action(async (opts) => {
16975
+ cmd.command("poll").description("Poll for open tasks available for claiming").option("--agent <id>", "Filter by agent definition ID (human tokens only; ignored under agent auth)").option("--team <name>", "Poll a team's queue (name or UUID)").option("--limit <n>", "Max results", "10").action(async (opts) => {
16901
16976
  const ctx = getCtx();
16902
16977
  const limitN = parseInt(opts.limit, 10);
16903
16978
  if (isNaN(limitN) || limitN < 1) {
@@ -16906,6 +16981,8 @@ ${source_default.red("Error:")}`);
16906
16981
  const params = { limit: String(limitN) };
16907
16982
  if (opts.agent)
16908
16983
  params.agent_id = opts.agent;
16984
+ if (opts.team)
16985
+ params.team_id = opts.team;
16909
16986
  const items = await ctx.api.get("/api/tasks/poll", { params });
16910
16987
  if (isJson()) {
16911
16988
  console.log(JSON.stringify(items, null, 2));
@@ -17152,45 +17229,99 @@ init_errors();
17152
17229
  function sleep5(ms) {
17153
17230
  return new Promise((r) => setTimeout(r, ms));
17154
17231
  }
17155
- async function runOnce(ctx, opts, handled) {
17156
- const tasks = await ctx.api.get("/api/tasks/poll", {
17157
- params: { agent_id: opts.agent, limit: String(opts.pollLimit ?? 5) }
17232
+ function sleepUnref(ms) {
17233
+ return new Promise((r) => {
17234
+ const t = setTimeout(r, ms);
17235
+ t.unref?.();
17158
17236
  });
17237
+ }
17238
+ var DEFAULT_LEASE_S = 300;
17239
+ function heartbeatIntervalMs(leaseTimeoutS) {
17240
+ const lease = typeof leaseTimeoutS === "number" && leaseTimeoutS > 0 ? leaseTimeoutS : DEFAULT_LEASE_S;
17241
+ return Math.min(lease / 3, 60) * 1000;
17242
+ }
17243
+ async function runOnce(ctx, opts, handled) {
17244
+ const params = { limit: String(opts.pollLimit ?? 5) };
17245
+ if (opts.team)
17246
+ params.team_id = opts.team;
17247
+ const tasks = await ctx.api.get("/api/tasks/poll", { params });
17159
17248
  const candidates = Array.isArray(tasks) ? tasks : [];
17160
- const task = candidates.find((t) => (t.status ?? "open") === "open" && !handled?.has(t.id));
17249
+ const matchesAgent = (t) => !opts.agent || t.assigned_agent_id === opts.agent || t.agent_definition_id === opts.agent;
17250
+ const task = candidates.find((t) => (t.status ?? "open") === "open" && !handled?.has(t.id) && matchesAgent(t));
17161
17251
  if (!task)
17162
17252
  return { ran: false };
17163
- if (!opts.autoYes) {
17253
+ if (!opts.allowHostExec) {
17164
17254
  process.stderr.write(source_default.yellow(`
17165
- [runner] refused task ${task.id} — re-run with --yes to allow host execution
17255
+ [runner] refused task ${task.id} — re-run with --allow-host-exec to allow host execution
17166
17256
  `));
17167
17257
  return { ran: false, taskId: task.id, status: "refused" };
17168
17258
  }
17169
17259
  handled?.add(task.id);
17170
17260
  const claimed = await ctx.api.post(`/api/tasks/${encodeURIComponent(task.id)}/claim`, {});
17171
17261
  const goal = claimed.goal ?? task.goal ?? "";
17262
+ const intervalMs = heartbeatIntervalMs(claimed.lease_timeout_s ?? DEFAULT_LEASE_S);
17172
17263
  process.stderr.write(source_default.dim(`
17173
- [runner] claimed ${task.id} — running: ${goal}
17264
+ [runner] claimed ${task.id} — running: ${goal} (heartbeat every ${Math.round(intervalMs / 1000)}s)
17174
17265
  `));
17175
17266
  let result;
17267
+ let cancelled = false;
17176
17268
  try {
17177
- result = await runHostCommand(goal, `task-${task.id}`, { cwd: opts.cwd });
17269
+ const handle = await startHostCommand(goal, `task-${task.id}`, {
17270
+ cwd: opts.cwd
17271
+ });
17272
+ const done = handle.done.catch((e) => ({
17273
+ stdout: "",
17274
+ stderr: String(e),
17275
+ exit_code: -1
17276
+ }));
17277
+ for (;; ) {
17278
+ const raced = await Promise.race([
17279
+ done,
17280
+ sleepUnref(intervalMs).then(() => null)
17281
+ ]);
17282
+ if (raced !== null) {
17283
+ result = raced;
17284
+ break;
17285
+ }
17286
+ let reply;
17287
+ try {
17288
+ reply = await ctx.api.post(`/api/tasks/${encodeURIComponent(task.id)}/heartbeat`, {});
17289
+ } catch (e) {
17290
+ process.stderr.write(source_default.yellow(`[runner] heartbeat failed for ${task.id} (job unaffected): ${String(e)}
17291
+ `));
17292
+ continue;
17293
+ }
17294
+ if (reply && reply.signal === "cancel") {
17295
+ process.stderr.write(source_default.yellow(`[runner] cancel signal for ${task.id} — killing process tree
17296
+ `));
17297
+ handle.kill();
17298
+ result = await done;
17299
+ cancelled = true;
17300
+ break;
17301
+ }
17302
+ }
17178
17303
  } catch (e) {
17179
17304
  result = { stdout: "", stderr: String(e), exit_code: -1 };
17180
17305
  }
17181
17306
  const exit_code = result.exit_code;
17182
17307
  const stdout = result.stdout || result.stderr || "";
17183
- const status = exit_code === 0 ? "completed" : "failed";
17308
+ const status = cancelled ? "cancelled" : exit_code === 0 ? "completed" : "failed";
17184
17309
  await ctx.api.post(`/api/tasks/${encodeURIComponent(task.id)}/complete`, {
17185
- outcome: { stdout, exit_code },
17310
+ outcome: cancelled ? { stdout, exit_code, cancelled: true } : { stdout, exit_code },
17186
17311
  status
17187
17312
  });
17188
17313
  process.stderr.write(source_default.dim(`[runner] completed ${task.id} — ${status} (exit ${exit_code})
17189
17314
  `));
17190
17315
  return { ran: true, taskId: task.id, status, exitCode: exit_code };
17191
17316
  }
17317
+ function startupEchoLine(who, opts) {
17318
+ const identity2 = who ? who.kind === "agent" ? `agent ${who.agent_name ?? "?"} (${(who.agent_id ?? "").slice(0, 8)}…)` : `${who.kind} ${who.sub ?? ""}`.trim() : "identity unknown (whoami unavailable)";
17319
+ const scope = opts.team ? `team:${opts.team}` : "direct";
17320
+ const exec = opts.allowHostExec ? "host exec allowed" : "host exec refused (pass --allow-host-exec)";
17321
+ return `[runner] authenticated as ${identity2}, scope ${scope}, cwd ${opts.cwd}, ${exec}${opts.once ? " — once" : ""}`;
17322
+ }
17192
17323
  function registerRunnerCommand(program2) {
17193
- program2.command("runner").description("Run as an external agent's executor: claim tasks and run them on this host.").requiredOption("--agent <id>", "Agent definition id this runner serves").option("--once", "Claim + run a single task, then exit").option("--poll-interval <ms>", "Poll interval when idle (ms)", "3000").option("--cwd <dir>", "Scoped workspace for task execution", process.cwd()).option("--yes", "Consent to run host commands (required to execute)").action(async (opts) => {
17324
+ program2.command("runner").description("Run as an external agent's executor: claim tasks and run them on this host.").option("--agent <id>", "Deprecated: identity comes from the API token; applied as a client-side filter only").option("--team <name>", "Poll this team's queue (name or UUID) instead of direct-only").option("--once", "Claim + run a single task, then exit").option("--poll-interval <ms>", "Poll interval when idle (ms)", "3000").option("--cwd <dir>", "Scoped workspace for task execution", process.cwd()).option("--allow-host-exec", "Consent to run host commands (required to execute)").option("--yes", "Deprecated alias for --allow-host-exec").action(async (opts) => {
17194
17325
  const ctx = createContext({
17195
17326
  profileOverride: program2.opts().profile,
17196
17327
  verbose: program2.opts().verbose
@@ -17201,12 +17332,21 @@ function registerRunnerCommand(program2) {
17201
17332
  if (isNaN(pollInterval) || pollInterval < 250) {
17202
17333
  throw new CLIError("--poll-interval must be >= 250 (ms)", 4 /* Validation */);
17203
17334
  }
17335
+ if (opts.agent) {
17336
+ process.stderr.write(source_default.yellow(`[runner] --agent is deprecated — identity comes from the API token; the value is applied as a client-side filter only
17337
+ `));
17338
+ }
17204
17339
  const runOpts = {
17205
17340
  agent: opts.agent,
17341
+ team: opts.team,
17206
17342
  cwd: opts.cwd,
17207
- autoYes: !!opts.yes
17343
+ allowHostExec: !!(opts.allowHostExec || opts.yes)
17208
17344
  };
17209
- process.stderr.write(source_default.dim(`[runner] serving agent ${opts.agent} (cwd ${opts.cwd})${opts.once ? " — once" : ""}
17345
+ let who;
17346
+ try {
17347
+ who = await ctx.api.get("/api/tasks/whoami");
17348
+ } catch {}
17349
+ process.stderr.write(source_default.dim(`${startupEchoLine(who, { team: opts.team, cwd: opts.cwd, allowHostExec: runOpts.allowHostExec, once: !!opts.once })}
17210
17350
  `));
17211
17351
  if (opts.once) {
17212
17352
  await runOnce(ctx, runOpts);
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.31.0",
4
4
  "description": "Terminal-first client for the Martha AI platform",
5
5
  "homepage": "https://docs.martha.nomadriver.co",
6
6
  "repository": {