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

A resolve run that pushed nothing is never shown as the crew's commit #2296

Merged opened this

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

Merged

scout/forge:scout/2276-a-resolve-run-that-pushed-nothing-is-nev → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/2276-a-resolve-run-that-pushed-nothing-is-nev was merged into main as 073891d.

Review

Sign in to review this change.
4 files changed +92 −4
src/crew/proposal.ts +10 −1
@@ -217,6 +217,8 @@ export const PROPOSE_WHY: Record<ProposeError, string> = {
217217 not_queued: "The crew could not take it on right now (its ceiling or breaker); try again later.",
218218 };
219219
220 const NOT_PUSHED = "the crew did not push a resolution";
221
220222 /**
221223 * The latest proposal. A landed one is shown only while the pull's head is
222224 * still the commit that run left: after another push, it is not the crew's.
@@ -239,7 +241,14 @@ export async function proposalOf(
239241 ? { kind: "none" }
240242 : { kind: "failed", state: last.state, summary: last.summary };
241243 }
242 if (last.commitOid === "" || last.commitOid !== pull.headOid) return { kind: "none" };
244 if (last.commitOid === "") {
245 // Ended ok but left no commit: news only while the pull still conflicts.
246 const status = await pullStatus(dir, pull);
247 return status.conflicts.length === 0
248 ? { kind: "none" }
249 : { kind: "failed", state: last.state, summary: NOT_PUSHED };
250 }

The summary is shown as "The last proposal did not land (ok): …". Showing "ok" beside a failure is confusing; for this case, render the summary without the state.

251 if (last.commitOid !== pull.headOid) return { kind: "none" };
243252 const agent = proposerOf(app, pull);
244253 return {
245254 kind: "landed",
src/crew/runner.ts +23 −3
@@ -62,6 +62,7 @@ import {
6262 finishRun,
6363 lastAgentRun,
6464 noteRunProgress,
65 setRunBase,
6566 overranBefore,
6667 type Run,
6768 } from "../core/runs.ts";
@@ -294,6 +295,10 @@ export async function executeAgentRun(
294295 const claimed = claimRun(app.db, run.id);
295296 if (!claimed) return undefined;
296297 const lease = holdLease(app.db, run.id);
298 // A resolve run keeps the head it started from, to tell a push from none.
299 const startedAt =
300 run.reason === "resolve" ? await pullHeadOf(app, run, false) : undefined;
301 if (startedAt !== undefined) setRunBase(app.db, run.id, startedAt);
297302 const controller = new AbortController();
298303 trackAgentRun(run.id, controller);
299304 const write = (line: string): void =>
@@ -387,7 +392,7 @@ export async function executeAgentRun(
387392 // never calls a later push the crew's commit.
388393 const left =
389394 run.reason === "resolve" && outcome.state === "ok"
390 ? await headLeftBy(app, run)
395 ? await headLeftBy(app, run, startedAt)
391396 : undefined;
392397 const done = finishRun(app.db, run.id, {
393398 state: outcome.state,
@@ -401,14 +406,29 @@ export async function executeAgentRun(
401406 return undefined;
402407 }
403408
404 /** The head of the run's pull as the run left it. */
405 async function headLeftBy(app: App, run: Run): Promise<string | undefined> {
409 /** The head the run left, if it moved since `startedAt`; none if it pushed nothing. */
410 async function headLeftBy(
411 app: App,
412 run: Run,
413 startedAt: string | undefined,
414 ): Promise<string | undefined> {
415 const head = await pullHeadOf(app, run, true);
416 return head === startedAt ? undefined : head;
417 }
418
419 /** The head of the run's pull, fetched fresh when `refresh` is set. */
420 async function pullHeadOf(
421 app: App,
422 run: Run,
423 refresh: boolean,
424 ): Promise<string | undefined> {
406425 const repo = getRepoById(app.db, run.repoId);
407426 const issue =
408427 run.issueId === null ? undefined : getIssueById(app.db, run.issueId);
409428 if (repo === undefined || issue === undefined || issue.kind !== "pull") return undefined;
410429 const pull = getPull(app.db, repo.id, issue.number);
411430 if (pull === undefined) return undefined;
431 if (!refresh) return pull.headOid;
412432 // A fork's push reaches the pull's row by a fetch; take it now, not later.
413433 // A push's own follow-up may have taken the head already, after this row
414434 // was read: ask the row again rather than trust the copy in hand.
test/crew-proposal.test.ts +25
@@ -431,4 +431,29 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Wrote note
431431 expect(text).toContain("with `trunk`");
432432 expect(text).not.toContain("`main`");
433433 });
434
435 test("a resolve run that ends ok without pushing records no commit and is not the crew's", async () => {
436 // The agent now reports success and pushes nothing; the pull still conflicts.
437 const fake = join(dataDir, "fake-claude");
438 writeFileSync(
439 fake,
440 `#!/bin/sh
441 if [ "$1" = "--version" ]; then echo "2.1.0 (fake)"; exit 0; fi
442 IFS= read -r first
443 echo '{"type":"system","subtype":"init","session_id":"fake-2","model":"fake"}'
444 echo '{"type":"result","subtype":"success","is_error":false,"result":"Looked, changed nothing.","total_cost_usd":0.01,"num_turns":1,"session_id":"fake-2","terminal_reason":"completed"}'
445 `,
446 );
447 const known = new Set(listRuns(app.db, repo.id, { kind: "agent" }).map((r) => r.id));
448 const head = current().headOid;
449 const res = await tap();
450 expect(res.status).toBe(303);
451 const run = await settled((r) => r.reason === "resolve" && !known.has(r.id));
452 expect(run.state).toBe("ok");
453 expect(run.commitOid).toBe("");
454 expect(current().headOid).toBe(head);
455 const page = await resolvePage();
456 expect(page).not.toContain("committed as");
457 expect(page).toContain("the crew did not push a resolution");
458 }, 60_000);
434459 });
test/proposal-run.test.ts +34
@@ -18,6 +18,8 @@ import type { Repo } from "../src/core/repos.ts";
1818 import { submitReview as record } from "../src/core/reviews.ts";
1919 import { listRuns } from "../src/core/runs.ts";
2020 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
21 import { proposalOf } from "../src/crew/proposal.ts";
22 import { pullDir } from "../src/core/pulls.ts";
2123 import { landIfGreen } from "../src/crew/autonomy.ts";
2224 import { landsOnItsOwn } from "../src/crew/direct.ts";
2325 import { createForgeApp } from "../src/tui/forge.ts";
@@ -236,4 +238,36 @@ describe("letting a proposed pull run", () => {
236238 await approveToRun(app, alice, lib, pull());
237239 expect(pullMayRun(app.db, lib, pull(), head)).toBe(true);
238240 });
241
242 test("an ok resolve run with no commit on a conflicting pull reads failed; one that pushed reads landed", async () => {
243 const theirs = await commitToBranch(app, alice, lib, {
244 branch: "feature/clash",
245 message: "clash",
246 files: [{ path: "clash.md", content: "other\n" }],
247 });
248 if (!theirs.ok) throw new Error(JSON.stringify(theirs.error));
249 await push("main", "clash.md");
250 const opened = await openPull(app, alice, lib, {
251 title: "Clash",
252 baseBranch: "main",
253 headBranch: "feature/clash",
254 });
255 if (!opened.ok) throw new Error(String(opened.error));
256 const pull = () => getPull(app.db, lib.id, opened.value.number)!;
257 const dir = await pullDir(app.db, app.reposRoot, lib, pull());
258 const ran = (commitOid?: string) => {
259 const run = createRun(app.db, {
260 repoId: lib.id, kind: "agent", issueId: pull().id, reason: "resolve",
261 });
262 claimRun(app.db, run.id);
263 finishRun(app.db, run.id, { state: "ok", summary: "done", commitOid });
264 return run;
265 };
266 ran();
267 const seen = await proposalOf(app, lib, pull(), dir);
268 expect(seen).toEqual({ kind: "failed", state: "ok", summary: "the crew did not push a resolution" });
269
270 ran(pull().headOid);
271 expect((await proposalOf(app, lib, pull(), dir)).kind).toBe("landed");
272 });
239273 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs