Public: anyone can read this; the crew can change it.
The tick says why each ready issue cannot start: counts per filter, and the planner acts on them #2254
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/2244-the-tick-says-why-each-ready-issue-canno → main 3 commits fork 3 threads · 3 unresolved
11 files
changed +761
−90
README.md +12
| @@ -664,6 +664,18 @@ is full: no pull has merged in 48 h while one was closed unmerged, or more than | ||
| 664 | 664 | words. A comment on the direction issue from someone who may write there lifts |
| 665 | 665 | the hold; an issue handed to the planner is still owed its pass. |
| 666 | 666 | |
| 667 | Every tick says why each ready issue a builder cannot start is held, counted | |
| 668 | under the first filter that holds it (`crew/held.ts`): `held: running=1 | |
| 669 | open_pull=10 stalled=12 run_cap=11` — a run on it, an open pull proposing it, | |
| 670 | the planner's, stalled, at its run cap, parked twice, covered by a red-main | |
| 671 | fix, or a setting's finding. `/flow` draws the same per autonomous app as one | |
| 672 | bar, and the terminal's Flow command prints it as a line per app | |
| 673 | (`flow/held.ts`, kept 5 s). When nothing is startable while builder slots are free and some of the | |
| 674 | held are stalled, capped or parked twice, the planner is owed one pass with | |
| 675 | that breakdown in its brief, to re-plan them into smaller, fresh issues; a | |
| 676 | full plate and its own unanswered decline do not hold that pass, and the | |
| 677 | same breakdown does not ask it twice. | |
| 678 | ||
| 667 | 679 | The forge asks the host for one more binary: `claude`, on `PATH` or at |
| 668 | 680 | `FORGE_CLAUDE`, authenticated as the operator (an `ANTHROPIC_API_KEY` in the |
| 669 | 681 | forge's environment is handed to runs; the machine's own Claude Code login |
docs/FLOW.md +8
| @@ -55,3 +55,11 @@ Flow command, a forge admin's like the page, opens a dialog with the | ||
| 55 | 55 | bottleneck sentence, the headline and one line per stage of its last-hour |
| 56 | 56 | and 24-hour counts. A sealed issue is counted, its title is |
| 57 | 57 | not shown. |
| 58 | ||
| 59 | Under the pipeline, `/flow` shows each autonomous repository's ready list as | |
| 60 | one bar (#2244): what a builder could start beside the count per filter that | |
| 61 | holds the rest — `crew/held.ts`'s `heldBy`, the same counts the autonomy | |
| 62 | tick logs as `held=`, read through `flow/held.ts`, which keeps the rows 5 s. | |
| 63 | The terminal's Flow command prints the same rows, one line per repository | |
| 64 | (`alice/engine startable=0 stalled=4 run_cap=1`). A granted agent sees | |
| 65 | public repositories only, on both. |
src/crew/autonomy.ts +49 −85
| @@ -35,8 +35,6 @@ import { | ||
| 35 | 35 | crewRole, |
| 36 | 36 | } from "../core/agents.ts"; |
| 37 | 37 | import { pickBuilder, routeIssue } from "./route.ts"; |
| 38 | import { coveredByRedFix } from "./red_fix.ts"; | |
| 39 | import { isSettingFinding } from "./setting_finding.ts"; | |
| 40 | 38 | import { readyNow, repoGraph, type Ranked } from "../core/dependencies.ts"; |
| 41 | 39 | import { |
| 42 | 40 | latestDecision, |
| @@ -91,13 +89,8 @@ import { reviewSummary } from "../core/reviews.ts"; | ||
| 91 | 89 | import { standing } from "../core/standing.ts"; |
| 92 | 90 | import { pace, type Pace } from "./pacing.ts"; |
| 93 | 91 | import { BREAKER_NOTE, breakerTripped } from "./breaker.ts"; |
| 94 | import { | |
| 95 | capReached, | |
| 96 | handedOverPulls, | |
| 97 | runCapOf, | |
| 98 | SKIP_SUMMARY, | |
| 99 | skipsReplan, | |
| 100 | } from "./guard.ts"; | |
| 92 | import { handedOverPulls, SKIP_SUMMARY, skipsReplan } from "./guard.ts"; | |
| 93 | import { atRunCap, type Held, heldBy, heldWords, replannable } from "./held.ts"; | |
| 101 | 94 | import { BY_CREW, DONE_LIKE } from "../core/done_on_main.ts"; |
| 102 | 95 | import { ALREADY_NOTE, alreadyDoneOids } from "./already.ts"; |
| 103 | 96 | import { interruptionOf } from "./interrupted.ts"; |
| @@ -115,8 +108,6 @@ import { | ||
| 115 | 108 | directorsAmong, |
| 116 | 109 | landsOnItsOwn, |
| 117 | 110 | markReaders, |
| 118 | proposedWork, | |
| 119 | speaksForCrew, | |
| 120 | 111 | } from "./direct.ts"; |
| 121 | 112 | import { crewOffInPreview } from "./preview.ts"; |
| 122 | 113 | import { moveRequested } from "./requested.ts"; |
| @@ -364,7 +355,8 @@ export async function driveRepo( | ||
| 364 | 355 | // operator's; at a wall nothing new starts until the reset. |
| 365 | 356 | const paced = repoPace(app, repo, now); |
| 366 | 357 | if (paced.slots === 0 && slotted.length === 0) return "waiting"; |
| 367 | const ready = startable(app, repo, readyNow(repoGraph(app.db, repo.id, { sealed: true })), crew, active); | |
| 358 | const held = heldBy(app, repo, readyNow(repoGraph(app.db, repo.id, { sealed: true })), crew, active); | |
| 359 | const ready = held.startable; | |
| 368 | 360 | if (slotted.length >= paced.slots) { |
| 369 | 361 | // The builders are busy; the planner can still refill the map, when |
| 370 | 362 | // it is thin — counted, never assumed: a full crew with a full map is |
| @@ -412,17 +404,20 @@ export async function driveRepo( | ||
| 412 | 404 | return "picked"; |
| 413 | 405 | } |
| 414 | 406 | |
| 415 | // Nothing is ready: the planner is asked, thin or dry, at most every | |
| 416 | // REPLAN_AFTER_MS. | |
| 417 | if (await planIfThin(app, repo, by, crew, active, 0, now)) return "planned"; | |
| 407 | // Nothing is startable with slots free: the planner is asked, thin or | |
| 408 | // dry, at most every REPLAN_AFTER_MS. When ready issues are held by what | |
| 409 | // re-planning lifts — stalled, capped, parked twice — it is asked with | |
| 410 | // that breakdown, and neither a full plate nor its own unanswered | |
| 411 | // decline holds it: nothing else will move them (#2244). | |
| 412 | const stuck = replannable(held).length > 0 ? held : null; | |
| 413 | if (await planIfThin(app, repo, by, crew, active, 0, now, stuck)) return "planned"; | |
| 418 | 414 | return "idle"; |
| 419 | 415 | } |
| 420 | 416 | |
| 421 | 417 | /** |
| 422 | * The ready issues a builder may start now: not the direction, not in | |
| 423 | * flight, not already proposed in an open pull, not held by the planner or | |
| 424 | * a reviewer, not stalled, capped, parked, covered by a red fix, or a | |
| 425 | * setting's finding. The tick log counts these beside the raw ready list. | |
| 418 | * The ready issues a builder may start now: not the direction, and held by | |
| 419 | * none of `held.ts`'s filters. The tick log counts these beside the raw | |
| 420 | * ready list, and the held ones per filter. | |
| 426 | 421 | */ |
| 427 | 422 | export function startable( |
| 428 | 423 | app: App, |
| @@ -431,24 +426,7 @@ export function startable( | ||
| 431 | 426 | crew: readonly { agent: Agent; role: CrewRole }[], |
| 432 | 427 | active: readonly Run[], |
| 433 | 428 | ): Ranked[] { |
| 434 | const busyIssues = new Set(active.map((r) => r.issueId)); | |
| 435 | const proposed = proposedWork(app.db, repo); | |
| 436 | // An issue a planner or a reviewer holds is theirs, not a builder's. | |
| 437 | const planners = new Set( | |
| 438 | crew.filter((c) => c.role !== "builder").map((c) => c.agent.user.id), | |
| 439 | ); | |
| 440 | return ready.filter( | |
| 441 | (r) => | |
| 442 | r.issue.id !== repo.directionIssueId && | |
| 443 | !busyIssues.has(r.issue.id) && | |
| 444 | !proposed.has(r.issue.number) && | |
| 445 | (r.issue.assigneeId === null || !planners.has(r.issue.assigneeId)) && | |
| 446 | !stalled(app, repo, r.issue) && | |
| 447 | !atRunCap(app, repo, r.issue) && | |
| 448 | parkedCount(app, repo, r.issue.id) < PARK_STRIKES && | |
| 449 | !coveredByRedFix(app.db, repo.id, r.issue) && | |
| 450 | !isSettingFinding(app.db, r.issue.id), | |
| 451 | ); | |
| 429 | return heldBy(app, repo, ready, crew, active).startable; | |
| 452 | 430 | } |
| 453 | 431 | |
| 454 | 432 | /** The map is thin below this many ready issues; the planner refills it. */ |
| @@ -581,6 +559,7 @@ async function planIfThin( | ||
| 581 | 559 | active: readonly Run[], |
| 582 | 560 | ready: number, |
| 583 | 561 | now: Date, |
| 562 | stuck: Held | null = null, | |
| 584 | 563 | ): Promise<boolean> { |
| 585 | 564 | const handed = [...parkedTwice(app.db, [repo]), ...handedOverPulls(app.db, repo)]; |
| 586 | 565 | if (ready >= THIN_MAP && handed.length === 0) return false; |
| @@ -604,19 +583,23 @@ async function planIfThin( | ||
| 604 | 583 | const seen = lastPlan?.created_at ?? ""; |
| 605 | 584 | const owed = handed.filter((issue) => issue.at > seen); |
| 606 | 585 | if (ready >= THIN_MAP && owed.length === 0) return false; |
| 586 | // Nothing startable for issues held since its last pass that re-planning | |
| 587 | // lifts: owed one pass like a hand-over, once (#2244). | |
| 588 | const unstuck = stuck === null ? [] : replannable(stuck).filter((issue) => heldSince(app, issue, seen)); | |
| 589 | const news = owed.length + unstuck.length; | |
| 607 | 590 | // The direction is the planner's standing issue, not one issue retried: |
| 608 | 591 | // it is held to its own day's share, never the builders' cap (#2143). |
| 609 | 592 | if (atRunCap(app, repo, direction)) |
| 610 | 593 | return notPlanned(repo, "parked: the direction is at its day's planning cap"); |
| 611 | 594 | // An issue the crew handed over is owed its pass whatever the hold: the |
| 612 | 595 | // planner splitting it is how work starts landing again. |
| 613 | const hold = owed.length === 0 ? plannerHold(app, repo, now) : null; | |
| 596 | const hold = news === 0 ? plannerHold(app, repo, now) : null; | |
| 614 | 597 | if (hold !== null) return held(repo, holdWhy(hold)); |
| 615 | 598 | if (lastPlan !== null && lastPlan !== undefined) { |
| 616 | 599 | // A planner that declined the direction is not asked again until a |
| 617 | 600 | // person has said something on it, or the crew hands it an issue. |
| 618 | 601 | if ( |
| 619 | owed.length === 0 && | |
| 602 | news === 0 && | |
| 620 | 603 | lastPlan.state === "failed" && |
| 621 | 604 | declinedUnanswered(app, repo, direction) |
| 622 | 605 | ) |
| @@ -631,7 +614,7 @@ async function planIfThin( | ||
| 631 | 614 | // Nobody spoke and the map has not moved since the last pass: another |
| 632 | 615 | // would read its own summary and change nothing (#1097). |
| 633 | 616 | if ( |
| 634 | owed.length === 0 && | |
| 617 | news === 0 && | |
| 635 | 618 | skipsReplan(app.db, repo, direction, planner, lastPlan.created_at) |
| 636 | 619 | ) |
| 637 | 620 | return notPlanned(repo, SKIP_SUMMARY); |
| @@ -650,9 +633,11 @@ async function planIfThin( | ||
| 650 | 633 | log.info( |
| 651 | 634 | owed.length > 0 |
| 652 | 635 | ? "parked twice; planner asked" |
| 653 | : ready === 0 | |
| 654 | ? "map ran dry; planner asked" | |
| 655 | : "map is thin; planner asked", | |
| 636 | : unstuck.length > 0 | |
| 637 | ? "nothing startable; planner asked" | |
| 638 | : ready === 0 | |
| 639 | ? "map ran dry; planner asked" | |
| 640 | : "map is thin; planner asked", | |
| 656 | 641 | { |
| 657 | 642 | repo: `${repo.ownerName}/${repo.name}`, |
| 658 | 643 | planner: planner.user.username, |
| @@ -660,43 +645,25 @@ async function planIfThin( | ||
| 660 | 645 | ...(owed.length > 0 |
| 661 | 646 | ? { parkedTwice: owed.map((issue) => `#${issue.number}`).join(" ") } |
| 662 | 647 | : {}), |
| 648 | ...(stuck === null ? {} : { held: heldWords(stuck) }), | |
| 663 | 649 | }, |
| 664 | 650 | ); |
| 665 | 651 | return true; |
| 666 | 652 | } |
| 667 | 653 | |
| 668 | 654 | /** |
| 669 | * An issue a builder already ran on and left without a pull — declined, or | |
| 670 | * cut short — is not picked again by the clock: running it again blind is | |
| 671 | * the loop autonomy refuses. It waits for a person's word, which is a | |
| 672 | * feedback run like any other. | |
| 655 | * Whether `issue` came to be held after `since` — its newest builder run | |
| 656 | * ended then, which is what stalls or caps it — so a planning pass that | |
| 657 | * started before has not seen it. | |
| 673 | 658 | */ |
| 674 | function stalled(app: App, repo: Repo, issue: Issue): boolean { | |
| 659 | function heldSince(app: App, issue: Issue, since: string): boolean { | |
| 675 | 660 | const last = app.db |
| 676 | .query<{ state: string; summary: string; created_at: string }, [string]>( | |
| 677 | `SELECT state, summary, created_at FROM run WHERE issue_id = ?1 AND kind = 'agent' | |
| 678 | ORDER BY created_at DESC LIMIT 1`, | |
| 661 | .query<{ at: string | null }, [string]>( | |
| 662 | `SELECT MAX(COALESCE(finished_at, created_at)) AS at FROM run | |
| 663 | WHERE issue_id = ?1 AND kind = 'agent'`, | |
| 679 | 664 | ) |
| 680 | .get(issue.id); | |
| 681 | if (last === null || last === undefined) return false; | |
| 682 | if (last.state === "queued" || last.state === "running") return true; | |
| 683 | if (/opened pull #|updated pull #/.test(last.summary)) return false; | |
| 684 | // Cut short by the provider or a restart: nobody tried it, so the clock | |
| 685 | // tries again (`interrupted.ts`). | |
| 686 | if (last.state === "failed" && interruptionOf(last.summary) !== undefined) | |
| 687 | return false; | |
| 688 | // A word since then — from someone who may direct the crew, or the | |
| 689 | // planner's when it re-planned a parked issue — is what un-stalls it. A | |
| 690 | // reader's comment stays on the thread, and so does one imported from | |
| 691 | // an origin, whoever connected it (#619). | |
| 692 | return !app.db | |
| 693 | .query<{ id: string }, [string, string]>( | |
| 694 | `SELECT DISTINCT n.author_id AS id FROM comment n | |
| 695 | WHERE n.issue_id = ?1 AND n.created_at > ?2 AND n.origin_author IS NULL | |
| 696 | AND NOT (n.body LIKE '${DONE_LIKE}' AND ${BY_CREW})`, | |
| 697 | ) | |
| 698 | .all(issue.id, last.created_at) | |
| 699 | .some((r) => speaksForCrew(app.db, repo, getUserById(app.db, r.id))); | |
| 665 | .get(issue.id)?.at; | |
| 666 | return typeof last === "string" && last > since; | |
| 700 | 667 | } |
| 701 | 668 | |
| 702 | 669 | /** Whether the newest run on the issue was a decline and no person has spoken since. */ |
| @@ -799,11 +766,6 @@ export function parkForBreaker( | ||
| 799 | 766 | return true; |
| 800 | 767 | } |
| 801 | 768 | |
| 802 | /** Whether `issue` is at its run cap (`guard.ts`): the clock neither picks nor plans it. */ | |
| 803 | export function atRunCap(app: App, repo: Repo, issue: Issue): boolean { | |
| 804 | return capReached(app.db, repo, issue, runCapOf(repo, issue, app.config)); | |
| 805 | } | |
| 806 | ||
| 807 | 769 | /** How many times the crew has parked this issue since a person who directs it last spoke (`parks.ts`). */ |
| 808 | 770 | export function parkedCount(app: App, repo: Repo, issueId: string): number { |
| 809 | 771 | return countParks(app.db, repo, issueId); |
| @@ -1147,19 +1109,21 @@ export async function autonomyTick( | ||
| 1147 | 1109 | // never a mystery: the numbers behind "idle" are on the line. |
| 1148 | 1110 | const status = autonomyStatus(app, current, now); |
| 1149 | 1111 | const ready = readyNow(repoGraph(app.db, current.id, { sealed: true })); |
| 1112 | const held = heldBy( | |
| 1113 | app, | |
| 1114 | current, | |
| 1115 | ready, | |
| 1116 | listCrewRoles(app.db, current.id), | |
| 1117 | activeRuns(app, current.id), | |
| 1118 | ); | |
| 1150 | 1119 | log.info("tick", { |
| 1151 | 1120 | repo: `${repo.ownerName}/${repo.name}`, |
| 1152 | 1121 | did, |
| 1153 | 1122 | ready: ready.length, |
| 1154 | // What the builders could start after the filters: 47 ready with 0 | |
| 1155 | // startable is every one in a pull, stalled or capped (#2143). | |
| 1156 | startable: startable( | |
| 1157 | app, | |
| 1158 | current, | |
| 1159 | ready, | |
| 1160 | listCrewRoles(app.db, current.id), | |
| 1161 | activeRuns(app, current.id), | |
| 1162 | ).length, | |
| 1123 | // What the builders could start after the filters (#2143), and | |
| 1124 | // what holds the rest, per filter (#2244). | |
| 1125 | startable: held.startable.length, | |
| 1126 | held: heldWords(held), | |
| 1163 | 1127 | active: status.active, |
| 1164 | 1128 | slots: status.slots, |
| 1165 | 1129 | ...(requested.length === 0 |
src/crew/brief.ts +37 −1
| @@ -187,6 +187,20 @@ export interface TaskInput extends BriefInput { | ||
| 187 | 187 | readonly parkedTwice?: readonly HandedOver[] | undefined; |
| 188 | 188 | /** For a planner: the issue deferred work waits behind, if there is one. */ |
| 189 | 189 | readonly laterGate?: number | undefined; |
| 190 | /** For a planner: nothing ready can start, and what holds it (`held.ts`). */ | |
| 191 | readonly stuck?: Stuck | undefined; | |
| 192 | } | |
| 193 | ||
| 194 | /** Ready issues no builder can start, as the planner is told of them (#2244). */ | |
| 195 | export interface Stuck { | |
| 196 | /** The count per filter, as the tick line says it: `open_pull=10 stalled=3`. */ | |
| 197 | readonly held: string; | |
| 198 | /** The held issues re-planning lifts: stalled or at their run cap. */ | |
| 199 | readonly issues: readonly { | |
| 200 | readonly number: number; | |
| 201 | readonly title: string; | |
| 202 | readonly hold: "stalled" | "run_cap"; | |
| 203 | }[]; | |
| 190 | 204 | } |
| 191 | 205 | |
| 192 | 206 | /** An issue the crew parked twice, as the planner is told of it. */ |
| @@ -219,7 +233,9 @@ function taskBody(input: TaskInput): string { | ||
| 219 | 233 | const { issue } = input; |
| 220 | 234 | const out: string[] = []; |
| 221 | 235 | const isAgent = (name: string): boolean => name === input.agent.user.username; |
| 222 | const handed = parkedTwiceLines(input); | |
| 236 | const handed = [parkedTwiceLines(input), stuckLines(input)] | |
| 237 | .filter((lines) => lines.length > 0) | |
| 238 | .flatMap((lines, n) => (n === 0 ? lines : ["", ...lines])); | |
| 223 | 239 | const readers = input.readers ?? new Set<string>(); |
| 224 | 240 | const said = (author: string): string => |
| 225 | 241 | readers.has(author) ? `@${author} ${READER}` : `@${author}`; |
| @@ -515,6 +531,26 @@ function parkedTwiceLines(input: TaskInput): string[] { | ||
| 515 | 531 | ]; |
| 516 | 532 | } |
| 517 | 533 | |
| 534 | /** | |
| 535 | * What a planner is told when no ready issue can start while builders sit | |
| 536 | * free: the count per filter, and each stalled or capped issue, to re-plan | |
| 537 | * now rather than wait for a word nobody is going to write. | |
| 538 | */ | |
| 539 | function stuckLines(input: TaskInput): string[] { | |
| 540 | const stuck = input.role === "planner" ? input.stuck : undefined; | |
| 541 | if (stuck === undefined || stuck.issues.length === 0) return []; | |
| 542 | const issues = `$FORGE_URL/api/v1/repos/${input.repo.ownerName}/${input.repo.name}/issues`; | |
| 543 | return [ | |
| 544 | `Builders are free and no ready issue can start. What holds them: ${stuck.held}.`, | |
| 545 | "Only re-planning moves these; do not wait for a person's comment on them:", | |
| 546 | ...stuck.issues.map( | |
| 547 | (issue) => | |
| 548 | `- #${issue.number} ${issue.title} (${issue.hold === "stalled" ? "stalled: a builder left it without a pull" : "at its run cap for the day"})`, | |
| 549 | ), | |
| 550 | `Re-plan each into smaller, fresh issues a builder can finish in one run: open them, then close the old one as split into them — PATCH ${issues}/N with {"state":"closed","reason":"split","into":[A,B]} — or, for one fresh issue, with "into":[M]. Say on the old issue what the fresh ones do differently.`, | |
| 551 | ]; | |
| 552 | } | |
| 553 | ||
| 518 | 554 | /** |
| 519 | 555 | * The turn after the budget runs out, in the same session: nothing new, |
| 520 | 556 | * what exists made to land well, and the description written anyway. |
src/crew/held.ts added +184
| @@ -0,0 +1,184 @@ | ||
| 1 | /** | |
| 2 | * Why a ready issue cannot start (#2244). The map's ready list is what no | |
| 3 | * open issue stands in front of; a builder may start only the ones no | |
| 4 | * filter holds. Each held issue is counted under the first filter that | |
| 5 | * holds it, so a tick that says `ready=33 startable=0` also says why: | |
| 6 | * `held: open_pull=10 stalled=12 run_cap=11`. | |
| 7 | * | |
| 8 | * The filters, in the order they are asked: | |
| 9 | * - `running`: a run is in flight on it. | |
| 10 | * - `open_pull`: an open pull already proposes it (`direct.ts`). | |
| 11 | * - `planner`: the planner or a reviewer holds it, not a builder. | |
| 12 | * - `stalled`: a builder ran on it and left without a pull, and nobody | |
| 13 | * who directs the crew has spoken since. | |
| 14 | * - `run_cap`: it has used its runs for the day (`guard.ts`). | |
| 15 | * - `parked_twice`: the crew parked it twice; it is the planner's (`parks.ts`). | |
| 16 | * - `red_fix`: a fix for a red main already covers it (`red_fix.ts`). | |
| 17 | * - `setting`: a setting's finding, a person's to answer (`setting_finding.ts`). | |
| 18 | * | |
| 19 | * The direction issue is the planner's standing issue and never a | |
| 20 | * builder's; it is left out, not counted. | |
| 21 | */ | |
| 22 | ||
| 23 | import type { App } from "../app.ts"; | |
| 24 | import { listCrewRoles, type Agent, type CrewRole } from "../core/agents.ts"; | |
| 25 | import { readyNow, repoGraph, type Ranked } from "../core/dependencies.ts"; | |
| 26 | import { BY_CREW, DONE_LIKE } from "../core/done_on_main.ts"; | |
| 27 | import type { Issue } from "../core/issues.ts"; | |
| 28 | import type { Repo } from "../core/repos.ts"; | |
| 29 | import { listActive, type Run } from "../core/runs.ts"; | |
| 30 | import { getUserById } from "../core/users.ts"; | |
| 31 | import type { Stuck } from "./brief.ts"; | |
| 32 | import { proposedWork, speaksForCrew } from "./direct.ts"; | |
| 33 | import { capReached, runCapOf } from "./guard.ts"; | |
| 34 | import { interruptionOf } from "./interrupted.ts"; | |
| 35 | import { PARK_STRIKES, parkedCount } from "./parks.ts"; | |
| 36 | import { coveredByRedFix } from "./red_fix.ts"; | |
| 37 | import { isSettingFinding } from "./setting_finding.ts"; | |
| 38 | ||
| 39 | export const HOLDS = [ | |
| 40 | "running", | |
| 41 | "open_pull", | |
| 42 | "planner", | |
| 43 | "stalled", | |
| 44 | "run_cap", | |
| 45 | "parked_twice", | |
| 46 | "red_fix", | |
| 47 | "setting", | |
| 48 | ] as const; | |
| 49 | ||
| 50 | export type HoldName = (typeof HOLDS)[number]; | |
| 51 | ||
| 52 | /** The holds the planner can lift by re-planning: the rest wait on work in flight or a person. */ | |
| 53 | export const REPLANNABLE: readonly HoldName[] = ["stalled", "run_cap", "parked_twice"]; | |
| 54 | ||
| 55 | export interface Held { | |
| 56 | /** The ready issues a builder may start now, in the map's order. */ | |
| 57 | readonly startable: Ranked[]; | |
| 58 | /** Each held issue under the first filter that holds it. */ | |
| 59 | readonly by: Readonly<Record<HoldName, readonly Issue[]>>; | |
| 60 | } | |
| 61 | ||
| 62 | /** | |
| 63 | * An issue a builder already ran on and left without a pull — declined, or | |
| 64 | * cut short — is not picked again by the clock: running it again blind is | |
| 65 | * the loop autonomy refuses. It waits for a person's word, which is a | |
| 66 | * feedback run like any other. | |
| 67 | */ | |
| 68 | export function stalled(app: App, repo: Repo, issue: Issue): boolean { | |
| 69 | const last = app.db | |
| 70 | .query<{ state: string; summary: string; created_at: string }, [string]>( | |
| 71 | `SELECT state, summary, created_at FROM run WHERE issue_id = ?1 AND kind = 'agent' | |
| 72 | ORDER BY created_at DESC LIMIT 1`, | |
| 73 | ) | |
| 74 | .get(issue.id); | |
| 75 | if (last === null || last === undefined) return false; | |
| 76 | if (last.state === "queued" || last.state === "running") return true; | |
| 77 | if (/opened pull #|updated pull #/.test(last.summary)) return false; | |
| 78 | // Cut short by the provider or a restart: nobody tried it, so the clock | |
| 79 | // tries again (`interrupted.ts`). | |
| 80 | if (last.state === "failed" && interruptionOf(last.summary) !== undefined) | |
| 81 | return false; | |
| 82 | // A word since then — from someone who may direct the crew, or the | |
| 83 | // planner's when it re-planned a parked issue — is what un-stalls it. A | |
| 84 | // reader's comment stays on the thread, and so does one imported from | |
| 85 | // an origin, whoever connected it (#619). | |
| 86 | return !app.db | |
| 87 | .query<{ id: string }, [string, string]>( | |
| 88 | `SELECT DISTINCT n.author_id AS id FROM comment n | |
| 89 | WHERE n.issue_id = ?1 AND n.created_at > ?2 AND n.origin_author IS NULL | |
| 90 | AND NOT (n.body LIKE '${DONE_LIKE}' AND ${BY_CREW})`, | |
| 91 | ) | |
| 92 | .all(issue.id, last.created_at) | |
| 93 | .some((r) => speaksForCrew(app.db, repo, getUserById(app.db, r.id))); | |
| 94 | } | |
| 95 | ||
| 96 | /** Whether `issue` is at its run cap (`guard.ts`): the clock neither picks nor plans it. */ | |
| 97 | export function atRunCap(app: App, repo: Repo, issue: Issue): boolean { | |
| 98 | return capReached(app.db, repo, issue, runCapOf(repo, issue, app.config)); | |
| 99 | } | |
| 100 | ||
| 101 | /** Sort the ready list into what a builder may start and what holds the rest. */ | |
| 102 | export function heldBy( | |
| 103 | app: App, | |
| 104 | repo: Repo, | |
| 105 | ready: readonly Ranked[], | |
| 106 | crew: readonly { agent: Agent; role: CrewRole }[], | |
| 107 | active: readonly Run[], | |
| 108 | ): Held { | |
| 109 | const busyIssues = new Set(active.map((r) => r.issueId)); | |
| 110 | const proposed = proposedWork(app.db, repo); | |
| 111 | // An issue a planner or a reviewer holds is theirs, not a builder's. | |
| 112 | const planners = new Set( | |
| 113 | crew.filter((c) => c.role !== "builder").map((c) => c.agent.user.id), | |
| 114 | ); | |
| 115 | const holdOf = (issue: Issue): HoldName | null => { | |
| 116 | if (busyIssues.has(issue.id)) return "running"; | |
| 117 | if (proposed.has(issue.number)) return "open_pull"; | |
| 118 | if (issue.assigneeId !== null && planners.has(issue.assigneeId)) return "planner"; | |
| 119 | if (stalled(app, repo, issue)) return "stalled"; | |
| 120 | if (atRunCap(app, repo, issue)) return "run_cap"; | |
| 121 | if (parkedCount(app.db, repo, issue.id) >= PARK_STRIKES) return "parked_twice"; | |
| 122 | if (coveredByRedFix(app.db, repo.id, issue)) return "red_fix"; | |
| 123 | if (isSettingFinding(app.db, issue.id)) return "setting"; | |
| 124 | return null; | |
| 125 | }; | |
| 126 | const by = Object.fromEntries(HOLDS.map((h) => [h, [] as Issue[]])) as Record< | |
| 127 | HoldName, | |
| 128 | Issue[] | |
| 129 | >; | |
| 130 | const startable: Ranked[] = []; | |
| 131 | for (const r of ready) { | |
| 132 | if (r.issue.id === repo.directionIssueId) continue; | |
| 133 | const hold = holdOf(r.issue); | |
| 134 | if (hold === null) startable.push(r); | |
| 135 | else by[hold].push(r.issue); | |
| 136 | } | |
| 137 | return { startable, by }; | |
| 138 | } | |
| 139 | ||
| 140 | /** The count per filter, in `HOLDS` order. */ | |
| 141 | export function heldCounts(held: Held): Record<HoldName, number> { | |
| 142 | return Object.fromEntries(HOLDS.map((h) => [h, held.by[h].length])) as Record< | |
| 143 | HoldName, | |
| 144 | number | |
| 145 | >; | |
| 146 | } | |
| 147 | ||
| 148 | /** The counts as the tick line says them: `open_pull=10 stalled=3`, the empty ones left out. */ | |
| 149 | export function heldWords(held: Held): string { | |
| 150 | const said = HOLDS.filter((h) => held.by[h].length > 0).map( | |
| 151 | (h) => `${h}=${held.by[h].length}`, | |
| 152 | ); | |
| 153 | return said.length === 0 ? "none" : said.join(" "); | |
| 154 | } | |
| 155 | ||
| 156 | /** The held issues the planner can lift by re-planning them. */ | |
| 157 | export function replannable(held: Held): Issue[] { | |
| 158 | return REPLANNABLE.flatMap((h) => held.by[h]); | |
| 159 | } | |
| 160 | ||
| 161 | /** The ready list sorted now, from the map, the crew and the runs in flight. */ | |
| 162 | export function heldNow(app: App, repo: Repo): Held { | |
| 163 | return heldBy( | |
| 164 | app, | |
| 165 | repo, | |
| 166 | readyNow(repoGraph(app.db, repo.id, { sealed: true })), | |
| 167 | listCrewRoles(app.db, repo.id), | |
| 168 | listActive(app.db, "agent").filter((r) => r.repoId === repo.id), | |
| 169 | ); | |
| 170 | } | |
| 171 | ||
| 172 | /** | |
| 173 | * What a planning pass is told when nothing ready can start: the counts, | |
| 174 | * and the stalled and capped issues to re-plan. Parked-twice ones reach it | |
| 175 | * as hand-overs already (`parks.ts`). Undefined when something can start | |
| 176 | * or nothing re-planning lifts holds the rest. | |
| 177 | */ | |
| 178 | export function stuckOf(held: Held): Stuck | undefined { | |
| 179 | if (held.startable.length > 0) return undefined; | |
| 180 | const issues = (["stalled", "run_cap"] as const).flatMap((hold) => | |
| 181 | held.by[hold].map((issue) => ({ number: issue.number, title: issue.title, hold })), | |
| 182 | ); | |
| 183 | return issues.length === 0 ? undefined : { held: heldWords(held), issues }; | |
| 184 | } |
src/crew/runner.ts +4 −1
| @@ -108,6 +108,7 @@ import { clearStaleLocks, lockedOut } from "./locks.ts"; | ||
| 108 | 108 | import { interruptedSummary } from "./interrupted.ts"; |
| 109 | 109 | import { laterGate } from "./later.ts"; |
| 110 | 110 | import { parkedTwice } from "./parks.ts"; |
| 111 | import { heldNow, stuckOf } from "./held.ts"; | |
| 111 | 112 | import { noteLimit } from "../core/standing.ts"; |
| 112 | 113 | import { pauseReason } from "./pacing.ts"; |
| 113 | 114 | import { |
| @@ -913,11 +914,13 @@ async function work( | ||
| 913 | 914 | .get(issue.id, run.id, run.createdAt)!.n > 0, |
| 914 | 915 | } |
| 915 | 916 | : {}), |
| 916 | // A planning run is given what the crew handed over (`parks.ts`). | |
| 917 | // A planning run is given what the crew handed over (`parks.ts`), | |
| 918 | // and what holds the ready list when nothing on it can start (`held.ts`). | |
| 917 | 919 | ...(brief.role === "planner" && issue.id === repo.directionIssueId |
| 918 | 920 | ? { |
| 919 | 921 | parkedTwice: [...parkedTwice(app.db, [repo]), ...handedOverPulls(app.db, repo)], |
| 920 | 922 | laterGate: laterGate(app.db, repo.id), |
| 923 | stuck: stuckOf(heldNow(app, repo)), | |
| 921 | 924 | } |
| 922 | 925 | : {}), |
| 923 | 926 | checkKind: |
src/flow/held.ts added +47
| @@ -0,0 +1,47 @@ | ||
| 1 | /** | |
| 2 | * Why ready issues cannot start, per autonomous repository (#2244): the | |
| 3 | * rows /flow draws as bars and the terminal's Flow dialog prints as lines — | |
| 4 | * `crew/held.ts`'s counts, the same the autonomy tick logs as `held=`. | |
| 5 | * | |
| 6 | * Sorting a ready list asks a few queries per issue, so the rows are kept | |
| 7 | * for a few seconds: a page reloaded, or the dialog opened beside it, reads | |
| 8 | * them once. The tick never reads this cache; it sorts afresh every time. | |
| 9 | */ | |
| 10 | ||
| 11 | import type { App } from "../app.ts"; | |
| 12 | import { listAutonomousRepos } from "../core/repos.ts"; | |
| 13 | import { heldCounts, heldNow, type HoldName } from "../crew/held.ts"; | |
| 14 | ||
| 15 | export interface HeldRow { | |
| 16 | /** `owner/name`. */ | |
| 17 | readonly repo: string; | |
| 18 | readonly public: boolean; | |
| 19 | readonly startable: number; | |
| 20 | readonly counts: Readonly<Record<HoldName, number>>; | |
| 21 | } | |
| 22 | ||
| 23 | /** How long the rows are kept. */ | |
| 24 | export const HELD_TTL_MS = 5_000; | |
| 25 | ||
| 26 | const kept = new WeakMap<App, { at: number; rows: readonly HeldRow[] }>(); | |
| 27 | ||
| 28 | /** Each autonomous repository's ready list, sorted by what holds it; private ones for the owner only. */ | |
| 29 | export function readHeld(app: App, owner: boolean, now = Date.now()): HeldRow[] { | |
| 30 | let entry = kept.get(app); | |
| 31 | if (entry === undefined || now - entry.at >= HELD_TTL_MS || now < entry.at) { | |
| 32 | entry = { | |
| 33 | at: now, | |
| 34 | rows: listAutonomousRepos(app.db).map((repo) => { | |
| 35 | const held = heldNow(app, repo); | |
| 36 | return { | |
| 37 | repo: `${repo.ownerName}/${repo.name}`, | |
| 38 | public: repo.visibility === "public", | |
| 39 | startable: held.startable.length, | |
| 40 | counts: heldCounts(held), | |
| 41 | }; | |
| 42 | }), | |
| 43 | }; | |
| 44 | kept.set(app, entry); | |
| 45 | } | |
| 46 | return entry.rows.filter((r) => owner || r.public); | |
| 47 | } |
src/tui/forge.ts +2
| @@ -53,6 +53,7 @@ import { domainsScreen } from "./screens/domains.ts"; | ||
| 53 | 53 | import { ownerScreen } from "./screens/owner.ts"; |
| 54 | 54 | import { resilienceLines, resilienceScreen } from "./screens/resilience.ts"; |
| 55 | 55 | import { flowLines, flowScreen } from "./screens/flow.ts"; |
| 56 | import { readHeld } from "../flow/held.ts"; | |
| 56 | 57 | import { memoryScreen, systemLines, systemScreen } from "./screens/system.ts"; |
| 57 | 58 | import { grantable, READS_KEPT, recentReads } from "../core/telemetry.ts"; |
| 58 | 59 | import { noteRead, setTelemetryGrant, telemetryReader } from "../actions/telemetry.ts"; |
| @@ -267,6 +268,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 267 | 268 | new Date(), |
| 268 | 269 | systemFor(session.app, flowReader).duplicates, |
| 269 | 270 | flowReader.kind === "agent" ? { queues: queuesFor(session.app, flowReader) } : undefined, |
| 271 | readHeld(session.app, flowReader.kind === "owner"), | |
| 270 | 272 | ))); |
| 271 | 273 | }, |
| 272 | 274 | }); |
src/tui/screens/flow.ts +28 −3
| @@ -6,11 +6,15 @@ | ||
| 6 | 6 | * MCP `flow` tool read. Under them, each repository's merge queue (#2036): |
| 7 | 7 | * queued, gating, landed in the last hour (`flow/queue.ts`); then the |
| 8 | 8 | * conflicted pulls, and the pulls with changes requested waiting on a |
| 9 | * builder and on a reviewer (`flow/requested.ts`, #2172). Read-only; | |
| 10 | * Enter, Esc or q closes it. | |
| 9 | * builder and on a reviewer (`flow/requested.ts`, #2172); then each | |
| 10 | * autonomous repository's ready list, startable beside the count per | |
| 11 | * filter that holds the rest (`flow/held.ts`, #2244), as /flow draws it. | |
| 12 | * Read-only; Enter, Esc or q closes it. | |
| 11 | 13 | */ |
| 12 | 14 | |
| 13 | 15 | import type { Database } from "bun:sqlite"; |
| 16 | import { HOLDS } from "../../crew/held.ts"; | |
| 17 | import type { HeldRow } from "../../flow/held.ts"; | |
| 14 | 18 | import { conflictedPulls, conflictedSays } from "../../flow/conflicted.ts"; |
| 15 | 19 | import { readPipeline, STAGE_LABEL, verdict } from "../../flow/pipeline.ts"; |
| 16 | 20 | import { readQueues } from "../../flow/queue.ts"; |
| @@ -31,7 +35,9 @@ export function flowLines( | ||
| 31 | 35 | now = new Date(), |
| 32 | 36 | dups = live().read().duplicates, |
| 33 | 37 | /** What an agent reader may see: its queue lines, conflicted pulls and changes-requested waits come already cut to public. */ |
| 34 | agent?: { queues: ReturnType<typeof readQueues> }, | |
| 38 | agent?: { queues: ReturnType<typeof readQueues> } | undefined, | |
| 39 | /** Why ready issues wait, per autonomous repository (`readHeld`, already cut to what the reader may see). */ | |
| 40 | held?: readonly HeldRow[], | |
| 35 | 41 | ): string[] { |
| 36 | 42 | const stages = readPipeline(db, now); |
| 37 | 43 | const flow = readThroughput(db, stages.map((s) => s.key), now); |
| @@ -63,11 +69,30 @@ export function flowLines( | ||
| 63 | 69 | )), |
| 64 | 70 | ...(conflicted === null ? [] : ["", ...wrap(conflicted, inner)]), |
| 65 | 71 | ...waits, |
| 72 | ...(held === undefined ? [] : ["", ...heldLines(held, inner)]), | |
| 66 | 73 | "", |
| 67 | 74 | ...duplicateLines(dups, inner), |
| 68 | 75 | ]; |
| 69 | 76 | } |
| 70 | 77 | |
| 78 | /** One line per autonomous repository: its name, then `startable=N` and each filter holding any. */ | |
| 79 | export function heldLines(rows: readonly HeldRow[], inner: number): string[] { | |
| 80 | if (rows.length === 0) return ["Ready, and why it waits: no repository runs on its own."]; | |
| 81 | return [ | |
| 82 | "Ready, and why it waits:", | |
| 83 | ...rows.flatMap((r) => | |
| 84 | wrap( | |
| 85 | [ | |
| 86 | r.repo, | |
| 87 | `startable=${r.startable}`, | |
| 88 | ...HOLDS.filter((h) => r.counts[h] > 0).map((h) => `${h}=${r.counts[h]}`), | |
| 89 | ].join(" "), | |
| 90 | inner, | |
| 91 | ), | |
| 92 | ), | |
| 93 | ]; | |
| 94 | } | |
| 95 | ||
| 71 | 96 | export function flowScreen(ctx: Ctx, lines: readonly string[]): Screen { |
| 72 | 97 | return { |
| 73 | 98 | title: "Flow", |
src/web/pages/flow.ts +73
| @@ -21,6 +21,11 @@ | ||
| 21 | 21 | * Beside the conflicted pulls, the pulls with changes requested (#2172): |
| 22 | 22 | * those waiting on a builder apart from those waiting on a reviewer, by |
| 23 | 23 | * age — `flow/requested.ts`'s, the rows the tick moves. |
| 24 | * | |
| 25 | * Under the pipeline, why ready issues cannot start (#2244): per autonomous | |
| 26 | * repository, its ready list as one bar, startable beside each filter that | |
| 27 | * holds the rest — `flow/held.ts`'s rows, the same the tick line says and | |
| 28 | * the terminal's Flow command prints. | |
| 24 | 29 | */ |
| 25 | 30 | |
| 26 | 31 | import { noteRead, telemetryReader } from "../../actions/telemetry.ts"; |
| @@ -47,6 +52,8 @@ import { | ||
| 47 | 52 | type StageThroughput, |
| 48 | 53 | } from "../../flow/throughput.ts"; |
| 49 | 54 | import { readQueues, type QueueRow } from "../../flow/queue.ts"; |
| 55 | import { HOLDS } from "../../crew/held.ts"; | |
| 56 | import { readHeld, type HeldRow } from "../../flow/held.ts"; | |
| 50 | 57 | import type { Ctx } from "../../http/context.ts"; |
| 51 | 58 | import { live } from "../../system/live.ts"; |
| 52 | 59 | import { notFoundPage, signInFirst } from "../chrome.ts"; |
| @@ -59,6 +66,18 @@ const css = /* css */ ` | ||
| 59 | 66 | .flow-out svg { width: 72px; height: 16px; } |
| 60 | 67 | .flow-bar { fill: var(--muted-fg); } |
| 61 | 68 | .flow-bar[data-empty] { fill: var(--muted); } |
| 69 | .held-bar { width: 100%; height: 12px; display: block; } | |
| 70 | .held-key { display: inline-flex; align-items: center; gap: 4px; margin-right: 12px; font-variant-numeric: tabular-nums; } | |
| 71 | .held-key svg { width: 10px; height: 10px; } | |
| 72 | .held-startable { fill: var(--success); } | |
| 73 | .held-running { fill: var(--accent); } | |
| 74 | .held-open_pull { fill: var(--merged); } | |
| 75 | .held-planner { fill: var(--primary); } | |
| 76 | .held-stalled { fill: var(--attn); } | |
| 77 | .held-run_cap { fill: var(--danger); } | |
| 78 | .held-parked_twice { fill: var(--del-fg); } | |
| 79 | .held-red_fix { fill: var(--muted-fg); } | |
| 80 | .held-setting { fill: var(--border-strong); } | |
| 62 | 81 | `; |
| 63 | 82 | |
| 64 | 83 | /** What `?out=` and `?hour=` ask to list, checked: an unknown stage or hour lists nothing. */ |
| @@ -193,11 +212,65 @@ export function flowPage(ctx: Ctx): Response { | ||
| 193 | 212 | </div>`, |
| 194 | 213 | { pad: false }, |
| 195 | 214 | )} |
| 215 | ${heldCard(readHeld(ctx.app, reader.kind === "owner", now))} | |
| 196 | 216 | ${queueCard(queuesFor(ctx.app, reader, new Date(now)))} |
| 197 | 217 | ${listed === null || shown === undefined ? null : outList(shown, listed.hour, now)}`; |
| 198 | 218 | return page({ ctx, title: "Flow", body, styleCss: css }); |
| 199 | 219 | } |
| 200 | 220 | |
| 221 | /** One segment per kind, as wide as its share: a picture only; the counts beside it are the words. */ | |
| 222 | function heldBar(row: HeldRow): Html { | |
| 223 | const parts: [string, number][] = [ | |
| 224 | ["startable", row.startable], | |
| 225 | ...HOLDS.map((h): [string, number] => [h, row.counts[h]]), | |
outdated
heldNow is computed per request for every autonomous repo, with per-issue queries. Cache it briefly or measure it. The same rows must also reach the terminal Flow screen. |
||
| 226 | ]; | |
| 227 | const total = parts.reduce((n, [, c]) => n + c, 0); | |
| 228 | let x = 0; | |
| 229 | const rects = parts | |
| 230 | .filter(([, c]) => c > 0) | |
| 231 | .map(([kind, c]) => { | |
| 232 | const w = (c / total) * 100; | |
| 233 | const rect = html`<rect class="held-${kind}" x="${x.toFixed(2)}" y="0" width="${w.toFixed(2)}" height="12" />`; | |
| 234 | x += w; | |
| 235 | return rect; | |
| 236 | }); | |
| 237 | return html`<svg class="held-bar" viewBox="0 0 100 12" preserveAspectRatio="none" aria-hidden="true" focusable="false">${rects}</svg>`; | |
| 238 | } | |
| 239 | ||
| 240 | function heldKey(kind: string, n: number): Html { | |
| 241 | return html`<span class="held-key small" data-held="${kind}"> | |
| 242 | <svg viewBox="0 0 10 10" aria-hidden="true" focusable="false"><rect class="held-${kind}" width="10" height="10" /></svg> | |
| 243 | <span class="mono">${kind}=${String(n)}</span> | |
| 244 | </span>`; | |
| 245 | } | |
| 246 | ||
| 247 | /** Why ready issues cannot start, per autonomous repository (#2244). */ | |
| 248 | function heldCard(rows: readonly HeldRow[]): Html { | |
| 249 | return card( | |
| 250 | html`<h2>Ready, and why it waits</h2> | |
| 251 | <p class="muted small"> | |
| 252 | Each ready issue a builder could start, or the first filter that holds | |
| 253 | it: a run on it, an open pull, the planner, stalled, its run cap, | |
| 254 | parked twice, a red-main fix, a setting's finding. When nothing can | |
| 255 | start and builders are free, the planner is asked to re-plan what is | |
| 256 | stalled or capped. | |
| 257 | </p> | |
| 258 | ${rows.length === 0 | |
| 259 | ? html`<p class="muted">No repository runs on its own.</p>` | |
| 260 | : rows.map( | |
| 261 | (r) => html`<div data-held-repo="${r.repo}"> | |
| 262 | <p><a href="/${r.repo}/map">${r.repo}</a></p> | |
| 263 | ${heldBar(r)} | |
| 264 | <p> | |
| 265 | ${heldKey("startable", r.startable)} | |
| 266 | ${HOLDS.filter((h) => r.counts[h] > 0).map((h) => heldKey(h, r.counts[h]))} | |
| 267 | </p> | |
| 268 | </div>`, | |
| 269 | )}`, | |
| 270 | { id: "held" }, | |
| 271 | ); | |
| 272 | } | |
| 273 | ||
| 201 | 274 | /** The merge queue, one row per repository with something in it or landed. */ |
| 202 | 275 | function queueCard(rows: readonly QueueRow[]): Html { |
| 203 | 276 | return card( |
test/crew-held.test.ts added +317
| @@ -0,0 +1,317 @@ | ||
| 1 | /** | |
| 2 | * Why each ready issue cannot start (#2244): an issue held by each filter | |
| 3 | * is counted under that filter's name, the tick's words say the counts, | |
| 4 | * and a crew with free slots and nothing startable asks the planner once, | |
| 5 | * with the breakdown — a full plate does not hold it. /flow and the | |
| 6 | * terminal's Flow dialog show the same counts, private repositories to the | |
| 7 | * owner only. Each test makes its own repository and crew. | |
| 8 | */ | |
| 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 10 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 11 | import { tmpdir } from "node:os"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 15 | import { addComment, assignIssue, openIssue } from "../src/actions/issues.ts"; | |
| 16 | import { openPull } from "../src/actions/pulls.ts"; | |
| 17 | import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 18 | import { getAgentByName, setCrewRole } from "../src/core/agents.ts"; | |
| 19 | import { getIssueById, type Issue } from "../src/core/issues.ts"; | |
| 20 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 21 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 22 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 23 | import { driveRepo } from "../src/crew/autonomy.ts"; | |
| 24 | import { taskPrompt } from "../src/crew/brief.ts"; | |
| 25 | import { ISSUE_RUN_CAP } from "../src/crew/guard.ts"; | |
| 26 | import { heldCounts, heldNow, heldWords, stuckOf } from "../src/crew/held.ts"; | |
| 27 | import { PARK_NOTE } from "../src/crew/parks.ts"; | |
| 28 | import { keyPart } from "../src/crew/sentinel.ts"; | |
| 29 | import { HELD_TTL_MS, readHeld } from "../src/flow/held.ts"; | |
| 30 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 31 | import { flowLines, heldLines } from "../src/tui/screens/flow.ts"; | |
| 32 | ||
| 33 | const minutes = (n: number) => new Date(Date.now() - n * 60_000).toISOString(); | |
| 34 | ||
| 35 | async function crewed( | |
| 36 | app: App, | |
| 37 | alice: User, | |
| 38 | name: string, | |
| 39 | visibility: "public" | "private" = "private", | |
| 40 | ): Promise<{ repo: Repo; atlas: User; mason: User }> { | |
| 41 | const made = await createRepo(app, alice, { name, visibility }); | |
| 42 | if (!made.ok) throw new Error(String(made.error)); | |
| 43 | for (const agent of ["atlas", "mason"]) { | |
| 44 | if (getUserByName(app.db, agent) === undefined) { | |
| 45 | const created = await createAgent(app, alice, { username: agent }); | |
| 46 | if (!created.ok) throw new Error(String(created.error)); | |
| 47 | } | |
| 48 | expect(addToCrew(app, alice, made.value, agent).ok).toBe(true); | |
| 49 | } | |
| 50 | setCrewRole(app.db, made.value.id, getUserByName(app.db, "atlas")!.id, "planner"); | |
| 51 | const set = await updateRepoSettings(app, alice, made.value, { | |
| 52 | autonomy: "autonomous", | |
| 53 | autonomyDirection: "Keep the engine small.", | |
| 54 | autonomyCeilingUsd: 100, | |
| 55 | autonomySlots: 1, | |
| 56 | autonomyBy: alice.id, | |
| 57 | }); | |
| 58 | if (!set.ok) throw new Error(JSON.stringify(set.error)); | |
| 59 | return { | |
| 60 | repo: getRepoById(app.db, made.value.id)!, | |
| 61 | atlas: getUserByName(app.db, "atlas")!, | |
| 62 | mason: getUserByName(app.db, "mason")!, | |
| 63 | }; | |
| 64 | } | |
| 65 | ||
| 66 | describe("the tick says why each ready issue cannot start", () => { | |
| 67 | let dataDir: string; | |
| 68 | let app: App; | |
| 69 | let alice: User; | |
| 70 | let servers: Servers; | |
| 71 | ||
| 72 | /** A finished agent run on `issue`, `ago` minutes back. */ | |
| 73 | function ran(repo: Repo, issue: Issue, by: User, summary: string, ago = 0, state: "ok" | "failed" = "failed"): void { | |
| 74 | const run = createRun(app.db, { | |
| 75 | repoId: repo.id, | |
| 76 | kind: "agent", | |
| 77 | issueId: issue.id, | |
| 78 | actorId: by.id, | |
| 79 | reason: "assigned", | |
| 80 | }); | |
| 81 | claimRun(app.db, run.id); | |
| 82 | finishRun(app.db, run.id, { state, summary, costUsd: 0.1 }); | |
| 83 | const at = minutes(ago); | |
| 84 | app.db | |
| 85 | .query("UPDATE run SET created_at = ?2, started_at = ?2, finished_at = ?2 WHERE id = ?1") | |
| 86 | .run(run.id, at); | |
| 87 | } | |
| 88 | ||
| 89 | function issue(repo: Repo, title: string, body = ""): Issue { | |
| 90 | const opened = openIssue(app, alice, getRepoById(app.db, repo.id)!, { title, body }); | |
| 91 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 92 | return opened.value; | |
| 93 | } | |
| 94 | ||
| 95 | beforeAll(async () => { | |
| 96 | dataDir = await mkdtemp(join(tmpdir(), "forge-held-")); | |
| 97 | app = createApp({ dataDir, port: 0 }); | |
| 98 | const a = await createUser(app.db, { | |
| 99 | trusted: true, | |
| 100 | username: "alice", | |
| 101 | email: "[email protected]", | |
| 102 | password: "a-good-passphrase", | |
| 103 | adminIfFirst: true, | |
| 104 | }); | |
| 105 | if (!a.ok) throw new Error(a.error); | |
| 106 | alice = a.value; | |
| 107 | servers = await serve(app); | |
| 108 | }); | |
| 109 | ||
| 110 | afterAll(async () => { | |
| 111 | servers.stop(); | |
| 112 | app.close(); | |
| 113 | await rm(dataDir, { recursive: true, force: true }); | |
| 114 | }); | |
| 115 | ||
| 116 | test("an issue held by each filter is counted under its name", async () => { | |
| 117 | const { repo, atlas, mason } = await crewed(app, alice, "engine"); | |
| 118 | const free = issue(repo, "Free to start"); | |
| 119 | ||
| 120 | const running = issue(repo, "Running"); | |
| 121 | createRun(app.db, { repoId: repo.id, kind: "agent", issueId: running.id, actorId: mason.id, reason: "assigned" }); | |
| 122 | ||
| 123 | const proposed = issue(repo, "Proposed"); | |
| 124 | const seeded = await commitToBranch(app, alice, repo, { | |
| 125 | message: "Start", | |
| 126 | files: [{ path: "README.md", content: "# engine\n" }], | |
| 127 | }); | |
| 128 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 129 | const branch = await commitToBranch(app, alice, repo, { | |
| 130 | branch: "feature/proposed", | |
| 131 | base: "main", | |
| 132 | message: "Proposed", | |
| 133 | files: [{ path: "proposed.ts", content: "export const n = 1;\n" }], | |
| 134 | }); | |
| 135 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 136 | const pull = await openPull(app, alice, repo, { | |
| 137 | title: "Proposed", | |
| 138 | body: `Closes #${proposed.number}`, | |
| 139 | baseBranch: "main", | |
| 140 | headBranch: "feature/proposed", | |
| 141 | }); | |
| 142 | if (!pull.ok) throw new Error(String(pull.error)); | |
| 143 | ||
| 144 | const planned = issue(repo, "The planner's"); | |
| 145 | expect(assignIssue(app, alice, repo, planned, atlas).ok).toBe(true); | |
| 146 | ||
| 147 | const stuck = issue(repo, "Stalled"); | |
| 148 | ran(repo, stuck, mason, "declined: the ask is unclear"); | |
| 149 | ||
| 150 | const capped = issue(repo, "Capped"); | |
| 151 | for (let n = 0; n < ISSUE_RUN_CAP; n++) ran(repo, capped, mason, `opened pull #${90 + n}`, 60 - n, "ok"); | |
| 152 | ||
outdated
mason and atlas are only assigned in the previous test, so this test depends on test order. Assign them here. |
||
| 153 | const parked = issue(repo, "Parked twice"); | |
| 154 | addComment(app, mason, repo, parked, `${PARK_NOTE}first`); | |
| 155 | addComment(app, mason, repo, getIssueById(app.db, parked.id)!, `${PARK_NOTE}second`); | |
| 156 | ||
| 157 | const quoting = issue(repo, "Quotes the red test", "fails: the widget renders"); | |
| 158 | const fix = issue(repo, "Fix main"); | |
| 159 | const check = createRun(app.db, { | |
| 160 | repoId: repo.id, | |
| 161 | kind: "check", | |
| 162 | env: repo.defaultBranch, | |
| 163 | commitOid: "a".repeat(40), | |
| 164 | reason: "push", | |
| 165 | }); | |
| 166 | claimRun(app.db, check.id); | |
| 167 | finishRun(app.db, check.id, { | |
| 168 | state: "failed", | |
| 169 | summary: "check failed: 1 test failed", | |
| 170 | checkDetail: { passed: 1, failed: 1, names: ["the widget renders"] }, | |
| 171 | }); | |
| 172 | app.db | |
| 173 | .query("INSERT INTO sentinel_finding (key, issue_id, evidence, first_seen, last_seen) VALUES (?1, ?2, '', 'x', 'x')") | |
| 174 | .run(`degraded:${keyPart(`red:${repo.id}`)}`, fix.id); | |
| 175 | ||
| 176 | const finding = issue(repo, "A setting's finding"); | |
| 177 | app.db | |
| 178 | .query("INSERT INTO sentinel_finding (key, issue_id, evidence, first_seen, last_seen) VALUES ('setting:x', ?1, '', 'x', 'x')") | |
| 179 | .run(finding.id); | |
| 180 | ||
| 181 | const held = heldNow(app, getRepoById(app.db, repo.id)!); | |
| 182 | const named = (h: keyof typeof held.by) => held.by[h].map((i) => i.number); | |
| 183 | expect(held.startable.map((r) => r.issue.number).sort()).toEqual([free.number, fix.number].sort()); | |
| 184 | expect(named("running")).toEqual([running.number]); | |
| 185 | expect(named("open_pull")).toEqual([proposed.number]); | |
| 186 | expect(named("planner")).toEqual([planned.number]); | |
| 187 | expect(named("stalled")).toEqual([stuck.number]); | |
| 188 | expect(named("run_cap")).toEqual([capped.number]); | |
| 189 | expect(named("parked_twice")).toEqual([parked.number]); | |
| 190 | expect(named("red_fix")).toEqual([quoting.number]); | |
| 191 | expect(named("setting")).toEqual([finding.number]); | |
| 192 | expect(heldCounts(held).open_pull).toBe(1); | |
| 193 | expect(heldWords(held)).toBe( | |
| 194 | "running=1 open_pull=1 planner=1 stalled=1 run_cap=1 parked_twice=1 red_fix=1 setting=1", | |
| 195 | ); | |
| 196 | // Something can start: the planner is not told the crew is stuck. | |
| 197 | expect(stuckOf(held)).toBeUndefined(); | |
| 198 | }); | |
| 199 | ||
| 200 | test("nothing startable with a slot free asks the planner once, with the breakdown, whatever the plate", async () => { | |
| 201 | const { repo, atlas, mason } = await crewed(app, alice, "stuck"); | |
| 202 | const fresh = () => getRepoById(app.db, repo.id)!; | |
| 203 | // The map ran dry: the planner's first pass, an hour ago. | |
| 204 | expect(await driveRepo(app, fresh())).toBe("planned"); | |
| 205 | const direction = getIssueById(app.db, fresh().directionIssueId!)!; | |
| 206 | ran(fresh(), direction, atlas, "opened #2", 60, "ok"); | |
| 207 | expect(assignIssue(app, alice, fresh(), getIssueById(app.db, direction.id)!, null).ok).toBe(true); | |
| 208 | // Then five issues, each stalled by a builder since: more than the | |
| 209 | // plate holds (slots × 3), and nothing a builder may start. | |
| 210 | const stalled = ["One", "Two", "Three", "Four"].map((t) => issue(fresh(), t)); | |
| 211 | for (const s of stalled) ran(fresh(), s, mason, "declined: too big", 40); | |
| 212 | const capped = issue(fresh(), "Five"); | |
| 213 | for (let n = 0; n < ISSUE_RUN_CAP; n++) ran(fresh(), capped, mason, `opened pull #${90 + n}`, 45 - n, "ok"); | |
| 214 | const held = heldNow(app, fresh()); | |
| 215 | expect(held.startable).toHaveLength(0); | |
| 216 | expect(heldWords(held)).toBe("stalled=4 run_cap=1"); | |
| 217 | ||
| 218 | expect(await driveRepo(app, fresh())).toBe("planned"); | |
| 219 | expect(getIssueById(app.db, direction.id)!.assigneeId).toBe(atlas.id); | |
| 220 | ||
| 221 | // The pass the crew would start, given the breakdown in its brief. | |
| 222 | const told = stuckOf(heldNow(app, fresh())); | |
| 223 | expect(told?.held).toBe("stalled=4 run_cap=1"); | |
| 224 | const brief = taskPrompt({ | |
| 225 | agent: getAgentByName(app.db, "atlas")!, | |
| 226 | repo: fresh(), | |
| 227 | issue: getIssueById(app.db, direction.id)!, | |
| 228 | branch: "atlas/1-direction", | |
| 229 | forgeUrl: "http://forge.test", | |
| 230 | isApp: false, | |
| 231 | resuming: false, | |
| 232 | role: "planner", | |
| 233 | reason: "assigned", | |
| 234 | comments: [], | |
| 235 | reviews: [], | |
| 236 | reviewComments: [], | |
| 237 | since: direction.createdAt, | |
| 238 | stuck: told, | |
| 239 | }); | |
| 240 | expect(brief).toContain("What holds them: stalled=4 run_cap=1."); | |
| 241 | expect(brief).toContain(`- #${stalled[0]!.number} One (stalled: a builder left it without a pull)`); | |
| 242 | expect(brief).toContain(`- #${capped.number} Five (at its run cap for the day)`); | |
| 243 | expect(brief).toContain("do not wait for a person's comment"); | |
| 244 | // A builder is never told. | |
| 245 | expect(taskPrompt({ ...{ | |
| 246 | agent: getAgentByName(app.db, "mason")!, | |
| 247 | repo: fresh(), | |
| 248 | issue: stalled[0]!, | |
| 249 | branch: "mason/1-one", | |
| 250 | forgeUrl: "http://forge.test", | |
| 251 | isApp: false, | |
| 252 | resuming: false, | |
| 253 | role: "builder", | |
| 254 | reason: "assigned", | |
| 255 | comments: [], | |
| 256 | reviews: [], | |
| 257 | reviewComments: [], | |
| 258 | since: direction.createdAt, | |
| 259 | }, stuck: told })).not.toContain("What holds them"); | |
| 260 | ||
| 261 | // That pass ran and left them as they were: it is not asked again for | |
| 262 | // the same breakdown, and the full plate holds it as before. | |
| 263 | ran(fresh(), direction, atlas, "left them", 30, "ok"); | |
| 264 | expect(assignIssue(app, alice, fresh(), getIssueById(app.db, direction.id)!, null).ok).toBe(true); | |
| 265 | expect(await driveRepo(app, fresh())).toBe("idle"); | |
| 266 | expect(getIssueById(app.db, direction.id)!.assigneeId).toBeNull(); | |
| 267 | }); | |
| 268 | ||
| 269 | test("both doors: /flow and the terminal's Flow dialog show the counts, private ones to the owner only", async () => { | |
| 270 | const open = await crewed(app, alice, "open-door", "public"); | |
| 271 | const shut = await crewed(app, alice, "shut-door"); | |
| 272 | for (const { repo, mason } of [open, shut]) { | |
| 273 | ran(repo, issue(repo, "Stalled"), mason, "declined: too big", 10); | |
| 274 | const capped = issue(repo, "Capped"); | |
| 275 | for (let n = 0; n < ISSUE_RUN_CAP; n++) ran(repo, capped, mason, `opened pull #${90 + n}`, 20 - n, "ok"); | |
| 276 | } | |
| 277 | const signin = await fetch(`${servers.url}/signin`, { | |
| 278 | method: "POST", | |
| 279 | redirect: "manual", | |
| 280 | headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url }, | |
| 281 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 282 | }); | |
| 283 | const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 284 | const res = await fetch(`${servers.url}/flow`, { headers: { cookie } }); | |
| 285 | expect(res.status).toBe(200); | |
| 286 | const page = await res.text(); | |
| 287 | expect(page).toContain('id="held"'); | |
| 288 | const row = page.slice(page.indexOf('data-held-repo="alice/open-door"')); | |
| 289 | expect(row).toContain('class="held-bar"'); | |
| 290 | expect(row).toContain('<span class="mono">startable=0</span>'); | |
| 291 | expect(row).toContain('<span class="mono">stalled=1</span>'); | |
| 292 | expect(row).toContain('<span class="mono">run_cap=1</span>'); | |
| 293 | expect(page).toContain('data-held-repo="alice/shut-door"'); | |
| 294 | ||
| 295 | // The terminal reads the same rows: the owner sees both, an agent | |
| 296 | // reader the public one alone. | |
| 297 | const owner = flowLines(app.db, new Date(), [], undefined, readHeld(app, true)).join("\n"); | |
| 298 | expect(owner).toContain("Ready, and why it waits:"); | |
| 299 | expect(owner).toContain("alice/open-door startable=0 stalled=1 run_cap=1"); | |
| 300 | expect(owner).toContain("alice/shut-door startable=0 stalled=1 run_cap=1"); | |
| 301 | const agent = flowLines(app.db, new Date(), [], { queues: [] }, readHeld(app, false)).join("\n"); | |
| 302 | expect(agent).toContain("alice/open-door startable=0 stalled=1 run_cap=1"); | |
| 303 | expect(agent).not.toContain("shut-door"); | |
| 304 | expect(heldLines([], 58)).toEqual(["Ready, and why it waits: no repository runs on its own."]); | |
| 305 | }); | |
| 306 | ||
| 307 | test("the rows are kept a few seconds, then read afresh", async () => { | |
| 308 | const { repo, mason } = await crewed(app, alice, "kept"); | |
| 309 | const t0 = Date.now() + 60 * 60_000; | |
| 310 | const stalled = (now: number) => | |
| 311 | readHeld(app, true, now).find((r) => r.repo === "alice/kept")!.counts.stalled; | |
| 312 | expect(stalled(t0)).toBe(0); | |
| 313 | ran(repo, issue(repo, "Stalled"), mason, "declined: too big", 5); | |
| 314 | expect(stalled(t0 + 1_000)).toBe(0); | |
| 315 | expect(stalled(t0 + HELD_TTL_MS)).toBe(1); | |
| 316 | }); | |
| 317 | }); |
new above
offline
The first uncached read sorts every autonomous repository on the request path, about 19 ms measured. A page reload after the 5 s TTL pays it, and so does a stampede of readers. Fine for now. If the repository count grows, refresh the rows from the tick instead.