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
64 changes: 64 additions & 0 deletions .specs/features/open-boot-latency/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# open-boot-latency

> Cortar o tempo fixo que o CodeDeck soma antes do harness subir no `codedeck open`, e medir o que sobra.

## Contexto medido (sessões 80c5 e c3ea, fake harness, daemon isolado)

| Cenário | Mediana | Mín |
|---|---:|---:|
| `open`, daemon quente, animação padrão | 930 ms | 924 ms |
| `open`, daemon quente, `--no-theme` | 189 ms | 151 ms |
| `open`, daemon frio, `--no-theme` | 724 ms | 538 ms |
| `ps` quente / frio | 105 / 305 ms | 91 / 295 ms |
| daemon spawn até aceitar socket | 79 ms | 61 ms |

- `playBoot` (`src/open/runtime.ts:424-460`) dorme 19 x 40 ms antes do spawn: ~740 ms fixos.
- `ensureDaemonStarted` (`src/daemon/ipc.ts:213-216`) dorme 200 ms antes da primeira checagem, com o socket pronto em ~60-80 ms.

## Requisitos

- **R1**: WHEN `playBoot` runs on a TTY THEN the sum of its animation delays SHALL be at most 200 ms.
- **R2**: WHEN `playBoot` runs on a TTY THEN its last logo frame SHALL be the fully resolved frame (progress 1), followed by the `role · model · effort` line and the `booting…` line.
- **R3**: WHILE stdout is not a TTY, `playBoot` SHALL write `renderBanner(...)` once and SHALL NOT wait on any timer.
- **R4**: WHEN `ensureDaemonStarted` spawns the daemon THEN it SHALL resolve within 50 ms after the socket starts accepting connections.
- **R5**: IF the socket never accepts THEN `ensureDaemonStarted` SHALL reject with `Failed to start daemon` no earlier than 6 s after the spawn.
- **R6**: WHEN a daemon already accepts on the socket THEN `ensureDaemonStarted` SHALL NOT spawn a process.

## Fora de escopo

- Rodar a animação em paralelo aos probes ou ao daemon.
- Lazy import dos comandos em `src/cli/index.ts` (~48 ms medidos).
- Cache de `detect()` no `doctor`.
- Mudanças no plugin, hooks e status line (dependem da medição D1).

## Tasks

| Task | Requisitos | Arquivos | Tests | Gate |
|---|---|---|---|---|
| T1 animação com orçamento | R1, R2, R3 | `src/open/runtime.ts`, `tests/open-boot.test.ts` | unit, fake timers | `npx vitest run tests/open-boot.test.ts` |
| T2 poll do daemon | R4, R5, R6 | `src/daemon/ipc.ts`, `tests/ipc-daemon-start.test.ts` | unit, socket unix real em dir temp, spawn mockado | `npx vitest run tests/ipc-daemon-start.test.ts` |
| D1 medição do boot do claude e probes | discovery | nenhum | none | relatório |

## Coverage matrix

| Camada | Tipo | Onde | Comando |
|---|---|---|---|
| `src/open/runtime.ts` (`playBoot`) | unit, fake timers | `tests/open-boot.test.ts` | `npx vitest run tests/open-boot.test.ts` |
| `src/daemon/ipc.ts` (`ensureDaemonStarted`) | unit, socket real | `tests/ipc-daemon-start.test.ts` | `npx vitest run tests/ipc-daemon-start.test.ts` |
| Regressão do open | unit existente | `tests/open-*.test.ts` | `npx vitest run tests/open-` |
| D1 | none (discovery) | - | - |

## Rodada 2 (medição D1, sessão 9e3f)

Com cache quente, `resolveBinary` custa 78 ms (`which` + `claude --version`, versão descartada) e `assertSupport` 176 ms (sobe o claude pra testar a flag), em todo `open`.

- **R7**: WHEN `resolveBinary` (claude launcher) finds `claude` on PATH THEN it SHALL return its absolute path without spawning the `claude` binary.
- **R8**: WHEN `assertSupport` already passed for the same binary (same realpath, size and mtime) THEN it SHALL NOT spawn `claude`.
- **R9**: WHEN no support record exists for the binary, or its realpath, size or mtime changed, THEN `assertSupport` SHALL run the probe.
- **R10**: IF the probe reports an unknown option THEN `assertSupport` SHALL throw the upgrade error and SHALL NOT record support.

