@irythill/orq 0.3.0 → 0.5.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.
@@ -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 herdr-notify.
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/
@@ -14,16 +14,18 @@ import { appendFileSync, copyFileSync, createWriteStream, existsSync, mkdirSync,
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 { agentDef, ALIASES, expandString, expandTemplate, FORMATS } from "../agents.js";
18
- import { loadConfig } from "../config.js";
17
+ import { gateFor } from "../diagnose.js";
18
+ import { argvModel, useThrowaway, agentDef, ALIASES, expandString, expandTemplate, FORMATS } from "../agents.js";
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
24
  import { 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] [-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 || "codex", g = "", t = "", d = "", o = "", model = "", effort = "", acc = "", resume = "", base = "";
39
- let ro = false, tui = false, prompt;
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] ?? "";
@@ -72,6 +74,8 @@ export async function agent(argv) {
72
74
  ro = true;
73
75
  else if (x === "-T")
74
76
  tui = true;
77
+ else if (x === "-F")
78
+ force++;
75
79
  else if (!x.startsWith("-") && prompt === undefined)
76
80
  prompt = x;
77
81
  else {
@@ -88,6 +92,11 @@ export async function agent(argv) {
88
92
  console.error(`${m("missing", "faltou")} -${f}`);
89
93
  return 2;
90
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
+ }
91
100
  a = ALIASES[a] ?? a;
92
101
  const { cfg } = loadConfig();
93
102
  const def = agentDef(cfg, a);
@@ -121,6 +130,31 @@ export async function agent(argv) {
121
130
  return 2;
122
131
  }
123
132
  const repo = resolve(d), O = resolve(o);
133
+ // Fix rounds: every -R on the same session counts, whatever the prefix is called. policy.max_rounds is enforced here,
134
+ // not left to the conductor to remember (a 7-round fix loop is what burns the tokens). -F = the user said go on.
135
+ if (resume) {
136
+ const max = Number(getPath(cfg, "policy.max_rounds") ?? 2), cf = join(dirname(O), `.rounds-${resume}`);
137
+ let n = 0;
138
+ try {
139
+ n = Number(readFileSync(cf, "utf8")) || 0;
140
+ }
141
+ catch { /* first follow-up */ }
142
+ if (n >= max && !force) {
143
+ 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.`));
144
+ return 4;
145
+ }
146
+ // -F past the limit also needs the watchdog's diagnosis of this round count (-F -F skips it: the user's own call).
147
+ // The issue folder is the prefix's folder, .orquestra/<ID>/ (where the .rounds-<session> counter lives).
148
+ if (n >= max && force === 1 && basename(dirname(dirname(O))) === ".orquestra") {
149
+ const g = gateFor(dirname(O)), id = basename(dirname(O));
150
+ if (g.verdict !== "ok") {
151
+ 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.`));
152
+ return 4;
153
+ }
154
+ }
155
+ mkdirSync(dirname(O), { recursive: true });
156
+ writeFileSync(cf, String(n + 1));
157
+ }
124
158
  let P = resolve(prompt);
125
159
  if (!existsSync(repo)) {
126
160
  console.error(m(`no folder ${repo}`, `pasta ${repo} não existe`));
@@ -158,6 +192,7 @@ export async function agent(argv) {
158
192
  }
159
193
  catch { /* first run or ended */ }
160
194
  if (tui && resume && alive(pid) && screenOpen(open)) {
195
+ ackPrefix(O); // a follow-up on the issue: its failure notices were seen
161
196
  for (const s of [".done", ".out.md"])
162
197
  rmSync(O + s, { force: true });
163
198
  typeInto(open, readFileSync(P, "utf8"));
@@ -166,7 +201,7 @@ export async function agent(argv) {
166
201
  }
167
202
  for (const s of [".done", ".out.md", ".pid"])
168
203
  rmSync(O + s, { force: true });
169
- const throwaway = Boolean(def.throwaway && ro && !resume);
204
+ const throwaway = useThrowaway(def, ro, Boolean(resume), git(repo, "rev-parse", "--git-dir") !== undefined);
170
205
  const dir = throwaway ? `${repo}.review-${process.pid}` : repo;
171
206
  const gd = git(repo, "rev-parse", "--path-format=absolute", "--git-common-dir");
172
207
  const wg = git(repo, "rev-parse", "--absolute-git-dir");
@@ -199,7 +234,7 @@ export async function agent(argv) {
199
234
  const vars = {
200
235
  prompt: P, prompt_text: tpl.includes("{prompt_text}") ? readFileSync(P, "utf8") : "",
201
236
  prompt_hint: `Read the file ${PF} (in the current folder, with your file-reading tool): it holds your complete instructions for this task. Follow them. ${PF} is not part of the code: don't review or edit it.`,
202
- out: `${O}.out.md`, dir, repo, title: t, model: model === "auto" ? "" : model, effort, session,
237
+ out: `${O}.out.md`, dir, repo, base, title: t, model: model === "auto" ? "" : model, effort, session,
203
238
  gitdir: gd ?? "", gitroots: roots, gitroots_json: JSON.stringify(roots), mode: [], opencode_config: JSON.stringify(ocCfg),
204
239
  hook_cmd: hook.map(shq).join(" "), hook_json: JSON.stringify(hook),
205
240
  hook_settings: JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: "command", command: hook.map(shq).join(" ") }] }] } }),
