Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions apps/api/src/storage/task-board-advance-review.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,56 @@ describe("advanceToReviewIfInProgress (real Postgres)", () => {

expect(results.filter((r) => r !== null)).toHaveLength(1);
});

// A repo-backed task can't dead-end In Review with no PR — on finish it stays In Progress until a PR is linked, then the finish backstop advances it.
it("holds a repo-backed task on finish until a PR is linked", async () => {
const task = await taskBoard.create({
organizationId: ORG,
title: "repo, no PR yet",
status: "in_progress",
repo: "acme/site",
by: USER,
});
const thread = await threads.create({
organization_id: ORG,
title: "run",
status: "completed",
message_storage_version: 2,
created_by: USER,
});
await taskBoard.linkThread(task.id, thread.id, ORG);
await database.db
.insertInto("thread_message_parts")
.values({
id: `${thread.id}:m:0`,
seq: 0,
org_id: ORG,
thread_id: thread.id,
run_id: thread.id,
message_id: `${thread.id}:m`,
role: "user",
kind: "text",
payload: JSON.stringify({ type: "text", text: "go" }),
created_at: new Date().toISOString(),
})
.execute();

// No PR → stays In Progress.
await taskBoard.advanceLinkedTasksToReviewOnThreadFinish(thread.id, ORG);
expect((await taskBoard.getById(task.id, ORG))?.status).toBe("in_progress");

// Link a PR → the finish backstop now advances it.
await taskBoard.linkPr({
taskBoardItemId: task.id,
organizationId: ORG,
url: "https://github.com/acme/site/pull/3",
prNumber: 3,
repoOwner: "acme",
repoName: "site",
});
await taskBoard.advanceLinkedTasksToReviewOnThreadFinish(thread.id, ORG);
expect((await taskBoard.getById(task.id, ORG))?.status).toBe("in_review");
});
});

/**
Expand Down
28 changes: 28 additions & 0 deletions apps/api/src/storage/task-board.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,34 @@ describe("shouldAdvanceToReview", () => {
).toBe(true);
});

// A repo-backed task that finished with no PR has nothing to review yet.
it("does NOT advance a repo-backed task on finish with no PR", () => {
expect(
shouldAdvanceToReview(
{
status: "in_progress",
repo: "acme/site",
threads: [thread("completed")],
},
false,
),
).toBe(false);
});

// Backstop for missed PR-open detection: a PR exists, so finishing means there is one to review.
it("advances a repo-backed task on finish once a PR is linked", () => {
expect(
shouldAdvanceToReview(
{
status: "in_progress",
repo: "acme/site",
threads: [thread("completed")],
},
true,
),
).toBe(true);
});

// Inverted deliberately: this used to assert that a failed run advances the
// card. It is how eight tasks whose sandboxes never came up landed In Review
// with no PR and no work done. In Review means there is something to review.
Expand Down
22 changes: 17 additions & 5 deletions apps/api/src/storage/task-board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,20 @@ export const TERMINAL_THREAD_STATUSES = new Set([
* one would advance a card whose work nobody ever started; a card whose only
* thread is empty has effectively no thread at all.
*/
export function shouldAdvanceToReview(item: {
status: TaskBoardItemStatus;
threads: { status: string | null; hasMessages: boolean }[];
}): boolean {
export function shouldAdvanceToReview(
item: {
status: TaskBoardItemStatus;
repo?: string | null;
threads: { status: string | null; hasMessages: boolean }[];
},
/** Whether a PR is linked — a repo-backed task needs one to advance on finish. */
hasPr = false,
): boolean {
if (item.status !== "in_progress") return false;
const used = item.threads.filter((t) => t.hasMessages);
if (used.length === 0) return false;
// A repo-backed task reaches In Review only once a PR exists (the agent's PR-open hook moves it mid-run); on thread-finish we require a linked PR so a finished edit with no PR doesn't dead-end In Review. Non-repo tasks advance on finish.
if (item.repo != null && !hasPr) return false;
if (
!used.every(
(t) => t.status !== null && TERMINAL_THREAD_STATUSES.has(t.status),
Expand Down Expand Up @@ -1005,7 +1012,12 @@ export class TaskBoardStorage {
const moved: TaskBoardItem[] = [];
for (const taskId of await this.linkedTaskIds(threadId, organizationId)) {
const item = await this.getById(taskId, organizationId);
if (!item || !shouldAdvanceToReview(item)) continue;
if (!item) continue;
// Only query PRs for a repo-backed task — that's the one gate that needs it.
const hasPr =
item.repo != null &&
(await this.listPrs(taskId, organizationId)).length > 0;
if (!shouldAdvanceToReview(item, hasPr)) continue;
// The status flip is a CONDITIONAL update guarded on the status we just
// read, so exactly one concurrent caller can win it.
//
Expand Down
6 changes: 5 additions & 1 deletion apps/api/src/tools/task-board/stall-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,11 @@ export async function recoverStalledTasks(
// costs zero queries. Threads are newest-first (`attachThreads` orders by
// `link.created_at desc`), so the newest *used* one is the last run to have
// happened — an empty thread linked afterwards must not shadow it.
if (!shouldAdvanceToReview(item)) continue;
// Only query PRs for a repo-backed task — that's the one gate that needs it.
const hasPr =
item.repo != null &&
(await ctx.storage.taskBoard.listPrs(item.id, organizationId)).length > 0;
if (!shouldAdvanceToReview(item, hasPr)) continue;
const thread = item.threads.find((t) => t.hasMessages);
if (!thread) continue;
try {
Expand Down
Loading