| Task | Requisitos | Arquivos | Tests | Gate |
|---|---|---|---|---|
| T3 probes do claude | R7-R10 | `src/open/launchers/claude.ts`, `tests/open-claude-probes.test.ts` | unit, binário fake em PATH temp | `npx vitest run tests/open-claude-probes.test.ts tests/open-` |

Fora de escopo nesta rodada: launchers codex/opencode (mesmo padrão), cache do catálogo de modelos (367 ms só no cache frio), status line (138 ms a cada 2 s), boot real do claude (não medido: exige a conta autenticada).
7 changes: 4 additions & 3 deletions src/daemon/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,10 @@ export class IpcClient {
});
child.unref();

// Wait for socket to appear
for (let i = 0; i < 30; i++) {
await new Promise((r) => setTimeout(r, 200));
// Poll the socket until the daemon is ready or the startup budget expires.
const deadline = Date.now() + 6000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, Math.min(25, deadline - Date.now())));
if (await isDaemonRunning()) return;
}
throw new Error("Failed to start daemon");
Expand Down
87 changes: 85 additions & 2 deletions src/open/launchers/claude.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import { DISPATCHER_PRESET, type OrchestratorMode } from "../../config/orchestrator-mode.js";
import { getPaths } from "../../config/paths.js";
import { detectBinary } from "../../drivers/helpers.js";
import { getRegistry } from "../../drivers/registry.js";
import { autocompactArgs } from "../../core/autocompact.js";
Expand Down Expand Up @@ -42,6 +44,62 @@ const THEME_REF = `custom:${PLUGIN_NAME}:codedeck-ultra`;
export const CLAUDE_NOT_FOUND =
"Claude Code was not found on PATH. Install Claude Code and ensure `claude` is available.";
const execFileAsync = promisify(execFile);
const SUPPORT_CACHE_FILE = "claude-support.json";

interface SupportRecord {
size: number;
mtimeMs: number;
}

function findOnPath(command: string): string | undefined {
for (const entry of (process.env.PATH ?? "").split(path.delimiter)) {
const candidate = path.resolve(entry || process.cwd(), command);
try {
if (fs.statSync(candidate).isFile()) {
fs.accessSync(candidate, fs.constants.X_OK);
return candidate;
}
} catch {}
}
}

function readSupportRecords(): Record<string, SupportRecord> {
try {
const parsed: unknown = JSON.parse(
fs.readFileSync(path.join(getPaths().base, SUPPORT_CACHE_FILE), "utf8"),
);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
const records: Record<string, SupportRecord> = {};
for (const [binary, value] of Object.entries(parsed)) {
if (
typeof value === "object" && value !== null &&
typeof (value as SupportRecord).size === "number" &&
typeof (value as SupportRecord).mtimeMs === "number"
) {
records[binary] = value as SupportRecord;
}
}
return records;
} catch {
return {};
}
}

function writeSupportRecord(realpath: string, record: SupportRecord): void {
const cacheFile = path.join(getPaths().base, SUPPORT_CACHE_FILE);
const temporaryFile = `${cacheFile}.${process.pid}.${randomUUID()}.tmp`;
try {
fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
const records = readSupportRecords();
records[realpath] = record;
fs.writeFileSync(temporaryFile, JSON.stringify(records));
fs.renameSync(temporaryFile, cacheFile);
} catch {
try {
fs.rmSync(temporaryFile, { force: true });
} catch {}
}
}

