Public: anyone can read this; the crew can change it.
The planner is not started on a direction issue whose only news is its own last summary #1317
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/1097-the-planner-is-not-started-on-a-directio → main 2 commits fork
7 files
changed +208
−21
docs/PLANNING.md +5 −1
| @@ -395,7 +395,11 @@ it, assigns the planner, and the planner's brief says the rest: open | ||
| 395 | 395 | issues each small enough for one run, link what waits for what, assign |
| 396 | 396 | nobody, push nothing. The direction issue stays open, is never picked as |
| 397 | 397 | work, and the planner is not asked again for twenty minutes unless its run |
| 398 | ended. | |
| 398 | ended. Nor is it asked again with nothing new: when nobody but the planner | |
| 399 | has spoken on the direction since its last pass began, and no issue has | |
| 400 | been opened by anyone else or closed since that pass ended, the clock | |
| 401 | skips it and records one `guard` decision by rule. A busy crew counts the | |
| 402 | ready issues before it plans; full slots alone are not a thin map. | |
| 399 | 403 | |
| 400 | 404 | **What counts as done and as taken.** A pull that says _Closes #12_ |
| 401 | 405 | closes #12 when it lands, as the person who landed it; that is what frees |
src/crew/autonomy.ts +26 −11
| @@ -83,7 +83,12 @@ import { reviewSummary } from "../core/reviews.ts"; | ||
| 83 | 83 | import { standing } from "../core/standing.ts"; |
| 84 | 84 | import { pace, type Pace } from "./pacing.ts"; |
| 85 | 85 | import { BREAKER_NOTE, breakerTripped } from "./breaker.ts"; |
| 86 | import { capReached, ISSUE_RUN_CAP } from "./guard.ts"; | |
| 86 | import { | |
| 87 | capReached, | |
| 88 | ISSUE_RUN_CAP, | |
| 89 | SKIP_SUMMARY, | |
| 90 | skipsReplan, | |
| 91 | } from "./guard.ts"; | |
| 87 | 92 | import { interruptionOf } from "./interrupted.ts"; |
| 88 | 93 | import { |
| 89 | 94 | PARK_NOTE, |
| @@ -344,17 +349,7 @@ export async function driveRepo( | ||
| 344 | 349 | // operator's; at a wall nothing new starts until the reset. |
| 345 | 350 | const paced = repoPace(app, repo, now); |
| 346 | 351 | if (paced.slots === 0 && slotted.length === 0) return "waiting"; |
| 347 | if (slotted.length >= paced.slots) { | |
| 348 | // The builders are busy; the planner can still refill the map. | |
| 349 | if (await planIfThin(app, repo, by, crew, active, 0, now)) return "planned"; | |
| 350 | return "full"; | |
| 351 | } | |
| 352 | ||
| 353 | 352 | const busyIssues = new Set(active.map((r) => r.issueId)); |
| 354 | const inFlight = new Map<string, number>(); | |
| 355 | for (const r of active) | |
| 356 | if (r.actorId !== null) | |
| 357 | inFlight.set(r.actorId, (inFlight.get(r.actorId) ?? 0) + 1); | |
| 358 | 353 | const graph = repoGraph(app.db, repo.id, { sealed: true }); |
| 359 | 354 | const proposed = proposedWork(app.db, repo); |
| 360 | 355 | const ready = readyNow(graph).filter( |
| @@ -367,6 +362,19 @@ export async function driveRepo( | ||
| 367 | 362 | !atRunCap(app, repo, r.issue) && |
| 368 | 363 | parkedCount(app, repo, r.issue.id) < PARK_STRIKES, |
| 369 | 364 | ); |
| 365 | if (slotted.length >= paced.slots) { | |
| 366 | // The builders are busy; the planner can still refill the map, when | |
| 367 | // it is thin — counted, never assumed: a full crew with a full map is | |
| 368 | // not a reason to plan (#1097). | |
| 369 | if (await planIfThin(app, repo, by, crew, active, ready.length, now)) | |
| 370 | return "planned"; | |
| 371 | return "full"; | |
| 372 | } | |
| 373 | ||
| 374 | const inFlight = new Map<string, number>(); | |
| 375 | for (const r of active) | |
| 376 | if (r.actorId !== null) | |
| 377 | inFlight.set(r.actorId, (inFlight.get(r.actorId) ?? 0) + 1); | |
| 370 | 378 | const next = await nextOf(app, repo, ready); |
| 371 | 379 | if (next !== undefined) { |
| 372 | 380 | const routed = await routeIssue(app, repo, next.issue, crew, inFlight); |
| @@ -480,6 +488,13 @@ async function planIfThin( | ||
| 480 | 488 | REPLAN_AFTER_MS |
| 481 | 489 | ) |
| 482 | 490 | return notPlanned(repo, "planned recently"); |
| 491 | // Nobody spoke and the map has not moved since the last pass: another | |
| 492 | // would read its own summary and change nothing (#1097). | |
| 493 | if ( | |
| 494 | owed.length === 0 && | |
| 495 | skipsReplan(app.db, repo, direction, planner, lastPlan.created_at) | |
| 496 | ) | |
| 497 | return notPlanned(repo, SKIP_SUMMARY); | |
| 483 | 498 | } else if (direction.assigneeId === planner.user.id) { |
| 484 | 499 | return notPlanned(repo, "the planner already holds the direction"); |
| 485 | 500 | } |
src/crew/brief.ts +9 −2
| @@ -349,10 +349,15 @@ function taskBody(input: TaskInput): string { | ||
| 349 | 349 | |
| 350 | 350 | // feedback |
| 351 | 351 | const news = newsSince({ ...input, agentName: input.agent.user.username }); |
| 352 | // An assignment that resumes the session is told here too, as what it | |
| 353 | // is: the issue given again, not a message nobody wrote (#1097). | |
| 354 | const again = input.reason === "assigned"; | |
| 352 | 355 | out.push( |
| 353 | 356 | input.pull !== undefined |
| 354 | 357 | ? `There is feedback on your pull #${issue.number} (${issue.title}).` |
| 355 | : `There is a new message on issue #${issue.number} (${issue.title}).`, | |
| 358 | : again | |
| 359 | ? `You have been given issue #${issue.number} (${issue.title}) again.` | |
| 360 | : `There is a new message on issue #${issue.number} (${issue.title}).`, | |
| 356 | 361 | ); |
| 357 | 362 | if ( |
| 358 | 363 | [...news.comments, ...news.reviews, ...news.lines].some((c) => |
| @@ -388,7 +393,9 @@ function taskBody(input: TaskInput): string { | ||
| 388 | 393 | ) { |
| 389 | 394 | out.push( |
| 390 | 395 | "", |
| 391 | "(The feedback was not captured in words; read the pull's conversation through the API.)", | |
| 396 | again | |
| 397 | ? "Nothing was said on it since your last run; what moved is in the repository's issues and map, through the API." | |
| 398 | : "(The feedback was not captured in words; read the pull's conversation through the API.)", | |
| 392 | 399 | ); |
| 393 | 400 | } |
| 394 | 401 | out.push( |
src/crew/guard.ts +38 −2
| @@ -3,7 +3,9 @@ | ||
| 3 | 3 | * before a run is queued, neither a model call: |
| 4 | 4 | * |
| 5 | 5 | * - A follow-up with nothing new (`news.ts`) is skipped: the agent would |
| 6 | * read its own reply as the newest word and decline, at full price. | |
| 6 | * read its own reply as the newest word and decline, at full price. So | |
| 7 | * is the clock giving the planner the direction again when nobody spoke | |
| 8 | * and the map has not moved since its last pass. | |
| 7 | 9 | * - An issue that has started `issueRunCap` runs inside a day since a |
| 8 | 10 | * person who may direct the crew last spoke is capped: parked with one |
| 9 | 11 | * note, raised as one ask, and given nothing more until that person |
| @@ -19,7 +21,7 @@ import { recordDecision } from "../core/decisions.ts"; | ||
| 19 | 21 | import type { Issue } from "../core/issues.ts"; |
| 20 | 22 | import type { Repo } from "../core/repos.ts"; |
| 21 | 23 | import { directorsAmong } from "./direct.ts"; |
| 22 | import { nothingNew } from "./news.ts"; | |
| 24 | import { nothingNew, plannerNothingNew } from "./news.ts"; | |
| 23 | 25 | |
| 24 | 26 | /** Runs one issue may start in `CAP_WINDOW_MS` unless the setting says otherwise. */ |
| 25 | 27 | export const ISSUE_RUN_CAP = 6; |
| @@ -53,6 +55,40 @@ export function skipsFeedback( | ||
| 53 | 55 | return true; |
| 54 | 56 | } |
| 55 | 57 | |
| 58 | /** | |
| 59 | * The clock giving `planner` the direction again with nothing new since its | |
| 60 | * last pass (`plannerNothingNew`): skip it. The clock asks every minute, so | |
| 61 | * one skip is recorded per pass, the first time it is asked after `since`. | |
| 62 | */ | |
| 63 | export function skipsReplan( | |
| 64 | db: Database, | |
| 65 | repo: Repo, | |
| 66 | direction: Issue, | |
| 67 | planner: Agent, | |
| 68 | since: string, | |
| 69 | ): boolean { | |
| 70 | if (!plannerNothingNew(db, repo, direction, planner)) return false; | |
| 71 | const recorded = db | |
| 72 | .query<{ n: number }, [string, string]>( | |
| 73 | `SELECT COUNT(*) AS n FROM decision | |
| 74 | WHERE issue_id = ?1 AND kind = 'guard' AND answer = 'skipped' | |
| 75 | AND state LIKE '%"reason":"replan"%' AND created_at > ?2`, | |
| 76 | ) | |
| 77 | .get(direction.id, since); | |
| 78 | if ((recorded?.n ?? 0) === 0) | |
| 79 | recordDecision(db, { | |
| 80 | kind: "guard", | |
| 81 | repoId: repo.id, | |
| 82 | issueId: direction.id, | |
| 83 | state: { reason: "replan", agent: planner.user.username }, | |
| 84 | answer: "skipped", | |
| 85 | p: 1, | |
| 86 | by: "rule", | |
| 87 | ms: 0, | |
| 88 | }); | |
| 89 | return true; | |
| 90 | } | |
| 91 | ||
| 56 | 92 | /** |
| 57 | 93 | * Whether `issue` has started `cap` runs inside the window since a person |
| 58 | 94 | * who may direct the crew last spoke on it. A run a restart or the |
src/crew/news.ts +34
| @@ -104,6 +104,40 @@ function lastBegun( | ||
| 104 | 104 | ); |
| 105 | 105 | } |
| 106 | 106 | |
| 107 | /** | |
| 108 | * Whether the planner, given the direction again by the clock, would find | |
| 109 | * nothing new since its last pass (#1097): no word on the direction but its | |
| 110 | * own since that pass began, and no issue another opened, nor any closed, | |
| 111 | * since it ended. The map is the planner's to read; when it has not moved | |
| 112 | * and nobody spoke, another pass reads its own summary and changes nothing. | |
| 113 | * With no ended pass yet, everything is new. | |
| 114 | */ | |
| 115 | export function plannerNothingNew( | |
| 116 | db: Database, | |
| 117 | repo: Repo, | |
| 118 | direction: Issue, | |
| 119 | planner: Agent, | |
| 120 | ): boolean { | |
| 121 | const pass = db | |
| 122 | .query<{ ended: string }, [string, string]>( | |
| 123 | `SELECT finished_at AS ended FROM run WHERE kind = 'agent' AND issue_id = ?1 | |
| 124 | AND actor_id = ?2 AND started_at IS NOT NULL AND finished_at IS NOT NULL | |
| 125 | AND state IN ('ok','failed','cancelled') | |
| 126 | ORDER BY started_at DESC LIMIT 1`, | |
| 127 | ) | |
| 128 | .get(direction.id, planner.user.id); | |
| 129 | if (pass === null || pass === undefined) return false; | |
| 130 | if (!nothingNew(db, repo, direction, planner)) return false; | |
| 131 | const moved = db | |
| 132 | .query<{ n: number }, [string, string, string, string]>( | |
| 133 | `SELECT COUNT(*) AS n FROM issue | |
| 134 | WHERE repo_id = ?1 AND kind = 'issue' AND id != ?2 | |
| 135 | AND ((created_at > ?3 AND author_id != ?4) OR closed_at > ?3)`, | |
| 136 | ) | |
| 137 | .get(repo.id, direction.id, pass.ended, planner.user.id); | |
| 138 | return (moved?.n ?? 0) === 0; | |
| 139 | } | |
| 140 | ||
| 107 | 141 | /** Whether a feedback run of `agent` on `issue` would find nothing new to act on. */ |
| 108 | 142 | export function nothingNew( |
| 109 | 143 | db: Database, |
test/autonomy.test.ts +8
| @@ -233,6 +233,14 @@ describe("a repository on its own", () => { | ||
| 233 | 233 | costUsd: 1, |
| 234 | 234 | }); |
| 235 | 235 | expect(await driveRepo(app, fresh())).toBe("idle"); |
| 236 | // Past the interval with nothing new since that pass — nobody spoke, | |
| 237 | // the map has not moved — it is not asked again (#1097)… | |
| 238 | expect(await driveRepo(app, fresh(), later)).toBe("idle"); | |
| 239 | // …and once a person speaks on the direction, it is. | |
| 240 | const { addComment } = await import("../src/actions/issues.ts"); | |
| 241 | expect( | |
| 242 | addComment(app, alice, fresh(), direction, "Rigid bodies next.").ok, | |
| 243 | ).toBe(true); | |
| 236 | 244 | expect(await driveRepo(app, fresh(), later)).toBe("planned"); |
| 237 | 245 | expect(getIssueById(app.db, direction.id)!.assigneeId).toBe(atlas.id); |
| 238 | 246 | }); |
test/crew-guard.test.ts +88 −5
| @@ -4,8 +4,10 @@ | ||
| 4 | 4 | * the newest word — is skipped before it is queued, and again as a queued |
| 5 | 5 | * one starts; an issue that started its day's share of runs without a |
| 6 | 6 | * person's word is parked once and asked about once; a reader's word |
| 7 | * re-arms neither the cap nor the breaker. Each skip and cap is counted for | |
| 8 | * the token-return view. | |
| 7 | * re-arms neither the cap nor the breaker. The clock does not give the | |
| 8 | * planner the direction again when nobody spoke and the map has not moved | |
| 9 | * since its last pass (#1097). Each skip and cap is counted for the | |
| 10 | * token-return view. | |
| 9 | 11 | */ |
| 10 | 12 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 11 | 13 | import { mkdtemp, rm } from "node:fs/promises"; |
| @@ -13,10 +15,18 @@ import { tmpdir } from "node:os"; | ||
| 13 | 15 | import { join } from "node:path"; |
| 14 | 16 | import { createApp, type App } from "../src/app.ts"; |
| 15 | 17 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 16 | import { addComment, openIssue } from "../src/actions/issues.ts"; | |
| 17 | import { createRepo } from "../src/actions/repos.ts"; | |
| 18 | import { | |
| 19 | addComment, | |
| 20 | assignIssue, | |
| 21 | openIssue, | |
| 22 | } from "../src/actions/issues.ts"; | |
| 23 | import { | |
| 24 | commitToBranch, | |
| 25 | createRepo, | |
| 26 | updateRepoSettings, | |
| 27 | } from "../src/actions/repos.ts"; | |
| 18 | 28 | import { readUsage } from "../src/actions/usage.ts"; |
| 19 | import { getAgent, type Agent } from "../src/core/agents.ts"; | |
| 29 | import { getAgent, setCrewRole, type Agent } from "../src/core/agents.ts"; | |
| 20 | 30 | import { listDecisions } from "../src/core/decisions.ts"; |
| 21 | 31 | import { |
| 22 | 32 | getIssueById, |
| @@ -34,6 +44,7 @@ import { | ||
| 34 | 44 | listRuns, |
| 35 | 45 | } from "../src/core/runs.ts"; |
| 36 | 46 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| 47 | import { driveRepo } from "../src/crew/autonomy.ts"; | |
| 37 | 48 | import { breakerParked, breakerTripped } from "../src/crew/breaker.ts"; |
| 38 | 49 | import { queueAgentRun } from "../src/crew/dispatch.ts"; |
| 39 | 50 | import { |
| @@ -338,6 +349,78 @@ describe("an issue cannot loop", () => { | ||
| 338 | 349 | }); |
| 339 | 350 | }); |
| 340 | 351 | |
| 352 | describe("the planner is not given the direction again with nothing new (#1097)", () => { | |
| 353 | test("its own summary, posted as its run ends, starts nothing; a person's word does", async () => { | |
| 354 | const atlas = ok(await createAgent(app, alice, { username: "atlas" })); | |
| 355 | const round = ok( | |
| 356 | await createRepo(app, alice, { name: "round", visibility: "public" }), | |
| 357 | ); | |
| 358 | ok( | |
| 359 | await commitToBranch(app, alice, round, { | |
| 360 | message: "Start", | |
| 361 | files: [{ path: "README.md", content: "# round\n" }], | |
| 362 | }), | |
| 363 | ); | |
| 364 | for (const name of ["bot", "atlas"]) | |
| 365 | expect(addToCrew(app, alice, round, name).ok).toBe(true); | |
| 366 | setCrewRole(app.db, round.id, atlas.user.id, "planner"); | |
| 367 | ok( | |
| 368 | await updateRepoSettings(app, alice, round, { | |
| 369 | autonomy: "autonomous", | |
| 370 | autonomyDirection: "Make the square rounder.", | |
| 371 | autonomyCeilingUsd: 10, | |
| 372 | autonomySlots: 2, | |
| 373 | autonomyBy: alice.id, | |
| 374 | }), | |
| 375 | ); | |
| 376 | const now = (): Repo => getRepoById(app.db, round.id)!; | |
| 377 | const skips = () => | |
| 378 | listDecisions(app.db, round.id, { kind: "guard" }).filter( | |
| 379 | (d) => d.answer === "skipped", | |
| 380 | ).length; | |
| 381 | // The map is dry: the clock gives the planner the direction. | |
| 382 | expect(await driveRepo(app, now())).toBe("planned"); | |
| 383 | const direction = getIssueById(app.db, now().directionIssueId!)!; | |
| 384 | expect(direction.assigneeId).toBe(atlas.user.id); | |
| 385 | // Its pass: it reads, posts its summary as it ends, and lets go. | |
| 386 | const pass = createRun(app.db, { | |
| 387 | repoId: round.id, | |
| 388 | kind: "agent", | |
| 389 | issueId: direction.id, | |
| 390 | actorId: atlas.user.id, | |
| 391 | reason: "assigned", | |
| 392 | }); | |
| 393 | claimRun(app.db, pass.id); | |
| 394 | await Bun.sleep(5); | |
| 395 | ok(addComment(app, atlas.user, now(), direction, "I linked the map.")); | |
| 396 | finishRun(app.db, pass.id, { state: "ok", summary: "planned", costUsd: 0.5 }); | |
| 397 | ok(assignIssue(app, alice, now(), direction, null)); | |
| 398 | // Its own summary is no news: no follow-up is queued on it… | |
| 399 | const unheld = getIssueById(app.db, direction.id)!; | |
| 400 | expect( | |
| 401 | queueAgentRun(app, now(), unheld, atlas, "feedback", alice.id), | |
| 402 | ).toBeUndefined(); | |
| 403 | // …and the clock, past its interval, does not give it the direction | |
| 404 | // again. The skip is the rule's, recorded once however often it asks. | |
| 405 | const later = new Date(Date.now() + 30 * 60_000); | |
| 406 | const before = skips(); | |
| 407 | expect(await driveRepo(app, now(), later)).not.toBe("planned"); | |
| 408 | expect(await driveRepo(app, now(), later)).not.toBe("planned"); | |
| 409 | expect(getIssueById(app.db, direction.id)!.assigneeId).toBeNull(); | |
| 410 | expect(skips()).toBe(before + 1); | |
| 411 | const skip = listDecisions(app.db, round.id, { kind: "guard" }).find((d) => | |
| 412 | JSON.stringify(d.state).includes("replan"), | |
| 413 | )!; | |
| 414 | expect(skip.by).toBe("rule"); | |
| 415 | expect(skip.issueId).toBe(direction.id); | |
| 416 | // A person's word on the direction is news: it is given again. | |
| 417 | await Bun.sleep(5); | |
| 418 | ok(addComment(app, alice, now(), unheld, "Rounder means radius 8.")); | |
| 419 | expect(await driveRepo(app, now(), later)).toBe("planned"); | |
| 420 | expect(getIssueById(app.db, direction.id)!.assigneeId).toBe(atlas.user.id); | |
| 421 | }); | |
| 422 | }); | |
| 423 | ||
| 341 | 424 | describe("what the guard saved is counted", () => { |
| 342 | 425 | test("skips and caps reach the usage report", () => { |
| 343 | 426 | const since = new Date(Date.now() - 86_400_000).toISOString(); |
new above
offline