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

An agent's pull opens at its first push, marked in progress, and the run's end fills that same pull in #1978

Merged opened this

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

Merged

scout/forge:scout/1965-an-agent-s-pull-opens-at-its-first-push → main 9 commits fork 8 threads · 8 unresolved

  • Merged. scout/1965-an-agent-s-pull-opens-at-its-first-push was merged into main as c4a5703.

Review

Sign in to review this change.
6 files changed +409 −17
README.md +10 −5
@@ -495,11 +495,16 @@ reads and writes that repository and administers nothing.
495495 Assign an issue to an agent and it starts: the forge clones the repository,
496496 puts the agent on a branch named after the issue, and runs Claude Code
497497 headless with a brief that says who it is, what the task is, how to finish,
498 and what will be refused. When it stops, the forge pushes what it committed,
499 opens the pull (the agent's last message is the description), and the
500 preview comes up. Leave feedback on the pull — a comment, a request for
501 changes, a line comment — and the agent picks it up in the background,
502 resuming its session. A failed check re-engages it too, a bounded number of
498 and what will be refused. The pull opens at the agent's first push, marked
499 "In progress" with a link to the run's log, and its preview follows each later
500 push (`src/crew/early.ts`). When the agent stops, the forge pushes what it
501 committed and fills in that same pull (the agent's last message is the
502 description). A run that ends without reporting leaves the pull saying so, with
503 the log link; a run that is only paused (a limit, an outage) keeps saying "In
504 progress". A body a person has edited is left alone, unless they kept its first
505 words ("In progress:" or "The run ended"); change those words too. Leave
506 feedback on the pull — a comment, a request for changes, a line comment — and
507 the agent picks it up in the background, resuming its session. A failed check re-engages it too, a bounded number of
503508 times before a person must speak.
504509
505510 **Three rounds, then a person.** When the crew reviewer has asked for
src/crew/early.ts added +169
@@ -0,0 +1,169 @@
1 /**
2 * The pull that opens at the agent's first push, not at the run's end.
3 *
4 * While the agent works, its branch in the fork is looked at once a second
5 * for the first few seconds, then every few; each look costs git processes.
6 * The first time it is a commit beyond the base, the pull opens with the
7 * title, base and head `landWork` would use, and a body that says the work
8 * is in progress and where the run's log is. Every later push reaches the
9 * pull's preview as any push does. At the end `landWork` finds this pull by
10 * its head and fills it in (`fillPull`); it never opens a second.
11 */
12
13 import { openPull } from "../actions/pulls.ts";
14 import { editIssue } from "../actions/issues.ts";
15 import type { App } from "../app.ts";
16 import type { Agent } from "../core/agents.ts";
17 import type { Issue } from "../core/issues.ts";
18 import { listPulls, type Pull } from "../core/pulls.ts";
19 import type { Repo } from "../core/repos.ts";
20 import type { Run } from "../core/runs.ts";
21 import { aheadCount, resolveOid } from "../git/merge.ts";
22
23 /** How often the branch is looked at at first. */
24 export const WATCH_MS = 1000;
25
26 /** Looks at the quick rate before the watcher slows to `SLOW_FACTOR` times it. */
27 export const QUICK_LOOKS = 5;
28 export const SLOW_FACTOR = 5;
29
30 /** The body's first words while the run is still going. */
31 export const IN_PROGRESS = "In progress:";
32
33 /** The body's first words once the run has ended without reporting on it. */
34 export const ENDED = "The run ended before it reported on this work.";
35
36 /** The line that links the pull to the issue (or pull) it answers. */
37 export function trailerFor(issue: Issue): string {
38 return issue.kind === "pull"
39 ? `Continues #${issue.number}`
40 : `Closes #${issue.number}`;
41 }
42
43 /** The open pull whose head is this branch of this fork, if any. */
44 export function pullOfBranch(
45 app: App,
46 repo: Repo,
47 fork: Repo,
48 branch: string,
49 ): Pull | undefined {
50 return listPulls(app.db, repo.id, {
51 sealed: true,
52 state: "open",
53 limit: 200,
54 }).find((p) => p.headRepoId === fork.id && p.headBranch === branch);
55 }
56
57 export interface EarlyInput {
58 app: App;
59 repo: Repo;
60 agent: Agent;
61 issue: Issue;
62 run: Run;
63 fork: Repo;
64 branch: string;
65 forkBare: string;
66 bare: string;
67 forgeUrl: string;
68 write: (line: string) => void;
69 variantGroup: string | null;
70 /** A pull already stands for this work: nothing to open. */
71 continuing: boolean;
72 intervalMs?: number;
73 }
74
75 export interface EarlyPull {
76 stop: () => Promise<void>;
77 /** The run is over: a pull still saying "in progress" says it ended. */
78 settle: () => void;
79 }
80
81 /**
82 * Start watching; `stop` ends it, waiting for an open in flight. A push in
83 * the last second is not looked for: `landWork` opens the pull for it.
84 */
85 export function watchForPull(input: EarlyInput): EarlyPull {
86 const log = `${input.forgeUrl}/${input.repo.ownerName}/${input.repo.name}/runs/${input.run.id}`;
87 let done = input.continuing;
88 let busy: Promise<void> = Promise.resolve();
89 const tick = async (): Promise<void> => {
90 if (done) return;
91 const head = await resolveOid(input.forkBare, `refs/heads/${input.branch}`);
92 if (head === undefined || done) return;
93 const base = await resolveOid(input.bare, `refs/heads/${input.repo.defaultBranch}`);
94 if (base === undefined || done) return;
95 if ((await aheadCount(input.forkBare, base, head)) === 0 || done) return;
96 // Set before the open, so a failed open is not retried every second:
97 // `landWork` opens the pull at the run's end when this did not.
98 done = true;
outdated

A push after a tick but before the run ends is only picked up by landWork. That is fine, but a failed early open is silent apart from a log line. Test it.

99 if (pullOfBranch(input.app, input.repo, input.fork, input.branch) !== undefined)
100 return;
101 const opened = await openPull(input.app, input.agent.user, input.repo, {
102 title: input.issue.title,
103 body: `${IN_PROGRESS} @${input.agent.user.username} is still working on this; the description is filled in when the run ends. Run log: ${log}\n\n${trailerFor(input.issue)}`,
104 baseBranch: input.repo.defaultBranch,
105 headBranch: input.branch,
106 headRepo: input.fork,
107 ...(input.variantGroup === null ? {} : { variantGroup: input.variantGroup }),
108 });
109 input.write(
110 opened.ok
111 ? `opened pull #${opened.value.number} at the first push, in progress`
112 : `could not open the pull early: ${JSON.stringify(opened.error)}`,
113 );
outdated

fillPull only runs on the landing path. Add a counterpart for abnormal ends that replaces the In progress body with an honest ended-early note and the run log link.

114 };
115 const every = input.intervalMs ?? WATCH_MS;
116 let looks = 0;
117 let timer: ReturnType<typeof setTimeout> | undefined;
118 const schedule = (): void => {
119 if (done) return;
120 timer = setTimeout(
121 () => {
122 looks++;
123 busy = busy
124 .then(tick)
125 .catch(() => undefined)
126 .then(schedule);
127 },
128 looks < QUICK_LOOKS ? every : every * SLOW_FACTOR,
129 );
130 };
131 schedule();
132 return {
133 settle: () => {
134 const pull = pullOfBranch(input.app, input.repo, input.fork, input.branch);
135 if (pull === undefined || !pull.body.startsWith(IN_PROGRESS)) return;
136 editIssue(input.app, input.agent.user, input.repo, pull, {
137 body: `${ENDED} Run log: ${log}\n\n${trailerFor(input.issue)}`,
138 });
139 },
140 stop: async () => {
141 clearTimeout(timer);
142 done = true;
143 await busy;
144 },
145 };
146 }
147
148 /**
149 * The body still begins with the words the early open wrote, or `settle`
150 * (a run that ended without reporting, then resumed by feedback). A person
151 * who edits the body but keeps those words has it filled in over.
152 */
153 export function isEarlyBody(body: string): boolean {
outdated

This accepts ENDED only so that a resumed run can overwrite the false 'ended' that a pause wrote. Once pauses stop settling, this second prefix should not be needed.

154 return body.startsWith(IN_PROGRESS) || body.startsWith(ENDED);
155 }
156
157 /** The run is over: the early pull's body becomes the run's report. */
158 export function fillPull(
159 app: App,
160 agent: Agent,
161 repo: Repo,
162 pull: Pull,
163 issue: Issue,
164 report: string,
165 ): void {
166 if (!isEarlyBody(pull.body)) return;
167 const body = `${report === "" ? "" : `${report}\n\n`}${trailerFor(issue)}`;
168 editIssue(app, agent.user, repo, pull, { body });
169 }
src/crew/runner.ts +40 −12
@@ -17,6 +17,14 @@ import { join } from "node:path";
1717 import type { App } from "../app.ts";
1818 import { addComment } from "../actions/issues.ts";
1919 import { openPull } from "../actions/pulls.ts";
20 import {
outdated

This import is one long line and unsorted against its neighbours. Wrap it the way the other imports are written.

21 fillPull,
22 isEarlyBody,
23 pullOfBranch,
24 trailerFor,
25 watchForPull,
26 type EarlyPull,
27 } from "./early.ts";
2028 import { forkRepo, sealedForkFor } from "../actions/repos.ts";
2129 import { announceRun } from "../actions/runs.ts";
2230 import { previewEnv } from "../apps/data.ts";
@@ -661,6 +669,7 @@ async function work(
661669 summary: `pull #${pull.number} was closed before the run began`,
662670 };
663671 const continuing = pull !== undefined && pull.mergedAt !== null;
672 const pullStands = pull !== undefined && !continuing;
664673 const branch =
665674 pull !== undefined && !continuing && !reviewing
666675 ? pull.headBranch
@@ -705,6 +714,9 @@ async function work(
705714
706715 let grant: Grant | undefined;
707716 let runTmp: string | undefined;
717 let early: EarlyPull | undefined;
718 // A paused run resumes: its pull keeps saying "In progress".
719 let pausing = false;
708720 try {
709721 // The secrets this run declared, for this run only: opened now, held for
710722 // redaction from this moment, dropped in the `finally` below — a crash
@@ -1043,6 +1055,22 @@ async function work(
10431055 `sandbox: off here${options.sandboxNote === undefined ? "" : ` — ${options.sandboxNote}`}; the shell runs unrestricted`,
10441056 );
10451057 const working = budget - reserve;
1058 if (!reviewing)
1059 early = watchForPull({
1060 app,
1061 repo,
1062 agent,
1063 issue,
1064 run,
1065 fork: fork.value,
1066 branch,
1067 forkBare,
1068 bare,
1069 forgeUrl: options.forgeUrl,
1070 write,
1071 variantGroup: run.variantGroup,
1072 continuing: pullStands,
1073 });
10461074 let result = await invoke(
10471075 prompt,
10481076 working,
@@ -1101,6 +1129,7 @@ async function work(
11011129 run.attempt >= MAX_PAUSES
11021130 )
11031131 return undefined;
1132 pausing = true;
11041133 const at = new Date(Date.now() + NETWORK_PAUSE_MS);
11051134 write(`network still down; paused, resumes ${whenText(at)}`);
11061135 return {
@@ -1116,6 +1145,7 @@ async function work(
11161145 ? { prompt, sessionId: undefined }
11171146 : { prompt: "Continue where you stopped.", sessionId: last.sessionId },
11181147 );
1148 await early?.stop();
11191149 const down = outagePause(result, result.costUsd);
11201150 if (down !== undefined) return down;
11211151 noteRunProgress(app.db, run.id, {
@@ -1170,6 +1200,7 @@ async function work(
11701200 write(
11711201 `${picked.subscription.name} rests until ${whenText(until)}; ${next.subscription.name} takes the run`,
11721202 );
1203 pausing = true;
11731204 return {
11741205 state: "paused",
11751206 summary: `paused: ${pauseReason(picked.subscription.name, limit, said)}; ${next.subscription.name} takes over`,
@@ -1193,6 +1224,7 @@ async function work(
11931224 }
11941225 if (run.attempt < MAX_PAUSES) {
11951226 const at = resumeAt(result.result, new Date());
1227 pausing = true;
11961228 return {
11971229 state: "paused",
11981230 summary: `paused: ${pauseReason(picked.kind === "ready" ? picked.subscription.name : undefined, limitOf(result.result, new Date()), said)}; resumes ${whenText(at)}`,
@@ -1339,6 +1371,8 @@ async function work(
13391371 turns: result.turns,
13401372 };
13411373 } finally {
1374 await early?.stop();
1375 if (!pausing) early?.settle();
13421376 grant?.release();
13431377 deleteToken(app.db, agent.user.id, token.id);
13441378 if (runTmp !== undefined) rmSync(runTmp, { recursive: true, force: true });
@@ -1715,26 +1749,20 @@ async function landWork(
17151749 };
17161750 }
17171751
1718 const existing =
1719 pull ??
1720 listPulls(app.db, repo.id, {
1721 sealed: true,
1722 state: "open",
1723 limit: 200,
1724 }).find((p) => p.headRepoId === fork.id && p.headBranch === branch);
1752 const existing = pull ?? pullOfBranch(app, repo, fork, branch);
17251753 if (existing !== undefined) {
1754 // A pull this run opened at its first push is still the run's opening.
1755 const openedByRun = isEarlyBody(existing.body);
1756 fillPull(app, agent, repo, existing, issue, report);
17261757 if (report !== "") addComment(app, agent.user, repo, existing, report);
17271758 return {
17281759 pushed: true,
1729 summary: `updated pull #${existing.number} (${remote?.slice(0, 7)})${committedForIt ? ", committing its leftover changes" : ""}`,
1760 summary: `${openedByRun ? "opened" : "updated"} pull #${existing.number} (${remote?.slice(0, 7)})${committedForIt ? ", committing its leftover changes" : ""}`,
17301761 };
17311762 }
17321763 // No pull to update and yet the task is one: the pull it continues was
17331764 // merged, and this one follows it rather than closing it.
1734 const trailer =
1735 issue.kind === "pull"
1736 ? `Continues #${issue.number}`
1737 : `Closes #${issue.number}`;
1765 const trailer = trailerFor(issue);
17381766 const body = `${report === "" ? "" : `${report}\n\n`}${trailer}`;
17391767 const opened = await openPull(app, agent.user, repo, {
17401768 title: issue.title,
test/crew-network.test.ts +25
@@ -10,6 +10,7 @@ import type { App } from "../src/app.ts";
1010 import type { Repo } from "../src/core/repos.ts";
1111 import type { User } from "../src/core/users.ts";
1212 import { assignIssue, openIssue } from "../src/actions/issues.ts";
13 import { listPulls } from "../src/core/pulls.ts";
1314 import { listRuns, type Run } from "../src/core/runs.ts";
1415 import { MAX_PAUSES } from "../src/crew/limits.ts";
1516 import { isNetworkError, NETWORK_PAUSE_MS } from "../src/crew/network.ts";
@@ -120,6 +121,30 @@ describe("the crew, with a stand-in Claude Code: network errors", () => {
120121 rmSync(join(standIn.dataDir, "wrapdown"));
121122 }, 60_000);
122123
124 test("a run paused after its first push keeps its pull saying it is in progress", async () => {
125 const issue = openIssue(app, alice, repo, {
126 title: "Pause after push",
127 body: "PUSH-THEN-DOWN",
128 });
129 if (!issue.ok) throw new Error(String(issue.error));
130 expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true);
131 const until = Date.now() + 40_000;
132 let paused: Run | undefined;
133 while (paused === undefined && Date.now() < until) {
134 paused = listRuns(app.db, repo.id, { kind: "agent" }).find(
135 (r) => r.issueId === issue.value.id && r.state === "cancelled",
136 );
137 if (paused === undefined) await Bun.sleep(50);
138 }
139 expect(paused?.summary).toStartWith("paused: network error");
140 const pull = listPulls(app.db, repo.id, { state: "open", limit: 200 }).find(
141 (p) => p.headBranch === paused!.branch,
142 );
143 expect(pull).toBeDefined();
144 expect(pull!.body).toStartWith("In progress:");
145 rmSync(join(standIn.dataDir, "down"), { force: true });
146 }, 60_000);
147
123148 test("an outage that outlasts the retries pauses the run; after MAX_PAUSES it ends", async () => {
124149 const issue = openIssue(app, alice, repo, {
125150 title: "Network down",
test/crew.test.ts +154
@@ -11,7 +11,9 @@ import {
1111 mkdirSync,
1212 readFileSync,
1313 realpathSync,
14 rmSync,
1415 symlinkSync,
16 writeFileSync,
1517 } from "node:fs";
1618 import { mkdtemp, rm } from "node:fs/promises";
1719 import { tmpdir } from "node:os";
@@ -20,11 +22,15 @@ import type { App } from "../src/app.ts";
2022 import {
2123 addComment,
2224 assignIssue,
25 editIssue,
2326 openIssue,
2427 setIssueState,
2528 } from "../src/actions/issues.ts";
29 import { pullOfBranch, trailerFor, watchForPull } from "../src/crew/early.ts";
30 import { openPull } from "../src/actions/pulls.ts";
2631 import { sandboxAvailable } from "../src/crew/sandbox.ts";
2732 import { queueAgentRun, type CrewHandle } from "../src/crew/dispatch.ts";
33 import { abortAgentRun } from "../src/crew/running.ts";
2834 import { workdirFor } from "../src/crew/runner.ts";
2935 import { getAgentByName } from "../src/core/agents.ts";
3036 import { getIssue, listComments } from "../src/core/issues.ts";
@@ -367,4 +373,152 @@ describe("the crew, with a stand-in Claude Code", () => {
367373 `pull #${closed.number} was closed before the run began`,
368374 );
369375 }, 30_000);
376
377 test("the pull opens at the first push, in progress, and the run's end fills that same pull in", async () => {
378 const issue = openIssue(app, alice, repo, {
379 title: "Plant crocuses",
380 body: "LINGER-ME by the gate.",
381 });
382 expect(issue.ok).toBe(true);
383 if (!issue.ok) return;
384 expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true);
385 const branch = `bot/${issue.value.number}-plant-crocuses`;
386 const mine = () =>
387 listPulls(app.db, repo.id, { state: "open", limit: 200 }).filter(
388 (p) => p.headBranch === branch,
389 );
390 // The agent has pushed and is still working: the pull is already there.
391 const deadline = Date.now() + 20_000;
392 while (mine().length === 0 && Date.now() < deadline) await Bun.sleep(100);
393 const early = mine()[0]!;
394 expect(early).toBeDefined();
395 expect(early.title).toBe("Plant crocuses");
396 expect(early.baseBranch).toBe(repo.defaultBranch);
397 expect(early.body).toStartWith("In progress:");
398 expect(early.body).toMatch(/\/runs\/[\w-]+/);
399 expect(early.body).toContain(`Closes #${issue.value.number}`);
400 expect(existsSync(join(dataDir, "release"))).toBe(false);
401
402 writeFileSync(join(dataDir, "release"), "");
403 const run = await settled((r) => r.branch === branch);
404 expect(run.state).toBe("ok");
405 expect(run.summary).toMatch(new RegExp(`^opened pull #${early.number}\\b`));
406 const after = mine();
407 expect(after.length).toBe(1);
408 expect(after[0]!.number).toBe(early.number);
409 expect(after[0]!.body).not.toContain("In progress:");
410 expect(after[0]!.body).toContain("Added CREW.md as asked.");
411 expect(after[0]!.body).toContain(`Closes #${issue.value.number}`);
412 const log = readRunLog(dataDir, run.id).lines.join("\n");
413 expect(log).toContain(`opened pull #${early.number} at the first push`);
414 }, 60_000);
415
416 test("a body a person edited while the run was going is left alone at the run's end", async () => {
417 rmSync(join(dataDir, "release"), { force: true });
418 const issue = openIssue(app, alice, repo, {
419 title: "Plant snowdrops",
420 body: "LINGER-ME under the oak.",
421 });
422 if (!issue.ok) throw new Error("issue");
423 expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true);
424 const branch = `bot/${issue.value.number}-plant-snowdrops`;
425 const mine = () =>
426 listPulls(app.db, repo.id, { state: "open", limit: 200 }).filter(
427 (p) => p.headBranch === branch,
428 );
429 const deadline = Date.now() + 20_000;
430 while (mine().length === 0 && Date.now() < deadline) await Bun.sleep(100);
431 const early = mine()[0]!;
432 expect(
433 editIssue(app, alice, repo, early, { body: "My own words.\n\nCloses #1" }).ok,
434 ).toBe(true);
435 writeFileSync(join(dataDir, "release"), "");
436 const run = await settled((r) => r.branch === branch);
437 expect(run.summary).toMatch(new RegExp(`^updated pull #${early.number}\\b`));
438 expect(mine()[0]!.body).toBe("My own words.\n\nCloses #1");
439 }, 60_000);
440
441 test("a run cancelled after its first push leaves the pull saying the run ended, with the log", async () => {
442 // The stand-in waits for the release file; take it away so it lingers.
443 rmSync(join(dataDir, "release"), { force: true });
444 const issue = openIssue(app, alice, repo, {
445 title: "Plant bluebells",
446 body: "LINGER-ME in the shade.",
447 });
448 if (!issue.ok) throw new Error("issue");
449 expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true);
450 const branch = `bot/${issue.value.number}-plant-bluebells`;
451 const mine = () =>
452 listPulls(app.db, repo.id, { state: "open", limit: 200 }).filter(
453 (p) => p.headBranch === branch,
454 );
455 const deadline = Date.now() + 20_000;
456 while (mine().length === 0 && Date.now() < deadline) await Bun.sleep(100);
457 expect(mine()[0]!.body).toStartWith("In progress:");
458 const running = listRuns(app.db, repo.id, { kind: "agent" }).find(
459 (r) => r.branch === branch,
460 )!;
461 expect(abortAgentRun(running.id)).toBe(true);
462 const run = await settled((r) => r.branch === branch);
463 expect(run.summary).toContain("cancelled");
464 const body = mine()[0]!.body;
465 expect(body).not.toContain("In progress:");
466 expect(body).toStartWith("The run ended before it reported");
467 expect(body).toContain(`/runs/${run.id}`);
468 expect(body).toContain(`Closes #${issue.value.number}`);
469 }, 60_000);
470
471 test("an early open that fails is tried once and leaves no pull behind", async () => {
472 const agent = getAgentByName(app.db, "bot")!;
473 const fork = getForkOf(app.db, bot.id, repo.id)!;
474 const bare = repoDir(app.reposRoot, repo.ownerName, repo.name);
475 const forkBare = repoDir(app.reposRoot, fork.ownerName, fork.name);
476 const issue = openIssue(app, alice, repo, { title: "Plant ferns", body: "Shade." });
477 if (!issue.ok) throw new Error("issue");
478 // A branch one commit beyond the base, in the fork.
479 const branch = "bot/early-fails";
480 const git = async (...args: string[]) => {
481 const p = Bun.spawn(["git", ...args], {
482 cwd: forkBare,
483 stdout: "pipe",
484 stderr: "pipe",
485 env: {
486 ...process.env,
487 GIT_AUTHOR_NAME: "t",
488 GIT_AUTHOR_EMAIL: "[email protected]",
489 GIT_COMMITTER_NAME: "t",
490 GIT_COMMITTER_EMAIL: "[email protected]",
491 },
492 });
493 const out = await new Response(p.stdout).text();
494 expect(await p.exited).toBe(0);
495 return out.trim();
496 };
497 const baseTip = (await resolveOid(bare, `refs/heads/${repo.defaultBranch}`))!;
498 const tree = await git("rev-parse", `${baseTip}^{tree}`);
499 const commit = await git("commit-tree", tree, "-p", baseTip, "-m", "ferns");
500 await git("update-ref", `refs/heads/${branch}`, commit);
501
502 const lines: string[] = [];
503 // An issue with no title is one the forge cannot open a pull for.
504 const untitled = { ...issue.value, title: "" };
505 const run = listRuns(app.db, repo.id, { kind: "agent" })[0]!;
506 const early = watchForPull({
507 app, repo, agent, issue: untitled, run, fork, branch, forkBare, bare,
508 forgeUrl: "http://forge.test",
509 write: (l) => lines.push(l),
510 variantGroup: null,
511 continuing: false,
512 intervalMs: 20,
513 });
514 const deadline = Date.now() + 5_000;
515 while (lines.length === 0 && Date.now() < deadline) await Bun.sleep(20);
516 expect(lines.length).toBe(1);
517 expect(lines[0]).toContain("could not open the pull early");
518 // Many looks later it has not tried again, and no pull stands.
519 await Bun.sleep(300);
520 await early.stop();
521 expect(lines.length).toBe(1);
522 expect(pullOfBranch(app, repo, fork, branch)).toBeUndefined();
523 }, 30_000);
370524 });
test/helpers/crew-standin.ts +11
@@ -62,6 +62,14 @@ echo "half-done" > PAUSED.md
6262 echo '{"type":"result","subtype":"error_during_execution","is_error":true,"result":"You\u2019ve hit your session limit \u00b7 resets 7:20am (UTC)","total_cost_usd":0.02,"num_turns":2,"session_id":"fake-1","terminal_reason":"api_error"}'
6363 exit 0
6464 fi
65 if grep -q PUSH-THEN-DOWN "${promptFile}" && [ ! -f "${dataDir}/down" ]; then
66 echo "work by the crew $(date +%s%N)" >> CREW.md
67 git add -A && git commit -qm "crew work" && git push -q -u origin HEAD
68 sleep 2.5
69 touch "${dataDir}/down"
70 echo '{"type":"result","subtype":"error_during_execution","is_error":true,"result":"API Error: Can'"'"'t reach the API server","total_cost_usd":0,"num_turns":0,"session_id":"fake-1","terminal_reason":"api_error"}'
71 exit 0
72 fi
6573 if grep -q NET-DOWN "${promptFile}"; then touch "${dataDir}/down"; fi
6674 if [ -f "${dataDir}/down" ] && grep -q "Continue where you stopped\\|NET-DOWN" "${promptFile}"; then
6775 echo '{"type":"result","subtype":"error_during_execution","is_error":true,"result":"API Error: Can'"'"'t reach the API server","total_cost_usd":0,"num_turns":0,"session_id":"fake-1","terminal_reason":"api_error"}'
@@ -126,6 +134,9 @@ echo "work by the crew $(date +%s%N)" >> CREW.md
126134 # package cache in it. Never the work; the clone's exclude keeps it out.
127135 mkdir -p .tmp/cache && echo "junk" > .tmp/cache/leftover
128136 git add -A && git commit -qm "crew work" && git push -q -u origin HEAD
137 if grep -q LINGER-ME "${promptFile}"; then
138 n=0; while [ ! -f "${dataDir}/release" ] && [ $n -lt 300 ]; do sleep 0.1; n=$((n+1)); done
139 fi
129140 echo '{"type":"assistant","message":{"content":[{"type":"text","text":"Added CREW.md."},{"type":"tool_use","name":"Read","input":{"file_path":"'"$(pwd)"'/CREW.md"}},{"type":"tool_use","name":"Bash","input":{"command":"cat /etc/hosts"}},{"type":"tool_use","name":"Bash","input":{"command":"git push"}}]}}'
130141 echo '{"type":"result","subtype":"success","is_error":false,"result":"Added CREW.md as asked.","total_cost_usd":0.01,"num_turns":3,"session_id":"fake-1","terminal_reason":"completed"}'
131142 `,

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs