mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-11-06 12:51:54 +00:00
Add mixed Claude and Codex runtimes
This commit is contained in:
1 parent
23d03b624c
commit
1c9834e3d7
12 files changed
+452
-31
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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
@@ -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.' });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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], {
|
||||
|
||||
Reference in new issue
Block a user