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

A change is not parked for the runner's failure: ask whose fault before stopping #149

Merged opened this

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

Merged

mason/forge:mason/128-a-change-is-not-parked-for-the-runner-s → main 3 commits fork

  • Merged. mason/128-a-change-is-not-parked-for-the-runner-s was merged into main as 50a1697.

Review

Sign in to review this change.
4 files changed +369 −130
docs/APPS.md +5 −1
@@ -496,7 +496,11 @@ outbox with a sweep behind the wake-up:
496496 lands as a new pull that names the one it continues.
497497 - **A failed check.** A deploy that fails on the agent's pull, or the
498498 repository's own check failing on it, starts a follow-up with the log's
499 tail. Bounded by rule 7.
499 tail. Bounded by rule 7. A failure the crew reads as the place it ran —
500 a timeout, a missing tool, a flaky runner — runs the check again once
501 for that commit instead, and spends none of the pull's tries; a second
502 red on the same commit is taken at face value, and what the pull is
503 parked with says which of the two failed.
500504
501505 Forks follow their parents. A fork's default branch is a copy of the
502506 parent's until someone commits on it, and while that is so the parent
docs/PLANNING.md +16 −9
@@ -234,11 +234,15 @@ Where it decides today:
234234 lands by rule. The decider reads the pull against the direction and may
235235 say _show_: the pull stays open and appears in the studio as a look,
236236 saying the decider asked. One answer per head; a new push is asked again.
237 - **Try again or stop** (`crew/dispatch.ts`, `retryOrStop`): a failed check
238 gets another automatic run by rule, up to the limit. The decider reads
239 the failure, the log's tail and the count, and may stop sooner, before
240 the run's money is spent; the pull then waits for a person as a stopped
241 ask.
237 - **Whose fault, and try again or stop** (`crew/dispatch.ts`,
238 `judgeFailedCheck`): a failed check gets another automatic run by rule,
239 up to the limit. The decider reads the failure, the log's tail and the
240 count and answers both at once — whether another try is worth its money,
241 and whether the change, the environment or the base branch is what
242 failed. The cause is settled before anything is parked: an environmental
243 failure runs the check again (below) whatever the verdict, so a flaky
244 runner never throws a change away. Otherwise _stop_ waits for a person as
245 a stopped ask, and the parked reason says which of the two it was.
242246
243247 **The ledger.** Each of these is written to the `decision` table
244248 (`core/decisions.ts`): what the decider saw, what it answered and with what
@@ -248,10 +252,13 @@ or red, a picked issue shipped. `GET /api/v1/repos/:owner/:name/decisions`
248252 lists it with the week summed per kind. It is the value function's raw
249253 material: whether the decider beats the rule is a query, not an opinion.
250254
251 - **A red check** (`crew/dispatch.ts`): from the tail of the check's log,
252 whether the change, the environment or the base branch failed. When it
253 is the environment's fault, at 70% or better, the check runs again once
254 instead of the agent reworking a change that is fine.
255 - **A red check** (`crew/dispatch.ts`): the `cause` half of the decision
256 above — from the tail of the check's log, whether the change, the
257 environment or the base branch failed. At 70% or better the
258 environment's fault runs the check again, once per head commit, instead
259 of the agent reworking a change that is fine or the pull being parked
260 for someone else's bad luck. A second red on the same commit is real:
261 the pull parks, saying the environment.
255262 - **Cards** (`web/studio/leans.ts`): which option on an "asks you" card is
256263 likely right, so the card leads with it — _likely_, _probably_ or _maybe_
257264 beside the button, the probability on hover. The person still decides.
src/crew/dispatch.ts +193 −118
@@ -50,7 +50,7 @@ import {
5050 spentToday,
5151 } from "./autonomy.ts";
5252 import { checksFor } from "../checks/service.ts";
53 import { certain, decideOr } from "./decide.ts";
53 import { certain } from "./decide.ts";
5454 import { readRunLog } from "../runs/log.ts";
5555 import { whenText } from "./limits.ts";
5656 import {
@@ -391,9 +391,6 @@ function route(app: App, event: ForgeEvent): void {
391391 });
392392 return;
393393 }
394 // Whose fault: the change, or the environment the check ran in? A
395 // flaky runner or a broken base is not the agent's to rework; the
396 // check runs again instead, once, and the agent's tries are not spent.
397394 const runId = event.payload["runId"];
398395 // Without a decider the rule answers at once, and the rework queues
399396 // in the same tick the event landed — nothing waits on a promise.
@@ -401,44 +398,20 @@ function route(app: App, event: ForgeEvent): void {
401398 queueAgentRun(app, repo, issue, agent, "check_failed");
402399 return;
403400 }
404 void retryOrStop(app, repo, issue, runId, since).then((verdict) => {
405 if (verdict === "stop") {
406 // The decider's stop is the crew's stop: parked, never waiting,
407 // in a repository on its own.
408 const pull = getPull(app.db, repo.id, issue.number);
409 if (pull)
410 parkPull(
411 app,
412 repo,
413 pull,
414 "the check failed and the decider saw no point in another try",
415 );
416 return;
417 }
418 return whoseFault(app, runId).then((fault) => {
419 const checks = checksFor(app);
420 const kind = event.payload["kind"];
421 const commit = event.payload["commitOid"];
422 const env = event.payload["env"];
423 if (
424 fault === "environment" &&
425 kind === "check" &&
426 checks !== undefined &&
427 typeof commit === "string" &&
428 typeof env === "string" &&
429 !retriedChecks.has(`${issue.id}:${commit}`)
430 ) {
431 retriedChecks.add(`${issue.id}:${commit}`);
432 log.info(
433 "check looked like the environment's fault; running it again",
434 { issue: issue.number },
435 );
436 void checks.request(repo, env, commit, "retry", issue.id);
437 return;
438 }
439 queueAgentRun(app, repo, issue, agent, "check_failed");
440 });
441 });
401 const commitOid = event.payload["commitOid"];
402 const env = event.payload["env"];
403 void answerFailedCheck(app, repo, issue, agent, {
404 runId,
405 kind: String(event.payload["kind"] ?? ""),
406 commitOid: typeof commitOid === "string" ? commitOid : undefined,
407 env: typeof env === "string" ? env : undefined,
408 since,
409 }).catch((error) =>
410 log.error("a failed check went unanswered", {
411 issue: issue.number,
412 error: String(error),
413 }),
414 );
442415 return;
443416 }
444417 case "pull.closed":
@@ -480,32 +453,178 @@ function route(app: App, event: ForgeEvent): void {
480453 }
481454 }
482455
483 /** The agent an issue or pull belongs to: the assignee, or the pull's author. */
484456 /** Checks already run again once for a head; a second red is real. */
485457 const retriedChecks = new Set<string>();
486458
459 /** What a failed check led to, once the crew had read it. */
460 export type CheckAnswer = "reran" | "reworked" | "parked" | "waiting";
461
487462 /**
488 * Whose fault a red check is, from the tail of its log: the change
489 * (the agent reworks it), the environment (the check runs again), or the
490 * base branch (a person's). The rule says the change; a decider may know
491 * better from the log — a network timeout, a missing tool, a flake.
463 * A failed check, answered. The cause is established *before* the pull is
464 * parked, because the two questions are one decision (`judgeFailedCheck`):
465 *
466 * - the environment's fault — a timeout, a flaky runner, a full disk — and
467 * the check runs again, once for that commit, without spending the
468 * agent's tries. A stop on that would throw away a change nobody has
469 * found fault with.
470 * - `stop` — the pull is parked, with a reason that says which of the two
471 * it was, so the next reader (and the planner) can tell a change that
472 * failed from a change that was unlucky. The re-run is bounded by
473 * `retriedChecks`: a permanently broken runner parks on the second red.
474 * - `again` — the agent reworks it, as ever.
492475 */
493 async function whoseFault(
476 export async function answerFailedCheck(
494477 app: App,
495 runId: string | undefined,
496 ): Promise<"change" | "environment" | "base"> {
497 const decider = app.config.decider;
498 if (decider === undefined || runId === undefined) return "change";
499 const tail = readRunLog(app.config.dataDir, runId)
500 .lines.join("\n")
501 .slice(-4_000);
502 const options = ["change", "environment", "base"] as const;
503 const decided = await decideOr(
504 decider,
505 { checkLogTail: tail },
478 repo: Repo,
479 issue: Issue,
480 agent: Agent,
481 failed: {
482 readonly runId: string;
483 readonly kind: string;
484 readonly commitOid?: string | undefined;
485 readonly env?: string | undefined;
486 /** When a person last spoke: what the tries are counted from. */
487 readonly since: string;
488 },
489 ): Promise<CheckAnswer> {
490 const judged = await judgeFailedCheck(
491 app,
492 repo,
493 issue,
494 failed.runId,
495 failed.since,
496 );
497 const head = `${issue.id}:${failed.commitOid ?? ""}`;
498 const checks = checksFor(app);
499 if (
500 judged.cause === "environment" &&
501 failed.kind === "check" &&
502 checks !== undefined &&
503 failed.commitOid !== undefined &&
504 failed.env !== undefined &&
505 !retriedChecks.has(head)
506 ) {
507 retriedChecks.add(head);
508 log.info("check looked like the environment's fault; running it again", {
509 issue: issue.number,
510 retry: judged.retry,
511 });
512 await checks
513 .request(repo, failed.env, failed.commitOid, "retry", issue.id)
514 .catch((error) =>
515 log.warn("the check could not be run again", {
516 issue: issue.number,
517 error: String(error),
518 }),
519 );
520 return "reran";
521 }
522 if (judged.retry === "stop") {
523 // The decider's stop is the crew's stop: parked, never waiting, in a
524 // repository on its own.
525 const pull = getPull(app.db, repo.id, issue.number);
526 if (
527 pull &&
528 parkPull(app, repo, pull, parkedReason(judged, failed.kind, head))
529 )
530 return "parked";
531 log.info("check failed; waiting for a person", { issue: issue.number });
532 return "waiting";
533 }
534 queueAgentRun(app, repo, issue, agent, "check_failed");
535 return "reworked";
536 }
537
538 /**
539 * Why a parked pull was parked, in the two cases a reader must tell apart:
540 * a change that failed, and a change that was unlucky. It names what
541 * failed too — a preview that would not boot is not a red test.
542 */
543 function parkedReason(
544 judged: CheckJudgement,
545 kind: string,
546 head: string,
547 ): string {
548 const what = kind === "deploy" ? "the preview's deploy" : "the check";
549 if (judged.cause !== "environment")
550 return `${what} failed and the decider saw no point in another try`;
551 return retriedChecks.has(head)
552 ? `${what} failed in the place it ran, not in the change, and failed the same way when it was run again`
553 : `${what} failed in the place it ran, not in the change, and it could not be run again`;
554 }
555
556 export function agentFor(app: App, issue: Issue): Agent | undefined {
557 if (issue.assigneeId !== null) {
558 const assigned = getAgent(app.db, issue.assigneeId);
559 if (assigned) return assigned;
560 }
561 return issue.kind === "pull" ? getAgent(app.db, issue.authorId) : undefined;
562 }
563
564 /** What the crew makes of a red check: whose fault, and whether to try again. */
565 export interface CheckJudgement {
566 /** Whether another automatic try by the agent is worth its cost. */
567 readonly retry: "again" | "stop";
568 /** Whose fault the failure is, from the tail of the check's log. */
569 readonly cause: "change" | "environment" | "base";
570 }
571
572 const RETRY_OPTIONS = ["again", "stop"];
573 const CAUSE_OPTIONS = ["change", "environment", "base"];
574
575 /**
576 * Whose fault the failure is, and whether to try again — asked together,
577 * of one log tail, in one round trip, because the answers are only useful
578 * together: a `stop` on a flaky runner throws away a change that is fine,
579 * and an `again` on a broken environment reworks code that is not at
580 * fault. The rule says the change's fault and another try, up to the
581 * limit; the decider reads the failure, the log's tail and the count and
582 * may know better. `retry` is the answer the ledger keeps (the next
583 * check's result is its outcome); `cause` rides along.
584 */
585 export async function judgeFailedCheck(
586 app: App,
587 repo: Repo,
588 issue: Issue,
589 runId: string,
590 since: string,
591 ): Promise<CheckJudgement> {
592 if (app.config.decider === undefined)
593 return { retry: "again", cause: "change" };
594 const run = getRun(app.db, runId);
595 const tail = readRunLog(app.config.dataDir, runId, 8 * 1024).lines.slice(-25);
596 const decided = await decideAndRecord(
597 app,
598 { kind: "retry", repo, issue },
506599 {
600 direction: repo.autonomyDirection,
601 pull: { number: issue.number, title: issue.title },
602 tries: automaticRunsSince(app.db, issue.id, since),
603 limit: CHECK_FAILED_LIMIT,
604 check: {
605 kind: run?.kind ?? "check",
606 summary: run?.summary ?? "",
607 // What the check's own output said, parsed once when it finished
608 // (`checks/bun-test.ts`): one red name beside 274 green ones reads
609 // very differently from a suite that is red all through, and that
610 // difference is most of what `cause` turns on.
611 ...(run?.checkDetail == null ? {} : { failing: run.checkDetail }),
612 },
613 log: tail,
614 },
615 {
616 retry: {
617 options: RETRY_OPTIONS,
618 about:
619 "The check failed. Is another automatic try by the agent worth its cost, or should a person look?",
620 describe: {
621 again:
622 "Try again: the failure reads as something the agent can fix from the log.",
623 stop: "Stop: the failure repeats an earlier one, or is beyond what another run would fix.",
624 },
625 },
507626 cause: {
508 options,
627 options: CAUSE_OPTIONS,
509628 about:
510629 "Why the repository's check failed, judging from the end of its log.",
511630 describe: {
@@ -517,70 +636,26 @@ async function whoseFault(
517636 },
518637 },
519638 },
520 () => ({ cause: certain(options, "change") }),
639 () => ({
640 retry: certain(RETRY_OPTIONS, "again"),
641 cause: certain(CAUSE_OPTIONS, "change"),
642 }),
521643 );
522 return decided.answers.cause.p >= 0.7
523 ? (decided.answers.cause.choice as "change" | "environment" | "base")
524 : "change";
525 }
526
527 export function agentFor(app: App, issue: Issue): Agent | undefined {
528 if (issue.assigneeId !== null) {
529 const assigned = getAgent(app.db, issue.assigneeId);
530 if (assigned) return assigned;
531 }
532 return issue.kind === "pull" ? getAgent(app.db, issue.authorId) : undefined;
533 }
534
535 /**
536 * Try again, or stop and wait for a person. The rule tries again up to the
537 * limit; the decider reads the failure and the count and may stop sooner,
538 * before another run's money is spent. The next check's result is the
539 * outcome, written back on the ledger row.
540 */
541 export async function retryOrStop(
542 app: App,
543 repo: Repo,
544 issue: Issue,
545 runId: string,
546 since: string,
547 ): Promise<"again" | "stop"> {
548 if (app.config.decider === undefined) return "again";
549 const run = getRun(app.db, runId);
550 const tail = readRunLog(app.config.dataDir, runId, 8 * 1024).lines.slice(-25);
551 const options = ["again", "stop"];
552 const decided = await decideAndRecord(
553 app,
554 { kind: "retry", repo, issue },
555 {
556 direction: repo.autonomyDirection,
557 pull: { number: issue.number, title: issue.title },
558 tries: automaticRunsSince(app.db, issue.id, since),
559 limit: CHECK_FAILED_LIMIT,
560 check: { kind: run?.kind ?? "check", summary: run?.summary ?? "" },
561 log: tail,
562 },
563 {
564 retry: {
565 options,
566 about:
567 "The check failed. Is another automatic try by the agent worth its cost, or should a person look?",
568 describe: {
569 again:
570 "Try again: the failure reads as something the agent can fix from the log.",
571 stop: "Stop: the failure is environmental, repeats an earlier one, or is beyond what another run would fix.",
572 },
573 },
574 },
575 () => ({ retry: certain(options, "again") }),
576 );
577 if (decided.answers.retry.choice === "stop")
644 const retry = decided.answers.retry.choice === "stop" ? "stop" : "again";
645 // The change is what a half-sure answer means: an environment called
646 // that way on a hunch is how a broken change escapes a rework.
647 const cause =
648 decided.answers.cause.p >= 0.7
649 ? (decided.answers.cause.choice as CheckJudgement["cause"])
650 : "change";
651 if (retry === "stop")
578652 log.info("check failed; the decider stopped here for a person", {
579653 issue: issue.number,
580654 by: decided.by,
581655 p: decided.answers.retry.p.toFixed(2),
656 cause,
582657 });
583 return decided.answers.retry.choice === "stop" ? "stop" : "again";
658 return { retry, cause };
584659 }
585660
586661 /** When a person last said anything on the conversation, or its start. */
test/ledger.test.ts +155 −2
@@ -31,7 +31,8 @@ import { createUser, getUserByName, type User } from "../src/core/users.ts";
3131 import { setCrewRole } from "../src/core/agents.ts";
3232 import type { Answer, Decider, Questions } from "../src/crew/decide.ts";
3333 import { driveRepo, landIfGreen } from "../src/crew/autonomy.ts";
34 import { retryOrStop } from "../src/crew/dispatch.ts";
34 import { answerFailedCheck, judgeFailedCheck } from "../src/crew/dispatch.ts";
35 import { startChecks } from "../src/checks/service.ts";
3536 import { serve, type Servers } from "../src/http/server.ts";
3637
3738 /** A decider that answers from a script: question name → choice. */
@@ -238,7 +239,8 @@ describe("the decision ledger", () => {
238239 });
239240 script["retry"] = "stop";
240241 expect(
241 await retryOrStop(app, fresh(), pull, failed.id, pull.createdAt),
242 (await judgeFailedCheck(app, fresh(), pull, failed.id, pull.createdAt))
243 .retry,
242244 ).toBe("stop");
243245 const seen = decider.seen.at(-1) as {
244246 tries: number;
@@ -261,6 +263,157 @@ describe("the decision ledger", () => {
261263 expect(resolveOpen(app.db, pull.id, "retry", "green")).toBe(false);
262264 });
263265
266 test("stop is not the last word on a red check: the environment's fault runs it again, once per commit, before anything is parked", async () => {
267 const { getAgentByName } = await import("../src/core/agents.ts");
268 const { listComments } = await import("../src/core/issues.ts");
269 const scoutAgent = getAgentByName(app.db, "scout")!;
270 const checks = startChecks(app, { forgeUrl: servers.url, concurrency: 1 });
271 try {
272 const branch = await commitToBranch(app, alice, repo, {
273 branch: "feature/flaky",
274 base: "main",
275 message: "Flaky",
276 files: [
277 {
278 path: "forge.json",
279 content: `{"check": {"run": "test -f OK.md", "install": ""}}\n`,
280 },
281 { path: "OK.md", content: "ok\n" },
282 ],
283 });
284 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
285 const opened = await openPull(app, scout, repo, {
286 title: "Flaky",
287 baseBranch: "main",
288 headBranch: "feature/flaky",
289 });
290 if (!opened.ok) throw new Error(String(opened.error));
291 const pull = opened.value;
292 const red = (): string => {
293 const run = createRun(app.db, {
294 repoId: repo.id,
295 kind: "check",
296 issueId: pull.id,
297 env: `pull-${pull.number}`,
298 commitOid: pull.headOid,
299 reason: "pull",
300 });
301 claimRun(app.db, run.id);
302 finishRun(app.db, run.id, {
303 state: "failed",
304 summary: "(fail) a pull closed under a running agent [60000.50ms]",
305 });
306 return run.id;
307 };
308 const failure = {
309 kind: "check",
310 commitOid: pull.headOid,
311 env: `pull-${pull.number}`,
312 since: pull.createdAt,
313 };
314 const retries = (): number =>
315 listRuns(app.db, repo.id, { kind: "check", limit: 50 }).filter(
316 (r) => r.issueId === pull.id && r.reason === "retry",
317 ).length;
318
319 // A timeout in a file the change never touched, and the decider says
320 // stop. Today that parked the pull on the spot; the cause is asked
321 // first now, and it is the environment's — the check runs again.
322 script["retry"] = "stop";
323 script["cause"] = "environment";
324 const asked = decider.seen.length;
325 expect(
326 await answerFailedCheck(app, fresh(), pull, scoutAgent, {
327 runId: red(),
328 ...failure,
329 }),
330 ).toBe("reran");
331 // One decision, not two: whose fault and whether to try again come
332 // back from the same round trip.
333 expect(decider.seen.length).toBe(asked + 1);
334 expect(getPull(app.db, repo.id, pull.number)?.state).toBe("open");
335 expect(retries()).toBe(1);
336 // The agent's tries are not spent: no rework was queued.
337 expect(
338 listRuns(app.db, repo.id, { kind: "agent" }).some(
339 (r) => r.issueId === pull.id && r.reason === "check_failed",
340 ),
341 ).toBe(false);
342
343 // Once per head commit: red again on the same commit is real, and
344 // the pull parks with a reason that says the environment.
345 expect(
346 await answerFailedCheck(app, fresh(), pull, scoutAgent, {
347 runId: red(),
348 ...failure,
349 }),
350 ).toBe("parked");
351 expect(retries()).toBe(1);
352 expect(getPull(app.db, repo.id, pull.number)?.state).toBe("closed");
353 const why = listComments(app.db, pull.id).map((c) => c.body);
354 expect(
355 why.some((b) => b.includes("in the place it ran, not in the change")),
356 ).toBe(true);
357 } finally {
358 await checks.stop();
359 delete script["cause"];
360 }
361 }, 60_000);
362
363 test("the change's fault parks as it always has, and says so", async () => {
364 const { getAgentByName } = await import("../src/core/agents.ts");
365 const { listComments } = await import("../src/core/issues.ts");
366 const scoutAgent = getAgentByName(app.db, "scout")!;
367 const branch = await commitToBranch(app, alice, repo, {
368 branch: "feature/broken",
369 base: "main",
370 message: "Broken",
371 files: [{ path: "broken.ts", content: "export const broken = 1;\n" }],
372 });
373 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
374 const opened = await openPull(app, scout, repo, {
375 title: "Broken",
376 baseBranch: "main",
377 headBranch: "feature/broken",
378 });
379 if (!opened.ok) throw new Error(String(opened.error));
380 const pull = opened.value;
381 const run = createRun(app.db, {
382 repoId: repo.id,
383 kind: "check",
384 issueId: pull.id,
385 env: `pull-${pull.number}`,
386 commitOid: pull.headOid,
387 reason: "pull",
388 });
389 claimRun(app.db, run.id);
390 finishRun(app.db, run.id, {
391 state: "failed",
392 summary: "expect(received).toBe(expected): 1 of 274",
393 });
394 script["retry"] = "stop";
395 script["cause"] = "change";
396 try {
397 expect(
398 await answerFailedCheck(app, fresh(), pull, scoutAgent, {
399 runId: run.id,
400 kind: "check",
401 commitOid: pull.headOid,
402 env: `pull-${pull.number}`,
403 since: pull.createdAt,
404 }),
405 ).toBe("parked");
406 expect(getPull(app.db, repo.id, pull.number)?.state).toBe("closed");
407 expect(
408 listComments(app.db, pull.id).some((c) =>
409 c.body.includes("saw no point in another try"),
410 ),
411 ).toBe(true);
412 } finally {
413 delete script["cause"];
414 }
415 });
416
264417 test("conflicts come first: a green pull that no longer merges gets a conflict run before anyone decides", async () => {
265418 const branch = await commitToBranch(app, alice, repo, {
266419 branch: "feature/readme",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs