diff --git a/README.md b/README.md index 27fca6e..f4cea1e 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,9 @@ and the first Claude reference-fleet path: - equivalent CLI and identity-bound MCP surfaces; - current MCP stdio interoperability through the official protocol SDK; - an explicit-capability Claude adapter with durable-message fallback; +- an explicit-capability Codex adapter with resumable JSONL thread capture; +- per-domain mixed Claude/Codex runtime planning and identity-bound MCP registration plans; +- durable-first Codex steering with native `queue` notification when available; - worker-first/Session-Manager-last fresh start and resume planning; - safe wind-down with active-work refusal and local resume snapshots. - shell-free configured checks with exact-commit receipts; @@ -42,8 +45,8 @@ and the first Claude reference-fleet path: - a local bare canonical Git option with honest one-disk/local-remote grading; - mutation audit events tied to project and Fleet identity. -Codex portability, self-hosting, COMBATRIG re-import, scheduling, richer -observability, and the Fleet Console remain staged work. +Live mixed-provider qualification, self-hosting, COMBATRIG re-import, +scheduling, richer observability, and the Fleet Console remain staged work. The canonical architecture and delivery plan is [docs/PORTABLE_AGENT_FLEET_SPEC.md](docs/PORTABLE_AGENT_FLEET_SPEC.md). @@ -83,7 +86,8 @@ node bin/torch.mjs worktrees --yes --json node bin/torch.mjs up --fresh --dry-run --json ``` -Starting or stopping Claude sessions is always a separate explicit step: +Starting or stopping configured runtime sessions is always a separate explicit +step: ```bash node bin/torch.mjs up --fresh --yes --json @@ -105,9 +109,10 @@ specialist can keep its relevant working set hot instead of one general session reloading unrelated project areas. TORCH treats cache/token savings as a measurement hypothesis until provider usage and verified task outcomes prove it. -This is still an alpha. The lifecycle is scenario-tested with a virtualized -Claude boundary; a paid live-Claude multi-domain acceptance run remains an -explicit owner-authorized qualification gate. +This is still an alpha. The lifecycle is scenario-tested with virtualized +Claude and Codex boundaries. The tests make no paid provider calls. A live +mixed-provider, multi-domain acceptance run remains an explicit +owner-authorized qualification gate. ## Product principles diff --git a/docs/PORTABLE_AGENT_FLEET_SPEC.md b/docs/PORTABLE_AGENT_FLEET_SPEC.md index 673fd29..1cc865c 100644 --- a/docs/PORTABLE_AGENT_FLEET_SPEC.md +++ b/docs/PORTABLE_AGENT_FLEET_SPEC.md @@ -1356,6 +1356,13 @@ integration without GitHub or hosted CI. Exit gate: Claude and Codex occupy persistent domains in one Fleet and coordinate through TORCH rather than native vendor messaging. +Implementation checkpoint (2026-09-27): the Codex adapter, mixed-runtime +planning, identity-bound MCP configuration plans, JSONL runtime-ID capture, +durable-first steering, and explicit degraded capabilities are implemented and +scenario-tested with virtualized runners. The exit gate remains open until an +owner-authorized live Claude/Codex acceptance run demonstrates coordination; +the automated suite intentionally incurs no provider usage. + ### Stage 6: Self-hosting - stable/candidate isolation; diff --git a/package.json b/package.json index c094ec8..3ee508b 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "exports": { ".": "./src/cli.mjs", "./adapters/claude": "./src/adapters/claude.mjs", + "./adapters/codex": "./src/adapters/codex.mjs", "./canonical/local": "./src/canonical/local.mjs", "./checks": "./src/checks/service.mjs", "./control-plane": "./src/control-plane/service.mjs", diff --git a/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml b/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml index a36b8ed..880f36f 100644 --- a/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml +++ b/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml @@ -74,6 +74,10 @@ test_integrity_note: given: a Claude runtime adapter with a virtualized command runner when: sessions are planned and runtime state is queried then: command construction is deterministic, capabilities are explicit, and planning launches no process + - id: SCN-codex-adapter + given: a Codex runtime adapter with a virtualized command runner and an identity-bound TORCH control plane + when: create, resume, MCP configuration, runtime-ID capture, and steering are planned or attempted + then: no provider call occurs, thread IDs come from JSONL, every message is durable, and unsupported native controls fail explicitly - id: SCN-fleet-fresh-resume given: approved identities and managed worktrees with a virtualized Claude executor when: the fleet starts fresh, winds down safely, and plans its next start @@ -82,6 +86,10 @@ test_integrity_note: given: a launch failure or a domain reporting active work when: startup or wind-down is attempted then: manager launch is prevented after worker failure and active work blocks shutdown + - id: SCN-mixed-runtime + given: one approved Codex worker domain and one Claude Session Manager with stable Fleet identities + when: the mixed fleet starts and winds down through virtualized runtime boundaries + then: provider-specific commands and runtime IDs remain separate, the manager starts last, Codex completes idle, and only resident processes are stopped - id: SCN-exact-sha-checks given: approved shell-free checks and a clean managed worktree when: passing, failing, and dirty-input checks are requested @@ -120,6 +128,7 @@ test_integrity_note: - per-fixture XDG_DATA_HOME - injected UUID sequence and clock where ordering matters - virtualized Claude command runner with no provider calls + - virtualized Codex command runner and deterministic JSONL events with no provider calls - local stdio MCP child process with bounded response timeout - shell-free fixture checks with controlled exit codes - injected resource clock and identifiers @@ -137,6 +146,8 @@ test_integrity_note: - repository content redirecting machine-local database writes - MCP and CLI drifting onto separate business logic - launch plans that execute provider commands during preview + - a mixed-runtime claim implemented as renamed Claude-only commands + - lost Codex thread IDs or native steering that bypasses durable messaging - manager-first startup or manager-first shutdown - passing receipts detached from the commit actually measured - unfair or automatically stolen resource leases diff --git a/src/adapters/claude.mjs b/src/adapters/claude.mjs index db75334..f5dcc24 100644 --- a/src/adapters/claude.mjs +++ b/src/adapters/claude.mjs @@ -28,6 +28,10 @@ function commandRecord(command, args, cwd) { return { command, args, cwd, mutatesRuntime: true }; } +function mcpName(areaId) { + return `torch-${areaId.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-|-$/g, '')}`; +} + export class ClaudeRuntimeAdapter { constructor({ env = process.env, @@ -69,12 +73,13 @@ export class ClaudeRuntimeAdapter { configure({ repositoryRoot, areaId, mcpEntry } = {}) { const root = text(repositoryRoot, 'repositoryRoot'); const entry = text(mcpEntry, 'mcpEntry'); + const id = text(areaId, 'areaId'); return { adapter: this.name, mutationPerformed: false, mcp: { - name: 'torch', command: this.nodeExecutable, - args: [entry, '--root', root, '--area', text(areaId, 'areaId')], cwd: root, + name: mcpName(id), command: this.nodeExecutable, + args: [entry, '--root', root, '--area', id], cwd: root, }, note: 'Apply this MCP registration through the owner-approved Claude configuration surface.', }; diff --git a/src/adapters/codex.mjs b/src/adapters/codex.mjs new file mode 100644 index 0000000..06b00fd --- /dev/null +++ b/src/adapters/codex.mjs @@ -0,0 +1,184 @@ +import { accessSync, constants, existsSync } from 'node:fs'; +import { delimiter, join } from 'node:path'; +import { TorchError } from '../kernel/errors.mjs'; +import { unsupportedCapability, validateRuntimeAdapter } from './runtime.mjs'; + +function findExecutable(name, env = process.env) { + for (const directory of (env.PATH ?? '').split(delimiter).filter(Boolean)) { + const candidate = join(directory, name); + try { + accessSync(candidate, constants.X_OK); + return candidate; + } catch { /* keep searching */ } + } + return null; +} + +function text(value, name) { + if (typeof value !== 'string' || !value.trim()) { + throw new TorchError(`${name} must be a non-empty string`, { + code: 'INVALID_RUNTIME_INPUT', details: { field: name }, + }); + } + return value.trim(); +} + +function optionalModelArgs(model) { + return typeof model === 'string' && model.trim() ? ['--model', model.trim()] : []; +} + +function commandRecord(command, args, cwd) { + return { command, args, cwd, mutatesRuntime: true }; +} + +function mcpName(areaId) { + return `torch-${areaId.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-|-$/g, '')}`; +} + +function parseJsonLines(output) { + const records = []; + for (const line of String(output ?? '').split('\n').map((value) => value.trim()).filter(Boolean)) { + try { records.push(JSON.parse(line)); } catch { /* non-JSON diagnostics are not events */ } + } + return records; +} + +export class CodexRuntimeAdapter { + constructor({ + env = process.env, + runner = null, + executable = 'codex', + nodeExecutable = process.execPath, + } = {}) { + this.name = 'codex'; + this.env = env; + this.runner = runner; + this.executable = executable; + this.nodeExecutable = nodeExecutable; + this.capabilities = Object.freeze({ + detect: true, + configure: 'plan-only', + createSession: true, + resumeSession: true, + sendOrSteer: 'native-with-durable-fallback', + getStatus: false, + listSessions: false, + stopSession: false, + captureRuntimeId: true, + installHooks: false, + removeHooks: false, + liveSteering: true, + durableMessaging: true, + processModel: 'resumable-turn', + }); + validateRuntimeAdapter(this); + } + + detect() { + const path = this.executable.includes('/') && existsSync(this.executable) + ? this.executable : findExecutable(this.executable, this.env); + return { available: Boolean(path), executable: path, adapter: this.name }; + } + + configure({ repositoryRoot, areaId, mcpEntry } = {}) { + const root = text(repositoryRoot, 'repositoryRoot'); + const id = text(areaId, 'areaId'); + const entry = text(mcpEntry, 'mcpEntry'); + return { + adapter: this.name, + mutationPerformed: false, + mcp: { + name: mcpName(id), + command: this.executable, + args: ['mcp', 'add', mcpName(id), '--', this.nodeExecutable, entry, '--root', root, '--area', id], + cwd: root, + }, + note: 'Apply this identity-bound MCP registration only through an owner-approved Codex configuration change.', + }; + } + + createSession({ areaId, worktree, promptFile, firstMessage, model } = {}) { + const id = text(areaId, 'areaId'); + const cwd = text(worktree, 'worktree'); + const args = [ + 'exec', '--json', '--cd', cwd, '--sandbox', 'workspace-write', '--approve-for-me', + ...optionalModelArgs(model), + `${text(firstMessage, 'firstMessage')}\n\nRead and follow the TORCH domain prompt at ${text(promptFile, 'promptFile')}.`, + ]; + return { + adapter: this.name, + areaId: id, + runtimeSessionId: null, + requiresRuntimeIdCapture: true, + completionState: 'idle', + launch: commandRecord(this.executable, args, cwd), + }; + } + + resumeSession({ areaId, runtimeSessionId, worktree, message, model } = {}) { + const id = text(areaId, 'areaId'); + const sessionId = text(runtimeSessionId, 'runtimeSessionId'); + const cwd = text(worktree, 'worktree'); + const args = [ + 'exec', '--json', '--cd', cwd, '--sandbox', 'workspace-write', '--approve-for-me', + ...optionalModelArgs(model), 'resume', sessionId, text(message, 'message'), + ]; + return { + adapter: this.name, + areaId: id, + runtimeSessionId: sessionId, + requiresRuntimeIdCapture: false, + completionState: 'idle', + launch: commandRecord(this.executable, args, cwd), + }; + } + + sendOrSteer({ controlPlane, sender = 'session-manager', recipient, body, references, runtimeSessionId } = {}) { + if (!controlPlane) { + throw new TorchError('Codex messaging requires the TORCH durable control plane', { + code: 'CONTROL_PLANE_REQUIRED', + }); + } + const message = controlPlane.sendMessage({ sender, recipient, body, references }); + if (this.runner && runtimeSessionId) { + try { + const result = this.runner(this.executable, [ + 'queue', '--thread', text(runtimeSessionId, 'runtimeSessionId'), '--message', text(body, 'body'), + ]); + if (result?.status === undefined || result.status === 0) { + return { delivery: 'native+durable', liveSteeringAttempted: true, runtimeSessionId, message }; + } + } catch { /* the durable message remains available for polling */ } + } + return { delivery: 'durable', liveSteeringAttempted: Boolean(this.runner && runtimeSessionId), message }; + } + + captureRuntimeId({ areaId, stdout, output, runtimeSessionId } = {}) { + if (runtimeSessionId) { + return { areaId: text(areaId, 'areaId'), runtimeSessionId: text(runtimeSessionId, 'runtimeSessionId') }; + } + const event = parseJsonLines(stdout ?? output).find((candidate) => + candidate?.type === 'thread.started' && (candidate.thread_id || candidate.threadId)); + const captured = event?.thread_id ?? event?.threadId; + if (!captured) { + throw new TorchError('Codex launch output did not contain a thread.started event', { + code: 'RUNTIME_ID_NOT_CAPTURED', details: { adapter: this.name }, + }); + } + return { areaId: text(areaId, 'areaId'), runtimeSessionId: text(captured, 'runtimeSessionId') }; + } + + getStatus() { return unsupportedCapability(this.name, 'getStatus'); } + + listSessions() { return unsupportedCapability(this.name, 'listSessions'); } + + stopSession() { return unsupportedCapability(this.name, 'stopSession'); } + + installHooks() { return unsupportedCapability(this.name, 'installHooks'); } + + removeHooks() { return unsupportedCapability(this.name, 'removeHooks'); } +} + +export function createCodexAdapter(options) { + return new CodexRuntimeAdapter(options); +} diff --git a/src/cli.mjs b/src/cli.mjs index da63f76..621afa2 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -1,5 +1,6 @@ import { analyzeRepository } from './kernel/analyze.mjs'; import { createClaudeAdapter } from './adapters/claude.mjs'; +import { createCodexAdapter } from './adapters/codex.mjs'; import { CheckService } from './checks/service.mjs'; import { classifyRecoverability, createLocalCanonical, planLocalCanonical } from './canonical/local.mjs'; import { openControlPlane } from './control-plane/service.mjs'; @@ -97,6 +98,13 @@ function withControlPlane(repository, env, callback) { } } +function runtimeAdapters() { + return new Map([ + ['claude', createClaudeAdapter()], + ['codex', createCodexAdapter()], + ]); +} + export async function runCli(argv = process.argv.slice(2), { cwd = process.cwd(), env = process.env } = {}) { const command = argv.find((arg) => !arg.startsWith('-')) ?? 'help'; const json = argv.includes('--json'); @@ -163,9 +171,9 @@ export async function runCli(argv = process.argv.slice(2), { cwd = process.cwd() if (command === 'up') { const control = openControlPlane({ repositoryRoot: repository.root, env }); try { - const adapter = createClaudeAdapter(); + const adapters = runtimeAdapters(); const plan = planFleetUp({ - repositoryRoot: repository.root, controlPlane: control, adapter, fresh: argv.includes('--fresh'), + repositoryRoot: repository.root, controlPlane: control, adapters, fresh: argv.includes('--fresh'), }); if (argv.includes('--dry-run')) { print(plan, { json }); @@ -177,7 +185,7 @@ export async function runCli(argv = process.argv.slice(2), { cwd = process.cwd() }); } print(startFleet({ - plan, controlPlane: control, + plan, controlPlane: control, adapters, executor: (launch) => spawnSync(launch.command, launch.args, { cwd: launch.cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }), @@ -190,7 +198,8 @@ export async function runCli(argv = process.argv.slice(2), { cwd = process.cwd() if (command === 'down') { const control = openControlPlane({ repositoryRoot: repository.root, env }); try { - const plan = planFleetDown({ repositoryRoot: repository.root, controlPlane: control }); + const adapters = runtimeAdapters(); + const plan = planFleetDown({ repositoryRoot: repository.root, controlPlane: control, adapters }); if (argv.includes('--dry-run')) { print(plan, { json }); return plan.canProceed ? 0 : 1; @@ -200,14 +209,18 @@ export async function runCli(argv = process.argv.slice(2), { cwd = process.cwd() code: 'APPROVAL_REQUIRED', }); } - const adapter = createClaudeAdapter({ + const runtimeStoppers = new Map([ + ['claude', createClaudeAdapter({ runner: (executable, args) => spawnSync(executable, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }), - }); + })], + ['codex', createCodexAdapter()], + ]); print(stopFleet({ plan, controlPlane: control, - stopRuntime: (action) => adapter.stopSession({ runtimeSessionId: action.runtimeSessionId }), + stopRuntime: (action) => runtimeStoppers.get(action.runtime) + .stopSession({ runtimeSessionId: action.runtimeSessionId }), }), { json }); return 0; } finally { diff --git a/src/kernel/domains.mjs b/src/kernel/domains.mjs index a46588a..b0ec9b6 100644 --- a/src/kernel/domains.mjs +++ b/src/kernel/domains.mjs @@ -136,6 +136,7 @@ function domainFromComponent(component) { shared_paths: [], neighbours: [], required_checks: [], + runtime: 'claude', evidence: component.evidence, }; } @@ -151,6 +152,7 @@ export function proposeDomains({ repository, analysis }) { id: 'core', title: 'Core project', kind: 'development', scope: ['initial project implementation and architecture'], not_scope: [], owned_paths: ['**'], shared_paths: [], neighbours: [], required_checks: [], + runtime: 'claude', evidence: analysis.inventory.manifests, }); } @@ -162,6 +164,7 @@ export function proposeDomains({ repository, analysis }) { not_scope: ['implementing domain features solely to make checks pass'], owned_paths: ['test/**', 'tests/**', 'e2e/**'], shared_paths: [], neighbours: [], required_checks: [], evidence: graph.verificationSurfaces.slice(0, 12), + runtime: 'claude', }); } if (graph.operationalSurfaces.some((path) => /deploy|release/i.test(path))) { @@ -171,6 +174,7 @@ export function proposeDomains({ repository, analysis }) { not_scope: ['feature implementation', 'deployment without configured authority'], owned_paths: graph.operationalSurfaces.filter((path) => /deploy|release|workflow/i.test(path)).slice(0, 20), shared_paths: [], neighbours: [], required_checks: [], + runtime: 'claude', evidence: graph.operationalSurfaces.slice(0, 12), }); } @@ -274,6 +278,9 @@ export function validateApprovedProposal({ proposal, repository }) { if (!domain.scope?.length) problems.push(`domain ${domain.id} has no scope`); if (!Array.isArray(domain.not_scope)) problems.push(`domain ${domain.id} has no not_scope list`); if (!domain.owned_paths?.length) problems.push(`domain ${domain.id} has no owned paths`); + if (domain.runtime !== undefined && (typeof domain.runtime !== 'string' || !domain.runtime.trim())) { + problems.push(`domain ${domain.id} has an invalid runtime`); + } } for (const collision of proposal?.collisions ?? []) { if (!collision.resolution?.strategy) problems.push(`collision ${collision.domains?.join('/')} has no resolution`); diff --git a/src/kernel/install.mjs b/src/kernel/install.mjs index e97e06c..9659e79 100644 --- a/src/kernel/install.mjs +++ b/src/kernel/install.mjs @@ -34,7 +34,11 @@ function initialFiles({ repository, projectId, createdAt, proposal }) { allow_force_push: false, allow_bare_stash: false, }, synchronization: { strategy: 'dispatcher-managed', auto_merge_worktrees: false }, - runtimes: { default: 'claude', claude: { model: 'opus', background: true } }, + runtimes: { + default: 'claude', + claude: { model: 'opus', background: true }, + codex: { sandbox: 'workspace-write', approval: 'approve-for-me' }, + }, checks: proposal.checks ?? [], resources: proposal.resources ?? [], integration: { @@ -42,7 +46,7 @@ function initialFiles({ repository, projectId, createdAt, proposal }) { required_checks: (proposal.checks ?? []).map((check) => check.id), landing_authority: ['session-manager'], }, - session_manager: { id: 'session-manager', start_last: true }, + session_manager: { id: 'session-manager', runtime: 'claude', start_last: true }, domains: proposal.domains.map((domain) => ({ id: domain.id, title: domain.title, @@ -52,6 +56,7 @@ function initialFiles({ repository, projectId, createdAt, proposal }) { shared_paths: domain.shared_paths ?? [], neighbours: domain.neighbours ?? [], required_checks: domain.required_checks ?? [], + runtime: domain.runtime ?? 'claude', })), }; const roster = { @@ -61,6 +66,7 @@ function initialFiles({ repository, projectId, createdAt, proposal }) { scope: ['routing', 'priority', 'ownership rulings', 'fleet health'], not_scope: ['project feature implementation by default'], neighbours: ['all'], + runtime: config.session_manager.runtime, }, ...config.domains], }; diff --git a/src/runtime/lifecycle.mjs b/src/runtime/lifecycle.mjs index bc5117d..9692149 100644 --- a/src/runtime/lifecycle.mjs +++ b/src/runtime/lifecycle.mjs @@ -4,6 +4,7 @@ import { dirname, join } from 'node:path'; import { TorchError } from '../kernel/errors.mjs'; import { readInstallManifest } from '../kernel/install.mjs'; import { loadFleetDefinition } from '../kernel/worktrees.mjs'; +import { validateRuntimeAdapter } from '../adapters/runtime.mjs'; function localStateRoot(manifest) { const record = (manifest.external ?? []).find((entry) => entry.type === 'local-state'); @@ -22,6 +23,21 @@ function orderedAreas(roster) { ...roster.areas.filter((area) => area.id === 'session-manager')]; } +function adapterMap({ adapter, adapters } = {}) { + if (adapters instanceof Map) return adapters; + if (adapters && typeof adapters === 'object') return new Map(Object.entries(adapters)); + if (adapter) return new Map([[adapter.name, adapter]]); + return new Map(); +} + +function configuredRuntime(area, config) { + if (area.id === 'session-manager') { + return area.runtime ?? config.session_manager?.runtime ?? config.runtimes?.default; + } + return area.runtime ?? config.domains?.find((domain) => domain.id === area.id)?.runtime + ?? config.runtimes?.default; +} + function promptPaths(stateRoot, worktree, areaId) { return { common: join(worktree.path, '.torch', 'prompts', 'COMMON.md'), @@ -59,11 +75,12 @@ function inspectWorktree(worktree) { }; } -export function planFleetUp({ repositoryRoot, controlPlane, adapter, fresh = false } = {}) { +export function planFleetUp({ repositoryRoot, controlPlane, adapter, adapters, fresh = false } = {}) { const { config, roster } = loadFleetDefinition(repositoryRoot); const manifest = readInstallManifest(repositoryRoot); const worktrees = worktreesByArea(manifest); const stateRoot = localStateRoot(manifest); + const runtimes = adapterMap({ adapter, adapters }); const actions = []; const blockers = []; @@ -73,23 +90,39 @@ export function planFleetUp({ repositoryRoot, controlPlane, adapter, fresh = fal blockers.push({ areaId: area.id, code: 'WORKTREE_MISSING' }); continue; } + const runtime = configuredRuntime(area, config); + const runtimeAdapter = runtimes.get(runtime); + if (!runtimeAdapter) { + blockers.push({ areaId: area.id, code: 'RUNTIME_ADAPTER_MISSING', runtime }); + continue; + } + if (runtimeAdapter.name !== runtime) { + blockers.push({ + areaId: area.id, code: 'RUNTIME_ADAPTER_MISMATCH', runtime, adapter: runtimeAdapter.name, + }); + continue; + } + validateRuntimeAdapter(runtimeAdapter); const identity = controlPlane.identity(area.id); const prompts = promptPaths(stateRoot, worktree, area.id); - const model = area.model ?? config.runtimes?.claude?.model ?? 'opus'; - const background = config.runtimes?.claude?.background ?? true; - const shouldResume = !fresh && Boolean(identity.runtimeSessionId); + const runtimeConfig = config.runtimes?.[runtime] ?? {}; + const model = area.model ?? runtimeConfig.model; + const background = runtimeConfig.background ?? true; + const shouldResume = !fresh && identity.runtime === runtime && Boolean(identity.runtimeSessionId); const runtimePlan = shouldResume - ? adapter.resumeSession({ + ? runtimeAdapter.resumeSession({ areaId: area.id, runtimeSessionId: identity.runtimeSessionId, worktree: worktree.path, model, background, message: startupMessage(area.id, false), }) - : adapter.createSession({ + : runtimeAdapter.createSession({ areaId: area.id, title: area.title, worktree: worktree.path, promptFile: prompts.combined, firstMessage: startupMessage(area.id, true), model, background, }); actions.push({ areaId: area.id, title: area.title, mode: shouldResume ? 'resume' : 'create', - runtime: adapter.name, runtimeSessionId: runtimePlan.runtimeSessionId, + runtime: runtimeAdapter.name, runtimeSessionId: runtimePlan.runtimeSessionId, + requiresRuntimeIdCapture: runtimePlan.requiresRuntimeIdCapture ?? false, + completionState: runtimePlan.completionState ?? 'starting', worktree: worktree.path, branch: worktree.branch, promptFile: prompts.combined, promptSources: [prompts.common, prompts.area], launch: runtimePlan.launch, }); @@ -107,7 +140,7 @@ function writeCombinedPrompt(action) { writeFileSync(action.promptFile, `${content}\n`, { encoding: 'utf8', mode: 0o600 }); } -export function startFleet({ plan, controlPlane, executor } = {}) { +export function startFleet({ plan, controlPlane, executor, adapters } = {}) { if (!plan?.canProceed) { throw new TorchError('Fleet startup plan has unresolved blockers', { code: 'FLEET_NOT_READY_TO_START', details: plan?.blockers ?? [], @@ -119,6 +152,7 @@ export function startFleet({ plan, controlPlane, executor } = {}) { }); } const started = []; + const runtimes = adapterMap({ adapters }); for (const action of plan.actions) { controlPlane.assertIdentity(action.areaId); if (action.mode === 'create') writeCombinedPrompt(action); @@ -133,31 +167,70 @@ export function startFleet({ plan, controlPlane, executor } = {}) { details: { areaId: action.areaId, status: result.status, stderr: result.stderr ?? null, started }, }); } + let runtimeSessionId = action.runtimeSessionId; + if (action.requiresRuntimeIdCapture) { + const runtimeAdapter = runtimes.get(action.runtime); + if (!runtimeAdapter) { + controlPlane.reportStatus({ + areaId: action.areaId, state: 'offline', runtime: action.runtime, + summary: 'Runtime launched but its durable session ID could not be captured.', + }); + throw new TorchError(`Fleet startup could not capture a runtime ID for ${action.areaId}`, { + code: 'RUNTIME_ADAPTER_MISSING', details: { areaId: action.areaId, runtime: action.runtime, started }, + }); + } + try { + runtimeSessionId = runtimeAdapter.captureRuntimeId({ + areaId: action.areaId, runtimeSessionId, stdout: result?.stdout, output: result?.output, + }).runtimeSessionId; + } catch (error) { + controlPlane.reportStatus({ + areaId: action.areaId, state: 'offline', runtime: action.runtime, + summary: 'Runtime launch completed without a capturable durable session ID.', + }); + throw new TorchError(`Fleet startup could not capture a runtime ID for ${action.areaId}`, { + code: 'FLEET_START_FAILED', + details: { areaId: action.areaId, cause: error.code ?? error.message, started }, + }); + } + } const identity = controlPlane.reportStatus({ - areaId: action.areaId, state: 'starting', runtime: action.runtime, - runtimeSessionId: action.runtimeSessionId, summary: `${action.mode} requested`, + areaId: action.areaId, state: action.completionState, runtime: action.runtime, + runtimeSessionId, summary: action.completionState === 'idle' + ? `${action.mode} turn completed` : `${action.mode} requested`, }); started.push({ areaId: action.areaId, runtimeSessionId: identity.runtimeSessionId, mode: action.mode }); } return { projectId: plan.projectId, started, mutationPerformed: true }; } -export function planFleetDown({ repositoryRoot, controlPlane } = {}) { +export function planFleetDown({ repositoryRoot, controlPlane, adapters } = {}) { const { roster } = loadFleetDefinition(repositoryRoot); const manifest = readInstallManifest(repositoryRoot); const worktrees = worktreesByArea(manifest); + const runtimes = adapterMap({ adapters }); + const adapterRegistryProvided = adapters !== undefined; const actions = orderedAreas(roster).map((area) => { const identity = controlPlane.identity(area.id); const worktree = worktrees.get(area.id); return { areaId: area.id, state: identity.state, runtime: identity.runtime, runtimeSessionId: identity.runtimeSessionId, + requiresRuntimeStop: identity.runtimeSessionId + ? runtimes.get(identity.runtime)?.capabilities.stopSession !== false + : false, + runtimeAdapterMissing: Boolean( + adapterRegistryProvided && identity.runtimeSessionId && !runtimes.has(identity.runtime), + ), worktree: worktree ? inspectWorktree(worktree) : null, }; }); const blockers = actions .filter((action) => ['starting', 'working', 'stopping'].includes(action.state)) .map((action) => ({ areaId: action.areaId, state: action.state, code: 'ACTIVE_SESSION' })); + blockers.push(...actions + .filter((action) => action.runtimeAdapterMissing) + .map((action) => ({ areaId: action.areaId, runtime: action.runtime, code: 'RUNTIME_ADAPTER_MISSING' }))); return { action: 'fleet-down', projectId: manifest.projectId, stateRoot: localStateRoot(manifest), actions, blockers, canProceed: blockers.length === 0, mutationPerformed: false, @@ -177,8 +250,14 @@ export function stopFleet({ plan, controlPlane, stopRuntime, now = () => new Dat } const stopped = []; for (const action of plan.actions) { - if (!action.runtimeSessionId || action.state === 'offline') { - stopped.push({ areaId: action.areaId, runtimeSessionId: action.runtimeSessionId, alreadyOffline: true }); + if (!action.runtimeSessionId || action.state === 'offline' || action.requiresRuntimeStop === false) { + if (action.state !== 'offline') { + controlPlane.reportStatus({ areaId: action.areaId, state: 'offline', summary: 'No resident runtime process to stop.' }); + } + stopped.push({ + areaId: action.areaId, runtimeSessionId: action.runtimeSessionId, + alreadyOffline: true, residentProcess: action.requiresRuntimeStop !== false, + }); continue; } controlPlane.reportStatus({ areaId: action.areaId, state: 'stopping', summary: 'TORCH wind-down requested.' }); diff --git a/test/fleet/lifecycle.test.mjs b/test/fleet/lifecycle.test.mjs index fb7ea35..125db6d 100644 --- a/test/fleet/lifecycle.test.mjs +++ b/test/fleet/lifecycle.test.mjs @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'; import { basename, join } from 'node:path'; import test from 'node:test'; import { createClaudeAdapter } from '../../src/adapters/claude.mjs'; +import { createCodexAdapter } from '../../src/adapters/codex.mjs'; import { openControlPlane } from '../../src/control-plane/service.mjs'; import { analyzeRepository } from '../../src/kernel/analyze.mjs'; import { proposeDomains } from '../../src/kernel/domains.mjs'; @@ -13,7 +14,7 @@ import { installProject } from '../../src/kernel/install.mjs'; import { createWorktrees } from '../../src/kernel/worktrees.mjs'; import { planFleetDown, planFleetUp, startFleet, stopFleet } from '../../src/runtime/lifecycle.mjs'; -function fleetFixture() { +function fleetFixture({ workerRuntime = 'claude' } = {}) { const root = mkdtempSync(join(tmpdir(), 'torch-lifecycle-')); execFileSync('git', ['init', '-b', 'main', root]); execFileSync('git', ['-C', root, 'config', 'user.email', 'torch-test@example.invalid']); @@ -23,6 +24,7 @@ function fleetFixture() { execFileSync('git', ['-C', root, 'commit', '-m', 'fixture']); let repository = inspectRepository(root); const proposal = proposeDomains({ repository, analysis: analyzeRepository(repository) }); + proposal.domains[0].runtime = workerRuntime; proposal.review = { status: 'approved', reviewedAt: '2026-09-27T00:00:00Z', reviewedBy: 'fixture-owner', notes: [], }; @@ -101,3 +103,46 @@ test('SCN-fleet-wind-down-safety: active work blocks runtime shutdown and failed ); control.close(); }); + +test('SCN-mixed-runtime: Codex and Claude share stable identities while planning and capture stay provider-specific', () => { + const fixture = fleetFixture({ workerRuntime: 'codex' }); + const control = openControlPlane({ repositoryRoot: fixture.root, env: fixture.env }); + const adapters = new Map([ + ['claude', createClaudeAdapter({ idFactory: () => 'claude-manager-1' })], + ['codex', createCodexAdapter({ executable: 'codex' })], + ]); + const plan = planFleetUp({ + repositoryRoot: fixture.root, controlPlane: control, adapters, fresh: true, + }); + assert.equal(plan.canProceed, true); + assert.equal(plan.actions[0].areaId, fixture.worker); + assert.equal(plan.actions[0].runtime, 'codex'); + assert.equal(plan.actions[0].launch.args[0], 'exec'); + assert.equal(plan.actions.at(-1).runtime, 'claude'); + assert.equal(plan.actions.at(-1).runtimeSessionId, 'claude-manager-1'); + + const started = startFleet({ + plan, controlPlane: control, adapters, + executor: (launch) => launch.areaId === fixture.worker + ? { status: 0, stdout: '{"type":"thread.started","thread_id":"codex-worker-1"}\n' } + : { status: 0 }, + }); + assert.deepEqual(started.started.map((item) => item.runtimeSessionId), [ + 'codex-worker-1', 'claude-manager-1', + ]); + assert.equal(control.identity(fixture.worker).runtime, 'codex'); + assert.equal(control.identity(fixture.worker).state, 'idle'); + assert.equal(control.identity('session-manager').runtime, 'claude'); + assert.equal(control.identity('session-manager').state, 'starting'); + + control.reportStatus({ areaId: 'session-manager', state: 'idle', summary: 'Ready to stop.' }); + const down = planFleetDown({ repositoryRoot: fixture.root, controlPlane: control, adapters }); + const stoppedRuntimes = []; + stopFleet({ + plan: down, controlPlane: control, + stopRuntime: (action) => { stoppedRuntimes.push(action.runtime); return { stopped: true }; }, + }); + assert.deepEqual(stoppedRuntimes, ['claude']); + assert.equal(control.identity(fixture.worker).state, 'offline'); + control.close(); +}); diff --git a/test/fleet/mcp-adapter.test.mjs b/test/fleet/mcp-adapter.test.mjs index ffb7be7..c12474e 100644 --- a/test/fleet/mcp-adapter.test.mjs +++ b/test/fleet/mcp-adapter.test.mjs @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; import { createClaudeAdapter } from '../../src/adapters/claude.mjs'; +import { createCodexAdapter } from '../../src/adapters/codex.mjs'; import { openControlPlane } from '../../src/control-plane/service.mjs'; import { analyzeRepository } from '../../src/kernel/analyze.mjs'; import { proposeDomains } from '../../src/kernel/domains.mjs'; @@ -75,6 +76,11 @@ test('SCN-claude-adapter: planning is deterministic, capabilities are honest, an }); assert.equal(adapter.capabilities.liveSteering, false); assert.equal(adapter.capabilities.sendOrSteer, 'durable-fallback'); + const configuration = adapter.configure({ + repositoryRoot: context.root, areaId: context.worker, mcpEntry: MCP_SERVER, + }); + assert.equal(configuration.mcp.name, `torch-${context.worker}`); + assert.equal(configuration.mcp.args.at(-1), context.worker); const launch = adapter.createSession({ areaId: context.worker, title: 'Core', worktree: '/tmp/core', promptFile: '/tmp/core.md', firstMessage: 'Resume TASK-1.', model: 'opus', @@ -92,6 +98,58 @@ test('SCN-claude-adapter: planning is deterministic, capabilities are honest, an assert.throws(() => noRunner.listSessions(), (error) => error.code === 'RUNTIME_EXECUTION_NOT_AUTHORIZED'); }); +test('SCN-codex-adapter: turns are resumable, identity-bound, durable, and capability-honest', () => { + const context = fixture(); + const control = openControlPlane({ repositoryRoot: context.root, env: context.env }); + const calls = []; + const adapter = createCodexAdapter({ + executable: '/opt/codex/bin/codex', + runner: (command, args) => { calls.push({ command, args }); return { status: 1, stderr: 'not attached' }; }, + }); + const configuration = adapter.configure({ + repositoryRoot: context.root, areaId: context.worker, mcpEntry: MCP_SERVER, + }); + assert.equal(configuration.mutationPerformed, false); + assert.deepEqual(configuration.mcp.args.slice(0, 4), [ + 'mcp', 'add', `torch-${context.worker}`, '--', + ]); + assert.equal(configuration.mcp.args.includes(context.worker), true); + + const launch = adapter.createSession({ + areaId: context.worker, worktree: '/tmp/codex-core', promptFile: '/tmp/codex-core.md', + firstMessage: 'Resume TASK-2.', model: 'gpt-test', + }); + assert.equal(launch.runtimeSessionId, null); + assert.equal(launch.requiresRuntimeIdCapture, true); + assert.equal(launch.completionState, 'idle'); + assert.deepEqual(launch.launch.args.slice(0, 7), [ + 'exec', '--json', '--cd', '/tmp/codex-core', '--sandbox', 'workspace-write', '--approve-for-me', + ]); + assert.equal(calls.length, 0, 'creating a launch plan must not execute Codex'); + assert.equal(adapter.captureRuntimeId({ + areaId: context.worker, + stdout: '{"type":"thread.started","thread_id":"codex-thread-123"}\n{"type":"turn.completed"}\n', + }).runtimeSessionId, 'codex-thread-123'); + + const resumed = adapter.resumeSession({ + areaId: context.worker, runtimeSessionId: 'codex-thread-123', worktree: '/tmp/codex-core', + message: 'Continue.', + }); + assert.equal(resumed.launch.args.includes('resume'), true); + assert.equal(resumed.launch.args.includes('codex-thread-123'), true); + + const delivery = adapter.sendOrSteer({ + controlPlane: control, recipient: context.worker, body: 'Check your durable inbox.', + runtimeSessionId: 'codex-thread-123', + }); + assert.equal(delivery.delivery, 'durable'); + assert.equal(delivery.liveSteeringAttempted, true); + assert.equal(control.readMessages({ recipient: context.worker }).at(-1).body, 'Check your durable inbox.'); + assert.throws(() => adapter.listSessions(), (error) => error.code === 'RUNTIME_CAPABILITY_UNSUPPORTED'); + assert.throws(() => adapter.stopSession(), (error) => error.code === 'RUNTIME_CAPABILITY_UNSUPPORTED'); + control.close(); +}); + test('SCN-mcp-stdio: an MCP host can initialize and list the TORCH tool surface over stdio', async () => { const context = fixture(); const child = spawn(process.execPath, [MCP_SERVER, '--root', context.root, '--area', context.worker], {