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
57 changes: 56 additions & 1 deletion apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId {
case "thread.pull-request-link.sync":
case "thread.pull-request.watch":
case "thread.pull-request-watch.sync":
case "thread.pull-request-watch.checks-reopen":
case "thread.pull-request.sync":
case "thread.title.regeneration.complete":
case "thread.project.adopt-folder":
Expand Down Expand Up @@ -782,6 +783,26 @@ function withPullRequestWatch(
return ended === undefined ? rest : { ...rest, endedWatch: ended };
}

function syncedPullRequestWatch(
existing: ThreadPullRequestWatch | undefined,
command: {
readonly watch: ThreadPullRequestWatch | null;
readonly checksPendingBefore?: boolean | undefined;
},
): ThreadPullRequestWatch | undefined {
if (command.watch === null) return undefined;
// A push cleared the answer while this read was in flight, so its answer is the old head's.
if (command.checksPendingBefore === undefined || existing?.checksPending !== undefined) {
return command.watch;
}
return withoutChecksAnswer(command.watch);
}

const withoutChecksAnswer = ({
checksPending: _answer,
...watch
}: ThreadPullRequestWatch): ThreadPullRequestWatch => watch;

function legacyPullRequestLink(
thread: OrchestrationV2AppThread,
linked: ThreadLinkedPullRequest,
Expand Down Expand Up @@ -3871,6 +3892,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
| "thread.pull-request-link.sync"
| "thread.pull-request.watch"
| "thread.pull-request-watch.sync"
| "thread.pull-request-watch.checks-reopen"
| "thread.pull-request.sync"
| "thread.title.regeneration.complete"
| "thread.runtime-mode.set"
Expand Down Expand Up @@ -4669,6 +4691,18 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
updatedAt: command.type === "thread.pull-request-link.sync" ? thread.updatedAt : now,
};
}
case "thread.pull-request-watch.checks-reopen": {
const linked = threadPullRequestsOf(thread);
if (!linked.some((link) => link.watch?.checksPending !== undefined)) return thread;
return {
...thread,
pullRequests: linked.map((link) =>
link.watch?.checksPending === undefined
? link
: { ...link, watch: withoutChecksAnswer(link.watch) },
),
};
}
case "thread.pull-request.watch":
case "thread.pull-request-watch.sync": {
const key = normalizeThreadPullRequestKey(command);
Expand Down Expand Up @@ -4705,7 +4739,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
const watch =
command.type === "thread.pull-request-watch.sync"
? existing.watch?.startedAt === command.startedAt
? (command.watch ?? undefined)
? syncedPullRequestWatch(existing.watch, command)
: existing.watch
: !command.watching
? undefined
Expand Down Expand Up @@ -4736,6 +4770,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
...(existing.endedWatch?.approvalAnnounced !== undefined && {
approvalAnnounced: existing.endedWatch.approvalAnnounced,
}),
// A finished-checks answer may be an older head's; a fresh read decides.
...(existing.endedWatch?.checksPending === true && { checksPending: true }),
...(existing.endedWatch?.checksAwaitedSince !== undefined && {
checksAwaitedSince: existing.endedWatch.checksAwaitedSince,
}),
Expand Down Expand Up @@ -4823,6 +4859,20 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
};
}
})();
if (command.type === "thread.pull-request-watch.checks-reopen") {
if (updatedThread === thread) return;
yield* emit(
events,
command,
)({
type: "thread.pull-request-synced",
threadId: command.threadId,
providerInstanceId: updatedThread.providerInstanceId,
occurredAt: now,
payload: updatedThread,
});
return;
}
if (
command.type === "thread.pull-request-link.sync" ||
command.type === "thread.pull-request-watch.sync"
Expand Down Expand Up @@ -14481,6 +14531,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
case "provider.switch":
yield* dispatchThreadMutation(command, events, effects);
break;
case "thread.pull-request-watch.checks-reopen":
yield* dispatchThreadMutation(command, events, effects);
break;
case "thread.pull-request-watch.sync":
yield* dispatchPullRequestWatchSync(command, events, effects);
break;
Expand Down Expand Up @@ -14838,6 +14891,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
planned.events.length > 0 ||
isUnobservedAcknowledgement(command) ||
command.type === "thread.background-work.settle" ||
// A read can answer a watch again before a push's reopen lands; nothing is left to clear.
command.type === "thread.pull-request-watch.checks-reopen" ||
command.type === "thread.interrupt.unresponsive" ||
command.type === "thread.stop" ||
command.type === "subagent.stop" ||
Expand Down
62 changes: 62 additions & 0 deletions apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
ThreadId,
TurnItemId,
type OrchestrationV2Command as OrchestrationCommand,
type OrchestrationV2InternalCommand,
type OrchestrationV2DomainEvent,
type OrchestrationV2Run,
type OrchestrationProjectShell,
Expand Down Expand Up @@ -51,6 +52,10 @@ type SyncCommand = Extract<
{ readonly type: "thread.pull-request-link.sync" }
>;
type LinkCommand = Extract<OrchestrationCommand, { readonly type: "thread.pull-request.link" }>;
type ChecksReopenCommand = Extract<
OrchestrationV2InternalCommand,
{ readonly type: "thread.pull-request-watch.checks-reopen" }
>;

const testCrypto = SecureRandom.make((size) => new Uint8Array(size).fill(1));

Expand Down Expand Up @@ -189,6 +194,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options:
const shellSnapshotReads = yield* Ref.make(0);
const syncCommands = yield* Ref.make<ReadonlyArray<SyncCommand>>([]);
const linkCommands = yield* Ref.make<ReadonlyArray<LinkCommand>>([]);
const reopenCommands = yield* Queue.unbounded<ChecksReopenCommand>();
const summaryCalls = yield* Ref.make<ReadonlyArray<PullRequestRef>>([]);
const stackCalls = yield* Ref.make<ReadonlyArray<PullRequestRef>>([]);
const domainEvents = yield* Queue.unbounded<OrchestrationV2DomainEvent>();
Expand Down Expand Up @@ -223,6 +229,11 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options:
Effect.as({ sequence: 1, storedEvents: [] }),
);
}
if (command.type === "thread.pull-request-watch.checks-reopen") {
return Queue.offer(reopenCommands, command).pipe(
Effect.as({ sequence: 1, storedEvents: [] }),
);
}
return Effect.die(new Error(`Unexpected command: ${command.type}`));
};

