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
105 changes: 105 additions & 0 deletions apps/server/src/orchestration-v2/ThreadSettlementService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,53 @@ describe("resolveAutoSettlementAt", () => {
).toBeNull();
});

it("does not settle on pull requests that were already merged when linked", () => {
const link = (number: number, linkedAt: DateTime.Utc, mergedAt: DateTime.Utc) => ({
host: "example.test",
repository: "owner/repository",
number,
url: `https://example.test/owner/repository/pull/${number}`,
source: "agent" as const,
linkedAt: DateTime.formatIso(linkedAt),
snapshot: {
state: "merged" as const,
title: "Pull request",
headBranch: `feature-${number}`,
baseBranch: "main",
isDraft: false,
updatedAt: DateTime.formatIso(mergedAt),
syncedAt: DateTime.formatIso(linkedAt),
mergedAt: DateTime.formatIso(mergedAt),
},
stack: null,
});
// The user asked the agent to merge pull requests; it merged one, then
// linked it to the thread as a record of the work.
const recorded = link(1, at(-50 * 60 * 1_000), at(-55 * 60 * 1_000));
const input = {
thread: shell({
latestUserMessageAt: at(-2 * 60 * 60 * 1_000),
latestRunCompletedAt: at(-45 * 60 * 1_000),
pullRequests: [recorded],
}),
pullRequest: null,
nowMs: NOW_MS,
autoSettleAfterDays: null,
autoSettleOnMerge: true,
};
expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toBeNull();
// A pull request linked while open still settles the thread when it merges.
expect(
ThreadSettlementService.resolveAutoSettlementAt({
...input,
thread: {
...input.thread,
pullRequests: [recorded, link(2, at(-90 * 60 * 1_000), at(-40 * 60 * 1_000))],
},
}),
).toEqual(at(-45 * 60 * 1_000));
});

it("settles inactive threads even when their pull request remains open", () => {
const input = {
thread: shell({ latestUserMessageAt: at(-30 * DAY_MS) }),
Expand Down Expand Up @@ -833,6 +880,64 @@ describe("ThreadSettlementServiceV2 worker", () => {
),
);

it.effect("does not look up a pull request that was already merged when linked", () =>
Effect.scoped(
Effect.gen(function* () {
yield* TestClock.setTime(Date.parse(NOW));
const reference = {
projectId: PROJECT_ID,
repository: "owner/repository",
number: 42,
url: "https://example.test/owner/repository/pull/42",
};
const fixture = yield* makeHarness({
snapshot: makeSnapshot([
makeThread("merged-then-linked", {
linkedPullRequest: reference,
pullRequests: [
{
host: "example.test",
repository: reference.repository,
number: reference.number,
url: reference.url,
source: "agent",
linkedAt: "2026-08-28T00:00:00.000Z",
snapshot: {
state: "merged",
title: "Pull request",
headBranch: "feature",
baseBranch: "main",
isDraft: false,
updatedAt: "2026-08-27T00:00:00.000Z",
syncedAt: "2026-08-28T00:00:00.000Z",
mergedAt: "2026-08-27T00:00:00.000Z",
},
stack: null,
},
],
}),
]),
settings: {
...DEFAULT_SERVER_SETTINGS,
sidebarAutoSettleAfterDays: null,
sidebarAutoSettleOnMerge: true,
},
pullRequestSummary: (input) =>
Effect.succeed({
...makePullRequestSummary({ ...input, state: "merged" }),
mergedAt: "2026-08-27T00:00:00.000Z",
}),
});
yield* Effect.gen(function* () {
const reactor = yield* ThreadSettlementService.ThreadSettlementServiceV2;
yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);
assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), []);
}).pipe(Effect.provide(fixture.layer));
}),
),
);

it.effect("reevaluates immediately after a pull request merge", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
38 changes: 30 additions & 8 deletions apps/server/src/orchestration-v2/ThreadSettlementService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,34 @@ function pullRequestMatchesProject(
);
}

type SettlementPullRequestLink = NonNullable<
ProjectionStore.ProjectionSettlementCandidate["pullRequests"]
>[number];

/** When the linked pull request merged or closed, or -Infinity if it has not. */
function linkTerminalAtMs(link: SettlementPullRequestLink): number {
const snapshot = link.snapshot;
const value = snapshot?.state === "merged" ? snapshot.mergedAt : snapshot?.closedAt;
const timestamp = Date.parse(value ?? "");
return Number.isNaN(timestamp) ? Number.NEGATIVE_INFINITY : timestamp;
}

/**
* The links whose outcome the thread waits on. A link made after its pull
* request had already merged or closed records work the thread did, such as an
* agent merging pull requests it was asked to merge, so it cannot settle the
* thread.
*/
function settlementPullRequestLinks(
links: ReadonlyArray<SettlementPullRequestLink>,
): ReadonlyArray<SettlementPullRequestLink> {
return visibleThreadPullRequests(links).filter((link) => {
if (link.snapshot === null || link.snapshot.state === "open") return true;
const linkedAtMs = Date.parse(link.linkedAt);
return Number.isNaN(linkedAtMs) || linkTerminalAtMs(link) >= linkedAtMs;
});
}

/**
* A recent user message stays queued until a run adopts its timestamp.
* Absolute age bounds client clock skew in both directions and stops stale
Expand Down Expand Up @@ -205,17 +233,11 @@ export function resolveAutoSettlementAt(input: {
}): DateTime.Utc | null {
const { thread } = input;
let pullRequest = input.pullRequest;
const links = visibleThreadPullRequests(thread.pullRequests ?? []);
const links = settlementPullRequestLinks(thread.pullRequests ?? []);
if (links.some((link) => link.snapshot === null || link.snapshot.state === "open")) return null;
if (links.length > 0) {
const terminalAt = (link: (typeof links)[number]) => {
const snapshot = link.snapshot;
const value = snapshot?.state === "merged" ? snapshot.mergedAt : snapshot?.closedAt;
const timestamp = Date.parse(value ?? "");
return Number.isNaN(timestamp) ? Number.NEGATIVE_INFINITY : timestamp;
};
const latest = links.reduce((current, candidate) =>
terminalAt(candidate) > terminalAt(current) ? candidate : current,
linkTerminalAtMs(candidate) > linkTerminalAtMs(current) ? candidate : current,
);
pullRequest =
latest.snapshot === null
Expand Down
Loading