Add mixed Claude and Codex runtimes

This commit is contained in:
thePR0M3TH3AN committed 2026-09-27 16:27:13 -04:00
1 parent 23d03b624c
commit 1c9834e3d7
12 files changed
+452 -31

No files matched your search

+11 -6
View File
@@ -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
+7
View File
@@ -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;
+1
View File
@@ -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",
@@ -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
+7 -2
View File
@@ -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.',
};
+184
View File
@@ -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);
}
+20 -7
View File
@@ -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 {
+7
View File
@@ -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`);
+8 -2
View File
@@ -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],
};
+92 -13
View File
@@ -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.' });
+46 -1
View File
@@ -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', '[email protected]']);
@@ -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();
});
+58
View File
@@ -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], {