Expand Down Expand Up @@ -284,6 +295,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options:
shellSnapshotReads,
syncCommands,
linkCommands,
reopenCommands,
summaryCalls,
stackCalls,
domainEvents,
Expand Down Expand Up @@ -1880,6 +1892,56 @@ describe("PullRequestSyncReactor", () => {
),
);

it.effect("reopens a watch's checks when its thread's agent pushes", () =>
Effect.scoped(
Effect.gen(function* () {
yield* TestClock.setTime(Date.parse(NOW));
const watch = {
startedAt: NOW,
headSha: "aaaaaaaaaa",
failedChecks: [],
passed: true,
passedChecks: ["test"],
remarksThrough: NOW,
remarkIds: [],
conflicting: false,
wakes: 0,
};
const fixture = yield* makeHarness({
snapshot: makeSnapshot([
makeThread("agent", {
pullRequests: [
makeLink(7, { state: "open" }, { watch: { ...watch, checksPending: false } }),
],
}),
makeThread("unread", {
pullRequests: [makeLink(8, { state: "open" }, { watch })],
}),
]),
});

yield* Effect.gen(function* () {
yield* startAndSweep(fixture);
const agent = ThreadId.make("agent");
yield* Queue.offerAll(fixture.domainEvents, [
commandRan(agent, "git status && echo 'then git push'", undefined, "status"),
// A watch that has not read its checks yet has no answer to forget.
commandRan(ThreadId.make("unread"), "git push", undefined, "unread-push"),
commandRan(agent, "cd app && git -C . push --force-with-lease", undefined, "push"),
commandRan(agent, '/bin/zsh -lc "gh stack submit --auto"', undefined, "stack"),
]);
for (const item of ["push", "stack"]) {
const command = yield* Queue.take(fixture.reopenCommands);
assert.deepStrictEqual(
[command.threadId, command.commandId],
[agent, `server:pr-push-checks:agent:item:agent:${item}`],
);
}
}).pipe(Effect.provide(fixture.layer));
}),
),
);

