Public: anyone can read this; the crew can change it.

The planner is held while nothing lands or the plate is full, and the tick log says why #1444

Merged opened this

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

Merged

scout/forge:scout/1127-the-planner-is-held-while-nothing-lands → main 5 commits fork 4 threads · 4 unresolved

  • Merged. scout/1127-the-planner-is-held-while-nothing-lands was merged into main as 982e4bf.

Review

Sign in to review this change.
6 files changed +287 −6
README.md +7
@@ -309,6 +309,13 @@ changes, a line comment — and the agent picks it up in the background,
309309 resuming its session. A failed check re-engages it too, a bounded number of
310310 times before a person must speak.
311311
312 An autonomous app's planner is **held** while nothing is landing or the plate
313 is full: no pull has merged in 48 h while one was closed unmerged, or more than
314 `slots × 3` ready, unassigned issues are waiting. The tick log says which
315 (`planner: held`), and the board and the terminal's repository list say it in
316 words. A comment on the direction issue from someone who may write there lifts
317 the hold; an issue handed to the planner is still owed its pass.
318
312319 The forge asks the host for one more binary: `claude`, on `PATH` or at
313320 `FORGE_CLAUDE`, authenticated as the operator (an `ANTHROPIC_API_KEY` in the
314321 forge's environment is handed to runs; the machine's own Claude Code login
src/crew/autonomy.ts +103
@@ -59,6 +59,7 @@ import {
5959 pullDir,
6060 } from "../core/pulls.ts";
6161 import {
62 canWrite,
6263 getRepoById,
6364 listAutonomousRepos,
6465 setDirectionIssue,
@@ -439,6 +440,104 @@ function notPlanned(repo: Repo, why: string): false {
439440 return false;
440441 }
441442
443 /** Nothing landing for this long, with a pull closed unmerged, holds the planner. */
444 const LANDING_WINDOW_MS = 48 * 60 * 60_000;
445 /** More ready, unassigned issues than slots × this fill the plate. */
446 const PLATE_PER_SLOT = 3;
447
448 export type PlannerHold =
449 | { readonly kind: "nothing landed"; readonly since: string }
450 | { readonly kind: "plate full"; readonly ready: number };
451
452 /** Why a hold is held, as the tick log says it. */
453 export function holdWhy(hold: PlannerHold): string {
454 return hold.kind === "plate full"
455 ? `plate full (${hold.ready} ready)`
456 : `nothing landed since ${hold.since.slice(0, 10)}`;
457 }
458
459 /** The same reason in plain words, for the board. */
460 export function holdWords(hold: PlannerHold): string {
461 if (hold.kind === "plate full")
462 return `Planning paused: ${hold.ready} issues are ready and waiting`;
463 const at = new Date(hold.since);
464 const day = at.toLocaleDateString("en-US", {
465 month: "short",
466 day: "numeric",
467 timeZone: "UTC",
468 });
469 return `Planning paused: nothing has landed since ${day}`;
470 }
471
472 /**
473 * A word on the direction issue from someone who may write here, newer than
474 * the planner's last pass: the owner steering, which is never held.
475 */
476 function steered(app: App, repo: Repo): boolean {
477 if (repo.directionIssueId === null) return false;
478 const last = app.db
479 .query<{ created_at: string }, [string]>(
480 `SELECT created_at FROM run WHERE issue_id = ?1 AND kind = 'agent'
481 ORDER BY created_at DESC LIMIT 1`,
482 )
483 .get(repo.directionIssueId);
484 return app.db
485 .query<{ id: string }, [string, string]>(
486 `SELECT DISTINCT author_id AS id FROM comment
487 WHERE issue_id = ?1 AND created_at > ?2 AND origin_author IS NULL`,
488 )
489 .all(repo.directionIssueId, last?.created_at ?? "")
490 .some((r) => {
491 const who = getUserById(app.db, r.id);
492 return who !== undefined && who.kind !== "agent" && canWrite(app.db, repo, who);
493 });
494 }
495
496 /**
497 * Whether the planner is held for this repository: no pull merged in 48 h
498 * while one closed unmerged, or more ready, unassigned issues than
499 * `slots × 3`. A steer from a writer on the direction issue lifts it.
500 */
501 export function plannerHold(
502 app: App,
503 repo: Repo,
504 now = new Date(),
505 ): PlannerHold | null {
506 if (steered(app, repo)) return null;
507 const cutoff = new Date(now.getTime() - LANDING_WINDOW_MS).toISOString();
508 const merged = app.db
509 .query<{ at: string | null }, [string]>(
510 `SELECT MAX(p.merged_at) AS at FROM pull p JOIN issue i ON i.id = p.issue_id
511 WHERE i.repo_id = ?1`,
512 )
513 .get(repo.id)?.at ?? null;
514 if (merged === null || merged < cutoff) {
515 const refused = app.db
516 .query<{ n: number }, [string, string]>(
517 `SELECT COUNT(*) AS n FROM issue i JOIN pull p ON p.issue_id = i.id
518 WHERE i.repo_id = ?1 AND i.kind = 'pull' AND i.state = 'closed'
519 AND p.merged_at IS NULL AND i.closed_at >= ?2`,
520 )
521 .get(repo.id, cutoff)?.n ?? 0;
522 if (refused > 0) return { kind: "nothing landed", since: merged ?? cutoff };
523 }
524 const ready = readyNow(repoGraph(app.db, repo.id, { sealed: true })).filter(
525 (r) => r.issue.id !== repo.directionIssueId && r.issue.assigneeId === null,
526 ).length;
527 if (ready > Math.max(1, repo.autonomySlots) * PLATE_PER_SLOT)
528 return { kind: "plate full", ready };
529 return null;
530 }
531
532 function held(repo: Repo, why: string): false {
533 log.info("tick", {
534 repo: `${repo.ownerName}/${repo.name}`,
535 planner: "held",
536 why,
537 });
538 return false;
539 }
540
442541 async function planIfThin(
443542 app: App,
444543 repo: Repo,
@@ -472,6 +571,10 @@ async function planIfThin(
472571 if (ready >= THIN_MAP && owed.length === 0) return false;
473572 if (atRunCap(app, repo, direction))
474573 return notPlanned(repo, "parked: the direction is at its run cap");
574 // An issue the crew handed over is owed its pass whatever the hold: the
575 // planner splitting it is how work starts landing again.
576 const hold = owed.length === 0 ? plannerHold(app, repo, now) : null;
577 if (hold !== null) return held(repo, holdWhy(hold));
475578 if (lastPlan !== null && lastPlan !== undefined) {
476579 // A planner that declined the direction is not asked again until a
477580 // person has said something on it, or the crew hands it an issue.
src/tui/screens/dashboard.ts +12 −1
@@ -14,6 +14,7 @@
1414 * whole of this screen's design.
1515 */
1616
17 import { holdWords, plannerHold } from "../../crew/autonomy.ts";
1718 import { forgeNeeds } from "../../actions/health.ts";
1819 import { mayView } from "../../core/sealed.ts";
1920 import { pinRepo, unpinRepo } from "../../actions/origins.ts";
@@ -89,6 +90,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
8990 let pins: Pin[] = [];
9091 let pinned = new Set<string>();
9192 let onYou: OnYouItem[] = [];
93 /** Why planning is paused, for the autonomous apps where it is; as on the board. */
94 let holds = new Map<string, string>();
9295 let forge: string[] | undefined;
9396 const filter = new Filter();
9497 /** One cursor per pane: moving in one must never move another. */
@@ -106,6 +109,14 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
106109 pinned = f.user ? pinnedRepoIds(f.db, f.user.id) : new Set<string>();
107110 all = listReposVisibleTo(f.db, f.user, { limit: 200 });
108111 onYou = assignedToMe();
112 const now = new Date();

This computes plannerHold (a repoGraph build plus queries) for every autonomous repo in the 200-row list on each load. Compute it lazily for the visible rows, or cache it per tick.

113 holds = new Map(
114 all.flatMap((repo) => {
115 const hold =
116 repo.autonomy === "autonomous" ? plannerHold(f.app, repo, now) : null;
117 return hold === null ? [] : [[repo.id, holdWords(hold)] as const];
118 }),
119 );
109120 forge = forgeNeeds(f.app, f.user);
110121 // "Yours" is ownership plus the orgs you are on a team of; typing in the
111122 // filter reaches every repository you can see.
@@ -434,7 +445,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
434445 rows.map(
435446 (repo): ListRow => ({
436447 title: `${repoMark(pinned.has(repo.id), set)}${repo.ownerName}/${repo.name}`,
437 secondary: repo.description,
448 secondary: holds.get(repo.id) ?? repo.description,
438449 meta: metaOf(
439450 repo.visibility === "private" ? "private" : "",
440451 counts(repo.counts),
src/web/pages/board.ts +11
@@ -58,6 +58,8 @@ import { listActiveRuns, listRuns, type Run } from "../../core/runs.ts";
5858 import { getUsersByIds } from "../../core/users.ts";
5959 import {
6060 autonomyStatus,
61 holdWords,
62 plannerHold,
6163 plannerDeclines,
6264 startOfTodayUtc,
6365 truncateAtWord,
@@ -919,6 +921,9 @@ function today(ctx: Ctx, scope: BoardScope): Html {
919921 }).filter((p) => p.mergedAt !== null && p.mergedAt >= since).length;
920922 const share = s.ceilingUsd > 0 ? s.spentTodayUsd / s.ceilingUsd : 0;
921923 const review = reviewWords(reviewStats(db, repo.id, since));
924 const held =
925 repo.autonomy === "autonomous" ? plannerHold(ctx.app, repo, now) : null;

plannerHold runs per autonomous app on each render. Fine at today's scale, but share one result per tick with the tick log. Please look at this line at 360px.

926 const holdLine = held === null ? null : holdWords(held);
922927 const note =
923928 s.paused === "ceiling"
924929 ? "at its ceiling until 00:00 UTC"
@@ -948,6 +953,12 @@ function today(ctx: Ctx, scope: BoardScope): Html {
948953 <span class="board-app-week"
949954 >${perShippedWords(week.get(repo.id))} this week</span
950955 >
956 ${when(
957 holdLine !== null,
958 () => html`<span class="board-app-week board-app-hold"
outdated

Avoid the non-null assertion; compute the words once (const holdLine = hold && holdWords(hold)) and test that.

959 >${holdLine}</span
960 >`,
961 )}
951962 ${when(
952963 review !== null,
953964 () => html`<span class="board-app-week board-app-review"
test/autonomy.test.ts +136 −5
@@ -30,11 +30,15 @@ import { getPull } from "../src/core/pulls.ts";
3030 import { getRepoById, type Repo } from "../src/core/repos.ts";
3131 import { submitReview } from "../src/core/reviews.ts";
3232 import { claimRun, createRun, finishRun, listRuns } from "../src/core/runs.ts";
33 import { createUser, type User } from "../src/core/users.ts";
33 import { createUser, getUserByName, type User } from "../src/core/users.ts";
3434 import { serve, type Servers } from "../src/http/server.ts";
35 import { addComment, setIssueState } from "../src/actions/issues.ts";
3536 import {
3637 autonomyStatus,
3738 driveRepo,
39 holdWhy,
40 holdWords,
41 plannerHold,
3842 landIfGreen,
3943 pickBuilder,
4044 sweepGreen,
@@ -110,15 +114,15 @@ describe("a repository on its own", () => {
110114 if (!agent.ok) throw new Error(String(agent.error));
111115 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
112116 }
113 scout = (await import("../src/core/users.ts")).getUserByName(
117 scout = getUserByName(
114118 app.db,
115119 "scout",
116120 )!;
117 mason = (await import("../src/core/users.ts")).getUserByName(
121 mason = getUserByName(
118122 app.db,
119123 "mason",
120124 )!;
121 atlas = (await import("../src/core/users.ts")).getUserByName(
125 atlas = getUserByName(
122126 app.db,
123127 "atlas",
124128 )!;
@@ -664,7 +668,7 @@ describe("the slot count is the rule the loop enforces", () => {
664668 if (!agent.ok) throw new Error(String(agent.error));
665669 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
666670 }
667 const byName = (await import("../src/core/users.ts")).getUserByName;
671 const byName = getUserByName;
668672 builder = byName(app.db, "brick")!;
669673 reviewer = byName(app.db, "quill")!;
670674 planner = byName(app.db, "chart")!;
@@ -750,3 +754,130 @@ describe("the crew in a preview", () => {
750754 }
751755 });
752756 });
757
758 describe("the planner is held while nothing lands or the plate is full", () => {
759 let dataDir: string;
760 let app: App;
761 let alice: User;
762 let repo: Repo;
763 let planner: User;
764 const fresh = () => getRepoById(app.db, repo.id)!;
765 const direction = () => getIssueById(app.db, fresh().directionIssueId!)!;
766 let n = 0;
767 const closedPull = async (merged: boolean) => {
768 n += 1;
769 const branch = await commitToBranch(app, alice, repo, {
770 branch: `feature/p${n}`,
771 base: "main",
772 message: `P${n}`,
773 files: [{ path: `p${n}.ts`, content: `export const p = ${n};\n` }],
774 });
775 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
776 const opened = await openPull(app, alice, repo, {
777 title: `P${n}`,
778 baseBranch: "main",
779 headBranch: `feature/p${n}`,
780 });
781 if (!opened.ok) throw new Error(String(opened.error));
782 const closed = setIssueState(app, alice, repo, opened.value, "closed");
783 if (!closed.ok) throw new Error(String(closed.error));
784 if (merged)
785 app.db
786 .query("UPDATE pull SET merged_at = ?2 WHERE issue_id = ?1")
787 .run(opened.value.id, new Date().toISOString());
788 };
789
790 beforeAll(async () => {
791 dataDir = await mkdtemp(join(tmpdir(), "forge-held-"));
792 app = createApp({ dataDir, port: 0 });
793 const a = await createUser(app.db, {
794 trusted: true,
795 username: "alice",
796 email: "[email protected]",
797 password: "a-good-passphrase",
798 adminIfFirst: true,
799 });
800 if (!a.ok) throw new Error(a.error);
801 alice = a.value;
802 const made = await createRepo(app, alice, { name: "held", visibility: "private" });
803 if (!made.ok) throw new Error(String(made.error));
804 repo = made.value;
805 const seeded = await commitToBranch(app, alice, repo, {
806 message: "Start",
807 files: [{ path: "README.md", content: "# held\n" }],
808 });
809 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
810 for (const name of ["brick", "chart"]) {
811 const agent = await createAgent(app, alice, { username: name });
812 if (!agent.ok) throw new Error(String(agent.error));
813 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
814 }
815 const byName = getUserByName;
816 planner = byName(app.db, "chart")!;
817 setCrewRole(app.db, repo.id, planner.id, "planner");
818 const on = await updateRepoSettings(app, alice, repo, {
819 autonomy: "autonomous",
820 autonomyDirection: "Weave the loom",

Import getUserByName at the top rather than dynamically inside beforeAll.

821 autonomyCeilingUsd: 10,
822 autonomySlots: 1,
823 autonomyBy: alice.id,
824 });
825 expect(on.ok).toBe(true);
826 });
827
828 afterAll(async () => {
829 app.close();
830 await rm(dataDir, { recursive: true, force: true });
831 });
832
833 test("nothing landed with a pull closed unmerged holds it; a merge releases it; the owner's steer goes through", async () => {
834 // Nothing closed yet: a quiet repository is not a stuck one.
835 expect(plannerHold(app, fresh())).toBeNull();
836 await closedPull(false);
837 const hold = plannerHold(app, fresh());
838 expect(hold?.kind).toBe("nothing landed");
839 expect(holdWhy(hold!)).toMatch(/^nothing landed since \d{4}-\d\d-\d\d$/);
840 expect(holdWords(hold!)).toMatch(/^Planning paused: nothing has landed since \w{3} \d+$/);
841 await driveRepo(app, fresh());
842 expect(direction().assigneeId).toBeNull();
843 // The owner steers on the direction issue: never held.
844 const said = addComment(app, alice, fresh(), direction(), "Try the shuttle first");
845 expect(said.ok).toBe(true);
846 expect(plannerHold(app, fresh())).toBeNull();
847 // A reader's or an agent's word is not a steer.
848 app.db.query("DELETE FROM comment WHERE issue_id = ?1").run(direction().id);
849 expect(plannerHold(app, fresh())?.kind).toBe("nothing landed");
850 // A pull merges: the hold lifts and the planner is asked.
851 await closedPull(true);
852 expect(plannerHold(app, fresh())).toBeNull();
853 await driveRepo(app, fresh());
854 expect(direction().assigneeId).toBe(planner.id);
855 });
856
857 test("a merge older than 48 h does not release it, and a closed pull older than that does not hold", async () => {
858 const day3 = new Date(Date.now() + 72 * 60 * 60_000);
859 expect(plannerHold(app, fresh(), day3)).toBeNull();
860 await closedPull(false);
861 // Three days on, the old merge is stale; the unmerged pull is too.
862 expect(plannerHold(app, fresh(), day3)).toBeNull();
863 // Two days after the last merge, with a fresh refusal: held.
864 const day2 = new Date(Date.now() + 49 * 60 * 60_000);
865 app.db.query("UPDATE issue SET closed_at = ?1 WHERE kind = 'pull'").run(new Date(day2.getTime() - 60_000).toISOString());
866 expect(plannerHold(app, fresh(), day2)?.kind).toBe("nothing landed");
867 });
868
869 test("more than slots × 3 ready unassigned issues is a full plate, and it drains", () => {
870 app.db.query("DELETE FROM issue WHERE kind = 'pull'").run();
871 const made: Issue[] = [];
872 for (let i = 0; i < 4; i++) {
873 const o = openIssue(app, alice, fresh(), { title: `Thread ${i}` });
874 if (!o.ok) throw new Error(String(o.error));
875 made.push(o.value);
876 }
877 const hold = plannerHold(app, fresh());
878 expect(hold).toEqual({ kind: "plate full", ready: 4 });
879 expect(holdWhy(hold!)).toBe("plate full (4 ready)");
880 expect(setIssueState(app, alice, fresh(), made[0]!, "closed").ok).toBe(true);
881 expect(plannerHold(app, fresh())).toBeNull();
882 });
883 });
test/tui-screens.test.ts +18
@@ -221,6 +221,24 @@ describe("the terminal UI, against a real forge", () => {
221221 h.stop();
222222 });
223223
224 test("the dashboard says why planning is paused on an autonomous app, and only there", async () => {
225 const made = await createRepo(forge, alice, { name: "loom", visibility: "public" });
226 if (!made.ok) throw new Error("repo creation failed");
227 for (let i = 0; i < 4; i++) openIssue(forge, alice, made.value, { title: `Thread ${i}` });
228 // Assisted: the same plate, but nothing is planning, so nothing is held.
229 forge.db.query("UPDATE repo SET autonomy = 'assisted' WHERE id = ?1").run(made.value.id);
230 let h = boot(forge, asAlice());
231 await h.settle();
232 expect(h.text()).not.toContain("Planning paused");
233 h.stop();
234 forge.db.query("UPDATE repo SET autonomy = 'autonomous' WHERE id = ?1").run(made.value.id);
235 h = boot(forge, asAlice());
236 await h.settle();
237 expect(h.text()).toContain("Planning paused: 4 issues are ready and waiting");
238 h.stop();
239 forge.db.query("UPDATE repo SET autonomy = 'off' WHERE id = ?1").run(made.value.id);
240 });
241
224242 test("filter-as-you-type narrows the repository list", async () => {
225243 const h = boot(forge, asAlice());
226244 await h.settle();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs