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
2 changes: 1 addition & 1 deletion apps/mobile/src/state/pending-task-editor-writes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ vi.mock("./thread-outbox", async () => {
harness.manager = createThreadOutboxManager({
registry: appAtomRegistry,
storage: {
load: async () => ({ messages: [], errors: [] }),
load: async () => ({ messages: [], status: "complete" }),
write: async (message) => {
const pending = harness.writeGates.shift();
if (pending) {
Expand Down
46 changes: 24 additions & 22 deletions apps/mobile/src/state/thread-outbox-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export interface ThreadOutboxManagerOptions {
readonly warn?: (message: string, error: unknown) => void;
}

type ThreadOutboxHydrationResult =
| { readonly status: "complete" }
| { readonly status: "incomplete"; readonly error: ThreadOutboxManagerError };

export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
const queuedMessagesByThreadKeyAtom = Atom.make<
Record<string, ReadonlyArray<QueuedThreadMessage>>
Expand All @@ -46,7 +50,7 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
((message: string, error: unknown) => {
console.warn(message, error);
});
let loadPromise: Promise<boolean> | null = null;
let loadPromise: Promise<ThreadOutboxHydrationResult> | null = null;
let mutationQueue: Promise<void> = Promise.resolve();
// Monotonic per-message write counter. Every accepted write (enqueue publish
// or update) bumps it, so a writer that captured a revision before slow work
Expand All @@ -72,14 +76,14 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
options.registry.set(queuedMessagesByThreadKeyAtom, groupQueuedThreadMessages(messages));
};

// Readable messages can be used after a partial load. Only a complete load
// returns true, so cleanup cannot delete files owned by unreadable records.
// A later call retries failed reads without replacing live message objects.
const load = (): Promise<boolean> => {
// Readable messages enter the atom even when ownership is incomplete.
// Cleanup and account changes require a complete inventory. Failed reads
// can be retried without replacing live messages or retaining old snapshots.
const load = (): Promise<ThreadOutboxHydrationResult> => {
if (loadPromise !== null) {
return loadPromise;
}
loadPromise = serialize(async () => {
loadPromise = serialize<ThreadOutboxHydrationResult>(async () => {
const result = await options.storage.load();
const current = currentMessages();
const currentIds = new Set(current.map((message) => message.messageId));
Expand All @@ -89,23 +93,21 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
// Accepted edits and removals win over a later disk read. Retaining
// current objects also keeps retries from restarting the drain.
if (recovered.length > 0) setMessages([...recovered, ...current]);
if (result.errors.length > 0) {
throw new AggregateError(result.errors, "Some queued messages could not be read.");
if (result.status === "incomplete") {
throw result.error;
}
return true;
return { status: "complete" };
}).catch((cause) => {
loadPromise = null;
warn(
"[thread-outbox] failed to load persisted messages",
new ThreadOutboxManagerError({
operation: "load",
environmentId: null,
threadId: null,
messageId: null,
cause,
}),
);
return false;
const error = new ThreadOutboxManagerError({
operation: "load",
environmentId: null,
threadId: null,
messageId: null,
cause,
});
warn("[thread-outbox] failed to load persisted messages", error);
return { status: "incomplete", error };
});
return loadPromise;
};
Expand Down Expand Up @@ -288,8 +290,8 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
const persisted = await options.storage
.load()
.then((result) => {
if (result.errors.length > 0) {
throw new AggregateError(result.errors, "Some queued messages could not be read.");
if (result.status === "incomplete") {
throw result.error;
}
return result.messages;
})
Expand Down
4 changes: 3 additions & 1 deletion apps/mobile/src/state/thread-outbox-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ import type { DraftComposerAttachment } from "../lib/composerImages";
import { scopedThreadKey } from "../lib/scopedEntities";
import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode";

// Keep current writes until a compatible native baseline includes the v4 reader.
// Keep v3 writes until each platform has a new native runtime with embedded
// v4 readers and storage-failure guards. The runtime must exclude old binaries
// whose embedded JavaScript cannot recover file-backed drafts after OTA rollback.
const THREAD_OUTBOX_SCHEMA_VERSION = 3;
const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000;

Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/state/thread-outbox-removal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ vi.mock("./thread-outbox", async () => {
harness.manager = createThreadOutboxManager({
registry: appAtomRegistry,
storage: {
load: async () => ({ messages: [], errors: [] }),
load: async () => ({ messages: [], status: "complete" }),
write: async () => undefined,
remove: async () => undefined,
},
Expand Down
33 changes: 21 additions & 12 deletions apps/mobile/src/state/thread-outbox-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,12 @@ export class ThreadOutboxStorageError extends Schema.TaggedErrorClass<ThreadOutb
}
}

export interface ThreadOutboxLoadResult {
export type ThreadOutboxLoadResult = {
readonly messages: ReadonlyArray<QueuedThreadMessage>;
readonly errors: ReadonlyArray<ThreadOutboxStorageError>;
}
} & (
| { readonly status: "complete" }
| { readonly status: "incomplete"; readonly error: ThreadOutboxStorageError }
);

export interface ThreadOutboxStorage {
readonly load: () => Promise<ThreadOutboxLoadResult>;
Expand Down Expand Up @@ -100,17 +102,24 @@ export const expoThreadOutboxStorage: ThreadOutboxStorage = {
);
}
}
if (errors.length > 0) {
throw new AggregateError(errors, "Some queued messages could not be read.");
}
return { status: "complete", messages };
} catch (cause) {
throw new ThreadOutboxStorageError({
operation: "load",
environmentId: null,
threadId: null,
messageId: null,
fileName: null,
cause,
});
return {
status: "incomplete",
messages,
error: new ThreadOutboxStorageError({
operation: "load",
environmentId: null,
threadId: null,
messageId: null,
fileName: null,
cause,
}),
};
}
return { messages, errors };
},
write: async (message) => {
const fileName = messageFileName(message.messageId);
Expand Down
Loading
Loading