@@ -229,11 +264,13 @@ export async function agent(argv) {
229
264
  swap: parseSize(mach.agent_swap_max), mode: mach.mem_enforce ?? "auto",
230
265
  throwaway, promptInDir: Boolean(def.prompt_in_dir), closeAfter,
231
266
  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,
232
268
  };
233
269
  writeAtomic(`${O}.job.json`, JSON.stringify(job, null, 1));
234
270
  // Readable copy of the command (also what orq stats and herdr-board read).
235
271
  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` +
236
272
  `cd ${shq(dir)}\n${Object.entries(env).map(([k, val]) => `${k}=${shq(val)} `).join("")}${cmd.map(shq).join(" ")}${job.stdin ? ` < ${shq(P)}` : ""}\n`);
273
+ ackPrefix(O); // starting work on the issue again: its failure notices were seen
237
274
  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 });
238
275
  writeFileSync(`${O}.pane`, `${where}\n`);
239
276
  console.log(`[orq agent] ${t} → ${where}${tui ? " (TUI)" : ""}`);
@@ -303,10 +340,11 @@ function turn(file, kind, rest) {
303
340
  writeFileSync(`${O}.out.md`, answer);
304
341
  if (sid)
305
342
  writeFileSync(`${O}.id`, `${sid}\n`);
306
- const st = /\S/.test(answer) ? "ok" : "fail";
343
+ const noReview = noReviewMatch(j.noReview, answer);
344
+ const st = /\S/.test(answer) && !noReview ? "ok" : "fail";
307
345
  writeAtomic(`${O}.done`, `status=${st} rc=0 at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
308
346
  if (j.parent)
309
- notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=${st} rc=0 answer: ${O}.out.md`, true);
347
+ notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=${st} rc=0 answer: ${O}.out.md`);
310
348
  }
311
349
  catch (e) {
312
350
  console.error(`orq agent --turn: ${e.message}`);
@@ -332,6 +370,27 @@ function lastClaudeText(transcript) {
332
370
  catch { /* no transcript */ }
333
371
  return text;
334
372
  }
373
+ /** agents.<name>.no_review tested against the start of the answer (first line) or, for a short answer, the whole
374
+ * thing — not anywhere in a long one, so a review that quotes the phrase while discussing real code doesn't match.
375
+ * An invalid pattern is logged and ignored rather than killing the runner. */
376
+ function noReviewMatch(pattern, answer) {
377
+ if (pattern === undefined)
378
+ return false;
379
+ if (typeof pattern !== "string" || !pattern.trim()) {
380
+ console.error(`orq agent: ignoring invalid agents.<name>.no_review ${JSON.stringify(pattern)}: expected a non-empty regex string`);
381
+ return false;
382
+ }
383
+ let re;
384
+ try {
385
+ re = new RegExp(pattern, "m");
386
+ }
387
+ catch (e) {
388
+ console.error(`orq agent: ignoring invalid agents.<name>.no_review pattern ${JSON.stringify(pattern)}: ${e.message}`);
389
+ return false;
390
+ }
391
+ const first = answer.split(/\r?\n/, 1)[0] ?? "";
392
+ return re.test(first) || (answer.trim().length <= 200 && re.test(answer));
393
+ }
335
394
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
336
395
  /** Splits a stream into lines for `each`. */
337
396
  function lines(s, each) {
@@ -355,7 +414,7 @@ async function runJob(file) {
355
414
  const stateF = join(stateDir(), "agents", `${j.unit}.json`);
356
415
  const at = new Date().toISOString();
357
416
  const state = (extra = {}) => { try {
358
- writeAtomic(stateF, JSON.stringify({ pid: process.pid, prefix: O, title: j.title, agent: j.agent, host: j.host, cap: j.cap, at, ...extra }));
417
+ 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 }));
359
418
  }
360
419
  catch { /* best effort */ } };
