Skip to content

Commit 22e8c2b

Browse files
authored
Merge pull request #225 from pylon-code/fix/prime-wsl2-only
fix(prime): preserve provider admission across restarts
2 parents 03defac + 183ad68 commit 22e8c2b

102 files changed

Lines changed: 8501 additions & 1514 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ jobs:
6464
strategy:
6565
fail-fast: false
6666
matrix:
67-
os: [ubuntu-24.04, windows-2025]
67+
os: [ubuntu-24.04]
6868
steps:
6969
- name: Checkout
7070
uses: actions/checkout@v6

apps/mobile/src/features/threads/NewTaskDraftScreen.tsx

Lines changed: 65 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
1717
import { useUniwindTheme } from "../../lib/useUniwindTheme";
1818
import { useFontFamily } from "../../lib/useFontFamily";
1919

20+
import { getProviderAdmissionUnavailableReason } from "@t3tools/client-runtime/providerAvailability";
2021
import {
2122
isAtomCommandInterrupted,
2223
squashAtomCommandFailure,
@@ -38,6 +39,7 @@ import { SymbolView } from "../../components/AppSymbol";
3839
import { AppText as Text } from "../../components/AppText";
3940
import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer";
4041
import { ComposerCommandPopover } from "./ComposerCommandPopover";
42+
import { ProviderUnavailableNotice } from "./ProviderUnavailableNotice";
4143
import { useComposerCommandMenu } from "./use-composer-command-menu";
4244
import {
4345
ComposerDictationCancelAction,
@@ -158,6 +160,15 @@ export function NewTaskDraftScreen(props: {
158160
connectedEnvironments.find(
159161
(environment) => environment.environmentId === selectedProject.environmentId,
160162
)?.connectionState === "connected";
163+
const providerAdmissionReason = getProviderAdmissionUnavailableReason({
164+
provider: flow.selectedProviderStatus,
165+
instanceId: flow.selectedModel ? String(flow.selectedModel.instanceId) : undefined,
166+
providerSnapshotKnown: selectedEnvironmentServerConfig != null,
167+
});
168+
const providerUnavailable =
169+
providerAdmissionReason === null
170+
? null
171+
: { headline: "Unavailable" as const, detail: providerAdmissionReason };
161172
const promptInputRef = useRef<ComposerEditorHandle>(null);
162173
const loadedBranchesProjectKeyRef = useRef<string | null>(null);
163174
const [isComposerFocused, setIsComposerFocused] = useState(false);
@@ -794,18 +805,20 @@ export function NewTaskDraftScreen(props: {
794805
if (voiceInput.blocksSubmission) return;
795806
const selectedProject = flow.selectedProject;
796807
const draftKey = flow.draftKey;
797-
if (!selectedProject || !draftKey) {
808+
if (!selectedProject || !draftKey || providerUnavailable) {
798809
return;
799810
}
800811
const draft = getComposerDraftSnapshot(draftKey);
801-
// Snapshot read keeps just-typed selector state; the availability gate
802-
// still applies so a stored selection on a disabled provider falls back
803-
// to the flow's resolved model.
812+
// Snapshot read keeps just-typed selector state. Ambient stale defaults
813+
// may fall back, but a human/recovered exact provider choice must remain
814+
// blocked rather than silently switch accounts.
804815
const modelSelection =
805-
resolveSelectableModelSelection(
806-
selectedEnvironmentServerConfig,
807-
draft.modelSelection ?? null,
808-
) ?? flow.selectedModel;
816+
draft.providerSelectionExplicit === true && draft.modelSelection !== undefined
817+
? resolveSelectableModelSelection(selectedEnvironmentServerConfig, draft.modelSelection)
818+
: (resolveSelectableModelSelection(
819+
selectedEnvironmentServerConfig,
820+
draft.modelSelection ?? null,
821+
) ?? flow.selectedModel);
809822
const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode;
810823
const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName;
811824
const selectedWorktreePath =
@@ -837,26 +850,43 @@ export function NewTaskDraftScreen(props: {
837850
}
838851

839852
const editingPendingTask = flow.editingPendingTask;
853+
const retryTurnMetadata =
854+
editingPendingTask?.deliveryHold === undefined ? null : makeTurnCommandMetadata();
840855

841856
if (!environmentConnected) {
842857
// Offline: park the task in the outbox; the drain sends it when the
843-
// environment reconnects. Editing an existing pending task re-queues it
844-
// under its original identifiers.
845-
const metadata = editingPendingTask
846-
? {
847-
threadId: editingPendingTask.threadId,
848-
commandId: editingPendingTask.commandId,
849-
messageId: editingPendingTask.messageId,
850-
createdAt: editingPendingTask.createdAt,
851-
}
852-
: makeTurnCommandMetadata();
858+
// environment reconnects. Ordinary edits preserve their identifiers;
859+
// explicitly submitting a held retarget uses the fresh metadata above.
860+
const metadata =
861+
retryTurnMetadata ??
862+
(editingPendingTask
863+
? {
864+
threadId: editingPendingTask.threadId,
865+
commandId: editingPendingTask.commandId,
866+
messageId: editingPendingTask.messageId,
867+
createdAt: editingPendingTask.createdAt,
868+
}
869+
: makeTurnCommandMetadata());
853870
const message = flow.buildPendingTaskMessage(metadata);
854871
if (!message) {
855872
return;
856873
}
857874
flow.setSubmitting(true);
858875
try {
859876
await enqueueThreadOutboxMessage(message);
877+
if (
878+
editingPendingTask !== null &&
879+
editingPendingTask.deliveryHold !== undefined &&
880+
editingPendingTask.messageId !== message.messageId
881+
) {
882+
try {
883+
await removeThreadOutboxMessage(editingPendingTask);
884+
} catch (error) {
885+
// The replacement is already durable and the old entry remains
886+
// held, so neither copy can lose or double-send the content.
887+
console.warn("[new-task] failed to remove retargeted held task", error);
888+
}
889+
}
860890
} catch (error) {
861891
Alert.alert(
862892
"Could not queue task",
@@ -912,7 +942,7 @@ export function NewTaskDraftScreen(props: {
912942
},
913943
...(editingPendingTask
914944
? {
915-
turnMetadata: {
945+
turnMetadata: retryTurnMetadata ?? {
916946
threadId: editingPendingTask.threadId,
917947
commandId: editingPendingTask.commandId,
918948
messageId: editingPendingTask.messageId,
@@ -972,8 +1002,9 @@ export function NewTaskDraftScreen(props: {
9721002

9731003
const isAndroid = Platform.OS === "android";
9741004
const canStart =
975-
Boolean(flow.selectedProject) &&
1005+
Boolean(flow.selectedProject?.workspaceRoot?.trim()) &&
9761006
Boolean(flow.selectedModel) &&
1007+
providerUnavailable === null &&
9771008
flow.prompt.trim().length > 0 &&
9781009
isIncomingShareReady &&
9791010
!isImportingShare &&
@@ -1140,6 +1171,11 @@ export function NewTaskDraftScreen(props: {
11401171
) : null}
11411172
<View className="pb-1">{workspaceControls}</View>
11421173

1174+
<ProviderUnavailableNotice
1175+
provider={flow.selectedProviderStatus}
1176+
reason={providerAdmissionReason}
1177+
/>
1178+
11431179
<ComposerSurface
11441180
style={{
11451181
borderRadius: 26,
@@ -1247,11 +1283,15 @@ export function NewTaskDraftScreen(props: {
12471283
{voicePresentation.showsSend ? (
12481284
<ComposerActionButton
12491285
accessibilityLabel={
1250-
flow.submitting
1251-
? "Starting task"
1252-
: environmentConnected
1253-
? "Start task"
1254-
: "Queue task"
1286+
providerUnavailable
1287+
? `Start unavailable. ${providerUnavailable.detail}`
1288+
: flow.submitting
1289+
? "Starting task"
1290+
: !canStart
1291+
? "Start unavailable. Add a message and complete the task setup."
1292+
: environmentConnected
1293+
? "Start task"
1294+
: "Queue task. The environment is disconnected; this task will remain queued."
12551295
}
12561296
disabled={!canStart}
12571297
icon={environmentConnected ? "arrow.up" : "tray.and.arrow.up"}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { getProviderUnavailablePresentation } from "@t3tools/client-runtime/providerAvailability";
2+
import type { ServerProvider } from "@t3tools/contracts";
3+
import { View } from "react-native";
4+
5+
import { AppText as Text } from "../../components/AppText";
6+
7+
export function ProviderUnavailableNotice(props: {
8+
readonly provider: ServerProvider | null | undefined;
9+
readonly reason?: string | null;
10+
readonly title?: string;
11+
}) {
12+
const presentation = getProviderUnavailablePresentation(props.provider);
13+
const detail = props.reason?.trim() || presentation?.detail;
14+
if (!detail) return null;
15+
16+
const providerName = props.provider?.displayName?.trim() || "Provider";
17+
const title = props.title ?? `${providerName} is unavailable`;
18+
19+
return (
20+
<View
21+
accessible
22+
accessibilityLabel={`${title}. ${detail}`}
23+
accessibilityLiveRegion="polite"
24+
accessibilityRole="alert"
25+
className="mb-2 gap-1 rounded-2xl border border-red-500/30 bg-red-500/10 px-3.5 py-3"
26+
>
27+
<Text className="text-sm font-t3-bold text-foreground">{title}</Text>
28+
<Text className="text-sm leading-snug text-foreground-muted">{detail}</Text>
29+
</View>
30+
);
31+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
2+
import { getProviderAdmissionUnavailableReason } from "@t3tools/client-runtime/providerAvailability";
3+
import { resolveProviderContinuationTransition } from "@t3tools/client-runtime/providerContinuation";
4+
import type {
5+
ModelSelection,
6+
OrchestrationSession,
7+
ServerConfig,
8+
ServerProvider,
9+
} from "@t3tools/contracts";
10+
11+
/** Resolve every composer surface against the persisted session binding first. */
12+
export function resolveThreadComposerAuthority(input: {
13+
readonly serverConfig: Pick<ServerConfig, "providers"> | null | undefined;
14+
readonly modelSelection: ModelSelection;
15+
readonly sessionProviderInstanceId?: ModelSelection["instanceId"] | undefined;
16+
}): {
17+
readonly modelSelection: ModelSelection | null;
18+
readonly provider: ServerProvider | null;
19+
readonly providerAdmissionAvailable: boolean;
20+
readonly providerAdmissionReason: string | null;
21+
readonly providerBindingMismatch: boolean;
22+
} {
23+
const providers = input.serverConfig?.providers ?? [];
24+
const instanceId = input.modelSelection.instanceId;
25+
const selectedProvider =
26+
providers.find((candidate) => candidate.instanceId === instanceId) ?? null;
27+
const transition = input.sessionProviderInstanceId
28+
? resolveProviderContinuationTransition({
29+
providers,
30+
currentInstanceId: input.sessionProviderInstanceId,
31+
targetInstanceId: instanceId,
32+
})
33+
: ({ compatible: true } as const);
34+
const providerBindingMismatch = !transition.compatible;
35+
const provider = providerBindingMismatch
36+
? (providers.find((candidate) => candidate.instanceId === input.sessionProviderInstanceId) ??
37+
null)
38+
: selectedProvider;
39+
const providerAdmissionReason = transition.compatible
40+
? getProviderAdmissionUnavailableReason({
41+
provider,
42+
instanceId: String(instanceId),
43+
providerSnapshotKnown: input.serverConfig !== null && input.serverConfig !== undefined,
44+
})
45+
: transition.reason;
46+
return {
47+
modelSelection: providerBindingMismatch ? null : input.modelSelection,
48+
provider,
49+
providerAdmissionAvailable: providerAdmissionReason === null,
50+
providerAdmissionReason,
51+
providerBindingMismatch,
52+
};
53+
}
54+
55+
/** Describe why a turn cannot be admitted immediately, even when it can be saved to the outbox. */
56+
export function resolveThreadComposerAdmissionReason(input: {
57+
readonly providerReason: string | null;
58+
readonly projectCwd: string | null;
59+
readonly connectionState: EnvironmentConnectionPhase;
60+
}): string | null {
61+
if (input.providerReason !== null) return input.providerReason;
62+
if (input.projectCwd === null) return "This thread's project workspace is unavailable.";
63+
if (input.connectionState !== "connected") {
64+
if (input.connectionState === "connecting" || input.connectionState === "reconnecting") {
65+
return "The environment is still connecting. This send will remain queued.";
66+
}
67+
if (input.connectionState === "error") {
68+
return "The environment connection failed. This send will remain queued.";
69+
}
70+
return "The environment is offline. This send will remain queued.";
71+
}
72+
return null;
73+
}
74+
75+
/** Provider unavailability must never remove the active turn's escape hatch. */
76+
export function threadComposerShowsStopAction(
77+
status: OrchestrationSession["status"] | null | undefined,
78+
): boolean {
79+
return status === "running" || status === "starting";
80+
}

0 commit comments

Comments
 (0)