@irythill/orq 0.4.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 +62 -37
- package/README.pt-BR.md +59 -33
- package/dist/agents.js +59 -5
- package/dist/ci.js +4 -2
- package/dist/cli.js +5 -1
- package/dist/commands/agent.js +145 -31
- package/dist/commands/block.js +26 -3
- package/dist/commands/board.js +113 -31
- package/dist/commands/claim.js +2 -0
- package/dist/commands/completion.js +3 -3
- package/dist/commands/conductor.js +104 -0
- package/dist/commands/gc.js +48 -10
- package/dist/commands/handoff.js +1 -0
- package/dist/commands/pick.js +1 -1
- package/dist/commands/rounds.js +77 -3
- package/dist/commands/stats.js +1 -1
- package/dist/commands/verify.js +2 -0
- package/dist/commands/wait.js +7 -1
- package/dist/config.js +2 -1
- package/dist/diagnose.js +105 -0
- package/dist/notice.js +310 -0
- package/dist/pick.js +33 -4
- package/dist/process.js +7 -0
- package/dist/roles.js +11 -2
- package/dist/rounds.js +14 -5
- package/dist/stats.js +6 -4
- package/dist/testrun.js +4 -27
- package/package.json +1 -1
- package/skills/issue/SKILL.md +50 -45
- package/templates/workspace.CLAUDE.md +8 -6
package/dist/commands/agent.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// under its memory cap (src/process.ts), in a herdr tab, a tmux window or the background (src/terminal.ts).
|
|
3
3
|
// Writes <prefix>.out.md (final answer) / .log / .id (session, for -R) / .pane / .done ("status=ok|fail rc=N at=...").
|
|
4
4
|
// Returns at once; `orq wait <prefix>` blocks until .done. A conductor in herdr (HERDR_PANE_ID) also gets ONE line
|
|
5
|
-
// "[HERDR-AGENT DONE] <title> status=... answer: <prefix>.out.md" typed into its pane by
|
|
5
|
+
// "[HERDR-AGENT DONE] <title> status=... answer: <prefix>.out.md" typed into its pane by src/notice.ts.
|
|
6
6
|
// The launcher writes <prefix>.job.json and starts `orq agent --run <job>` on the terminal host; that runner does the rest.
|
|
7
7
|
// -T (or machine.tui / HERDR_AGENT_TUI=1): the agent's own screen (its `tui` template) in a herdr tab or tmux window,
|
|
8
8
|
// same cap and files. The agent calls `orq agent --turn <job> <kind>` at every end of turn, which writes .out.md/.id/
|
|
@@ -10,20 +10,22 @@
|
|
|
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";
|
|
17
|
-
import {
|
|
17
|
+
import { gateFor } from "../diagnose.js";
|
|
18
|
+
import { argvModel, useThrowaway, agentDef, ALIASES, expandString, expandTemplate, FORMATS } from "../agents.js";
|
|
18
19
|
import { getPath, loadConfig } from "../config.js";
|
|
19
20
|
import { writeAtomic } from "../fsutil.js";
|
|
20
21
|
import { m } from "../i18n.js";
|
|
21
22
|
import { alive } from "../lock.js";
|
|
22
23
|
import { stateDir } from "../paths.js";
|
|
23
|
-
import { killTree, parseSize, resolveMode, runCapped } from "../process.js";
|
|
24
|
+
import { killedRc, killTree, parseSize, resolveMode, runCapped } from "../process.js";
|
|
24
25
|
import { closeOwnTab, detectHost, launch, screenOpen, shq, typeInto } from "../terminal.js";
|
|
26
|
+
import { ackPrefix } from "../notice.js";
|
|
25
27
|
import { dropWorktree, notify } from "../testrun.js";
|
|
26
|
-
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]] [-T] -- <prompt.md> run a child agent";
|
|
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";
|
|
27
29
|
const CLI = fileURLToPath(new URL("../cli.js", import.meta.url));
|
|
28
30
|
const PF = ".herdr-agent-prompt.md"; // prompt_in_dir file name (kept from herdr-agent)
|
|
29
31
|
const git = (dir, ...a) => {
|
|
@@ -35,8 +37,8 @@ export async function agent(argv) {
|
|
|
35
37
|
return runJob(argv[1]);
|
|
36
38
|
if (argv[0] === "--turn")
|
|
37
39
|
return turn(argv[1], argv[2], argv.slice(3));
|
|
38
|
-
let a = process.env.AGENT || "
|
|
39
|
-
let ro = false, tui = false, force =
|
|
40
|
+
let a = process.env.AGENT || "", g = "", t = "", d = "", o = "", model = "", effort = "", acc = "", resume = "", base = "";
|
|
41
|
+
let ro = false, tui = false, force = 0, prompt;
|
|
40
42
|
const inc = [];
|
|
41
43
|
for (let i = 0; i < argv.length; i++) {
|
|
42
44
|
const x = argv[i], v = () => argv[++i] ?? "";
|
|
@@ -73,7 +75,7 @@ export async function agent(argv) {
|
|
|
73
75
|
else if (x === "-T")
|
|
74
76
|
tui = true;
|
|
75
77
|
else if (x === "-F")
|
|
76
|
-
force
|
|
78
|
+
force++;
|
|
77
79
|
else if (!x.startsWith("-") && prompt === undefined)
|
|
78
80
|
prompt = x;
|
|
79
81
|
else {
|
|
@@ -90,6 +92,11 @@ export async function agent(argv) {
|
|
|
90
92
|
console.error(`${m("missing", "faltou")} -${f}`);
|
|
91
93
|
return 2;
|
|
92
94
|
}
|
|
95
|
+
// No default agent: an empty $(orq pick ...) means no candidate, and must not start anything.
|
|
96
|
+
if (!a) {
|
|
97
|
+
console.error(m("missing -a (if it came from orq pick, it found no candidate): nothing started", "faltou -a (se veio do orq pick, ele não achou candidato): nada foi iniciado"));
|
|
98
|
+
return 2;
|
|
99
|
+
}
|
|
93
100
|
a = ALIASES[a] ?? a;
|
|
94
101
|
const { cfg } = loadConfig();
|
|
95
102
|
const def = agentDef(cfg, a);
|
|
@@ -123,6 +130,22 @@ export async function agent(argv) {
|
|
|
123
130
|
return 2;
|
|
124
131
|
}
|
|
125
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
|
+
}
|
|
126
149
|
// Fix rounds: every -R on the same session counts, whatever the prefix is called. policy.max_rounds is enforced here,
|
|
127
150
|
// not left to the conductor to remember (a 7-round fix loop is what burns the tokens). -F = the user said go on.
|
|
128
151
|
if (resume) {
|
|
@@ -133,9 +156,18 @@ export async function agent(argv) {
|
|
|
133
156
|
}
|
|
134
157
|
catch { /* first follow-up */ }
|
|
135
158
|
if (n >= max && !force) {
|
|
136
|
-
console.error(m(`round limit: session ${resume} already had ${n} follow-up(s) (policy.max_rounds = ${max}). Stop and tell the user; with their OK, repeat with -F.`, `limite de rodadas: a sessão ${resume} já teve ${n} follow-up(s) (policy.max_rounds = ${max}). Pare e avise o usuário; com o OK dele, repita com -F.`));
|
|
159
|
+
console.error(m(`round limit: session ${resume} already had ${n} follow-up(s) (policy.max_rounds = ${max}). Stop and tell the user; with their OK, run \`orq rounds <ID> --diagnose\`, then repeat with -F.`, `limite de rodadas: a sessão ${resume} já teve ${n} follow-up(s) (policy.max_rounds = ${max}). Pare e avise o usuário; com o OK dele, rode \`orq rounds <ID> --diagnose\` e repita com -F.`));
|
|
137
160
|
return 4;
|
|
138
161
|
}
|
|
162
|
+
// -F past the limit also needs the watchdog's diagnosis of this round count (-F -F skips it: the user's own call).
|
|
163
|
+
// The issue folder is the prefix's folder, .orquestra/<ID>/ (where the .rounds-<session> counter lives).
|
|
164
|
+
if (n >= max && force === 1 && basename(dirname(dirname(O))) === ".orquestra") {
|
|
165
|
+
const g = gateFor(dirname(O)), id = basename(dirname(O));
|
|
166
|
+
if (g.verdict !== "ok") {
|
|
167
|
+
console.error(m(`no current diagnosis: ${g.verdict === "stale" ? `diagnosis-${g.rounds}.md is older than the latest fix/review` : `diagnosis-${g.rounds}.md does not exist`}. Run \`orq rounds ${id} --diagnose\` first, read it, and ask the user. -F -F skips this check.`, `sem diagnóstico atual: ${g.verdict === "stale" ? `diagnosis-${g.rounds}.md é mais antigo que o último fix/revisão` : `diagnosis-${g.rounds}.md não existe`}. Rode \`orq rounds ${id} --diagnose\` antes, leia e consulte o usuário. -F -F pula esta checagem.`));
|
|
168
|
+
return 4;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
139
171
|
mkdirSync(dirname(O), { recursive: true });
|
|
140
172
|
writeFileSync(cf, String(n + 1));
|
|
141
173
|
}
|
|
@@ -169,13 +201,8 @@ export async function agent(argv) {
|
|
|
169
201
|
// Follow-ups (-R) reuse the prefix: remove the previous round's result so ".done exists" means "this one finished".
|
|
170
202
|
// -T -R with the screen still open: the follow-up is typed into it (its runner, and .pid, carry on). Open = the agent
|
|
171
203
|
// is still running there, not just the pane: after it exits the pane may hold a shell, which must never get the text.
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
open = readFileSync(`${O}.pane`, "utf8").trim();
|
|
175
|
-
pid = Number(readFileSync(`${O}.pid`, "utf8"));
|
|
176
|
-
}
|
|
177
|
-
catch { /* first run or ended */ }
|
|
178
|
-
if (tui && resume && alive(pid) && screenOpen(open)) {
|
|
204
|
+
if (typed) {
|
|
205
|
+
ackPrefix(O); // a follow-up on the issue: its failure notices were seen
|
|
179
206
|
for (const s of [".done", ".out.md"])
|
|
180
207
|
rmSync(O + s, { force: true });
|
|
181
208
|
typeInto(open, readFileSync(P, "utf8"));
|
|
@@ -184,7 +211,7 @@ export async function agent(argv) {
|
|
|
184
211
|
}
|
|
185
212
|
for (const s of [".done", ".out.md", ".pid"])
|
|
186
213
|
rmSync(O + s, { force: true });
|
|
187
|
-
const throwaway =
|
|
214
|
+
const throwaway = useThrowaway(def, ro, Boolean(resume), git(repo, "rev-parse", "--git-dir") !== undefined);
|
|
188
215
|
const dir = throwaway ? `${repo}.review-${process.pid}` : repo;
|
|
189
216
|
const gd = git(repo, "rev-parse", "--path-format=absolute", "--git-common-dir");
|
|
190
217
|
const wg = git(repo, "rev-parse", "--absolute-git-dir");
|
|
@@ -247,17 +274,33 @@ export async function agent(argv) {
|
|
|
247
274
|
swap: parseSize(mach.agent_swap_max), mode: mach.mem_enforce ?? "auto",
|
|
248
275
|
throwaway, promptInDir: Boolean(def.prompt_in_dir), closeAfter,
|
|
249
276
|
keep: process.env.HERDR_KEEP_TAB === "1" || process.env.ORQ_KEEP_TAB === "1" || closeAfter <= 0, tui,
|
|
250
|
-
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,
|
|
251
279
|
};
|
|
252
280
|
writeAtomic(`${O}.job.json`, JSON.stringify(job, null, 1));
|
|
253
281
|
// Readable copy of the command (also what orq stats and herdr-board read).
|
|
254
282
|
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` +
|
|
255
283
|
`cd ${shq(dir)}\n${Object.entries(env).map(([k, val]) => `${k}=${shq(val)} `).join("")}${cmd.map(shq).join(" ")}${job.stdin ? ` < ${shq(P)}` : ""}\n`);
|
|
284
|
+
ackPrefix(O); // starting work on the issue again: its failure notices were seen
|
|
256
285
|
const where = launch(host, { group: g || "agents", title: t, dir: repo, argv: [process.execPath, CLI, "agent", "--run", `${O}.job.json`], id: unit, keep: job.keep });
|
|
257
286
|
writeFileSync(`${O}.pane`, `${where}\n`);
|
|
258
287
|
console.log(`[orq agent] ${t} → ${where}${tui ? " (TUI)" : ""}`);
|
|
259
288
|
return 0;
|
|
260
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
|
+
}
|
|
261
304
|
/** OpenCode (v2) plugin for -T: when the main session's run ends, its last answer goes to `orq agent --turn`. */
|
|
262
305
|
function opencodePlugin(O, hook) {
|
|
263
306
|
return `// orq agent -T end of turn (generated)
|
|
@@ -326,7 +369,7 @@ function turn(file, kind, rest) {
|
|
|
326
369
|
const st = /\S/.test(answer) && !noReview ? "ok" : "fail";
|
|
327
370
|
writeAtomic(`${O}.done`, `status=${st} rc=0 at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
|
|
328
371
|
if (j.parent)
|
|
329
|
-
notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=${st} rc=0 answer: ${O}.out.md
|
|
372
|
+
notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=${st} rc=0 answer: ${O}.out.md`);
|
|
330
373
|
}
|
|
331
374
|
catch (e) {
|
|
332
375
|
console.error(`orq agent --turn: ${e.message}`);
|
|
@@ -374,6 +417,26 @@ function noReviewMatch(pattern, answer) {
|
|
|
374
417
|
return re.test(first) || (answer.trim().length <= 200 && re.test(answer));
|
|
375
418
|
}
|
|
376
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
|
+
}
|
|
377
440
|
/** Splits a stream into lines for `each`. */
|
|
378
441
|
function lines(s, each) {
|
|
379
442
|
return new Promise((res) => {
|
|
@@ -396,12 +459,14 @@ async function runJob(file) {
|
|
|
396
459
|
const stateF = join(stateDir(), "agents", `${j.unit}.json`);
|
|
397
460
|
const at = new Date().toISOString();
|
|
398
461
|
const state = (extra = {}) => { try {
|
|
399
|
-
writeAtomic(stateF, JSON.stringify({ pid: process.pid, prefix: O, title: j.title, agent: j.agent, host: j.host, cap: j.cap, at, ...extra }));
|
|
462
|
+
writeAtomic(stateF, JSON.stringify({ pid: process.pid, prefix: O, title: j.title, agent: j.agent, model: argvModel(j.argv), host: j.host, cap: j.cap, at, ...extra }));
|
|
400
463
|
}
|
|
401
464
|
catch { /* best effort */ } };
|
|
402
465
|
state();
|
|
403
466
|
const fmt = FORMATS[j.format] ?? FORMATS.text;
|
|
404
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");
|
|
405
470
|
const pre = () => {
|
|
406
471
|
if (j.throwaway) {
|
|
407
472
|
// Throwaway worktree: detached copy of HEAD with the repo's node_modules linked; removed at the end.
|
|
@@ -439,20 +504,26 @@ async function runJob(file) {
|
|
|
439
504
|
if (j.tui) {
|
|
440
505
|
// The agent's own screen: the terminal is its; answers come from the end-of-turn hook (`--turn`).
|
|
441
506
|
let ended = false;
|
|
442
|
-
const end = (code) => {
|
|
507
|
+
const end = (code, killed = false) => {
|
|
443
508
|
if (ended)
|
|
444
509
|
return "fail";
|
|
445
510
|
ended = true;
|
|
446
511
|
post();
|
|
447
512
|
// Closed before any turn ended (or mid follow-up): a failure, so `orq wait` doesn't hang.
|
|
448
|
-
const ok = /\bstatus=ok\b/.test(existsSync(`${O}.done`) ? readFileSync(`${O}.done`, "utf8") : "");
|
|
449
|
-
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`)) {
|
|
450
521
|
writeAtomic(`${O}.done`, `status=fail rc=${code} at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
|
|
451
522
|
if (j.parent)
|
|
452
|
-
notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=fail rc=${code} answer: ${O}.out.md
|
|
523
|
+
notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=fail rc=${code} answer: ${O}.out.md`);
|
|
453
524
|
}
|
|
454
525
|
rmSync(`${O}.pid`, { force: true });
|
|
455
|
-
return ok ? "ok" : "fail";
|
|
526
|
+
return killed ? "killed" : ok ? "ok" : "fail";
|
|
456
527
|
};
|
|
457
528
|
if (!pre())
|
|
458
529
|
rc = 2;
|
|
@@ -462,16 +533,46 @@ async function runJob(file) {
|
|
|
462
533
|
delete env[k];
|
|
463
534
|
// Ctrl+C belongs to the agent (it reaches us too, as part of the terminal's foreground group). The screen going
|
|
464
535
|
// away (tab or tmux server closed) still ends the agent and cleans up.
|
|
465
|
-
let child;
|
|
536
|
+
let child, idleT, idleClosed = false;
|
|
466
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).
|
|
467
539
|
const gone = () => { if (child)
|
|
468
|
-
killTree(child, false); end(129); process.exit(0); };
|
|
540
|
+
killTree(child, false); end(129); closeOwnTab(j.host); process.exit(0); };
|
|
469
541
|
for (const sig of ["SIGHUP", "SIGTERM"])
|
|
470
542
|
process.on(sig, gone);
|
|
471
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,
|
|
472
|
-
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
|
|
473
574
|
}
|
|
474
|
-
const st = end(rc);
|
|
575
|
+
const st = end(rc, killedRc(rc));
|
|
475
576
|
state({ status: st, rc, end: new Date().toISOString() });
|
|
476
577
|
console.log(`\n[${j.agent} (TUI) ${m("exited", "encerrado")}] ${m("answer", "resposta")}: ${O}.out.md`);
|
|
477
578
|
if (!j.keep) {
|
|
@@ -534,12 +635,24 @@ async function runJob(file) {
|
|
|
534
635
|
await new Promise((r) => log.end(r));
|
|
535
636
|
post();
|
|
536
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
|
+
}
|
|
537
650
|
if (j.answer !== "file")
|
|
538
651
|
writeFileSync(`${O}.out.md`, res.answer ?? "");
|
|
539
652
|
const sid = j.fromLog ? new RegExp(j.fromLog, "m").exec(logText)?.[1] : res.session ?? j.session;
|
|
540
653
|
if (sid)
|
|
541
654
|
writeFileSync(`${O}.id`, `${sid}\n`);
|
|
542
|
-
if (rc === 0 && res.rc)
|
|
655
|
+
if (rc === 0 && res.rc && !recovered)
|
|
543
656
|
rc = res.rc;
|
|
544
657
|
// A review tool exits non-zero on findings: an ok_exit code is a verdict (rc stays in .done), not a failure.
|
|
545
658
|
let verdict = rc === 0 || (j.okExit ?? []).includes(rc);
|
|
@@ -560,7 +673,8 @@ async function runJob(file) {
|
|
|
560
673
|
// A reviewer whose answer says nothing was reviewed (agents.<name>.no_review) is not a verdict either: pick's
|
|
561
674
|
// fallback and the conductor must see it as not run, same as a whitespace-only answer.
|
|
562
675
|
const noReview = noReviewMatch(j.noReview, answer);
|
|
563
|
-
|
|
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
|
|
564
678
|
writeAtomic(`${O}.done`, `status=${st} rc=${rc} at=${new Date().toISOString()} agent=${j.agent}\n`);
|
|
565
679
|
rmSync(`${O}.pid`, { force: true });
|
|
566
680
|
state({ status: st, rc, end: new Date().toISOString() });
|
package/dist/commands/block.js
CHANGED
|
@@ -8,7 +8,9 @@ import { writeAtomic } from "../fsutil.js";
|
|
|
8
8
|
import { loginId } from "../identity.js";
|
|
9
9
|
import { m } from "../i18n.js";
|
|
10
10
|
import { stateDir } from "../paths.js";
|
|
11
|
-
|
|
11
|
+
import { getPath, loadConfig } from "../config.js";
|
|
12
|
+
import { collect } from "../quota.js";
|
|
13
|
+
export const USAGE = `orq block <agent|account> [--until YYYY-MM-DD[THH:MM] | --days N | --quota] [--reason "text"] default: 7 days; --quota: until the quota window resets
|
|
12
14
|
orq block -r <agent|account> unblock early
|
|
13
15
|
orq block -l list active blocks (expired ones disappear by themselves)`;
|
|
14
16
|
const file = () => join(stateDir(), "blocks.json");
|
|
@@ -45,7 +47,16 @@ export function parseLocal(s) {
|
|
|
45
47
|
throw new Error(m(`invalid date: ${s}`, `data inválida: ${s}`));
|
|
46
48
|
return d;
|
|
47
49
|
}
|
|
48
|
-
|
|
50
|
+
/** When a quota line's exhausted window resets, in epoch seconds: the week if it is used up, else the 5h window (the
|
|
51
|
+
* reading can lag behind the limit: Codex's comes from its last session). undefined = no reset known. */
|
|
52
|
+
export function quotaUntil(line) {
|
|
53
|
+
const reset = (k) => { const x = new RegExp(`\\b${k}=(\\d+)`).exec(line); return x ? Number(x[1]) : undefined; };
|
|
54
|
+
const week = /\bweek=(\d+)%/.exec(line);
|
|
55
|
+
if (week && Number(week[1]) >= 100 && reset("resetweek"))
|
|
56
|
+
return reset("resetweek");
|
|
57
|
+
return reset("reset5h") ?? reset("resetweek");
|
|
58
|
+
}
|
|
59
|
+
export async function block(argv) {
|
|
49
60
|
const b = load();
|
|
50
61
|
if (!argv.length || argv[0] === "-h" || argv[0] === "--help") {
|
|
51
62
|
console.log(USAGE);
|
|
@@ -69,7 +80,7 @@ export function block(argv) {
|
|
|
69
80
|
return 0;
|
|
70
81
|
}
|
|
71
82
|
const [name, ...rest] = argv;
|
|
72
|
-
let until = new Date(Date.now() + 7 * 86400_000), reason = "";
|
|
83
|
+
let until = new Date(Date.now() + 7 * 86400_000), reason = "", byQuota = false;
|
|
73
84
|
while (rest.length) {
|
|
74
85
|
const o = rest.shift();
|
|
75
86
|
if (o === "--until")
|
|
@@ -78,6 +89,18 @@ export function block(argv) {
|
|
|
78
89
|
until = new Date(Date.now() + Number(rest.shift()) * 86400_000);
|
|
79
90
|
else if (o === "--reason")
|
|
80
91
|
reason = rest.shift() ?? "";
|
|
92
|
+
else if (o === "--quota")
|
|
93
|
+
byQuota = true;
|
|
94
|
+
}
|
|
95
|
+
if (byQuota) {
|
|
96
|
+
const sources = (getPath(loadConfig().cfg, "quota") ?? {});
|
|
97
|
+
const line = (await collect(sources, [name])).find((l) => l.startsWith(`${name} `));
|
|
98
|
+
const r = line && quotaUntil(line);
|
|
99
|
+
if (!r) {
|
|
100
|
+
console.error(m(`${name}: no quota reset known; use --until or --days`, `${name}: sem horário de renovação da cota; use --until ou --days`));
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
until = new Date(r * 1000);
|
|
81
104
|
}
|
|
82
105
|
if (Number.isNaN(until.getTime())) {
|
|
83
106
|
console.error(USAGE);
|