@irythill/orq 0.6.0 → 0.7.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 +26 -16
- package/README.pt-BR.md +37 -17
- package/dist/agents.js +107 -4
- package/dist/ci.js +6 -6
- package/dist/commands/agent.js +203 -44
- package/dist/commands/board.js +1 -1
- package/dist/commands/claim.js +8 -1
- package/dist/commands/conductor.js +7 -6
- package/dist/commands/gc.js +9 -11
- package/dist/commands/pick.js +16 -4
- package/dist/commands/quota.js +1 -1
- package/dist/commands/rounds.js +48 -18
- package/dist/commands/setup.js +2 -6
- package/dist/commands/stats.js +2 -2
- package/dist/config.js +3 -0
- package/dist/diagnose.js +3 -1
- package/dist/install.js +5 -0
- package/dist/issue.js +47 -0
- package/dist/pick.js +125 -19
- package/dist/process.js +28 -7
- package/dist/quota.js +85 -19
- package/dist/rounds.js +40 -4
- package/dist/stats.js +55 -14
- package/package.json +6 -5
- package/skills/issue/SKILL.md +18 -7
- package/templates/conductor.instructions.md +31 -0
package/dist/commands/agent.js
CHANGED
|
@@ -15,17 +15,21 @@ import { basename, dirname, join, resolve } from "node:path";
|
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import { claudeAccounts } from "../accounts.js";
|
|
17
17
|
import { gateFor } from "../diagnose.js";
|
|
18
|
-
import {
|
|
18
|
+
import { hasVerdict } from "../rounds.js";
|
|
19
|
+
import { argvModel, baseCommit, commitMessage, isCommitBase, stepIns, stepInNote, useThrowaway, agentDef, ALIASES, expandString, expandTemplate, FORMATS } from "../agents.js";
|
|
19
20
|
import { getPath, loadConfig } from "../config.js";
|
|
20
21
|
import { writeAtomic } from "../fsutil.js";
|
|
21
22
|
import { m } from "../i18n.js";
|
|
22
23
|
import { alive } from "../lock.js";
|
|
24
|
+
import { agyToken, humanReset, learnExhausted } from "../quota.js";
|
|
23
25
|
import { stateDir } from "../paths.js";
|
|
24
26
|
import { killedRc, killTree, parseSize, resolveMode, runCapped } from "../process.js";
|
|
25
27
|
import { closeOwnTab, detectHost, launch, screenOpen, shq, typeInto } from "../terminal.js";
|
|
26
28
|
import { ackPrefix } from "../notice.js";
|
|
27
29
|
import { dropWorktree, notify } from "../testrun.js";
|
|
28
|
-
export const USAGE = "orq agent -a <agent> -t <title> -d <dir> -o <prefix> [-g group] [-m model] [-e effort] [-c account] [-i file]... [-b base] [-r] [-R session [-F [-F]]] [-T] -- <prompt.md> run a child agent";
|
|
30
|
+
export const USAGE = "orq agent -a <agent> -t <title> -d <dir> -o <prefix> [-g group] [-m model] [-e effort] [-c account] [-i file]... [-b base [--base-branch <branch>]] [-r] [-R session [-F [-F]] [--reason review|verify|unblock]] [-T] -- <prompt.md> run a child agent";
|
|
31
|
+
/** Why a follow-up (-R) runs: only `review` (fixing a reviewer's findings) counts towards policy.max_rounds. */
|
|
32
|
+
export const ROUND_REASONS = ["review", "verify", "unblock"];
|
|
29
33
|
const CLI = fileURLToPath(new URL("../cli.js", import.meta.url));
|
|
30
34
|
const PF = ".herdr-agent-prompt.md"; // prompt_in_dir file name (kept from herdr-agent)
|
|
31
35
|
const git = (dir, ...a) => {
|
|
@@ -37,8 +41,8 @@ export async function agent(argv) {
|
|
|
37
41
|
return runJob(argv[1]);
|
|
38
42
|
if (argv[0] === "--turn")
|
|
39
43
|
return turn(argv[1], argv[2], argv.slice(3));
|
|
40
|
-
let a = process.env.AGENT || "", g = "", t = "", d = "", o = "", model = "", effort = "", acc = "", resume = "", base = "";
|
|
41
|
-
let ro = false, tui = false, force = 0, prompt;
|
|
44
|
+
let a = process.env.AGENT || "", g = "", t = "", d = "", o = "", model = "", effort = "", acc = "", resume = "", base = "", baseBranch = "";
|
|
45
|
+
let ro = false, tui = false, force = 0, prompt, reason = "review";
|
|
42
46
|
const inc = [];
|
|
43
47
|
for (let i = 0; i < argv.length; i++) {
|
|
44
48
|
const x = argv[i], v = () => argv[++i] ?? "";
|
|
@@ -76,6 +80,10 @@ export async function agent(argv) {
|
|
|
76
80
|
tui = true;
|
|
77
81
|
else if (x === "-F")
|
|
78
82
|
force++;
|
|
83
|
+
else if (x === "--reason")
|
|
84
|
+
reason = v();
|
|
85
|
+
else if (x === "--base-branch")
|
|
86
|
+
baseBranch = v();
|
|
79
87
|
else if (!x.startsWith("-") && prompt === undefined)
|
|
80
88
|
prompt = x;
|
|
81
89
|
else {
|
|
@@ -87,6 +95,14 @@ export async function agent(argv) {
|
|
|
87
95
|
console.error(m("give the prompt file", "informe o arquivo de prompt"));
|
|
88
96
|
return 2;
|
|
89
97
|
}
|
|
98
|
+
if (!ROUND_REASONS.includes(reason)) {
|
|
99
|
+
console.error(m(`--reason must be one of ${ROUND_REASONS.join("|")}`, `--reason deve ser ${ROUND_REASONS.join("|")}`));
|
|
100
|
+
return 2;
|
|
101
|
+
}
|
|
102
|
+
if (reason !== "review" && !resume) {
|
|
103
|
+
console.error(m("--reason only applies to a follow-up (-R)", "--reason só vale para um follow-up (-R)"));
|
|
104
|
+
return 2;
|
|
105
|
+
}
|
|
90
106
|
for (const [f, val] of [["t", t], ["d", d], ["o", o]])
|
|
91
107
|
if (!val) {
|
|
92
108
|
console.error(`${m("missing", "faltou")} -${f}`);
|
|
@@ -148,7 +164,14 @@ export async function agent(argv) {
|
|
|
148
164
|
}
|
|
149
165
|
// Fix rounds: every -R on the same session counts, whatever the prefix is called. policy.max_rounds is enforced here,
|
|
150
166
|
// not left to the conductor to remember (a 7-round fix loop is what burns the tokens). -F = the user said go on.
|
|
167
|
+
// Only --reason review counts; every follow-up is logged. Written at launch, so a later refusal costs no round.
|
|
168
|
+
let countRound;
|
|
151
169
|
if (resume) {
|
|
170
|
+
const lf = join(dirname(O), `.rounds-${resume}.log`);
|
|
171
|
+
const logRound = () => { mkdirSync(dirname(O), { recursive: true }); appendFileSync(lf, `${new Date().toISOString()} ${reason} ${basename(O)}\n`); };
|
|
172
|
+
countRound = logRound;
|
|
173
|
+
}
|
|
174
|
+
if (resume && reason === "review") {
|
|
152
175
|
const max = Number(getPath(cfg, "policy.max_rounds") ?? 2), cf = join(dirname(O), `.rounds-${resume}`);
|
|
153
176
|
let n = 0;
|
|
154
177
|
try {
|
|
@@ -168,8 +191,8 @@ export async function agent(argv) {
|
|
|
168
191
|
return 4;
|
|
169
192
|
}
|
|
170
193
|
}
|
|
171
|
-
|
|
172
|
-
writeFileSync(cf, String(n + 1));
|
|
194
|
+
const logRound = countRound;
|
|
195
|
+
countRound = () => { logRound(); writeFileSync(cf, String(n + 1)); };
|
|
173
196
|
}
|
|
174
197
|
let P = resolve(prompt);
|
|
175
198
|
if (!existsSync(repo)) {
|
|
@@ -182,17 +205,32 @@ export async function agent(argv) {
|
|
|
182
205
|
}
|
|
183
206
|
mkdirSync(dirname(O), { recursive: true });
|
|
184
207
|
// -b <base>: attaches `git diff origin/<base>...HEAD` (review without running git; required for Antigravity).
|
|
208
|
+
// A base with no origin/<base> but naming a commit (a fix round's round-<n>.sha) attaches `<base>..HEAD`: only that round's delta.
|
|
209
|
+
// An agent that can't review a commit base (agents.<name>.base_commit) gets the branch base instead: the whole branch.
|
|
210
|
+
let scopeNote = "";
|
|
211
|
+
if (base && !baseCommit(def) && isCommitBase(repo, base)) {
|
|
212
|
+
const branch = baseBranch?.replace(/^origin\//, "") || git(repo, "symbolic-ref", "--short", "refs/remotes/origin/HEAD")?.replace(/^origin\//, "");
|
|
213
|
+
if (!branch) {
|
|
214
|
+
console.error(m(`${a} can't review -b ${base} (a commit) and no branch base is known: pass --base-branch <branch>`, `${a} não revisa -b ${base} (um commit) e a base da branch é desconhecida: informe --base-branch <branch>`));
|
|
215
|
+
return 2;
|
|
216
|
+
}
|
|
217
|
+
scopeNote = m(`${a} can't review a commit base (agents.${a}.base_commit = false): reviewing against origin/${branch} instead. The review covers the whole branch, not just the round delta.`, `${a} não revisa uma base que é commit (agents.${a}.base_commit = false): revisando contra origin/${branch}. A revisão cobre a branch inteira, não só o delta da rodada.`);
|
|
218
|
+
console.log(`[orq agent] ${scopeNote}`);
|
|
219
|
+
base = branch;
|
|
220
|
+
}
|
|
185
221
|
if (base) {
|
|
186
|
-
const
|
|
222
|
+
const range = isCommitBase(repo, base) ? `${base}..HEAD` : `origin/${base}...HEAD`;
|
|
223
|
+
const r = spawnSync("git", ["-C", repo, "diff", range], { encoding: "utf8", maxBuffer: 1 << 30 });
|
|
187
224
|
if (r.status !== 0) {
|
|
188
|
-
console.error(m(`could not build the diff
|
|
225
|
+
console.error(m(`could not build the diff ${range}`, `não consegui gerar o diff ${range}`));
|
|
189
226
|
return 2;
|
|
190
227
|
}
|
|
191
228
|
writeFileSync(`${O}.diff`, r.stdout);
|
|
192
229
|
inc.push(`${O}.diff`);
|
|
193
230
|
}
|
|
194
|
-
|
|
195
|
-
|
|
231
|
+
const steps = stepIns(def, ro, git(repo, "rev-parse", "--git-dir") !== undefined), note = stepInNote(steps);
|
|
232
|
+
if (inc.length || note) {
|
|
233
|
+
let text = readFileSync(P, "utf8") + note;
|
|
196
234
|
for (const f of inc)
|
|
197
235
|
text += `\n--- ${basename(f)} ---\n${readFileSync(f, "utf8")}\n--- end of ${basename(f)} ---\n`;
|
|
198
236
|
writeFileSync(`${O}.prompt.md`, text);
|
|
@@ -205,12 +243,19 @@ export async function agent(argv) {
|
|
|
205
243
|
ackPrefix(O); // a follow-up on the issue: its failure notices were seen
|
|
206
244
|
for (const s of [".done", ".out.md"])
|
|
207
245
|
rmSync(O + s, { force: true });
|
|
246
|
+
countRound?.();
|
|
208
247
|
typeInto(open, readFileSync(P, "utf8"));
|
|
209
248
|
console.log(`[orq agent] ${t} → ${open} ${m("(follow-up typed into the open screen)", "(follow-up digitado na tela aberta)")}`);
|
|
210
249
|
return 0;
|
|
211
250
|
}
|
|
212
251
|
for (const s of [".done", ".out.md", ".pid"])
|
|
213
252
|
rmSync(O + s, { force: true });
|
|
253
|
+
// .id is the session this prefix's screen answers for (`--turn codex` drops other threads): an id left by an earlier
|
|
254
|
+
// run on the prefix (another agent, another session) would drop every turn of this one.
|
|
255
|
+
if (resume)
|
|
256
|
+
writeFileSync(`${O}.id`, `${resume}\n`);
|
|
257
|
+
else
|
|
258
|
+
rmSync(`${O}.id`, { force: true });
|
|
214
259
|
const throwaway = useThrowaway(def, ro, Boolean(resume), git(repo, "rev-parse", "--git-dir") !== undefined);
|
|
215
260
|
const dir = throwaway ? `${repo}.review-${process.pid}` : repo;
|
|
216
261
|
const gd = git(repo, "rev-parse", "--path-format=absolute", "--git-common-dir");
|
|
@@ -253,6 +298,12 @@ export async function agent(argv) {
|
|
|
253
298
|
const cmd = expandTemplate(tpl, vars);
|
|
254
299
|
if (!tpl.includes("{mode}") && !resume && !tui)
|
|
255
300
|
cmd.push(...vars.mode);
|
|
301
|
+
// A prompt passed as an argument ({prompt_text}) can be over what the OS takes: refuse now, not with E2BIG in the tab.
|
|
302
|
+
const tooBig = argvTooBig(cmd);
|
|
303
|
+
if (tooBig) {
|
|
304
|
+
console.error(m(`prompt too big for ${a}: ${tooBig}. Attach less with -i (e.g. only the round's diff; the agent can run git diff itself) or use an agent that reads the prompt from stdin or a file. Nothing started.`, `prompt grande demais para ${a}: ${tooBig}. Anexe menos com -i (ex.: só o diff da rodada; o agente pode rodar git diff) ou use um agente que lê o prompt por stdin ou arquivo. Nada foi iniciado.`));
|
|
305
|
+
return 2;
|
|
306
|
+
}
|
|
256
307
|
const env = {};
|
|
257
308
|
for (const [k, val] of Object.entries(def.env ?? {}))
|
|
258
309
|
env[k] = expandString(String(val), vars);
|
|
@@ -274,10 +325,13 @@ export async function agent(argv) {
|
|
|
274
325
|
swap: parseSize(mach.agent_swap_max), mode: mach.mem_enforce ?? "auto",
|
|
275
326
|
throwaway, promptInDir: Boolean(def.prompt_in_dir), closeAfter,
|
|
276
327
|
keep: process.env.HERDR_KEEP_TAB === "1" || process.env.ORQ_KEEP_TAB === "1" || closeAfter <= 0, tui,
|
|
277
|
-
okExit: def.ok_exit, filter: def.filter, noReview: def.no_review, ro,
|
|
328
|
+
okExit: def.ok_exit, filter: def.filter, noReview: def.no_review, ro, orqCommits: steps.commit, orqFetches: steps.fetch, scopeNote,
|
|
329
|
+
// A review (-r, prefix review*) must end with a verdict; policy.review_verdict = false turns the check off.
|
|
330
|
+
review: ro && /^review/i.test(basename(O)) && getPath(cfg, "policy.review_verdict") !== false,
|
|
278
331
|
idleCloseMin: tui ? Number(mach.tui_idle_close_min ?? 30) : undefined,
|
|
279
332
|
};
|
|
280
333
|
writeAtomic(`${O}.job.json`, JSON.stringify(job, null, 1));
|
|
334
|
+
countRound?.();
|
|
281
335
|
// Readable copy of the command (also what orq stats and herdr-board read).
|
|
282
336
|
writeFileSync(`${O}.run.sh`, `#!/usr/bin/env bash\n# orq agent (generated; the run itself is \`orq agent --run ${O}.job.json\`) --unit=${unit} \n` +
|
|
283
337
|
`cd ${shq(dir)}\n${Object.entries(env).map(([k, val]) => `${k}=${shq(val)} `).join("")}${cmd.map(shq).join(" ")}${job.stdin ? ` < ${shq(P)}` : ""}\n`);
|
|
@@ -342,6 +396,12 @@ function turn(file, kind, rest) {
|
|
|
342
396
|
return 0;
|
|
343
397
|
answer = String(e["last-assistant-message"] ?? "");
|
|
344
398
|
sid = e["thread-id"];
|
|
399
|
+
// Codex also runs internal turns (e.g. generating the tab title) and notifies for them: only the main thread counts.
|
|
400
|
+
const known = existsSync(`${O}.id`) ? readFileSync(`${O}.id`, "utf8").trim() : "";
|
|
401
|
+
if (known && sid && sid !== known)
|
|
402
|
+
return 0;
|
|
403
|
+
if (isTitleOnly(answer))
|
|
404
|
+
return 0;
|
|
345
405
|
}
|
|
346
406
|
else if (kind === "claude") {
|
|
347
407
|
const e = JSON.parse(readFileSync(0, "utf8"));
|
|
@@ -363,19 +423,30 @@ function turn(file, kind, rest) {
|
|
|
363
423
|
answer = readFileSync(0, "utf8");
|
|
364
424
|
}
|
|
365
425
|
writeFileSync(`${O}.out.md`, answer);
|
|
426
|
+
appendFileSync(`${O}.turns`, `${new Date().toISOString()}\n`); // stats: work time ends at the first turn
|
|
366
427
|
if (sid)
|
|
367
428
|
writeFileSync(`${O}.id`, `${sid}\n`);
|
|
368
|
-
const noReview = noReviewMatch(j.noReview, answer);
|
|
369
|
-
const st = /\S/.test(answer) && !noReview ? "ok" : "fail";
|
|
370
|
-
writeAtomic(`${O}.done`, `status=${st} rc=0 at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
|
|
429
|
+
const noReview = noReviewMatch(j.noReview, answer), noVerdict = Boolean(j.review) && /\S/.test(answer) && !hasVerdict(answer);
|
|
430
|
+
const st = /\S/.test(answer) && !noReview && !noVerdict ? "ok" : "fail", why = noVerdict ? " reason=no-verdict" : "";
|
|
431
|
+
writeAtomic(`${O}.done`, `status=${st} rc=0${why} at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
|
|
371
432
|
if (j.parent)
|
|
372
|
-
notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=${st} rc=0 answer: ${O}.out.md`);
|
|
433
|
+
notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=${st} rc=0${why} answer: ${O}.out.md`);
|
|
373
434
|
}
|
|
374
435
|
catch (e) {
|
|
375
436
|
console.error(`orq agent --turn: ${e.message}`);
|
|
376
437
|
}
|
|
377
438
|
return 0;
|
|
378
439
|
}
|
|
440
|
+
/** The `{"title": "..."}` answer of Codex's session-title turn (a JSON object whose only key is `title`). */
|
|
441
|
+
function isTitleOnly(answer) {
|
|
442
|
+
try {
|
|
443
|
+
const o = JSON.parse(answer);
|
|
444
|
+
return typeof o === "object" && o !== null && !Array.isArray(o) && Object.keys(o).length === 1 && "title" in o;
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
379
450
|
/** The last text Claude wrote, from its transcript (older Stop hooks have no last_assistant_message). */
|
|
380
451
|
function lastClaudeText(transcript) {
|
|
381
452
|
let text = "";
|
|
@@ -427,6 +498,29 @@ export function idleExpired(done, idleMs, now = Date.now(), busyAt = 0) {
|
|
|
427
498
|
return false;
|
|
428
499
|
}
|
|
429
500
|
}
|
|
501
|
+
/** A -T turn ended without its hook: the screen was seen busy (`busyAt`) after the last .done (if any), and has been
|
|
502
|
+
* idle since `idleSince` for `graceMs`. All times epoch ms; 0 = never seen. */
|
|
503
|
+
export function missedTurn(done, busyAt, idleSince, graceMs, now = Date.now()) {
|
|
504
|
+
if (!busyAt || !idleSince || idleSince < busyAt || now - idleSince < graceMs)
|
|
505
|
+
return false;
|
|
506
|
+
try {
|
|
507
|
+
return statSync(done).mtimeMs < busyAt;
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
const NO_HOOK_GRACE_MS = 60_000;
|
|
514
|
+
/** Signals a -T turn whose end-of-turn hook never fired: .done (status=fail reason=no-hook) and the done line, with the
|
|
515
|
+
* screen's last lines as the answer, so the conductor isn't left waiting. A late hook still overwrites both. */
|
|
516
|
+
function noHook(j, pane) {
|
|
517
|
+
const O = j.prefix;
|
|
518
|
+
const r = spawnSync("herdr", ["pane", "read", pane, "--lines", "80", "--format", "text"], { encoding: "utf8", timeout: 5_000 });
|
|
519
|
+
writeFileSync(`${O}.out.md`, `${m("The agent's end-of-turn hook did not fire; its turn looks finished (the screen went idle). Last lines of the screen:", "O hook de fim de turno do agente não disparou; o turno parece terminado (a tela ficou ociosa). Últimas linhas da tela:")}\n\n${r.status === 0 ? r.stdout : ""}`);
|
|
520
|
+
writeAtomic(`${O}.done`, `status=fail rc=0 reason=no-hook at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
|
|
521
|
+
if (j.parent)
|
|
522
|
+
notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=fail rc=0 reason=no-hook answer: ${O}.out.md`);
|
|
523
|
+
}
|
|
430
524
|
/** herdr's view of the agent on a pane: working | blocked | idle | done | unknown; undefined when it can't be read. */
|
|
431
525
|
function paneStatus(pane) {
|
|
432
526
|
const r = spawnSync("herdr", ["pane", "get", pane], { encoding: "utf8", timeout: 5_000 });
|
|
@@ -453,6 +547,30 @@ function lines(s, each) {
|
|
|
453
547
|
s.on("error", () => res());
|
|
454
548
|
});
|
|
455
549
|
}
|
|
550
|
+
/** The answer of a run whose command never started: what failed and what to do, for the conductor. */
|
|
551
|
+
export const spawnFailText = (j, e) => `orq agent: ${j.agent} could not start (${e.code ?? "error"}): ${e.message}\n` +
|
|
552
|
+
(e.code === "E2BIG" ? "The prompt (with its -i attachments) is too big to pass as an argument: attach less (e.g. the round's diff only) and let the agent read the rest with git.\n"
|
|
553
|
+
: e.code === "ENOENT" ? `Command not found: ${j.argv[0]}. Is it installed for the current PATH (and Node version)?\n` : "");
|
|
554
|
+
/** Largest command line the OS takes: Linux caps each argument (MAX_ARG_STRLEN, 128 KiB), Windows the whole line. */
|
|
555
|
+
export function argvTooBig(argv, platform = process.platform) {
|
|
556
|
+
const kb = (n) => `${Math.round(n / 1024)} KB`;
|
|
557
|
+
if (platform === "win32") {
|
|
558
|
+
const n = argv.reduce((s, a) => s + a.length + 3, 0);
|
|
559
|
+
return n > 32_000 ? m(`the command line is ${kb(n)} (Windows takes ~32 KB)`, `a linha de comando tem ${kb(n)} (o Windows aceita ~32 KB)`) : undefined;
|
|
560
|
+
}
|
|
561
|
+
const big = Math.max(0, ...argv.map((a) => Buffer.byteLength(a)));
|
|
562
|
+
if (platform === "linux" && big >= 128 * 1024)
|
|
563
|
+
return m(`one argument is ${kb(big)} (Linux takes 128 KB per argument)`, `um argumento tem ${kb(big)} (o Linux aceita 128 KB por argumento)`);
|
|
564
|
+
const total = argv.reduce((s, a) => s + Buffer.byteLength(a) + 1, 0);
|
|
565
|
+
return total > 1_000_000 ? m(`the command line is ${kb(total)} (over ~1 MB)`, `a linha de comando tem ${kb(total)} (acima de ~1 MB)`) : undefined;
|
|
566
|
+
}
|
|
567
|
+
/** Commits everything in `dir` for an agent that can't; returns the line for its answer. */
|
|
568
|
+
function orqCommit(dir, msg) {
|
|
569
|
+
const add = spawnSync("git", ["-C", dir, "add", "-A"], { encoding: "utf8" });
|
|
570
|
+
const c = add.status === 0 ? spawnSync("git", ["-C", dir, "commit", "-q", "-m", msg], { encoding: "utf8" }) : add;
|
|
571
|
+
return c.status === 0 ? `${git(dir, "rev-parse", "--short", "HEAD")} ${msg}`
|
|
572
|
+
: `FAILED (exit ${c.status}): ${(c.stderr || c.stdout).trim()}. The changes are still in the worktree.`;
|
|
573
|
+
}
|
|
456
574
|
async function runJob(file) {
|
|
457
575
|
const j = JSON.parse(readFileSync(file, "utf8")), O = j.prefix;
|
|
458
576
|
writeFileSync(`${O}.pid`, String(process.pid));
|
|
@@ -477,6 +595,8 @@ async function runJob(file) {
|
|
|
477
595
|
}
|
|
478
596
|
catch { /* none */ }
|
|
479
597
|
}
|
|
598
|
+
if (j.orqFetches && spawnSync("git", ["-C", j.dir, "fetch", "-q", "origin"], { stdio: "inherit" }).status !== 0)
|
|
599
|
+
console.error(m("[orq agent] git fetch origin failed; the agent sees the local origin/* refs", "[orq agent] git fetch origin falhou; o agente vê as refs origin/* locais"));
|
|
480
600
|
if (j.promptInDir) {
|
|
481
601
|
copyFileSync(j.prompt, join(j.dir, PF));
|
|
482
602
|
const ex = git(j.dir, "rev-parse", "--git-path", "info/exclude");
|
|
@@ -495,15 +615,18 @@ async function runJob(file) {
|
|
|
495
615
|
}
|
|
496
616
|
return true;
|
|
497
617
|
};
|
|
498
|
-
|
|
618
|
+
let committed = "";
|
|
619
|
+
const post = (answer = "") => {
|
|
499
620
|
if (j.promptInDir)
|
|
500
621
|
rmSync(join(j.dir, PF), { force: true });
|
|
501
622
|
if (j.throwaway)
|
|
502
623
|
dropWorktree(j.repo, j.dir);
|
|
624
|
+
else if (j.orqCommits && existsSync(j.dir) && git(j.dir, "status", "--porcelain"))
|
|
625
|
+
committed = orqCommit(j.dir, commitMessage(answer, j.title));
|
|
503
626
|
};
|
|
504
627
|
if (j.tui) {
|
|
505
628
|
// The agent's own screen: the terminal is its; answers come from the end-of-turn hook (`--turn`).
|
|
506
|
-
let ended = false;
|
|
629
|
+
let ended = false, tuiErr;
|
|
507
630
|
const end = (code, killed = false) => {
|
|
508
631
|
if (ended)
|
|
509
632
|
return "fail";
|
|
@@ -518,9 +641,10 @@ async function runJob(file) {
|
|
|
518
641
|
notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=killed rc=${code} answer: ${O}.out.md`);
|
|
519
642
|
}
|
|
520
643
|
else if (!existsSync(`${O}.done`)) {
|
|
521
|
-
|
|
644
|
+
const why = tuiErr ? ` reason=spawn:${tuiErr.code ?? "error"}` : "";
|
|
645
|
+
writeAtomic(`${O}.done`, `status=fail rc=${code}${why} at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
|
|
522
646
|
if (j.parent)
|
|
523
|
-
notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=fail rc=${code} answer: ${O}.out.md`);
|
|
647
|
+
notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=fail rc=${code}${why} answer: ${O}.out.md`);
|
|
524
648
|
}
|
|
525
649
|
rmSync(`${O}.pid`, { force: true });
|
|
526
650
|
return killed ? "killed" : ok ? "ok" : "fail";
|
|
@@ -541,32 +665,40 @@ async function runJob(file) {
|
|
|
541
665
|
for (const sig of ["SIGHUP", "SIGTERM"])
|
|
542
666
|
process.on(sig, gone);
|
|
543
667
|
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,
|
|
668
|
+
onError: (e) => { tuiErr = e; writeFileSync(`${O}.out.md`, spawnFailText(j, e)); },
|
|
544
669
|
onSpawn: (c) => {
|
|
545
670
|
child = c.pid;
|
|
546
671
|
// A finished agent waits for a follow-up forever and holds its memory: after the idle timeout without a new
|
|
547
672
|
// turn, kill it; the normal end below cleans up and closes the tab. "New turn" = .done removed (`-R` typed
|
|
548
673
|
// into the screen removes it) or rewritten (the next end of turn), so idle = .done untouched that long.
|
|
549
|
-
const idleMs = (j.idleCloseMin
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
674
|
+
const idleMs = (j.idleCloseMin && !j.keep ? j.idleCloseMin : 0) * 60_000;
|
|
675
|
+
// On herdr the pane's agent status also catches a turn typed straight into the screen (.done only changes
|
|
676
|
+
// when it ends), and a turn whose hook never fired (busy, then idle, .done not rewritten): that one is
|
|
677
|
+
// signalled from here, flagged reason=no-hook, with the screen's tail as the answer. Elsewhere (tmux) only
|
|
678
|
+
// .done tells: a turn typed directly into the screen that runs longer than the timeout can still be closed.
|
|
679
|
+
const pane = j.host === "herdr" ? process.env.HERDR_PANE_ID : undefined;
|
|
680
|
+
if (!pane && idleMs <= 0)
|
|
681
|
+
return;
|
|
682
|
+
let busyAt = 0, idleSince = 0;
|
|
683
|
+
idleT = setInterval(() => {
|
|
684
|
+
const st = pane ? paneStatus(pane) : undefined, now = Date.now();
|
|
685
|
+
if (st === "working" || st === "blocked") {
|
|
686
|
+
busyAt = now;
|
|
687
|
+
idleSince = 0;
|
|
688
|
+
}
|
|
689
|
+
else if (st === "idle" || st === "done")
|
|
690
|
+
idleSince ||= now;
|
|
691
|
+
if (pane && missedTurn(`${O}.done`, busyAt, idleSince, NO_HOOK_GRACE_MS, now))
|
|
692
|
+
noHook(j, pane);
|
|
693
|
+
if (!idleExpired(`${O}.done`, idleMs, now, busyAt))
|
|
694
|
+
return;
|
|
695
|
+
clearInterval(idleT);
|
|
696
|
+
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`));
|
|
697
|
+
idleClosed = true;
|
|
698
|
+
if (child)
|
|
699
|
+
killTree(child, false);
|
|
700
|
+
}, pane ? 15_000 : Math.min(30_000, Math.max(1_000, idleMs / 2)));
|
|
701
|
+
idleT.unref();
|
|
570
702
|
} });
|
|
571
703
|
clearInterval(idleT);
|
|
572
704
|
if (idleClosed)
|
|
@@ -582,6 +714,7 @@ async function runJob(file) {
|
|
|
582
714
|
return 0;
|
|
583
715
|
}
|
|
584
716
|
const log = createWriteStream(`${O}.log`);
|
|
717
|
+
let spawnErr;
|
|
585
718
|
if (!pre())
|
|
586
719
|
rc = 2;
|
|
587
720
|
else {
|
|
@@ -610,6 +743,7 @@ async function runJob(file) {
|
|
|
610
743
|
rc = await runCapped(j.argv[0], j.argv.slice(1), {
|
|
611
744
|
cap: j.cap, swap: j.swap, mode: j.mode, unit: j.unit, cwd: j.dir, env, quote: true,
|
|
612
745
|
stdio: [j.stdin ? openSync(j.prompt, "r") : "ignore", "pipe", "pipe"],
|
|
746
|
+
onError: (e) => { spawnErr = e; log.write(`[orq agent] could not start ${j.argv[0]}: ${e.message}\n`); },
|
|
613
747
|
onSpawn: (c) => {
|
|
614
748
|
streams = Promise.all([lines(c.stdout, (l) => onLine(l, true)), lines(c.stderr, (l) => onLine(l, false))]);
|
|
615
749
|
// OpenCode sometimes doesn't exit after its final event (helpers hold the pipe): stop it after 30 s of quiet.
|
|
@@ -633,8 +767,13 @@ async function runJob(file) {
|
|
|
633
767
|
await Promise.race([streams, sleep(5000)]);
|
|
634
768
|
}
|
|
635
769
|
await new Promise((r) => log.end(r));
|
|
636
|
-
post();
|
|
637
770
|
const res = fmt.finish(logText, stdout);
|
|
771
|
+
post(j.answer === "file" ? (existsSync(`${O}.out.md`) ? readFileSync(`${O}.out.md`, "utf8") : "") : res.answer ?? "");
|
|
772
|
+
if (committed)
|
|
773
|
+
res.answer = `${res.answer ?? ""}\n## Commit (by orq)\n${committed}\n`;
|
|
774
|
+
const scope = j.scopeNote ? `\n## Review scope (by orq)\n${j.scopeNote}\n` : "";
|
|
775
|
+
if (scope)
|
|
776
|
+
res.answer = `${res.answer ?? ""}${scope}`;
|
|
638
777
|
// 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
778
|
let recovered = false;
|
|
640
779
|
if (res.denied?.length && !j.ro && !j.throwaway && existsSync(j.dir)) {
|
|
@@ -647,8 +786,26 @@ async function runJob(file) {
|
|
|
647
786
|
else
|
|
648
787
|
res.answer = `${res.answer ?? ""}\nNo changes were made in the worktree.\n`;
|
|
649
788
|
}
|
|
789
|
+
// Out of quota: not a code failure. Learn the model's bucket (orq quota / pick see it) and say why, so the conductor re-picks.
|
|
790
|
+
if (res.exhausted) {
|
|
791
|
+
const model = argvModel(j.argv);
|
|
792
|
+
let until = "";
|
|
793
|
+
if (res.exhausted.secs !== undefined) {
|
|
794
|
+
const at = Date.now() + res.exhausted.secs * 1000;
|
|
795
|
+
if (model)
|
|
796
|
+
learnExhausted("antigravity", model, res.exhausted.secs, agyToken()?.acct);
|
|
797
|
+
until = ` ${m("until", "até")} ${humanReset(Math.floor(at / 1000))} (${new Date(at).toISOString()})`;
|
|
798
|
+
}
|
|
799
|
+
res.answer = `quota exhausted: ${model ?? j.agent}${until}\n\n${res.exhausted.message}\n`;
|
|
800
|
+
res.rc = res.rc || 3;
|
|
801
|
+
}
|
|
650
802
|
if (j.answer !== "file")
|
|
651
803
|
writeFileSync(`${O}.out.md`, res.answer ?? "");
|
|
804
|
+
else if (committed || scope)
|
|
805
|
+
appendFileSync(`${O}.out.md`, `${committed ? `\n## Commit (by orq)\n${committed}\n` : ""}${scope}`);
|
|
806
|
+
// The agent never started: say why in the answer (the conductor reads it) instead of leaving it empty.
|
|
807
|
+
if (spawnErr)
|
|
808
|
+
writeFileSync(`${O}.out.md`, spawnFailText(j, spawnErr));
|
|
652
809
|
const sid = j.fromLog ? new RegExp(j.fromLog, "m").exec(logText)?.[1] : res.session ?? j.session;
|
|
653
810
|
if (sid)
|
|
654
811
|
writeFileSync(`${O}.id`, `${sid}\n`);
|
|
@@ -674,12 +831,14 @@ async function runJob(file) {
|
|
|
674
831
|
// fallback and the conductor must see it as not run, same as a whitespace-only answer.
|
|
675
832
|
const noReview = noReviewMatch(j.noReview, answer);
|
|
676
833
|
// killed (SIGKILL, memory cap...) is its own status: never ok, even if an ok_exit list or a leftover answer says otherwise.
|
|
677
|
-
const
|
|
678
|
-
|
|
834
|
+
const noVerdict = !spawnErr && Boolean(j.review) && verdict && !noReview && /\S/.test(answer) && !hasVerdict(answer);
|
|
835
|
+
const st = spawnErr ? "fail" : killedRc(rc) ? "killed" : verdict && !noReview && !noVerdict && /\S/.test(answer) ? "ok" : "fail"; // whitespace-only answer = failure
|
|
836
|
+
const why = spawnErr ? ` reason=spawn:${spawnErr.code ?? "error"}` : noVerdict ? " reason=no-verdict" : "";
|
|
837
|
+
writeAtomic(`${O}.done`, `status=${st} rc=${rc}${why} at=${new Date().toISOString()} agent=${j.agent}\n`);
|
|
679
838
|
rmSync(`${O}.pid`, { force: true });
|
|
680
839
|
state({ status: st, rc, end: new Date().toISOString() });
|
|
681
840
|
if (j.parent)
|
|
682
|
-
notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=${st} rc=${rc} answer: ${O}.out.md`);
|
|
841
|
+
notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=${st} rc=${rc}${why} answer: ${O}.out.md`);
|
|
683
842
|
console.log(`\n[${j.agent} ${m("finished", "terminou")}: exit ${rc} status=${st}] ${m("answer", "resposta")}: ${O}.out.md`);
|
|
684
843
|
if (j.host !== "plain" && !j.keep) {
|
|
685
844
|
await sleep(j.closeAfter * 1000);
|
package/dist/commands/board.js
CHANGED
|
@@ -466,7 +466,7 @@ export function render(s, theme, cols, mode = colorMode(), hideH = HIDE_H) {
|
|
|
466
466
|
if (top.length)
|
|
467
467
|
out.push(` ${c.mut}${m("role", "papel").padEnd(7)} ${m("agent", "agente").padEnd(12)} ${m("model", "modelo").padEnd(20)} ${"n".padStart(4)} ${"ok%".padStart(5)} ${m("approved%", "aprov%").padStart(10)} ${"min".padStart(6)}${c.r}`);
|
|
468
468
|
for (const x of top)
|
|
469
|
-
out.push(` ${c.ink2}${x.role.padEnd(7)}${c.r} ${c.ink}${fit(x.agent, 12)}${c.r} ${c.mut}${fit(x.model, 20)}${c.r} ${c.ink}${String(x.n).padStart(4)} ${String(x.ok).padStart(5)} ${(x.approved === null ? "-" : `${x.approved}%`).padStart(10)} ${String(x.min).padStart(6)}${c.r}`);
|
|
469
|
+
out.push(` ${c.ink2}${x.role.padEnd(7)}${c.r} ${c.ink}${fit(x.agent, 12)}${c.r} ${c.mut}${fit(x.model, 20)}${c.r} ${c.ink}${String(x.n).padStart(4)} ${String(x.ok).padStart(5)} ${(x.approved === null ? "-" : `${x.approved}%`).padStart(10)} ${String(x.min ?? "-").padStart(6)}${c.r}`);
|
|
470
470
|
if (!top.length)
|
|
471
471
|
out.push(` ${c.mut}${m("no runs yet", "nenhuma execução ainda")}${c.r}`);
|
|
472
472
|
out.push("", `${c.rul}${"─".repeat(W - 2)}${c.r}`, ` ${c.mut}${m("q quit · r quota · l language · t theme", "q sai · r cota · l idioma · t tema")}${c.r}`);
|
package/dist/commands/claim.js
CHANGED
|
@@ -15,6 +15,12 @@ import { acquire, release } from "../lock.js";
|
|
|
15
15
|
import { handoffFile } from "../notice.js";
|
|
16
16
|
export const USAGE = `orq claim [-w <workspace>] <ID> | -f <ID> | -r <ID> | -l | --register issue owner among conductors (exit 1 = someone else's)
|
|
17
17
|
orq claim [-w <workspace>] --from <name> [-f] a conductor's issues by its name (part of it is enough); -f takes them all`;
|
|
18
|
+
/** The Claude session name of a conductor of `ws` (what ListAgents shows): conductor-<workspace folder>, plus
|
|
19
|
+
* :<name> (its herdr label or ORQ_CONDUCTOR_ID, slugged) so several conductors on one workspace can be told apart. */
|
|
20
|
+
export function sessionName(ws, name = "") {
|
|
21
|
+
const slug = name.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
22
|
+
return `conductor-${basename(ws)}${slug ? `:${slug}` : ""}`;
|
|
23
|
+
}
|
|
18
24
|
const field = (text, k) => new RegExp(`${k}=(\\S+)`).exec(text)?.[1] ?? "";
|
|
19
25
|
export const nameOf = (text) => / name=(.*)$/m.exec(text)?.[1].trim() ?? "";
|
|
20
26
|
const herdrList = (args) => {
|
|
@@ -127,7 +133,8 @@ export async function claim(argv) {
|
|
|
127
133
|
// The herdr workspace's current label while the pane is alive; the name stored at claim time otherwise
|
|
128
134
|
// (the user may have renamed the workspace since, or it may not be a herdr conductor at all).
|
|
129
135
|
const n = (alive && (field(t, "kind") || "herdr") === "herdr" ? names.get(field(t, "pane")) ?? "" : "") || nameOf(t);
|
|
130
|
-
console.log(`${d} ${n ? `"${n}" ` : ""}${t.replace(/ name=.*$/, "")} (${alive ? "alive" : "dead"})`
|
|
136
|
+
console.log(`${d} ${n ? `"${n}" ` : ""}${t.replace(/ name=.*$/, "")} (${alive ? "alive" : "dead"})`
|
|
137
|
+
+ ` session=${sessionName(ws, n)}`);
|
|
131
138
|
}
|
|
132
139
|
return 0;
|
|
133
140
|
}
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
// instructions, the chosen account and model (port of setup/herdr-conductor, for any terminal and OS).
|
|
3
3
|
// orq conductor [account|-] [model] [-r <session>]
|
|
4
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
|
|
5
|
+
// instructions file. The session is named conductor-<workspace folder>:<herdr label or ORQ_CONDUCTOR_ID> (see sessionName).
|
|
6
6
|
import { spawnSync } from "node:child_process";
|
|
7
7
|
import { existsSync } from "node:fs";
|
|
8
|
-
import {
|
|
8
|
+
import { join, resolve } from "node:path";
|
|
9
9
|
import { claudeAccounts } from "../accounts.js";
|
|
10
10
|
import { m } from "../i18n.js";
|
|
11
11
|
import { runCapped } from "../process.js";
|
|
12
|
-
import { claim } from "./claim.js";
|
|
12
|
+
import { claim, herdrName, sessionName } from "./claim.js";
|
|
13
13
|
export const USAGE = `orq conductor [account|-] [model] [-r <session>] open the conductor (Claude) here: instructions, account, model
|
|
14
14
|
model: sonnet | opus | haiku | a full id; "-" as account = default account; -r resumes that conversation`;
|
|
15
15
|
export const MODELS = {
|
|
@@ -48,8 +48,8 @@ export function findWorkspace(cwd, env, exists = existsSync) {
|
|
|
48
48
|
}
|
|
49
49
|
export const instructionsFile = (ws, env) => env.CONDUCTOR_FILE ? resolve(env.CONDUCTOR_FILE) : join(ws, ".orquestra-conductor.md");
|
|
50
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",
|
|
51
|
+
export function claudeArgs(ws, a, file, name = "") {
|
|
52
|
+
return [...(a.resume ? ["--resume", a.resume] : []), "-n", sessionName(ws, name),
|
|
53
53
|
...(a.model ? ["--model", modelId(a.model)] : []), "--append-system-prompt-file", file];
|
|
54
54
|
}
|
|
55
55
|
export async function conductor(argv) {
|
|
@@ -100,5 +100,6 @@ export async function conductor(argv) {
|
|
|
100
100
|
delete process.env.CLAUDE_CONFIG_DIR;
|
|
101
101
|
else
|
|
102
102
|
process.env.CLAUDE_CONFIG_DIR = saved;
|
|
103
|
-
|
|
103
|
+
const name = (process.env.HERDR_PANE_ID ? herdrName(process.env.HERDR_PANE_ID) : "") || process.env.ORQ_CONDUCTOR_ID || "";
|
|
104
|
+
return runCapped("claude", claudeArgs(ws, a, file, name), { mode: "off", env, cwd: ws, quote: true, tty: true });
|
|
104
105
|
}
|
package/dist/commands/gc.js
CHANGED
|
@@ -4,6 +4,7 @@ import { existsSync, readdirSync, readFileSync, realpathSync, rmSync } from "nod
|
|
|
4
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 { issueFromName, issuePattern, issuesWithLiveAgent } from "../issue.js";
|
|
7
8
|
import { alive } from "../lock.js";
|
|
8
9
|
import { stateDir } from "../paths.js";
|
|
9
10
|
import { aliveOwner } from "./claim.js";
|
|
@@ -69,10 +70,11 @@ export async function gc(argv, clock = Date.now) {
|
|
|
69
70
|
return 2;
|
|
70
71
|
}
|
|
71
72
|
}
|
|
72
|
-
const { cfg } = loadConfig(ws), rows = [];
|
|
73
|
+
const { cfg } = loadConfig(ws), rows = [], issuePat = issuePattern(ws);
|
|
73
74
|
let ghUnavailable = false, fetchWarned = false;
|
|
74
75
|
const root = join(ws, ".worktrees");
|
|
75
76
|
const orq = join(ws, ".orquestra");
|
|
77
|
+
const liveAgents = issuesWithLiveAgent();
|
|
76
78
|
const issues = existsSync(orq) ? readdirSync(orq).filter((x) => !x.startsWith(".")) : [];
|
|
77
79
|
for (const repoName of readdirSync(ws, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)) {
|
|
78
80
|
const repo = join(ws, repoName);
|
|
@@ -102,8 +104,8 @@ export async function gc(argv, clock = Date.now) {
|
|
|
102
104
|
const branch = wt.branch?.replace(/^refs\/heads\//, "") ?? "";
|
|
103
105
|
if (!branch)
|
|
104
106
|
continue;
|
|
105
|
-
// The branch carries the ID in lowercase (
|
|
106
|
-
const id =
|
|
107
|
+
// The branch carries the ID in lowercase (abc-123, maybe with a prefix or -suffix); .orquestra/ keeps it as written (ABC-123).
|
|
108
|
+
const id = issueFromName(branch, issuePat);
|
|
107
109
|
const issue = id && issues.find((d) => d.toLowerCase() === id.toLowerCase());
|
|
108
110
|
const status = output(path, "status", "--porcelain");
|
|
109
111
|
const unpushed = output(repo, "rev-list", branch, "--not", "--remotes");
|
|
@@ -117,14 +119,10 @@ export async function gc(argv, clock = Date.now) {
|
|
|
117
119
|
verdict = "KEEP";
|
|
118
120
|
reason = m("issue has a live owner", "issue tem um responsável ativo");
|
|
119
121
|
}
|
|
120
|
-
// A
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (running) {
|
|
125
|
-
verdict = "KEEP";
|
|
126
|
-
reason = m("child agent may still be running", "um agente filho pode estar em execução");
|
|
127
|
-
}
|
|
122
|
+
// A child agent of the issue whose state says running and whose pid is alive (the board's rule).
|
|
123
|
+
if (verdict !== "KEEP" && issue && liveAgents.has(issue)) {
|
|
124
|
+
verdict = "KEEP";
|
|
125
|
+
reason = m("child agent may still be running", "um agente filho pode estar em execução");
|
|
128
126
|
}
|
|
129
127
|
let merged = false;
|
|
130
128
|
if (verdict !== "KEEP") {
|