/**
* The settings are built here, at launch, rather than shipped as a file, and
Expand Down Expand Up @@ -207,8 +265,11 @@ export async function preflightModel(model: string, fromConfig: boolean): Promis
* hoping it answers the same way.
*/
export async function resolveBinary(): Promise<string> {
// detectBinary reports failure in its result and never rejects, so there is
// nothing here to catch.
const onPath = findOnPath("claude");
if (onPath) return onPath;

// Keep detectBinary's login-shell fallback for installations that PATH
// lookup cannot see in this process.
const installation = await detectBinary("claude");
if (!installation.installed || !installation.path) {
throw new Error(CLAUDE_NOT_FOUND);
Expand All @@ -217,6 +278,25 @@ export async function resolveBinary(): Promise<string> {
}

export async function assertSupport(claudeBin: string, cwd: string): Promise<void> {
let identity: { realpath: string; record: SupportRecord } | undefined;
try {
// Version-manager shims (mise, asdf, volta) resolve to the wrapper, so changes
// to the real binary do not invalidate this record. This is accepted for a
// long-standing flag because the real launch still reports any error.
const realpath = fs.realpathSync(claudeBin);
const stat = fs.statSync(realpath);
identity = { realpath, record: { size: stat.size, mtimeMs: stat.mtimeMs } };
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
throw new Error(CLAUDE_NOT_FOUND);
}
}

if (identity) {
const cached = readSupportRecords()[identity.realpath];
if (cached?.size === identity.record.size && cached.mtimeMs === identity.record.mtimeMs) return;
}

try {
await execFileAsync(claudeBin, ["--append-system-prompt-file"], {
cwd,
Expand All @@ -239,6 +319,9 @@ export async function assertSupport(claudeBin: string, cwd: string): Promise<voi
// A supported Commander option reports a missing argument for this probe.
// Other probe failures are left to the real launch, which can provide the
// harness-specific diagnostic without blocking a valid installation.
if (identity && /argument missing|missing required argument|requires an argument/i.test(details.text)) {
writeSupportRecord(identity.realpath, identity.record);
}
}
}

Expand Down
4 changes: 3 additions & 1 deletion src/open/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,9 @@ export function bootFrame(progress: number, noise: (column: number) => string):
}

const BOOT_STEPS = 18;
const BOOT_STEP_MS = 40;
const BOOT_BUDGET_MS = 200;
// Keep all 19 resolving frames within one 200 ms launch budget.
const BOOT_STEP_MS = Math.floor(BOOT_BUDGET_MS / (BOOT_STEPS + 1));
const KATAKANA = [
...new Set([...SPINNER_VERBS.join("")].filter((glyph) => !/[0-9]/.test(glyph))),
];
Expand Down
100 changes: 100 additions & 0 deletions tests/ipc-daemon-start.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IpcClient } from "../src/daemon/ipc.js";

const { spawn } = vi.hoisted(() => ({ spawn: vi.fn(() => ({ unref: vi.fn() })) }));

vi.mock("node:child_process", () => ({ spawn }));

describe("IpcClient.ensureDaemonStarted", () => {
let tempDir: string;
let server: net.Server | undefined;
let previousRunAgentDir: string | undefined;
let previousConfigDir: string | undefined;

beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "run-agent-ipc-start-"));
previousRunAgentDir = process.env.RUN_AGENT_DIR;
previousConfigDir = process.env.RUN_AGENT_CONFIG_DIR;
process.env.RUN_AGENT_DIR = tempDir;
process.env.RUN_AGENT_CONFIG_DIR = path.join(tempDir, "config");
spawn.mockClear();
server = undefined;
});

afterEach(async () => {
vi.useRealTimers();
vi.clearAllTimers();
if (server?.listening) {
await new Promise<void>((resolve, reject) => {
server!.close((error) => error ? reject(error) : resolve());
});
}
fs.rmSync(tempDir, { recursive: true, force: true });
if (previousRunAgentDir === undefined) delete process.env.RUN_AGENT_DIR;
else process.env.RUN_AGENT_DIR = previousRunAgentDir;
if (previousConfigDir === undefined) delete process.env.RUN_AGENT_CONFIG_DIR;
else process.env.RUN_AGENT_CONFIG_DIR = previousConfigDir;
});

