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

Merged opened this

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

Merged

mason/forge:mason/1097-the-planner-is-not-started-on-a-directio → main 2 commits fork

  • Merged. mason/1097-the-planner-is-not-started-on-a-directio was merged into main as 42d0008.

Review

Sign in to review this change.
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
395395 issues each small enough for one run, link what waits for what, assign
396396 nobody, push nothing. The direction issue stays open, is never picked as
397397 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.
399403
400404 **What counts as done and as taken.** A pull that says _Closes #12_
401405 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";
8383 import { standing } from "../core/standing.ts";
8484 import { pace, type Pace } from "./pacing.ts";
8585 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";
8792 import { interruptionOf } from "./interrupted.ts";
8893 import {
8994 PARK_NOTE,
@@ -344,17 +349,7 @@ export async function driveRepo(
344349 // operator's; at a wall nothing new starts until the reset.
345350 const paced = repoPace(app, repo, now);
346351 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
353352 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);
358353 const graph = repoGraph(app.db, repo.id, { sealed: true });
359354 const proposed = proposedWork(app.db, repo);
360355 const ready = readyNow(graph).filter(
@@ -367,6 +362,19 @@ export async function driveRepo(
367362 !atRunCap(app, repo, r.issue) &&
368363 parkedCount(app, repo, r.issue.id) < PARK_STRIKES,
369364 );
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);
370378 const next = await nextOf(app, repo, ready);
371379 if (next !== undefined) {
372380 const routed = await routeIssue(app, repo, next.issue, crew, inFlight);
@@ -480,6 +488,13 @@ async function planIfThin(
480488 REPLAN_AFTER_MS
481489 )
482490 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);
483498 } else if (direction.assigneeId === planner.user.id) {
484499 return notPlanned(repo, "the planner already holds the direction");
485500 }
src/crew/brief.ts +9 −2
@@ -349,10 +349,15 @@ function taskBody(input: TaskInput): string {
349349
350350 // feedback
351351 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";
352355 out.push(
353356 input.pull !== undefined
354357 ? `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}).`,
356361 );
357362 if (
358363 [...news.comments, ...news.reviews, ...news.lines].some((c) =>
@@ -388,7 +393,9 @@ function taskBody(input: TaskInput): string {
388393 ) {
389394 out.push(
390395 "",
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.)",
392399 );
393400 }
394401 out.push(
src/crew/guard.ts +38 −2
@@ -3,7 +3,9 @@
33 * before a run is queued, neither a model call:
44 *
55 * - 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.
79 * - An issue that has started `issueRunCap` runs inside a day since a
810 * person who may direct the crew last spoke is capped: parked with one
911 * note, raised as one ask, and given nothing more until that person
@@ -19,7 +21,7 @@ import { recordDecision } from "../core/decisions.ts";
1921 import type { Issue } from "../core/issues.ts";
2022 import type { Repo } from "../core/repos.ts";
2123 import { directorsAmong } from "./direct.ts";
22 import { nothingNew } from "./news.ts";
24 import { nothingNew, plannerNothingNew } from "./news.ts";
2325
2426 /** Runs one issue may start in `CAP_WINDOW_MS` unless the setting says otherwise. */
2527 export const ISSUE_RUN_CAP = 6;
@@ -53,6 +55,40 @@ export function skipsFeedback(
5355 return true;
5456 }
5557
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
5692 /**
5793 * Whether `issue` has started `cap` runs inside the window since a person
5894 * 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(
104104 );
105105 }
106106
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
107141 /** Whether a feedback run of `agent` on `issue` would find nothing new to act on. */
108142 export function nothingNew(
109143 db: Database,
test/autonomy.test.ts +8
@@ -233,6 +233,14 @@ describe("a repository on its own", () => {
233233 costUsd: 1,
234234 });
235235 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);
236244 expect(await driveRepo(app, fresh(), later)).toBe("planned");
237245 expect(getIssueById(app.db, direction.id)!.assigneeId).toBe(atlas.id);
238246 });
test/crew-guard.test.ts +88 −5
@@ -4,8 +4,10 @@
44 * the newest word — is skipped before it is queued, and again as a queued
55 * one starts; an issue that started its day's share of runs without a
66 * 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.
911 */
1012 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1113 import { mkdtemp, rm } from "node:fs/promises";
@@ -13,10 +15,18 @@ import { tmpdir } from "node:os";
1315 import { join } from "node:path";
1416 import { createApp, type App } from "../src/app.ts";
1517 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";
1828 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";
2030 import { listDecisions } from "../src/core/decisions.ts";
2131 import {
2232 getIssueById,
@@ -34,6 +44,7 @@ import {
3444 listRuns,
3545 } from "../src/core/runs.ts";
3646 import { createUser, getUserByName, type User } from "../src/core/users.ts";
47 import { driveRepo } from "../src/crew/autonomy.ts";
3748 import { breakerParked, breakerTripped } from "../src/crew/breaker.ts";
3849 import { queueAgentRun } from "../src/crew/dispatch.ts";
3950 import {
@@ -338,6 +349,78 @@ describe("an issue cannot loop", () => {
338349 });
339350 });
340351
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
341424 describe("what the guard saved is counted", () => {
342425 test("skips and caps reach the usage report", () => {
343426 const since = new Date(Date.now() - 86_400_000).toISOString();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs