From 2e1c211e96bcf7fd7edfcd2c96799a37ef5dbb88 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 14:52:39 +0900 Subject: [PATCH 1/6] fix(ci): give the Windows leg the budgets and the crash retry it never had Six shard failures in three groups (#2152). None came from main..dev; all three needed a different answer, and none of them was skipping a test that can actually run. Group 1, budgets. watchdogMs is a FLOOR, not a multiplier, so a case calling watchdogMs(30_000) still got exactly 30s -- 'Restore truth' failed at 30,147ms. Windows CI now floors at 45s, under the lane's own 60s per-test timeout so a hung test stays bounded. 'A-reduced' was misread in the issue: its 79,978ms was elapsed time against a 150s ceiling, so the outer budget was never the constraint. The real failure was Fixture.request's unscaled 10s AbortSignal, which aborted the case from inside. It is scaled now like every neighbouring budget. 'E' does not start ocx at all. Its lock holder released after a fixed 3s busy wait, and on a Windows shard the contender's process spawn can outlast that -- the parent then sees 'acquired' where it demands 'busy', which reads as a broken exclusion invariant rather than a hold that expired early. The release-marker handshake still ends the hold early everywhere else; only the ceiling moved. Group 2, skip guard. The issue says an unprivileged Windows user cannot create symlinks, but the GitHub runner can -- so canSymlink was true, the cases ran, and they failed on how the preflight reads mode and access through a Windows symlink. Two neighbouring cases in the same file already skip on process.platform === "win32"; these three now use that same guard, and keep the capability check for unprivileged POSIX. Group 3, crash retry. A Bun panic is a crash in the interpreter, not a test result. The macOS leg has carried a crash-signature retry for this; the Windows shards, a separate matrix job with their own one-shot command, had none. They now use the same wrapper, extended with panic(thread since that is the signature this leg actually printed. An assertion failure returns its status immediately and is never retried. What this cannot prove locally: whether 45s is sufficient under real Windows shard contention, the actual skip result on the runner, and PIPESTATUS behavior in Git Bash. Those need a Windows CI dispatch, which is the evidence to look for on this PR. --- .github/workflows/ci.yml | 26 +++++++++++++++++++++++- tests/codex-composed-acceptance.test.ts | 19 +++++++++++++++-- tests/helpers/ci-watchdog.ts | 9 +++++++- tests/helpers/codex-write-lock-child.ts | 8 +++++++- tests/update-npm-cache-preflight.test.ts | 20 +++++++++++++++--- 5 files changed, 74 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02344a0aa2..5872136ecf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -611,7 +611,31 @@ jobs: # the only one left on Bun's 5s default, and it is the slowest hardware on the board. # Three of its failures were the default firing on tests that had not hung — the # composed-acceptance cases spawn a real `ocx start` and were still working at 41s. - run: bun test --isolate --timeout 60000 tests --shard=${{ matrix.shard }}/4 + # + # The retry is the same one the macOS leg already carries, for the same reason: a Bun + # runtime panic is a crash in the interpreter, not a test result, and failing the shard + # on it reports a defect this repository does not have (#2152). An ordinary assertion + # failure returns its status immediately — only the crash signatures below are retried, + # and only once, so a genuinely broken build cannot be retried into green. + shell: bash + run: | + set +e + set -uo pipefail + suite_log="$(mktemp -t ocx-windows-suite.XXXXXX)" + for attempt in 1 2; do + bun test --isolate --timeout 60000 tests --shard=${{ matrix.shard }}/4 2>&1 | tee "$suite_log" + suite_status="${PIPESTATUS[0]}" + if [ "$suite_status" -eq 0 ]; then + exit 0 + fi + if ! grep -Eqi 'oh no: Bun has crashed|Segmentation fault at address|Illegal instruction|Bus error|panic\(thread' "$suite_log"; then + echo "::error::Windows shard ${{ matrix.shard }}/4 failed on attempt ${attempt} (exit ${suite_status}); assertion failures are not retried." + exit "$suite_status" + fi + echo "::warning::Bun runtime crash in Windows shard ${{ matrix.shard }}/4 (exit ${suite_status}, attempt ${attempt})." + done + echo "::error::Bun runtime crash repeated on Windows shard ${{ matrix.shard }}/4; failing after one retry." + exit 1 - name: CLI help smoke run: bun run src/cli/index.ts help diff --git a/tests/codex-composed-acceptance.test.ts b/tests/codex-composed-acceptance.test.ts index ded130e17e..29950f68b1 100644 --- a/tests/codex-composed-acceptance.test.ts +++ b/tests/codex-composed-acceptance.test.ts @@ -266,7 +266,11 @@ class Fixture { runtime: RuntimeRecord, path: string, init: RequestInit = {}, - timeoutMs = 10_000, + // Scaled like every other budget in this file. This one was left unscaled, and it is what + // actually failed `A-reduced` on Windows: the case has a 150 s ceiling and reported ~80 s + // elapsed, so the outer budget was never the constraint — a single request hit this fixed + // 10 s AbortSignal and aborted the case from inside (#2152). + timeoutMs = watchdogMs(10_000), ): Promise<{ status: number; body: Record }> { const response = await fetch(`http://127.0.0.1:${runtime.port}${path}`, { ...init, @@ -619,7 +623,18 @@ describe("WP13 composed toggle acceptance", () => { const release = join(fx.root, "release"); const holder = Bun.spawn([process.execPath, lockChildPath], { cwd: repoRoot, - env: { ...fx.env(fx.homeA, fx.userprofileA), OCX_LOCK_CHILD_PAYLOAD: JSON.stringify({ timeoutMs: 5_000, holdMarker: held, releaseMarker: release }) }, + // The hold has to outlast the contender's process spawn, which is the slow part on a + // Windows shard. The release marker below still ends it early everywhere else, so this + // is a ceiling rather than a sleep the test pays for. + env: { + ...fx.env(fx.homeA, fx.userprofileA), + OCX_LOCK_CHILD_PAYLOAD: JSON.stringify({ + timeoutMs: 5_000, + holdMarker: held, + releaseMarker: release, + holdMs: watchdogMs(3_000), + }), + }, stdout: "pipe", stderr: "pipe", }); fx.children.push(holder); diff --git a/tests/helpers/ci-watchdog.ts b/tests/helpers/ci-watchdog.ts index f09713dc98..a794d7fe3e 100644 --- a/tests/helpers/ci-watchdog.ts +++ b/tests/helpers/ci-watchdog.ts @@ -11,7 +11,14 @@ * hung test, not to assert latency. Local behaviour is unchanged. Bun's own * per-test timeout (`--timeout`, 60 s on CI) would pre-empt a 30 s watchdog, * so the lane timeout and this floor move together. + * + * Windows needs a higher floor still. Its shards run four Bun pools on one runner, and process + * spawn there is slower than on the POSIX lanes to begin with — a `ocx restore --json` child + * that finishes comfortably elsewhere was observed failing the 30 s floor at 30,147 ms (#2152). + * 45 s keeps the watchdog meaningful while staying under the lane's own 60 s per-test timeout, + * so a genuinely hung test is still bounded by something rather than running to the ceiling. */ export function watchdogMs(base: number): number { - return process.env.CI === "true" ? Math.max(base, 30_000) : base; + if (process.env.CI !== "true") return base; + return Math.max(base, process.platform === "win32" ? 45_000 : 30_000); } diff --git a/tests/helpers/codex-write-lock-child.ts b/tests/helpers/codex-write-lock-child.ts index be61dc84be..0ab0549f99 100644 --- a/tests/helpers/codex-write-lock-child.ts +++ b/tests/helpers/codex-write-lock-child.ts @@ -17,6 +17,7 @@ const payload = JSON.parse(process.env.OCX_LOCK_CHILD_PAYLOAD ?? "{}") as { timeoutMs?: number; holdMarker?: string; releaseMarker?: string; + holdMs?: number; }; const admitted = { authoritySnapshotId: "authority-child" } as AdmissionSnapshot; @@ -40,7 +41,12 @@ const result = await withCodexWriteLock( // an unheld lock and saw `acquired` where the test demands `busy`, which // reads exactly like a broken exclusion invariant rather than a late marker. writeFileSync(payload.holdMarker, "held"); - const until = Date.now() + 3_000; + // The release marker is the real signal; this is only the ceiling for how long we wait + // to see it. Three seconds was enough where the contender starts quickly, but on a + // Windows shard the contender's process spawn can outlast the hold — the holder then + // releases first and the parent sees `acquired` where it demands `busy`, which reads as + // a broken exclusion invariant rather than as a hold that expired too early (#2152). + const until = Date.now() + (payload.holdMs ?? 3_000); while (Date.now() < until) { if (payload.releaseMarker && Bun.file(payload.releaseMarker).size > 0) break; } diff --git a/tests/update-npm-cache-preflight.test.ts b/tests/update-npm-cache-preflight.test.ts index 520c0d6b6a..e22d72cfee 100644 --- a/tests/update-npm-cache-preflight.test.ts +++ b/tests/update-npm-cache-preflight.test.ts @@ -27,6 +27,20 @@ const canSymlink = (() => { } })(); +/** + * Windows needs its own guard, separate from `canSymlink`. + * + * The capability probe answers "may this user create a symlink", and on a GitHub-hosted + * Windows runner the answer is YES — so these cases ran and then failed in the fixture with + * `cache_entry_inaccessible`, because what actually differs there is how the preflight reads + * mode and access through a Windows symlink, not whether the link can be made (#2152). + * + * Two neighbouring cases in this file already skip on `process.platform === "win32"` for the + * same reason, so this reuses that guard rather than inventing a second mechanism. The + * capability check stays: an unprivileged POSIX-like environment still skips honestly. + */ +const WINDOWS = process.platform === "win32"; + function tempRoot(name: string): string { const root = join(tmpdir(), `ocx-cache-preflight-${name}-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`); mkdirSync(root, { recursive: true }); @@ -67,7 +81,7 @@ describe("npm cache access pre-flight", () => { } }); - test.skipIf(!canSymlink)("lstats normal nested symlinks but never traverses their targets", () => { + test.skipIf(WINDOWS || !canSymlink)("lstats normal nested symlinks but never traverses their targets", () => { const cache = tempRoot("symlink-cache"); const missingTarget = join(tempRoot("symlink-target"), "does-not-exist"); const npx = join(cache, "_npx"); @@ -79,7 +93,7 @@ describe("npm cache access pre-flight", () => { expect(inspectNpmCacheDirectory(cache)).toEqual({ ok: true, reason: "cache_accessible" }); }); - test.skipIf(!canSymlink)("a foreign-owned nested symlink does not block the update", () => { + test.skipIf(WINDOWS || !canSymlink)("a foreign-owned nested symlink does not block the update", () => { // The distinction that decides whether this feature is usable. A real npm cache is full of // symlinks below _npx/node_modules/.bin, and their owner is irrelevant because we never // follow them. Rejecting on ownership before skipping the link would abort updates for @@ -164,7 +178,7 @@ describe("npm cache access pre-flight", () => { })).toEqual({ ok: false, reason: "worker_output_malformed" }); }); - test.skipIf(!canSymlink)("a cache root symlinked to another volume is inspected, not rejected", () => { + test.skipIf(WINDOWS || !canSymlink)("a cache root symlinked to another volume is inspected, not rejected", () => { // Pointing ~/.npm at another volume is ordinary npm configuration. Rejecting it outright was // the same class of false positive as failing on a large cache: it blocks updates for users // whose setup is fine. The root is resolved once; nested links are still never followed. From 491e106bdf851d2ab90c38ba6911848612e473d8 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 14:57:05 +0900 Subject: [PATCH 2/6] feat(gui): show and filter intercepted helper requests in Logs The Codex App now sends gpt-5.6-luna helper requests on every message and every turn completion, not only for title generation (#2157). That traffic originates in the App, so stopping it is not ours to do; what is ours is letting the operator tell it apart from their own work. #2166 landed the backend half. shadowCallRewrittenFrom already reaches /api/logs -- requestLogDto spreads the whole entry -- but the GUI's LogEntry type omitted it, so TypeScript and the UI ignored a field that was sitting right there in the payload. Adds the type field, a row badge naming the original helper model, and an "Intercepted helpers only" filter following the same local-state plus predicate-conjunct shape the surface filter already uses. The label is deliberately narrow. The marker is written only when Shadow Call Intercept actually rewrote the request, so a helper request that was not intercepted -- interception off, no replacement model, or a slug the matcher does not recognize -- carries no marker and is indistinguishable from ordinary traffic here. "Helper traffic" or "hide agent traffic" would promise a classification this data cannot support. --- gui/src/i18n/de.ts | 2 ++ gui/src/i18n/en.ts | 2 ++ gui/src/i18n/fr.ts | 2 ++ gui/src/i18n/ja.ts | 2 ++ gui/src/i18n/ko.ts | 2 ++ gui/src/i18n/ru.ts | 2 ++ gui/src/i18n/tr.ts | 2 ++ gui/src/i18n/zh-TW.ts | 2 ++ gui/src/i18n/zh.ts | 2 ++ gui/src/pages/Logs.tsx | 35 ++++++++++++++++-- gui/tests/logs-auto-refresh.test.tsx | 53 ++++++++++++++++++++++++++++ 11 files changed, 104 insertions(+), 2 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 113fc419d2..3fa2de1c65 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -638,6 +638,8 @@ export const de: Record = { "logs.filter.surface.claude": "Claude", "logs.filter.surface.codex": "Codex", "logs.filter.surface.grok": "Grok", + "logs.filter.interceptedHelpersOnly": "Nur abgefangene Helfer", + "logs.badge.interceptedHelper": "Abgefangener Helfer · {model}", "logs.filter.conversation.label": "Konversation", "logs.filter.conversation.placeholder": "Konversations-ID einfügen", "logs.filter.conversation.clear": "Löschen", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 997fcdc806..46a0e6fcc6 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -671,6 +671,8 @@ export const en = { "logs.filter.surface.claude": "Claude", "logs.filter.surface.codex": "Codex", "logs.filter.surface.grok": "Grok", + "logs.filter.interceptedHelpersOnly": "Intercepted helpers only", + "logs.badge.interceptedHelper": "Intercepted helper · {model}", "logs.filter.conversation.label": "Conversation", "logs.filter.conversation.placeholder": "Paste conversation id", "logs.filter.conversation.clear": "Clear", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 112fd7ad2a..dea8476cc2 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -652,6 +652,8 @@ export const fr: Record = { "logs.filter.surface.claude": "Claude", "logs.filter.surface.codex": "Codex", "logs.filter.surface.grok": "Grok", + "logs.filter.interceptedHelpersOnly": "Assistants interceptés uniquement", + "logs.badge.interceptedHelper": "Assistant intercepté · {model}", "logs.filter.conversation.label": "Conversation", "logs.filter.conversation.placeholder": "Coller l’ID de conversation", "logs.filter.conversation.clear": "Effacer", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 69fdac1c93..8c1c22fa7a 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -614,6 +614,8 @@ export const ja: Record = { "logs.filter.surface.claude": "Claude", "logs.filter.surface.codex": "Codex", "logs.filter.surface.grok": "Grok", + "logs.filter.interceptedHelpersOnly": "インターセプトされたヘルパーのみ", + "logs.badge.interceptedHelper": "インターセプト済みヘルパー · {model}", "logs.filter.conversation.label": "会話", "logs.filter.conversation.placeholder": "会話 ID を貼り付け", "logs.filter.conversation.clear": "クリア", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 9f491a1efc..0547ca5524 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -657,6 +657,8 @@ export const ko: Record = { "logs.filter.surface.claude": "Claude", "logs.filter.surface.codex": "Codex", "logs.filter.surface.grok": "Grok", + "logs.filter.interceptedHelpersOnly": "가로챈 헬퍼만", + "logs.badge.interceptedHelper": "가로챈 헬퍼 · {model}", "logs.filter.conversation.label": "대화", "logs.filter.conversation.placeholder": "대화 ID 붙여넣기", "logs.filter.conversation.clear": "지우기", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 00aa9cb06b..e765961261 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -655,6 +655,8 @@ export const ru: Record = { "logs.filter.surface.claude": "Claude", "logs.filter.surface.codex": "Codex", "logs.filter.surface.grok": "Grok", + "logs.filter.interceptedHelpersOnly": "Только перехваченные помощники", + "logs.badge.interceptedHelper": "Перехваченный помощник · {model}", "logs.filter.conversation.label": "Диалог", "logs.filter.conversation.placeholder": "Вставьте ID диалога", "logs.filter.conversation.clear": "Сбросить", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 743be2af77..52a4f194d1 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -662,6 +662,8 @@ export const tr: Record = { "logs.filter.surface.claude": "Claude", "logs.filter.surface.codex": "Codex", "logs.filter.surface.grok": "Grok", + "logs.filter.interceptedHelpersOnly": "Yalnizca yakalanan yardimcilar", + "logs.badge.interceptedHelper": "Yakalanan yardimci · {model}", "logs.filter.conversation.label": "Sohbet", "logs.filter.conversation.placeholder": "Sohbet ID'sini yapıştırın", "logs.filter.conversation.clear": "Temizle", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index c195aead79..9d3eccb28b 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -509,6 +509,8 @@ export const zhTW: Record = { "logs.filter.surface.claude": "Claude", "logs.filter.surface.codex": "Codex", "logs.filter.surface.grok": "Grok", + "logs.filter.interceptedHelpersOnly": "僅已攔截的輔助請求", + "logs.badge.interceptedHelper": "已攔截輔助請求 · {model}", "logs.filter.conversation.label": "對話", "logs.filter.conversation.placeholder": "貼上對話 ID", "logs.filter.conversation.clear": "清除", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 7dd4beb860..d404893e21 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -650,6 +650,8 @@ export const zh: Record = { "logs.filter.surface.claude": "Claude", "logs.filter.surface.codex": "Codex", "logs.filter.surface.grok": "Grok", + "logs.filter.interceptedHelpersOnly": "仅已拦截的辅助请求", + "logs.badge.interceptedHelper": "已拦截辅助请求 · {model}", "logs.filter.conversation.label": "会话", "logs.filter.conversation.placeholder": "粘贴会话 ID", "logs.filter.conversation.clear": "清除", diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index 55a7ef634d..129ce95301 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -133,6 +133,15 @@ export interface LogEntry { provider: string; surface?: LogSurface; conversationId?: string; + /** + * The original helper model, when Shadow Call Intercept rewrote this request. + * + * Present ONLY for an intercepted request. A helper request that was not intercepted -- + * interception off, no replacement model, or a slug the matcher does not recognize -- is + * indistinguishable here from ordinary traffic, which is why the filter below says + * "intercepted" rather than "helper". + */ + shadowCallRewrittenFrom?: string; requestedEffort?: string; effectiveEffort?: string; reasoningWireField?: string; @@ -174,6 +183,7 @@ function validCachedLogs(cached: LogEntry[] | null): LogEntry[] | null { || typeof entry.provider !== "string" || typeof entry.status !== "number" || typeof entry.durationMs !== "number" + || (entry.shadowCallRewrittenFrom !== undefined && typeof entry.shadowCallRewrittenFrom !== "string") || !validCachedRouteDecision(entry.routeDecision) ) { return null; @@ -355,6 +365,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { const [autoRefresh, setAutoRefresh] = useState(true); const [detail, setDetail] = useState(null); const [surfaceFilter, setSurfaceFilter] = useState("all"); + const [interceptedHelpersOnly, setInterceptedHelpersOnly] = useState(false); const [conversationFilter, setConversationFilter] = useState(""); const [conversationQueryHash, setConversationQueryHash] = useState(); const scrollContainerRef = useRef(null); @@ -472,6 +483,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { const filteredLogs = logs.filter(log => ( logMatchesSurface(log, surfaceFilter) + && (!interceptedHelpersOnly || Boolean(log.shadowCallRewrittenFrom)) && (!conversationQuery || matchesLogConversationId(log.conversationId, conversationQuery, conversationQueryHash)) )); const conversationTotals = conversationQuery ? summarizeFilteredLogs(filteredLogs) : null; @@ -566,6 +578,20 @@ export default function Logs({ apiBase }: { apiBase: string }) { ))} + {/* + "Intercepted", not "helper". The marker only exists when Shadow Call Intercept + rewrote the request, so a helper request that was not intercepted looks exactly like + ordinary traffic here. A broader label would promise a classification this data + cannot support. + */} +