it("resolves within 50 ms after the socket starts accepting connections", async () => {
const socketPath = path.join(tempDir, "daemon.sock");
const offsets = [5, 30, 55, 80, 105, 130];

for (const [index, offset] of offsets.entries()) {
server = net.createServer();
const attemptServer = server;
let listeningAt = 0;
const started = new IpcClient().ensureDaemonStarted().then(() => Date.now());
const listening = new Promise<void>((resolve, reject) => {
setTimeout(() => {
attemptServer.once("error", reject);
attemptServer.listen(socketPath, () => {
listeningAt = Date.now();
resolve();
});
}, offset);
});

const [resolvedAt] = await Promise.all([started, listening.then(() => undefined)]);
expect(resolvedAt - listeningAt).toBeLessThanOrEqual(50);
expect(spawn).toHaveBeenCalledTimes(index + 1);

await new Promise<void>((resolve, reject) => {
attemptServer.close((error) => error ? reject(error) : resolve());
});
fs.rmSync(socketPath, { force: true });
server = undefined;
}
});

it("rejects after the six second startup budget when the socket never accepts", async () => {
vi.useFakeTimers();
let settled = false;
const started = new IpcClient().ensureDaemonStarted();
started.finally(() => { settled = true; }).catch(() => {});

await vi.advanceTimersByTimeAsync(5999);
expect(settled).toBe(false);

await vi.advanceTimersByTimeAsync(1);
await expect(started).rejects.toThrow("Failed to start daemon");
expect(spawn).toHaveBeenCalledTimes(1);
});

it("does not spawn when the daemon already accepts on the socket", async () => {
const socketPath = path.join(tempDir, "daemon.sock");
server = net.createServer();
await new Promise<void>((resolve, reject) => {
server!.once("error", reject);
server!.listen(socketPath, resolve);
});

await new IpcClient().ensureDaemonStarted();

expect(spawn).not.toHaveBeenCalled();
});
});
76 changes: 76 additions & 0 deletions tests/open-boot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import { INDENT, LOGO } from "../src/cli/ui.js";
import { bootFrame, playBoot, renderBanner } from "../src/open/runtime.js";

const role = "reviewer";
const model = "claude-sonnet";
const effort = "high";

function stubStdout(isTTY: boolean) {
const writes: string[] = [];
const stdout = {
isTTY,
write: vi.fn((chunk: string) => {
writes.push(chunk);
return true;
}),
};

vi.spyOn(process, "stdout", "get").mockReturnValue(stdout as NodeJS.WriteStream);
return { stdout, writes };
}

afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});

describe("playBoot", () => {
it("keeps the total TTY animation delay within 200 ms", async () => {
vi.useFakeTimers();
stubStdout(true);
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");

const boot = playBoot(role, model, effort);
await vi.runAllTimersAsync();
await boot;

const delays = setTimeoutSpy.mock.calls.map(([, delay]) => Number(delay));
expect(delays).toHaveLength(19);
expect(delays.reduce((total, delay) => total + delay, 0)).toBeLessThanOrEqual(200);
});

it("ends with the resolved logo, role details, and booting line", async () => {
vi.useFakeTimers();
const { writes } = stubStdout(true);

const boot = playBoot(role, model, effort);
await vi.runAllTimersAsync();
await boot;

const ending = writes.slice(-5);
expect(ending.slice(0, 3)).toEqual(
bootFrame(1, () => "").map(
(line) => `\r\x1b[2K${INDENT}\x1b[38;2;225;29;72m${line}\x1b[0m\n`,
),
);
expect(ending[3]).toBe(
`${INDENT}\x1b[38;2;163;139;143m${role} · ${model} · ${effort}\x1b[0m\n`,
);
expect(ending[4]).toBe(`${INDENT}\x1b[38;2;163;139;143mbooting…\x1b[0m\n`);
expect(bootFrame(1, () => "")).toEqual(LOGO);
});

it("writes one static banner and schedules no timer when stdout is not a TTY", async () => {
vi.useFakeTimers();
const { stdout, writes } = stubStdout(false);
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");

await playBoot(role, model, effort);

expect(writes).toEqual([renderBanner(role, model, effort)]);
expect(stdout.write).toHaveBeenCalledTimes(1);
expect(setTimeoutSpy).not.toHaveBeenCalled();
});
});
Loading
Loading