Skip to content

Commit 4ad2aad

Browse files
committed
fix: restore UI component fork patches
- OnboardingWizard: combine fork credential binding with upstream gate - AgentConfigForm: restore useFeatures hook and currentEnv state - CompanySettingsSidebar: restore useBoardCapabilities and exposedSurfaces - CompanySettingsNav: combine fork surface filtering with upstream hiddenSettings - SummarySlotCard: restore useFeatures hook - IssueChatThread: combine upstream import pause with fork custom display - AgentConfigForm tests: fix buildCurrentBoardAccess pattern - CompanySettingsSidebar tests: restore fork test patterns - Additional adapter-utils and page fixes
1 parent 2ba4e36 commit 4ad2aad

13 files changed

Lines changed: 675 additions & 375 deletions

packages/adapter-utils/src/acpx-engine/execute.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3898,24 +3898,52 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
38983898
cancelTurnReason: null,
38993899
};
39003900
await emitPhase("ensure_session", ensureSessionPhaseStart, "failed");
3901-
const { classified, message } = await emitAcpxFailure({
3901+
const { classified, message, childStderrTail } = await emitAcpxFailure({
39023902
ctx,
39033903
prepared,
39043904
err,
39053905
phase: "ensure_session",
39063906
});
3907+
const causeMessage =
3908+
typeof classified.errorMeta?.causeMessage === "string"
3909+
? classified.errorMeta.causeMessage
3910+
: null;
3911+
// The composed message becomes the tenant-facing, persisted
3912+
// errorMessage/summary. The raw child stderr (and cause) can carry
3913+
// secrets (tokens, Authorization headers, api-key values), so redact
3914+
// before surfacing. The full unredacted tail still reached the internal
3915+
// run log and the acpx.error event above.
3916+
const composedMessage = redactSensitiveText(
3917+
composeSessionInitFailureMessage({
3918+
message,
3919+
causeMessage,
3920+
childStderrTail,
3921+
}),
3922+
);
3923+
// Auto-selected (non-explicit) runs throw so the adapter's execute()
3924+
// wrapper catches it and falls back to the proven CLI lane. Explicit
3925+
// engine=acp runs keep the terminal failed result (no silent lane switch).
3926+
if (allowSessionInitLaneFallback(ctx)) {
3927+
throw new AcpxSessionInitError({
3928+
message: composedMessage,
3929+
errorCode: classified.errorCode ?? "acpx_session_init_failed",
3930+
errorMeta: classified.errorMeta,
3931+
childStderrTail,
3932+
cause: err,
3933+
});
3934+
}
39073935
capturedResult = {
39083936
exitCode: 1,
39093937
signal: null,
39103938
timedOut: false,
3911-
errorMessage: message,
3939+
errorMessage: composedMessage,
39123940
...classified,
39133941
...billingFields,
39143942
...referencedProjectStagingFailuresField,
39153943
model: prepared.requestedModel || null,
39163944
clearSession,
39173945
resultJson: { phase: "ensure_session" },
3918-
summary: message,
3946+
summary: composedMessage,
39193947
};
39203948
return settleFor("handshake", err);
39213949
}

packages/adapter-utils/src/execution-target.ts

Lines changed: 105 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2191,30 +2191,94 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
21912191
});
21922192
});
21932193

