mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Prewarm Codex Live with safe fallback
Return after a durable starting record, overlap app-server initialization with page startup, dynamically reclaim generation after worker failure, and cap hard-crash leases at 15 seconds.\n\nAI-assisted: OpenAI Codex.
This commit is contained in:
@@ -26,7 +26,7 @@ The global bar **Impeccable mark** dims and shows a pulsing amber dot when no ag
|
||||
Harness policy:
|
||||
- **Claude Code**: run the poll as a **background task** (no short timeout). The harness notifies you when it completes, so the main conversation stays free. Do not block the shell.
|
||||
- **Cursor**: run **one-shot** poll in a **background terminal** with notify on `"type":"(steer|generate|accept|discard|exit)"`. After each event the poll exits; handle it, `--reply`, then start `live-poll.mjs` again. Do **not** use `--stream` on Cursor: incremental stdout notify is slower in practice than exit-based notify (~5s vs sub-second in testing).
|
||||
- **Codex**: the main thread is the **foreground poll supervisor**. Keep the poll command itself in a yielded foreground exec session and retain its session id; do not suffix it with `&`. A yielded foreground process continues while other tool calls run, whereas a traditional shell-backgrounded child may be reaped when its shell exits. On `generate`, delegate to the low-effort `impeccable_live_generator` agent when available. Give it a compact handoff: project/scripts paths, the complete event plus scaffold, the identity lock, relevant source/component excerpt, available tokens, and current design/product constraints. Do not paste this full reference into the handoff. The worker publishes variants and posts the generation reply; poll again immediately in the main thread so the supervisor remains available for early Accept/Discard and the next Go. If the named agent is unavailable, use one generic generation worker with the same compact contract. Do not put the poll itself in a subagent or a fire-and-forget background shell: browser control events must return to the main thread immediately. The explicit experimental dedicated-worker switch documented under Recovery partitions the queue: keep only its filtered foreground control poll, never an overlapping unfiltered poll.
|
||||
- **Codex**: `live.mjs` starts a dedicated app-server generation lane by default and returns its exact foreground control command. Run that command in a yielded foreground exec session; do not suffix it with `&`. It dynamically adds generation events only if the dedicated worker fails or exits. When the worker is explicitly disabled or unavailable, keep the default poll in the main thread and delegate to the low-effort `impeccable_live_generator` agent with a compact handoff. Do not paste this full reference into the handoff. Never run an overlapping unfiltered poll while the dedicated worker owns generation.
|
||||
- **Other harnesses**: one-shot foreground unless you know stdout reliably returns to this session when a shell exits.
|
||||
|
||||
Generation delivery policy:
|
||||
@@ -46,7 +46,7 @@ node {{scripts_path}}/live.mjs
|
||||
|
||||
Output JSON: `{ ok, serverPort, serverToken, pageFiles, hasProduct, product, productPath, hasDesign, design, designPath }`. `pageFiles` is the list of HTML entries the live script was injected into. Keep PRODUCT.md and DESIGN.md in mind for variant generation; **DESIGN.md wins on visual decisions; PRODUCT.md wins on strategic/voice decisions.** When DESIGN.md is missing, identity is **not** absent; extract it from CSS variables, computed styles, and sibling components on the page (see Step 4 Phase A). Identity preservation is the default; departure from existing identity requires an explicit trigger from PRODUCT.md anti-references or the user's freeform prompt.
|
||||
|
||||
If output includes `codexWorker.enabled: true`, the dedicated lane owns only `generate,accept,discard,prefetch`. Keep the main task on the non-overlapping control lane with `node {{scripts_path}}/live-poll.mjs --types=steer,manual_edit_apply,carbonize_cleanup,exit`; after each event, immediately restart that filtered command. Do not also run the default unfiltered poll while the worker is enabled.
|
||||
If output includes `codexWorker.enabled: true`, run the returned `codexWorker.foregroundPoll` command. The dedicated lane owns `generate,accept,discard,prefetch`; the foreground owns `steer,manual_edit_apply,carbonize_cleanup,exit`. The fallback flag restores generation to the foreground only when the worker's owned process record is failed or unreachable. After each event, restart that same command. Do not also run the default unfiltered poll.
|
||||
|
||||
`serverPort` and `serverToken` belong to the small **Impeccable live helper** HTTP server (serves `/live.js`, SSE, and `/poll`). That port is **not** your dev server and is usually not the URL you open to view the app. The browser page is whatever origin serves one of the `pageFiles` entries (Vite / Next / Bun / tunnel / LAN hostname).
|
||||
|
||||
@@ -118,7 +118,11 @@ Activation is process-local: the worker is enabled by default only when the proc
|
||||
}
|
||||
```
|
||||
|
||||
The app-server worker is **default-on in Codex and Codex-only**. Claude, Gemini, Cursor, and every other harness keep the portable foreground/atomic behavior. When enabled, `live.mjs` returns `codexWorker.enabled: true`; run only the filtered foreground control poll shown under Start. If app-server startup, authentication, or model selection fails and the child terminates cleanly, `live.mjs` returns `codexWorker.fallback: true` and leaves the portable foreground poll path untouched.
|
||||
The app-server worker is **default-on in Codex and Codex-only**. Claude, Gemini, Cursor, and every other harness keep the portable foreground/atomic behavior. Live records the worker as `starting` and returns immediately, so app-server initialization overlaps page/dev-server startup. Run only the returned foreground control poll. It checks the owned worker process every two seconds and safely restores generation/accept/discard leasing if startup, authentication, model selection, or the worker process fails. Dedicated-worker leases expire after 15 seconds, so a hard process loss cannot strand browser work behind the portable ten-minute lease.
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/live-poll.mjs --types=steer,manual_edit_apply,carbonize_cleanup,exit --codex-worker-fallback
|
||||
```
|
||||
|
||||
The supervisor launches its own `codex app-server --stdio` process, dynamically prefers the strongest visible general model (currently GPT-5.6 Sol), and uses medium reasoning. The optional `fast` profile retains Spark/mini selection and low reasoning for controlled comparisons. It creates a dedicated Impeccable-owned thread and persists only that id in `.impeccable/live/codex-worker.json`; it never lists, resumes, steers, or writes to the desktop task. A crash reconnect may resume that id only when the ownership marker and project cwd both match. Clean Live exit interrupts the active turn, archives the dedicated thread, and stops app-server.
|
||||
|
||||
@@ -563,7 +567,7 @@ When `_acceptResult.carbonize === true`, the accepted variant was stitched into
|
||||
|
||||
After the file is clean, the cleanup owner runs `live-complete.mjs --id SESSION_ID` and verifies `phase: "completed"`. The Codex supervisor keeps polling throughout; synchronous harnesses poll again only after that verification.
|
||||
|
||||
With the experimental dedicated worker, Accept emits a foreground `carbonize_cleanup` control event: `{id, sessionId, file, variantId, acceptResult}`. Perform the same five steps above for `sessionId`, run `live-complete.mjs --id SESSION_ID`, then acknowledge the control event with `live-poll.mjs --reply EVENT_ID complete --file FILE`. Restart the filtered control poll immediately. The dedicated generation lane may prepare the next request concurrently, but its publisher must re-prepare after any stale source fence.
|
||||
With the dedicated worker, Accept emits a foreground `carbonize_cleanup` control event: `{id, sessionId, file, variantId, acceptResult}`. Perform the same five steps above for `sessionId`, run `live-complete.mjs --id SESSION_ID`, then acknowledge the control event with `live-poll.mjs --reply EVENT_ID complete --file FILE`. Restart the returned control poll immediately. The dedicated generation lane may prepare the next request concurrently, but its publisher must re-prepare after any stale source fence.
|
||||
|
||||
## Handle `discard`
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { createCodexAppServerClient } from './live/codex-app-server-client.mjs';
|
||||
import {
|
||||
CODEX_WORKER_OWNER,
|
||||
codexWorkerProcessStateIsOwned,
|
||||
codexWorkerStateIsOwned,
|
||||
resolveCodexWorkerConfig,
|
||||
} from './live/codex-worker.mjs';
|
||||
@@ -24,7 +26,7 @@ const scriptsDir = path.dirname(scriptPath);
|
||||
const statePath = getLiveCodexWorkerStatePath(cwd);
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`Usage: node live-codex-worker.mjs [--background | --status | --stop]
|
||||
console.log(`Usage: node live-codex-worker.mjs [--background [--no-wait] | --status | --stop]
|
||||
|
||||
Codex Live generation supervisor. It owns a separate
|
||||
app-server process and dedicated worker thread; it never attaches to the
|
||||
@@ -55,7 +57,7 @@ if (args.includes('--status')) {
|
||||
|
||||
if (args.includes('--stop')) {
|
||||
const state = readJson(statePath);
|
||||
if (state?.pid && !codexWorkerStateIsOwned(state, cwd)) {
|
||||
if (state?.pid && !codexWorkerProcessStateIsOwned(state, cwd)) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
status: 'not_stopped',
|
||||
@@ -95,10 +97,10 @@ if (!config.enabled) {
|
||||
|
||||
if (args.includes('--background')) {
|
||||
const existing = readJson(statePath);
|
||||
if (codexWorkerStateIsOwned(existing, cwd)
|
||||
if (codexWorkerProcessStateIsOwned(existing, cwd)
|
||||
&& existing?.pid
|
||||
&& pidReachable(existing.pid)
|
||||
&& ['ready', 'working'].includes(existing.status)) {
|
||||
&& ['starting', 'ready', 'working'].includes(existing.status)) {
|
||||
console.log(JSON.stringify({ ...existing, ok: true, reused: true }));
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -113,6 +115,24 @@ if (args.includes('--background')) {
|
||||
});
|
||||
child.unref();
|
||||
fs.closeSync(logFd);
|
||||
const observed = readJson(statePath);
|
||||
const starting = observed?.pid === child.pid && ['ready', 'working'].includes(observed.status)
|
||||
? observed
|
||||
: writeState({
|
||||
ok: true,
|
||||
owner: CODEX_WORKER_OWNER,
|
||||
pid: child.pid,
|
||||
status: 'starting',
|
||||
threadId: null,
|
||||
model: config.model,
|
||||
effort: config.effort,
|
||||
profile: config.profile,
|
||||
delivery: config.delivery,
|
||||
});
|
||||
if (args.includes('--no-wait')) {
|
||||
console.log(JSON.stringify({ ...starting, ok: true, starting: true, logPath }));
|
||||
process.exit(0);
|
||||
}
|
||||
const ready = await waitFor(() => {
|
||||
const state = readJson(statePath);
|
||||
if (state?.pid !== child.pid) return null;
|
||||
@@ -213,6 +233,7 @@ function writeState(value) {
|
||||
const temporary = `${statePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.writeFileSync(temporary, JSON.stringify(state, null, 2) + '\n', 'utf-8');
|
||||
fs.renameSync(temporary, statePath);
|
||||
return state;
|
||||
}
|
||||
|
||||
function readJson(file) {
|
||||
|
||||
+47
-10
@@ -10,10 +10,12 @@
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
import { getLiveCodexWorkerStatePath, readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
import { codexWorkerProcessStateIsOwned } from './live/codex-worker.mjs';
|
||||
|
||||
// Absolute path to a sibling script in this skill's scripts dir, so runtime
|
||||
// error hints print a directly-runnable command instead of a placeholder.
|
||||
@@ -152,7 +154,13 @@ export async function waitForEventAck(base, token, eventId, {
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function fetchNextEvent(base, token, { totalDeadline, types } = {}) {
|
||||
export async function fetchNextEvent(base, token, {
|
||||
totalDeadline,
|
||||
types,
|
||||
resolveTypes,
|
||||
perRequestTimeoutMs = PER_REQUEST_TIMEOUT_MS,
|
||||
leaseMs = DEFAULT_EVENT_LEASE_MS,
|
||||
} = {}) {
|
||||
while (true) {
|
||||
if (totalDeadline && Date.now() >= totalDeadline) {
|
||||
return { type: 'timeout' };
|
||||
@@ -161,13 +169,13 @@ export async function fetchNextEvent(base, token, { totalDeadline, types } = {})
|
||||
const remaining = totalDeadline
|
||||
? totalDeadline - Date.now()
|
||||
: PER_REQUEST_TIMEOUT_MS;
|
||||
const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS);
|
||||
const slice = Math.min(Math.max(remaining, 1000), perRequestTimeoutMs);
|
||||
const query = new URLSearchParams({
|
||||
token,
|
||||
timeout: String(slice),
|
||||
leaseMs: String(DEFAULT_EVENT_LEASE_MS),
|
||||
leaseMs: String(leaseMs),
|
||||
});
|
||||
const normalizedTypes = normalizePollTypes(types);
|
||||
const normalizedTypes = normalizePollTypes(resolveTypes ? await resolveTypes() : types);
|
||||
if (normalizedTypes.length > 0) query.set('types', normalizedTypes.join(','));
|
||||
const res = await fetch(`${base}/poll?${query}`);
|
||||
|
||||
@@ -253,9 +261,9 @@ export function printPollEvent(event) {
|
||||
console.log(JSON.stringify(event));
|
||||
}
|
||||
|
||||
export async function runPollOnce(base, token, { totalTimeout = 600_000, types } = {}) {
|
||||
export async function runPollOnce(base, token, { totalTimeout = 600_000, types, resolveTypes, perRequestTimeoutMs } = {}) {
|
||||
const deadline = Date.now() + totalTimeout;
|
||||
const event = await fetchNextEvent(base, token, { totalDeadline: deadline, types });
|
||||
const event = await fetchNextEvent(base, token, { totalDeadline: deadline, types, resolveTypes, perRequestTimeoutMs });
|
||||
await augmentEventWithAcceptHandling(event, base, token);
|
||||
writeCarbonizeBanner(event);
|
||||
printPollEvent(event);
|
||||
@@ -267,11 +275,13 @@ export async function runPollStream(base, token, {
|
||||
ackPollIntervalMs = 400,
|
||||
shouldContinue = () => true,
|
||||
types,
|
||||
resolveTypes,
|
||||
perRequestTimeoutMs,
|
||||
} = {}) {
|
||||
process.stderr.write('[impeccable-poll] stream mode: one JSON object per line on stdout; use --reply while this process stays running\n');
|
||||
|
||||
while (shouldContinue()) {
|
||||
const event = await fetchNextEvent(base, token, { types });
|
||||
const event = await fetchNextEvent(base, token, { types, resolveTypes, perRequestTimeoutMs });
|
||||
await augmentEventWithAcceptHandling(event, base, token);
|
||||
writeCarbonizeBanner(event);
|
||||
printPollEvent(event);
|
||||
@@ -332,6 +342,8 @@ Modes:
|
||||
Options:
|
||||
--timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode
|
||||
--types=A,B Lease only these event types (used by partitioned Codex control lane)
|
||||
--codex-worker-fallback
|
||||
Add generation events only if the dedicated Codex worker fails or exits
|
||||
--ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000)
|
||||
--file PATH Attach a source file path to the reply (generate/steer flow)
|
||||
--data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON
|
||||
@@ -372,18 +384,21 @@ Harness note:
|
||||
const streamMode = args.includes('--stream');
|
||||
const typesArg = args.find((a) => a.startsWith('--types='));
|
||||
const types = normalizePollTypes(typesArg ? typesArg.slice('--types='.length) : null);
|
||||
const workerFallback = args.includes('--codex-worker-fallback');
|
||||
const resolveTypes = workerFallback ? () => resolveCodexWorkerFallbackTypes(types) : null;
|
||||
const perRequestTimeoutMs = workerFallback ? 2_000 : undefined;
|
||||
const ackTimeoutArg = args.find((a) => a.startsWith('--ack-timeout='));
|
||||
const ackTimeoutMs = ackTimeoutArg ? parseInt(ackTimeoutArg.split('=')[1], 10) : 600_000;
|
||||
|
||||
try {
|
||||
if (streamMode) {
|
||||
await runPollStream(base, info.token, { ackTimeoutMs, types });
|
||||
await runPollStream(base, info.token, { ackTimeoutMs, types, resolveTypes, perRequestTimeoutMs });
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutArg = args.find((a) => a.startsWith('--timeout='));
|
||||
const totalTimeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600_000;
|
||||
await runPollOnce(base, info.token, { totalTimeout, types });
|
||||
await runPollOnce(base, info.token, { totalTimeout, types, resolveTypes, perRequestTimeoutMs });
|
||||
} catch (err) {
|
||||
handlePollError(err);
|
||||
}
|
||||
@@ -394,6 +409,28 @@ export function normalizePollTypes(value) {
|
||||
return [...new Set(values.map((type) => String(type).trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
export function resolveCodexWorkerFallbackTypes(baseTypes, {
|
||||
cwd = process.cwd(),
|
||||
state = readJson(getLiveCodexWorkerStatePath(cwd)),
|
||||
isPidReachable = pidReachable,
|
||||
} = {}) {
|
||||
const base = normalizePollTypes(baseTypes);
|
||||
const workerOwnsGeneration = codexWorkerProcessStateIsOwned(state, cwd)
|
||||
&& ['starting', 'ready', 'working'].includes(state?.status)
|
||||
&& isPidReachable(state?.pid);
|
||||
if (workerOwnsGeneration) return base;
|
||||
return normalizePollTypes([...base, 'generate', 'accept', 'discard', 'prefetch']);
|
||||
}
|
||||
|
||||
function readJson(file) {
|
||||
try { return JSON.parse(fs.readFileSync(file, 'utf-8')); } catch { return null; }
|
||||
}
|
||||
|
||||
function pidReachable(pid) {
|
||||
if (!Number.isInteger(pid) || pid <= 0) return false;
|
||||
try { process.kill(pid, 0); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
// Auto-execute when run directly
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('live-poll.mjs') || _running?.endsWith('live-poll.mjs/')) {
|
||||
|
||||
@@ -299,7 +299,7 @@ function ensureCodexWorker(cwd, liveConfig) {
|
||||
if (!config.enabled) {
|
||||
return { enabled: false, mode: 'foreground', codexOnly: true };
|
||||
}
|
||||
const out = runScript('live-codex-worker.mjs', ['--background'], { cwd });
|
||||
const out = runScript('live-codex-worker.mjs', ['--background', '--no-wait'], { cwd });
|
||||
const result = safeParse(out);
|
||||
if (!result?.ok) {
|
||||
const safeFallback = result?.fallback === 'foreground' && result?.terminated !== false;
|
||||
@@ -315,7 +315,7 @@ function ensureCodexWorker(cwd, liveConfig) {
|
||||
}
|
||||
return {
|
||||
enabled: true,
|
||||
mode: 'dedicated-app-server',
|
||||
mode: result.starting ? 'prewarming-app-server' : 'dedicated-app-server',
|
||||
codexOnly: true,
|
||||
pid: result.pid,
|
||||
threadId: result.threadId,
|
||||
@@ -324,7 +324,7 @@ function ensureCodexWorker(cwd, liveConfig) {
|
||||
profile: result.profile,
|
||||
delivery: result.delivery,
|
||||
foregroundTypes: ['steer', 'manual_edit_apply', 'carbonize_cleanup', 'exit'],
|
||||
foregroundPoll: 'live-poll.mjs --types=steer,manual_edit_apply,carbonize_cleanup,exit',
|
||||
foregroundPoll: 'live-poll.mjs --types=steer,manual_edit_apply,carbonize_cleanup,exit --codex-worker-fallback',
|
||||
logPath: result.logPath || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from '../live-poll.mjs';
|
||||
|
||||
export const CODEX_WORKER_EVENT_TYPES = Object.freeze(['generate', 'accept', 'discard', 'prefetch']);
|
||||
export const CODEX_WORKER_EVENT_LEASE_MS = 15_000;
|
||||
|
||||
export class CodexLiveWorkerSupervisor {
|
||||
constructor({
|
||||
@@ -117,7 +118,10 @@ export class CodexLiveWorkerSupervisor {
|
||||
if (!this.thread) await this.initialize();
|
||||
this.running = true;
|
||||
while (this.running) {
|
||||
const event = await this.fetchEvent(this.base, this.token, { types: CODEX_WORKER_EVENT_TYPES });
|
||||
const event = await this.fetchEvent(this.base, this.token, {
|
||||
types: CODEX_WORKER_EVENT_TYPES,
|
||||
leaseMs: CODEX_WORKER_EVENT_LEASE_MS,
|
||||
});
|
||||
if (!event || event.type === 'timeout') continue;
|
||||
if (event.type === 'exit') {
|
||||
await this.cancelActive('live_exit');
|
||||
|
||||
@@ -296,12 +296,22 @@ export function generationIsCanceled(eventId, { cwd = process.cwd() } = {}) {
|
||||
}
|
||||
|
||||
export function codexWorkerStateIsOwned(state, cwd) {
|
||||
return state?.owner === CODEX_WORKER_OWNER
|
||||
&& canonicalPath(state?.cwd) === canonicalPath(cwd)
|
||||
return codexWorkerOwnerMatches(state, cwd)
|
||||
&& typeof state?.threadId === 'string'
|
||||
&& state.threadId.length > 0;
|
||||
}
|
||||
|
||||
export function codexWorkerProcessStateIsOwned(state, cwd) {
|
||||
return codexWorkerOwnerMatches(state, cwd)
|
||||
&& Number.isInteger(state?.pid)
|
||||
&& state.pid > 0;
|
||||
}
|
||||
|
||||
function codexWorkerOwnerMatches(state, cwd) {
|
||||
return state?.owner === CODEX_WORKER_OWNER
|
||||
&& canonicalPath(state?.cwd) === canonicalPath(cwd);
|
||||
}
|
||||
|
||||
function canonicalPath(value) {
|
||||
if (!value || typeof value !== 'string') return null;
|
||||
const resolved = path.resolve(value);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { describe, it } from 'node:test';
|
||||
|
||||
import { CODEX_WORKER_OWNER } from '../skill/scripts/live/codex-worker.mjs';
|
||||
import {
|
||||
CODEX_WORKER_EVENT_LEASE_MS,
|
||||
CODEX_WORKER_EVENT_TYPES,
|
||||
CodexLiveWorkerSupervisor,
|
||||
buildDeterministicScaffoldCommand,
|
||||
@@ -145,6 +146,7 @@ describe('Codex Live worker supervisor ownership and lifecycle', () => {
|
||||
supervisor.thread = { id: 'live-worker-thread' };
|
||||
supervisor.fetchEvent = async (_base, _token, options) => {
|
||||
assert.deepEqual(options.types, CODEX_WORKER_EVENT_TYPES);
|
||||
assert.equal(options.leaseMs, CODEX_WORKER_EVENT_LEASE_MS);
|
||||
return cleanups.length === 0
|
||||
? { type: 'accept', id: 'abc12345', variantId: '1' }
|
||||
: { type: 'exit' };
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
buildCodexWorkerInstructions,
|
||||
buildCodexWorkerTurnInputs,
|
||||
buildGenerationTurnInput,
|
||||
codexWorkerProcessStateIsOwned,
|
||||
codexWorkerStateIsOwned,
|
||||
isCodexRuntime,
|
||||
readPreparedArtifact,
|
||||
@@ -53,6 +54,8 @@ describe('Codex Live worker configuration', () => {
|
||||
assert.equal(codexWorkerStateIsOwned({ owner: CODEX_WORKER_OWNER, cwd, threadId: 'worker-1' }, cwd), true);
|
||||
assert.equal(codexWorkerStateIsOwned({ owner: 'desktop', cwd, threadId: 'desktop-1' }, cwd), false);
|
||||
assert.equal(codexWorkerStateIsOwned({ owner: CODEX_WORKER_OWNER, cwd: '/tmp/other', threadId: 'worker-1' }, cwd), false);
|
||||
assert.equal(codexWorkerProcessStateIsOwned({ owner: CODEX_WORKER_OWNER, cwd, pid: 123, status: 'starting' }, cwd), true);
|
||||
assert.equal(codexWorkerStateIsOwned({ owner: CODEX_WORKER_OWNER, cwd, pid: 123, status: 'starting' }, cwd), false);
|
||||
});
|
||||
|
||||
it('leaves the portable foreground path untouched when the switch is off', () => {
|
||||
@@ -160,6 +163,47 @@ describe('Codex Live worker configuration', () => {
|
||||
assert.equal(output.fallback, 'foreground');
|
||||
assert.throws(() => process.kill(output.childPid, 0), (error) => error.code === 'ESRCH');
|
||||
});
|
||||
|
||||
it('returns a durable starting record without waiting for app-server readiness', () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), 'codex-worker-prewarm-'));
|
||||
const liveDir = path.join(cwd, '.impeccable/live');
|
||||
mkdirSync(liveDir, { recursive: true });
|
||||
writeFileSync(path.join(liveDir, 'server.json'), JSON.stringify({
|
||||
pid: process.pid,
|
||||
port: 1,
|
||||
token: 'smoke-token',
|
||||
}));
|
||||
const fakeCodex = path.join(cwd, 'fake-codex');
|
||||
writeFileSync(fakeCodex, '#!/bin/sh\nwhile true; do sleep 1; done\n');
|
||||
chmodSync(fakeCodex, 0o755);
|
||||
const script = path.resolve('skill/scripts/live-codex-worker.mjs');
|
||||
const startedAt = Date.now();
|
||||
const result = spawnSync(process.execPath, [script, '--background', '--no-wait'], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
IMPECCABLE_LIVE_CODEX_WORKER: '1',
|
||||
IMPECCABLE_CODEX_PATH: fakeCodex,
|
||||
},
|
||||
timeout: 5_000,
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const output = JSON.parse(result.stdout);
|
||||
assert.equal(output.status, 'starting');
|
||||
assert.equal(output.starting, true);
|
||||
assert.equal(codexWorkerProcessStateIsOwned(output, cwd), true);
|
||||
assert.ok(Date.now() - startedAt < 1_000, 'prewarm should not wait for app-server initialization');
|
||||
|
||||
const stopped = spawnSync(process.execPath, [script, '--stop'], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, IMPECCABLE_LIVE_CODEX_STOP_TIMEOUT_MS: '1000' },
|
||||
timeout: 3_000,
|
||||
});
|
||||
assert.equal(stopped.status, 0, stopped.stderr);
|
||||
assert.equal(JSON.parse(stopped.stdout).status, 'stopped');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Codex Live worker structured artifact boundary', () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
manualApplyPollBanner,
|
||||
normalizePollTypes,
|
||||
parseReplyArgs,
|
||||
resolveCodexWorkerFallbackTypes,
|
||||
requiresAgentReply,
|
||||
} from '../skill/scripts/live-poll.mjs';
|
||||
|
||||
@@ -167,4 +168,27 @@ describe('live-poll stream helpers', () => {
|
||||
['steer', 'manual_edit_apply', 'carbonize_cleanup', 'exit'],
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps generation isolated while the Codex worker starts and restores it after failure', () => {
|
||||
const cwd = '/tmp/live-fallback';
|
||||
const control = ['steer', 'manual_edit_apply', 'carbonize_cleanup', 'exit'];
|
||||
const starting = {
|
||||
owner: 'impeccable-live-codex-worker-v1',
|
||||
cwd,
|
||||
pid: 123,
|
||||
status: 'starting',
|
||||
};
|
||||
assert.deepEqual(resolveCodexWorkerFallbackTypes(control, {
|
||||
cwd,
|
||||
state: starting,
|
||||
isPidReachable: () => true,
|
||||
}), control);
|
||||
|
||||
const fallback = resolveCodexWorkerFallbackTypes(control, {
|
||||
cwd,
|
||||
state: { ...starting, status: 'error' },
|
||||
isPidReachable: () => false,
|
||||
});
|
||||
assert.deepEqual(fallback, [...control, 'generate', 'accept', 'discard', 'prefetch']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user