@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.
- package/CHANGELOG.md +12 -0
- package/dist/index.js +163 -23
- 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
|
-
|
|
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
|
-
|
|
6841
|
-
|
|
6842
|
-
|
|
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.
|
|
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
|
-
|
|
17156
|
-
|
|
17157
|
-
|
|
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
|
|
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.
|
|
17253
|
+
if (!opts.allowHostExec) {
|
|
17164
17254
|
process.stderr.write(source_default.yellow(`
|
|
17165
|
-
[runner] refused task ${task.id} — re-run with --
|
|
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
|
-
|
|
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.").
|
|
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
|
-
|
|
17343
|
+
allowHostExec: !!(opts.allowHostExec || opts.yes)
|
|
17208
17344
|
};
|
|
17209
|
-
|
|
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);
|