Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const clientSettings: ClientSettings = {
diffIgnoreWhitespace: true,
favorites: [],
providerModelPreferences: {},
projectThreadEnvModeOverrides: {},
sidebarProjectGroupingMode: "repository_path",
sidebarProjectGroupingOverrides: {
"environment-1:/tmp/project-a": "separate",
Expand Down
41 changes: 36 additions & 5 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import {
CommandId,
DEFAULT_PROVIDER_INTERACTION_MODE,
DEFAULT_RUNTIME_MODE,
DEFAULT_SERVER_SETTINGS,
MessageId,
ThreadId,
} from "@t3tools/contracts";
import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode";
import * as Arr from "effect/Array";
import { pipe } from "effect/Function";

Expand Down Expand Up @@ -345,10 +347,28 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey);
const prompt = selectedProjectDraft.text;
const attachments = selectedProjectDraft.attachments;
const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? "local";
// Mobile is a detached surface — there is no local checkout in front of
// the user — so surface derivation (the default) resolves to an isolated
// worktree pinned to the latest remote base. An explicit
// defaultThreadEnvMode in the environment settings still applies, and
// "Local" remains an explicit per-draft exception.
const environmentSettings = selectedEnvironmentServerConfig?.settings;
const defaultWorkspaceMode = resolveDefaultThreadEnvMode({
deriveFromSurface:
environmentSettings?.deriveThreadEnvModeFromSurface ??
DEFAULT_SERVER_SETTINGS.deriveThreadEnvModeFromSurface,
configuredMode:
environmentSettings?.defaultThreadEnvMode ?? DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode,
surface: "detached",
});
const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode;
const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null;
const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null;
const startFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin ?? false;
const startFromOrigin =
selectedProjectDraft.workspaceSelection?.startFromOrigin ??
(workspaceMode === "worktree" &&
(environmentSettings?.newWorktreesStartFromOrigin ??
DEFAULT_SERVER_SETTINGS.newWorktreesStartFromOrigin));
const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE;
const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE;

Expand Down Expand Up @@ -597,14 +617,21 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
if (workspaceMode !== "worktree" || selectedBranchName !== null) {
return;
}
// Wait for the environment's settings before auto-writing a workspace
// selection: selectBranch persists the current mode into the draft, and
// doing that against the pre-config fallback would freeze the wrong
// default once the real settings arrive.
if (environmentSettings === undefined) {
return;
}
const preferredBranch =
availableBranches.find((branch) => branch.current) ??
availableBranches.find((branch) => branch.isDefault) ??
null;
if (preferredBranch) {
selectBranch(preferredBranch);
}
}, [availableBranches, selectBranch, selectedBranchName, workspaceMode]);
}, [availableBranches, environmentSettings, selectBranch, selectedBranchName, workspaceMode]);

const setRuntimeMode = useCallback(
(value: RuntimeMode) => {
Expand Down Expand Up @@ -667,7 +694,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
return null;
}
const workspaceSelection = draft.workspaceSelection;
const mode = workspaceSelection?.mode ?? "local";
const mode = workspaceSelection?.mode ?? defaultWorkspaceMode;
const pendingStartFromOrigin =
workspaceSelection?.startFromOrigin ?? (mode === "worktree" && startFromOrigin);
// When the selection is the stand-in built from the queued snapshot,
// persist the original (possibly absent) snapshot values — the
// stand-in's placeholder title/workspaceRoot must never be written back
Expand Down Expand Up @@ -696,17 +725,19 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
workspaceMode: mode,
branch: workspaceSelection?.branch ?? null,
worktreePath: mode === "worktree" ? null : (workspaceSelection?.worktreePath ?? null),
...(workspaceSelection?.startFromOrigin ? { startFromOrigin: true } : {}),
...(pendingStartFromOrigin ? { startFromOrigin: true } : {}),
},
createdAt: metadata.createdAt,
};
},
[
defaultWorkspaceMode,
editingPendingProject,
editingPendingTask,
selectedModel,
selectedProject,
selectedProjectDraftKey,
startFromOrigin,
],
);

Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/features/threads/use-project-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
type ProviderInteractionMode,
type RuntimeMode,
} from "@t3tools/contracts";
import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
import { buildThreadWorktreeBranchName } from "@t3tools/shared/git";
import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/unstable/reactivity";

Expand Down Expand Up @@ -74,7 +74,7 @@ export function useCreateProjectThread() {
branch: input.branch,
worktreePath: input.worktreePath,
startFromOrigin: input.startFromOrigin ?? false,
worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex),
worktreeBranchName: buildThreadWorktreeBranchName(metadata.threadId, randomHex),
}),
});
if (AsyncResult.isFailure(result)) {
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/state/use-thread-outbox-drain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
DEFAULT_RUNTIME_MODE,
type MessageId,
} from "@t3tools/contracts";
import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
import { buildThreadWorktreeBranchName } from "@t3tools/shared/git";
import * as Cause from "effect/Cause";
import { AsyncResult, Atom } from "effect/unstable/reactivity";
import { useCallback, useEffect, useRef, useState } from "react";
Expand Down Expand Up @@ -269,7 +269,7 @@ export function useThreadOutboxDrain(): void {
branch: creation.branch,
worktreePath: creation.worktreePath,
startFromOrigin: creation.startFromOrigin ?? false,
worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex),
worktreeBranchName: buildThreadWorktreeBranchName(queuedMessage.threadId, randomHex),
}),
});
return completeDelivery(deliveryResult);
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export interface ServerDerivedPaths {
readonly anonymousIdPath: string;
readonly environmentIdPath: string;
readonly serverRuntimeStatePath: string;
readonly worktreeRegistryPath: string;
readonly secretsDir: string;
}

Expand Down Expand Up @@ -116,6 +117,7 @@ export const deriveServerPaths = Effect.fn(function* (
anonymousIdPath: join(stateDir, "anonymous-id"),
environmentIdPath: join(stateDir, "environment-id"),
serverRuntimeStatePath: join(stateDir, "server-runtime.json"),
worktreeRegistryPath: join(stateDir, "worktree-registry.json"),
secretsDir: join(stateDir, "secrets"),
};
});
Expand Down
203 changes: 203 additions & 0 deletions apps/server/src/git/WorktreeBasePlanner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import {
GitCommandError,
WORKTREE_BASE_FRESHNESS_WINDOW,
WORKTREE_BASE_USABLE_HORIZON,
type WorktreeBaseProvenance,
} from "@t3tools/contracts";
import * as Clock from "effect/Clock";
import * as Context from "effect/Context";
import * as DateTime from "effect/DateTime";
import * as Deferred from "effect/Deferred";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Layer from "effect/Layer";

import * as GitWorkflowService from "./GitWorkflowService.ts";

export interface WorktreeBasePin {
/** Commit SHA the new worktree is created from. */
readonly commitSha: string;
/** Remote-tracking ref the SHA was resolved from. */
readonly remoteRefName: string;
readonly provenance: WorktreeBaseProvenance;
/** Time of the successful fetch backing this pin (null when unknown). */
readonly fetchedAt: string | null;
}

export interface EnsureFreshRemoteResult {
readonly fetchedAt: string | null;
readonly fetchSucceeded: boolean;
}

/**
* Plans the base commit for new worktree threads.
*
* Freshness ladder ("always current" without a blocking fetch on every send):
* 1. A fetch of the repo's primary remote within {@link WORKTREE_BASE_FRESHNESS_WINDOW}
* counts as fresh — pin the remote-tracking commit immediately.
* 2. Otherwise fetch now (deduped per repository: concurrent sends and the
* composer-open prefetch share one in-flight fetch).
* 3. If that fetch fails but the last successful fetch is within
* {@link WORKTREE_BASE_USABLE_HORIZON}, proceed on the last-known remote
* commit with `provenance: "stale"` (fail-visible).
* 4. With no usable fetch, fail closed — the caller surfaces the error and
* never silently falls back to a local ref.
*/
export class WorktreeBasePlanner extends Context.Service<
WorktreeBasePlanner,
{
/**
* Fetch the repository's primary remote unless a successful fetch is
* already within the freshness window. Never fails: fetch failures are
* reported through `fetchSucceeded` so composer-open prefetches are
* fire-and-forget.
*/
readonly ensureFreshRemote: (input: {
readonly cwd: string;
}) => Effect.Effect<EnsureFreshRemoteResult>;

/**
* Resolve the pinned base commit for a new worktree from `baseRef`,
* applying the freshness ladder above.
*/
readonly pinRemoteBase: (input: {
readonly cwd: string;
readonly baseRef: string;
}) => Effect.Effect<WorktreeBasePin, GitCommandError>;
}
>()("t3/git/WorktreeBasePlanner") {}

interface RepoFetchState {
/** Completion time of the last successful fetch (ISO). */
lastSuccessAt: string | null;
/** Shared in-flight fetch so concurrent callers coalesce. */
inFlight: Deferred.Deferred<boolean> | null;
}

