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
7 changes: 7 additions & 0 deletions devlog/_plan/260907_axis1_bugfixes/021_source_review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# wp1 source review

Three bounded patches implemented with regression coverage. Hooke independently passed the physical-response quota observer wiring; Tesla independently passed quota/recovery security and source review with zero blockers. Version comparator and status/doctor projections inspected by main. All source workers report no local suite/typecheck/build execution.

Quota source: #3809, Éverton Toffanetto; Co-authored-by included in f215f79b4. Version report: garysassano; Reported-by included in f91e3953a. Recovery report: Hu9956; Reported-by included in recovery commit.

Source-only checks: git diff --check and documentation fence/whitespace inspection. These do not prove runtime correctness. wp2 final cumulative hosted CI is still mandatory. Final CI dispatch includes Windows because ordinary PR workflow omits it. No release/deploy workflow will be dispatched.
13 changes: 13 additions & 0 deletions docs-site/src/content/docs/ko/reference/cli/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,19 @@ dedicated-provider history도 포함됩니다. 상태를 백업하고 이 전체

### `ocx status [--json]`

status와 `ocx doctor`는 현재 CLI와 실행 중인 프록시의 버전을 비교합니다. CLI가 더 새로우면
원하는 최신 설치로 프록시를 재시작하십시오. 백그라운드 서비스라면 `ocx service repair`를
실행합니다(`ocx service restart`는 별칭). 프록시가 더 새로우면 CLI를 업그레이드하거나
`PATH`가 원하는 설치를 가리키도록 수정하십시오. 이 진단은 서비스를 복구하거나 요청 허용
여부를 바꾸지 않습니다.

버전 문자열이 같거나 어느 쪽이 `unknown` / `0.0.0`이면 경고하지 않으며, 프록시 버전이 없어도
경고하지 않습니다. doctor는 placeholder를 버전 일치로 확정하지 않습니다. 엄격한 SemVer로
해석할 수 없는 서로 다른 문자열이나 build metadata만 다른 버전은 어느 쪽이 오래됐다고
단정하지 않는 중립 경고를 표시합니다. 공백을 제거하거나 앞의 `v`를 정규화하지 않습니다.
JSON의 `versionSkew`에도 같은 안내가 들어가며 필드는 `cliVersion`, `proxyVersion`, `skewed`,
`warning` 그대로입니다.

읽기 전용 진단 요약을 출력합니다. 프록시 PID, `/healthz` 도달 가능 여부, 대시보드 URL,
설정 경로, 기본 공급자, Codex 자동 시작 설정, 서비스 상태, shim 상태, 그리고 마스킹된
실제로 적용되는 Codex 홈이 포함됩니다. 명시적이고 높은 신뢰도의 Windows Orca 런타임 홈 시그니처만
Expand Down
14 changes: 12 additions & 2 deletions docs-site/src/content/docs/reference/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,17 @@ response is not cacheable. Post-commit and 5xx errors keep the no-resend path.

When encrypted agent-task recovery refuses a routed task, its existing 400 error
can include a bounded `recovery_reason`: `unsupported_envelope`,
`admission_denied`, `recovery_unavailable`, `caller_cancelled`, or `input_changed`.
The field is omitted when no classified recovery result exists.
`admission_denied`, `recovery_unavailable`, `caller_cancelled`, `input_changed`,
`recovery_http_rejected`, `recovery_timeout`, `recovery_aborted`,
`recovery_transport_error`, or `recovery_invalid_output`.
HTTP rejection requires an observed non-success response. Invalid output includes
invalid UTF-8, oversized bodies, malformed or incomplete recovery streams, and
invalid or conflicting assignments. A caller's cancellation takes precedence over
an owned deadline, which takes precedence over decode/transport failures.
`recovery_aborted` describes a shared recovery cancelled independently of that caller.
Shared-flight waiters receive the same underlying failure unless individually cancelled;
only successful plaintext is cached. Diagnostics contain no upstream error or payload text.
The field is omitted when no classified recovery result exists, and existing combo
branches that return the original target failure keep that response.
`recovery_unavailable` includes cache/singleflight capacity and does not prove an
upstream request was attempted. No retry or broader envelope acceptance is enabled.
20 changes: 17 additions & 3 deletions docs-site/src/content/docs/reference/cli/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,19 @@ are left in place.

### `ocx status [--json]`

Status and `ocx doctor` compare this CLI's version with the running proxy. If the CLI is newer,
restart the proxy using the intended current installation; for a background service, run
`ocx service repair` (`ocx service restart` is an alias). If the proxy is newer, upgrade the CLI
or resolve `PATH` to the intended installation. These diagnostics do not repair the service or
change whether requests are allowed.

