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
14 changes: 13 additions & 1 deletion apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ import {
} from "@t3tools/shared/projectScripts";
import { Alert, Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { pairRewindBlockedReason } from "@t3tools/client-runtime/state/pair";
import { useThreadShells } from "../../state/entities";
import { useWorkspaceState } from "../../state/workspace";
import { restoredNewTaskDraftKey } from "../../state/new-task-draft-key";
import { clearPendingThreadCreationOutcome } from "../../state/pending-thread-creation";
Expand Down Expand Up @@ -259,6 +261,7 @@ function ThreadRouteContent(
const gitActions = useSelectedThreadGitActions();
const requests = useSelectedThreadRequests();
const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");
const threadShells = useThreadShells();
const revertThreadCheckpoint = useAtomCommand(threadEnvironment.revertCheckpoint, {
label: "thread rollback",
reportFailure: false,
Expand Down Expand Up @@ -778,6 +781,15 @@ function ThreadRouteContent(
const onRevertMessage = useCallback(
(target: RollbackTarget) => {
if (!selectedThread || !rollbackTargetIdle) return;
const pairBlockedReason = pairRewindBlockedReason({
threads: threadShells,
environmentId: selectedThread.environmentId,
leadThreadId: selectedThread.id,
});
if (pairBlockedReason !== null) {
Alert.alert("Rollback unavailable", pairBlockedReason);
return;
}
const revert = (restoreFiles: boolean) => {
setRollbackCommandPending(true);
void revertThreadCheckpoint({
Expand Down Expand Up @@ -809,7 +821,7 @@ function ThreadRouteContent(
],
);
},
[revertThreadCheckpoint, rollbackTargetIdle, selectedThread],
[revertThreadCheckpoint, rollbackTargetIdle, selectedThread, threadShells],
);

const onRecoverRollback = useCallback(
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
type WorktreeSetupSnapshot,
} from "@t3tools/contracts";
import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection";
import { pairRewindBlockedReason } from "@t3tools/client-runtime/state/pair";
import { deriveReportedTurnCosts } from "@t3tools/client-runtime/state/turn-costs";
import { canAskSessionSideQuestion } from "@t3tools/client-runtime/state/session-side-question";
import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors";
Expand Down Expand Up @@ -7327,6 +7328,15 @@ export default function ChatView(props: ChatViewProps) {
setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
return;
}
const pairBlockedReason = pairRewindBlockedReason({
threads: allThreadShells,
environmentId,
leadThreadId: activeThread.id,
});
if (pairBlockedReason !== null) {
setThreadError(activeThread.id, pairBlockedReason);
return;
}
if (restoreFiles === undefined) {
setPendingRevert({ target, messageId, routeThreadKey });
return;
Expand Down Expand Up @@ -7450,6 +7460,7 @@ export default function ChatView(props: ChatViewProps) {
activeThread,
activeEnvironmentUnavailable,
activeEnvironmentUnavailableLabel,
allThreadShells,
composerDraftTarget,
composerRef,
createAttachmentAssetUrl,
Expand Down
4 changes: 3 additions & 1 deletion docs/internals/delegation.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,9 @@ back, because archiving it is how a user turns the pair off, and unarchiving a l
pair back on. A rewind of the lead is not blocked, since that would mean changing rewind admission in
the decider. Instead the reactor interrupts a running executor the moment the rewind is requested: the
two threads share one worktree, and an executor that kept editing would write over the restored
files. It may still write for a moment before the interrupt lands. The idle-session reaper skips an
files. It may still write for a moment before the interrupt lands, so the web and mobile clients
refuse a rewind while the executor holds a turn, including one paused on an approval or a question,
and say why (`pairRewindBlockedReason`). The reactor stays as the backstop for any other client. The idle-session reaper skips an
executor while its lead's session is starting or running, so it is not restarted between briefs. The
reactor reads no settings: following a lead is cleanup and keeps working after delegation is turned
off.
Expand Down
3 changes: 2 additions & 1 deletion docs/user/agent-delegation.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,8 @@ stay as they are. You can ask for it: “reset your executor.”

The executor appears under its parent in the sidebar like any delegated thread. Archive it to turn
the pair off for that thread. Archiving, settling, or deleting the lead does the same to its
executor. Rewinding the lead stops the executor first, because both work on the same files. The Pair
executor. A lead cannot be rewound while its executor is working, because both use the same files:
stop the executor first, then rewind. The Pair
control is on the web and desktop apps; the mobile app does not have it yet.

Pairing does not need **Pylon delegation** turned on. That setting decides whether agents may start
Expand Down
34 changes: 34 additions & 0 deletions packages/client-runtime/src/state/pair.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { EnvironmentThreadShell } from "./models.ts";
import {
PAIR_UNSUPPORTED_LEAD_REASON,
isPairLeadSupported,
pairRewindBlockedReason,
pairExecutorCreateInput,
resolvePairState,
withoutPairExecutors,
Expand Down Expand Up @@ -238,3 +239,36 @@ describe("pairExecutorCreateInput", () => {
).toHaveLength(200);
});
});

describe("pairRewindBlockedReason", () => {
const ask = (threads: readonly EnvironmentThreadShell[]) =>
pairRewindBlockedReason({ threads, environmentId: ENV, leadThreadId: LEAD });

it("blocks a rewind while the executor holds a turn in the shared worktree", () => {
const busy = [
executor({
latestTurn: runningTurn,
session: session({ status: "running", activeTurnId: turnId }),
}),
executor({ session: session({ status: "starting" }) }),
// Paused on the user, but it resumes and writes once they answer.
executor({ latestTurn: runningTurn, hasPendingApprovals: true }),
executor({ latestTurn: runningTurn, hasPendingUserInput: true }),
];
for (const thread of busy) {
expect(ask([thread])).toBe(
"Stop the executor before rewinding. It works in this thread's worktree and would write over the restored files.",
);
}
});

it("allows it otherwise", () => {
expect(ask([])).toBeNull();
expect(ask([executor()])).toBeNull();
expect(ask([executor({ latestTurn: completedTurn, session: session() })])).toBeNull();
// A pair that was turned off, another thread's child, another environment.
expect(ask([executor({ latestTurn: runningTurn, archivedAt: NOW })])).toBeNull();
expect(ask([shell(FAN_OUT_CHILD, { latestTurn: runningTurn })])).toBeNull();
expect(ask([executor({ latestTurn: runningTurn, environmentId: OTHER_ENV })])).toBeNull();
});
});
26 changes: 26 additions & 0 deletions packages/client-runtime/src/state/pair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,29 @@ export function pairExecutorCreateInput(input: {
worktreePath: input.lead.worktreePath,
};
}

/**
* Why a lead cannot be rewound right now, or null. The executor works in the
* lead's worktree, so one that holds a turn would write over the files a rewind
* restores. The server interrupts it when a rewind is requested, but that lands
* a moment late; the clients ask the user to stop it first.
*/
export function pairRewindBlockedReason(input: {
readonly threads: readonly EnvironmentThreadShell[];
readonly environmentId: EnvironmentId;
readonly leadThreadId: ThreadId;
}): string | null {
const state = resolvePairState({
threads: input.threads,
// Any lead that has an executor counts here, whatever its provider.
lead: { environmentId: input.environmentId, threadId: input.leadThreadId, driverKind: null },
});
if (state.kind !== "on") {
return null;
}
return state.phase === "running" ||
state.phase === "needs-approval" ||
state.phase === "needs-input"
? "Stop the executor before rewinding. It works in this thread's worktree and would write over the restored files."
: null;
}
Loading