Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
19 changes: 14 additions & 5 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,15 @@ export interface ThreadDetailScreenProps {
* The server has not created this thread yet. "preparing" runs while the
* queued creation is delivered (a worktree may be checking out); "failed"
* is a rejected creation whose content went back to the project draft.
* `serverOwned` marks a thread the server already created that is still
* setting up its worktree; follow-ups queue on the server behind that setup.
*/
readonly creationState:
| { readonly kind: "preparing"; readonly preparingWorktree: boolean }
| {
readonly kind: "preparing";
readonly preparingWorktree: boolean;
readonly serverOwned: boolean;
}
| { readonly kind: "failed"; readonly reason: string; readonly onEditTask: () => void }
| null;
readonly activePendingApproval: PendingApproval | null;
Expand Down Expand Up @@ -1392,11 +1398,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
canStopThread={props.canStopThread}
environmentId={props.environmentId}
projectCwd={props.threadCwd ?? props.projectWorkspaceRoot}
// Follow-ups typed during setup wait in the draft: queueing
// them against a thread id the server may still reject
// would strand them in the outbox.
// Follow-ups typed before the server creates the thread wait in
// the draft: queueing them against a thread id the server may
// still reject would strand them in the outbox.
sendBlockedReason={
props.creationState?.kind === "preparing" ? "Starting the task…" : null
props.creationState?.kind === "preparing" &&
!props.creationState.serverOwned
? "Starting the task…"
: null
}
draftKey={props.composerDraftKey ?? undefined}
followUpBehavior={props.followUpBehavior}
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -889,7 +889,9 @@ function ThreadRouteContent(
]);
const creationState = ((): ThreadDetailScreenProps["creationState"] => {
if (selectedThreadCreation === null) {
return awaitingBootstrapTurn ? { kind: "preparing", preparingWorktree: true } : null;
return awaitingBootstrapTurn
? { kind: "preparing", preparingWorktree: true, serverOwned: true }
: null;
}
if (selectedThreadCreation.outcome?.kind === "failed") {
return {
Expand All @@ -901,6 +903,7 @@ function ThreadRouteContent(
return {
kind: "preparing",
preparingWorktree: selectedThreadCreation.message.creation?.workspaceMode === "worktree",
serverOwned: false,
};
})();
if (!environmentId || !threadId) {
Expand Down
13 changes: 10 additions & 3 deletions apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1298,15 +1298,22 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
// Hold the queue so the user decides when to resume it. Validation
// failures (setup, unsupported handoff) belong to that message alone,
// and a message queued for another provider is how users recover.
// A message queued behind a worktree that was cancelled or never created
// would run in the project checkout instead, so it waits for the user.
const failedRun = latestExecutedRun(projection.runs);
const failureClass =
failedRun?.id === options?.failedRunId
? latestRootProviderFailure(failedRun, projection.turnItems)?.class
: undefined;
const worktreeMissing =
failedRun?.id === options?.failedRunId &&
failedRun?.workspacePreparation?.type === "worktree" &&
projection.thread.worktreePath === null;
if (
failureClass !== undefined &&
failureClass !== "validation_error" &&
failedRun?.providerInstanceId === queuedRun.providerInstanceId
worktreeMissing ||
Comment thread
coderabbitai[bot] marked this conversation as resolved.
(failureClass !== undefined &&
failureClass !== "validation_error" &&
failedRun?.providerInstanceId === queuedRun.providerInstanceId)
) {
const now = yield* DateTime.now;
yield* writeSystemEvents(
Expand Down
62 changes: 62 additions & 0 deletions apps/server/src/orchestration-v2/ThreadLaunchService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1422,6 +1422,68 @@ it.effect("retries a failed workspace preparation on the same run", () => {
}).pipe(Effect.provide(harness.layer));
});

it.effect("holds a message queued during setup when the workspace preparation fails", () =>
Effect.gen(function* () {
const allowFetch = yield* Deferred.make<void>();
const harness = makeHarness({
fetchRemote: () =>
Deferred.await(allowFetch).pipe(
Effect.andThen(
Effect.fail(
new GitCommandError({
operation: "GitVcsDriver.fetchRemote",
command: "git",
cwd: project.workspaceRoot,
detail: "Git could not reach the remote.",
exitCode: 128,
}),
),
),
),
});
yield* Effect.gen(function* () {
const launches = yield* ThreadLaunch.ThreadLaunchService;
const threads = yield* ThreadManagement.ThreadManagementService;
const launched = yield* launches.launch(
launchInput({
command: "command:launch:queued-failed-setup",
thread: "thread:launch:queued-failed-setup",
message: "First message",
workspace: { type: "worktree", baseRef: "main", startFromOrigin: true },
}),
);
const queued = yield* threads.sendToThread({
projectId,
commandId: CommandId.make("command:launch:queued-failed-setup:follow-up"),
threadId: launched.threadId,
messageId: MessageId.make("message:launch:queued-failed-setup:follow-up"),
text: "Sent during setup",
attachments: [],
mode: "queue",
createdBy: "user",
creationSource: "web",
});
assert.equal(queued.delivery, "queued");
yield* Deferred.succeed(allowFetch, undefined);

// Without its worktree the follow-up would start in the project checkout.
yield* threads.streamStoredEventsFrom({ threadId: launched.threadId }).pipe(
Stream.filter(
(stored) =>
stored.event.type === "run.updated" && stored.event.payload.queueHeld === true,
),
Stream.runHead,
);
const projection = yield* threads.getThreadProjection(launched.threadId);
assert.deepEqual(
projection.runs.map((run) => run.status),
["failed", "queued"],
);
assert.equal(projection.thread.worktreePath, null);
}).pipe(Effect.provide(harness.layer));
}),
);

it.effect("a retry reuses a recorded worktree without undoing its branch rename", () => {
let setupFailures = 1;
const harness = makeHarness({
Expand Down
74 changes: 49 additions & 25 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3667,6 +3667,13 @@ export default function ChatView(props: ChatViewProps) {
// Server-side workspace preparation: unlike the local-dispatch flag this
// survives reloads and shows on remote viewers of the same thread.
const activeRunPreparing = activeActivityRun?.status === "preparing";
// The server queues a message behind a run that is still preparing its
// worktree or starting its agent, so a send during setup waits there and
// starts on its own instead of being refused.
const sendQueuesBehindSetup =
isServerThread && (activeRunPreparing || activeActivityRun?.status === "starting");
// Such a send skips the local dispatch, so this keeps the composer busy while it is in flight.
const [setupQueueSendInFlight, setSetupQueueSendInFlight] = useState(false);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
useEffect(() => {
attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId;
}, [attachmentPreviewHandoffByMessageId]);
Expand Down Expand Up @@ -4020,11 +4027,13 @@ export default function ChatView(props: ChatViewProps) {
// Sends wait for the agent handoff, not for the setup script: an async
// script keeps the snapshot running while the agent already works, and a
// follow-up must not be held behind a slow install. Before the first
// snapshot arrives the starting session stands in for it.
// snapshot arrives the starting session stands in for it. Once the server
// has the thread, a send queues behind the setup run instead.
const worktreeSetupBlocksSend =
worktreeSetup !== null
!sendQueuesBehindSetup &&
(worktreeSetup !== null
? worktreeSetup.phase === "running" && !worktreeSetupAgentStarted(worktreeSetup)
: isPreparingWorktree;
: isPreparingWorktree);
const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, {
reportFailure: false,
});
Expand Down Expand Up @@ -8770,7 +8779,7 @@ export default function ChatView(props: ChatViewProps) {
if (
!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) ||
!activeThread ||
isSendBusy ||
(isSendBusy && !sendQueuesBehindSetup) ||
isConnecting ||
isRevertingCheckpoint ||
!clientSettingsHydrated ||
Expand Down Expand Up @@ -9214,7 +9223,7 @@ export default function ChatView(props: ChatViewProps) {
return;
}
const threadIdForSend = activeThread.id;
const isFirstMessage = !isServerThread || activeMessageCount === 0;
const isFirstMessage = !sendQueuesBehindSetup && (!isServerThread || activeMessageCount === 0);
const baseBranchForWorktree =
isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath
? activeThreadBranch
Expand Down Expand Up @@ -9271,9 +9280,11 @@ export default function ChatView(props: ChatViewProps) {
resumeCompactionTokens !== null &&
!keepFullHistory &&
messageTextForSend.toLowerCase() !== "/compact";
const turnDispatchMode = compactBeforeSend ? "queue" : dispatchMode;
const turnDispatchMode = compactBeforeSend || sendQueuesBehindSetup ? "queue" : dispatchMode;
const shouldQueueBehindActiveRun =
compactBeforeSend || (phase === "running" && dispatchMode === "queue");
compactBeforeSend ||
sendQueuesBehindSetup ||
(phase === "running" && dispatchMode === "queue");
const outgoingMessageText = formatOutgoingPrompt({
provider: ctxSelectedProvider,
model: ctxSelectedModel,
Expand Down Expand Up @@ -9422,21 +9433,28 @@ export default function ChatView(props: ChatViewProps) {
);
return;
}
beginLocalDispatch({
preparingWorktree: multipleModelSelections !== null || Boolean(baseBranchForWorktree),
// Only a draft has a background submission to hide behind its hero.
submissionIntent:
submissionIntent === "background" && !isLocalDraftThread ? "foreground" : submissionIntent,
});
setWorktreeSetupRef(
multipleModelSelections === null && baseBranchForWorktree
? {
environmentId: activeThread.environmentId,
threadId: threadIdForSend,
ownerKey: worktreeSetupOwnerKey,
}
: null,
);
// A send queued behind setup leaves the first send's dispatch and setup card alone.
if (sendQueuesBehindSetup) {
setSetupQueueSendInFlight(true);
} else {
beginLocalDispatch({
preparingWorktree: multipleModelSelections !== null || Boolean(baseBranchForWorktree),
// Only a draft has a background submission to hide behind its hero.
submissionIntent:
submissionIntent === "background" && !isLocalDraftThread
? "foreground"
: submissionIntent,
});
setWorktreeSetupRef(
multipleModelSelections === null && baseBranchForWorktree
? {
environmentId: activeThread.environmentId,
threadId: threadIdForSend,
ownerKey: worktreeSetupOwnerKey,
}
: null,
);
}

const turnAttachmentsPromise = Promise.all(
composerAttachmentsSnapshot.map(async (attachment) => {
Expand Down Expand Up @@ -10103,11 +10121,12 @@ export default function ChatView(props: ChatViewProps) {
}
}
sendInFlightRef.current = false;
setSetupQueueSendInFlight(false);
if (!turnStartSucceeded) {
setDockedDraftHeroThreadKey((currentThreadKey) =>
currentThreadKey === activeThreadKey ? null : currentThreadKey,
);
resetLocalDispatch();
if (!sendQueuesBehindSetup) resetLocalDispatch();
}
};

Expand Down Expand Up @@ -11614,7 +11633,12 @@ export default function ChatView(props: ChatViewProps) {
phase={phase}
canInterrupt={canInterruptRunningThread}
isConnecting={isConnecting}
isSendBusy={isSendBusy || isSavingQueuedEdit || isResuming}
isSendBusy={
(isSendBusy && !sendQueuesBehindSetup) ||
setupQueueSendInFlight ||
isSavingQueuedEdit ||
isResuming
}
canResume={resumableRunId !== null || hasHeldQueuedRuns}
isRevertingCheckpoint={isRevertingCheckpoint}
sendDisabledReason={
Expand All @@ -11632,7 +11656,7 @@ export default function ChatView(props: ChatViewProps) {
? "Preparing worktree"
: projectCloneSendBlockReason
}
isPreparingWorktree={isPreparingWorktree}
isPreparingWorktree={isPreparingWorktree && !sendQueuesBehindSetup}
queuedRunsControl={
isServerThread && activeThread ? (
<QueuedRunsControl
Expand Down
Loading