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
6 changes: 5 additions & 1 deletion apps/mobile/src/features/tasks/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ function isRetryableError(error: unknown): boolean {
return error.status >= 500 && error.status < 600;
}
if (error instanceof Error) {
if (error.name === "AbortError" || error.name === "TimeoutError") {
return true;
}
const message = error.message.toLowerCase();
if (message.includes("network")) return true;
if (message.includes("timeout")) return true;
Expand Down Expand Up @@ -719,9 +722,10 @@ export async function fetchSessionLogs(
offset: String(options.offset ?? 0),
});

// The server re-reads the whole log chain per page, so big runs need a generous budget.
const response = await authedFetch(
`${baseUrl}/api/projects/${projectId}/tasks/${taskId}/runs/${runId}/session_logs/?${params}`,
{ signal: createTimeoutSignal(10_000) },
{ signal: createTimeoutSignal(120_000) },
);

if (!response.ok) {
Expand Down
41 changes: 36 additions & 5 deletions apps/mobile/src/features/tasks/lib/cloudTaskStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -805,14 +805,18 @@ async function fetchTaskRunState(

/**
* Loads the historical log entries for the run, mirroring the desktop's
* dual-source strategy:
* 1. Try the paginated `session_logs/` API — the live source while a run
* strategy:
* 1. Prefer the presigned resume-chain `log_urls` (S3 NDJSON, oldest
* first) when the server provides them. Downloading straight from S3
* avoids the paginated API's per-page full-chain re-read, which times
* out on runs with very large histories.
* 2. Try the paginated `session_logs/` API — the live source while a run
* is active. For older / archived runs this can come back empty even
* though the canonical log exists on S3.
* 2. Fall back to the run's presigned `log_url` (S3 NDJSON), which is the
* 3. Fall back to the run's presigned `log_url` (S3 NDJSON), which is the
* canonical archive for completed runs.
*
* Returns `null` only when both sources fail outright (so the bootstrap can
* Returns `null` only when the sources fail outright (so the bootstrap can
* surface a retryable error). An empty paginated result is treated as "no
* data yet" and falls through to S3 — if S3 also has nothing we return the
* empty array so the snapshot can still flip the session to `"connected"`.
Expand All @@ -821,6 +825,13 @@ async function fetchHistoricalEntries(
watcher: WatcherState,
run: TaskRun,
): Promise<StoredLogEntry[] | null> {
if (run.log_urls?.length) {
const chainEntries = await fetchChainLogEntries(watcher, run.log_urls);
Comment thread
charlesvien marked this conversation as resolved.
if (watcher.stopped || watcher.failed) return null;
// An all-empty chain read falls through: a misdirected presigned 404 must not mask real data.
if (chainEntries?.length) return chainEntries;
}

const paginated = await fetchAllSessionLogs(watcher);
if (watcher.stopped || watcher.failed) return null;
if (paginated && paginated.length > 0) return paginated;
Expand All @@ -837,13 +848,33 @@ async function fetchHistoricalEntries(
return paginated ?? null;
}

async function fetchChainLogEntries(
watcher: WatcherState,
logUrls: string[],
): Promise<StoredLogEntry[] | null> {
const entries: StoredLogEntry[] = [];
for (const [index, logUrl] of logUrls.entries()) {
const chunk = await fetchS3LogEntries(watcher, logUrl);
if (watcher.stopped || watcher.failed) return null;
if (chunk === null) return null;
// An empty ancestor object is missing or expired; fall back rather than truncate history.
if (chunk.length === 0 && index < logUrls.length - 1) return null;
// Per-entry push: spreading a huge chunk into push() overflows the engine's argument limit.
for (const entry of chunk) {
entries.push(entry);
}
}
return entries;
}

async function fetchS3LogEntries(
watcher: WatcherState,
logUrl: string,
): Promise<StoredLogEntry[] | null> {
try {
// RN fetch buffers the whole body, so the budget must cover a full multi-hundred-MB download.
const response = await fetch(logUrl, {
signal: createTimeoutSignal(15_000),
signal: createTimeoutSignal(120_000),
});
if (response.status === 404) {
// No archived log yet for this run — not an error, just no data.
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/tasks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export interface TaskRun {
environment?: "local" | "cloud";
status: TaskRunStatus;
log_url: string;
// Presigned resume-chain log URLs, oldest first; absent on old servers, empty when presigning fails.
log_urls?: string[];
error_message: string | null;
reasoning_effort?: string | null;
output: Record<string, unknown> | null;
Expand Down
Loading
Loading