Skip to content
10 changes: 9 additions & 1 deletion docs/operations/codexhost-console.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ Desktop Controller 在注入失败时仍会让 Codex 正常运行并在后台重

## 与运行中的 codexhost 连接

Launcher 启动的本地 Host Runtime 在 `127.0.0.1` 的随机端口开放控制通道,把端口与随机令牌写入 `<数据目录>/console/hosts/host-<进程号>.json`(`0600`),退出时删除。控制台读取该文件,把设置请求转发给 Host,由 Host 按 Codex 设置页相同的逻辑处理;只接受 `CONSOLE_HOST_METHODS` 列出的设置方法,不接受 Thread 或 Turn 操作。
Launcher 启动的本地 Host Runtime 在 `127.0.0.1` 的随机端口开放控制通道,把端口与随机令牌写入 `<数据目录>/console/hosts/host-<进程号>.json`(`0600`),退出时删除。控制台读取该文件,把设置请求转发给 Host,由 Host 按 Codex 设置页相同的逻辑处理;只接受 `CONSOLE_HOST_METHODS` 列出的方法,不转发任意 Thread 或 Turn 请求。该名单以设置方法为主,另有 `codexhost/archive/health` 与 `codexhost/archive/force` 两个归档维护入口(见「归档体检与强制归档」)。