export const make = Effect.gen(function* () {
const gitWorkflow = yield* GitWorkflowService.GitWorkflowService;

// Keyed by cwd. Bootstraps and the composer-open prefetch always pass the
// project root, so a per-cwd key deduplicates the flows that matter.
const fetchStateByCwd = new Map<string, RepoFetchState>();

const getState = (cwd: string): RepoFetchState => {
const existing = fetchStateByCwd.get(cwd);
if (existing) {
return existing;
}
const created: RepoFetchState = { lastSuccessAt: null, inFlight: null };
fetchStateByCwd.set(cwd, created);
return created;
};

const isWithin = (isoTime: string | null, window: Duration.Duration): Effect.Effect<boolean> =>
Effect.gen(function* () {
if (isoTime === null) {
return false;
}
const at = Date.parse(isoTime);
if (Number.isNaN(at)) {
return false;
}
const now = yield* Clock.currentTimeMillis;
return now - at <= Duration.toMillis(window);
});

const runDedupedFetch = (cwd: string): Effect.Effect<boolean> =>
Effect.gen(function* () {
const state = getState(cwd);
const existingInFlight = state.inFlight;
if (existingInFlight) {
return yield* Deferred.await(existingInFlight);
}
// Claim the slot synchronously (no suspension between the check above
// and this assignment) so concurrent callers can't both start a fetch.
const inFlight = Deferred.makeUnsafe<boolean>();
state.inFlight = inFlight;
Comment thread
cursor[bot] marked this conversation as resolved.
return yield* gitWorkflow.fetchRemote({ cwd, remoteName: "origin" }).pipe(
Effect.flatMap(() =>
DateTime.now.pipe(
Effect.map((now) => {
state.lastSuccessAt = DateTime.formatIso(now);
return true;
}),
),
),
Effect.catch((error) =>
Effect.logDebug("worktree base fetch failed", {
cwd,
detail: error.message,
}).pipe(Effect.map(() => false)),
),
Effect.onExit((exit) => {
state.inFlight = null;
return Deferred.done(inFlight, Exit.isSuccess(exit) ? exit : Exit.succeed(false)).pipe(
Effect.asVoid,
);
}),
);
});

const ensureFreshRemote: WorktreeBasePlanner["Service"]["ensureFreshRemote"] = Effect.fn(
"WorktreeBasePlanner.ensureFreshRemote",
)(function* (input) {
const state = getState(input.cwd);
if (yield* isWithin(state.lastSuccessAt, WORKTREE_BASE_FRESHNESS_WINDOW)) {
return { fetchedAt: state.lastSuccessAt, fetchSucceeded: true };
}
const fetchSucceeded = yield* runDedupedFetch(input.cwd);
return { fetchedAt: state.lastSuccessAt, fetchSucceeded };
});

const pinRemoteBase: WorktreeBasePlanner["Service"]["pinRemoteBase"] = Effect.fn(
"WorktreeBasePlanner.pinRemoteBase",
)(function* (input) {
const { fetchedAt, fetchSucceeded } = yield* ensureFreshRemote({ cwd: input.cwd });

const resolvePin = (provenance: WorktreeBaseProvenance) =>
gitWorkflow
.resolveRemoteTrackingCommit({
cwd: input.cwd,
refName: input.baseRef,
fallbackRemoteName: "origin",
})
.pipe(
Effect.map(
(resolved): WorktreeBasePin => ({
commitSha: resolved.commitSha,
remoteRefName: resolved.remoteRefName,
provenance,
fetchedAt,
}),
),
);

if (fetchSucceeded) {
return yield* resolvePin("fresh");
}

// Fail-visible: the remote is unreachable, but a recent fetch means the
// remote-tracking ref is still a sane base. Proceed with stale provenance.
if (yield* isWithin(fetchedAt, WORKTREE_BASE_USABLE_HORIZON)) {
return yield* resolvePin("stale");
}

// Fail closed: no usable remote data. The caller must not silently fall
// back to a local ref — surfacing this error is the contract.
return yield* new GitCommandError({
operation: "WorktreeBasePlanner.pinRemoteBase",
command: "fetch",
cwd: input.cwd,
detail: `Couldn't prepare a fresh worktree from ${input.baseRef}: the remote could not be reached and no recent fetch is available. Nothing has started and your checkout was not modified.`,
});
});

return WorktreeBasePlanner.of({
ensureFreshRemote,
pinRemoteBase,
});
});

export const layer = Layer.effect(WorktreeBasePlanner, make);
Loading
Loading