mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-09 01:51:39 +00:00
feat(codex): share sessions across terminal and web
Use one native app-server for terminal, Web and phone clients while retaining the existing CLI and Runner lifecycle. Synchronize native queues, permissions, question history and steering state; preserve explicit permission precedence and per-turn usage models. Resume inactive clear commands through Runner and reject independent child cold resumes. Add shared-runtime regression tests, generated protocol fixtures and lifecycle documentation.
This commit is contained in:
@@ -125,8 +125,6 @@ describe('buildCliArgs', () => {
|
||||
'codex',
|
||||
'resume',
|
||||
'codex-thread-1',
|
||||
'--hapi-starting-mode',
|
||||
'remote',
|
||||
'--started-by',
|
||||
'runner',
|
||||
'--existing-session-id',
|
||||
|
||||
+43
-2
@@ -28,6 +28,7 @@ import { join } from 'path';
|
||||
import { buildMachineMetadata } from '@/agent/sessionFactory';
|
||||
import { resolveWorkspaceRoots } from '@/utils/workspaceRoot';
|
||||
import { hashRunnerCliApiToken, hashRunnerExtraHeaders } from './runnerIdentity';
|
||||
import { readRuntimes, runtimeMayBeAlive, runtimeAuthHash } from '@/codex/shared/registry';
|
||||
import { scheduleCursorModelsPrewarm } from '@/modules/common/cursorModelsPrewarm';
|
||||
import { isLinkedGitWorktree } from '@/utils/isLinkedGitWorktree';
|
||||
import { agentUnavailableMessage, getAgentAvailability } from '@/agent/agentAvailability';
|
||||
@@ -414,7 +415,9 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}):
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
const getCurrentChildren = () => Array.from(pidToTrackedSession.values());
|
||||
const getCurrentChildren = () => Array.from(pidToTrackedSession.values()).flatMap(session => session.sharedSessions
|
||||
? Object.entries(session.sharedSessions).map(([happySessionId, metadata]) => ({ ...session, happySessionId, happySessionMetadataFromLocalWebhook: metadata }))
|
||||
: [session]);
|
||||
|
||||
// Handle webhook from HAPI session reporting itself
|
||||
const onHappySessionWebhook = (sessionId: string, sessionMetadata: Metadata) => {
|
||||
@@ -432,6 +435,18 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}):
|
||||
// Check if we already have this PID (runner-spawned)
|
||||
const existingSession = pidToTrackedSession.get(pid);
|
||||
|
||||
if (existingSession && sessionMetadata.capabilities?.concurrentClients) {
|
||||
existingSession.sharedSessions ??= {};
|
||||
if (sessionMetadata.lifecycleState === 'archived') {
|
||||
delete existingSession.sharedSessions[sessionId];
|
||||
return;
|
||||
}
|
||||
existingSession.sharedSessions[sessionId] = sessionMetadata;
|
||||
invalidateVerifiedExit(sessionId);
|
||||
// Native /new or /fork cannot replace the primary spawn confirmation.
|
||||
if (existingSession.happySessionId && existingSession.happySessionId !== sessionId) return;
|
||||
}
|
||||
|
||||
if (existingSession && existingSession.startedBy === 'runner') {
|
||||
// Update runner-spawned session with reported data
|
||||
invalidateVerifiedExit(sessionId);
|
||||
@@ -466,6 +481,11 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}):
|
||||
// anything claiming `'runner'` here must be the second case and
|
||||
// should be ignored + terminated instead of silently promoted.
|
||||
if (sessionMetadata.startedBy === 'runner') {
|
||||
// A shared root can report /new after a Runner restart. Unknown is
|
||||
// not proof of an orphan: never kill its sibling roots. Known spawn
|
||||
// timeouts already terminate their ChildProcess tree at the source.
|
||||
// No registry scan/adoption lifecycle is needed for live attachment.
|
||||
if (sessionMetadata.capabilities?.concurrentClients) return;
|
||||
logger.debug(
|
||||
`[RUNNER RUN] Ignoring late webhook from orphaned runner-spawned PID ${pid} (session ${sessionId}). Terminating child.`
|
||||
);
|
||||
@@ -482,6 +502,7 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}):
|
||||
|
||||
// New session started externally (terminal)
|
||||
const trackedSession: TrackedSession = {
|
||||
...(sessionMetadata.capabilities?.concurrentClients ? { sharedSessions: { [sessionId]: sessionMetadata } } : {}),
|
||||
startedBy: 'hapi directly - likely by user from terminal',
|
||||
happySessionId: sessionId,
|
||||
happySessionMetadataFromLocalWebhook: sessionMetadata,
|
||||
@@ -968,6 +989,22 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}):
|
||||
const stopSession = async (sessionId: string): Promise<'stopped' | 'already_gone' | 'still_alive'> => {
|
||||
logger.debug(`[RUNNER RUN] Attempting to stop session ${sessionId}`);
|
||||
|
||||
const { findRuntime } = await import('@/codex/shared/registry');
|
||||
const sharedRuntime = await findRuntime(sessionId);
|
||||
if (sharedRuntime) {
|
||||
try {
|
||||
const { runtimeControl } = await import('@/codex/shared/frontend');
|
||||
await runtimeControl(sharedRuntime, 'hapi/stopSession', sessionId);
|
||||
const tracked = pidToTrackedSession.get(sharedRuntime.pid);
|
||||
if (tracked?.sharedSessions) delete tracked.sharedSessions[sessionId];
|
||||
return 'stopped';
|
||||
} catch { return 'still_alive'; }
|
||||
}
|
||||
if ((await readRuntimes()).some(runtime => runtime.hub === configuration.apiUrl && runtime.authHash === runtimeAuthHash()
|
||||
&& runtime.sessions[sessionId]?.active && runtimeMayBeAlive(runtime))) return 'still_alive';
|
||||
// Missing registry is not permission to kill siblings in a live execution.
|
||||
if ([...pidToTrackedSession.values()].some(session => session.sharedSessions?.[sessionId])) return 'still_alive';
|
||||
|
||||
// Try to find by sessionId first
|
||||
for (const [pid, session] of pidToTrackedSession.entries()) {
|
||||
if (session.happySessionId === sessionId ||
|
||||
@@ -1081,6 +1118,7 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}):
|
||||
// Handle child process exit
|
||||
const onChildExited = (pid: number) => {
|
||||
const session = pidToTrackedSession.get(pid);
|
||||
for (const id of Object.keys(session?.sharedSessions ?? {})) rememberVerifiedExit(id);
|
||||
const requestedSessionId = session?.requestedHappySessionId ?? pidToRequestedSessionId.get(pid);
|
||||
if (requestedSessionId) rememberVerifiedExit(requestedSessionId);
|
||||
const confirmedSessionId = session?.happySessionId ?? pidToConfirmedSessionId.get(pid);
|
||||
@@ -1145,6 +1183,7 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}):
|
||||
|
||||
// Write initial runner state (no lock needed for state file)
|
||||
const fileState: RunnerLocallyPersistedState = {
|
||||
sharedCodexRuntime: true,
|
||||
pid: process.pid,
|
||||
httpPort: controlPort,
|
||||
startTime: new Date().toLocaleString(),
|
||||
@@ -1567,7 +1606,9 @@ export function buildCliArgs(
|
||||
args.push('--fork-session');
|
||||
}
|
||||
const startingMode = options.startingMode || 'remote';
|
||||
args.push('--hapi-starting-mode', startingMode, '--started-by', 'runner');
|
||||
// Codex shares one engine; Runner owns the wrapper, not a remote mode.
|
||||
if (agent !== 'codex') args.push('--hapi-starting-mode', startingMode);
|
||||
args.push('--started-by', 'runner');
|
||||
// Codex, Cursor ACP, OpenCode, Pi native resume, and Claude message-level
|
||||
// forks reuse the original HAPI row via --existing-session-id.
|
||||
if (agent === 'codex' || agent === 'cursor' || agent === 'pi'
|
||||
|
||||
@@ -9,6 +9,8 @@ import { ChildProcess } from 'child_process';
|
||||
* Session tracking for runner
|
||||
*/
|
||||
export interface TrackedSession {
|
||||
/** All immutable HAPI roots in this shared execution; primary ID remains the spawn ACK. */
|
||||
sharedSessions?: Record<string, Metadata>;
|
||||
startedBy: 'runner' | string;
|
||||
happySessionId?: string;
|
||||
/** HAPI row requested for this process generation before its webhook arrives. */
|
||||
|
||||
Reference in New Issue
Block a user