Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ published version with a date and open a fresh empty `[Unreleased]` above it.
- See `docs/migration/file-native-writeback.md` for the new read/edit/create/delete contract, schema discovery flow, writeback status surface, and per-adapter draft filename examples.
- See `docs/migration/new-json-callers.md` for the Cloud and demo caller scan that identifies `new.json` write paths to migrate.

### Fixed

- Direct HTTP write admission now honors `Retry-After` up to 30 seconds for `workspace_busy` / `write_admission_limit` responses in the SDK's existing four-attempt retry layer, while preserving the prior two-second cap for other retryable responses. Implicit defaults are three seconds for receipt polling and 90 seconds for admission; an explicit `writebackTimeoutMs` bounds each phase independently, with `0` leaving admission unbounded and receipt polling disabled.

### Added

- GitHub pull indexes now surface `merged` and `mergedAt` across webhook, direct, and bulk ingestion paths so time-windowed consumers can identify merged pull requests without opening every `meta.json`.
Expand Down
196 changes: 171 additions & 25 deletions packages/core/src/vfs-client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,33 @@ export class RelayfileWritebackPendingError extends RelayfileWritebackError {
}
}

export interface RelayfileWritebackAdmissionTimeoutErrorOptions {
provider: string;
operation: string;
path: string;
timeoutMs: number;
}

/** Direct HTTP admission never minted an op before the caller's admission deadline. */
export class RelayfileWritebackAdmissionTimeoutError extends RelayfileWritebackError {
readonly path: string;
readonly timeoutMs: number;

constructor(options: RelayfileWritebackAdmissionTimeoutErrorOptions) {
super({
provider: options.provider,
operation: options.operation,
cause: new Error(
`writeback_admission_timeout: no operation admitted for ${options.path} after ${options.timeoutMs}ms`
),
retryable: true
});
this.name = "RelayfileWritebackAdmissionTimeoutError";
this.path = options.path;
this.timeoutMs = options.timeoutMs;
}
}