Identical version strings and the `unknown` / `0.0.0` placeholders suppress the warning, as does
an absent proxy version. Doctor does not report placeholders as a confirmed match. Different
strings still produce a neutral warning when they cannot be strictly parsed as SemVer or differ
only in build metadata; neither side is called older. Versions are not trimmed and a leading `v`
is not normalized. JSON exposes the same advice in `versionSkew`, whose fields remain
`cliVersion`, `proxyVersion`, `skewed`, and `warning`.

Print a read-only diagnostic summary: proxy PID, `/healthz` reachability, dashboard URL, config path,
default provider, Codex autostart setting, service state, shim state, and the redacted effective Codex
home. Only the explicit, high-confidence Windows Orca runtime-home signature adds an actionable App-home
Expand Down Expand Up @@ -261,9 +274,10 @@ bundled Bun paths are deliberately rediscovered after upgrades instead of being
Definitions installed before this change still carry the old versioned paths and cannot migrate
themselves — once the old executable is deleted, no opencodex code runs to fix it. Run
`ocx service repair` once after upgrading; after that, each service start follows the launcher.
An already-running proxy is not replaced by an external upgrade: restart the service (or run
`ocx service repair`) so the new build serves, and treat a CLI/proxy version mismatch warning as
exactly that signal.
An already-running proxy is not replaced by an external upgrade: when the installed CLI is newer
than the running proxy, restart the service (or run `ocx service repair`) so the new build serves.
If the proxy is newer instead, check the CLI installation and `PATH` as described under
[`ocx status`](#ocx-status---json).

| Subcommand | Action |
| --- | --- |
Expand Down
13 changes: 13 additions & 0 deletions docs-site/src/content/docs/ru/reference/cli/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,19 @@ ocx eject back

### `ocx status [--json]`

Status и `ocx doctor` сравнивают версии текущего CLI и работающего прокси. Если CLI новее,
перезапустите прокси из нужной актуальной установки. Для фоновой службы используйте
`ocx service repair` (`ocx service restart` — её псевдоним). Если новее прокси, обновите CLI
или исправьте `PATH`, чтобы он указывал на нужную установку. Диагностика не ремонтирует службу
и не меняет разрешение запросов.

При одинаковых строках версий, значениях `unknown` / `0.0.0` или отсутствии версии прокси
предупреждение подавляется. Doctor не считает placeholder подтверждённым совпадением.
Разные строки, которые нельзя строго разобрать как SemVer, и версии, отличающиеся только
build metadata, вызывают нейтральное предупреждение без указания устаревшей стороны.
Пробелы не удаляются, префикс `v` не нормализуется. JSON содержит ту же рекомендацию в
`versionSkew` с прежними полями `cliVersion`, `proxyVersion`, `skewed` и `warning`.

Печатает read-only диагностическую сводку: PID прокси, достижимость `/healthz`, URL дашборда,
путь к конфигу, провайдера по умолчанию, настройку автозапуска Codex, состояние службы, состояние
shim'а и redacted effective Codex home. Только явная и высокоуверенная сигнатура mismatch
Expand Down
4 changes: 2 additions & 2 deletions src/cli/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1157,11 +1157,11 @@ export async function runDoctor(args: string[] = []): Promise<void> {
// No extra probe -- findLiveProxy already carried the version back.
{
const { packageVersion } = await import("./help");
const { computeVersionSkew } = await import("./version-skew");
const { computeVersionSkew, isConfirmedVersionMatch } = await import("./version-skew");
const skew = computeVersionSkew(packageVersion(), live?.version);
if (skew.skewed && skew.warning) {
console.log(`!! ${skew.warning}`);
} else if (skew.proxyVersion !== null) {
} else if (isConfirmedVersionMatch(skew)) {
console.log(`ok ocx ${skew.cliVersion} matches the running proxy`);
}
}
Expand Down
39 changes: 36 additions & 3 deletions src/cli/version-skew.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* CLI-versus-proxy version skew (#2701).
* CLI-versus-proxy version skew (#2701, #3464).
*
* The reported failure: `ocx` on PATH is an older install than the running proxy, so its
* help describes commands the proxy does not have and its output describes a different
Expand All @@ -9,6 +9,7 @@
* comparison instead of reimplementing it -- two diagnostics disagreeing about whether an
* install is stale would be worse than neither reporting it.
*/
import { parseStrictSemver, type StrictSemver } from "../lib/strict-semver";

/** Placeholder versions that mean "unknown", not "different". */
const PLACEHOLDERS = new Set(["unknown", "0.0.0"]);
Expand All @@ -22,6 +23,30 @@ export interface VersionSkew {
readonly warning: string | null;
}

/** Suppressed comparisons are not confirmed matches, even when both placeholders agree. */
export function isConfirmedVersionMatch(skew: VersionSkew): boolean {
return skew.proxyVersion === skew.cliVersion && !PLACEHOLDERS.has(skew.cliVersion);
}

/** SemVer precedence ignores build metadata; raw equality is handled separately. */
function compareVersions(cli: StrictSemver, proxy: StrictSemver): number {
for (let i = 0; i < cli.core.length; i++) {
if (cli.core[i]! !== proxy.core[i]!) return cli.core[i]! > proxy.core[i]! ? 1 : -1;
}
if (cli.prerelease.length === 0) return proxy.prerelease.length === 0 ? 0 : 1;
if (proxy.prerelease.length === 0) return -1;
for (let i = 0; i < Math.max(cli.prerelease.length, proxy.prerelease.length); i++) {
const left = cli.prerelease[i];
const right = proxy.prerelease[i];
if (left === right) continue;
if (left === undefined) return -1;
if (right === undefined) return 1;
if (typeof left !== typeof right) return typeof left === "bigint" ? -1 : 1;
return left > right ? 1 : -1;
}
return 0;
}

/**
* Compare the running CLI against the live proxy.
*
Expand All @@ -36,11 +61,19 @@ export function computeVersionSkew(cliVersion: string, proxyVersion: string | un
if (proxy === null || PLACEHOLDERS.has(proxy) || PLACEHOLDERS.has(cliVersion) || proxy === cliVersion) {
return { cliVersion, proxyVersion: proxy, skewed: false, warning: null };
}
const cliSemver = parseStrictSemver(cliVersion);
const proxySemver = parseStrictSemver(proxy);
const order = cliSemver && proxySemver ? compareVersions(cliSemver, proxySemver) : 0;
const advice = order > 0
? "the running proxy is older than this CLI. Restart the proxy using the intended current installation. "
+ "For a background service, run ocx service repair (ocx service restart is an alias)."
: order < 0
? "this ocx on PATH is older than the running proxy. Upgrade the CLI or resolve PATH to the intended installation."
: "the versions differ, but neither can be identified as older. Check which installations the CLI and proxy use.";
return {
cliVersion,
proxyVersion: proxy,
skewed: true,
warning: `CLI ${cliVersion} does not match the running proxy ${proxy} — this ocx on PATH is stale. `
+ "Its help and features describe a different build. Reinstall, or run the proxy's own binary.",
warning: `CLI ${cliVersion} does not match the running proxy ${proxy} — ${advice}`,
};
}
29 changes: 22 additions & 7 deletions src/lib/bounded-body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,13 +212,28 @@ export async function readBoundedResponseBytes(
}
}

function decodeUtf8(chunks: readonly Uint8Array[], fatal: boolean): string {
// Mark only exceptions thrown by our decoder, preserving their identity and TypeError contract.
// Timeout-path flushing may fail too; retain that origin so callers do not lose the deadline.
const decodeFailures = new WeakMap<object, "invalid_utf8" | "timeout">();

export function boundedBodyDecodeFailure(error: unknown): "invalid_utf8" | "timeout" | undefined {
return error !== null && typeof error === "object" ? decodeFailures.get(error) : undefined;
}

function decodeUtf8(chunks: readonly Uint8Array[], fatal: boolean, timedOut = false): string {
const decoder = new TextDecoder("utf-8", { fatal });
let text = "";
for (const chunk of chunks) text += decoder.decode(chunk, { stream: true });
// Flush an incomplete trailing UTF-8 sequence deterministically.
text += decoder.decode();
return text;
try {
let text = "";
for (const chunk of chunks) text += decoder.decode(chunk, { stream: true });
// Flush an incomplete trailing UTF-8 sequence deterministically.
text += decoder.decode();
return text;
} catch (error) {
if (error !== null && typeof error === "object") {
decodeFailures.set(error, timedOut ? "timeout" : "invalid_utf8");
}
throw error;
}
}

/**
Expand Down Expand Up @@ -297,7 +312,7 @@ export async function readBoundedResponseBody(
"TimeoutError",
);
return {
text: decodeUtf8([retained.subarray(0, retainedBytes)], options.fatalUtf8 === true),
text: decodeUtf8([retained.subarray(0, retainedBytes)], options.fatalUtf8 === true, true),
truncated: true,
timedOut: true,
totalTimedOut: outcome === TOTAL_TIMEOUT,
Expand Down
57 changes: 43 additions & 14 deletions src/server/responses/agent-task-recovery-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@ const MAX_CACHE_BYTES = 8 * 1024 * 1024;
const MAX_CONCURRENT_RECOVERIES = 32;
const CACHE_TTL_MS = 15 * 60 * 1000;

export type AgentTaskRecoveryResolutionFailureReason =
| "recovery_unavailable"
| "caller_cancelled"
| "recovery_http_rejected"
| "recovery_timeout"
| "recovery_aborted"
| "recovery_transport_error"
| "recovery_invalid_output";

/** Shared flights carry bounded failures; only successful plaintext enters the cache. */
export type AgentTaskRecoveryResolution =
| { readonly recovered: true; readonly assignment: string }
| { readonly recovered: false; readonly reason: AgentTaskRecoveryResolutionFailureReason };

interface RecoveryCacheEntry {
assignment: string;
bytes: number;
Expand All @@ -11,7 +25,7 @@ interface RecoveryCacheEntry {

interface RecoveryFlight {
controller: AbortController;
promise: Promise<string | null>;
promise: Promise<AgentTaskRecoveryResolution>;
waiters: number;
settled: boolean;
}
Expand Down Expand Up @@ -63,7 +77,7 @@ function insertRecoveryCacheEntry(key: string, assignment: string, maxEntries: n
function startRecoveryFlight(
key: string,
maxEntries: number,
request: (signal: AbortSignal) => Promise<string | null>,
request: (signal: AbortSignal) => Promise<AgentTaskRecoveryResolution>,
): RecoveryFlight | null {
const active = RECOVERY_FLIGHTS.get(key);
if (active) return active;
Expand All @@ -72,15 +86,15 @@ function startRecoveryFlight(
const controller = new AbortController();
const flight: RecoveryFlight = {
controller,
promise: Promise.resolve(null),
promise: Promise.resolve({ recovered: false, reason: "recovery_unavailable" }),
waiters: 0,
settled: false,
};
flight.promise = request(controller.signal)
.then((assignment) => {
if (!assignment || controller.signal.aborted) return null;
insertRecoveryCacheEntry(key, assignment, maxEntries);
return assignment;
.then((result): AgentTaskRecoveryResolution => {
if (controller.signal.aborted) return { recovered: false, reason: "recovery_aborted" };
if (result.recovered) insertRecoveryCacheEntry(key, result.assignment, maxEntries);
return result;
})
.finally(() => {
flight.settled = true;
Expand All @@ -93,14 +107,14 @@ function startRecoveryFlight(
async function waitForRecoveryFlight(
flight: RecoveryFlight,
abortSignal?: AbortSignal,
): Promise<string | null> {
if (abortSignal?.aborted) return null;
): Promise<AgentTaskRecoveryResolution> {
if (abortSignal?.aborted) return { recovered: false, reason: "caller_cancelled" };
flight.waiters += 1;
let onAbort: (() => void) | undefined;
try {
if (!abortSignal) return await flight.promise;
const cancelled = new Promise<null>((resolve) => {
onAbort = () => resolve(null);
const cancelled = new Promise<AgentTaskRecoveryResolution>((resolve) => {
onAbort = () => resolve({ recovered: false, reason: "caller_cancelled" });
abortSignal.addEventListener("abort", onAbort, { once: true });
if (abortSignal.aborted) onAbort();
});
Expand All @@ -120,12 +134,27 @@ export async function resolveCachedAgentTaskRecovery(
request: (signal: AbortSignal) => Promise<string | null>,
abortSignal?: AbortSignal,
): Promise<string | null> {
if (abortSignal?.aborted) return null;
const result = await resolveCachedAgentTaskRecoveryWithResult(key, maxEntries, async signal => {
const assignment = await request(signal);
return assignment
? { recovered: true, assignment }
: { recovered: false, reason: "recovery_unavailable" };
}, abortSignal);
return result.recovered ? result.assignment : null;
}

export async function resolveCachedAgentTaskRecoveryWithResult(
key: string,
maxEntries: number,
request: (signal: AbortSignal) => Promise<AgentTaskRecoveryResolution>,
abortSignal?: AbortSignal,
): Promise<AgentTaskRecoveryResolution> {
if (abortSignal?.aborted) return { recovered: false, reason: "caller_cancelled" };
sweepRecoveryCache(Date.now(), maxEntries);
const cached = RECOVERY_CACHE.get(key)?.assignment;
if (cached) return cached;
if (cached) return { recovered: true, assignment: cached };
const flight = startRecoveryFlight(key, maxEntries, request);
return flight ? waitForRecoveryFlight(flight, abortSignal) : null;
return flight ? waitForRecoveryFlight(flight, abortSignal) : { recovered: false, reason: "recovery_unavailable" };
}

export function discardCachedAgentTaskRecovery(key: string): void {
Expand Down
Loading
Loading