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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1176-three-review-rounds-on-one-pull-without → main 9 commits fork 2 threads · 2 unresolved
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, | ||
| 433 | 433 | resuming its session. A failed check re-engages it too, a bounded number of |
| 434 | 434 | times before a person must speak. |
| 435 | 435 | |
| 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 | ||
| 436 | 448 | **Already done.** A builder that finds its issue already built on the base |
| 437 | 449 | branch declines with `DECLINED: already done on main in <commit>`, naming the |
| 438 | 450 | 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"; | ||
| 35 | 35 | import { listReviews } from "../core/reviews.ts"; |
| 36 | 36 | import { |
| 37 | 37 | greenHead, |
| 38 | handedToPerson, | |
| 39 | handoverNote, | |
| 40 | reviewRounds, | |
| 41 | 38 | sendToReview, |
| 42 | 39 | } from "./review.ts"; |
| 43 | 40 | import { |
| @@ -53,7 +50,7 @@ import { | ||
| 53 | 50 | listQueued, |
| 54 | 51 | type Run, |
| 55 | 52 | } from "../core/runs.ts"; |
| 56 | import { getUserById } from "../core/users.ts"; | |
| 53 | import { getUserById, type User } from "../core/users.ts"; | |
| 57 | 54 | import { createLog } from "../kernel/log.ts"; |
| 58 | 55 | import { issueOfBranch, type RunReason } from "./brief.ts"; |
| 59 | 56 | import { detectClaude } from "./claude.ts"; |
| @@ -62,6 +59,10 @@ import { breakerTripped } from "./breaker.ts"; | ||
| 62 | 59 | import { |
| 63 | 60 | CAP_NOTE, |
| 64 | 61 | capReached, |
| 62 | HANDOVER_NOTE, | |
| 63 | handoverNote, | |
| 64 | handoverOf, | |
| 65 | type Handover, | |
| 65 | 66 | ISSUE_RUN_CAP, |
| 66 | 67 | recordCap, |
| 67 | 68 | skipsFeedback, |
| @@ -251,6 +252,25 @@ export async function startCrew( | ||
| 251 | 252 | return handle; |
| 252 | 253 | } |
| 253 | 254 | |
| 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 | ||
| 254 | 274 | /** Queue a run about an issue for an agent, unless one is already waiting. */ |
| 255 | 275 | export function queueAgentRun( |
| 256 | 276 | app: App, |
| @@ -458,20 +478,13 @@ function route(app: App, event: ForgeEvent): void { | ||
| 458 | 478 | } |
| 459 | 479 | // The crew is off: a verdict is read by people, and wakes no one. |
| 460 | 480 | 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") { | |
| 485 | const over = handoverOf(app.db, repo, issue.id); | |
| 486 | if (over !== undefined) { | |
| 487 | handOver(app, repo, issue, actor, over); | |
| 475 | 488 | return; |
| 476 | 489 | } |
| 477 | 490 | } |
src/crew/guard.ts +95
| @@ -176,3 +176,98 @@ export function guardCounts( | ||
| 176 | 176 | rows.find((r) => r.answer === answer)?.n ?? 0; |
| 177 | 177 | return { skipped: of("skipped"), capped: of("capped") }; |
| 178 | 178 | } |
| 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"; | ||
| 24 | 24 | import { getIssueById, LIST_LIMIT_MAX } from "../core/issues.ts"; |
| 25 | 25 | import { listPulls, pullDiff, type Pull, pullDir} from "../core/pulls.ts"; |
| 26 | 26 | 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"; | |
| 28 | 28 | import { checksForHead } from "../core/runs.ts"; |
| 29 | 29 | import { repoDir } from "../git/repos.ts"; |
| 30 | 30 | import { createLog } from "../kernel/log.ts"; |
| 31 | 31 | import { queueAgentRun } from "./dispatch.ts"; |
| 32 | import { type Handover, handoverOf } from "./guard.ts"; | |
| 32 | 33 | |
| 33 | 34 | const log = createLog("crew"); |
| 34 | 35 | |
| @@ -197,50 +198,15 @@ export function stageOf( | ||
| 197 | 198 | return reviewStage(history.lateAt ?? history.greenAt, history.attempts, now); |
| 198 | 199 | } |
| 199 | 200 | |
| 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 | ||
| 203 | 201 | /** |
| 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. | |
| 206 | 205 | */ |
| 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; | |
| 244 | 210 | } |
| 245 | 211 | |
| 246 | 212 | /** Why an agent's green pull waits for a person, and since when. */ |
| @@ -269,8 +235,8 @@ export function waitsForPerson( | ||
| 269 | 235 | if (!pull.authorIsAgent || getHold(db, pull.id) !== undefined) return undefined; |
| 270 | 236 | const seen = reviewSummary(db, pull.id, pull.headOid); |
| 271 | 237 | 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 }; | |
| 274 | 240 | const history = reviewAttempts(db, pull); |
| 275 | 241 | if (history === undefined) return undefined; |
| 276 | 242 | if (reviewersOf(db, repoId).length === 0) |
| @@ -304,7 +270,7 @@ export function waitsForReviewer( | ||
| 304 | 270 | const seen = reviewSummary(db, pull.id, pull.headOid); |
| 305 | 271 | if (seen.approvals > 0 || seen.changesRequested > 0) return undefined; |
| 306 | 272 | 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; | |
| 308 | 274 | const history = reviewAttempts(db, pull); |
| 309 | 275 | if (history === undefined || stageOf(history, now) === "lapsed") return undefined; |
| 310 | 276 | const sent = history.attempts.at(-1)?.reviewerId; |
| @@ -331,7 +297,7 @@ export async function sendToReview( | ||
| 331 | 297 | if (reviewers.length === 0) return "none"; |
| 332 | 298 | const seen = reviewSummary(app.db, pull.id, pull.headOid); |
| 333 | 299 | 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"; | |
| 335 | 301 | const history = reviewAttempts(app.db, pull); |
| 336 | 302 | if (history === undefined) return "wait"; |
| 337 | 303 | const stage = stageOf(history, now); |
src/tui/screens/dashboard.ts +28 −13
| @@ -94,6 +94,8 @@ interface OnYouItem { | ||
| 94 | 94 | readonly repo: Repo; |
| 95 | 95 | /** A hand-over card that asks this person: the crew's issue, not assigned to them. */ |
| 96 | 96 | readonly hand?: true; |
| 97 | /** A pull the crew handed to a person: closed, marked and opened like any row. */ | |
| 98 | readonly over?: true; | |
| 97 | 99 | } |
| 98 | 100 | |
| 99 | 101 | /** The `tab` order, whichever of these the frame had room for. */ |
| @@ -191,22 +193,35 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 191 | 193 | }; |
| 192 | 194 | |
| 193 | 195 | /** |
| 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. | |
| 196 | 200 | */ |
| 197 | 201 | 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 | ]; | |
| 204 | 216 | 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); | |
| 207 | 221 | const issue = getIssueById(f.db, id); |
| 208 | 222 | 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 }) }); | |
| 210 | 225 | } |
| 211 | 226 | return out; |
| 212 | 227 | }; |
| @@ -221,7 +236,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 221 | 236 | |
| 222 | 237 | /** One verb for the set, through the same action the web and API use. */ |
| 223 | 238 | 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}`); | |
| 225 | 240 | if (items.length === 0) return; |
| 226 | 241 | runAsync(ctx, async () => { |
| 227 | 242 | const done = await applyAsksBulk(f.app, f.user, action, items, reason); |
| @@ -643,7 +658,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 643 | 658 | ? PULL_MARK |
| 644 | 659 | : ISSUE_MARK; |
| 645 | 660 | 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}`, | |
| 647 | 662 | meta: `${item.repo.ownerName}/${item.repo.name}`, |
| 648 | 663 | }; |
| 649 | 664 | }), |
src/web/studio/asks.ts +49 −6
| @@ -51,6 +51,7 @@ import { | ||
| 51 | 51 | } from "../../crew/autonomy.ts"; |
| 52 | 52 | import { breakerParked, WAITING_NOTES } from "../../crew/breaker.ts"; |
| 53 | 53 | import { CHECK_FAILED_LIMIT } from "../../crew/dispatch.ts"; |
| 54 | import { handoverLines, handoverOf, type Handover } from "../../crew/guard.ts"; | |
| 54 | 55 | import { isStalePull, mergesAgainSince } from "../../crew/stale_asks.ts"; |
| 55 | 56 | import { waitsForPerson, waitsForReviewer, type ReviewWait } from "../../crew/review.ts"; |
| 56 | 57 | import type { Ctx } from "../../http/context.ts"; |
| @@ -98,7 +99,7 @@ export function writableAppsOf(ctx: Ctx, user: User | undefined) { | ||
| 98 | 99 | ); |
| 99 | 100 | } |
| 100 | 101 | |
| 101 | export type DecisionKind = "stopped" | "look" | "review" | "paused" | "parked" | "declined"; | |
| 102 | export type DecisionKind = "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover"; | |
| 102 | 103 | |
| 103 | 104 | /** Dollars as a person reads them: cents when small, whole when not. */ |
| 104 | 105 | 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>> = { | ||
| 154 | 155 | options: ["Tell them"], |
| 155 | 156 | ifNothing: "nothing more starts on it", |
| 156 | 157 | }, |
| 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 | }, | |
| 157 | 163 | declined: { |
| 158 | 164 | decision: "The planner declined the direction: answer it?", |
| 159 | 165 | options: ["Answer the planner"], |
| @@ -183,6 +189,7 @@ export const KIND_TIER: Readonly<Record<DecisionKind, Consequence>> = { | ||
| 183 | 189 | declined: { score: 70, why: "the planner has stopped for this app" }, |
| 184 | 190 | stopped: { score: 50, why: "red, and the crew stopped" }, |
| 185 | 191 | 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" }, | |
| 186 | 193 | parked: { score: 40, why: "parked by the crew" }, |
| 187 | 194 | look: { score: 30, why: "green, waiting" }, |
| 188 | 195 | }; |
| @@ -225,6 +232,8 @@ interface AskCandidate { | ||
| 225 | 232 | readonly stoppedByDecider?: boolean; |
| 226 | 233 | /** For "declined": the declining run and what the planner said. */ |
| 227 | 234 | readonly decline?: { readonly runId: string; readonly words: string }; |
| 235 | /** For "handover": the reviews that asked for changes. */ | |
| 236 | readonly handover?: Handover; | |
| 228 | 237 | } |
| 229 | 238 | |
| 230 | 239 | /** |
| @@ -274,6 +283,22 @@ function askCandidatesFor( | ||
| 274 | 283 | const who = issue.authorName; |
| 275 | 284 | // The crew is on it: that is working, not a question for a person. |
| 276 | 285 | 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 | } | |
| 277 | 302 | if (check?.state === "failed") { |
| 278 | 303 | // Automatic retries, and the limit that stops them, are the crew's |
| 279 | 304 | // own machinery — a person's own failing pull never accrues tries, |
| @@ -606,14 +631,14 @@ function waitingOn( | ||
| 606 | 631 | } |
| 607 | 632 | |
| 608 | 633 | /** How an ask that is no longer asked ended, when nobody dismissed it. */ |
| 609 | function endOf( | |
| 634 | export function endOf( | |
| 610 | 635 | db: Database, |
| 611 | 636 | viewerId: string, |
| 612 | 637 | askId: string, |
| 613 | 638 | raisedAt: string, |
| 614 | 639 | ): Exclude<AskEnd, "dismissed"> { |
| 615 | 640 | 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") { | |
| 617 | 642 | const pull = getIssueById(db, what); |
| 618 | 643 | // Shipped or closed: someone decided. Held by the one it asked: they |
| 619 | 644 | // answered "not yet". Still open otherwise: the cause went away. |
| @@ -691,6 +716,8 @@ function lineOf(c: AskCandidate): string { | ||
| 691 | 716 | return `the crew parked #${c.issue!.number} after three failed runs`; |
| 692 | 717 | if (c.kind === "declined") |
| 693 | 718 | 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`; | |
| 694 | 721 | const title = c.pull?.title ?? ""; |
| 695 | 722 | if (c.kind === "review") |
| 696 | 723 | return `"${title}" waits for your review: ${reviewWhy(c.reviewWait)}`; |
| @@ -715,6 +742,7 @@ export const ROW_PLURAL: Readonly<Record<DecisionKind, (n: number) => string>> = | ||
| 715 | 742 | review: (n) => `${n} agent changes wait for your review`, |
| 716 | 743 | stopped: (n) => `the crew stopped on ${n} changes`, |
| 717 | 744 | 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`, | |
| 718 | 746 | declined: (n) => `the planner declined ${n} times and waits for you`, |
| 719 | 747 | paused: (n) => `${n} pauses at the ceiling`, |
| 720 | 748 | }; |
| @@ -843,6 +871,16 @@ export function decisionsFor( | ||
| 843 | 871 | says: `The planner declined the direction: \u201c${c.decline.words}\u201d. It plans nothing more until you answer on #${c.issue.number}.`, |
| 844 | 872 | short: `the planner declined the direction: \u201c${c.decline.words}\u201d`, |
| 845 | 873 | }; |
| 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 | }; | |
| 846 | 884 | if (c.kind === "stopped" && c.pull) { |
| 847 | 885 | const tries = c.tries ?? 0; |
| 848 | 886 | const stopped = c.stoppedByDecider ?? false; |
| @@ -919,7 +957,7 @@ export function decisionForm(d: Decision, back?: string): Html | null { | ||
| 919 | 957 | if (pull === undefined) return null; |
| 920 | 958 | const base = repoBase(d.repo); |
| 921 | 959 | 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") | |
| 923 | 961 | return html`<form ${own} action="${base}/pulls/${pull.number}/state"> |
| 924 | 962 | <input type="hidden" name="state" value="closed" />${where} |
| 925 | 963 | </form>`; |
| @@ -951,7 +989,7 @@ export function decisionButtons(ctx: Ctx, d: Decision): { primary: Html; others: | ||
| 951 | 989 | : getAppByRepo(ctx.app.db, d.repo.id) === undefined |
| 952 | 990 | ? undefined |
| 953 | 991 | : `${base}/app/pull-${pull.number}/open`; |
| 954 | if (d.kind === "stopped" && pull) | |
| 992 | if ((d.kind === "stopped" || d.kind === "handover") && pull) | |
| 955 | 993 | return { |
| 956 | 994 | primary: btn("Tell them", { |
| 957 | 995 | href: `${changeHref(base, pull.number)}#comment-body`, |
| @@ -1065,7 +1103,12 @@ export function asksAnswer(ctx: Ctx, user: User) { | ||
| 1065 | 1103 | */ |
| 1066 | 1104 | export function askItemsOf(issue: Issue): string[] { |
| 1067 | 1105 | 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 | ] | |
| 1069 | 1112 | : [`ask:parked:${issue.id}`]; |
| 1070 | 1113 | } |
| 1071 | 1114 |
src/web/studio/asks_page.ts +2 −1
| @@ -62,7 +62,7 @@ const ASK_KIND_WORDS: Readonly<Record<string, string>> = { | ||
| 62 | 62 | }; |
| 63 | 63 | |
| 64 | 64 | /** 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"]); | |
| 66 | 66 | |
| 67 | 67 | /** A row's box for the bulk form, which sits apart (`form="asks-bulk"`): forms do not nest. */ |
| 68 | 68 | const pick = (d: Decision, picked: ReadonlySet<string>): Html | null => |
| @@ -161,6 +161,7 @@ const KIND_LABEL: Readonly<Record<Decision["kind"], string>> = { | ||
| 161 | 161 | look: "Ready to ship", |
| 162 | 162 | review: "Needs review", |
| 163 | 163 | stopped: "Stopped", |
| 164 | handover: "Handed over", | |
| 164 | 165 | parked: "Parked", |
| 165 | 166 | declined: "Declined", |
| 166 | 167 | paused: "Paused", |
test/crew-loop.test.ts +186 −4
| @@ -14,7 +14,8 @@ import { addComment, openIssue } from "../src/actions/issues.ts"; | ||
| 14 | 14 | import { commitToBranch } from "../src/actions/repos.ts"; |
| 15 | 15 | import { openPull, submitReview } from "../src/actions/pulls.ts"; |
| 16 | 16 | 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"; | |
| 18 | 19 | import { createRepo, updateRepoSettings } from "../src/actions/repos.ts"; |
| 19 | 20 | import { setCrewRole } from "../src/core/agents.ts"; |
| 20 | 21 | import { getIssueById, listComments, type Issue } from "../src/core/issues.ts"; |
| @@ -25,6 +26,12 @@ import { serve, type Servers } from "../src/http/server.ts"; | ||
| 25 | 26 | import { driveRepo, settleAgentRun } from "../src/crew/autonomy.ts"; |
| 26 | 27 | import { startCrew, queueAgentRun, type CrewHandle } from "../src/crew/dispatch.ts"; |
| 27 | 28 | 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"; | |
| 28 | 35 | import { breakerParked, breakerTripped } from "../src/crew/breaker.ts"; |
| 29 | 36 | |
| 30 | 37 | 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", () => { | ||
| 351 | 358 | await ask("Second: the test is missing."); |
| 352 | 359 | await push(2); |
| 353 | 360 | 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(); | |
| 355 | 362 | await ask("Third: still no fence."); |
| 356 | 363 | 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(); | |
| 358 | 365 | expect(cards().length).toBe(1); |
| 359 | 366 | for (const said of ["First: name", "Second: the test", "Third: still"]) |
| 360 | 367 | expect(cards()[0]!.body).toContain(said); |
| @@ -367,7 +374,7 @@ describe("a crew reviewer's changes-requested wakes the builder", () => { | ||
| 367 | 374 | // A person's approval ends the handover. |
| 368 | 375 | const ok = await submitReview(app, alice, fresh(), pull, { state: "approved" }); |
| 369 | 376 | 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(); | |
| 371 | 378 | }); |
| 372 | 379 | |
| 373 | 380 | test("in off, the verdict queues nothing", async () => { |
| @@ -384,4 +391,179 @@ describe("a crew reviewer's changes-requested wakes the builder", () => { | ||
| 384 | 391 | await settle(); |
| 385 | 392 | expect(feedback(pull).length).toBe(0); |
| 386 | 393 | }); |
| 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 | }); | |
| 387 | 569 | }); |
new above
offline
The old path is removed here, but review.ts still uses handedToPerson. Route that through handoverOf too, so the rule is single.