Public: anyone can read this; the crew can change it.

Three review rounds on one pull without an approval hand it to a person in one card #1763

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/1176-three-review-rounds-on-one-pull-without → main 9 commits fork 2 threads · 2 unresolved

  • Merged. scout/1176-three-review-rounds-on-one-pull-without was merged into main as f3fcab0.

Review

Sign in to review this change.
8 files changed +416 −89
README.md +12
@@ -433,6 +433,18 @@ changes, a line comment — and the agent picks it up in the background,
433433 resuming its session. A failed check re-engages it too, a bounded number of
434434 times before a person must speak.
435435
436 **Three rounds, then a person.** When the crew reviewer has asked for
437 changes three times on one pull with no approval and no word from a person
438 who may direct the crew between, no more feedback runs start on it. The
439 thread keeps a note ("Handed to a person by the crew:") with the three
440 asks, and the pull becomes a **hand-over** card in asks: *Tell them what to
441 try*, or *Close it*. Rounds are counted from the reviews, not the runs, so a
442 restart never adds one. A person resets the count by commenting as a
443 director (the crew then resumes) or by approving the pull from its page; the
444 card ends when the pull is approved, closed or merged. Hand-overs can be
445 closed in bulk like the other pull asks. The terminal's On you card lists
446 them as "handed over" rows: `enter` opens the pull, `x` closes it.
447
436448 **Already done.** A builder that finds its issue already built on the base
437449 branch declines with `DECLINED: already done on main in <commit>`, naming the
438450 full or abbreviated id of each commit that did it (at most five). The forge
src/crew/dispatch.ts +31 −18
@@ -35,9 +35,6 @@ import { getRepoById, type Repo } from "../core/repos.ts";
3535 import { listReviews } from "../core/reviews.ts";
3636 import {
3737 greenHead,
38 handedToPerson,
39 handoverNote,
40 reviewRounds,
4138 sendToReview,
4239 } from "./review.ts";
4340 import {
@@ -53,7 +50,7 @@ import {
5350 listQueued,
5451 type Run,
5552 } from "../core/runs.ts";
56 import { getUserById } from "../core/users.ts";
53 import { getUserById, type User } from "../core/users.ts";
5754 import { createLog } from "../kernel/log.ts";
5855 import { issueOfBranch, type RunReason } from "./brief.ts";
5956 import { detectClaude } from "./claude.ts";
@@ -62,6 +59,10 @@ import { breakerTripped } from "./breaker.ts";
6259 import {
6360 CAP_NOTE,
6461 capReached,
62 HANDOVER_NOTE,
63 handoverNote,
64 handoverOf,
65 type Handover,
6566 ISSUE_RUN_CAP,
6667 recordCap,
6768 skipsFeedback,
@@ -251,6 +252,25 @@ export async function startCrew(
251252 return handle;
252253 }
253254
255 /** Say the hand-over on the pull's thread, once per hand-over. */
256 function handOver(
257 app: App,
258 repo: Repo,
259 issue: Issue,
260 by: User,
261 over: Handover,
262 ): void {
263 const said = listComments(app.db, issue.id).some(
264 (c) => c.body.startsWith(HANDOVER_NOTE) && c.createdAt >= over.at,
265 );
266 if (said) return;
267 addComment(app, by, repo, issue, handoverNote(over));
268 log.info("handed to a person after three reviews", {
269 repo: `${repo.ownerName}/${repo.name}`,
270 pull: issue.number,
271 });
272 }
273
254274 /** Queue a run about an issue for an agent, unless one is already waiting. */
255275 export function queueAgentRun(
256276 app: App,
@@ -458,20 +478,13 @@ function route(app: App, event: ForgeEvent): void {
458478 }
459479 // The crew is off: a verdict is read by people, and wakes no one.
460480 if (reviewerVerdict && repo.autonomy === "off") return;
461 if (reviewerVerdict) {
462 // The loop is bounded: the third round hands the pull to a person.
463 const pull = getPull(app.db, repo.id, number);
464 if (pull && handedToPerson(app.db, repo.id, pull)) {
465 // One card: a re-sent verdict does not post a second.
466 const last = listComments(app.db, issue.id, { kind: "comment" }).at(-1);
467 if (!last?.body.startsWith("Handed to a person"))
468 addComment(
469 app,
470 actor,
471 repo,
472 issue,
473 handoverNote(reviewRounds(app.db, repo.id, pull)),
474 );
481 // The third changes-requested with no approval or person's word
482 // between is a person's to answer: one card on the pull, and no
483 // feedback run, now or for the verdicts after (`guard.ts`).
484 if (reviewerVerdict && event.payload["state"] === "changes_requested") {

The old path is removed here, but review.ts still uses handedToPerson. Route that through handoverOf too, so the rule is single.

485 const over = handoverOf(app.db, repo, issue.id);
486 if (over !== undefined) {
487 handOver(app, repo, issue, actor, over);
475488 return;
476489 }
477490 }
src/crew/guard.ts +95
@@ -176,3 +176,98 @@ export function guardCounts(
176176 rows.find((r) => r.answer === answer)?.n ?? 0;
177177 return { skipped: of("skipped"), capped: of("capped") };
178178 }
179
180 /** Crew `changes_requested` verdicts on one pull, with no approval or person's word between, before it is a person's. */

Duplicates HANDOVER_ROUNDS and the rule in review.ts. Keep one definition and delete the other.

181 export const HANDOVER_ROUNDS = 3;
182
183 /** What the hand-over card starts with: the thread's own record of it, and what `handover` ignores as a person's word. */
184 export const HANDOVER_NOTE = "Handed to a person by the crew:";
185
186 export interface Handover {
187 /** The crew reviewer's verdicts that asked for changes, oldest first. */
188 readonly reviews: readonly { id: string; author: string; body: string; at: string }[];
189 /** When the third one landed: the card's age. */
190 readonly at: string;
191 }
192
193 /**
194 * Whether `pullId` has had `HANDOVER_ROUNDS` crew `changes_requested` verdicts
195 * since the last approval or the last word of a person who may direct the
196 * crew. Counted from the verdict rows, never the runs: a run a restart
197 * repeats is one verdict.
198 */
199 export function handoverOf(
200 db: Database,
201 repo: Repo,
202 pullId: string,
203 ): Handover | undefined {
204 // The common pull has fewer than three crew verdicts: one cheap count
205 // answers it before the approval, comment and director lookups.
206 const crewAsked = db
207 .query<{ n: number }, [string, string]>(
208 `SELECT COUNT(*) AS n FROM review r
209 JOIN repo_crew c ON c.user_id = r.author_id AND c.repo_id = ?2
210 WHERE r.issue_id = ?1 AND r.state = 'changes_requested' AND c.role = 'reviewer'`,
211 )
212 .get(pullId, repo.id)!.n;
213 if (crewAsked < HANDOVER_ROUNDS) return undefined;
214 const approved =
215 db
216 .query<{ at: string | null }, [string]>(
217 "SELECT MAX(created_at) AS at FROM review WHERE issue_id = ?1 AND state = 'approved'",
218 )
219 .get(pullId)?.at ?? "";
220 const spoke = db
221 .query<{ id: string; at: string }, [string, string]>(
222 `SELECT author_id AS id, MAX(created_at) AS at FROM comment
223 WHERE issue_id = ?1 AND origin_author IS NULL AND kind != 'review'
224 AND body NOT LIKE ?2
225 GROUP BY author_id`,
226 )
227 .all(pullId, `${HANDOVER_NOTE}%`);
228 const directors = new Set(
229 directorsAmong(
230 db,
231 repo,
232 spoke.map((s) => s.id),
233 ),
234 );
235 const since = spoke
236 .filter((s) => directors.has(s.id))
237 .map((s) => s.at)
238 .reduce((a, b) => (a > b ? a : b), approved);
239 const rounds = db
240 .query<
241 { id: string; author: string; body: string; at: string },
242 [string, string, string]
243 >(
244 `SELECT r.id AS id, u.username AS author, r.body AS body, r.created_at AS at
245 FROM review r JOIN user u ON u.id = r.author_id
246 JOIN repo_crew c ON c.user_id = r.author_id AND c.repo_id = ?3
247 WHERE r.issue_id = ?1 AND r.state = 'changes_requested'
248 AND r.created_at > ?2 AND c.role = 'reviewer'
249 ORDER BY r.created_at, r.id`,
250 )
251 .all(pullId, since, repo.id);
252 if (rounds.length < HANDOVER_ROUNDS) return undefined;
253 return { reviews: rounds, at: rounds[HANDOVER_ROUNDS - 1]!.at };
254 }
255
256 /** One line per review, for the card and the thread: who asked, and the first line of what. */
257 export function handoverLines(h: Handover): string[] {
258 return h.reviews.slice(0, HANDOVER_ROUNDS).map((r, n) => {
259 const first = (r.body.split("\n").find((l) => l.trim() !== "") ?? "no words")
260 .trim()
261 .slice(0, 140);
262 return `${n + 1}. ${r.author}: ${first}`;
263 });
264 }
265
266 /** The note the pull's thread keeps of the hand-over. */
267 export function handoverNote(h: Handover): string {
268 return [
269 `${HANDOVER_NOTE} the reviewer asked for changes three times with no approval between, so no more feedback runs start here until you say what to try.`,
270 "",
271 ...handoverLines(h),
272 ].join("\n");
273 }
src/crew/review.ts +13 −47
@@ -24,11 +24,12 @@ import { getHold, lastRelease } from "../core/holds.ts";
2424 import { getIssueById, LIST_LIMIT_MAX } from "../core/issues.ts";
2525 import { listPulls, pullDiff, type Pull, pullDir} from "../core/pulls.ts";
2626 import { getRepoById, type Repo } from "../core/repos.ts";
27 import { listReviews, reviewSummary, type Review } from "../core/reviews.ts";
27 import { reviewSummary } from "../core/reviews.ts";
2828 import { checksForHead } from "../core/runs.ts";
2929 import { repoDir } from "../git/repos.ts";
3030 import { createLog } from "../kernel/log.ts";
3131 import { queueAgentRun } from "./dispatch.ts";
32 import { type Handover, handoverOf } from "./guard.ts";
3233
3334 const log = createLog("crew");
3435
@@ -197,50 +198,15 @@ export function stageOf(
197198 return reviewStage(history.lateAt ?? history.greenAt, history.attempts, now);
198199 }
199200
200 /** Rounds of changes the crew's reviewer asks on one pull before it is handed to a person. */
201 export const HANDOVER_ROUNDS = 3;
202
203201 /**
204 * The crew reviewer's rounds on a pull: its first request for changes on
205 * each head, oldest first. Two requests on one head are one round.
202 * Whether the crew's review loop on this agent pull is spent: the one rule
203 * is `handoverOf` (`guard.ts`), so the card, the thread note and the held
204 * state cannot answer differently.
206205 */
207 export function reviewRounds(db: Database, repoId: string, pull: Pull): Review[] {
208 const reviewers = new Set(
209 reviewersOf(db, repoId).map((a) => a.user.id),
210 );
211 const heads = new Set<string>();
212 const rounds: Review[] = [];
213 for (const r of listReviews(db, pull.id)) {
214 if (r.state !== "changes_requested" || !reviewers.has(r.authorId)) continue;
215 if (heads.has(r.commitOid)) continue;
216 heads.add(r.commitOid);
217 rounds.push(r);
218 }
219 return rounds;
220 }
221
222 /**
223 * Whether the crew's review loop on this agent pull is spent: the
224 * reviewer asked for changes `HANDOVER_ROUNDS` times and no approval is on
225 * the head. Nothing more is sent or started by the crew; a person decides.
226 */
227 export function handedToPerson(db: Database, repoId: string, pull: Pull): boolean {
228 if (!pull.authorIsAgent) return false;
229 if (reviewRounds(db, repoId, pull).length < HANDOVER_ROUNDS) return false;
230 return reviewSummary(db, pull.id, pull.headOid).approvals === 0;
231 }
232
233 /** The one card that hands a pull over: each round's verdict, in a line or two. */
234 export function handoverNote(rounds: readonly Review[]): string {
235 const lines = rounds.map((r, i) => {
236 const said = r.body.trim().replace(/\s+/g, " ");
237 return `${i + 1}. ${r.authorName}: ${said === "" ? "(no words)" : said.length > 300 ? `${said.slice(0, 300)}…` : said}`;
238 });
239 return [
240 `Handed to a person: the reviewer asked for changes ${rounds.length} times without approving, so the crew stops here. Nothing is closed or parked; approve it, push a fix, or say what to do.`,
241 "",
242 ...lines,
243 ].join("\n");
206 function handedOver(db: Database, repoId: string, pull: Pull): Handover | undefined {
207 if (!pull.authorIsAgent) return undefined;
208 const repo = getRepoById(db, repoId);
209 return repo ? handoverOf(db, repo, pull.id) : undefined;
244210 }
245211
246212 /** Why an agent's green pull waits for a person, and since when. */
@@ -269,8 +235,8 @@ export function waitsForPerson(
269235 if (!pull.authorIsAgent || getHold(db, pull.id) !== undefined) return undefined;
270236 const seen = reviewSummary(db, pull.id, pull.headOid);
271237 if (seen.approvals > 0 || seen.changesRequested > 0) return undefined;
272 if (handedToPerson(db, repoId, pull))
273 return { why: "rounds", since: reviewRounds(db, repoId, pull).at(-1)!.createdAt };
238 const over = handedOver(db, repoId, pull);
239 if (over !== undefined) return { why: "rounds", since: over.at };
274240 const history = reviewAttempts(db, pull);
275241 if (history === undefined) return undefined;
276242 if (reviewersOf(db, repoId).length === 0)
@@ -304,7 +270,7 @@ export function waitsForReviewer(
304270 const seen = reviewSummary(db, pull.id, pull.headOid);
305271 if (seen.approvals > 0 || seen.changesRequested > 0) return undefined;
306272 const reviewers = reviewersOf(db, repoId);
307 if (reviewers.length === 0 || handedToPerson(db, repoId, pull)) return undefined;
273 if (reviewers.length === 0 || handedOver(db, repoId, pull) !== undefined) return undefined;
308274 const history = reviewAttempts(db, pull);
309275 if (history === undefined || stageOf(history, now) === "lapsed") return undefined;
310276 const sent = history.attempts.at(-1)?.reviewerId;
@@ -331,7 +297,7 @@ export async function sendToReview(
331297 if (reviewers.length === 0) return "none";
332298 const seen = reviewSummary(app.db, pull.id, pull.headOid);
333299 if (seen.approvals > 0 || seen.changesRequested > 0) return "done";
334 if (handedToPerson(app.db, repo.id, pull)) return "held";
300 if (handedOver(app.db, repo.id, pull) !== undefined) return "held";
335301 const history = reviewAttempts(app.db, pull);
336302 if (history === undefined) return "wait";
337303 const stage = stageOf(history, now);
src/tui/screens/dashboard.ts +28 −13
@@ -94,6 +94,8 @@ interface OnYouItem {
9494 readonly repo: Repo;
9595 /** A hand-over card that asks this person: the crew's issue, not assigned to them. */
9696 readonly hand?: true;
97 /** A pull the crew handed to a person: closed, marked and opened like any row. */
98 readonly over?: true;
9799 }
98100
99101 /** The `tab` order, whichever of these the frame had room for. */
@@ -191,22 +193,35 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
191193 };
192194
193195 /**
194 * The parked cards that ask this person, which the breaker leaves assigned
195 * to the agent: listed after their own work, only where a decider can take them.
196 * The cards that ask this person and are not assigned to them: parked
197 * ones, which the breaker leaves assigned to the agent (only where a
198 * decider can take them), and pulls the crew handed over after three
199 * reviews. Listed after their own work.
196200 */
197201 const handOvers = (have: readonly OnYouItem[]): OnYouItem[] => {
198 if (f.user === undefined || f.app.config.decider === undefined) return [];
199 const ids = askLinesFor(f.db, f.user)
200 .filter((row) => row.kind === "parked")
201 .flatMap((row) => row.items)
202 .filter((id) => id.startsWith("parked:"))
203 .map((id) => id.slice("parked:".length));
202 if (f.user === undefined) return [];
203 const rows = askLinesFor(f.db, f.user);
204 const idsOf = (kind: string, prefix: string): string[] =>
205 rows
206 .filter((row) => row.kind === kind)
207 .flatMap((row) => row.items)
208 .filter((id) => id.startsWith(prefix))
209 .map((id) => id.slice(prefix.length));
210 const wanted: { id: string; flag: "hand" | "over" }[] = [
211 ...(f.app.config.decider === undefined
212 ? []
213 : idsOf("parked", "parked:").map((id) => ({ id, flag: "hand" as const }))),
214 ...idsOf("handover", "handover:").map((id) => ({ id, flag: "over" as const })),
215 ];
204216 const out: OnYouItem[] = [];
205 for (const id of new Set(ids)) {
206 if (have.some((item) => item.issue.id === id)) continue;
217 const seen = new Set<string>();
218 for (const { id, flag } of wanted) {
219 if (seen.has(id) || have.some((item) => item.issue.id === id)) continue;
220 seen.add(id);
207221 const issue = getIssueById(f.db, id);
208222 const repo = issue === undefined ? undefined : getRepoById(f.db, issue.repoId);
209 if (issue !== undefined && repo !== undefined) out.push({ issue, repo, hand: true });
223 if (issue !== undefined && repo !== undefined)
224 out.push({ issue, repo, ...(flag === "hand" ? { hand: true as const } : { over: true as const }) });
210225 }
211226 return out;
212227 };
@@ -221,7 +236,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
221236
222237 /** One verb for the set, through the same action the web and API use. */
223238 const clear = (action: "close" | "mute" | "ship", reason?: string): void => {
224 const items = targets().map((t) => `issue:${t.issue.id}`);
239 const items = targets().map((t) => `${t.over === true ? "handover" : "issue"}:${t.issue.id}`);
225240 if (items.length === 0) return;
226241 runAsync(ctx, async () => {
227242 const done = await applyAsksBulk(f.app, f.user, action, items, reason);
@@ -643,7 +658,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
643658 ? PULL_MARK
644659 : ISSUE_MARK;
645660 return {
646 title: `${marked.has(item.issue.id) ? "[x] " : ""}${item.hand === true ? "parked " : ""}${mark} #${item.issue.number} ${item.issue.title}`,
661 title: `${marked.has(item.issue.id) ? "[x] " : ""}${item.hand === true ? "parked " : item.over === true ? "handed over " : ""}${mark} #${item.issue.number} ${item.issue.title}`,
647662 meta: `${item.repo.ownerName}/${item.repo.name}`,
648663 };
649664 }),
src/web/studio/asks.ts +49 −6
@@ -51,6 +51,7 @@ import {
5151 } from "../../crew/autonomy.ts";
5252 import { breakerParked, WAITING_NOTES } from "../../crew/breaker.ts";
5353 import { CHECK_FAILED_LIMIT } from "../../crew/dispatch.ts";
54 import { handoverLines, handoverOf, type Handover } from "../../crew/guard.ts";
5455 import { isStalePull, mergesAgainSince } from "../../crew/stale_asks.ts";
5556 import { waitsForPerson, waitsForReviewer, type ReviewWait } from "../../crew/review.ts";
5657 import type { Ctx } from "../../http/context.ts";
@@ -98,7 +99,7 @@ export function writableAppsOf(ctx: Ctx, user: User | undefined) {
9899 );
99100 }
100101
101 export type DecisionKind = "stopped" | "look" | "review" | "paused" | "parked" | "declined";
102 export type DecisionKind = "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover";
102103
103104 /** Dollars as a person reads them: cents when small, whole when not. */
104105 const dollars = (n: number): string => (n < 10 ? n.toFixed(2) : n.toFixed(0));
@@ -154,6 +155,11 @@ export const ASK_WORDS: Readonly<Record<DecisionKind, AskWords>> = {
154155 options: ["Tell them"],
155156 ifNothing: "nothing more starts on it",
156157 },
158 handover: {
159 decision: "The reviewer asked for changes three times: what should the builder try?",
160 options: ["Tell them what to try", "Close it"],
161 ifNothing: "no more feedback runs start; the pull waits for you",
162 },
157163 declined: {
158164 decision: "The planner declined the direction: answer it?",
159165 options: ["Answer the planner"],
@@ -183,6 +189,7 @@ export const KIND_TIER: Readonly<Record<DecisionKind, Consequence>> = {
183189 declined: { score: 70, why: "the planner has stopped for this app" },
184190 stopped: { score: 50, why: "red, and the crew stopped" },
185191 review: { score: 45, why: "an agent's change waits for a review" },
192 handover: { score: 55, why: "three reviews without an approval: handed to a person" },
186193 parked: { score: 40, why: "parked by the crew" },
187194 look: { score: 30, why: "green, waiting" },
188195 };
@@ -225,6 +232,8 @@ interface AskCandidate {
225232 readonly stoppedByDecider?: boolean;
226233 /** For "declined": the declining run and what the planner said. */
227234 readonly decline?: { readonly runId: string; readonly words: string };
235 /** For "handover": the reviews that asked for changes. */
236 readonly handover?: Handover;
228237 }
229238
230239 /**
@@ -274,6 +283,22 @@ function askCandidatesFor(
274283 const who = issue.authorName;
275284 // The crew is on it: that is working, not a question for a person.
276285 if (activeRunFor(db, "agent", issue.id) !== undefined) continue;
286 // Three reviews without an approval: the crew stopped, and says so
287 // before anything about the check (`guard.ts`).
288 const over = handoverOf(db, repo, issue.id);
289 if (over !== undefined) {
290 out.push({
291 kind: "handover",
292 id: `handover:${pull.id}`,
293 since: over.at,
294 repo,
295 pull,
296 who,
297 authorIsAgent: issue.authorIsAgent,
298 handover: over,
299 });
300 continue;
301 }
277302 if (check?.state === "failed") {
278303 // Automatic retries, and the limit that stops them, are the crew's
279304 // own machinery — a person's own failing pull never accrues tries,
@@ -606,14 +631,14 @@ function waitingOn(
606631 }
607632
608633 /** How an ask that is no longer asked ended, when nobody dismissed it. */
609 function endOf(
634 export function endOf(
610635 db: Database,
611636 viewerId: string,
612637 askId: string,
613638 raisedAt: string,
614639 ): Exclude<AskEnd, "dismissed"> {
615640 const [kind, what] = askId.split(":") as [DecisionKind, string];
616 if (kind === "look" || kind === "review" || kind === "stopped") {
641 if (kind === "look" || kind === "review" || kind === "stopped" || kind === "handover") {
617642 const pull = getIssueById(db, what);
618643 // Shipped or closed: someone decided. Held by the one it asked: they
619644 // answered "not yet". Still open otherwise: the cause went away.
@@ -691,6 +716,8 @@ function lineOf(c: AskCandidate): string {
691716 return `the crew parked #${c.issue!.number} after three failed runs`;
692717 if (c.kind === "declined")
693718 return "the planner declined the direction and waits for you";
719 if (c.kind === "handover")
720 return `"${c.pull?.title ?? ""}" had three reviews asking for changes and waits for you`;
694721 const title = c.pull?.title ?? "";
695722 if (c.kind === "review")
696723 return `"${title}" waits for your review: ${reviewWhy(c.reviewWait)}`;
@@ -715,6 +742,7 @@ export const ROW_PLURAL: Readonly<Record<DecisionKind, (n: number) => string>> =
715742 review: (n) => `${n} agent changes wait for your review`,
716743 stopped: (n) => `the crew stopped on ${n} changes`,
717744 parked: (n) => `the crew parked ${n} issues after three failed runs`,
745 handover: (n) => `${n} changes had three reviews asking for changes and wait for you`,
718746 declined: (n) => `the planner declined ${n} times and waits for you`,
719747 paused: (n) => `${n} pauses at the ceiling`,
720748 };
@@ -843,6 +871,16 @@ export function decisionsFor(
843871 says: `The planner declined the direction: \u201c${c.decline.words}\u201d. It plans nothing more until you answer on #${c.issue.number}.`,
844872 short: `the planner declined the direction: \u201c${c.decline.words}\u201d`,
845873 };
874 if (c.kind === "handover" && c.pull && c.handover)
875 return {
876 kind: "handover",
877 id: `handover:${c.pull.id}`,
878 repo: c.repo,
879 pull: c.pull,
880 who: c.who,
881 says: `The reviewer asked for changes three times with no approval between, so the crew stopped. ${handoverLines(c.handover).join(" ")} Say what to try (or approve it on its page), or close it.`,
882 short: `"${c.pull.title}" had three reviews asking for changes`,
883 };
846884 if (c.kind === "stopped" && c.pull) {
847885 const tries = c.tries ?? 0;
848886 const stopped = c.stoppedByDecider ?? false;
@@ -919,7 +957,7 @@ export function decisionForm(d: Decision, back?: string): Html | null {
919957 if (pull === undefined) return null;
920958 const base = repoBase(d.repo);
921959 const where = back === undefined ? null : html`<input type="hidden" name="back" value="${back}" />`;
922 if (d.kind === "stopped")
960 if (d.kind === "stopped" || d.kind === "handover")
923961 return html`<form ${own} action="${base}/pulls/${pull.number}/state">
924962 <input type="hidden" name="state" value="closed" />${where}
925963 </form>`;
@@ -951,7 +989,7 @@ export function decisionButtons(ctx: Ctx, d: Decision): { primary: Html; others:
951989 : getAppByRepo(ctx.app.db, d.repo.id) === undefined
952990 ? undefined
953991 : `${base}/app/pull-${pull.number}/open`;
954 if (d.kind === "stopped" && pull)
992 if ((d.kind === "stopped" || d.kind === "handover") && pull)
955993 return {
956994 primary: btn("Tell them", {
957995 href: `${changeHref(base, pull.number)}#comment-body`,
@@ -1065,7 +1103,12 @@ export function asksAnswer(ctx: Ctx, user: User) {
10651103 */
10661104 export function askItemsOf(issue: Issue): string[] {
10671105 return issue.kind === "pull"
1068 ? [`ask:look:${issue.id}`, `ask:review:${issue.id}`, `ask:stopped:${issue.id}`]
1106 ? [
1107 `ask:look:${issue.id}`,
1108 `ask:review:${issue.id}`,
1109 `ask:stopped:${issue.id}`,
1110 `ask:handover:${issue.id}`,
1111 ]
10691112 : [`ask:parked:${issue.id}`];
10701113 }
10711114
src/web/studio/asks_page.ts +2 −1
@@ -62,7 +62,7 @@ const ASK_KIND_WORDS: Readonly<Record<string, string>> = {
6262 };
6363
6464 /** The kinds a bulk action takes: a ceiling and a planner's decline are decided where they live. */
65 const BULK_KINDS: ReadonlySet<Decision["kind"]> = new Set(["look", "review", "stopped", "parked"]);
65 const BULK_KINDS: ReadonlySet<Decision["kind"]> = new Set(["look", "review", "stopped", "handover", "parked"]);
6666
6767 /** A row's box for the bulk form, which sits apart (`form="asks-bulk"`): forms do not nest. */
6868 const pick = (d: Decision, picked: ReadonlySet<string>): Html | null =>
@@ -161,6 +161,7 @@ const KIND_LABEL: Readonly<Record<Decision["kind"], string>> = {
161161 look: "Ready to ship",
162162 review: "Needs review",
163163 stopped: "Stopped",
164 handover: "Handed over",
164165 parked: "Parked",
165166 declined: "Declined",
166167 paused: "Paused",
test/crew-loop.test.ts +186 −4
@@ -14,7 +14,8 @@ import { addComment, openIssue } from "../src/actions/issues.ts";
1414 import { commitToBranch } from "../src/actions/repos.ts";
1515 import { openPull, submitReview } from "../src/actions/pulls.ts";
1616 import { getPull, type Pull } from "../src/core/pulls.ts";
17 import { handedToPerson, sendToReview, waitsForPerson } from "../src/crew/review.ts";
17 import { HANDOVER_NOTE, handoverOf } from "../src/crew/guard.ts";
18 import { sendToReview, waitsForPerson } from "../src/crew/review.ts";
1819 import { createRepo, updateRepoSettings } from "../src/actions/repos.ts";
1920 import { setCrewRole } from "../src/core/agents.ts";
2021 import { getIssueById, listComments, type Issue } from "../src/core/issues.ts";
@@ -25,6 +26,12 @@ import { serve, type Servers } from "../src/http/server.ts";
2526 import { driveRepo, settleAgentRun } from "../src/crew/autonomy.ts";
2627 import { startCrew, queueAgentRun, type CrewHandle } from "../src/crew/dispatch.ts";
2728 import { getAgent } from "../src/core/agents.ts";
29 import type { Ctx } from "../src/http/context.ts";
30 import { setIssueState } from "../src/actions/issues.ts";
31 import { ASK_WORDS, KIND_TIER, askLinesFor, decisionActions, decisionForm, decisionsFor, endOf } from "../src/web/studio/asks.ts";
32 import { createForgeApp } from "../src/tui/forge.ts";
33 import { headlessTerminal } from "../src/tui/term/terminal.ts";
34 import { settleApp } from "./helpers/tui.ts";
2835 import { breakerParked, breakerTripped } from "../src/crew/breaker.ts";
2936
3037 describe("an agent's own comment never starts its next run", () => {
@@ -351,10 +358,10 @@ describe("a crew reviewer's changes-requested wakes the builder", () => {
351358 await ask("Second: the test is missing.");
352359 await push(2);
353360 expect(feedback(pull).length).toBe(1);
354 expect(handedToPerson(app.db, repo.id, pull)).toBe(false);
361 expect(handoverOf(app.db, fresh(), pull.id)).toBeUndefined();
355362 await ask("Third: still no fence.");
356363 expect(feedback(pull).length).toBe(1);
357 expect(handedToPerson(app.db, repo.id, pull)).toBe(true);
364 expect(handoverOf(app.db, fresh(), pull.id)).toBeDefined();
358365 expect(cards().length).toBe(1);
359366 for (const said of ["First: name", "Second: the test", "Third: still"])
360367 expect(cards()[0]!.body).toContain(said);
@@ -367,7 +374,7 @@ describe("a crew reviewer's changes-requested wakes the builder", () => {
367374 // A person's approval ends the handover.
368375 const ok = await submitReview(app, alice, fresh(), pull, { state: "approved" });
369376 if (!ok.ok) throw new Error(String(ok.error));
370 expect(handedToPerson(app.db, repo.id, pull)).toBe(false);
377 expect(handoverOf(app.db, fresh(), pull.id)).toBeUndefined();
371378 });
372379
373380 test("in off, the verdict queues nothing", async () => {
@@ -384,4 +391,179 @@ describe("a crew reviewer's changes-requested wakes the builder", () => {
384391 await settle();
385392 expect(feedback(pull).length).toBe(0);
386393 });
394
395 /** The builder's queued follow-ups end, so each round is counted on its own. */
396 const settleFeedback = (pull: Pull) => {
397 for (const r of feedback(pull)) {
398 claimRun(app.db, r.id);
399 finishRun(app.db, r.id, { state: "ok", summary: "Pushed a fix." });
400 }
401 };
402 const verdict = async (pull: Pull, body: string, state = "changes_requested") => {
403 await submitReview(app, sentinel, fresh(), pull, {
404 state: state as "changes_requested",
405 body,
406 });
407 await settle();
408 };
409 const handoverNotes = (pull: Pull) =>
410 listComments(app.db, pull.id).filter((c) =>
411 c.body.startsWith("Handed to a person by the crew:"),
412 );
413
414 test("the third changes-requested hands the pull to a person; a fourth queues nothing", async () => {
415 const set = await updateRepoSettings(app, alice, fresh(), {
416 autonomy: "autonomous",
417 autonomyCeilingUsd: 100,
418 autonomyBy: alice.id,
419 });
420 if (!set.ok) throw new Error(JSON.stringify(set.error));
421 const pull = await masonPull("rounds");
422 await verdict(pull, "Round one: the token is logged.");
423 expect(feedback(pull).length).toBe(1);
424 settleFeedback(pull);
425 await verdict(pull, "Round two: still logged.");
426 expect(feedback(pull).length).toBe(2);
427 settleFeedback(pull);
428 expect(handoverNotes(pull).length).toBe(0);
429 await verdict(pull, "Round three: and the retry loops.");
430 expect(feedback(pull).length).toBe(2);
431 const notes = handoverNotes(pull);
432 expect(notes.length).toBe(1);
433 expect(notes[0]!.body).toContain("1. sentinel: Round one");
434 expect(notes[0]!.body).toContain("3. sentinel: Round three");
435 // Not parked, not closed: it is still open and asks a person.
436 expect(getIssueById(app.db, pull.id)!.state).toBe("open");
437 const asked = askLinesFor(app.db, alice).filter((a) => a.kind === "handover");
438 expect(asked.length).toBe(1);
439 expect(asked[0]!.number).toBe(pull.number);
440 await verdict(pull, "Round four.");
441 expect(feedback(pull).length).toBe(2);
442 expect(handoverNotes(pull).length).toBe(1);
443 });
444
445 test("a person's comment resets the count", async () => {
446 const pull = await masonPull("reset-comment");
447 await verdict(pull, "One.");
448 settleFeedback(pull);
449 await verdict(pull, "Two.");
450 settleFeedback(pull);
451 await verdict(pull, "Three.");
452 expect(handoverNotes(pull).length).toBe(1);
453 addComment(app, alice, fresh(), getIssueById(app.db, pull.id)!, "Try the other way.");
454 await settle();
455 settleFeedback(pull);
456 expect(askLinesFor(app.db, alice).some((a) => a.number === pull.number && a.kind === "handover")).toBe(false);
457 const before = feedback(pull).length;
458 await verdict(pull, "Four, a new first round.");
459 expect(feedback(pull).length).toBe(before + 1);
460 });
461
462 test("changes-requested from someone who is not the crew's reviewer does not count", async () => {
463 const pull = await masonPull("not-reviewer");
464 for (const n of [1, 2, 3]) {
465 await submitReview(app, alice, fresh(), pull, {
466 state: "changes_requested",
467 body: `A person's ${n}.`,
468 });
469 await settle();
470 }
471 expect(handoverOf(app.db, fresh(), pull.id)).toBeUndefined();
472 expect(handoverNotes(pull).length).toBe(0);
473 });
474
475 test("an approval resets the count", async () => {
476 const pull = await masonPull("reset-approval");
477 await verdict(pull, "One.");
478 settleFeedback(pull);
479 await verdict(pull, "Two.");
480 settleFeedback(pull);
481 await verdict(pull, "Fine now.", "approved");
482 await verdict(pull, "Three.");
483 expect(handoverNotes(pull).length).toBe(0);
484 });
485 test("the hand-over card offers three words, a close and a way to speak; closing or approving ends it", async () => {
486 const pull = await masonPull("card");
487 await verdict(pull, "One.");
488 settleFeedback(pull);
489 await verdict(pull, "Two.");
490 settleFeedback(pull);
491 await verdict(pull, "Three.");
492 expect(ASK_WORDS.handover.options).toEqual(["Tell them what to try", "Close it"]);
493 expect(KIND_TIER.handover).toBeDefined();
494 const ctx = {
495 app,
496 request: new Request("http://forge.test/"),
497 url: new URL("http://forge.test/"),
498 params: {},
499 auth: { user: alice, scope: undefined, via: "session" },
500 theme: undefined,
501 } as Ctx;
502 const card = decisionsFor(ctx, [fresh()]).find((d) => d.kind === "handover");
503 expect(card?.pull?.number).toBe(pull.number);
504 expect(card!.consequence.why).toBe(KIND_TIER.handover.why);
505 expect(card!.says).toContain("Say what to try");
506 expect(askLinesFor(app.db, alice).find((a) => a.kind === "handover")!.words).toEqual(ASK_WORDS.handover);
507 const form = String(decisionForm(card!));
508 expect(form).toContain(`/pulls/${pull.number}/state`);
509 expect(form).toContain('value="closed"');
510 const actions = String(decisionActions(ctx, card!));
511 expect(actions).toContain("Tell them");
512 expect(actions).toContain("Close");
513 expect(actions).not.toContain("Approve");
514 const ask = `handover:${pull.id}`;
515 const raised = new Date(0).toISOString();
516 expect(endOf(app.db, alice.id, ask, raised)).toBe("expired");
517 await verdict(pull, "Fine now.", "approved");
518 expect(askLinesFor(app.db, alice).some((a) => a.number === pull.number && a.kind === "handover")).toBe(false);
519 await setIssueState(app, alice, fresh(), getIssueById(app.db, pull.id)!, "closed");
520 expect(endOf(app.db, alice.id, ask, raised)).toBe("acted");
521 });
522
523 test("a comment that only looks like the hand-over note is not a director's word", async () => {
524 const pull = await masonPull("note-lookalike");
525 await verdict(pull, "One.");
526 settleFeedback(pull);
527 await verdict(pull, "Two.");
528 settleFeedback(pull);
529 await verdict(pull, "Three.");
530 expect(handoverOf(app.db, fresh(), pull.id)).toBeDefined();
531 const issue = getIssueById(app.db, pull.id)!;
532 addComment(app, alice, fresh(), issue, `${HANDOVER_NOTE} pasted by the owner`);
533 expect(handoverOf(app.db, fresh(), pull.id)).toBeDefined();
534 addComment(app, alice, fresh(), issue, "Try the other way.");
535 expect(handoverOf(app.db, fresh(), pull.id)).toBeUndefined();
536 });
537
538 test("the terminal's On you lists a handed-over pull, and x closes it", async () => {
539 const pull = await masonPull("terminal-card");
540 await verdict(pull, "One.");
541 settleFeedback(pull);
542 await verdict(pull, "Two.");
543 settleFeedback(pull);
544 await verdict(pull, "Three.");
545 const first = askLinesFor(app.db, alice)
546 .filter((a) => a.kind === "handover")
547 .flatMap((a) => a.items)[0]!
548 .slice("handover:".length);
549 const terminal = headlessTerminal(200, 40);
550 const { app: ui } = createForgeApp(
551 app,
552 { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user: alice },
553 { terminal, exitHooks: false },
554 );
555 ui.start();
556 const press = async (key: string) => {
557 ui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
558 await settleApp(ui);
559 };
560 await settleApp(ui);
561 await press("tab");
562 expect(ui.screen.toText()).toContain("handed over");
563 expect(getIssueById(app.db, first)!.state).toBe("open");
564 await press("x");
565 await press("enter");
566 expect(getIssueById(app.db, first)!.state).toBe("closed");
567 ui.stop();
568 });
387569 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs