feat(pr-daemon): see maestro sessions, not just aoe
ci / nix (push) Successful in 14s
ci / lint (push) Failing after 19s

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:
Miguel Palhas
2026-09-01 15:04:44 +01:00
parent d1a198ab8b
commit 6b3cabe76a
3 changed files with 129 additions and 19 deletions
+17 -8
View File
@@ -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
View File
@@ -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);
+7 -4
View File
@@ -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: