@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 +12 -0
- package/dist/index.js +113 -5
- 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
|
@@ -11063,7 +11063,7 @@ init_config();
|
|
|
11063
11063
|
init_errors();
|
|
11064
11064
|
|
|
11065
11065
|
// src/version.ts
|
|
11066
|
-
var CLI_VERSION = "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
|
-
|
|
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})
|