Public: anyone can read this; the crew can change it.
Lifting an issue from Later makes it startable, and the tick says why any ready issue isn't #8926
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8924-lifting-an-issue-from-later-makes-it-sta → main 4 commits fork 4 threads · 4 unresolved
docs/features/next-up-why.md added +7
| @@ -0,0 +1,7 @@ | ||
| 1 | # Next up says why a ready issue is not started (#8924) | |
| 2 | ||
| 3 | - **A lift places the issue.** Taking an issue from behind the Later gate (the Lift button, the `wait_for` tool with `stop`, the pacer's release) leaves it under a pillar: the one it already serves, else the first, whose umbrella then waits for it. The one place is `stopWaiting` (`src/actions/gate.ts`), which `liftHold` and `wait_for` stop share, via `servePillar` (`src/core/pillar_pick.ts`), so neither route skips it; taking the `Later` label off by hand is not a lift, for the label puts the gate back. | |
| 4 | - **No silent case.** Every ready issue a builder will not start carries one reason on Next up (web, terminal, MCP `next_up` as `why`): what `held.ts` holds (a run, a pull, a cap, a hot file, a stall), or what the picker passes over (`src/crew/pick_filter.ts`): `polish_share`, `no_bet`, `no_builder`. The tick line counts the passes. | |
| 5 | - Not explained, because they need git: issues retired as already done on the base are closed with a note on the tick, not listed. | |
| 6 | ||
| 7 | `notStartable` (`src/crew/not_startable.ts`) joins the two; the page reads the scope once. Tested in `test/not-startable.test.ts` and `test/lift-startable.test.ts`. |
src/actions/gate.ts +21 −1
| @@ -11,6 +11,7 @@ import type { App } from "../app.ts"; | ||
| 11 | 11 | import { closedByReview, gateHolding, gateOf } from "../core/gate_hold.ts"; |
| 12 | 12 | import type { Issue, Label } from "../core/issues.ts"; |
| 13 | 13 | import { isStructural, LATER, pillarsOf } from "../core/pillars.ts"; |
| 14 | import { servePillar } from "../core/pillar_pick.ts"; | |
| 14 | 15 | import type { Repo } from "../core/repos.ts"; |
| 15 | 16 | import type { User } from "../core/users.ts"; |
| 16 | 17 | import { err, ok, type Result } from "../kernel/result.ts"; |
| @@ -24,6 +25,25 @@ import { setIssueState } from "./issues.ts"; | ||
| 24 | 25 | export const hasLater = (labels: readonly Pick<Label, "name">[]): boolean => |
| 25 | 26 | labels.some((l) => l.name.toLowerCase() === LATER.toLowerCase()); |
| 26 | 27 | |
| 28 | /** | |
| 29 | * The issue waits for `blocker` no more. When `blocker` is the Later gate | |
| 30 | * that is a lift, and a lift means startable: the issue is left under a | |
| 31 | * pillar, the unlinker's word on it (#8924). Taking the `Later` label off | |
| 32 | * by hand is not this: its label puts the gate back. | |
| 33 | */ | |
| 34 | export function stopWaiting( | |
| 35 | app: App, | |
| 36 | actor: User | undefined, | |
| 37 | repo: Repo, | |
| 38 | issue: Issue, | |
| 39 | blocker: Issue, | |
| 40 | ): Result<void, AccessError> { | |
| 41 | const done = unlinkIssue(app, actor, repo, issue, blocker); | |
| 42 | if (done.ok && actor !== undefined && pillarsOf(app.db, repo).gate?.id === blocker.id) | |
| 43 | servePillar(app.db, repo, issue, actor.id); | |
| 44 | return done; | |
| 45 | } | |
| 46 | ||
| 27 | 47 | /** |
| 28 | 48 | * Let the issue go from behind the gate: it waits for the gate no more, and |
| 29 | 49 | * its `Later` label goes with it. One the gate review closed is reopened as |
| @@ -41,7 +61,7 @@ export function liftHold( | ||
| 41 | 61 | const revive = closedByReview(app.db, issue); |
| 42 | 62 | const gate = revive ? gateOf(app.db, repo, issue) : gateHolding(app.db, repo, issue); |
| 43 | 63 | if (gate === undefined) return err("not_held"); |
| 44 | const lifted = unlinkIssue(app, actor, repo, issue, gate); | |
| 64 | const lifted = stopWaiting(app, actor, repo, issue, gate); | |
| 45 | 65 | if (!lifted.ok) return lifted; |
| 46 | 66 | if (!revive) return ok(issue); |
| 47 | 67 | const reopened = setIssueState(app, actor, repo, issue, "open"); |
src/core/next_up.ts +8 −4
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | import { type Graph, reachOf, readyNow, repoGraph } from "./dependencies.ts"; | |
| 10 | import { type Graph, type Ranked, reachOf, readyNow, repoGraph } from "./dependencies.ts"; | |
| 11 | 11 | import type { Issue } from "./issues.ts"; |
| 12 | 12 | import { pillarsOf, servedBy } from "./pillars.ts"; |
| 13 | 13 | import type { Repo } from "./repos.ts"; |
| @@ -37,7 +37,7 @@ export interface NextRow { | ||
| 37 | 37 | readonly place: number | null; |
| 38 | 38 | /** Who is on it: the crew member running it, else its assignee. */ |
| 39 | 39 | readonly onIt: string | null; |
| 40 | /** In one line, why it cannot start yet; null when it can. */ | |
| 40 | /** In one line, why it cannot start yet; null when it can. A ready row has one whenever a builder will not start it. */ | |
| 41 | 41 | readonly why: string | null; |
| 42 | 42 | } |
| 43 | 43 | |
| @@ -108,10 +108,14 @@ export function nextUp( | ||
| 108 | 108 | db: Database, |
| 109 | 109 | repo: Repo, |
| 110 | 110 | scope: { sealed?: true; sealedBy?: string } = { sealed: true }, |
| 111 | /** Why a ready issue is not startable, by issue id (`crew/not_startable.ts`): a page that has the app passes it. */ | |
| 112 | notStartable: (ready: readonly Ranked[]) => ReadonlyMap<string, string> = () => new Map(), | |
| 111 | 113 | ): NextUp { |
| 112 | 114 | const graph = repoGraph(db, repo.id, { ...scope, lean: true }); |
| 113 | 115 | const ranks = graph.ranked ?? new Map<string, number>(); |
| 114 | const ready = readyNow(graph).map((r) => r.issue); | |
| 116 | const readyRanked = readyNow(graph); | |
| 117 | const ready = readyRanked.map((r) => r.issue); | |
| 118 | const unstartable = notStartable(readyRanked); | |
| 115 | 119 | const readyIds = new Set(ready.map((i) => i.id)); |
| 116 | 120 | // Who is on an issue: the crew running it right now, else whoever it is assigned to. |
| 117 | 121 | const crew = new Map<string, string>(); |
| @@ -126,7 +130,7 @@ export function nextUp( | ||
| 126 | 130 | ...roleOf(holds.get(issue.id)?.size ?? 0, serves.get(issue.id), ranks.has(issue.id)), |
| 127 | 131 | place: ranks.get(issue.id) ?? null, |
| 128 | 132 | onIt: crew.get(issue.id) ?? issue.assigneeName, |
| 129 | why: readyIds.has(issue.id) ? null : whyNot(graph, issue), | |
| 133 | why: readyIds.has(issue.id) ? unstartable.get(issue.id) ?? null : whyNot(graph, issue), | |
| 130 | 134 | }); |
| 131 | 135 | const blocked = graph.issues.filter((i) => i.state === "open" && ranks.has(i.id) && !readyIds.has(i.id)); |
| 132 | 136 | const rows = pickOrder(ready, ranks, (i) => i.id, blocked).map(rowOf); |
src/core/pillar_pick.ts added +30
| @@ -0,0 +1,30 @@ | ||
| 1 | /** | |
| 2 | * Choosing a pillar for an issue let out from behind the Later gate (#8924). | |
| 3 | * The gate edge was also what a person's link meant: take it away and an | |
| 4 | * issue a reader opened is nobody's again (`unadoptedAmong`), and one that | |
| 5 | * serves no pillar says nothing about what it is for. So a lift, by hand or | |
| 6 | * by the pacer, leaves the issue under a pillar: the one it already serves, | |
| 7 | * else the first, whose umbrella then waits for it, linked by whoever lifted. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { Database } from "bun:sqlite"; | |
| 11 | import { addDependency } from "./dependencies.ts"; | |
| 12 | import type { Issue } from "./issues.ts"; | |
| 13 | import { isStructural, LATER, pillarsOf, servedBy } from "./pillars.ts"; | |
| 14 | import type { Repo } from "./repos.ts"; | |
| 15 | ||
| 16 | /** | |
| 17 | * Put `issue` under a pillar unless one already holds it. Returns the | |
| 18 | * pillar's name when it linked, else undefined: it served one, the gate | |
| 19 | * still held it, or the repository has no pillars to serve. | |
| 20 | */ | |
| 21 | export function servePillar(db: Database, repo: Repo, issue: Issue, actorId: string): string | undefined { | |
| 22 | const set = pillarsOf(db, repo); | |
| 23 | const pillar = set.pillars[0]; | |
| 24 | if (pillar === undefined || issue.kind !== "issue" || isStructural(set, issue.id)) return undefined; | |
| 25 | const serves = servedBy(db, set).get(issue.id); | |
| 26 | // `Later` is the gate or the Later umbrella: neither is a pillar. | |
| 27 | if (serves !== undefined && serves !== LATER) return undefined; | |
| 28 | const linked = addDependency(db, pillar.umbrella, issue, actorId); | |
| 29 | return linked.ok ? pillar.name : undefined; | |
| 30 | } |
src/crew/autonomy.ts +9 −40
| @@ -31,11 +31,9 @@ import { addLearned } from "../checks/learned.ts"; | ||
| 31 | 31 | import { batchWords, dropFromLine, enqueue, type GateCandidate, type Queued, type QueueOptions, queueClashes, queueWords, waitingPulls } from "../checks/queue.ts"; |
| 32 | 32 | import { checksFor } from "../checks/service.ts"; |
| 33 | 33 | import { queueBatch, queueDepth, smallShare } from "../core/capacity.ts"; |
| 34 | import { isPolish, polishSpend, shareWords, slotOrder, smallCap, smallIssues, smallOfIds } from "./small_share.ts"; | |
| 35 | import { backedBetOf, betsInUse } from "../core/bets.ts"; | |
| 36 | import { mayPick } from "./bets.ts"; | |
| 34 | import { shareWords, slotOrder, smallCap, smallIssues, smallOfIds } from "./small_share.ts"; | |
| 35 | import { crewFor as crewForIssue, passedWords, pickFilter, routable } from "./pick_filter.ts"; | |
| 37 | 36 | import { tickPolish } from "./polish_cap.ts"; |
| 38 | import { raisedAt } from "../core/thought_links.ts"; | |
| 39 | 37 | import { readConflictLane, readConflictRoom } from "./conflict_room.ts"; |
| 40 | 38 | import { mergeable, resolveOid, treeOf } from "../git/merge.ts"; |
| 41 | 39 | import { CLOSED_NOTE, leftNote, reopenedByPerson, unticked } from "../core/done_on_main.ts"; |
| @@ -73,7 +71,6 @@ import { | ||
| 73 | 71 | listComments, |
| 74 | 72 | MAX_TITLE, |
| 75 | 73 | type Issue, |
| 76 | labelsForIssues, | |
| 77 | 74 | } from "../core/issues.ts"; |
| 78 | 75 | import { |
| 79 | 76 | getPull, |
| @@ -117,7 +114,7 @@ import { given, pendingReplans } from "../core/limb_replan.ts"; | ||
| 117 | 114 | import { handedOverPulls, SKIP_SUMMARY, skipsReplan } from "./guard.ts"; |
| 118 | 115 | import { atRunCap, type Held, heldBy, heldWords, replannable } from "./held.ts"; |
| 119 | 116 | import { holdHot, hotFiles, hotWords, learnPullFiles } from "./hot_files.ts"; |
| 120 | import { landedUnder, NO_PULL, shunnedBy, stallReason, unstickOwed } from "./stalls.ts"; | |
| 117 | import { landedUnder, NO_PULL, stallReason, unstickOwed } from "./stalls.ts"; | |
| 121 | 118 | import { alreadyDone, BY_CREW, DONE_LIKE } from "../core/done_on_main.ts"; |
| 122 | 119 | import { repoDir } from "../git/repos.ts"; |
| 123 | 120 | import { ALREADY_NOTE, alreadyDoneOids } from "./already.ts"; |
| @@ -392,7 +389,7 @@ function releaseForPace(app: App, repo: Repo, by: User, slotted: number, startab | ||
| 392 | 389 | const week = weekFor(app, repo, now, { idleSlots: Math.max(0, repo.autonomySlots - slotted), startable, gated: gated.length }); |
| 393 | 390 | const next = gated[0]; |
| 394 | 391 | if (week === null || !week.release || next === undefined) return undefined; |
| 395 | const [number] = releaseFromGate(app.db, repo.id, [next.number]); | |
| 392 | const [number] = releaseFromGate(app.db, repo.id, [next.number], by.id); | |
| 396 | 393 | if (number === undefined) return undefined; |
| 397 | 394 | const why = `Released from Later to use the week's subscription: ${week.sentence}`; |
| 398 | 395 | log.info("released", { repo: `${repo.ownerName}/${repo.name}`, issue: number, why }); |
| @@ -414,30 +411,9 @@ export function inSlotOrder(app: App, repo: Repo, ready: readonly Ranked[], slot | ||
| 414 | 411 | const small = smallIssues(app.db, issues); |
| 415 | 412 | const first = firstAt(app.db, issues.map((i) => i.id)); |
| 416 | 413 | const ranked = ranksInRepo(app.db, repo.id); |
| 417 | const raised = raisedAt(app.db, issues.map((i) => i.id)); | |
| 418 | 414 | // Issues picked earlier in this tick hold their share before their runs show (#8729). |
| 419 | 415 | const running = smallOfIds(app.db, [...new Set([...slotted.flatMap((r) => (r.issueId === null ? [] : [r.issueId])), ...picked])]); |
| 420 | // Once the owner has backed a bet, builders pick from backed bets, the owner's raised issues and the small lane (#8713). | |
| 421 | const bets = betsInUse(app.db, repo.id) ? backedBetOf(app.db, repo.id, issues.map((i) => i.id)) : undefined; | |
| 422 | // Polish (labelled `polish`, or in the small-fixes lane) stops once it has taken its share of the week (#8853); | |
| 423 | // what the owner raised, ranked or put first is never held back. | |
| 424 | const polishLabels = labelsForIssues(app.db, issues.map((i) => i.id)); | |
| 425 | const polishOpen = polishSpend(app.db, repo.id, repo.polishShare, now).under; | |
| 426 | const heldBack = (r: Ranked): boolean => | |
| 427 | !polishOpen && | |
| 428 | !raised.has(r.issue.id) && !first.has(r.issue.id) && !ranked.has(r.issue.id) && | |
| 429 | isPolish(r.issue.title, (polishLabels.get(r.issue.id) ?? []).map((l) => l.name)); | |
| 430 | const pickable = ready.filter((r) => !heldBack(r)); | |
| 431 | const allowed = | |
| 432 | bets === undefined | |
| 433 | ? pickable | |
| 434 | : pickable.filter((r) => | |
| 435 | mayPick({ | |
| 436 | raised: raised.has(r.issue.id) || first.has(r.issue.id), | |
| 437 | small: small.has(r.issue.id), | |
| 438 | bet: bets.get(r.issue.id), | |
| 439 | }), | |
| 440 | ); | |
| 416 | const { allowed } = pickFilter(app, repo, ready, now); | |
| 441 | 417 | // The order is the one Next up shows (#8826): ranked by place, then the ready order. |
| 442 | 418 | return slotOrder( |
| 443 | 419 | pickOrder(allowed, ranked, (r) => r.issue.id), |
| @@ -554,17 +530,8 @@ async function driveOnce( | ||
| 554 | 530 | inFlight.set(r.actorId, (inFlight.get(r.actorId) ?? 0) + 1); |
| 555 | 531 | // A builder that stalled an issue within the hour leaves it to the |
| 556 | 532 | // others; one no builder may take now is skipped, not waited on (#4073). |
| 557 | const crewFor = (issue: Issue) => { | |
| 558 | const shunned = shunnedBy(app.db, repo, issue.id, now); | |
| 559 | return crew.filter((c) => c.role !== "builder" || !shunned.has(c.agent.user.id)); | |
| 560 | }; | |
| 561 | const pickable = ( | |
| 562 | await withoutRetired( | |
| 563 | app, | |
| 564 | repo, | |
| 565 | ready.filter((r) => crewFor(r.issue).some((c) => c.role === "builder")), | |
| 566 | ) | |
| 567 | ); | |
| 533 | const crewFor = (issue: Issue) => crewForIssue(app, repo, issue.id, crew, now); | |
| 534 | const pickable = await withoutRetired(app, repo, routable(app, repo, ready, crew, now).kept); | |
| 568 | 535 | // The roadmap first, and small issues held to their share of the slots (#7571). |
| 569 | 536 | const next = await nextOf(app, repo, inSlotOrder(app, repo, pickable, slotted, paced.slots, taken, now)); |
| 570 | 537 | if (next !== undefined) { |
| @@ -1403,6 +1370,8 @@ export async function autonomyTick( | ||
| 1403 | 1370 | // The standing reserve the planner keeps: startable/target (#3237). |
| 1404 | 1371 | reserve: reserveWords(reserveOf(held.startable.length, status.slots)), |
| 1405 | 1372 | held: heldWords(held), |
| 1373 | // What the picker passes over among the startable, so no ready issue goes unexplained (#8924). | |
| 1374 | passed: passedWords(pickFilter(app, current, held.startable, now).passed), | |
| 1406 | 1375 | // The files 5 or more open pulls touch, which no new build starts on (#5934). |
| 1407 | 1376 | ...(held.by.hot_file.length === 0 ? {} : { hot: hotWords(hotFiles(current.id)) }), |
| 1408 | 1377 | // Stalled issues this tick parked and freed for another builder (#4073). |
src/crew/not_startable.ts added +40
| @@ -0,0 +1,40 @@ | ||
| 1 | /** | |
| 2 | * Why a ready issue is not startable, one reason each (#8924): what | |
| 3 | * `held.ts` holds (a run, a pull, a cap, a hot file) and what the picker | |
| 4 | * passes over (`pick_filter.ts`: polish's share, the owner's bets, no builder | |
| 5 | willing). No ready | |
| 6 | * issue goes unexplained: Next up prints these beside the tick line's counts. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { App } from "../app.ts"; | |
| 10 | import type { Ranked } from "../core/dependencies.ts"; | |
| 11 | import type { Repo } from "../core/repos.ts"; | |
| 12 | import { listActive } from "../core/runs.ts"; | |
| 13 | import { listCrewRoles } from "../core/agents.ts"; | |
| 14 | import { PASSED_WORDS, pickFilter, routable } from "./pick_filter.ts"; | |
| 15 | import { admits, heldBy, mapHolds } from "./held.ts"; | |
| 16 | ||
| 17 | /** Each ready issue a builder will not start now, by issue id, with its one reason. */ | |
| 18 | export function notStartable( | |
| 19 | app: App, | |
| 20 | repo: Repo, | |
| 21 | ready: readonly Ranked[], | |
| 22 | scope: { sealed?: true; sealedBy?: string }, | |
| 23 | ): Map<string, string> { | |
| 24 | const active = listActive(app.db, "agent").filter((r) => r.repoId === repo.id); | |
| 25 | const admit = admits(app, repo, scope); | |
| 26 | const crew = listCrewRoles(app.db, repo.id); | |
| 27 | const held = heldBy(app, repo, ready, crew, active, admit); | |
| 28 | const out = new Map<string, string>(); | |
| 29 | const { holds } = mapHolds(repo, held, admit); | |
| 30 | for (const { issue } of ready) { | |
| 31 | if (issue.id === repo.directionIssueId) out.set(issue.id, "the planner's standing direction, never a builder's"); | |
| 32 | const hold = holds.get(issue.number); | |
outdated
Only holds and pickFilter passes are covered. Builder-routing passes (missing or shunned builder) still leave a ready issue with no reason. Add them here or in pickFilter. Without that, box 2 is not met. |
||
| 33 | if (hold !== undefined) out.set(issue.id, hold.detail); | |
| 34 | } | |
| 35 | const now = new Date(); | |
| 36 | const routed = routable(app, repo, held.startable, crew, now); | |
| 37 | const { passed } = pickFilter(app, repo, routed.kept, now); | |
| 38 | for (const [id, why] of [...routed.passed, ...passed]) out.set(id, PASSED_WORDS[why]); | |
| 39 | return out; | |
| 40 | } |
src/crew/pick_filter.ts added +114
| @@ -0,0 +1,114 @@ | ||
| 1 | /** | |
| 2 | * What the picker passes over, and why (#8924). A startable ready issue is | |
| 3 | * passed over for one of four reasons: polish has taken its share of the | |
| 4 | * week, the owner's bets do not cover it, no builder may take it now, or | |
| 5 | * (held, not passed) `held.ts` holds it. Small and free of the tick, so | |
| 6 | * Next up on the web, in the terminal and over MCP names the reason without | |
| 7 | * loading the autonomy loop. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { App } from "../app.ts"; | |
| 11 | import { backedBetOf, betsInUse } from "../core/bets.ts"; | |
| 12 | import type { Ranked } from "../core/dependencies.ts"; | |
| 13 | import { firstAt } from "../core/first.ts"; | |
| 14 | import { labelsForIssues } from "../core/issues.ts"; | |
| 15 | import { ranksInRepo } from "../core/rank.ts"; | |
| 16 | import type { Repo } from "../core/repos.ts"; | |
| 17 | import { raisedAt } from "../core/thought_links.ts"; | |
| 18 | import type { Agent, CrewRole } from "../core/agents.ts"; | |
| 19 | import { mayPick } from "./bets.ts"; | |
| 20 | import { isPolish, polishSpend, smallIssues } from "./small_share.ts"; | |
| 21 | import { shunnedBy } from "./stalls.ts"; | |
| 22 | ||
| 23 | /** Why the picker passes a startable issue over. */ | |
| 24 | export type PassedOver = "polish_share" | "no_bet" | "no_builder"; | |
| 25 | ||
| 26 | /** The words for each, as Next up and the tick line say them. */ | |
| 27 | export const PASSED_WORDS: Readonly<Record<PassedOver, string>> = { | |
| 28 | polish_share: "polish has taken its share of the week", | |
| 29 | no_bet: "it is in no backed bet, and was not raised, put first or small", | |
| 30 | no_builder: "no builder may take it now: each is shunned from it or there is none", | |
| 31 | }; | |
| 32 | ||
| 33 | /** | |
| 34 | * The builders that may take this issue: a builder that stalled it within | |
| 35 | * the hour leaves it to the others (#4073). | |
| 36 | */ | |
| 37 | export function crewFor<C extends { readonly agent: Agent; readonly role: CrewRole }>( | |
| 38 | app: App, | |
| 39 | repo: Repo, | |
| 40 | issueId: string, | |
| 41 | crew: readonly C[], | |
| 42 | now: Date, | |
| 43 | ): C[] { | |
| 44 | const shunned = shunnedBy(app.db, repo, issueId, now); | |
| 45 | return crew.filter((c) => c.role !== "builder" || !shunned.has(c.agent.user.id)); | |
| 46 | } | |
| 47 | ||
| 48 | /** The ready issues no builder may take now; the others stay in order. */ | |
| 49 | export function routable<C extends { readonly agent: Agent; readonly role: CrewRole }>( | |
| 50 | app: App, | |
| 51 | repo: Repo, | |
| 52 | ready: readonly Ranked[], | |
| 53 | crew: readonly C[], | |
| 54 | now: Date, | |
| 55 | ): { kept: Ranked[]; passed: Map<string, PassedOver> } { | |
| 56 | const passed = new Map<string, PassedOver>(); | |
| 57 | const kept = ready.filter((r) => { | |
| 58 | if (crewFor(app, repo, r.issue.id, crew, now).some((c) => c.role === "builder")) return true; | |
| 59 | passed.set(r.issue.id, "no_builder"); | |
| 60 | return false; | |
| 61 | }); | |
| 62 | return { kept, passed }; | |
| 63 | } | |
| 64 | ||
| 65 | /** | |
| 66 | * What the picker takes from `ready` and what it passes over, each with its | |
| 67 | * one reason (#8924): polish (labelled `polish`, or in the small-fixes lane) | |
| 68 | * stops once it has taken its share of the week (#8853); once the owner has | |
| 69 | * backed a bet, builders pick from backed bets, the owner's raised issues | |
| 70 | * and the small lane (#8713). What the owner raised, ranked or put first is | |
| 71 | * never held back by polish. | |
| 72 | */ | |
| 73 | export function pickFilter( | |
| 74 | app: App, | |
| 75 | repo: Repo, | |
| 76 | ready: readonly Ranked[], | |
| 77 | now: Date = new Date(), | |
| 78 | ): { allowed: Ranked[]; passed: Map<string, PassedOver> } { | |
| 79 | const issues = ready.map((r) => r.issue); | |
| 80 | const small = smallIssues(app.db, issues); | |
| 81 | const first = firstAt(app.db, issues.map((i) => i.id)); | |
| 82 | const ranked = ranksInRepo(app.db, repo.id); | |
| 83 | const raised = raisedAt(app.db, issues.map((i) => i.id)); | |
| 84 | const bets = betsInUse(app.db, repo.id) ? backedBetOf(app.db, repo.id, issues.map((i) => i.id)) : undefined; | |
| 85 | const polishLabels = labelsForIssues(app.db, issues.map((i) => i.id)); | |
| 86 | const polishOpen = polishSpend(app.db, repo.id, repo.polishShare, now).under; | |
| 87 | const passed = new Map<string, PassedOver>(); | |
| 88 | const allowed = ready.filter((r) => { | |
| 89 | const id = r.issue.id; | |
| 90 | if ( | |
| 91 | !polishOpen && | |
| 92 | !raised.has(id) && !first.has(id) && !ranked.has(id) && | |
| 93 | isPolish(r.issue.title, (polishLabels.get(id) ?? []).map((l) => l.name)) | |
| 94 | ) { | |
| 95 | passed.set(id, "polish_share"); | |
| 96 | return false; | |
| 97 | } | |
| 98 | if (bets !== undefined && !mayPick({ raised: raised.has(id) || first.has(id), small: small.has(id), bet: bets.get(id) })) { | |
| 99 | passed.set(id, "no_bet"); | |
| 100 | return false; | |
| 101 | } | |
| 102 | return true; | |
| 103 | }); | |
| 104 | return { allowed, passed }; | |
| 105 | } | |
| 106 | ||
| 107 | /** The tick line's words for what the picker passed over: `no_bet=3 polish_share=1`, or `none`. */ | |
| 108 | export function passedWords(passed: ReadonlyMap<string, PassedOver>): string { | |
| 109 | const said = (Object.keys(PASSED_WORDS) as PassedOver[]) | |
| 110 | .map((name) => [name, [...passed.values()].filter((p) => p === name).length] as const) | |
| 111 | .filter(([, n]) => n > 0) | |
| 112 | .map(([name, n]) => `${name}=${n}`); | |
| 113 | return said.length === 0 ? "none" : said.join(" "); | |
| 114 | } |
src/crew/release_later.ts +13 −4
| @@ -12,6 +12,9 @@ | ||
| 12 | 12 | |
| 13 | 13 | import type { Database } from "bun:sqlite"; |
| 14 | 14 | import { removeDependency } from "../core/dependencies.ts"; |
| 15 | import { getIssueById } from "../core/issues.ts"; | |
| 16 | import { servePillar } from "../core/pillar_pick.ts"; | |
| 17 | import { getRepoById } from "../core/repos.ts"; | |
| 15 | 18 | import { laterGate } from "./later.ts"; |
| 16 | 19 | |
| 17 | 20 | export interface Held { |
| @@ -45,11 +48,11 @@ export function heldByGateAlone(db: Database, repoId: string): Held[] { | ||
| 45 | 48 | } |
| 46 | 49 | |
| 47 | 50 | /** Delete the gate edge of each issue named, all in one transaction (a failure releases none); returns the numbers released. */ |
| 48 | export function releaseFromGate(db: Database, repoId: string, numbers: readonly number[]): number[] { | |
| 49 | return db.transaction(() => release(db, repoId, numbers)).immediate(); | |
| 51 | export function releaseFromGate(db: Database, repoId: string, numbers: readonly number[], actorId?: string): number[] { | |
| 52 | return db.transaction(() => release(db, repoId, numbers, actorId)).immediate(); | |
| 50 | 53 | } |
| 51 | 54 | |
| 52 | function release(db: Database, repoId: string, numbers: readonly number[]): number[] { | |
| 55 | function release(db: Database, repoId: string, numbers: readonly number[], actorId?: string): number[] { | |
| 53 | 56 | const number = laterGate(db, repoId); |
| 54 | 57 | if (number === undefined) return []; |
| 55 | 58 | const gate = db |
| @@ -60,7 +63,13 @@ function release(db: Database, repoId: string, numbers: readonly number[]): numb | ||
| 60 | 63 | if (gate === null) return []; |
| 61 | 64 | const want = new Set(numbers); |
| 62 | 65 | const out: number[] = []; |
| 66 | const repo = actorId === undefined ? undefined : getRepoById(db, repoId); | |
| 63 | 67 | for (const held of heldByGateAlone(db, repoId)) |
| 64 | if (want.has(held.number) && removeDependency(db, held.id, gate.id)) out.push(held.number); | |
| 68 | if (want.has(held.number) && removeDependency(db, held.id, gate.id)) { | |
| 69 | out.push(held.number); | |
| 70 | // Released means startable: under a pillar, in the releaser's name (#8924). | |
| 71 | const issue = repo === undefined ? undefined : getIssueById(db, held.id); | |
| 72 | if (repo !== undefined && issue !== undefined && actorId !== undefined) servePillar(db, repo, issue, actorId); | |
| 73 | } | |
| 65 | 74 | return out; |
| 66 | 75 | } |
src/http/mcp/tools/next_up.ts +3 −1
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { placeOf, rankIssue } from "../../../actions/next.ts"; |
| 2 | 2 | import { nextUp, TOP, type NextRow } from "../../../core/next_up.ts"; |
| 3 | import { notStartable } from "../../../crew/not_startable.ts"; | |
| 3 | 4 | import { weekView, type WeekView } from "../../../flow/week.ts"; |
| 4 | 5 | import { sealedScope } from "../../../core/sealed.ts"; |
| 5 | 6 | import { bad, good, type Tool, APP_ARG, planRepo, isAnswer, issueArg } from "../shared.ts"; |
| @@ -48,7 +49,8 @@ export const tool: Tool = { | ||
| 48 | 49 | if (!moved.ok) |
| 49 | 50 | return bad(moved.error.kind === "not_found" ? "no_such_issue" : moved.error.kind, moved.error.message); |
| 50 | 51 | } |
| 51 | const next = nextUp(ctx.app.db, repo, sealedScope(ctx.app.db, repo, user)); | |
| 52 | const scope = sealedScope(ctx.app.db, repo, user); | |
| 53 | const next = nextUp(ctx.app.db, repo, scope, (ready) => notStartable(ctx.app, repo, ready, scope)); | |
| 52 | 54 | const all = args["all"] === true; |
| 53 | 55 | const week = weekView(ctx.app.db, repo.id, next.rows, new Date()); |
| 54 | 56 | return good({ |
src/http/mcp/tools/wait_for.ts +3 −2
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { THREAD_SAYS } from "../../../actions/thread_words.ts"; |
| 2 | import { dependencyWords, linkIssue, unlinkIssue } from "../../../actions/dependencies.ts"; | |
| 2 | import { dependencyWords, linkIssue } from "../../../actions/dependencies.ts"; | |
| 3 | import { stopWaiting } from "../../../actions/gate.ts"; | |
| 3 | 4 | import { good, bad, type Tool, APP_ARG, planRepo, issueArg, isAnswer } from "../shared.ts"; |
| 4 | 5 | |
| 5 | 6 | export const tool: Tool = { |
| @@ -28,7 +29,7 @@ export const tool: Tool = { | ||
| 28 | 29 | if ("ok" in blocker) return blocker; |
| 29 | 30 | const stop = args["stop"] === true; |
| 30 | 31 | const done = stop |
| 31 | ? unlinkIssue(ctx.app, user, repo, issue, blocker) | |
| 32 | ? stopWaiting(ctx.app, user, repo, issue, blocker) | |
| 32 | 33 | : linkIssue(ctx.app, user, repo, issue, blocker); |
| 33 | 34 | if (!done.ok) |
| 34 | 35 | return bad( |
src/tui/screens/next.ts +3 −1
| @@ -15,6 +15,7 @@ import { rankedWords, rankIssue, stepTo, type Place, placeOf } from "../../actio | ||
| 15 | 15 | import { nextUp, type NextRow } from "../../core/next_up.ts"; |
| 16 | 16 | import { canRead, canWrite, type Repo } from "../../core/repos.ts"; |
| 17 | 17 | import { sealedScope } from "../../core/sealed.ts"; |
| 18 | import { notStartable } from "../../crew/not_startable.ts"; | |
| 18 | 19 | import { rangeWords, weekView, type WeekView } from "../../flow/week.ts"; |
| 19 | 20 | import type { Ctx, Screen } from "../app/app.ts"; |
| 20 | 21 | import { binding } from "../app/keys.ts"; |
| @@ -45,7 +46,8 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 45 | 46 | away = 0; |
| 46 | 47 | week = undefined; |
| 47 | 48 | } else { |
| 48 | const next = nextUp(f.db, repo, sealedScope(f.db, repo, f.user)); | |
| 49 | const scope = sealedScope(f.db, repo, f.user); | |
| 50 | const next = nextUp(f.db, repo, scope, (ready) => notStartable(f.app, repo, ready, scope)); | |
| 49 | 51 | rows = next.rows; |
| 50 | 52 | away = next.notNowTotal; |
| 51 | 53 | week = weekView(f.db, repo.id, rows, new Date()); |
src/web/pages/next.ts +3 −1
| @@ -11,6 +11,7 @@ import { nextUp, TOP, type NextRow } from "../../core/next_up.ts"; | ||
| 11 | 11 | import { canWrite, type Repo } from "../../core/repos.ts"; |
| 12 | 12 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 13 | 13 | import { sealedScope } from "../../core/sealed.ts"; |
| 14 | import { notStartable } from "../../crew/not_startable.ts"; | |
| 14 | 15 | import { CUT_TOP_WORDS, cutWords, FIT_COUNT_JS, rangeWords, rowCosts, weekView, type Cut, type Priced } from "../../flow/week.ts"; |
| 15 | 16 | import type { Ctx } from "../../http/context.ts"; |
| 16 | 17 | import { crumbsFor, loadRepo, notFoundPage, repoBase, repoHeader, repoPalette, signInFirst } from "../chrome.ts"; |
| @@ -169,7 +170,8 @@ export function nextPage(ctx: Ctx): Response { | ||
| 169 | 170 | const base = repoBase(repo); |
| 170 | 171 | const all = ctx.url.searchParams.get("all") === "1"; |
| 171 | 172 | const writer = ctx.auth.user !== undefined && ctx.auth.user.kind !== "agent" && canWrite(ctx.app.db, repo, ctx.auth.user); |
| 172 | const next = nextUp(ctx.app.db, repo, sealedScope(ctx.app.db, repo, ctx.auth.user)); | |
| 173 | const scope = sealedScope(ctx.app.db, repo, ctx.auth.user); | |
| 174 | const next = nextUp(ctx.app.db, repo, scope, (ready) => notStartable(ctx.app, repo, ready, scope)); | |
outdated
|
||
| 173 | 175 | const shown = all ? next.rows : next.rows.slice(0, TOP); |
| 174 | 176 | const rest = next.rows.length - shown.length; |
| 175 | 177 | const back = all ? `${base}/next?all=1` : `${base}/next`; |
test/lift-startable.test.ts added +135
| @@ -0,0 +1,135 @@ | ||
| 1 | /** | |
| 2 | * Lifting an issue from Later makes it startable (#8924): the one action | |
| 3 | * leaves it reachable by the picker, under a pillar when it named none, and | |
| 4 | * the tick and Next up say why any ready issue is not startable. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 12 | import { linkIssue } from "../src/actions/dependencies.ts"; | |
| 13 | import { liftHold } from "../src/actions/gate.ts"; | |
| 14 | import { openIssue } from "../src/actions/issues.ts"; | |
| 15 | import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 16 | import { readyNow, repoGraph } from "../src/core/dependencies.ts"; | |
| 17 | import { getIssueById, type Issue } from "../src/core/issues.ts"; | |
| 18 | import { pillarsOf, servedBy } from "../src/core/pillars.ts"; | |
| 19 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 20 | import { createUser, type User } from "../src/core/users.ts"; | |
| 21 | import { nextUp } from "../src/core/next_up.ts"; | |
| 22 | import { createRun } from "../src/core/runs.ts"; | |
| 23 | import { notStartable } from "../src/crew/not_startable.ts"; | |
| 24 | import { driveRepo } from "../src/crew/autonomy.ts"; | |
| 25 | import { passedWords } from "../src/crew/pick_filter.ts"; | |
| 26 | import { heldNow } from "../src/crew/held.ts"; | |
| 27 | import { releaseFromGate } from "../src/crew/release_later.ts"; | |
| 28 | ||
| 29 | let dataDir: string; | |
| 30 | let app: App; | |
| 31 | let alice: User; | |
| 32 | let bob: User; | |
| 33 | let repo: Repo; | |
| 34 | let gate: Issue; | |
| 35 | ||
| 36 | const fresh = () => getRepoById(app.db, repo.id)!; | |
| 37 | const current = (i: Issue) => getIssueById(app.db, i.id)!; | |
| 38 | const open = (who: User, title: string): Issue => { | |
| 39 | const made = openIssue(app, who, fresh(), { title }); | |
| 40 | if (!made.ok) throw new Error(String(made.error)); | |
| 41 | return made.value; | |
| 42 | }; | |
| 43 | const readyNumbers = () => readyNow(repoGraph(app.db, repo.id, { sealed: true })).map((r) => r.issue.number); | |
| 44 | ||
| 45 | beforeAll(async () => { | |
| 46 | dataDir = await mkdtemp(join(tmpdir(), "forge-lift-startable-")); | |
| 47 | app = createApp({ dataDir, port: 0 }); | |
| 48 | const a = await createUser(app.db, { | |
| 49 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 50 | password: "a-good-passphrase", adminIfFirst: true, | |
| 51 | }); | |
| 52 | if (!a.ok) throw new Error(a.error); | |
| 53 | alice = a.value; | |
| 54 | const b = await createUser(app.db, { | |
| 55 | origin: "seed", trusted: true, username: "bob", email: "[email protected]", | |
| 56 | password: "a-good-passphrase", | |
| 57 | }); | |
| 58 | if (!b.ok) throw new Error(b.error); | |
| 59 | bob = b.value; | |
| 60 | const made = await createRepo(app, alice, { name: "notes", visibility: "public" }); | |
| 61 | if (!made.ok) throw new Error(String(made.error)); | |
| 62 | repo = made.value; | |
| 63 | const first = await commitToBranch(app, alice, repo, { | |
| 64 | message: "Start", | |
| 65 | files: [{ path: "package.json", content: `{"name":"notes"}\n` }], | |
| 66 | }); | |
| 67 | if (!first.ok) throw new Error(JSON.stringify(first.error)); | |
| 68 | const agent = await createAgent(app, alice, { username: "b1" }); | |
| 69 | if (!agent.ok) throw new Error(String(agent.error)); | |
| 70 | expect(addToCrew(app, alice, repo, "b1").ok).toBe(true); | |
| 71 | const on = await updateRepoSettings(app, alice, repo, { | |
| 72 | autonomy: "autonomous", | |
| 73 | autonomyDirection: "Keep the notes tidy.", | |
| 74 | autonomyCeilingUsd: 10, | |
| 75 | autonomySlots: 1, | |
| 76 | autonomyBy: alice.id, | |
| 77 | }); | |
| 78 | expect(on.ok).toBe(true); | |
| 79 | open(alice, "P0 Notes: keep notes"); | |
| 80 | gate = open(alice, "Later gate: nothing behind this starts until the owner opens it"); | |
| 81 | }); | |
| 82 | ||
| 83 | afterAll(async () => { | |
| 84 | app.close(); | |
| 85 | await rm(dataDir, { recursive: true, force: true }); | |
| 86 | }); | |
| 87 | ||
| 88 | describe("a lift reaches the picker", () => { | |
| 89 | test("an issue with no pillar, lifted, is under a pillar and picked on the next tick", async () => { | |
| 90 | const issue = open(alice, "Rename a note"); | |
| 91 | expect(linkIssue(app, alice, fresh(), issue, gate).ok).toBe(true); | |
| 92 | expect(readyNumbers()).not.toContain(issue.number); | |
| 93 | expect(servedBy(app.db, pillarsOf(app.db, fresh())).get(issue.id)).toBe("Later"); | |
| 94 | ||
| 95 | const lifted = liftHold(app, alice, fresh(), current(issue)); | |
| 96 | expect(lifted.ok).toBe(true); | |
| 97 | expect(servedBy(app.db, pillarsOf(app.db, fresh())).get(issue.id)).toBe("P0 Notes"); | |
| 98 | expect(readyNumbers()).toContain(issue.number); | |
| 99 | expect(await driveRepo(app, fresh())).toBe("picked"); | |
| 100 | expect(current(issue).assigneeId).not.toBeNull(); | |
| 101 | }); | |
| 102 | ||
| 103 | test("a reader's issue the lift would leave unadopted is still ready", () => { | |
| 104 | const issue = open(bob, "Export as text"); | |
| 105 | expect(linkIssue(app, alice, fresh(), issue, gate).ok).toBe(true); | |
| 106 | expect(liftHold(app, alice, fresh(), current(issue)).ok).toBe(true); | |
| 107 | expect(readyNumbers()).toContain(issue.number); | |
| 108 | }); | |
| 109 | ||
| 110 | test("the pacer's release does the same", () => { | |
| 111 | const issue = open(alice, "Pin the title"); | |
| 112 | expect(linkIssue(app, alice, fresh(), issue, gate).ok).toBe(true); | |
| 113 | expect(releaseFromGate(app.db, repo.id, [issue.number], alice.id)).toEqual([issue.number]); | |
| 114 | expect(servedBy(app.db, pillarsOf(app.db, fresh())).get(issue.id)).toBe("P0 Notes"); | |
| 115 | expect(readyNumbers()).toContain(issue.number); | |
| 116 | expect(heldNow(app, fresh()).startable.map((r) => r.issue.number)).toContain(issue.number); | |
| 117 | }); | |
| 118 | }); | |
| 119 | ||
| 120 | describe("no ready issue goes unexplained", () => { | |
| 121 | test("Next up gives a ready issue a builder will not start its one reason", () => { | |
| 122 | const busy = open(alice, "Sort notes by date"); | |
| 123 | const free = open(alice, "Tag notes"); | |
| 124 | createRun(app.db, { repoId: repo.id, kind: "agent", issueId: busy.id, actorId: alice.id, reason: "assigned" }); | |
| 125 | const scope = { sealed: true as const }; | |
| 126 | const rows = nextUp(app.db, fresh(), scope, (ready) => notStartable(app, fresh(), ready, scope)).rows; | |
| 127 | expect(rows.find((r) => r.issue.id === busy.id)!.why).toBe("a run is in flight on it"); | |
| 128 | expect(rows.find((r) => r.issue.id === free.id)!.why).toBeNull(); | |
| 129 | }); | |
| 130 | ||
| 131 | test("the tick line counts what the picker passes over, and says none when it passes nothing", () => { | |
outdated
This only tests the string formatter. Add tests that make |
||
| 132 | expect(passedWords(new Map())).toBe("none"); | |
| 133 | expect(passedWords(new Map([["a", "no_bet"], ["b", "no_bet"], ["c", "polish_share"]]))).toBe("polish_share=1 no_bet=2"); | |
| 134 | }); | |
| 135 | }); |
test/not-startable.test.ts added +212
| @@ -0,0 +1,212 @@ | ||
| 1 | /** | |
| 2 | * No ready issue goes unexplained (#8924): the picker's three passes (polish's | |
| 3 | * share, the owner's bets, a builder shunned from the issue) each name their | |
| 4 | * reason, and Next up says it on the web, in the terminal and over MCP. The | |
| 5 | * `wait_for` stop on the Later gate leaves the issue under a pillar. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 13 | import { linkIssue } from "../src/actions/dependencies.ts"; | |
| 14 | import { openIssue, setIssueLabels } from "../src/actions/issues.ts"; | |
| 15 | import { commitToBranch, createLabel, createRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 16 | import { createApp as insertApp } from "../src/core/apps.ts"; | |
| 17 | import { insertBet, markBacked } from "../src/core/bets.ts"; | |
| 18 | import { listCrewRoles } from "../src/core/agents.ts"; | |
| 19 | import { readyNow, repoGraph } from "../src/core/dependencies.ts"; | |
| 20 | import { getIssueById, type Issue } from "../src/core/issues.ts"; | |
| 21 | import { nextUp } from "../src/core/next_up.ts"; | |
| 22 | import { pillarsOf, servedBy } from "../src/core/pillars.ts"; | |
| 23 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 24 | import { createRun } from "../src/core/runs.ts"; | |
| 25 | import { createToken } from "../src/core/tokens.ts"; | |
| 26 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 27 | import { notStartable } from "../src/crew/not_startable.ts"; | |
| 28 | import { PASSED_WORDS, pickFilter, routable } from "../src/crew/pick_filter.ts"; | |
| 29 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 30 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 31 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 32 | import { quietLogs } from "./helpers/globals.ts"; | |
| 33 | import { expectWork, requestStatements } from "./helpers/work.ts"; | |
| 34 | import { settleApp } from "./helpers/tui.ts"; | |
| 35 | ||
| 36 | quietLogs(); | |
| 37 | ||
| 38 | let dataDir: string; | |
| 39 | let app: App; | |
| 40 | let alice: User; | |
| 41 | let repo: Repo; | |
| 42 | let servers: Servers; | |
| 43 | let token: string; | |
| 44 | let polish: Issue; | |
| 45 | let shunned: Issue; | |
| 46 | let plain: Issue; | |
| 47 | let id = 1; | |
| 48 | const STALLED = "a builder ran on it and left without a pull"; | |
| 49 | ||
| 50 | const fresh = () => getRepoById(app.db, repo.id)!; | |
| 51 | const open = (title: string): Issue => { | |
| 52 | const made = openIssue(app, alice, fresh(), { title }); | |
| 53 | if (!made.ok) throw new Error(String(made.error)); | |
| 54 | return made.value; | |
| 55 | }; | |
| 56 | const ready = () => readyNow(repoGraph(app.db, repo.id, { sealed: true })); | |
| 57 | const reasons = () => notStartable(app, fresh(), ready(), { sealed: true }); | |
| 58 | const rowWhy = (n: number) => nextUp(app.db, fresh(), { sealed: true }, (r) => notStartable(app, fresh(), r, { sealed: true })).rows.find((r) => r.issue.number === n)?.why; | |
| 59 | ||
| 60 | /** One MCP tool call as alice; the parsed answer. */ | |
| 61 | async function call(name: string, args: unknown): Promise<{ isError: boolean; value: any }> { | |
| 62 | const res = await fetch(`${servers.url}/mcp`, { | |
| 63 | method: "POST", | |
| 64 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 65 | body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name, arguments: args } }), | |
| 66 | }); | |
| 67 | const body = (await res.json()) as any; | |
| 68 | const text: string = body.result.content[0].text; | |
| 69 | return { isError: body.result.isError === true, value: body.result.isError === true ? text : JSON.parse(text) }; | |
| 70 | } | |
| 71 | ||
| 72 | beforeAll(async () => { | |
| 73 | dataDir = await mkdtemp(join(tmpdir(), "forge-not-startable-")); | |
| 74 | app = createApp({ dataDir, port: 0 }); | |
| 75 | const a = await createUser(app.db, { | |
| 76 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 77 | password: "a-good-passphrase", adminIfFirst: true, | |
| 78 | }); | |
| 79 | if (!a.ok) throw new Error(a.error); | |
| 80 | alice = a.value; | |
| 81 | const made = await createRepo(app, alice, { name: "notes", visibility: "public" }); | |
| 82 | if (!made.ok) throw new Error(String(made.error)); | |
| 83 | repo = made.value; | |
| 84 | const first = await commitToBranch(app, alice, repo, { | |
| 85 | message: "Start", files: [{ path: "package.json", content: `{"name":"notes"}\n` }], | |
| 86 | }); | |
| 87 | if (!first.ok) throw new Error(JSON.stringify(first.error)); | |
| 88 | const agent = await createAgent(app, alice, { username: "b1" }); | |
| 89 | if (!agent.ok) throw new Error(String(agent.error)); | |
| 90 | expect(addToCrew(app, alice, repo, "b1").ok).toBe(true); | |
| 91 | expect((await updateRepoSettings(app, alice, repo, { | |
| 92 | autonomy: "autonomous", autonomyDirection: "Keep the notes tidy.", autonomyCeilingUsd: 10, autonomySlots: 1, autonomyBy: alice.id, | |
| 93 | })).ok).toBe(true); | |
| 94 | insertApp(app.db, { repoId: repo.id, slug: "notes", access: "private" }); | |
| 95 | open("P0 Notes: keep notes"); | |
| 96 | createLabel(app, alice, fresh(), "polish", "888888"); | |
| 97 | polish = open("Nudge the margins"); | |
| 98 | expect(setIssueLabels(app, alice, fresh(), polish, ["polish"]).ok).toBe(true); | |
| 99 | shunned = open("Fix the sync"); | |
| 100 | plain = open("Tag notes"); | |
| 101 | servers = await serve(app); | |
| 102 | token = createToken(app.db, alice.id, "rw", "rw").token; | |
| 103 | }); | |
| 104 | ||
| 105 | afterAll(async () => { | |
| 106 | servers.stop(); | |
| 107 | app.close(); | |
| 108 | await rm(dataDir, { recursive: true, force: true }); | |
| 109 | }); | |
| 110 | ||
| 111 | describe("the picker names why it passes a ready issue over", () => { | |
| 112 | test("polish over its share is passed over as polish_share, and only polish", () => { | |
| 113 | const old = open("Tidy the footer"); | |
| 114 | expect(setIssueLabels(app, alice, fresh(), old, ["polish"]).ok).toBe(true); | |
| 115 | app.db.query("UPDATE issue SET state = 'closed', close_reason = 'done' WHERE id = ?1").run(old.id); | |
| 116 | const run = createRun(app.db, { repoId: repo.id, kind: "agent", issueId: old.id, actorId: alice.id, reason: "assigned" }); | |
| 117 | app.db.query("UPDATE run SET state = 'ok', cost_usd = 3, started_at = ?2 WHERE id = ?1").run(run.id, new Date().toISOString()); | |
| 118 | const { allowed, passed } = pickFilter(app, fresh(), ready()); | |
| 119 | expect(passed.get(polish.id)).toBe("polish_share"); | |
| 120 | expect(passed.has(plain.id)).toBe(false); | |
| 121 | expect(allowed.map((r) => r.issue.id)).toContain(plain.id); | |
| 122 | expect(reasons().get(polish.id)).toBe(PASSED_WORDS.polish_share); | |
| 123 | }); | |
| 124 | ||
| 125 | test("a builder shunned from an issue is held with its reason, and one with no builder to take it is no_builder", () => { | |
| 126 | const crew = listCrewRoles(app.db, repo.id); | |
| 127 | const b1 = crew.find((c) => c.agent.user.username === "b1")!; | |
| 128 | const run = createRun(app.db, { repoId: repo.id, kind: "agent", issueId: shunned.id, actorId: b1.agent.user.id, reason: "assigned" }); | |
| 129 | app.db.query("UPDATE run SET state = 'ok', summary = 'looked around', finished_at = ?2 WHERE id = ?1").run(run.id, new Date().toISOString()); | |
| 130 | expect(reasons().get(shunned.id)).toBe(STALLED); | |
| 131 | const none = routable(app, fresh(), ready(), crew.filter((c) => c.role !== "builder"), new Date()); | |
| 132 | expect(none.kept).toEqual([]); | |
| 133 | expect(none.passed.get(plain.id)).toBe("no_builder"); | |
| 134 | expect(reasons().has(plain.id)).toBe(false); | |
| 135 | }); | |
| 136 | ||
| 137 | test("once a bet is backed, an issue in no bet is passed over as no_bet", () => { | |
| 138 | expect(pickFilter(app, fresh(), ready()).passed.has(plain.id)).toBe(false); | |
| 139 | const bet = insertBet(app.db, { repoId: repo.id, name: "Tidy", journeys: "", appetite: 5, leadId: null, issueIds: [polish.id] }, new Date()); | |
| 140 | markBacked(app.db, bet.id, 5, "owner", new Date()); | |
| 141 | expect(pickFilter(app, fresh(), ready()).passed.get(plain.id)).toBe("no_bet"); | |
| 142 | expect(reasons().get(plain.id)).toBe(PASSED_WORDS.no_bet); | |
| 143 | }); | |
| 144 | }); | |
| 145 | ||
| 146 | describe("Next up shows the reason on every surface", () => { | |
| 147 | test("core rows carry it", () => { | |
| 148 | expect(rowWhy(plain.number)).toBe(PASSED_WORDS.no_bet); | |
| 149 | expect(rowWhy(shunned.number)).toBe(STALLED); | |
| 150 | }); | |
| 151 | ||
| 152 | test("the web page prints it", async () => { | |
| 153 | const res = await fetch(`${servers.url}/alice/notes/next`); | |
| 154 | expect(res.status).toBe(200); | |
| 155 | const page = await res.text(); | |
| 156 | expect(page).toContain(PASSED_WORDS.no_bet); | |
| 157 | expect(page).toContain(STALLED); | |
| 158 | }); | |
| 159 | ||
| 160 | test("the page, reasons and all, costs a bounded number of statements", async () => { | |
| 161 | await fetch(`${servers.url}/alice/notes/next`); | |
| 162 | const n = await requestStatements(app.db, `${servers.url}/alice/notes/next`); | |
| 163 | expectWork(n, { max: 120 }); | |
| 164 | }); | |
| 165 | ||
| 166 | test("the MCP tool carries it as why", async () => { | |
| 167 | const answer = await call("next_up", { app: "alice/notes", all: true }); | |
| 168 | const next = answer.value.next as { issue: number; why: string | null }[]; | |
| 169 | expect(next.find((r) => r.issue === plain.number)?.why).toBe(PASSED_WORDS.no_bet); | |
| 170 | expect(next.find((r) => r.issue === shunned.number)?.why).toBe(STALLED); | |
| 171 | }); | |
| 172 | ||
| 173 | test("the terminal prints it", async () => { | |
| 174 | const terminal = headlessTerminal(160, 40); | |
| 175 | const { app: tui } = createForgeApp(app, { sshUsername: "t", remoteAddress: "127.0.0.1", user: getUserByName(app.db, "alice") }, { terminal, exitHooks: false }); | |
| 176 | tui.start(); | |
| 177 | const type = async (text: string) => { | |
| 178 | for (const ch of text) tui.dispatch({ type: "key", key: ch, ctrl: false, alt: false, shift: false }); | |
| 179 | await settleApp(tui); | |
| 180 | }; | |
| 181 | const enter = async () => { | |
| 182 | tui.dispatch({ type: "key", key: "enter", ctrl: false, alt: false, shift: false }); | |
| 183 | await settleApp(tui); | |
| 184 | }; | |
| 185 | await settleApp(tui); | |
| 186 | tui.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false }); | |
| 187 | await settleApp(tui); | |
| 188 | await type("Next up in a repository"); | |
| 189 | await enter(); | |
| 190 | await type("alice/notes"); | |
| 191 | await enter(); | |
| 192 | const text = tui.screen.toText(); | |
| 193 | tui.stop(); | |
| 194 | expect(text).toContain("Tag notes"); | |
| 195 | expect(text).toContain("it is in no backed bet"); | |
| 196 | }); | |
| 197 | }); | |
| 198 | ||
| 199 | describe("wait_for stop on the Later gate", () => { | |
| 200 | test("leaves the issue under a pillar and ready", async () => { | |
| 201 | const gate = open("Later gate: nothing behind this starts until the owner opens it"); | |
| 202 | const held = open("Rename a note"); | |
| 203 | expect(linkIssue(app, alice, fresh(), held, gate).ok).toBe(true); | |
| 204 | expect(servedBy(app.db, pillarsOf(app.db, fresh())).get(held.id)).toBe("Later"); | |
| 205 | expect(ready().map((r) => r.issue.number)).not.toContain(held.number); | |
| 206 | const stopped = await call("wait_for", { app: "alice/notes", issue: held.number, blockedBy: gate.number, stop: true }); | |
| 207 | expect(stopped.isError).toBe(false); | |
| 208 | expect(getIssueById(app.db, held.id)!.state).toBe("open"); | |
| 209 | expect(servedBy(app.db, pillarsOf(app.db, fresh())).get(held.id)).toBe("P0 Notes"); | |
| 210 | expect(ready().map((r) => r.issue.number)).toContain(held.number); | |
| 211 | }); | |
| 212 | }); |
Non-blocking: this comment line lost its ' * ' prefix and wraps badly; reflow the paragraph.