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

A run that declines or fails after its pull opened early closes that pull with the reason #2216

Merged opened this

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

Merged

scout/forge:scout/1462-a-run-that-declines-or-fails-after-its-p → main 3 commits fork 4 threads · 4 unresolved

  • Merged. scout/1462-a-run-that-declines-or-fails-after-its-p was merged into main as c2e77c6.

Review

Sign in to review this change.
6 files changed +171 −8
README.md +4 −1
@@ -528,7 +528,10 @@ push (`src/crew/early.ts`). When the agent stops, the forge pushes what it
528528 committed and fills in that same pull (the agent's last message is the
529529 description). A run that ends without reporting leaves the pull saying so, with
530530 the log link; a run that is only paused (a limit, an outage) keeps saying "In
531 progress". A body a person has edited is left alone, unless they kept its first
531 progress". A run that declines after its pull opened closes that pull with the

This paragraph now has one very long line. Re-wrap it to match the lines around it.

532 decline sentence and the run link, unless a person has commented on or reviewed
533 it: then it stays open with a comment. An agent may withdraw (close) a pull it
534 wrote itself, and nothing else. A body a person has edited is left alone, unless they kept its first
532535 words ("In progress:" or "The run ended"); change those words too. Leave
533536 feedback on the pull — a comment, a request for changes, a line comment — and
534537 the agent picks it up in the background, resuming its session. A failed check re-engages it too, a bounded number of
src/actions/issues.ts +8
@@ -386,8 +386,16 @@ export function setIssueState(
386386 user.value.kind !== "agent" &&
387387 user.value.id === issue.authorId &&
388388 !issue.sealed;
389 // The one close an agent has on a pull: its own proposal, withdrawn when
390 // its run declines or fails (`crew/early.ts`). A person can reopen it.
391 const withdrawsOwn =
392 state === "closed" &&
393 issue.kind === "pull" &&
394 user.value.kind === "agent" &&
395 user.value.id === issue.authorId;

withdrawsOwn lets an agent close its own pull at any time, not only from settle. That is acceptable because it is limited to the agent's own pulls and a person can reopen. If you ever want it tighter, pass an explicit withdrawal flag from early.ts.

389396 const mayModerate =
390397 ownSay ||
398 withdrawsOwn ||
391399 canWrite(app.db, repo, user.value) ||
392400 (state === "closed" &&
393401 handedToPlanner(app, repo, issue, user.value, closing));
src/crew/early.ts +50 −5
@@ -11,12 +11,15 @@
1111 */
1212
1313 import { openPull } from "../actions/pulls.ts";
14 import { editIssue } from "../actions/issues.ts";
14 import { editIssue, addComment, setIssueState } from "../actions/issues.ts";
1515 import type { App } from "../app.ts";
1616 import type { Agent } from "../core/agents.ts";
1717 import type { Issue } from "../core/issues.ts";
18 import { listComments } from "../core/issues.ts";
1819 import { listPulls, type Pull } from "../core/pulls.ts";
20 import { listReviews } from "../core/reviews.ts";
1921 import type { Repo } from "../core/repos.ts";
22 import { getUserById } from "../core/users.ts";
2023 import type { Run } from "../core/runs.ts";
2124 import { aheadCount, resolveOid } from "../git/merge.ts";
2225
@@ -72,10 +75,31 @@ export interface EarlyInput {
7275 intervalMs?: number;
7376 }
7477
78 /**
79 * How a run ended without work to land. A run that errors after its pull
80 * opened has pushed work, which `landWork` lands for review: only a decline
81 * closes the pull.
82 */
83 export type Ending = { kind: "declined"; sentence: string };
84
7585 export interface EarlyPull {
7686 stop: () => Promise<void>;
77 /** The run is over: a pull still saying "in progress" says it ended. */
78 settle: () => void;
87 /**
88 * The run is over: a pull still saying "in progress" says it ended. A
89 * decline or a failure closes the pull with the reason, unless a person
90 * has spoken on it: that one stays open with a comment.
91 */
92 settle: (ending?: Ending) => void;
93 }
94
95 /** A person has commented on or reviewed the pull: it is theirs to close. */
96 function spokenOn(app: App, pull: Pull): boolean {
97 const person = (id: string): boolean =>
98 getUserById(app.db, id)?.kind === "user";
99 return (
100 listComments(app.db, pull.id).some((c) => person(c.authorId)) ||
101 listReviews(app.db, pull.id).some((r) => person(r.authorId))
102 );
79103 }
80104
81105 /**
@@ -130,9 +154,30 @@ export function watchForPull(input: EarlyInput): EarlyPull {
130154 };
131155 schedule();
132156 return {
133 settle: () => {
157 settle: (ending) => {
134158 const pull = pullOfBranch(input.app, input.repo, input.fork, input.branch);
135 if (pull === undefined || !pull.body.startsWith(IN_PROGRESS)) return;
159 if (pull === undefined) return;
160 if (ending !== undefined) {
161 const said = `Declined: ${ending.sentence} Run log: ${log}`;
162 if (spokenOn(input.app, pull)) {
163 addComment(
164 input.app,
165 input.agent.user,
outdated

Say what happened: include the run's failure reason instead of 'no new work or in an error'. The sentence is vague and said is built in a tangled way (the log is appended in two places); build it once.

166 input.repo,
167 pull,
168 `${said} Left open: someone has spoken on this pull.`,
169 );
170 return;
171 }
172 if (isEarlyBody(pull.body))
173 editIssue(input.app, input.agent.user, input.repo, pull, {
174 body: `${said}\n\n${trailerFor(input.issue)}`,
175 });
176 else addComment(input.app, input.agent.user, input.repo, pull, said);
177 setIssueState(input.app, input.agent.user, input.repo, pull, "closed");
178 return;
179 }
180 if (!pull.body.startsWith(IN_PROGRESS)) return;
136181 editIssue(input.app, input.agent.user, input.repo, pull, {
137182 body: `${ENDED} Run log: ${log}\n\n${trailerFor(input.issue)}`,
138183 });
src/crew/runner.ts +4 −1
@@ -25,6 +25,7 @@ import {
2525 trailerFor,
2626 watchForPull,
2727 type EarlyPull,
28 type Ending,
2829 } from "./early.ts";
2930 import { forkRepo, sealedForkFor } from "../actions/repos.ts";
3031 import { announceRun } from "../actions/runs.ts";
@@ -747,6 +748,7 @@ async function work(
747748 let early: EarlyPull | undefined;
748749 // A paused run resumes: its pull keeps saying "In progress".
749750 let pausing = false;
751 let ending: Ending | undefined;
750752 try {
751753 // The secrets this run declared, for this run only: opened now, held for
752754 // redaction from this moment, dropped in the `finally` below — a crash
@@ -1334,6 +1336,7 @@ async function work(
13341336 if (declined) {
13351337 const why = result.result.replace(/^\s*DECLINED:?\s*/i, "").trim();
13361338 write(`declined: ${why.split("\n")[0] ?? ""}`);
1339 ending = { kind: "declined", sentence: why.split("\n")[0] ?? "" };
13371340 // Done on the base already, by a commit that is there: its own
13381341 // outcome, not a park (`already.ts`). A claim without one is an
13391342 // ordinary decline.
@@ -1418,7 +1421,7 @@ async function work(
14181421 };
14191422 } finally {
14201423 await early?.stop();
1421 if (!pausing) early?.settle();
1424 if (!pausing) early?.settle(ending);
14221425 grant?.release();
14231426 deleteToken(app.db, agent.user.id, token.id);
14241427 if (runTmp !== undefined) rmSync(runTmp, { recursive: true, force: true });
test/crew.test.ts +91 −1
@@ -33,7 +33,7 @@ import { queueAgentRun, type CrewHandle } from "../src/crew/dispatch.ts";
3333 import { abortAgentRun } from "../src/crew/running.ts";
3434 import { workdirFor } from "../src/crew/runner.ts";
3535 import { getAgentByName } from "../src/core/agents.ts";
36 import { getIssue, listComments } from "../src/core/issues.ts";
36 import { createIssue, getIssue, listComments } from "../src/core/issues.ts";
3737 import { listPulls } from "../src/core/pulls.ts";
3838 import { resolveOid } from "../src/git/merge.ts";
3939 import { listPaths } from "../src/git/read.ts";
@@ -438,6 +438,96 @@ describe("the crew, with a stand-in Claude Code", () => {
438438 expect(mine()[0]!.body).toBe("My own words.\n\nCloses #1");
439439 }, 60_000);
440440
441 /** An agent's issue and ask, assigned, with the pull its first push opens. */
442 async function pullAfterPush(word: string, slug: string) {
443 rmSync(join(dataDir, "release"), { force: true });
444 const issue = openIssue(app, alice, repo, { title: `Weed ${slug}`, body: word });
445 if (!issue.ok) throw new Error("issue");
446 expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true);
447 const branch = `bot/${issue.value.number}-weed-${slug}`;
448 const mine = () =>
449 listPulls(app.db, repo.id, { state: "all", limit: 200 }).filter(
450 (p) => p.headBranch === branch,
451 );
452 const deadline = Date.now() + 20_000;
453 while (mine().length === 0 && Date.now() < deadline) await Bun.sleep(100);
454 expect(mine()[0]!.state).toBe("open");
455 writeFileSync(join(dataDir, "release"), "");
456 const run = await settled((r) => r.branch === branch);
457 return { run, pull: mine()[0]! };
458 }
459
460 test("a run that declines after its pull opened early closes that pull with the reason", async () => {
461 const { run, pull } = await pullAfterPush("PUSH-THEN-DECLINE", "declines");
462 expect(pull.state).toBe("closed");
463 expect(pull.body).not.toContain("In progress:");
464 expect(pull.body).toContain("Declined: this turned out to be out of scope.");
465 expect(pull.body).toContain(`/runs/${run.id}`);
466 }, 60_000);
467
468 test("a run that fails after its pull opened early has pushed work: the pull stays open for review", async () => {
469 const { run, pull } = await pullAfterPush("PUSH-THEN-FAIL", "fails");
470 expect(run.state).toBe("ok");
471 expect(pull.state).toBe("open");
472 expect(pull.body).not.toContain("In progress:");
473 }, 60_000);
474
475 test("an agent may close a pull it wrote and nothing else", () => {
476 const make = (authorId: string, kind: "issue" | "pull") => {
477 const made = createIssue(app.db, {
478 repoId: repo.id,
479 authorId,
480 title: `Withdraw ${kind}`,
481 kind,
482 });
483 if (!made.ok) throw new Error("issue");
484 return made.value;
485 };
486 const own = make(bot.id, "pull");
487 expect(setIssueState(app, bot, repo, own, "closed").ok).toBe(true);
488 const theirs = make(alice.id, "pull");
489 const refused = setIssueState(app, bot, repo, theirs, "closed");
490 expect(refused.ok).toBe(false);
491 expect(getIssue(app.db, repo.id, theirs.number)!.state).toBe("open");
492 const ownIssue = make(bot.id, "issue");
493 expect(setIssueState(app, bot, repo, ownIssue, "closed").ok).toBe(false);
494 expect(getIssue(app.db, repo.id, ownIssue.number)!.state).toBe("open");
495 });
496
497 test("a pull a person commented on stays open when the run declines, with a comment", async () => {
498 rmSync(join(dataDir, "release"), { force: true });
499 const issue = openIssue(app, alice, repo, {
500 title: "Weed PUSH-THEN-DECLINE twice",
501 body: "PUSH-THEN-DECLINE",
502 });
503 if (!issue.ok) throw new Error("issue");
504 expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true);
505 const branch = `bot/${issue.value.number}-weed-push-then-decline-twice`;
506 const mine = () =>
507 listPulls(app.db, repo.id, { state: "all", limit: 200 }).filter(
508 (p) => p.headBranch === branch,
509 );
510 const deadline = Date.now() + 20_000;
511 while (mine().length === 0 && Date.now() < deadline) await Bun.sleep(100);
512 const early = mine()[0]!;
513 expect(addComment(app, alice, repo, early, "I like where this goes.").ok).toBe(true);
514 writeFileSync(join(dataDir, "release"), "");
515 await settled((r) => r.branch === branch);
516 // The comment queued a follow-up run; let it end before the next test.
517 const idle = Date.now() + 20_000;
518 while (
519 listRuns(app.db, repo.id, { kind: "agent" }).some(
520 (r) => r.state === "queued" || r.state === "running",
521 ) &&
522 Date.now() < idle
523 )
524 await Bun.sleep(100);
525 expect(mine()[0]!.state).toBe("open");
526 const last = listComments(app.db, early.id).at(-1)!;
527 expect(last.body).toContain("this turned out to be out of scope.");
528 expect(last.body).toContain("Left open");
529 }, 60_000);
530
441531 test("a run cancelled after its first push leaves the pull saying the run ended, with the log", async () => {
442532 // The stand-in waits for the release file; take it away so it lingers.
443533 rmSync(join(dataDir, "release"), { force: true });
test/helpers/crew-standin.ts +14
@@ -108,6 +108,20 @@ if grep -q "Carry on from where you stopped" "${promptFile}"; then
108108 if [ -f PAUSED.md ]; then kept=yes; else kept=no; fi
109109 echo '{"type":"assistant","message":{"content":[{"type":"text","text":"PAUSED.md present: '"$kept"'"}]}}'
110110 fi
111 if grep -q PUSH-THEN-DECLINE "${promptFile}"; then
112 echo "early work $(date +%s%N)" >> CREW.md
113 git add -A && git commit -qm "crew work" && git push -q -u origin HEAD
114 n=0; while [ ! -f "${dataDir}/release" ] && [ $n -lt 300 ]; do sleep 0.1; n=$((n+1)); done
115 echo '{"type":"result","subtype":"success","is_error":false,"result":"DECLINED: this turned out to be out of scope.","total_cost_usd":0.005,"num_turns":1,"session_id":"fake-5","terminal_reason":"completed"}'
116 exit 0
117 fi
118 if grep -q PUSH-THEN-FAIL "${promptFile}"; then
119 echo "early work $(date +%s%N)" >> CREW.md
120 git add -A && git commit -qm "crew work" && git push -q -u origin HEAD
121 n=0; while [ ! -f "${dataDir}/release" ] && [ $n -lt 300 ]; do sleep 0.1; n=$((n+1)); done
122 echo '{"type":"result","subtype":"error_during_execution","is_error":true,"result":"the tool crashed","total_cost_usd":0.005,"num_turns":1,"session_id":"fake-6","terminal_reason":"tool_error"}'
123 exit 0
124 fi
111125 if grep -q DECLINE-ME "${promptFile}"; then
112126 echo "half-done edit" > SHOULD-NOT-LAND.md
113127 echo '{"type":"result","subtype":"success","is_error":false,"result":"DECLINED: this is out of scope for me.","total_cost_usd":0.005,"num_turns":1,"session_id":"fake-2","terminal_reason":"completed"}'

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs