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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1462-a-run-that-declines-or-fails-after-its-p → main 3 commits fork 4 threads · 4 unresolved
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 | ||
| 528 | 528 | committed and fills in that same pull (the agent's last message is the |
| 529 | 529 | description). A run that ends without reporting leaves the pull saying so, with |
| 530 | 530 | 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 | |
| 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 | |
| 532 | 535 | words ("In progress:" or "The run ended"); change those words too. Leave |
| 533 | 536 | feedback on the pull — a comment, a request for changes, a line comment — and |
| 534 | 537 | 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( | ||
| 386 | 386 | user.value.kind !== "agent" && |
| 387 | 387 | user.value.id === issue.authorId && |
| 388 | 388 | !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; | |
|
||
| 389 | 396 | const mayModerate = |
| 390 | 397 | ownSay || |
| 398 | withdrawsOwn || | |
| 391 | 399 | canWrite(app.db, repo, user.value) || |
| 392 | 400 | (state === "closed" && |
| 393 | 401 | handedToPlanner(app, repo, issue, user.value, closing)); |
src/crew/early.ts +50 −5
| @@ -11,12 +11,15 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import { openPull } from "../actions/pulls.ts"; |
| 14 | import { editIssue } from "../actions/issues.ts"; | |
| 14 | import { editIssue, addComment, setIssueState } from "../actions/issues.ts"; | |
| 15 | 15 | import type { App } from "../app.ts"; |
| 16 | 16 | import type { Agent } from "../core/agents.ts"; |
| 17 | 17 | import type { Issue } from "../core/issues.ts"; |
| 18 | import { listComments } from "../core/issues.ts"; | |
| 18 | 19 | import { listPulls, type Pull } from "../core/pulls.ts"; |
| 20 | import { listReviews } from "../core/reviews.ts"; | |
| 19 | 21 | import type { Repo } from "../core/repos.ts"; |
| 22 | import { getUserById } from "../core/users.ts"; | |
| 20 | 23 | import type { Run } from "../core/runs.ts"; |
| 21 | 24 | import { aheadCount, resolveOid } from "../git/merge.ts"; |
| 22 | 25 | |
| @@ -72,10 +75,31 @@ export interface EarlyInput { | ||
| 72 | 75 | intervalMs?: number; |
| 73 | 76 | } |
| 74 | 77 | |
| 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 | ||
| 75 | 85 | export interface EarlyPull { |
| 76 | 86 | 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 | ); | |
| 79 | 103 | } |
| 80 | 104 | |
| 81 | 105 | /** |
| @@ -130,9 +154,30 @@ export function watchForPull(input: EarlyInput): EarlyPull { | ||
| 130 | 154 | }; |
| 131 | 155 | schedule(); |
| 132 | 156 | return { |
| 133 | settle: () => { | |
| 157 | settle: (ending) => { | |
| 134 | 158 | 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 |
||
| 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; | |
| 136 | 181 | editIssue(input.app, input.agent.user, input.repo, pull, { |
| 137 | 182 | body: `${ENDED} Run log: ${log}\n\n${trailerFor(input.issue)}`, |
| 138 | 183 | }); |
src/crew/runner.ts +4 −1
| @@ -25,6 +25,7 @@ import { | ||
| 25 | 25 | trailerFor, |
| 26 | 26 | watchForPull, |
| 27 | 27 | type EarlyPull, |
| 28 | type Ending, | |
| 28 | 29 | } from "./early.ts"; |
| 29 | 30 | import { forkRepo, sealedForkFor } from "../actions/repos.ts"; |
| 30 | 31 | import { announceRun } from "../actions/runs.ts"; |
| @@ -747,6 +748,7 @@ async function work( | ||
| 747 | 748 | let early: EarlyPull | undefined; |
| 748 | 749 | // A paused run resumes: its pull keeps saying "In progress". |
| 749 | 750 | let pausing = false; |
| 751 | let ending: Ending | undefined; | |
| 750 | 752 | try { |
| 751 | 753 | // The secrets this run declared, for this run only: opened now, held for |
| 752 | 754 | // redaction from this moment, dropped in the `finally` below — a crash |
| @@ -1334,6 +1336,7 @@ async function work( | ||
| 1334 | 1336 | if (declined) { |
| 1335 | 1337 | const why = result.result.replace(/^\s*DECLINED:?\s*/i, "").trim(); |
| 1336 | 1338 | write(`declined: ${why.split("\n")[0] ?? ""}`); |
| 1339 | ending = { kind: "declined", sentence: why.split("\n")[0] ?? "" }; | |
| 1337 | 1340 | // Done on the base already, by a commit that is there: its own |
| 1338 | 1341 | // outcome, not a park (`already.ts`). A claim without one is an |
| 1339 | 1342 | // ordinary decline. |
| @@ -1418,7 +1421,7 @@ async function work( | ||
| 1418 | 1421 | }; |
| 1419 | 1422 | } finally { |
| 1420 | 1423 | await early?.stop(); |
| 1421 | if (!pausing) early?.settle(); | |
| 1424 | if (!pausing) early?.settle(ending); | |
| 1422 | 1425 | grant?.release(); |
| 1423 | 1426 | deleteToken(app.db, agent.user.id, token.id); |
| 1424 | 1427 | 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"; | ||
| 33 | 33 | import { abortAgentRun } from "../src/crew/running.ts"; |
| 34 | 34 | import { workdirFor } from "../src/crew/runner.ts"; |
| 35 | 35 | 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"; | |
| 37 | 37 | import { listPulls } from "../src/core/pulls.ts"; |
| 38 | 38 | import { resolveOid } from "../src/git/merge.ts"; |
| 39 | 39 | import { listPaths } from "../src/git/read.ts"; |
| @@ -438,6 +438,96 @@ describe("the crew, with a stand-in Claude Code", () => { | ||
| 438 | 438 | expect(mine()[0]!.body).toBe("My own words.\n\nCloses #1"); |
| 439 | 439 | }, 60_000); |
| 440 | 440 | |
| 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 | ||
| 441 | 531 | test("a run cancelled after its first push leaves the pull saying the run ended, with the log", async () => { |
| 442 | 532 | // The stand-in waits for the release file; take it away so it lingers. |
| 443 | 533 | 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 | ||
| 108 | 108 | if [ -f PAUSED.md ]; then kept=yes; else kept=no; fi |
| 109 | 109 | echo '{"type":"assistant","message":{"content":[{"type":"text","text":"PAUSED.md present: '"$kept"'"}]}}' |
| 110 | 110 | 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 | |
| 111 | 125 | if grep -q DECLINE-ME "${promptFile}"; then |
| 112 | 126 | echo "half-done edit" > SHOULD-NOT-LAND.md |
| 113 | 127 | 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"}' |
new above
offline
This paragraph now has one very long line. Re-wrap it to match the lines around it.