export interface RelayfileWritebackTerminalErrorOptions {
provider: string;
operation: string;
Expand Down Expand Up @@ -148,7 +175,12 @@ export interface IntegrationClientOptions {
/**
* Max wait, in ms, for the Relayfile writeback worker to emit a receipt onto
* the just-written draft. Defaults to 3000ms. `0` means fire-and-forget — the
* client returns immediately without a receipt.
* client returns immediately without a receipt. In direct HTTP mode, an
* explicit value also bounds write admission as an independent phase. When
* omitted, receipt waiting defaults to 3s while admission defaults to 90s.
* Advertised delays are honored while they fit inside that admission budget;
* after three consecutive 30s delays, the deadline wins at t+90s before a
* fourth request.
*/
writebackTimeoutMs?: number;
/** Poll interval while waiting for a receipt. Default 250ms. */
Expand Down Expand Up @@ -197,6 +229,9 @@ export interface WritebackResult {
}

const DEFAULT_WRITEBACK_TIMEOUT_MS = 3_000;
const SDK_LEGACY_RETRY_MAX_DELAY_MS = 2_000;
const WORKSPACE_BUSY_RETRY_MAX_DELAY_MS = 30_000;
const DEFAULT_WRITEBACK_ADMISSION_TIMEOUT_MS = WORKSPACE_BUSY_RETRY_MAX_DELAY_MS * 3;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Three 30s Retry-After delays cannot reach the promised fourth request: the 90s deadline is armed before attempt one and fires when the third retry becomes due. Allow time for the final attempt (or explicitly clamp/rework the final retry) so a valid max-delay sequence is not aborted after only three attempts.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/vfs-client/index.ts, line 232:

<comment>Three 30s `Retry-After` delays cannot reach the promised fourth request: the 90s deadline is armed before attempt one and fires when the third retry becomes due. Allow time for the final attempt (or explicitly clamp/rework the final retry) so a valid max-delay sequence is not aborted after only three attempts.</comment>

<file context>
@@ -228,6 +229,7 @@ export interface WritebackResult {
 const DEFAULT_WRITEBACK_TIMEOUT_MS = 3_000;
 const SDK_LEGACY_RETRY_MAX_DELAY_MS = 2_000;
 const WORKSPACE_BUSY_RETRY_MAX_DELAY_MS = 30_000;
+const DEFAULT_WRITEBACK_ADMISSION_TIMEOUT_MS = WORKSPACE_BUSY_RETRY_MAX_DELAY_MS * 3;
 
 function isRecord(value: unknown): value is Record<string, unknown> {
</file context>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed against 495b329. This boundary is intentional: the 90s admission deadline is installed before attempt 1 and must win at t+90 when all three advertised delays equal the 30s safety maximum. The red regression pins attempts=3 plus cancellation of the pending fourth attempt, so no retry survives the caller deadline. Four total attempts remain proven for advertised delays that fit inside the deadline (t+0/5/10/15). Allowing the t+90 request would exceed the client bound and conflict with the daily-ship 90s deadline. The PR timeline documents this pathological boundary explicitly.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parent comment was wrong here: the 90s admission deadline is intentional and documented, and it’s meant to win at t+90. The PR also proves the four-attempt path for delays that stay within the deadline, so the missing t+90 attempt is expected behavior.


function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
Expand All @@ -207,6 +242,85 @@ function nonEmpty(value: string | undefined): string | undefined {
return trimmed ? trimmed : undefined;
}

function isWorkspaceBusyAdmission(value: unknown): boolean {
if (!isRecord(value) || value.code !== "workspace_busy") return false;
const reason = value.reason ?? (isRecord(value.details) ? value.details.reason : undefined);
return reason === "write_admission_limit";
}

function isWorkspaceBusyAdmissionError(value: unknown): value is RelayFileApiError {
return value instanceof RelayFileApiError && value.status === 429 && isWorkspaceBusyAdmission(value);
}

function retryAfterDelayMs(value: string): number | undefined {
const trimmed = value.trim();
if (/^\d+$/.test(trimmed)) {
const seconds = Number(trimmed);
if (Number.isFinite(seconds)) return seconds * 1_000;
}
const timestamp = Date.parse(trimmed);
return Number.isNaN(timestamp) ? undefined : Math.max(0, timestamp - Date.now());
}
Comment on lines +255 to +263

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation of retryAfterDelayMs uses Number.parseInt(value, 10) to check if the Retry-After header is a relative number of seconds. However, parseInt parses characters from left to right until it encounters a non-digit.

If the Retry-After header contains an ISO 8601 date string (e.g., "2026-06-19T12:01:04Z") or a date string that starts with a digit (e.g., "29 Oct 2026 07:28:00 GMT"), parseInt will successfully parse the leading digits (e.g., 2026 or 29) and treat them as a relative delay in seconds instead of parsing the string as a timestamp.

To prevent this, we should use a regular expression to ensure the entire string consists only of digits before parsing it as relative seconds.

function retryAfterDelayMs(value: string): number | undefined {
  const trimmed = value.trim();
  if (/^\d+$/.test(trimmed)) {
    return Number.parseInt(trimmed, 10) * 1_000;
  }
  const timestamp = Date.parse(trimmed);
  return Number.isNaN(timestamp) ? undefined : Math.max(0, timestamp - Date.now());
}

@khaliqgant khaliqgant Jul 14, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3c09d3d. Relative-seconds parsing now requires the entire trimmed header to be digits. Exact workspace-admission responses are normalized to a numeric delay before the SDK sees them, which also avoids the SDK misreading digit-leading date strings. Added a red/green regression using a digit-leading 1970 date (old behavior slept 1s per retry; fixed behavior retries immediately).


async function responseIsWorkspaceBusyAdmission(response: Response): Promise<boolean> {
if (response.status !== 429) return false;
try {
return isWorkspaceBusyAdmission(await response.clone().json());
} catch {
return false;
}
}

function responseWithRetryAfter(response: Response, retryAfter: string): Response {
const headers = new Headers(response.headers);
headers.set("Retry-After", retryAfter);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers
});
}

function isDirectWriteAdmissionRequest(input: RequestInfo | URL, init?: RequestInit): boolean {
const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
const url = input instanceof Request ? input.url : String(input);
try {
return method === "PUT" && new URL(url, "http://relayfile.invalid").pathname.endsWith("/fs/file");
} catch {
return false;
}
}
Comment on lines +284 to +292

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If input is a relative URL string (e.g., "/v1/workspaces/rw_7ccfea89/fs/file"), new URL(url) will throw a TypeError: Invalid URL because it lacks a base URL. This will cause the catch block to execute and return false, failing to recognize valid direct write admission requests.

To make this robust against relative URLs, we should provide a dummy base URL to the URL constructor. If the input URL is absolute, the base URL is ignored; if it is relative, it resolves correctly without throwing.

Suggested change
function isDirectWriteAdmissionRequest(input: RequestInfo | URL, init?: RequestInit): boolean {
const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
const url = input instanceof Request ? input.url : String(input);
try {
return method === "PUT" && new URL(url).pathname.endsWith("/fs/file");
} catch {
return false;
}
}
function isDirectWriteAdmissionRequest(input: RequestInfo | URL, init?: RequestInit): boolean {
const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
const url = input instanceof Request ? input.url : String(input);
try {
return method === "PUT" && new URL(url, "http://dummy.com").pathname.endsWith("/fs/file");
} catch {
return false;
}
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3c09d3d. Relative request URLs now resolve against a non-routable parsing-only base; absolute URLs remain unchanged.


/**
* The SDK owns the only retry loop. Raising its max delay lets workspace write
* admission honor Retry-After; this adapter keeps the SDK's previous 2s cap for
* every other retryable response so unrelated 429/5xx behavior does not move.
*/
function directRetryFetch(fetchImpl: typeof fetch): typeof fetch {
return async (input, init) => {
const response = await fetchImpl(input, init);
if (response.status !== 429 && (response.status < 500 || response.status > 599)) {
return response;
}
const retryAfter = response.headers.get("Retry-After");
if (!retryAfter) return response;
const delayMs = retryAfterDelayMs(retryAfter);
if (
isDirectWriteAdmissionRequest(input, init) &&
(await responseIsWorkspaceBusyAdmission(response))
) {
return delayMs === undefined
? response
: responseWithRetryAfter(response, String(Math.ceil(delayMs / 1_000)));
}

if (delayMs === undefined || delayMs <= SDK_LEGACY_RETRY_MAX_DELAY_MS) {
return response;
}
return responseWithRetryAfter(response, String(SDK_LEGACY_RETRY_MAX_DELAY_MS / 1_000));
};
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
function mountRootCandidate(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
Expand Down Expand Up @@ -304,12 +418,16 @@ function directClientConfig(
nonEmpty(process.env.RELAYFILE_WORKSPACE) ??
nonEmpty(process.env.RELAY_WORKSPACE_ID);
if (!baseUrl || !token || !workspaceId) return undefined;
const fetchImpl = directRetryFetch(client.fetchImpl ?? globalThis.fetch);
return {
workspaceId,
relayfile: new RelayFileClient({
baseUrl,
token,
fetchImpl: client.fetchImpl
fetchImpl,
retry: {
maxDelayMs: WORKSPACE_BUSY_RETRY_MAX_DELAY_MS
}
})
};
}
Expand Down Expand Up @@ -423,30 +541,53 @@ async function writeJsonFileViaRelayfileApi(
body: unknown,
direct: { workspaceId: string; relayfile: RelayFileClient }
): Promise<WritebackResult> {
const queued = await direct.relayfile.writeFile({
workspaceId: direct.workspaceId,
path: relayPath,
baseRevision: "*",
contentType: "application/json",
content: `${JSON.stringify(body, null, 2)}\n`
});
if (queued.writeback && !nonEmpty(queued.opId)) {
throw new RelayfileWritebackReceiptError({
provider,
operation,
opId: "(missing)",
reason: "queued writeback response did not include opId"
const timeoutMs = client.writebackTimeoutMs ?? DEFAULT_WRITEBACK_ADMISSION_TIMEOUT_MS;
const controller = timeoutMs > 0 ? new AbortController() : undefined;
const deadlineTimer = controller
? setTimeout(() => controller.abort(), timeoutMs)
: undefined;
let admitted = false;
try {
const queued = await direct.relayfile.writeFile({
workspaceId: direct.workspaceId,
path: relayPath,
baseRevision: "*",
contentType: "application/json",
content: `${JSON.stringify(body, null, 2)}\n`,
signal: controller?.signal
});
admitted = true;
if (deadlineTimer !== undefined) clearTimeout(deadlineTimer);
if (queued.writeback && !nonEmpty(queued.opId)) {
throw new RelayfileWritebackReceiptError({
provider,
operation,
opId: "(missing)",
reason: "queued writeback response did not include opId"
});
}
const receipt = queued.opId
? await waitForOperationReceipt(client, provider, operation, direct, queued.opId, relayPath)
: undefined;
return {
path: relayPath,
absolutePath: relayPath,
opId: queued.opId,
...(receipt ? { receipt } : {})
};
} catch (error) {
if (controller?.signal.aborted && !admitted && !(error instanceof RelayfileWritebackError)) {
throw new RelayfileWritebackAdmissionTimeoutError({
provider,
operation,
path: relayPath,
timeoutMs
});
}
throw error;
} finally {
if (deadlineTimer !== undefined) clearTimeout(deadlineTimer);
}
const receipt = queued.opId
? await waitForOperationReceipt(client, provider, operation, direct, queued.opId, relayPath)
: undefined;
return {
path: relayPath,
absolutePath: relayPath,
opId: queued.opId,
...(receipt ? { receipt } : {})
};
}

function toAbsolutePath(client: IntegrationClientOptions, relayPath: string): string {
Expand Down Expand Up @@ -612,7 +753,12 @@ export async function writeJsonFile(
if (cause instanceof RelayfileWritebackError) {
throw cause;
}
throw new RelayfileWritebackError({ provider, operation, cause, retryable: false });
throw new RelayfileWritebackError({
provider,
operation,
cause,
retryable: isWorkspaceBusyAdmissionError(cause)
});
}
}

Expand Down
Loading
Loading