Skip to content

Commit bf543c8

Browse files
committed
fix(prime): preserve provider admission across restarts
Closes #222
1 parent 2d168f2 commit bf543c8

90 files changed

Lines changed: 6970 additions & 1399 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: 56 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
1616
import { useUniwindTheme } from "../../lib/useUniwindTheme";
1717
import { useFontFamily } from "../../lib/useFontFamily";
1818

19+
import { getProviderAdmissionUnavailableReason } from "@t3tools/client-runtime/providerAvailability";
1920
import {
2021
isAtomCommandInterrupted,
2122
squashAtomCommandFailure,
@@ -35,6 +36,7 @@ import { SymbolView } from "../../components/AppSymbol";
3536
import { AppText as Text } from "../../components/AppText";
3637
import { ComposerSurface } from "./ThreadComposer";
3738
import { ComposerCommandPopover } from "./ComposerCommandPopover";
39+
import { ProviderUnavailableNotice } from "./ProviderUnavailableNotice";
3840
import { useComposerCommandMenu } from "./use-composer-command-menu";
3941
import {
4042
useThreadSettingsSheetPresentation,
@@ -137,6 +139,15 @@ export function NewTaskDraftScreen(props: {
137139
connectedEnvironments.find(
138140
(environment) => environment.environmentId === selectedProject.environmentId,
139141
)?.connectionState === "connected";
142+
const providerAdmissionReason = getProviderAdmissionUnavailableReason({
143+
provider: flow.selectedProviderStatus,
144+
instanceId: flow.selectedModel ? String(flow.selectedModel.instanceId) : undefined,
145+
providerSnapshotKnown: selectedEnvironmentServerConfig != null,
146+
});
147+
const providerUnavailable =
148+
providerAdmissionReason === null
149+
? null
150+
: { headline: "Unavailable" as const, detail: providerAdmissionReason };
140151
const promptInputRef = useRef<ComposerEditorHandle>(null);
141152
const loadedBranchesProjectKeyRef = useRef<string | null>(null);
142153
const [isComposerFocused, setIsComposerFocused] = useState(false);
@@ -666,7 +677,7 @@ export function NewTaskDraftScreen(props: {
666677
async function handleStart(): Promise<void> {
667678
const selectedProject = flow.selectedProject;
668679
const draftKey = flow.draftKey;
669-
if (!selectedProject || !draftKey) {
680+
if (!selectedProject || !draftKey || providerUnavailable) {
670681
return;
671682
}
672683
const draft = getComposerDraftSnapshot(draftKey);
@@ -699,26 +710,43 @@ export function NewTaskDraftScreen(props: {
699710
}
700711

701712
const editingPendingTask = flow.editingPendingTask;
713+
const retryTurnMetadata =
714+
editingPendingTask?.deliveryHold === undefined ? null : makeTurnCommandMetadata();
702715

703716
if (!environmentConnected) {
704717
// Offline: park the task in the outbox; the drain sends it when the
705-
// environment reconnects. Editing an existing pending task re-queues it
706-
// under its original identifiers.
707-
const metadata = editingPendingTask
708-
? {
709-
threadId: editingPendingTask.threadId,
710-
commandId: editingPendingTask.commandId,
711-
messageId: editingPendingTask.messageId,
712-
createdAt: editingPendingTask.createdAt,
713-
}
714-
: makeTurnCommandMetadata();
718+
// environment reconnects. Ordinary edits preserve their identifiers;
719+
// explicitly submitting a held retarget uses the fresh metadata above.
720+
const metadata =
721+
retryTurnMetadata ??
722+
(editingPendingTask
723+
? {
724+
threadId: editingPendingTask.threadId,
725+
commandId: editingPendingTask.commandId,
726+
messageId: editingPendingTask.messageId,
727+
createdAt: editingPendingTask.createdAt,
728+
}
729+
: makeTurnCommandMetadata());
715730
const message = flow.buildPendingTaskMessage(metadata);
716731
if (!message) {
717732
return;
718733
}
719734
flow.setSubmitting(true);
720735
try {
721736
await enqueueThreadOutboxMessage(message);
737+
if (
738+
editingPendingTask !== null &&
739+
editingPendingTask.deliveryHold !== undefined &&
740+
editingPendingTask.messageId !== message.messageId
741+
) {
742+
try {
743+
await removeThreadOutboxMessage(editingPendingTask);
744+
} catch (error) {
745+
// The replacement is already durable and the old entry remains
746+
// held, so neither copy can lose or double-send the content.
747+
console.warn("[new-task] failed to remove retargeted held task", error);
748+
}
749+
}
722750
} catch (error) {
723751
Alert.alert(
724752
"Could not queue task",
@@ -770,7 +798,7 @@ export function NewTaskDraftScreen(props: {
770798
initialAttachments: draft.attachments,
771799
...(editingPendingTask
772800
? {
773-
turnMetadata: {
801+
turnMetadata: retryTurnMetadata ?? {
774802
threadId: editingPendingTask.threadId,
775803
commandId: editingPendingTask.commandId,
776804
messageId: editingPendingTask.messageId,
@@ -830,8 +858,9 @@ export function NewTaskDraftScreen(props: {
830858

831859
const isAndroid = Platform.OS === "android";
832860
const canStart =
833-
Boolean(flow.selectedProject) &&
861+
Boolean(flow.selectedProject?.workspaceRoot?.trim()) &&
834862
Boolean(flow.selectedModel) &&
863+
providerUnavailable === null &&
835864
flow.prompt.trim().length > 0 &&
836865
isIncomingShareReady &&
837866
!isImportingShare &&
@@ -997,6 +1026,11 @@ export function NewTaskDraftScreen(props: {
9971026
) : null}
9981027
<View className="pb-1">{workspaceControls}</View>
9991028

1029+
<ProviderUnavailableNotice
1030+
provider={flow.selectedProviderStatus}
1031+
reason={providerAdmissionReason}
1032+
/>
1033+
10001034
<ComposerSurface
10011035
animateLayout={false}
10021036
style={{
@@ -1062,7 +1096,15 @@ export function NewTaskDraftScreen(props: {
10621096
</ComposerToolbarScroller>
10631097
<ComposerToolbarButton
10641098
accessibilityLabel={
1065-
flow.submitting ? "Starting task" : environmentConnected ? "Start task" : "Queue task"
1099+
providerUnavailable
1100+
? `Start unavailable. ${providerUnavailable.detail}`
1101+
: flow.submitting
1102+
? "Starting task"
1103+
: !canStart
1104+
? "Start unavailable. Add a message and complete the task setup."
1105+
: environmentConnected
1106+
? "Start task"
1107+
: "Queue task. The environment is disconnected; this task will remain queued."
10661108
}
10671109
disabled={!canStart}
10681110
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)