app
Codex
Public Made by Adomby adom
Codex in Adom Hydrogen: ecosystem setup, dock dashboard and live engineering demos.
← Commit history
Merge PR #101: Preserve Codex UI access across compatible backend upgrades (fix/runtime-upgrade-continuity -> main) merge
Showing changes introduced by the merge (against its first parent).
4 files changed
+255−12
docs/RELOAD-INTEGRATION.md+14−5@@ -43,11 +43,20 @@ beats the watcher, a subsequent editor reload is needed; the watcher does not force reloads or cancel work. A changed binary starts only after all old adapter connections have closed and-the backend reports that every loaded thread is idle. Until then, the new-connection explains that the upgrade is deferred and existing work continues.-Retry after work completes and the previous editor connections close. The-adapter does not silently connect a new frontend to the old backend or force-an active turn to stop. Unrecognized upstream renderer code is left intact+the backend reports that every loaded thread is idle. A newer extension can+connect to the older backend while work continues when their complete generated+app-server JSON schemas (including experimental APIs) match exactly. Compatibility+is not inferred from the extension version or CLI major version. Schema hashes+are cached by executable identity, and new backends retain their schema hash so+removing an old VSIX does not invalidate the attestation.++`adom-codex-runtime --runtime-status` exposes a deferred `upgrade` record, including+the requested/running binary and whether connected views or backend work delayed+replacement. The next connection upgrades once all other views have closed and+the backend is authoritatively idle. The watcher does not proactively swap it.+Unknown/different protocols retain the conservative gate; downgrades, startup+option changes and unrecognized socket owners remain refused. No active turn is+interrupted. Unrecognized upstream renderer code is left intact and reported as unsupported in integration status; it needs a package update. Ordinary panel reloads use the existing backend immediately. This is not
docs/RUNTIME-UPGRADE-VALIDATION.mdadded+70@@ -0,0 +1,70 @@+# Runtime upgrade continuity: issue 2++Verified 2026-09-10 for [adom/codex #2](https://wiki.adom.inc/adom/codex/issues/2).++## Cause and change++The durable runtime's version gate rejected a newer extension when the older+backend had connected clients or active work. The launcher exited and the extension+could not create a usable conversation editor. The navigator warning was not the+cause, as the issue reporter's correction explains.++The runtime now compares the native app-server's generated JSON schema bundles,+including experimental APIs. Matching schemas allow the newer extension to proxy+to the existing writer. Different/unknown schemas still fail closed. It does not+assume compatibility from a shared major version and never downgrades a backend.+Schema hashes are cached by executable identity (bounded to eight entries). New+backends retain an attestation in server.json; legacy backends require their+original, unchanged binary to remain available for attestation.++A failed exclusive-lock conversion restores the shared client lock before reuse.+Backend-idle probe errors cannot authorize termination. Deferred state appears in+`adom-codex-runtime --runtime-status`. Replacement still requires no other proxy+clients and an authoritatively idle backend, on a subsequent connection. This+change does not implement a proactive watcher swap or an interrupt-and-upgrade UI.++## Real-binary regression test++The installed extension builds were used in an isolated CODEX_HOME:++- openai.chatgpt 26.901.22334: native codex-cli 0.153.0.+- openai.chatgpt 26.903.71938: native codex-cli 0.153.4.+- Their complete generated experimental schema bundles matched byte-for-byte.+- A local HTTP/SSE model fixture held a turn open; no real model call or billing.+- A newer client connected while old clients were attached, read the existing+ active turn, and preserved the old backend PID.+- With all old clients gone but that turn still active, the newer client again+ connected without replacing the backend.+- Three proxy replacements preserved the turn ID and delivered its completion.+- Exactly one fixture model request occurred; reconnect did not restart work.+- Once clients closed and work completed, the newer backend replaced the old one+ and reopened the saved conversation successfully.+- An unattested identity change still refused replacement during active work.++Reproduce with the installed native paths in ADOM_TEST_CODEX_OLD,+ADOM_TEST_CODEX_NEW and ADOM_CODEX_NATIVE (the latter selects the normal CLI):++```sh+python3 -m unittest discover -s tests -v+node --test tests/test_webview.cjs tests/test_dashboard.cjs+```++All 17 Python tests and four Node test entries passed. Unit tests also cover+shared-lock retention, failed idle probes, different/unknown protocols, replaced+legacy binaries, retained attestations after VSIX removal, cache invalidation,+and downgrade refusal.++## Local deployment++Only the installed package's runtime/runtime.py was patched from the reviewed+source. No editor reload, backend restart, settings changes, auth changes, or+arav-rog calls occurred. This is a local runtime patch, not a registry publication.++Before and after: native backend PID 49708, extension 26.903.71938. A fresh proxy+using the installed runtime initialized and listed three loaded threads. The+editor tab API retained the existing titled conversation editors. No new live+user turn was submitted and no UI reload was used to manufacture an upgrade.+The actual version transition was exercised in the isolated regression fixture.++Source and installed runtime SHA256:+`5c3a3cc957df5663bc08ebea0458c7b3e2a2f5afa5c5dcbd579da19c6af9b927`.
runtime/runtime.py+89−4@@ -12,6 +12,7 @@ import sys import threading import time import signal+import tempfile sys.path.insert(0, str(Path(__file__).resolve().parent / 'vendor')) @@ -119,6 +120,66 @@ def extension_version(binary): return tuple(map(int, match[1].split('.'))) if match else None +def protocol_fingerprint(binary, directory):+ """Cache the complete native schema by executable identity, not VSIX version."""+ before = identity(binary)+ key = hashlib.sha256(json.dumps(before).encode()).hexdigest()+ path = directory / 'protocols.json'+ try:+ cache = json.loads(path.read_text())+ if not isinstance(cache, dict): cache = {}+ except (OSError, ValueError):+ cache = {}+ if key in cache: return cache[key]+ with tempfile.TemporaryDirectory(prefix='codex-protocol-') as temporary:+ subprocess.run([str(binary), 'app-server', 'generate-json-schema',+ '--experimental', '--out', temporary], check=True,+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,+ stderr=subprocess.PIPE, timeout=15)+ files = sorted(Path(temporary).rglob('*.json'))+ if not files: raise RuntimeError('Native app-server schema generation returned no schemas')+ digest = hashlib.sha256()+ for file in files:+ schema = json.loads(file.read_text())+ digest.update(json.dumps([str(file.relative_to(temporary)), schema],+ sort_keys=True, separators=(',', ':')).encode())+ if identity(binary) != before:+ raise RuntimeError('Codex binary changed while checking its protocol')+ cache[key] = digest.hexdigest()+ cache = dict(list(cache.items())[-8:])+ temporary = path.with_suffix('.tmp')+ temporary.write_text(json.dumps(cache) + '\n')+ temporary.replace(path)+ return cache[key]+++def compatible_backend(metadata, binary, directory):+ running = Path(metadata.get('binary', ''))+ if not extension_version(binary) or not extension_version(running): return False+ try:+ running_protocol = metadata.get('protocolFingerprint')+ if not running_protocol:+ # Legacy servers have no saved attestation. A replaced/deleted path+ # cannot prove which protocol their running image implements.+ if metadata.get('identity') != identity(running): return False+ running_protocol = protocol_fingerprint(running, directory)+ return running_protocol == protocol_fingerprint(binary, directory)+ except (OSError, ValueError, RuntimeError, subprocess.SubprocessError):+ return False+++def defer_upgrade(directory, metadata, binary, reason):+ record = {'state': 'deferred', 'reason': reason, 'protocolCompatible': True,+ 'runningBinary': metadata['binary'], 'requestedBinary': str(binary),+ 'pid': metadata['pid'], 'checkedAt': time.time(),+ 'message': 'Connected to the existing compatible backend. Active work is preserved; '+ 'the next connection can upgrade after all views close and work is idle.'}+ path = directory / 'upgrade.json'+ temporary = path.with_suffix('.tmp')+ temporary.write_text(json.dumps(record, indent=2) + '\n')+ temporary.replace(path)++ def backend_idle(sock): """Ask the backend; disk history can lag an active turn.""" with unix_connect(str(sock), compression=None, max_size=None, open_timeout=5,@@ -174,15 +235,30 @@ def ensure_server(binary, args, client_guard=None): if requested_version and running_version and requested_version < running_version: raise RuntimeError('This editor uses an older Codex extension than the running backend. ' 'Reload the editor to use the updated extension; backend downgrade refused.')- # An extension upgrade may switch protocols. Drain the old version- # only with no other proxy clients and an authoritatively idle server.+ # Matching generated schemas permit reconnecting without replacing+ # the writer. Different/unknown protocols retain the conservative gate.+ compatible = compatible_backend(metadata, binary, directory) if client_guard is None: raise RuntimeError('Backend version differs; upgrade requires exclusive client ownership') try: fcntl.flock(client_guard, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError:+ if compatible:+ # Failed lock conversion may drop our shared lock on Linux.+ fcntl.flock(client_guard, fcntl.LOCK_SH)+ defer_upgrade(directory, metadata, binary, 'connected_views')+ return sock raise RuntimeError('Codex updated; the previous version still has connected views. ' 'Its work is preserved. Close those views after work finishes, then retry.')- if not backend_idle(sock):+ try:+ idle = backend_idle(sock)+ except Exception:+ if not compatible: raise+ idle = False+ if not idle:+ if compatible:+ fcntl.flock(client_guard, fcntl.LOCK_SH)+ defer_upgrade(directory, metadata, binary, 'backend_busy_or_unverified')+ return sock raise RuntimeError('Codex updated while a turn is running. Its work is preserved; ' 'retry when it finishes to activate the new version.') if process_matches(metadata): os.kill(metadata['pid'], signal.SIGTERM)@@ -193,6 +269,10 @@ def ensure_server(binary, args, client_guard=None): if socket_alive(sock): raise RuntimeError('An unrecognized process owns the Codex runtime socket') sock.unlink(missing_ok=True)+ protocol = None+ if extension_version(binary):+ try: protocol = protocol_fingerprint(binary, directory)+ except (OSError, ValueError, RuntimeError, subprocess.SubprocessError): pass log = directory / 'server.log' with log.open('ab') as output: process = subprocess.Popen(@@ -201,6 +281,7 @@ def ensure_server(binary, args, client_guard=None): start_new_session=True, close_fds=True, ) metadata = {'pid': process.pid, 'binary': str(binary), 'identity': identity(binary), 'args': args,+ 'protocolFingerprint': protocol, 'socket': str(sock), 'started_at': time.time(), 'start_ticks': Path(f'/proc/{process.pid}/stat').read_text() .rsplit(')', 1)[1].split()[19]}@@ -212,6 +293,7 @@ def ensure_server(binary, args, client_guard=None): if process.poll() is not None: raise RuntimeError(f'Codex backend exited ({process.returncode}); inspect {log}') if socket_alive(sock):+ (directory / 'upgrade.json').unlink(missing_ok=True) return sock time.sleep(0.05) raise RuntimeError(f'Codex backend is still starting; inspect {log}')@@ -250,7 +332,10 @@ def main(): if argv == ['--runtime-status']: path = state_dir() / 'server.json' metadata = json.loads(path.read_text()) if path.exists() else {}- print(json.dumps({**metadata, 'alive': process_matches(metadata)}, indent=2))+ pending = path.with_name('upgrade.json')+ upgrade = json.loads(pending.read_text()) if pending.exists() else None+ if upgrade and upgrade.get('pid') != metadata.get('pid'): upgrade = None+ print(json.dumps({**metadata, 'alive': process_matches(metadata), 'upgrade': upgrade}, indent=2)) return binary = native_binary() args = server_args(argv)
tests/test_runtime.py+82−3@@ -73,6 +73,69 @@ class RoutingTests(unittest.TestCase): with patch.dict(os.environ, {'ADOM_CODEX_RUNTIME_STATE': directory, 'CODEX_HOME': '/tmp/b'}): self.assertNotEqual(runtime.state_dir(), first) + def test_compatible_upgrade_preserves_busy_backend_and_connected_views(self):+ for connected, idle in [(False, False), (True, True), (False, RuntimeError('probe failed'))]:+ with self.subTest(connected=connected, idle=idle), tempfile.TemporaryDirectory() as tmp:+ directory = Path(tmp)+ old = directory/'openai.chatgpt-1.0.0-linux-x64/codex'+ new = directory/'openai.chatgpt-1.0.1-linux-x64/codex'+ for binary in [old, new]:+ binary.parent.mkdir(); binary.write_text('fixture')+ metadata = {'pid': 123, 'binary': str(old), 'identity': runtime.identity(old), 'args': ['app-server']}+ (directory/'server.json').write_text(json.dumps(metadata))+ with (directory/'clients.lock').open('a') as guard, (directory/'clients.lock').open('a') as other:+ runtime.fcntl.flock(guard, runtime.fcntl.LOCK_SH)+ if connected: runtime.fcntl.flock(other, runtime.fcntl.LOCK_SH)+ with patch.object(runtime, 'state_dir', return_value=directory), \+ patch.object(runtime, 'process_matches', return_value=True), \+ patch.object(runtime, 'socket_alive', return_value=True), \+ patch.object(runtime, 'protocol_fingerprint', return_value='same-schema'), \+ patch.object(runtime, 'backend_idle', **({'side_effect': idle} if isinstance(idle, Exception) else {'return_value': idle})), \+ patch.object(runtime.os, 'kill') as kill, \+ patch.object(runtime.subprocess, 'Popen') as spawn:+ self.assertEqual(runtime.ensure_server(new, ['app-server'], guard), directory/'server.sock')+ kill.assert_not_called(); spawn.assert_not_called()+ record = json.loads((directory/'upgrade.json').read_text())+ self.assertEqual(record['pid'], 123)+ self.assertTrue(record['protocolCompatible'])+ runtime.fcntl.flock(other, runtime.fcntl.LOCK_UN)+ with self.assertRaises(BlockingIOError):+ runtime.fcntl.flock(other, runtime.fcntl.LOCK_EX | runtime.fcntl.LOCK_NB)++ def test_unknown_changed_or_different_protocol_does_not_attest_compatibility(self):+ with tempfile.TemporaryDirectory() as tmp:+ directory = Path(tmp)+ old = directory/'openai.chatgpt-1.0.0-linux-x64/codex'+ new = directory/'openai.chatgpt-1.0.1-linux-x64/codex'+ for binary in [old, new]:+ binary.parent.mkdir(); binary.write_text('fixture')+ metadata = {'binary': str(old), 'identity': runtime.identity(old)}+ with patch.object(runtime, 'protocol_fingerprint', side_effect=['old-schema', 'new-schema']):+ self.assertFalse(runtime.compatible_backend(metadata, new, directory))+ with patch.object(runtime, 'protocol_fingerprint', side_effect=RuntimeError('unsupported')):+ self.assertFalse(runtime.compatible_backend(metadata, new, directory))+ old.write_text('replacement')+ with patch.object(runtime, 'protocol_fingerprint') as fingerprint:+ self.assertFalse(runtime.compatible_backend(metadata, new, directory))+ fingerprint.assert_not_called()+ metadata['protocolFingerprint'] = 'attested-at-start'+ old.unlink()+ with patch.object(runtime, 'protocol_fingerprint', return_value='attested-at-start'):+ self.assertTrue(runtime.compatible_backend(metadata, new, directory))++ def test_schema_fingerprint_is_cached_by_binary_identity(self):+ with tempfile.TemporaryDirectory() as tmp:+ directory = Path(tmp); binary = directory/'codex'; binary.write_text('fixture')+ def generate(args, **kwargs):+ (Path(args[-1])/'schema.json').write_text('{"type":"object","properties":{}}')+ with patch.object(runtime.subprocess, 'run', side_effect=generate) as run:+ first = runtime.protocol_fingerprint(binary, directory)+ self.assertEqual(runtime.protocol_fingerprint(binary, directory), first)+ self.assertEqual(run.call_count, 1)+ binary.write_text('changed fixture')+ self.assertEqual(runtime.protocol_fingerprint(binary, directory), first)+ self.assertEqual(run.call_count, 2)+ class Client: def __init__(self, env):@@ -160,6 +223,8 @@ class ReconnectTest(unittest.TestCase): 'wire_api = "responses"\nrequires_openai_auth = false\n') env = dict(os.environ, CODEX_HOME=str(home), ADOM_CODEX_RUNTIME_STATE=str(test_root / 'state')) env.pop('OPENAI_API_KEY', None)+ if os.environ.get('ADOM_TEST_CODEX_OLD'):+ env['ADOM_CODEX_NATIVE'] = os.environ['ADOM_TEST_CODEX_OLD'] try: # Simultaneous clients must share one server, even before any # conversation exists. IDs are scoped to each connection.@@ -182,6 +247,20 @@ class ReconnectTest(unittest.TestCase): 'input': [{'type': 'text', 'text': 'Local fixture', 'text_elements': []}]})['turn'] self.assertTrue(started.wait(10), 'Fixture did not receive the model request') self.assertFalse(runtime.backend_idle(Path(json.loads(metadata_path.read_text())['socket'])))+ if os.environ.get('ADOM_TEST_CODEX_NEW'):+ upgrade_env = dict(env, ADOM_CODEX_NATIVE=os.environ['ADOM_TEST_CODEX_NEW'])+ newer = Client(upgrade_env)+ clients.append(newer)+ observed = newer.rpc(2, 'thread/read', {'threadId': thread_id, 'includeTurns': True})['thread']+ self.assertEqual(observed['turns'][-1]['id'], turn['id'])+ self.assertEqual(json.loads(metadata_path.read_text())['pid'], original_pid)+ self.assertEqual(json.loads(metadata_path.with_name('upgrade.json').read_text())['reason'], 'connected_views')+ newer.close(); first.close(); second.close()+ env = upgrade_env+ first = Client(env)+ clients.append(first)+ self.assertEqual(json.loads(metadata_path.read_text())['pid'], original_pid)+ self.assertEqual(json.loads(metadata_path.with_name('upgrade.json').read_text())['reason'], 'backend_busy_or_unverified') second.close() for index in range(3): first.close(abrupt=index % 2 == 0)@@ -193,11 +272,11 @@ class ReconnectTest(unittest.TestCase): self.assertEqual(resumed['turns'][-1]['id'], turn['id']) self.assertEqual(resumed['turns'][-1]['status'], 'inProgress') self.assertEqual(json.loads(metadata_path.read_text())['pid'], original_pid)- # A store update must not connect its new frontend to an old- # backend, nor kill the active turn even with zero clients.+ # An unattested binary change must still fail closed, preserving+ # the active turn even when there are no proxy clients. first.close() original_metadata = json.loads(metadata_path.read_text())- changed_metadata = {**original_metadata, 'identity': ['older-extension']}+ changed_metadata = {**original_metadata, 'identity': ['older-extension'], 'protocolFingerprint': None} metadata_path.write_text(json.dumps(changed_metadata)) rejected = subprocess.run([sys.executable, str(ROOT/'runtime/runtime.py'), '-c', 'features.code_mode_host=true', 'app-server', '--analytics-default-enabled'],