361
420
  state();
@@ -408,7 +467,7 @@ async function runJob(file) {
408
467
  if (!existsSync(`${O}.done`)) {
409
468
  writeAtomic(`${O}.done`, `status=fail rc=${code} at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
410
469
  if (j.parent)
411
- notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=fail rc=${code} answer: ${O}.out.md`, true);
470
+ notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=fail rc=${code} answer: ${O}.out.md`);
412
471
  }
413
472
  rmSync(`${O}.pid`, { force: true });
414
473
  return ok ? "ok" : "fail";
@@ -500,12 +559,26 @@ async function runJob(file) {
500
559
  writeFileSync(`${O}.id`, `${sid}\n`);
501
560
  if (rc === 0 && res.rc)
502
561
  rc = res.rc;
562
+ // A review tool exits non-zero on findings: an ok_exit code is a verdict (rc stays in .done), not a failure.
563
+ let verdict = rc === 0 || (j.okExit ?? []).includes(rc);
564
+ if (j.filter && verdict) {
565
+ const f = spawnSync("sh", ["-c", j.filter], { cwd: j.dir, input: readFileSync(`${O}.out.md`, "utf8"), encoding: "utf8", maxBuffer: 1 << 30 });
566
+ if (f.status === 0)
567
+ writeFileSync(`${O}.out.md`, f.stdout);
568
+ else {
569
+ appendFileSync(`${O}.log`, `[orq agent] filter failed (exit ${f.status}): ${f.stderr}\n`);
570
+ verdict = false;
571
+ }
572
+ }
503
573
  let answer = "";
504
574
  try {
505
575
  answer = readFileSync(`${O}.out.md`, "utf8");
506
576
  }
507
577
  catch { /* none */ }
508
- const st = rc === 0 && /\S/.test(answer) ? "ok" : "fail"; // whitespace-only answer = failure
578
+ // A reviewer whose answer says nothing was reviewed (agents.<name>.no_review) is not a verdict either: pick's
579
+ // fallback and the conductor must see it as not run, same as a whitespace-only answer.
580
+ const noReview = noReviewMatch(j.noReview, answer);
581
+ const st = verdict && !noReview && /\S/.test(answer) ? "ok" : "fail"; // whitespace-only answer = failure
509
582
  writeAtomic(`${O}.done`, `status=${st} rc=${rc} at=${new Date().toISOString()} agent=${j.agent}\n`);
510
583
  rmSync(`${O}.pid`, { force: true });
511
584
  state({ status: st, rc, end: new Date().toISOString() });
@@ -1,12 +1,16 @@
1
1
  // orq block — takes an agent (or account) out of orq-pick until a date. For quotas without a collector
2
2
  // (Antigravity, Copilot, MiniMax) or any outage you know about before the agent fails.
3
- // State in <state dir>/blocks.json ({name: {until: <epoch seconds>, reason}}), shared with orq-pick.
3
+ // State in <state dir>/blocks.json ({name: {until: <epoch seconds>, reason, acct}}), shared with orq-pick. `acct` is the
4
+ // login at block time (src/identity.ts): if the agent/account is logged into another one later, the block is dropped.
4
5
  import { readFileSync } from "node:fs";
5
6
  import { join } from "node:path";
6
7
  import { writeAtomic } from "../fsutil.js";
8
+ import { loginId } from "../identity.js";
7
9
  import { m } from "../i18n.js";
8
10
  import { stateDir } from "../paths.js";
9
- export const USAGE = `orq block <agent|account> [--until YYYY-MM-DD[THH:MM] | --days N] [--reason "text"] default: 7 days
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
10
14
  orq block -r <agent|account> unblock early
11
15
  orq block -l list active blocks (expired ones disappear by themselves)`;
12
16
  const file = () => join(stateDir(), "blocks.json");
@@ -19,7 +23,21 @@ export function load() {
19
23
  b = JSON.parse(readFileSync(file(), "utf8"));
20
24
  }
21
25
  catch { /* missing or broken: no blocks */ }
22
- return Object.fromEntries(Object.entries(b).filter(([, v]) => (v?.until ?? 0) > now()));
26
+ const live = Object.fromEntries(Object.entries(b).filter(([, v]) => (v?.until ?? 0) > now()));
27
+ // A block made under another login (the account was switched) no longer applies: the new one has its own quota.
28
+ const kept = Object.fromEntries(Object.entries(live).filter(([k, v]) => {
29
+ if (!v.acct)
30
+ return true;
31
+ const cur = loginId(k);
32
+ return cur === undefined || cur === v.acct;
33
+ }));
34
+ if (Object.keys(kept).length !== Object.keys(live).length) {
35
+ try {
36
+ save(kept);
37
+ }
38
+ catch { /* best effort */ }
39
+ }
40
+ return kept;
23
41
  }
24
42
  const save = (b) => writeAtomic(file(), JSON.stringify(b, null, 2));
25
43
  /** "YYYY-MM-DD" or "YYYY-MM-DDTHH:MM" in local time (a bare date in JS would be UTC). */
@@ -29,7 +47,16 @@ export function parseLocal(s) {
29
47
  throw new Error(m(`invalid date: ${s}`, `data inválida: ${s}`));
30
48
  return d;
31
49
  }
32
- export function block(argv) {
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) {
33
60
  const b = load();
34
61
  if (!argv.length || argv[0] === "-h" || argv[0] === "--help") {
35
62
  console.log(USAGE);
@@ -53,7 +80,7 @@ export function block(argv) {
53
80
  return 0;
54
81
  }
55
82
  const [name, ...rest] = argv;
56
- let until = new Date(Date.now() + 7 * 86400_000), reason = "";
83
+ let until = new Date(Date.now() + 7 * 86400_000), reason = "", byQuota = false;
57
84
  while (rest.length) {
58
85
  const o = rest.shift();
59
86
  if (o === "--until")
@@ -62,12 +89,25 @@ export function block(argv) {
62
89
  until = new Date(Date.now() + Number(rest.shift()) * 86400_000);
63
90
  else if (o === "--reason")
64
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);
65
104
  }
66
105
  if (Number.isNaN(until.getTime())) {
67
106
  console.error(USAGE);
68
107
  return 2;
69
108
  }
70
- b[name] = { until: until.getTime() / 1000, reason };
109
+ const acct = loginId(name);
110
+ b[name] = { until: until.getTime() / 1000, reason, ...(acct ? { acct } : {}) };
71
111
  save(b);
72
112
  console.log(`${name} ${m("out of orq-pick until", "fora do orq-pick até")} ${fmt(until)}`);
73
113
  return 0;
@@ -7,20 +7,25 @@
7
7
  // Refreshes every --interval s on a TTY; otherwise (or with --once) prints once; --json for scripts.
8
8
  // Keys: q quits · r refreshes the quota · l language · t theme (ui.theme: gunmetal | chrome | lcd | terminal).
9
9
  import { execFile, spawn } from "node:child_process";
10
- import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, rmSync, statSync } from "node:fs";
10
+ import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, rmSync, statSync } from "node:fs";
11
11
  import { freemem, homedir, totalmem } from "node:os";
12
12
  import { basename, dirname, join, resolve } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { colorMode } from "../banner.js";
15
+ import { collectCi, collectedAt, loadCache as loadCiCache } from "../ci.js";
15
16
  import { getPath, loadConfig } from "../config.js";
16
17
  import { writeAtomic } from "../fsutil.js";
17
18
  import { m, setLang } from "../i18n.js";
19
+ import { argvModel } from "../agents.js";
18
20
  import { alive, status as lockStatus } from "../lock.js";
19
21
  import { expand, stateDir } from "../paths.js";
20
22
  import { processTableAsync, treeMem } from "../process.js";
21
23
  import { collect } from "../quota.js";
24
+ import { collectRounds, mark, roundsSig } from "../rounds.js";
22
25
  import { collectRuns, rows } from "../stats.js";
26
+ import { pendingNotices, readLog } from "../notice.js";
23
27
  import { load as loadBlocks } from "./block.js";
28
+ import { herdrNamesAsync, nameOf } from "./claim.js";
24
29
  import { setValue } from "./set.js";
25
30
  export const USAGE = "orq board [-w <workspace>]... [--once] [--json] [--interval S] live board: queue, agents, conductors, quota";
26
31
  const CLI = fileURLToPath(new URL("../cli.js", import.meta.url));
@@ -32,19 +37,20 @@ catch {
32
37
  return "";
33
38
  } };
34
39
  const models = new Map();
35
- /** The model a run was started with, from its job file (-m / --model in the argv). */
36
- function modelOf(prefix) {
37
- if (!models.has(prefix)) {
40
+ /** The model of a run recorded before its state file had one, from its job file; a rerun on the same prefix
41
+ * overwrites that file, so another unit's job says nothing about this run. */
42
+ function modelOf(prefix, unit) {
43
+ if (!models.has(unit)) {
38
44
  let v;
39
45
  try {
40
- const a = (JSON.parse(read(`${prefix}.job.json`)).argv ?? []);
41
- const i = a.findIndex((x) => x === "-m" || x === "--model");
42
- v = i >= 0 ? a[i + 1] : a.find((x) => x.startsWith("--model="))?.slice(8);
46
+ const j = JSON.parse(read(`${prefix}.job.json`));
47
+ if (!j.unit || j.unit === unit)
48
+ v = argvModel(j.argv ?? []);
43
49
  }
44
50
  catch { /* no job file */ }
45
- models.set(prefix, v?.split("/").pop());
51
+ models.set(unit, v);
46
52
  }
47
- return models.get(prefix);
53
+ return models.get(unit);
48
54
  }
49
55
  /** What the agent did last, from the tail of its log (stream JSON of claude/opencode, else the last text line). */
50
56
  export function activity(log) {
@@ -107,7 +113,7 @@ function agents(table, now) {
107
113
  continue;
108
114
  if ((st === "ok" || st === "fail") && now - (end ?? at) > RECENT_MS)
109
115
  continue;
110
- const a = { unit: f.slice(0, -5), title: String(s.title ?? ""), agent: String(s.agent ?? "?"), model: modelOf(prefix),
116
+ 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)),
111
117
  host: s.host, pid, issue: basename(dirname(prefix)), label: basename(prefix), state: st,
112
118
  secs: Math.max(0, Math.round(((end ?? now) - at) / 1000)), cap: typeof s.cap === "number" ? s.cap : undefined };
113
119
  if (s.rc !== undefined)
@@ -124,16 +130,40 @@ function agents(table, now) {
124
130
  }
125
131
  const field = (t, k) => new RegExp(`${k}=(\\S+)`).exec(t)?.[1] ?? "";
126
132
  const herdrAlive = (id) => new Promise((res) => execFile("herdr", ["agent", "get", id], { timeout: 5000, windowsHide: true }, (e) => res(!e)));
133
+ // The board redraws every few seconds: reread an issue's reviews only when its folder changed.
134
+ const roundsCache = new Map();
135
+ function cachedRounds(ws, id, max) {
136
+ const key = `${ws}\0${id}\0${max}`, sig = roundsSig(ws, id), hit = roundsCache.get(key);
137
+ if (hit && hit.sig === sig)
138
+ return hit.x;
139
+ const x = collectRounds(ws, id, max);
140
+ roundsCache.set(key, { sig, x });
141
+ return x;
142
+ }
127
143
  async function conductors(wss) {
128
144
  const by = new Map();
129
145
  for (const ws of wss) {
130
146
  const O = join(ws, ".orquestra");
131
- for (const id of existsSync(O) ? readdirSync(O) : []) {
147
+ const max = Number((() => { try {
148
+ return getPath(loadConfig(ws).cfg, "policy.max_rounds");
149
+ }
150
+ catch {
151
+ return undefined;
152
+ } })() ?? 2);
153
+ for (const id of existsSync(O) ? readdirSync(O).filter((x) => !x.startsWith(".")) : []) {
132
154
  const t = read(join(O, id, "owner")).trim();
133
155
  if (!t)
134
156
  continue;
135
157
  const who = field(t, "pane"), at = Date.parse(field(t, "at")) || 0;
136
- const c = by.get(who) ?? { id: who, kind: field(t, "kind") || "herdr", account: field(t, "account"), alive: false, issues: [], at };
158
+ const c = by.get(who) ?? { id: who, kind: field(t, "kind") || "herdr", account: field(t, "account"), alive: false, issues: [], at, name: nameOf(t) };
159
+ if (!c.name)
160
+ c.name = nameOf(t);
161
+ try {
162
+ const x = cachedRounds(ws, id, max);
163
+ if (x)
164
+ (c.rounds ??= {})[id] = { rounds: x.rounds, max: x.max, level: x.level };
165
+ }
166
+ catch { /* unreadable issue folder: no marker */ }
137
167
  c.issues.push(id);
138
168
  c.at = Math.max(c.at, at);
139
169
  by.set(who, c);
@@ -141,7 +171,15 @@ async function conductors(wss) {
141
171
  }
142
172
  const ttl = Number(process.env.ORQ_CLAIM_TTL_H ?? 24) * 3600_000;
143
173
  const out = [...by.values()];
144
- await Promise.all(out.map(async (c) => { c.alive = c.kind === "herdr" ? await herdrAlive(c.id) : Date.now() - c.at < ttl; }));
174
+ // One `herdr workspace list` per refresh, shared by every conductor (not 2 spawns each), off the event
175
+ // loop (execFile, not spawnSync) so a slow/busy herdr daemon delays only this redraw, never key handling.
176
+ const names = out.some((c) => c.kind === "herdr") ? await herdrNamesAsync(out.filter((c) => c.kind === "herdr").map((c) => c.id)) : new Map();
177
+ await Promise.all(out.map(async (c) => {
178
+ c.alive = c.kind === "herdr" ? await herdrAlive(c.id) : Date.now() - c.at < ttl;
179
+ // Alive herdr conductor: the workspace's current label (the user may have renamed it since the claim).
180
+ if (c.alive && c.kind === "herdr")
181
+ c.name = names.get(c.id) || c.name;
182
+ }));
145
183
  return out.filter((c) => c.alive).map(({ at: _, ...c }) => ({ ...c, issues: c.issues.sort() }));
146
184
  }
147
185
  // Quota: the board never waits for the network. A stale cache starts `orq board --collect-quota` detached.
@@ -149,8 +187,9 @@ const qdir = () => join(stateDir(), "board");
149
187
  const qfile = () => join(qdir(), "quota.txt");
150
188
  export function parseQuota(text) {
151
189
  return text.split(/\r?\n/).flatMap((l) => {
152
- const r = /^(\S+) 5h=(\S+) week=(\S+)(?: \[(.*)\])?/.exec(l.trim());
153
- return r ? [{ name: r[1], five: r[2], week: r[3], ...(r[4] ? { note: r[4] } : {}) }] : [];
190
+ const r = /^(\S+) 5h=(\S+) week=(\S+)(?: reset5h=(\d+))?(?: resetweek=(\d+))?(?: span5h=\d+)?(?: spanweek=\d+)?(?: \[(.*)\])?/.exec(l.trim());
191
+ return r ? [{ name: r[1], five: r[2], week: r[3], ...(r[4] ? { reset5h: Number(r[4]) } : {}),
192
+ ...(r[5] ? { resetWeek: Number(r[5]) } : {}), ...(r[6] ? { note: r[6] } : {}) }] : [];
154
193
  });
155
194
  }
156
195
  export function quota(now) {
@@ -179,6 +218,41 @@ async function collectQuota() {
179
218
  writeAtomic(qfile(), lines.join("\n") + "\n");
180
219
  return 0;
181
220
  }
221
+ // CI: the same never-block-the-board rule as quota — a stale cache starts `orq board --collect-ci -w <ws>...`
222
+ // detached, reusing this same timer instead of a new one; the board itself only ever reads the cache file.
223
+ function startCollectCi(wss) {
224
+ const lock = join(qdir(), "ci.pid");
225
+ const pid = Number(read(lock));
226
+ if (pid > 0 && alive(pid))
227
+ return;
228
+ mkdirSync(qdir(), { recursive: true });
229
+ const c = spawn(process.execPath, [CLI, "board", ...wss.flatMap((w) => ["-w", w]), "--collect-ci"], { detached: true, stdio: "ignore", windowsHide: true });
230
+ if (c.pid)
231
+ writeAtomic(lock, String(c.pid));
232
+ c.unref();
233
+ }
234
+ // Age per requested workspace, not the shared cache file's mtime: ci.json holds every workspace's entries and
235
+ // collectCi() rewrites the whole file on every run, so one workspace's collection would otherwise renew the
236
+ // mtime for all of them and starve a second workspace that was never actually collected. collectedAt() reads a
237
+ // per-workspace marker that collectCi() writes even when it finds zero entries (no claimed issue with a matching
238
+ // worktree) — without it, a workspace with nothing to collect would look permanently stale (entries' own `at`
239
+ // would never exist for it) and the board would relaunch the collector every redraw forever.
240
+ function ci(wss, now) {
241
+ const entries = loadCiCache().filter((e) => wss.includes(e.ws));
242
+ const ages = wss.map((w) => { const at = collectedAt(w); return at === undefined ? undefined : now - at; });
243
+ const age = ages.some((a) => a === undefined) ? undefined : Math.max(...ages);
244
+ if (age === undefined || age > QTTL)
245
+ startCollectCi(wss);
246
+ return { entries, age };
247
+ }
248
+ // Detached (stdio: "ignore"): collectCi()'s default notice (console.log) would otherwise vanish into /dev/null,
249
+ // so a non-herdr conductor's "CI failed" line is appended to a log file instead.
250
+ function collectCiAll(wss) {
251
+ const notice = (l) => appendFileSync(join(qdir(), "ci.log"), `${l}\n`);
252
+ for (const ws of wss)
253
+ collectCi(ws, { notice });
254
+ return 0;
255
+ }
182
256
  let sb;
183
257
  function scoreboard(ws, now) {
184
258
  if (!sb || sb.ws !== ws || now - sb.at > 60_000) {
@@ -197,7 +271,7 @@ export async function snapshot(wss, table) {
197
271
  return {
198
272
  at: new Date(now).toISOString(), mem: { used: totalmem() - freemem(), total: totalmem() }, workspaces: wss,
199
273
  queue: { holders: q.holders.map(item), waiters: q.waiters.map(item) }, agents: agents(table, now),
200
- conductors: await conductors(wss), quota: quota(now), blocks: loadBlocks(), scoreboard: scoreboard(wss[0], now),
274
+ conductors: await conductors(wss), quota: quota(now), ci: ci(wss, now), blocks: loadBlocks(), scoreboard: scoreboard(wss[0], now), notices: pendingNotices(readLog(), now),
201
275
  };
202
276
  }
203
277
  // --- render -----------------------------------------------------------------------------------------------------
@@ -251,6 +325,15 @@ export function render(s, theme, cols, mode = colorMode()) {
251
325
  out.push(`${c.surf}${c.b}${c.acc} ORQ${c.b ? "\x1b[22m" : ""}${c.surf}${c.ink2} ${ws}${" ".repeat(Math.max(1, W - left.length - right.length))}${c.mut}${right}`);
252
326
  const mp = Math.round(100 * s.mem.used / s.mem.total);
253
327
  out.push(` ${c.mut}MEM${c.r} ${meter(mp, 24)} ${c.ink}${gb(s.mem.used)}${c.r} ${c.mut}/ ${gb(s.mem.total)}${c.r}`);
328
+ // Red line(s) first: a failure the conductor may not know about is what a person must see before anything else.
329
+ if (s.notices.length) {
330
+ const ago = (at) => dur(Math.max(0, Math.round((new Date(s.at).getTime() - at) / 1000)));
331
+ out.push(` ${c.err}${c.b}✗ ${m(`${s.notices.length} failure(s) the conductor may not know about`, `${s.notices.length} falha(s) que o conductor pode não saber`)}${c.r}`);
332
+ for (const n of s.notices.slice(0, 5)) {
333
+ const what = n.line.replace(/^\[[^\]]+\] /, "").replace(/ answer: .*/, "");
334
+ out.push(` ${c.err} ${fit(n.issue ?? "-", 10)}${c.r} ${c.ink}${fit(what, Math.max(20, W - 44))}${c.r} ${c.err}${n.why === "undelivered" ? m("not delivered", "não entregue") : m("not acknowledged", "sem resposta")}${c.r} ${c.mut}${ago(n.at)}${m(" ago", " atrás")}${c.r}`);
335
+ }
336
+ }
254
337
  section("TRANSPORT", m("test queue", "fila de testes"));
255
338
  for (const h of s.queue.holders)
256
339
  out.push(` ${c.acc}▶ REC${c.r} ${c.ink}${fit(h.label, 34)}${c.r} ${c.ok}${m("running", "rodando")}${c.r} ${c.ink}${dur(h.secs).padEnd(7)}${c.r} ${c.ink2}${gb(h.mem)}${c.r}`
@@ -261,7 +344,7 @@ export function render(s, theme, cols, mode = colorMode()) {
261
344
  section("TRACKS", m("agents", "agentes"));
262
345
  s.agents.forEach((a, i) => {
263
346
  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} `
264
- + `${c.ink}${fit(a.agent, 11)}${c.r}${c.mut}${fit(a.model ?? m("default", "padrão"), 17)}${c.r} ${c.ink}${dur(a.secs).padEnd(7)}${c.r}`;
347
+ + `${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
265
348
  if (a.state === "running") {
266
349
  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;
267
350
  const warn = a.secs > 3 * 3600 ? ` ${c.err}⚠ ${m("for", "há")} ${dur(a.secs)}${c.r}` : "";
@@ -276,9 +359,15 @@ export function render(s, theme, cols, mode = colorMode()) {
276
359
  out.push(` ${c.mut}${m("none", "nenhum")}${c.r}`);
277
360
  section("MASTER", m("conductors", "conductors"));
278
361
  for (const k of s.conductors)
279
- out.push(` ${c.ok}●${c.r} ${c.mut}${fit(k.id, 22)}${c.r} ${c.ink2}${fit(k.account || "-", 14)}${c.r} ${c.ink}${k.issues.join(" ")}${c.r}`);
362
+ 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}`);
280
363
  if (!s.conductors.length)
281
364
  out.push(` ${c.mut}${m("no claimed issues", "nenhuma issue reivindicada")}${c.r}`);
365
+ section("MONITOR", m("CI/CD", "CI/CD"));
366
+ const ciMark = { passed: `${c.ok}✓`, failed: `${c.err}✗`, running: `${c.acc}●`, queued: `${c.mut}‖`, none: `${c.mut}·` };
367
+ for (const e of s.ci.entries)
368
+ out.push(` ${ciMark[e.state]}${c.r} ${c.ink}${fit(e.issue, 10)}${c.r} ${c.ink2}${fit(e.repo, 14)}${c.r} ${c.mut}${fit(e.branch, 24)}${c.r} ${c.ink}${(e.pr ? `#${e.pr}` : "-").padEnd(6)}${c.r} ${c.ink2}${e.state}${c.r}`);
369
+ if (!s.ci.entries.length)
370
+ out.push(` ${c.mut}${s.ci.age === undefined ? m("collecting...", "coletando...") : m("no claimed issue has a PR", "nenhuma issue reivindicada tem PR")}${c.r}`);
282
371
  section("MIXER", m("quota", "cota"));
283
372
  const win = (v, tag) => {
284
373
  const n = /^(\d+)%?$/.exec(v)?.[1];
@@ -329,6 +418,8 @@ export async function board(argv) {
329
418
  interval = Math.max(1, Number(argv[++i]) || 3);
330
419
  else if (a === "--collect-quota")
331
420
  return collectQuota();
421
+ else if (a === "--collect-ci")
422
+ return collectCiAll(wss);
332
423
  else if (a === "-h" || a === "--help") {
333
424
  console.log(USAGE);
334
425
  return 0;
@@ -0,0 +1,48 @@
1
+ // orq ci [-w <workspace>]... [--json] CI/CD signal per claimed issue with a PR: queued | running | passed |
2
+ // failed | none. This is the one caller of src/ci.ts's collectCi() that runs it in the foreground (network and
3
+ // all) instead of reading its cache — the user asked for CI status right now, so it collects right now; `orq
4
+ // board`'s CI column is the one that stays off the network and only reads the cache this same collectCi() fills.
5
+ import { resolve } from "node:path";
6
+ import { collectCi } from "../ci.js";
7
+ import { getPath, loadConfig } from "../config.js";
8
+ import { m } from "../i18n.js";
9
+ import { expand } from "../paths.js";
10
+ export const USAGE = "orq ci [-w <workspace>]... [--json] CI/CD status per claimed issue (queued|running|passed|failed|none)";
11
+ const label = (s) => ({ queued: m("queued", "na fila"), running: m("running", "rodando"),
12
+ passed: m("passed", "passou"), failed: m("failed", "falhou"), none: m("none", "nenhum") })[s];
13
+ export async function ci(argv) {
14
+ const wss = [];
15
+ let json = false;
16
+ for (let i = 0; i < argv.length; i++) {
17
+ const a = argv[i];
18
+ if (a === "-w")
19
+ wss.push(resolve(expand(argv[++i] ?? ".")));
20
+ else if (a === "--json")
21
+ json = true;
22
+ else if (a === "-h" || a === "--help") {
23
+ console.log(USAGE);
24
+ return 0;
25
+ }
26
+ else {
27
+ console.error(USAGE);
28
+ return 2;
29
+ }
30
+ }
31
+ if (!wss.length)
32
+ wss.push(getPath(loadConfig().cfg, "_workspace") ?? process.cwd());
33
+ // --json's stdout must be only the JSON: a non-herdr owner's notice goes to stderr instead of the default
34
+ // stdout, which would otherwise interleave a stray line in front of the JSON.
35
+ const hooks = json ? { notice: (line) => console.error(line) } : {};
36
+ const entries = wss.flatMap((ws) => collectCi(ws, hooks));
37
+ if (json) {
38
+ console.log(JSON.stringify(entries));
39
+ return 0;
40
+ }
41
+ if (!entries.length) {
42
+ console.log(m("no claimed issue has a PR", "nenhuma issue reivindicada tem PR"));
43
+ return 0;
44
+ }
45
+ for (const e of entries)
46
+ console.log(`${e.issue} ${e.repo} ${e.branch} ${e.pr ? `#${e.pr}` : "-"} ${label(e.state)}`);
47
+ return 0;
48
+ }