2194+
// In-memory per session/poll-loop instance: a fresh bridge (and thus a
2195+
// fresh watermark and failure streak) starts on every run, which is fine.
2196+
// There is nothing to recover across restarts, since the whole point of
2197+
// the watermark is to dedupe within a single still-running poll loop.
2198+
let lastDeliveredEventName: string | null = null;
2199+
let consecutivePollFailures = 0;
2200+
2201+
const logPollFailure = async (message: string) => {
2202+
consecutivePollFailures += 1;
2203+
await onLog(
2204+
"stderr",
2205+
`[paperclip] ACP process session bridge poll failed: ${message} ` +
2206+
`(attempt ${consecutivePollFailures}/${MAX_CONSECUTIVE_POLL_FAILURES})\n`,
2207+
);
2208+
// A single failed poll cycle (e.g. the directory listing itself
2209+
// failing, a single event file that will not read, or a malformed
2210+
// event body) is treated as transient: only tear the session down once
2211+
// MAX_CONSECUTIVE_POLL_FAILURES full cycles have failed back to back.
2212+
// Any cycle that completes without a failure resets the streak to 0.
2213+
if (consecutivePollFailures >= MAX_CONSECUTIVE_POLL_FAILURES) {
2214+
deliverRemoteEvent({ type: "error", message });
2215+
return true;
2216+
}
2217+
return false;
2218+
};
2219+
21942220
const poll = async () => {
21952221
if (stopping) return;
21962222
try {
2197-
// Read every file this tick fetched before this loop decides whether to
2198-
// keep polling. A `shutdownAck` can land in the same batch right after
2199-
// an `exit` event; deliver it too, so this tick never drops an
2200-
// already-fetched (and already-removed-from-disk) event.
2201-
const events = await readRemoteJsonFiles({ client, dir: eventsDir });
2223+
const { events, stoppedEarly } = await readRemoteJsonFiles({
2224+
client,
2225+
dir: eventsDir,
2226+
afterName: lastDeliveredEventName,
2227+
});
2228+
let midBatchFailure: string | null = null;
22022229
for (const event of events) {
2203-
const parsed = JSON.parse(event.body) as {
2230+
let parsed: {
22042231
type?: string;
22052232
stream?: "stdout" | "stderr";
22062233
data?: string;
22072234
code?: number | null;
22082235
signal?: string | null;
22092236
message?: string;
22102237
};
2211-
deliverRemoteEvent(parsed);
2238+
try {
2239+
parsed = JSON.parse(event.body) as typeof parsed;
2240+
} catch (error) {
2241+
const message = error instanceof Error ? error.message : String(error);
2242+
midBatchFailure = `failed to parse ACP process session event file ${event.name}: ${message}`;
2243+
break;
2244+
}
2245+
try {
2246+
deliverRemoteEvent(parsed);
2247+
} catch (error) {
2248+
const message = error instanceof Error ? error.message : String(error);
2249+
midBatchFailure = `failed to deliver ACP process session event file ${event.name}: ${message}`;
2250+
break;
2251+
}
2252+
// Only now that the event has actually been handed to the caller do
2253+
// we advance the watermark and attempt to remove the remote file. A
2254+
// throw above (parse or deliver) leaves both untouched, so the file
2255+
// is re-read from exactly this point on the next cycle and nothing
2256+
// already delivered is ever repeated.
2257+
lastDeliveredEventName = event.name;
2258+
const filePath = path.posix.join(eventsDir, event.name);
2259+
try {
2260+
await client.remove(filePath);
2261+
} catch (removeError) {
2262+
const removeMessage = removeError instanceof Error ? removeError.message : String(removeError);
2263+
await onLog(
2264+
"stderr",
2265+
`[paperclip] ACP process session bridge failed to remove processed event file ${event.name}; ` +
2266+
`relying on the delivery watermark to avoid re-sending it: ${removeMessage}\n`,
2267+
);
2268+
}
2269+
}
2270+
if (midBatchFailure) {
2271+
if (await logPollFailure(midBatchFailure)) return;
2272+
} else if (stoppedEarly) {
2273+
const error = stoppedEarly.error;
2274+
const message = error instanceof Error ? error.message : String(error);
2275+
if (await logPollFailure(`failed to read ACP process session event file ${stoppedEarly.name}: ${message}`)) return;
2276+
} else {
2277+
consecutivePollFailures = 0;
22122278
}
22132279
} catch (error) {
22142280
const message = error instanceof Error ? error.message : String(error);
2215-
await onLog("stderr", `[paperclip] ACP process session bridge poll failed: ${message}\n`);
2216-
deliverRemoteEvent({ type: "error", message });
2217-
return;
2281+
if (await logPollFailure(message)) return;
22182282
} finally {
22192283
if (!stopping) {
22202284
schedulePoll();
@@ -2375,7 +2439,7 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
23752439
if (stopReadingForShutdownAck) return;
23762440
void (async () => {
23772441
try {
2378-
const events = await readRemoteJsonFiles({ client, dir: eventsDir });
2442+
const { events } = await readRemoteJsonFiles({ client, dir: eventsDir, afterName: null });
23792443
if (stopReadingForShutdownAck) return;
23802444
for (const event of events) {
23812445
try {
@@ -4470,11 +4534,39 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
44704534
if (method !== "GET" && method !== "HEAD" && typeof request.body === "string") {
44714535
forwardInit.body = request.body;
44724536
}
4473-
const response = await fetch(buildBridgeForwardUrl(hostApiUrl, request), forwardInit);
4537+
let response: Response;
4538+
try {
4539+
response = await fetch(buildBridgeForwardUrl(hostApiUrl, request), forwardInit);
4540+
} catch (error) {
4541+
// Map fetch failures to a faithful status the in-sandbox agent can act
4542+
// on, instead of letting them surface as an opaque generic 502. A
4543+
// timeout in particular is ambiguous on a mutating call ("did my write
4544+
// land?"), so it must be distinguishable -- otherwise the agent tends to
4545+
// confabulate an outcome.
4546+
const name = error instanceof Error ? error.name : "";
4547+
if (name === "TimeoutError" || name === "AbortError") {
4548+
return {
4549+
status: 504,
4550+
headers: { "content-type": "application/json" },
4551+
body: JSON.stringify({
4552+
error: "The Paperclip API did not respond within the bridge timeout. The request may or may not have been applied; re-read state before retrying.",
4553+
code: "bridge_upstream_timeout",
4554+
}),
4555+
};
4556+
}
4557+
return {
4558+
status: 502,
4559+
headers: { "content-type": "application/json" },
4560+
body: JSON.stringify({
4561+
error: `Bridge could not reach the Paperclip API: ${error instanceof Error ? error.message : String(error)}`,
4562+
code: "bridge_upstream_unreachable",
4563+
}),
4564+
};
4565+
}
44744566
if (emitDebugLog) {
44754567
await onLog(
44764568
"stdout",
4477-
`[paperclip] Bridge proxy response ${response.status} for ${method} ${request.path}${request.query ? `?${request.query}` : ""}\n`,
4569+
`[paperclip] Bridge proxy response ${response.status} for ${method} ${request.path}${request.query ? `?${request.query}` : ""} (url=${response.url || "-"} ct=${response.headers.get("content-type") ?? "-"} server=${response.headers.get("server") ?? "-"} xpb=${response.headers.get("x-powered-by") ?? "-"} redirected=${response.redirected})\n`,
44784570
);
44794571
}
44804572
// The host delivered response headers, so the response-body read starts after

0 commit comments

Comments
 (0)