- codexhost 未运行:连接页显示离线的插件列表并可修改安装路径;远程连接、账号、会话导入提示先启动 codexhost。
- codexhost 运行但控制通道不可用(例如旧版本):提示重新启动 codexhost。
Expand Down Expand Up @@ -138,6 +138,14 @@ codexhost 未运行时,“连接”页列出已安装的插件(读取 `manif
- “导出诊断包”下载 JSON:状态、Codex 与 codexhost 版本、最近 5 次启动记录、注入状态,以及最新两个 Host Runtime 日志的末尾 32 KiB。用户主目录替换为 `~`,其他内容(如日志中的项目路径)不做处理,分享前请检查。
- “提交 Issue”打开 GitHub 新建 Issue 页面,预填版本、系统、状态和错误,不包含日志。

## 归档体检与强制归档

外部会话归档时,Host 会在安全且空闲的情况下异步释放它占用的 Harness 会话;释放失败不会阻断之后的读取、取消归档或删除。两个入口用于排查和修复归档异常:

- `GET /api/archive/health`(Host 方法 `codexhost/archive/health`)返回 `inconsistentThreads`、`officialProcesses` 与 `summary`。外部会话按释放失败、会话不可用、未提交记录、内存孤儿分类;已归档记录与正常的预热草稿不会被报告。官方进程扫描只匹配真正的 Codex CLI 可执行文件,跳过 Codex.app、其辅助进程以及 Host 自身的整棵进程树,并给出对应平台的终止提示。`summary.processScan` 为 `ok` / `failed` / `skipped`:扫描无法执行时明确标记,不会显示成“没有会话被占用”。
- `POST /api/archive/force`(Host 方法 `codexhost/archive/force`,参数 `{ "threadId": "..." }`)只修改映射存储里的归档标记并卸载内存实例,不删除任何转录本、Turn 或原生会话文件。运行中的 Turn、后台任务或待处理的审批/提问会以 `-32072` 拒绝;无法确认会话关闭时返回 `-32075` 并保留实例,避免同一原生会话出现第二个写入者;映射存储读取失败返回 `-32081`,归档写入失败同样返回 `-32081` 且不卸载实例。带首尾空白的线程 ID 一律以 `-32602` 拒绝。
- 控制台 REST 将这些错误映射为真实状态码:`-32602` → 404,`-32072` → 409,其余 → 500;成功统一为 200。

## 其他命令

`codexhost inspect --json` 输出 Codex Desktop 安装信息与 Launcher 运行状态;未找到 Codex Desktop 时在 `desktopError` 中说明,不以失败退出。
16 changes: 16 additions & 0 deletions openspec/specs/external-thread-list-archive-routing/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,22 @@ Host Runtime SHALL route `thread/archive` and `thread/unarchive` by persisted Th
- **THEN** Host SHALL treat the operation as an idempotent success
- **AND** it SHALL preserve response-before-notification ordering without changing Native state

### Requirement: Archive health inspection and safe forced archive
Host Runtime SHALL provide archive health inspection and safe force-archive entry points. It SHALL inspect inconsistent or failed external threads, detect live codex CLI processes holding official sessions, and provide safe forced archive for external threads without modifying native history or deleting turn records.

#### Scenario: Archive health inspection identifies inconsistent threads and processes
- **WHEN** Desktop or Console requests `codexhost/archive/health`
- **THEN** Host SHALL inspect MappingStore and loaded runtime instances for release failures, session unavailabilities, and orphaned instances
- **AND** it SHALL scan active processes to detect live codex CLI processes, returning PID, command line, and remediation instructions
- **AND** it SHALL return summary metrics covering total inconsistent threads, occupied official sessions, and recoverable external threads

#### Scenario: Safe forced archive updates external thread without data loss
- **WHEN** Desktop or Console requests `codexhost/archive/force` for an idle External Thread
- **THEN** Host SHALL update MappingStore `archived=true`, detach the loaded instance, and emit `thread/archived`
- **AND** it SHALL NOT delete any transcripts, turns, or native session files
- **AND** requests targeting non-existent threads SHALL fail with `-32602`
- **AND** requests targeting threads with active turns or background work SHALL fail with `-32072`

### Requirement: Official Thread management remains transparent
Host SHALL preserve original official Codex behavior for Thread list and management requests that do not target persisted External resources. Internal official list subrequests SHALL use an isolated ID namespace, SHALL be bounded, and SHALL not alter unrelated official responses or notifications.

Expand Down
32 changes: 32 additions & 0 deletions packages/console-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,38 @@ export function startConsoleServer(options: ConsoleServerOptions): Promise<Runni
sendJson(response, 200, await overview());
return;
}
if (route === "GET /api/archive/health") {
try {
const reply = await options.host.request("codexhost/archive/health", {});
if ("error" in reply) {
sendJson(response, 500, { error: reply.error.message });
} else {
sendJson(response, 200, reply.result);
}
} catch (error) {
sendJson(response, error instanceof HostUnavailableError ? 503 : 500, {
error: error instanceof Error ? error.message : String(error),
});
}
return;
}
if (route === "POST /api/archive/force") {
try {
const reply = await options.host.request("codexhost/archive/force", body);
if ("error" in reply) {
const status =
reply.error.code === -32602 ? 404 : reply.error.code === -32072 ? 409 : 500;
sendJson(response, status, { error: reply.error.message });
} else {
sendJson(response, 200, reply.result);
}
} catch (error) {
sendJson(response, error instanceof HostUnavailableError ? 503 : 500, {
error: error instanceof Error ? error.message : String(error),
});
}
return;
}
if (
route === "POST /api/update/check" ||
route === "GET /api/update/status" ||
Expand Down
74 changes: 74 additions & 0 deletions packages/console-server/test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,4 +370,78 @@ describe("console server", () => {
expect(request).toHaveBeenCalledWith("codexhost/update/start", {});
expect(updates.start).not.toHaveBeenCalled();
});

it("routes archive health and force-archive requests to the Host", async () => {
const healthResult = {
inconsistentThreads: [],
officialProcesses: [],
summary: {
totalInconsistent: 0,
officialOccupied: 0,
externalRecoverable: 0,
processScan: "ok" as const,
},
};
const forceResult = { ok: true, threadId: "t-1", archived: true };
const request = vi.fn(async (method: string) => {
if (method === "codexhost/archive/health") return { result: healthResult };
if (method === "codexhost/archive/force") return { result: forceResult };
throw new Error(`Unexpected method: ${method}`);
});
const { base } = await start({ host: { available: async () => true, request } });

// GET /api/archive/health
const healthResponse = await fetch(`${base}/api/archive/health`);
expect(healthResponse.status).toBe(200);
expect(await healthResponse.json()).toEqual(healthResult);
expect(request).toHaveBeenCalledWith("codexhost/archive/health", {});

// POST /api/archive/force
const forceResponse = await fetch(`${base}/api/archive/force`, {
method: "POST",
headers: { origin: base, ...CHANGE },
body: JSON.stringify({ threadId: "t-1" }),
});
expect(forceResponse.status).toBe(200);
expect(await forceResponse.json()).toEqual(forceResult);
expect(request).toHaveBeenCalledWith("codexhost/archive/force", { threadId: "t-1" });
});

it("maps archive Host errors to real HTTP statuses", async () => {
const request = vi.fn(
async (
method: string,
): Promise<{ result: unknown } | { error: { code: number; message: string } }> => {
throw new Error(`Unexpected method: ${method}`);
},
);
const { base } = await start({ host: { available: async () => true, request } });
const force = (threadId: string) =>
fetch(`${base}/api/archive/force`, {
method: "POST",
headers: { origin: base, ...CHANGE },
body: JSON.stringify({ threadId }),
});
const health = () => fetch(`${base}/api/archive/health`);

request.mockResolvedValueOnce({ error: { code: -32602, message: "not found" } });
const missing = await force("missing");
expect(missing.status).toBe(404);
expect(await missing.json()).toEqual({ error: "not found" });

request.mockResolvedValueOnce({ error: { code: -32072, message: "thread is busy" } });
const busy = await force("busy");
expect(busy.status).toBe(409);
expect(await busy.json()).toEqual({ error: "thread is busy" });

request.mockResolvedValueOnce({ error: { code: -32075, message: "close unconfirmed" } });
const unconfirmed = await force("stuck");
expect(unconfirmed.status).toBe(500);
expect(await unconfirmed.json()).toEqual({ error: "close unconfirmed" });

request.mockResolvedValueOnce({ error: { code: -32081, message: "store unreadable" } });
const unreadable = await health();
expect(unreadable.status).toBe(500);
expect(await unreadable.json()).toEqual({ error: "store unreadable" });
});
});
113 changes: 102 additions & 11 deletions packages/host-runtime/src/app-server-host.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {
archiveForceParamsSchema,
archiveHealthParamsSchema,
DELEGATION_READ_METHOD,
delegationReadParamsSchema,
EXTERNAL_THREAD_PREWARM_PARAM,
Expand Down Expand Up @@ -59,6 +61,7 @@ import {
rewriteDelegationMentionText,
} from "./delegation-mention-rewrite.js";
import { managedDelegationSkillReference } from "./delegation-skill.js";
import { ArchiveHealthService, ArchiveHealthError } from "./archive-health-service.js";
import { AccountRateLimits } from "./codex-runtime/account-rate-limits.js";
import { NativeAccountObserver } from "./native-account-observer.js";
import { nativeThreadSupportsReferences } from "./native-thread-reference-capability.js";
Expand Down Expand Up @@ -640,6 +643,7 @@ export class AppServerHost {
#pendingDesktopQuestions = new Map<HostQuestionRequestId, PendingDesktopQuestion>();
#nextApprovalRequestId = HOST_APPROVAL_REQUEST_ID_MAX;
#nextQuestionRequestId = HOST_QUESTION_REQUEST_ID_MAX;
#archiveHealthService: ArchiveHealthService;
#delegationCoordinator: HarnessDelegationCoordinator;
#sessionImportRequests: SessionImportRequests | undefined;
#unregisterDelegationApi: (() => void) | undefined;
Expand Down Expand Up @@ -797,15 +801,20 @@ export class AppServerHost {
await this.#resolveDesktopQuestion(pending.interaction.interactionId);
}
},
canRelease: (thread) =>
!this.#hasRunningSubagents(thread.id) &&
!thread.session.hasBackgroundWork?.() &&
!this.#externalSteering.hasPending(thread.id) &&
!this.#pendingExternalCommandRequests.has(thread.id) &&
![...this.#pendingDesktopApprovals.values()].some(
(pending) => pending.thread === thread,
) &&
![...this.#pendingDesktopQuestions.values()].some((pending) => pending.thread === thread),
canRelease: this.#externalThreadReleasable,
},
});
this.#archiveHealthService = new ArchiveHealthService({
repository: this.#repository,
runtime: this.#externalRuntime,
canRelease: this.#externalThreadReleasable,
diagnose: (error) => this.#diagnose(error),
forgetThread: (threadId) => this.#routeObservationTracker.forgetThread(threadId),
notifyArchived: async (threadId) => {
await this.#writer.json({
method: "thread/archived",
params: { threadId },
});
},
});
this.#delegationCoordinator = new HarnessDelegationCoordinator({
Expand Down Expand Up @@ -1131,9 +1140,23 @@ export class AppServerHost {
timeoutMs,
);
});
// A console force-archive owns the Thread exactly like a Desktop request: it joins
// the per-Thread queue and idle-release operations before touching the Session. The
// queue key is the exact ID the handler will accept, so a padded ID cannot open a
// second lane for the same Thread; `#forceArchiveThread` rejects it as invalid.
const consoleThreadId =
method === "codexhost/archive/force" &&
isRecord(params) &&
typeof params.threadId === "string"
? params.threadId.trim()
: undefined;
this.#dispatchDesktopRequest(() =>
this.#guardReply(request, () =>
this.#handleDesktopRequest(request, Buffer.from(JSON.stringify(request))),
this.#desktopRequests.run(consoleThreadId, () =>
this.#externalRuntime.idleRelease.runOperation(consoleThreadId, () =>
this.#guardReply(request, () =>
this.#handleDesktopRequest(request, Buffer.from(JSON.stringify(request))),
),
),
),
);
try {
Expand Down Expand Up @@ -1733,6 +1756,14 @@ export class AppServerHost {
await this.#executeThreadCommand(request);
return;
}
if (request.method === "codexhost/archive/health") {
await this.#inspectArchiveHealth(request);
return;
}
if (request.method === "codexhost/archive/force") {
await this.#forceArchiveThread(request);
return;
}
if (request.method === "thread/list") {
let listRequest: DecodedThreadListRequest;
try {
Expand Down Expand Up @@ -3379,6 +3410,54 @@ export class AppServerHost {
await this.#writer.json(rpcEnvelope(request, { result: jsonValueSchema.parse(inspection) }));
}

async #inspectArchiveHealth(request: JsonRpcRequest): Promise<void> {
const params = archiveHealthParamsSchema.safeParse(request.params ?? {});
if (!params.success) {
await this.#writer.json(rpcError(request, -32602, "Invalid archive health params"));
return;
}
try {
const result = await this.#archiveHealthService.inspect(params.data);
await this.#writer.json(rpcEnvelope(request, { result: jsonValueSchema.parse(result) }));
} catch (error) {
this.#diagnose(error);
await this.#writer.json(
rpcError(
request,
error instanceof ArchiveHealthError ? error.code : -32603,
errorMessage(error),
),
);
}
}

async #forceArchiveThread(request: JsonRpcRequest): Promise<void> {
const rawThreadId = isRecord(request.params) ? request.params.threadId : undefined;
const params = archiveForceParamsSchema.safeParse(request.params);
if (!params.success) {
await this.#writer.json(rpcError(request, -32602, "Invalid force archive params"));
return;
}
// The per-Thread queue is keyed by the request's raw ID, so a padded ID would open a
// second lane for the same Thread. Only the canonical ID may operate.
if (rawThreadId !== params.data.threadId) {
await this.#writer.json(
rpcError(request, -32602, "Thread ID must not contain surrounding whitespace"),
);
return;
}
try {
const result = await this.#archiveHealthService.forceArchive(params.data.threadId);
await this.#writer.json(rpcEnvelope(request, { result: jsonValueSchema.parse(result) }));
} catch (error) {
if (error instanceof ArchiveHealthError) {
await this.#writer.json(rpcError(request, error.code, error.message));
} else {
await this.#writer.json(rpcError(request, -32603, errorMessage(error)));
}
}
}

async #inspectThreadUsage(request: JsonRpcRequest): Promise<void> {
const params = threadUsageInspectionParamsSchema.safeParse(request.params);
if (!params.success) {
Expand Down Expand Up @@ -5881,6 +5960,18 @@ export class AppServerHost {
}
}

/**
* What counts as an idle External Thread: one policy for both automatic idle release and
* archive-health forced archive, so the two cannot drift when a new pending state appears.
*/
readonly #externalThreadReleasable = (thread: ExternalThread): boolean =>
!this.#hasRunningSubagents(thread.id) &&
!thread.session.hasBackgroundWork?.() &&
!this.#externalSteering.hasPending(thread.id) &&
!this.#pendingExternalCommandRequests.has(thread.id) &&
![...this.#pendingDesktopApprovals.values()].some((pending) => pending.thread === thread) &&
![...this.#pendingDesktopQuestions.values()].some((pending) => pending.thread === thread);

#diagnose(error: unknown): void {
this.#options.diagnosticOutput.write(`codexhost Host Runtime: ${errorMessage(error)}\n`);
}
Expand Down
Loading
Loading