feat(pr-daemon): see maestro sessions, not just aoe
Sessions started by hand now live in maestro. The daemon listed only aoe, so it found no owner for their branches and spawned a second session on a worktree that already had an agent in it. Both listings now feed one session set, keyed the same way, and a hint goes back out through whichever orchestrator owns the pane. Only the delivery call branches on source; routing, cooldowns and state read one set of names. Sessions the daemon creates are still aoe sessions -- profiles, yolo clearing and the review sandbox have no maestro equivalent. Assumes `maestro send <id> <message>`, which is landing separately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -124,22 +124,31 @@ No project, client, or host name belongs in a committed file here.
|
||||
## PR daemon
|
||||
|
||||
`bin/reviewer-poll.ts` watches PRs on GitHub and Gitea and turns them into
|
||||
Agent of Empires sessions. It is the only thing in this setup that polls a
|
||||
forge: `land` and `review-pr` do no waiting of their own, they react to what
|
||||
the daemon sends them.
|
||||
agent sessions. It is the only thing in this setup that polls a forge: `land`
|
||||
and `review-pr` do no waiting of their own, they react to what the daemon sends
|
||||
them.
|
||||
|
||||
**It reads metadata only** — state, draft, mergeable, head SHA, comment counts
|
||||
— and never a comment body. Its output is typed straight into an agent's prompt
|
||||
by `aoe send`, so untrusted text must not pass through it. What it sends is one
|
||||
into a live pane, so untrusted text must not pass through it. What it sends is one
|
||||
inert line naming a PR, a reason, and a skill; the session fetches the actual
|
||||
content itself, where it knows to treat it as data. Format and semantics are in
|
||||
`skills/pr-common/COMMON.md`.
|
||||
|
||||
**Routing is derived, not registered.** A PR belongs to the session whose
|
||||
worktree sits on its head branch, found through `aoe list --json --all`. No
|
||||
claim files, no database, no cooperation from any skill. A session you started
|
||||
by hand for your own work gets the hints for its branch, and loads the named
|
||||
skill on arrival if it doesn't have it.
|
||||
worktree sits on its head branch. No claim files, no database, no cooperation
|
||||
from any skill. A session you started by hand for your own work gets the hints
|
||||
for its branch, and loads the named skill on arrival if it doesn't have it.
|
||||
|
||||
**Both orchestrators are one session set.** Sessions are listed from `aoe list
|
||||
--json --all` and `maestro list --json` together, and a hint goes back out
|
||||
through whichever one owns the pane. Only the delivery call branches on it;
|
||||
routing, cooldowns and state all read one set of names. This is what stops the
|
||||
daemon spawning a second session on a worktree that already has an agent in it
|
||||
— it used to see the aoe half only. Sessions it creates itself are still aoe
|
||||
sessions, because the profile, yolo and sandbox handling below has no maestro
|
||||
equivalent yet. A maestro that is missing or stopped costs the aoe half
|
||||
nothing: its sessions just go invisible, logged once.
|
||||
|
||||
**Noise is dropped at the source.** A label, an assignee, an edited title all
|
||||
bump `updated_at` and move nothing in the snapshot, so no hint is sent at all.
|
||||
|
||||
+105
-7
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bun
|
||||
// PR daemon: watches forges, routes PRs to aoe sessions.
|
||||
// PR daemon: watches forges, routes PRs to agent sessions (aoe or maestro).
|
||||
// Design and rationale: README "PR daemon".
|
||||
// Hint format and what a session does with one: skills/pr-common/COMMON.md.
|
||||
|
||||
@@ -426,7 +426,11 @@ function isYolo(profile: string, title: string): boolean {
|
||||
const samePath = (a?: string, b?: string) =>
|
||||
!!a && !!b && a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
|
||||
|
||||
type Session = { id: string; title: string; path: string; profile: string; branch: string; mainRepo: string; tool: string };
|
||||
// Which orchestrator owns the pane. It decides one thing -- how a hint is
|
||||
// delivered -- and nothing else in the daemon branches on it.
|
||||
type Source = "aoe" | "maestro";
|
||||
|
||||
type Session = { id: string; title: string; path: string; profile: string; branch: string; mainRepo: string; tool: string; source: Source };
|
||||
|
||||
function gitLine(path: string, args: string[]): string {
|
||||
if (!path) return "";
|
||||
@@ -452,9 +456,75 @@ function repoAt(path: string): string {
|
||||
return dir.replace(/\/\.git\/?$/, "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- maestro
|
||||
|
||||
// Sessions started by hand now live in maestro rather than aoe, and a session
|
||||
// the daemon cannot see is one it spawns a duplicate of -- two agents on the
|
||||
// same worktree, both answering the same PR. Both listings feed one session
|
||||
// set from here on.
|
||||
async function maestro(args: string[]): Promise<string> {
|
||||
const proc = Bun.spawn(["maestro", ...args], { stdout: "pipe", stderr: "pipe" });
|
||||
const out = await new Response(proc.stdout).text();
|
||||
if ((await proc.exited) !== 0) throw new Error(`maestro ${args.join(" ")}: ${await new Response(proc.stderr).text()}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
// maestro's activity vocabulary in aoe's words, because evaluate() reads one
|
||||
// set of names: idle is sendable, anything else holds the hint for a cycle.
|
||||
const MAESTRO_STATE: Record<string, string> = {
|
||||
Idle: "idle",
|
||||
Active: "running",
|
||||
AwaitingInput: "waiting",
|
||||
Error: "error",
|
||||
};
|
||||
|
||||
// A missing or stopped maestro is not a daemon error -- the aoe half keeps
|
||||
// working -- so it degrades to an empty list. Logged once per outage, because
|
||||
// silently routing to half the sessions is exactly the failure this fixes.
|
||||
let maestroWarned = false;
|
||||
async function maestroRows(): Promise<any[]> {
|
||||
try {
|
||||
const rows = JSON.parse(await maestro(["list", "--json"])).sessions ?? [];
|
||||
maestroWarned = false;
|
||||
return rows;
|
||||
} catch (e) {
|
||||
if (!maestroWarned) log(`maestro list failed, its sessions are invisible until it answers: ${e}`);
|
||||
maestroWarned = true;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// `claude --dangerously-skip-permissions` -> `claude`. Only used to keep a PR's
|
||||
// reviewer on a different harness than its author, so a miss costs nothing.
|
||||
function toolOf(row: any): string {
|
||||
const argv0 = String(row.command ?? "").trim().split(/\s+/)[0] ?? "";
|
||||
return (argv0.split("/").pop() || row.foreground || "").trim();
|
||||
}
|
||||
|
||||
// maestro runs anything, including a plain shell. A pane with no agent in it
|
||||
// cannot act on a hint, and letting one own a PR would silence the branch
|
||||
// rather than route it -- so only agent panes join the session set.
|
||||
const AGENTS = new Set(["claude", "codex", "pi", "opencode"]);
|
||||
|
||||
function maestroSession(r: any): Session {
|
||||
const path = r.cwd ?? "";
|
||||
return {
|
||||
id: r.id,
|
||||
title: r.metadata?.name || r.id,
|
||||
path,
|
||||
profile: "", // maestro has no profiles; sendTo never reads this
|
||||
branch: branchAt(path),
|
||||
mainRepo: r.worktree?.base_repo || repoAt(path),
|
||||
tool: toolOf(r),
|
||||
source: "maestro",
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- session listing
|
||||
|
||||
async function listSessions(): Promise<Session[]> {
|
||||
const rows = JSON.parse(await aoe(["list", "--json", "--all"]));
|
||||
return rows.map((r: any) => ({
|
||||
const all: Session[] = rows.map((r: any) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
path: r.path ?? "",
|
||||
@@ -462,12 +532,28 @@ async function listSessions(): Promise<Session[]> {
|
||||
branch: branchAt(r.path ?? "") || r.worktree?.branch || "",
|
||||
mainRepo: r.worktree?.main_repo_path || repoAt(r.path ?? ""),
|
||||
tool: r.tool ?? "",
|
||||
source: "aoe" as const,
|
||||
}));
|
||||
// One worktree can carry a row in both, because aoe attaches to a worktree
|
||||
// maestro already made instead of creating its own. The aoe row wins: it is
|
||||
// the one this daemon may have started, and the only one with a profile.
|
||||
const taken = new Set(all.map((s) => s.path.replace(/\/+$/, "")).filter(Boolean));
|
||||
for (const r of await maestroRows()) {
|
||||
if (r.status !== "Running") continue;
|
||||
const path = String(r.cwd ?? "").replace(/\/+$/, "");
|
||||
if (!path || taken.has(path)) continue;
|
||||
const sess = maestroSession(r);
|
||||
if (!AGENTS.has(sess.tool)) continue;
|
||||
all.push(sess);
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
async function states(): Promise<Map<string, string>> {
|
||||
const rows = JSON.parse(await aoe(["ps", "--json"]));
|
||||
return new Map(rows.map((r: any) => [r.session, r.state]));
|
||||
const map = new Map<string, string>(rows.map((r: any) => [r.session, r.state]));
|
||||
for (const r of await maestroRows()) map.set(r.id, MAESTRO_STATE[r.activity] ?? "unknown");
|
||||
return map;
|
||||
}
|
||||
|
||||
const STOPWORDS = new Set([
|
||||
@@ -753,7 +839,9 @@ async function waitIdle(title: string, ms = 60_000): Promise<boolean> {
|
||||
while (Date.now() < until) {
|
||||
await sleep(3000);
|
||||
const all = await listSessions();
|
||||
const id = all.find((s) => s.title === title)?.id;
|
||||
// aoe only: titles are unique per profile there, and this waits on a
|
||||
// session the daemon just created, which is never a maestro one.
|
||||
const id = all.find((s) => s.source === "aoe" && s.title === title)?.id;
|
||||
if (id && (await states()).get(id) === "idle") return true;
|
||||
}
|
||||
return false;
|
||||
@@ -777,6 +865,16 @@ async function send(profile: string, target: string, message: string): Promise<v
|
||||
await aoe([...args, "send", "--no-revive", target, message]);
|
||||
}
|
||||
|
||||
// The one place the orchestrator matters. Everything upstream routes on branch
|
||||
// and repo and never asks where the session came from.
|
||||
async function sendTo(session: Session, message: string): Promise<void> {
|
||||
if (session.source === "maestro") {
|
||||
await maestro(["send", session.id, message]);
|
||||
return;
|
||||
}
|
||||
await send(session.profile, session.id, message);
|
||||
}
|
||||
|
||||
// One line: `aoe send` types into a pane and a newline submits early.
|
||||
function hint(pr: Pr, why: string[], skill: string): string {
|
||||
return `[pr-daemon] ${pr.forge}:${pr.repo}#${pr.number} reason=${why.join(",")} skill=${skill} updated=${pr.updatedAt}`;
|
||||
@@ -877,7 +975,7 @@ async function evaluate(prs: Pr[], mentioned: Set<string>, budget: { sessions: n
|
||||
}
|
||||
|
||||
if (known && !known.prompted) {
|
||||
await send(known.profile, session.id, opening(full, skill));
|
||||
await sendTo(session, opening(full, skill));
|
||||
known.prompted = true;
|
||||
pending.delete(pkey); // the opening sends it to read the PR whole
|
||||
hintedAt.set(pkey, Date.now());
|
||||
@@ -922,7 +1020,7 @@ async function evaluate(prs: Pr[], mentioned: Set<string>, budget: { sessions: n
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await send(session.profile, session.id, message);
|
||||
await sendTo(session, message);
|
||||
pending.delete(pkey);
|
||||
hintedAt.set(pkey, Date.now());
|
||||
lastHint.set(pkey, message);
|
||||
|
||||
@@ -14,9 +14,12 @@ changed it
|
||||
either creates a session for it or sends a one-line hint to the session
|
||||
that already owns it.
|
||||
|
||||
It finds the owning session through `aoe list --json --all`, matching
|
||||
the PR head branch against `worktree.branch`, so no skill has to
|
||||
register anything anywhere. Nothing you write on disk affects routing.
|
||||
It finds the owning session by listing both orchestrators on the box —
|
||||
`aoe list --json --all` and `maestro list --json` — and matching the PR
|
||||
head branch against the branch each session's directory is actually on,
|
||||
asked of git. So no skill has to register anything anywhere, and it does
|
||||
not matter which orchestrator you started your session in. Nothing you
|
||||
write on disk affects routing.
|
||||
|
||||
## Hints
|
||||
|
||||
@@ -26,7 +29,7 @@ A hint is a single line typed into the session:
|
||||
[pr-daemon] github:acme/webapp#47 reason=comments skill=land updated=2026-08-19T15:42:03Z
|
||||
```
|
||||
|
||||
One line because `aoe send` types into a pane and a newline submits
|
||||
One line because a send types into a live pane and a newline submits
|
||||
early. `reason` is a comma-separated list. Each value maps to exactly
|
||||
one cheap query:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user