@irythill/orq 0.5.0 → 0.6.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/README.md +56 -38
- package/README.pt-BR.md +57 -38
- package/dist/agents.js +49 -5
- package/dist/cli.js +3 -1
- package/dist/commands/agent.js +116 -20
- package/dist/commands/board.js +90 -20
- package/dist/commands/claim.js +2 -0
- package/dist/commands/completion.js +1 -1
- package/dist/commands/conductor.js +104 -0
- package/dist/commands/gc.js +48 -10
- package/dist/commands/handoff.js +1 -0
- package/dist/commands/wait.js +5 -1
- package/dist/config.js +2 -1
- package/dist/notice.js +33 -5
- package/dist/process.js +7 -0
- package/dist/stats.js +2 -1
- package/package.json +1 -1
- package/skills/issue/SKILL.md +41 -41
- package/templates/workspace.CLAUDE.md +6 -5
package/dist/commands/agent.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// the follow-up into it; with the screen closed it reopens the session (tui_resume).
|
|
11
11
|
import { spawnSync } from "node:child_process";
|
|
12
12
|
import { randomUUID } from "node:crypto";
|
|
13
|
-
import { appendFileSync, copyFileSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { appendFileSync, copyFileSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
14
14
|
import { basename, dirname, join, resolve } from "node:path";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import { claudeAccounts } from "../accounts.js";
|
|
@@ -21,7 +21,7 @@ import { writeAtomic } from "../fsutil.js";
|
|
|
21
21
|
import { m } from "../i18n.js";
|
|
22
22
|
import { alive } from "../lock.js";
|
|
23
23
|
import { stateDir } from "../paths.js";
|
|
24
|
-
import { killTree, parseSize, resolveMode, runCapped } from "../process.js";
|
|
24
|
+
import { killedRc, killTree, parseSize, resolveMode, runCapped } from "../process.js";
|
|
25
25
|
import { closeOwnTab, detectHost, launch, screenOpen, shq, typeInto } from "../terminal.js";
|
|
26
26
|
import { ackPrefix } from "../notice.js";
|
|
27
27
|
import { dropWorktree, notify } from "../testrun.js";
|
|
@@ -130,6 +130,22 @@ export async function agent(argv) {
|
|
|
130
130
|
return 2;
|
|
131
131
|
}
|
|
132
132
|
const repo = resolve(d), O = resolve(o);
|
|
133
|
+
// A live run on this prefix owns its files: a second agent on the same files (and its .done) is how a false DONE happens.
|
|
134
|
+
// Only a -T -R follow-up typed into that run's own open screen may reuse it.
|
|
135
|
+
let open = "", pid = 0;
|
|
136
|
+
try {
|
|
137
|
+
open = readFileSync(`${O}.pane`, "utf8").trim();
|
|
138
|
+
}
|
|
139
|
+
catch { /* first run */ }
|
|
140
|
+
try {
|
|
141
|
+
pid = Number(readFileSync(`${O}.pid`, "utf8"));
|
|
142
|
+
}
|
|
143
|
+
catch { /* none or ended */ }
|
|
144
|
+
const typed = tui && resume && alive(pid) && screenOpen(open);
|
|
145
|
+
if (!typed && runLive(O, pid)) {
|
|
146
|
+
console.error(m(`${basename(O)} still has a live run (${pid ? `pid ${pid}` : "unit active"}): wait for it (orq wait ${O}) or stop it, or use another prefix. Nothing started.`, `${basename(O)} ainda tem uma execução viva (${pid ? `pid ${pid}` : "unit ativa"}): espere (orq wait ${O}) ou pare, ou use outro prefixo. Nada foi iniciado.`));
|
|
147
|
+
return 3;
|
|
148
|
+
}
|
|
133
149
|
// Fix rounds: every -R on the same session counts, whatever the prefix is called. policy.max_rounds is enforced here,
|
|
134
150
|
// not left to the conductor to remember (a 7-round fix loop is what burns the tokens). -F = the user said go on.
|
|
135
151
|
if (resume) {
|
|
@@ -185,13 +201,7 @@ export async function agent(argv) {
|
|
|
185
201
|
// Follow-ups (-R) reuse the prefix: remove the previous round's result so ".done exists" means "this one finished".
|
|
186
202
|
// -T -R with the screen still open: the follow-up is typed into it (its runner, and .pid, carry on). Open = the agent
|
|
187
203
|
// is still running there, not just the pane: after it exits the pane may hold a shell, which must never get the text.
|
|
188
|
-
|
|
189
|
-
try {
|
|
190
|
-
open = readFileSync(`${O}.pane`, "utf8").trim();
|
|
191
|
-
pid = Number(readFileSync(`${O}.pid`, "utf8"));
|
|
192
|
-
}
|
|
193
|
-
catch { /* first run or ended */ }
|
|
194
|
-
if (tui && resume && alive(pid) && screenOpen(open)) {
|
|
204
|
+
if (typed) {
|
|
195
205
|
ackPrefix(O); // a follow-up on the issue: its failure notices were seen
|
|
196
206
|
for (const s of [".done", ".out.md"])
|
|
197
207
|
rmSync(O + s, { force: true });
|
|
@@ -264,7 +274,8 @@ export async function agent(argv) {
|
|
|
264
274
|
swap: parseSize(mach.agent_swap_max), mode: mach.mem_enforce ?? "auto",
|
|
265
275
|
throwaway, promptInDir: Boolean(def.prompt_in_dir), closeAfter,
|
|
266
276
|
keep: process.env.HERDR_KEEP_TAB === "1" || process.env.ORQ_KEEP_TAB === "1" || closeAfter <= 0, tui,
|
|
267
|
-
okExit: def.ok_exit, filter: def.filter, noReview: def.no_review,
|
|
277
|
+
okExit: def.ok_exit, filter: def.filter, noReview: def.no_review, ro,
|
|
278
|
+
idleCloseMin: tui ? Number(mach.tui_idle_close_min ?? 30) : undefined,
|
|
268
279
|
};
|
|
269
280
|
writeAtomic(`${O}.job.json`, JSON.stringify(job, null, 1));
|
|
270
281
|
// Readable copy of the command (also what orq stats and herdr-board read).
|
|
@@ -276,6 +287,20 @@ export async function agent(argv) {
|
|
|
276
287
|
console.log(`[orq agent] ${t} → ${where}${tui ? " (TUI)" : ""}`);
|
|
277
288
|
return 0;
|
|
278
289
|
}
|
|
290
|
+
/** The previous run on prefix `O` is still going: its runner pid is alive, or (no pid file yet / runner gone) its systemd scope is. */
|
|
291
|
+
function runLive(O, pid) {
|
|
292
|
+
if (alive(pid))
|
|
293
|
+
return true;
|
|
294
|
+
if (existsSync(`${O}.done`) || process.platform !== "linux")
|
|
295
|
+
return false; // finished (or no systemd): the pid said it all
|
|
296
|
+
try {
|
|
297
|
+
const unit = JSON.parse(readFileSync(`${O}.job.json`, "utf8")).unit;
|
|
298
|
+
return Boolean(unit) && spawnSync("systemctl", ["--user", "is-active", "--quiet", `${unit}.scope`], { timeout: 5_000 }).status === 0;
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
279
304
|
/** OpenCode (v2) plugin for -T: when the main session's run ends, its last answer goes to `orq agent --turn`. */
|
|
280
305
|
function opencodePlugin(O, hook) {
|
|
281
306
|
return `// orq agent -T end of turn (generated)
|
|
@@ -392,6 +417,26 @@ function noReviewMatch(pattern, answer) {
|
|
|
392
417
|
return re.test(first) || (answer.trim().length <= 200 && re.test(answer));
|
|
393
418
|
}
|
|
394
419
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
420
|
+
/** A -T agent is idle when its turn ended (.done exists) and, for `idleMs`, nothing touched .done (a follow-up removes
|
|
421
|
+
* it, a turn's end rewrites it) and the screen was not seen busy (`busyAt`, epoch ms, from herdr's agent status). */
|
|
422
|
+
export function idleExpired(done, idleMs, now = Date.now(), busyAt = 0) {
|
|
423
|
+
try {
|
|
424
|
+
return idleMs > 0 && now - Math.max(statSync(done).mtimeMs, busyAt) >= idleMs;
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
/** herdr's view of the agent on a pane: working | blocked | idle | done | unknown; undefined when it can't be read. */
|
|
431
|
+
function paneStatus(pane) {
|
|
432
|
+
const r = spawnSync("herdr", ["pane", "get", pane], { encoding: "utf8", timeout: 5_000 });
|
|
433
|
+
try {
|
|
434
|
+
return r.status === 0 ? JSON.parse(r.stdout).result?.pane?.agent_status : undefined;
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
return undefined;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
395
440
|
/** Splits a stream into lines for `each`. */
|
|
396
441
|
function lines(s, each) {
|
|
397
442
|
return new Promise((res) => {
|
|
@@ -420,6 +465,8 @@ async function runJob(file) {
|
|
|
420
465
|
state();
|
|
421
466
|
const fmt = FORMATS[j.format] ?? FORMATS.text;
|
|
422
467
|
let rc = 0, stdout = "", logText = "";
|
|
468
|
+
// State of the worktree before the run, to tell what this run changed (a -R on a dirty tree is not progress).
|
|
469
|
+
const headBefore = j.throwaway ? undefined : git(j.dir, "rev-parse", "HEAD"), statusBefore = j.throwaway ? undefined : git(j.dir, "status", "--porcelain");
|
|
423
470
|
const pre = () => {
|
|
424
471
|
if (j.throwaway) {
|
|
425
472
|
// Throwaway worktree: detached copy of HEAD with the repo's node_modules linked; removed at the end.
|
|
@@ -457,20 +504,26 @@ async function runJob(file) {
|
|
|
457
504
|
if (j.tui) {
|
|
458
505
|
// The agent's own screen: the terminal is its; answers come from the end-of-turn hook (`--turn`).
|
|
459
506
|
let ended = false;
|
|
460
|
-
const end = (code) => {
|
|
507
|
+
const end = (code, killed = false) => {
|
|
461
508
|
if (ended)
|
|
462
509
|
return "fail";
|
|
463
510
|
ended = true;
|
|
464
511
|
post();
|
|
465
512
|
// Closed before any turn ended (or mid follow-up): a failure, so `orq wait` doesn't hang.
|
|
466
|
-
const ok = /\bstatus=ok\b/.test(existsSync(`${O}.done`) ? readFileSync(`${O}.done`, "utf8") : "");
|
|
467
|
-
if (
|
|
513
|
+
const ok = !killed && /\bstatus=ok\b/.test(existsSync(`${O}.done`) ? readFileSync(`${O}.done`, "utf8") : "");
|
|
514
|
+
if (killed) {
|
|
515
|
+
// SIGKILL & co: whatever an earlier turn wrote, the screen did not end normally.
|
|
516
|
+
writeAtomic(`${O}.done`, `status=killed rc=${code} at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
|
|
517
|
+
if (j.parent)
|
|
518
|
+
notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=killed rc=${code} answer: ${O}.out.md`);
|
|
519
|
+
}
|
|
520
|
+
else if (!existsSync(`${O}.done`)) {
|
|
468
521
|
writeAtomic(`${O}.done`, `status=fail rc=${code} at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
|
|
469
522
|
if (j.parent)
|
|
470
523
|
notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=fail rc=${code} answer: ${O}.out.md`);
|
|
471
524
|
}
|
|
472
525
|
rmSync(`${O}.pid`, { force: true });
|
|
473
|
-
return ok ? "ok" : "fail";
|
|
526
|
+
return killed ? "killed" : ok ? "ok" : "fail";
|
|
474
527
|
};
|
|
475
528
|
if (!pre())
|
|
476
529
|
rc = 2;
|
|
@@ -480,16 +533,46 @@ async function runJob(file) {
|
|
|
480
533
|
delete env[k];
|
|
481
534
|
// Ctrl+C belongs to the agent (it reaches us too, as part of the terminal's foreground group). The screen going
|
|
482
535
|
// away (tab or tmux server closed) still ends the agent and cleans up.
|
|
483
|
-
let child;
|
|
536
|
+
let child, idleT, idleClosed = false;
|
|
484
537
|
process.on("SIGINT", () => { });
|
|
538
|
+
// The screen going away or being killed from outside: end the agent, and close the tab too (a dead tab stayed open).
|
|
485
539
|
const gone = () => { if (child)
|
|
486
|
-
killTree(child, false); end(129); process.exit(0); };
|
|
540
|
+
killTree(child, false); end(129); closeOwnTab(j.host); process.exit(0); };
|
|
487
541
|
for (const sig of ["SIGHUP", "SIGTERM"])
|
|
488
542
|
process.on(sig, gone);
|
|
489
543
|
rc = await runCapped(j.argv[0], j.argv.slice(1), { cap: j.cap, swap: j.swap, mode: j.mode, unit: j.unit, cwd: j.dir, env, quote: true, tty: true,
|
|
490
|
-
onSpawn: (c) => {
|
|
544
|
+
onSpawn: (c) => {
|
|
545
|
+
child = c.pid;
|
|
546
|
+
// A finished agent waits for a follow-up forever and holds its memory: after the idle timeout without a new
|
|
547
|
+
// turn, kill it; the normal end below cleans up and closes the tab. "New turn" = .done removed (`-R` typed
|
|
548
|
+
// into the screen removes it) or rewritten (the next end of turn), so idle = .done untouched that long.
|
|
549
|
+
const idleMs = (j.idleCloseMin ?? 0) * 60_000;
|
|
550
|
+
if (idleMs > 0 && !j.keep) {
|
|
551
|
+
// On herdr the pane's agent status also catches a turn typed straight into the screen (.done only changes
|
|
552
|
+
// when it ends). Elsewhere (tmux) only .done tells: a turn typed directly into the screen that runs longer
|
|
553
|
+
// than the timeout can still be closed.
|
|
554
|
+
let busyAt = 0;
|
|
555
|
+
const pane = j.host === "herdr" ? process.env.HERDR_PANE_ID : undefined;
|
|
556
|
+
idleT = setInterval(() => {
|
|
557
|
+
const st = pane ? paneStatus(pane) : undefined;
|
|
558
|
+
if (st === "working" || st === "blocked")
|
|
559
|
+
busyAt = Date.now();
|
|
560
|
+
if (!idleExpired(`${O}.done`, idleMs, Date.now(), busyAt))
|
|
561
|
+
return;
|
|
562
|
+
clearInterval(idleT);
|
|
563
|
+
console.log(m(`[orq agent] no follow-up for ${j.idleCloseMin} min: closing the finished agent`, `[orq agent] sem follow-up há ${j.idleCloseMin} min: fechando o agente que terminou`));
|
|
564
|
+
idleClosed = true;
|
|
565
|
+
if (child)
|
|
566
|
+
killTree(child, false);
|
|
567
|
+
}, Math.min(30_000, Math.max(1_000, idleMs / 2)));
|
|
568
|
+
idleT.unref();
|
|
569
|
+
}
|
|
570
|
+
} });
|
|
571
|
+
clearInterval(idleT);
|
|
572
|
+
if (idleClosed)
|
|
573
|
+
rc = 0; // closed on purpose after a finished turn: not a kill
|
|
491
574
|
}
|
|
492
|
-
const st = end(rc);
|
|
575
|
+
const st = end(rc, killedRc(rc));
|
|
493
576
|
state({ status: st, rc, end: new Date().toISOString() });
|
|
494
577
|
console.log(`\n[${j.agent} (TUI) ${m("exited", "encerrado")}] ${m("answer", "resposta")}: ${O}.out.md`);
|
|
495
578
|
if (!j.keep) {
|
|
@@ -552,12 +635,24 @@ async function runJob(file) {
|
|
|
552
635
|
await new Promise((r) => log.end(r));
|
|
553
636
|
post();
|
|
554
637
|
const res = fmt.finish(logText, stdout);
|
|
638
|
+
// Denied commands do not sink a write run that did work: keep the diff and report ok with the denied list in the answer.
|
|
639
|
+
let recovered = false;
|
|
640
|
+
if (res.denied?.length && !j.ro && !j.throwaway && existsSync(j.dir)) {
|
|
641
|
+
const status = git(j.dir, "status", "--porcelain"), head = git(j.dir, "rev-parse", "HEAD");
|
|
642
|
+
if (status !== statusBefore || head !== headBefore) {
|
|
643
|
+
const stat = (headBefore && git(j.dir, "diff", "--stat", headBefore)) || status || `HEAD ${headBefore?.slice(0, 7)} -> ${head?.slice(0, 7)}`;
|
|
644
|
+
recovered = true;
|
|
645
|
+
res.answer = `${res.answer ?? ""}\n## Worktree\nSome commands were denied, but the worktree changes are kept (review them before accepting):\n\`\`\`\n${stat}\n\`\`\`\n`;
|
|
646
|
+
}
|
|
647
|
+
else
|
|
648
|
+
res.answer = `${res.answer ?? ""}\nNo changes were made in the worktree.\n`;
|
|
649
|
+
}
|
|
555
650
|
if (j.answer !== "file")
|
|
556
651
|
writeFileSync(`${O}.out.md`, res.answer ?? "");
|
|
557
652
|
const sid = j.fromLog ? new RegExp(j.fromLog, "m").exec(logText)?.[1] : res.session ?? j.session;
|
|
558
653
|
if (sid)
|
|
559
654
|
writeFileSync(`${O}.id`, `${sid}\n`);
|
|
560
|
-
if (rc === 0 && res.rc)
|
|
655
|
+
if (rc === 0 && res.rc && !recovered)
|
|
561
656
|
rc = res.rc;
|
|
562
657
|
// A review tool exits non-zero on findings: an ok_exit code is a verdict (rc stays in .done), not a failure.
|
|
563
658
|
let verdict = rc === 0 || (j.okExit ?? []).includes(rc);
|
|
@@ -578,7 +673,8 @@ async function runJob(file) {
|
|
|
578
673
|
// A reviewer whose answer says nothing was reviewed (agents.<name>.no_review) is not a verdict either: pick's
|
|
579
674
|
// fallback and the conductor must see it as not run, same as a whitespace-only answer.
|
|
580
675
|
const noReview = noReviewMatch(j.noReview, answer);
|
|
581
|
-
|
|
676
|
+
// killed (SIGKILL, memory cap...) is its own status: never ok, even if an ok_exit list or a leftover answer says otherwise.
|
|
677
|
+
const st = killedRc(rc) ? "killed" : verdict && !noReview && /\S/.test(answer) ? "ok" : "fail"; // whitespace-only answer = failure
|
|
582
678
|
writeAtomic(`${O}.done`, `status=${st} rc=${rc} at=${new Date().toISOString()} agent=${j.agent}\n`);
|
|
583
679
|
rmSync(`${O}.pid`, { force: true });
|
|
584
680
|
state({ status: st, rc, end: new Date().toISOString() });
|
package/dist/commands/board.js
CHANGED
|
@@ -19,7 +19,7 @@ import { m, setLang } from "../i18n.js";
|
|
|
19
19
|
import { argvModel } from "../agents.js";
|
|
20
20
|
import { alive, status as lockStatus } from "../lock.js";
|
|
21
21
|
import { expand, stateDir } from "../paths.js";
|
|
22
|
-
import { processTableAsync, treeMem } from "../process.js";
|
|
22
|
+
import { killedRc, processTableAsync, treeMem, treePids } from "../process.js";
|
|
23
23
|
import { collect } from "../quota.js";
|
|
24
24
|
import { collectRounds, mark, roundsSig } from "../rounds.js";
|
|
25
25
|
import { collectRuns, rows } from "../stats.js";
|
|
@@ -29,7 +29,7 @@ import { herdrNamesAsync, nameOf } from "./claim.js";
|
|
|
29
29
|
import { setValue } from "./set.js";
|
|
30
30
|
export const USAGE = "orq board [-w <workspace>]... [--once] [--json] [--interval S] live board: queue, agents, conductors, quota";
|
|
31
31
|
const CLI = fileURLToPath(new URL("../cli.js", import.meta.url));
|
|
32
|
-
const QTTL = 300_000,
|
|
32
|
+
const QTTL = 300_000, HIDE_H = 6; // finished/dead rows older than ui.board_hide_hours (default 6) are folded into a count
|
|
33
33
|
const read = (f) => { try {
|
|
34
34
|
return readFileSync(f, "utf8");
|
|
35
35
|
}
|
|
@@ -91,9 +91,47 @@ function tail(file, bytes = 16_384) {
|
|
|
91
91
|
closeSync(fd);
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
|
+
// --- idle detection -----------------------------------------------------------------------------------------------
|
|
95
|
+
// An interactive (-T) agent that finished keeps its screen, and its pid, open: "running" for hours. Its tree's CPU time
|
|
96
|
+
// (/proc/<pid>/stat, utime+stime) barely moves while it only waits. Samples persist in <state>/board/cpu.json so a
|
|
97
|
+
// one-shot board and a restarted one see the same history: per unit, the CPU reading when it last did real work.
|
|
98
|
+
const IDLE_MIN = 30, ACTIVE_TICKS = 200; // 2 s of CPU (at 100 ticks/s) since the baseline counts as work
|
|
99
|
+
/** utime+stime, in clock ticks, summed over `pids` (Linux; undefined elsewhere or when nothing is readable). */
|
|
100
|
+
export function cpuTicks(pids) {
|
|
101
|
+
if (process.platform !== "linux")
|
|
102
|
+
return undefined;
|
|
103
|
+
let sum;
|
|
104
|
+
for (const p of pids) {
|
|
105
|
+
// comm (field 2) may hold spaces and parens: fields resume after the last ")"; state is field 3, utime/stime 14/15.
|
|
106
|
+
const raw = read(`/proc/${p}/stat`), f = raw.slice(raw.lastIndexOf(")") + 2).split(" ");
|
|
107
|
+
const t = Number(f[11]) + Number(f[12]);
|
|
108
|
+
if (!Number.isNaN(t))
|
|
109
|
+
sum = (sum ?? 0) + t;
|
|
110
|
+
}
|
|
111
|
+
return sum;
|
|
112
|
+
}
|
|
113
|
+
/** Seconds `unit` has been idle (no real CPU work since its baseline), or 0 while it is not; updates `base` in place. */
|
|
114
|
+
export function idleFor(base, unit, ticks, now, idleMin = IDLE_MIN) {
|
|
115
|
+
const b = base[unit];
|
|
116
|
+
if (!b || ticks < b.ticks || ticks - b.ticks >= ACTIVE_TICKS) {
|
|
117
|
+
base[unit] = { ticks, at: now };
|
|
118
|
+
return 0;
|
|
119
|
+
} // new, restarted tree, or worked
|
|
120
|
+
return now - b.at >= idleMin * 60_000 ? Math.round((now - b.at) / 1000) : 0;
|
|
121
|
+
}
|
|
122
|
+
const cpuFile = () => join(qdir(), "cpu.json");
|
|
123
|
+
function idleMinutes() {
|
|
124
|
+
const v = Number(getPath(loadConfig().cfg, "ui.board_idle_min") ?? IDLE_MIN);
|
|
125
|
+
return v > 0 ? v : IDLE_MIN;
|
|
126
|
+
}
|
|
94
127
|
function agents(table, now) {
|
|
95
|
-
const d = join(stateDir(), "agents"), out = []
|
|
96
|
-
|
|
128
|
+
const d = join(stateDir(), "agents"), out = [], running = new Set(), cpu = (() => { try {
|
|
129
|
+
return JSON.parse(read(cpuFile()));
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return {};
|
|
133
|
+
} })();
|
|
134
|
+
let idleMin, files = [];
|
|
97
135
|
try {
|
|
98
136
|
files = readdirSync(d).filter((f) => f.endsWith(".json"));
|
|
99
137
|
}
|
|
@@ -108,24 +146,43 @@ function agents(table, now) {
|
|
|
108
146
|
}
|
|
109
147
|
const pid = Number(s.pid), at = Date.parse(String(s.at)), end = s.end ? Date.parse(String(s.end)) : undefined;
|
|
110
148
|
const prefix = String(s.prefix ?? "");
|
|
111
|
-
const st = s.status === "ok" || s.status === "fail" ? s.status : alive(pid) ? "running" : "died";
|
|
112
|
-
if (st === "died" && now - at > ORPHAN_MS)
|
|
113
|
-
continue;
|
|
114
|
-
if ((st === "ok" || st === "fail") && now - (end ?? at) > RECENT_MS)
|
|
115
|
-
continue;
|
|
149
|
+
const st = s.status === "ok" && killedRc(Number(s.rc)) ? "killed" : s.status === "ok" || s.status === "fail" || s.status === "killed" ? s.status : alive(pid) ? "running" : "died";
|
|
116
150
|
const a = { unit: f.slice(0, -5), title: String(s.title ?? ""), agent: String(s.agent ?? "?"), model: typeof s.model === "string" ? s.model : modelOf(prefix, f.slice(0, -5)),
|
|
117
151
|
host: s.host, pid, issue: basename(dirname(prefix)), label: basename(prefix), state: st,
|
|
118
|
-
secs: Math.max(0, Math.round(((end ?? now) - at) / 1000)), cap: typeof s.cap === "number" ? s.cap : undefined };
|
|
152
|
+
secs: Math.max(0, Math.round(((end ?? now) - at) / 1000)), age: Math.max(0, Math.round((now - (end ?? at)) / 1000)), cap: typeof s.cap === "number" ? s.cap : undefined };
|
|
119
153
|
if (s.rc !== undefined)
|
|
120
154
|
a.rc = Number(s.rc);
|
|
121
155
|
if (st === "running") {
|
|
122
|
-
if (table)
|
|
156
|
+
if (table) {
|
|
123
157
|
a.mem = treeMem(pid, table);
|
|
158
|
+
const t = cpuTicks(treePids(pid, table));
|
|
159
|
+
if (t !== undefined) {
|
|
160
|
+
running.add(a.unit);
|
|
161
|
+
const i = idleFor(cpu, a.unit, t, now, idleMin ??= idleMinutes());
|
|
162
|
+
if (i) {
|
|
163
|
+
a.state = "idle";
|
|
164
|
+
a.idleSecs = i;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
124
168
|
a.activity = activity(tail(`${prefix}.log`))?.replace(/\s+/g, " ").replace(homedir(), "~").slice(0, 60);
|
|
125
169
|
}
|
|
126
170
|
out.push(a);
|
|
127
171
|
}
|
|
128
|
-
|
|
172
|
+
if (table) { // keep baselines of units still running; the rest are done or gone
|
|
173
|
+
for (const u of Object.keys(cpu))
|
|
174
|
+
if (!running.has(u))
|
|
175
|
+
delete cpu[u];
|
|
176
|
+
const text = JSON.stringify(cpu);
|
|
177
|
+
if (text !== read(cpuFile())) {
|
|
178
|
+
try {
|
|
179
|
+
mkdirSync(qdir(), { recursive: true });
|
|
180
|
+
writeAtomic(cpuFile(), text);
|
|
181
|
+
}
|
|
182
|
+
catch { /* best effort */ }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const rank = { running: 0, idle: 1, died: 1, killed: 2, fail: 2, ok: 3 };
|
|
129
186
|
return out.sort((x, y) => rank[x.state] - rank[y.state] || y.secs - x.secs);
|
|
130
187
|
}
|
|
131
188
|
const field = (t, k) => new RegExp(`${k}=(\\S+)`).exec(t)?.[1] ?? "";
|
|
@@ -310,7 +367,12 @@ const when = (sec) => {
|
|
|
310
367
|
};
|
|
311
368
|
const gb = (b) => (b ? `${(b / 2 ** 30).toFixed(1)} GB` : "-");
|
|
312
369
|
const fit = (s, n) => (s.length > n ? s.slice(0, Math.max(0, n - 1)) + "…" : s.padEnd(n));
|
|
313
|
-
|
|
370
|
+
/** Hours after which finished/dead agents leave the board (they stay in --json). */
|
|
371
|
+
export function hideHours() {
|
|
372
|
+
const v = Number(getPath(loadConfig().cfg, "ui.board_hide_hours") ?? HIDE_H);
|
|
373
|
+
return v > 0 ? v : HIDE_H;
|
|
374
|
+
}
|
|
375
|
+
export function render(s, theme, cols, mode = colorMode(), hideH = HIDE_H) {
|
|
314
376
|
const c = inks(theme, mode), W = Math.max(60, cols), out = [];
|
|
315
377
|
const meter = (pct, n) => {
|
|
316
378
|
const f = Math.min(n, Math.round(Math.max(0, pct) * n / 100)), k = pct >= 90 ? c.err : pct >= 70 ? c.wrn : c.ok;
|
|
@@ -342,21 +404,29 @@ export function render(s, theme, cols, mode = colorMode()) {
|
|
|
342
404
|
if (!s.queue.holders.length && !s.queue.waiters.length)
|
|
343
405
|
out.push(` ${c.mut}${m("empty", "vazia")}${c.r}`);
|
|
344
406
|
section("TRACKS", m("agents", "agentes"));
|
|
345
|
-
|
|
407
|
+
// A finished or dead agent stops being news after a while; running ones always show. age = since its end (a
|
|
408
|
+
// died row's end is unknown: since its start).
|
|
409
|
+
const old = (a) => a.state !== "running" && a.state !== "idle" && a.age > hideH * 3600;
|
|
410
|
+
const shownAgents = s.agents.filter((a) => !old(a)), hidden = s.agents.length - shownAgents.length;
|
|
411
|
+
shownAgents.forEach((a, i) => {
|
|
346
412
|
const n = `${c.mut}${String(i + 1).padStart(2, "0")}${c.r}`, who = `${c.ink}${fit(a.issue, 9)}${c.r} ${c.ink2}${fit(a.label, 22)}${c.r} `
|
|
347
413
|
+ `${c.ink}${fit(a.agent, 11)}${c.r}${c.mut}${fit(a.model ?? m("default", "padrão"), 17)}${c.r} ${c.ink}${(a.state === "died" ? "?" : dur(a.secs)).padEnd(7)}${c.r}`; // died: when it stopped is unknown
|
|
348
|
-
if (a.state === "running") {
|
|
414
|
+
if (a.state === "running" || a.state === "idle") {
|
|
349
415
|
const pct = a.cap && a.mem ? Math.round(100 * a.mem / a.cap) : 0, used = 1 + 2 + 2 + 10 + 23 + 11 + 17 + 1 + 8 + 6 + 1 + 7 + 1;
|
|
350
|
-
const
|
|
351
|
-
|
|
416
|
+
const idle = a.state === "idle", mark = idle ? `${c.wrn}◌` : `${c.acc}●`;
|
|
417
|
+
const warn = !idle && a.secs > 3 * 3600 ? ` ${c.err}⚠ ${m("for", "há")} ${dur(a.secs)}${c.r}` : "";
|
|
418
|
+
const what = idle ? `${c.wrn}${m("idle", "ocioso")} ${dur(a.idleSecs ?? 0)}` : fit(a.activity ?? "", Math.max(0, W - used - 2)).trimEnd();
|
|
419
|
+
out.push(` ${mark}${c.r} ${n} ${who} ${meter(pct, 4)} ${c.ink2}${gb(a.mem).padEnd(6)}${c.r} ${c.mut}${what}${c.r}${warn}`);
|
|
352
420
|
}
|
|
353
421
|
else if (a.state === "died")
|
|
354
422
|
out.push(` ${c.err}●${c.r} ${n} ${who} ${c.err}${m("runner gone without finishing", "runner sumiu sem terminar")}${c.r}`);
|
|
355
423
|
else
|
|
356
424
|
out.push(` ${a.state === "ok" ? c.ok : c.err}${a.state === "ok" ? "✓" : "✗"}${c.r} ${n} ${c.mut}${a.issue.padEnd(9).slice(0, 9)} ${fit(a.label, 22)} ${fit(a.agent, 11)}${fit(a.model ?? "", 17)} ${dur(a.secs).padEnd(7)} ${a.state} rc=${a.rc ?? "?"}${c.r}`);
|
|
357
425
|
});
|
|
358
|
-
if (!
|
|
426
|
+
if (!shownAgents.length)
|
|
359
427
|
out.push(` ${c.mut}${m("none", "nenhum")}${c.r}`);
|
|
428
|
+
if (hidden)
|
|
429
|
+
out.push(` ${c.mut}${m(`+${hidden} older finished hidden`, `+${hidden} antigos finalizados ocultos`)}${c.r}`);
|
|
360
430
|
section("MASTER", m("conductors", "conductors"));
|
|
361
431
|
for (const k of s.conductors)
|
|
362
432
|
out.push(` ${c.ok}●${c.r} ${c.mut}${fit(k.name || k.id, 22)}${c.r} ${c.ink2}${fit(k.account || "-", 14)}${c.r} ${c.ink}${k.issues.map((i) => (k.rounds?.[i] ? `${i} ${mark(k.rounds[i])}` : i)).join(" ")}${c.r}`);
|
|
@@ -441,7 +511,7 @@ export async function board(argv) {
|
|
|
441
511
|
}
|
|
442
512
|
const tty = Boolean(process.stdout.isTTY);
|
|
443
513
|
if (once || !tty) {
|
|
444
|
-
const lines = render(await snapshot(wss, await processTableAsync()), theme, process.stdout.columns ?? 100);
|
|
514
|
+
const lines = render(await snapshot(wss, await processTableAsync()), theme, process.stdout.columns ?? 100, colorMode(), hideHours());
|
|
445
515
|
const c = inks(theme);
|
|
446
516
|
process.stdout.write(lines.map((l) => c.base + l + (c.base ? "\x1b[K" : "")).join("\n") + (c.base ? "\x1b[0m" : "") + "\n");
|
|
447
517
|
return 0;
|
|
@@ -457,7 +527,7 @@ export async function board(argv) {
|
|
|
457
527
|
}
|
|
458
528
|
drawing = true;
|
|
459
529
|
try {
|
|
460
|
-
const c = inks(theme), lines = render(await snapshot(wss, table), theme, out.columns ?? 100);
|
|
530
|
+
const c = inks(theme), lines = render(await snapshot(wss, table), theme, out.columns ?? 100, colorMode(), hideHours());
|
|
461
531
|
out.write(`${c.base}\x1b[H` + lines.map((l) => c.base + l + "\x1b[K").join("\n") + `${c.base}\x1b[J`);
|
|
462
532
|
}
|
|
463
533
|
finally {
|
package/dist/commands/claim.js
CHANGED
|
@@ -12,6 +12,7 @@ import { basename, join, resolve } from "node:path";
|
|
|
12
12
|
import { writeAtomic } from "../fsutil.js";
|
|
13
13
|
import { m } from "../i18n.js";
|
|
14
14
|
import { acquire, release } from "../lock.js";
|
|
15
|
+
import { handoffFile } from "../notice.js";
|
|
15
16
|
export const USAGE = `orq claim [-w <workspace>] <ID> | -f <ID> | -r <ID> | -l | --register issue owner among conductors (exit 1 = someone else's)
|
|
16
17
|
orq claim [-w <workspace>] --from <name> [-f] a conductor's issues by its name (part of it is enough); -f takes them all`;
|
|
17
18
|
const field = (text, k) => new RegExp(`${k}=(\\S+)`).exec(text)?.[1] ?? "";
|
|
@@ -139,6 +140,7 @@ export async function claim(argv) {
|
|
|
139
140
|
if (mode === "register") {
|
|
140
141
|
if (kind !== "herdr")
|
|
141
142
|
return 0;
|
|
143
|
+
rmSync(handoffFile(me), { force: true }); // this pane is a live conductor again: drop a stale `orq handoff` redirect out of it
|
|
142
144
|
const dir = join(O, ".conductors");
|
|
143
145
|
mkdirSync(dir, { recursive: true });
|
|
144
146
|
writeAtomic(join(dir, me.replace(/[^A-Za-z0-9_-]/g, "_")), `pane=${me} kind=herdr account=${account} at=${new Date().toISOString()} name=${myName || me}\n`);
|
|
@@ -11,7 +11,7 @@ const DESC = {
|
|
|
11
11
|
quota: "quota per agent/account", pick: "pick agent, model and account for a role", stats: "scoreboard per agent", rounds: "rounds, time and repeated findings of an issue",
|
|
12
12
|
board: "live board", lock: "test queue", run: "run a command under a memory cap", agent: "run a child agent",
|
|
13
13
|
wait: "wait for child agents", verify: "tests of a diff", baseline: "tests already failing on the base",
|
|
14
|
-
instructions: "write the /issue playbook", claim: "issue owner among conductors", deps: "link node_modules into a worktree", gc: "remove merged issue worktrees", ci: "CI/CD status per claimed issue", handoff: "move a conductor low on quota to another account",
|
|
14
|
+
instructions: "write the /issue playbook", conductor: "open the conductor (Claude) in this terminal", claim: "issue owner among conductors", deps: "link node_modules into a worktree", gc: "remove merged issue worktrees", ci: "CI/CD status per claimed issue", handoff: "move a conductor low on quota to another account",
|
|
15
15
|
block: "block an agent or account", completion: "shell completion script",
|
|
16
16
|
};
|
|
17
17
|
/** Fixed words after a subcommand. */
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// orq conductor — opens the conductor (Claude Code) in the current terminal, with the workspace's conductor
|
|
2
|
+
// instructions, the chosen account and model (port of setup/herdr-conductor, for any terminal and OS).
|
|
3
|
+
// orq conductor [account|-] [model] [-r <session>]
|
|
4
|
+
// Workspace: the current folder if it has .orquestra-conductor.md, else $CONDUCTOR_WS. $CONDUCTOR_FILE overrides the
|
|
5
|
+
// instructions file. The session is named conductor-<workspace folder>.
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { basename, join, resolve } from "node:path";
|
|
9
|
+
import { claudeAccounts } from "../accounts.js";
|
|
10
|
+
import { m } from "../i18n.js";
|
|
11
|
+
import { runCapped } from "../process.js";
|
|
12
|
+
import { claim } from "./claim.js";
|
|
13
|
+
export const USAGE = `orq conductor [account|-] [model] [-r <session>] open the conductor (Claude) here: instructions, account, model
|
|
14
|
+
model: sonnet | opus | haiku | a full id; "-" as account = default account; -r resumes that conversation`;
|
|
15
|
+
export const MODELS = {
|
|
16
|
+
sonnet: "claude-sonnet-5",
|
|
17
|
+
opus: "claude-opus-5-5",
|
|
18
|
+
haiku: "claude-haiku-4-5-20251001",
|
|
19
|
+
};
|
|
20
|
+
export const modelId = (alias) => MODELS[alias] ?? alias;
|
|
21
|
+
/** [account|-] [model] [-r|--resume <id>]; further positionals are ignored (as herdr-conductor did). */
|
|
22
|
+
export function parseArgs(argv) {
|
|
23
|
+
const out = { account: "", model: "", resume: "", help: false };
|
|
24
|
+
const pos = [];
|
|
25
|
+
for (let i = 0; i < argv.length; i++) {
|
|
26
|
+
const a = argv[i];
|
|
27
|
+
if (a === "-r" || a === "--resume") {
|
|
28
|
+
const v = argv[++i];
|
|
29
|
+
if (!v) {
|
|
30
|
+
out.error = m("give the session id", "informe o id da sessão");
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
out.resume = v;
|
|
34
|
+
}
|
|
35
|
+
else if (a === "-h" || a === "--help")
|
|
36
|
+
out.help = true;
|
|
37
|
+
else
|
|
38
|
+
pos.push(a);
|
|
39
|
+
}
|
|
40
|
+
[out.account = "", out.model = ""] = pos;
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
/** The workspace folder: cwd when it holds .orquestra-conductor.md, else $CONDUCTOR_WS (resolved); undefined if neither. */
|
|
44
|
+
export function findWorkspace(cwd, env, exists = existsSync) {
|
|
45
|
+
if (exists(join(cwd, ".orquestra-conductor.md")))
|
|
46
|
+
return cwd;
|
|
47
|
+
return env.CONDUCTOR_WS ? resolve(cwd, env.CONDUCTOR_WS) : undefined;
|
|
48
|
+
}
|
|
49
|
+
export const instructionsFile = (ws, env) => env.CONDUCTOR_FILE ? resolve(env.CONDUCTOR_FILE) : join(ws, ".orquestra-conductor.md");
|
|
50
|
+
/** The claude command line (without the executable). The instructions go by file, so no shell ever sees their text. */
|
|
51
|
+
export function claudeArgs(ws, a, file) {
|
|
52
|
+
return [...(a.resume ? ["--resume", a.resume] : []), "-n", `conductor-${basename(ws)}`,
|
|
53
|
+
...(a.model ? ["--model", modelId(a.model)] : []), "--append-system-prompt-file", file];
|
|
54
|
+
}
|
|
55
|
+
export async function conductor(argv) {
|
|
56
|
+
const a = parseArgs(argv);
|
|
57
|
+
if (a.help) {
|
|
58
|
+
console.log(USAGE);
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
if (a.error) {
|
|
62
|
+
console.error(a.error);
|
|
63
|
+
return 2;
|
|
64
|
+
}
|
|
65
|
+
const ws = findWorkspace(process.cwd(), process.env);
|
|
66
|
+
if (!ws) {
|
|
67
|
+
console.error(m("no .orquestra-conductor.md here: cd into a workspace (orq setup --workspace <dir>) or set CONDUCTOR_WS", "não há .orquestra-conductor.md aqui: entre num workspace (orq setup --workspace <pasta>) ou defina CONDUCTOR_WS"));
|
|
68
|
+
return 2;
|
|
69
|
+
}
|
|
70
|
+
const file = instructionsFile(ws, process.env);
|
|
71
|
+
if (!existsSync(file)) {
|
|
72
|
+
console.error(`${m("conductor instructions not found", "instruções do conductor não encontradas")}: ${file}`);
|
|
73
|
+
return 2;
|
|
74
|
+
}
|
|
75
|
+
const env = { ...process.env };
|
|
76
|
+
if (a.account && a.account !== "-") {
|
|
77
|
+
const accounts = claudeAccounts(), found = accounts.find((x) => x.name === a.account);
|
|
78
|
+
if (!found) {
|
|
79
|
+
console.error(m(`account '${a.account}' does not exist. Accounts:`, `a conta '${a.account}' não existe. Contas:`));
|
|
80
|
+
for (const x of accounts)
|
|
81
|
+
console.error(`${x.name} ${x.dir}`);
|
|
82
|
+
return 1;
|
|
83
|
+
}
|
|
84
|
+
env.CLAUDE_CONFIG_DIR = found.dir;
|
|
85
|
+
}
|
|
86
|
+
if (process.env.HERDR_ENV === "1" && process.env.HERDR_PANE_ID)
|
|
87
|
+
spawnSync("herdr", ["pane", "rename", process.env.HERDR_PANE_ID, "conductor"], { stdio: "ignore", timeout: 5_000 });
|
|
88
|
+
process.chdir(ws);
|
|
89
|
+
// Registration (.orquestra/.conductors) is a herdr thing (keyed by pane id; `claim --register` does nothing elsewhere),
|
|
90
|
+
// so only inside herdr. claim reads the account from this process's env (best effort, silent).
|
|
91
|
+
const saved = process.env.CLAUDE_CONFIG_DIR;
|
|
92
|
+
if (env.CLAUDE_CONFIG_DIR)
|
|
93
|
+
process.env.CLAUDE_CONFIG_DIR = env.CLAUDE_CONFIG_DIR;
|
|
94
|
+
if (process.env.HERDR_PANE_ID)
|
|
95
|
+
try {
|
|
96
|
+
await claim(["-w", ws, "--register"]);
|
|
97
|
+
}
|
|
98
|
+
catch { /* best effort */ }
|
|
99
|
+
if (saved === undefined)
|
|
100
|
+
delete process.env.CLAUDE_CONFIG_DIR;
|
|
101
|
+
else
|
|
102
|
+
process.env.CLAUDE_CONFIG_DIR = saved;
|
|
103
|
+
return runCapped("claude", claudeArgs(ws, a, file), { mode: "off", env, cwd: ws, quote: true, tty: true });
|
|
104
|
+
}
|
package/dist/commands/gc.js
CHANGED
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
// orq gc — find issue worktrees whose branch is already safe to remove.
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
-
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
4
|
-
import { basename, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
5
|
import { loadConfig } from "../config.js";
|
|
6
6
|
import { m } from "../i18n.js";
|
|
7
|
+
import { alive } from "../lock.js";
|
|
8
|
+
import { stateDir } from "../paths.js";
|
|
7
9
|
import { aliveOwner } from "./claim.js";
|
|
8
|
-
export const USAGE = "orq gc [-w <workspace>] [--yes] [--days N] [--json] remove merged issue worktrees";
|
|
10
|
+
export const USAGE = "orq gc [-w <workspace>] [--yes] [--days N] [--json] remove merged issue worktrees and stale agent state";
|
|
9
11
|
const git = (cwd, ...args) => spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" });
|
|
10
12
|
// Paths as git prints them can differ from ours (macOS /var → /private/var, Windows C:/ vs C:\): compare real paths.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
|
|
13
|
+
// A path that does not exist (yet) is resolved through its deepest existing ancestor, so both sides get the same prefix.
|
|
14
|
+
const real = (p) => {
|
|
15
|
+
const abs = resolve(p);
|
|
16
|
+
try {
|
|
17
|
+
return realpathSync.native(abs);
|
|
18
|
+
}
|
|
19
|
+
catch { /* missing: resolve the parent */ }
|
|
20
|
+
const up = dirname(abs);
|
|
21
|
+
return up === abs ? abs : join(real(up), basename(abs));
|
|
22
|
+
};
|
|
23
|
+
const fold = (p) => (process.platform === "win32" ? p.toLowerCase() : p); // NTFS is case-insensitive
|
|
24
|
+
const inside = (dir, p) => { const r = relative(fold(dir), fold(p)); return !!r && !r.startsWith("..") && !isAbsolute(r); };
|
|
18
25
|
// ORQ_GH may be a .js file (tests: a fake gh that also runs on Windows).
|
|
19
26
|
const gh = (cwd, args) => {
|
|
20
27
|
const bin = process.env.ORQ_GH || "gh";
|
|
@@ -22,6 +29,30 @@ const gh = (cwd, args) => {
|
|
|
22
29
|
: spawnSync(bin, args, { cwd, encoding: "utf8" });
|
|
23
30
|
};
|
|
24
31
|
const output = (cwd, ...args) => { const r = git(cwd, ...args); return r.status === 0 ? r.stdout.trim() : undefined; };
|
|
32
|
+
/** Per-agent state files (<state>/agents/<unit>.json) of runs under `ws` that are finished or dead and older than `days`. */
|
|
33
|
+
function staleAgentStates(ws, days, now) {
|
|
34
|
+
const dir = join(stateDir(), "agents"), out = [];
|
|
35
|
+
let files = [];
|
|
36
|
+
try {
|
|
37
|
+
files = readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
38
|
+
}
|
|
39
|
+
catch { /* no runs yet */ }
|
|
40
|
+
for (const f of files) {
|
|
41
|
+
try {
|
|
42
|
+
const s = JSON.parse(readFileSync(join(dir, f), "utf8"));
|
|
43
|
+
if (!inside(real(ws), real(String(s.prefix ?? ""))))
|
|
44
|
+
continue;
|
|
45
|
+
const finished = s.status === "ok" || s.status === "fail" || s.status === "killed";
|
|
46
|
+
if (!finished && alive(Number(s.pid)))
|
|
47
|
+
continue;
|
|
48
|
+
const since = Date.parse(String(s.end ?? s.at));
|
|
49
|
+
if (now - since > days * 86400_000)
|
|
50
|
+
out.push(join(dir, f));
|
|
51
|
+
}
|
|
52
|
+
catch { /* unreadable state: leave it */ }
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
25
56
|
export async function gc(argv, clock = Date.now) {
|
|
26
57
|
let ws = process.cwd(), yes = false, json = false, days = 14;
|
|
27
58
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -153,6 +184,10 @@ export async function gc(argv, clock = Date.now) {
|
|
|
153
184
|
}
|
|
154
185
|
git(join(ws, row.repo), "worktree", "prune");
|
|
155
186
|
}
|
|
187
|
+
const stale = staleAgentStates(ws, days, clock());
|
|
188
|
+
if (yes)
|
|
189
|
+
for (const f of stale)
|
|
190
|
+
rmSync(f, { force: true });
|
|
156
191
|
if (json)
|
|
157
192
|
console.log(JSON.stringify(rows));
|
|
158
193
|
else {
|
|
@@ -160,5 +195,8 @@ export async function gc(argv, clock = Date.now) {
|
|
|
160
195
|
const w = table[0].map((_, i) => Math.max(...table.map((t) => t[i].length)));
|
|
161
196
|
console.log(table.map((t) => t.map((c, i) => i < t.length - 1 ? c.padEnd(w[i]) : c).join(" ")).join("\n"));
|
|
162
197
|
}
|
|
198
|
+
if (stale.length && !json)
|
|
199
|
+
console.log(yes ? m(`pruned ${stale.length} stale agent state file(s)`, `${stale.length} arquivo(s) de estado de agente antigo(s) removido(s)`)
|
|
200
|
+
: m(`${stale.length} stale agent state file(s) (finished/dead, older than ${days} days): --yes removes them`, `${stale.length} arquivo(s) de estado de agente antigo(s) (terminados/mortos, mais de ${days} dias): --yes remove`));
|
|
163
201
|
return failed ? 1 : 0;
|
|
164
202
|
}
|