it.effect("reads open links again when the run ends, after `merge --auto` left them open", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
38 changes: 37 additions & 1 deletion apps/server/src/orchestration-v2/PullRequestSyncReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ const CREATE_COMMANDS = sourceControlDefinitions.definitions.flatMap(
);
const createCommandCount = (code: string) =>
CREATE_COMMANDS.reduce((count, { run }) => count + [...code.matchAll(run)].length, 0);
// A push, directly or through a stack tool, that can start checks on a linked pull request.
const PUSH_COMMAND = new RegExp(
`${COMMAND_START}(?:git(?:\\s+-[cC]\\s+\\S+)*\\s+push|gh\\s+stack\\s+(?:push|submit)|cafe\\s+stack\\s+submit|jj\\s+git\\s+push)(?=$|[\\s;&|)])`,
"u",
);

// Codex runs each command as `/bin/zsh -lc <script>`, quoting the script in pieces such as
// `"a "'`b`'" c"`.
Expand Down Expand Up @@ -134,6 +139,8 @@ function shellSegments(script: string): ReadonlyArray<string> {
const shellCode = (script: string) =>
script.replace(HEREDOC_BODY, "<<").replace(QUOTED_OR_COMMENT, "''");

const isPushCommand = (input: string) => PUSH_COMMAND.test(shellCode(shellScript(input)));

type CommandExecution = Extract<OrchestrationV2TurnItem, { readonly type: "command_execution" }>;

/**
Expand Down Expand Up @@ -757,6 +764,32 @@ export const make = Effect.gen(function* () {
},
);

// A push can start new checks, so the thread's watches forget whether checks were running and
// hold the thread until they read the pull request again. The run is still going, so the
// thread never shows as done in between.
const reopenWatchedChecks = Effect.fn("PullRequestSyncReactor.reopenWatchedChecks")(
function* (threadId: ThreadId, item: CommandExecution) {
if (isOrchestrationV2WorkActive(item.status) || !isPushCommand(item.input)) return;
const threads = yield* projections.getThreadsWithPullRequests(threadId);
const answered = threads.some((thread) =>
(thread.pullRequests ?? []).some((link) => link.watch?.checksPending !== undefined),
);
if (!answered) return;
yield* engine.dispatch({
type: "thread.pull-request-watch.checks-reopen",
// Stable per command, so a repeated settlement clears an answer read since only once.
commandId: CommandId.make(`server:pr-push-checks:${threadId}:${item.id}`),
threadId,
});
},
(effect, threadId) =>
effect.pipe(
Effect.catchCause(
logSkipped("reopening a watch's checks after a push failed", { threadId }),
),
),
);

const start: PullRequestSyncReactor["Service"]["start"] = Effect.fn(
"PullRequestSyncReactor.start",
)(function* () {
Expand Down Expand Up @@ -787,7 +820,10 @@ export const make = Effect.gen(function* () {
case "turn-item.updated": {
const item = event.payload;
if (item.type !== "command_execution") return Effect.void;
const linked = linkCreatedChangeRequests(event.threadId, item);
const linked = Effect.andThen(
linkCreatedChangeRequests(event.threadId, item),
reopenWatchedChecks(event.threadId, item),
);
if (!isChangeRequestCloseCommand(item.input)) return linked;
const ended = closeCommands.get(event.threadId) ?? new Set<string>();
closeCommands.set(event.threadId, ended);
Expand Down
18 changes: 14 additions & 4 deletions apps/server/src/orchestration-v2/PullRequestWatchReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,11 +165,13 @@ function planRead(
if (last === undefined || group.targets.some((target) => !last.watches.has(watchKey(target)))) {
return { detail: true, activity: true };
}
// Nothing on the host changes when the no-checks deadline passes, so it alone prompts a read.
// Nothing on the host changes when the no-checks deadline passes, or when a push to another
// branch clears a watch's answer, so either alone prompts a read.
const checksDue = group.targets.some(
({ watch }) =>
watch.checksAwaitedSince != null &&
now - Date.parse(watch.checksAwaitedSince) >= PULL_REQUEST_WATCH_NO_CHECKS_MS,
watch.checksPending === undefined ||
(watch.checksAwaitedSince != null &&
now - Date.parse(watch.checksAwaitedSince) >= PULL_REQUEST_WATCH_NO_CHECKS_MS),
);
if (fingerprint === null) {
const read =
Expand All @@ -189,6 +191,10 @@ function planRead(
};
}

// Lets the orchestrator tell that a push cleared the answer while this read was in flight.
const checksPendingBeforeOf = ({ watch }: WatchTarget) =>
watch.checksPending === undefined ? {} : { checksPendingBefore: watch.checksPending };

function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatch): boolean {
return (
left.startedAt === right.startedAt &&
Expand All @@ -205,7 +211,8 @@ function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatc
left.mergeQueueRemovalId === right.mergeQueueRemovalId &&
left.wakes === right.wakes &&
left.awaitingBaseline === right.awaitingBaseline &&
left.checksAwaitedSince === right.checksAwaitedSince
left.checksAwaitedSince === right.checksAwaitedSince &&
left.checksPending === right.checksPending
);
}

Expand Down Expand Up @@ -314,6 +321,7 @@ export const make = Effect.gen(function* () {
...identityOf(target.link),
startedAt: target.watch.startedAt,
watch: next,
...checksPendingBeforeOf(target),
...(endedWatch === undefined ? {} : { endedWatch }),
...(wake === undefined
? {}
Expand Down Expand Up @@ -455,11 +463,13 @@ export const make = Effect.gen(function* () {
...identityOf(first.target.link),
startedAt: first.target.watch.startedAt,
watch: first.next,
...checksPendingBeforeOf(first.target),
...(first.endedWatch === undefined ? {} : { endedWatch: first.endedWatch }),
others: rest.map((item) => ({
...identityOf(item.target.link),
startedAt: item.target.watch.startedAt,
watch: item.next,
...checksPendingBeforeOf(item.target),
...(item.endedWatch === undefined ? {} : { endedWatch: item.endedWatch }),
...(item.wake.includesRemarks === true ? { includesRemarks: true } : {}),
})),
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/orchestration-v2/ThreadSettlementService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,17 @@ describe("isAutoSettlementCandidate", () => {
NOW_MS,
),
).toBe(true);
// A pull request watch whose checks finished holds nothing, yet still keeps the thread.
expect(
ThreadSettlementService.isAutoSettlementCandidate(
shell({
pendingBackgroundTasks: [
{ taskId: "pull-request-watch:github.com/acme/app#1", kind: "monitor" },
],
}),
NOW_MS,
),
).toBe(false);
});

it("never settles, or keeps snoozed, a thread whose guest is waiting on the owner", () => {
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/orchestration-v2/ThreadSettlementService.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
backgroundWorkHoldsCompletion,
isPullRequestWatchTask,
turnItemUpdateCanEndBackgroundWork,
} from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
Expand Down Expand Up @@ -154,7 +155,10 @@ export function isAutoSettlementCandidate(
// A guest is waiting on the owner; settling would hide the Request.
if ((thread.pendingGuestMessageCount ?? 0) > 0) return false;
if (thread.activityRunStatus != null) return false;
if (backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? [])) return false;
const pendingTasks = thread.pendingBackgroundTasks ?? [];
if (backgroundWorkHoldsCompletion(pendingTasks)) return false;
// Settling ends a watch, which would silently drop a reviewer's later comments.
if (pendingTasks.some(isPullRequestWatchTask)) return false;
if (threadHasQueuedTurnStart(thread, nowMs)) return false;
// Interrupted runs must keep the same snooze protection the sidebar shows.
return !isSnoozed(thread, nowMs);
Expand Down
Loading
Loading