diff --git a/src/main/orcad/orcad-terminal-census.test.ts b/src/main/orcad/orcad-terminal-census.test.ts index b2465d47902..c70ee02ba32 100644 --- a/src/main/orcad/orcad-terminal-census.test.ts +++ b/src/main/orcad/orcad-terminal-census.test.ts @@ -19,6 +19,7 @@ describe('orcad terminal census', () => { liveSessions: 3, startedSinceActivation: 2, daemonProtocolVersion: 7, + daemonProtocolVersions: [7], inProcessSessions: 0 }) }) @@ -34,18 +35,22 @@ describe('orcad terminal census', () => { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null, + daemonProtocolVersions: [], inProcessSessions: 0 }) }) - it('leaves the protocol unknown when sessions span daemon generations', async () => { + it('names every daemon generation when sessions span them', async () => { const census = await collectOrcadTerminalCensus( 0, - async () => [session(1, 7), session(2, 6)], + async () => [session(1, 7), session(2, 6), session(3, 7)], async () => 0 ) - expect(census.daemonProtocolVersion).toBeNull() - expect(census.liveSessions).toBe(2) + expect(census).toMatchObject({ + liveSessions: 3, + daemonProtocolVersion: null, + daemonProtocolVersions: [6, 7] + }) }) it('leaves the since-activation count unknown when a session has no creation time', async () => { @@ -85,6 +90,7 @@ describe('orcad terminal census', () => { liveSessions: 1, startedSinceActivation: null, daemonProtocolVersion: null, + daemonProtocolVersions: [], inProcessSessions: 1 }) }) diff --git a/src/main/orcad/orcad-terminal-census.ts b/src/main/orcad/orcad-terminal-census.ts index f10e733e34e..1629f2b4ed8 100644 --- a/src/main/orcad/orcad-terminal-census.ts +++ b/src/main/orcad/orcad-terminal-census.ts @@ -31,8 +31,9 @@ export async function collectOrcadTerminalCensus( const timestampsKnown = sessions.every( (session) => Number.isFinite(session.createdAt) && session.createdAt > 0 ) - const protocols = new Set(sessions.map((session) => session.protocolVersion)) - const [protocol] = protocols + const protocols = [...new Set(sessions.map((session) => session.protocolVersion))].sort( + (a, b) => a - b + ) return { liveSessions: sessions.length + inProcess, // Fallback terminals lack creation times, so activation-relative counts remain unknown. @@ -40,8 +41,9 @@ export async function collectOrcadTerminalCensus( timestampsKnown && inProcess === 0 ? sessions.filter((session) => session.createdAt >= activatedAt).length : null, - // Mixed daemon generations cannot establish one protocol owner for build compatibility. - daemonProtocolVersion: protocols.size === 1 && protocol !== undefined ? protocol : null, + // Older desktops read only the scalar, which cannot name mixed generations. + daemonProtocolVersion: protocols.length === 1 ? protocols[0] : null, + daemonProtocolVersions: protocols, inProcessSessions: inProcess } } diff --git a/src/main/ssh/orcad-daemon-protocol-crossing.ts b/src/main/ssh/orcad-daemon-protocol-crossing.ts index 5dd5653ccef..6517234a21b 100644 --- a/src/main/ssh/orcad-daemon-protocol-crossing.ts +++ b/src/main/ssh/orcad-daemon-protocol-crossing.ts @@ -26,10 +26,18 @@ export function orcadBuildCanAttachDaemon( } export type OrcadLiveDaemonCrossing = - | 'no-live-terminals' - | 'attachable' - | 'strands-live-terminals' - | 'unverifiable' + | { state: 'no-live-terminals' } + | { state: 'attachable' } + | { state: 'strands-live-terminals'; unattachable: readonly number[] } + | { state: 'unverifiable' } + +/** Hosts predating the protocol set report only a single-generation scalar. */ +function liveDaemonProtocols(census: OrcadTerminalCensus): readonly number[] | null { + if (census.daemonProtocolVersions !== undefined) { + return census.daemonProtocolVersions + } + return census.daemonProtocolVersion === null ? null : [census.daemonProtocolVersion] +} /** An unknown session count or daemon protocol is planned for as live and unattachable. */ export function assessOrcadLiveDaemonCrossing( @@ -37,12 +45,21 @@ export function assessOrcadLiveDaemonCrossing( incoming: OrcadDaemonProtocolFacts ): OrcadLiveDaemonCrossing { if (census.liveSessions === 0) { - return 'no-live-terminals' + return { state: 'no-live-terminals' } } - if (census.daemonProtocolVersion === null) { - return 'unverifiable' + const protocols = liveDaemonProtocols(census) + if (protocols === null || protocols.length === 0) { + return { state: 'unverifiable' } } - return orcadBuildCanAttachDaemon(incoming, census.daemonProtocolVersion) - ? 'attachable' - : 'strands-live-terminals' + // Every generation must stay reachable; one unattachable daemon strands its terminals. + const unattachable = protocols.filter( + (protocol) => !orcadBuildCanAttachDaemon(incoming, protocol) + ) + return unattachable.length === 0 + ? { state: 'attachable' } + : { state: 'strands-live-terminals', unattachable } +} + +export function describeDaemonProtocols(protocols: readonly number[]): string { + return protocols.length === 1 ? `protocol ${protocols[0]}` : `protocols ${protocols.join(', ')}` } diff --git a/src/main/ssh/orcad-update-plan.test.ts b/src/main/ssh/orcad-update-plan.test.ts index b476a7b54fb..a4f85337df8 100644 --- a/src/main/ssh/orcad-update-plan.test.ts +++ b/src/main/ssh/orcad-update-plan.test.ts @@ -291,6 +291,49 @@ describe('D7 daemon protocol crossing', () => { }) }) + const mixed = (daemonProtocolVersions: number[]) => ({ + liveSessions: 80, + startedSinceActivation: 0, + daemonProtocolVersion: null, + daemonProtocolVersions + }) + + it('forces an update past mixed daemon generations only when the candidate attaches each', () => { + const plan = (daemonProtocolVersions: number[]) => + planOrcadUpdate({ + record: record(), + candidateVersion: '0.3.0+cc01', + candidateDaemonProtocol: PROTOCOL, + census: mixed(daemonProtocolVersions), + force: true + }) + expect(plan([2, 3])).toMatchObject({ action: 'proceed', preservesLiveDaemon: true }) + expect(plan([3, 4])).toMatchObject({ + action: 'defer', + code: 'orcad_update_strands_live_terminals' + }) + expect(plan([])).toMatchObject({ + action: 'defer', + code: 'orcad_update_daemon_protocol_unverifiable' + }) + }) + + it('rolls back over mixed daemon generations only when the target attaches each', () => { + const rollback = (daemonProtocolVersions: number[]) => + assessOrcadRollback({ + record: record(), + snapshotPresent: true, + census: mixed(daemonProtocolVersions), + targetDaemonProtocol: PROTOCOL, + stateWritesSinceActivation: false + }) + expect(rollback([1, 3])).toMatchObject({ safety: 'clean' }) + expect(rollback([2, 4])).toMatchObject({ + safety: 'unsafe', + code: 'orcad_rollback_strands_live_terminals' + }) + }) + it('needs no protocol answer when no terminals are running', () => { const plan = planOrcadUpdate({ record: record(), diff --git a/src/main/ssh/orcad-update-plan.ts b/src/main/ssh/orcad-update-plan.ts index 83e03a92e08..4a6bd61fa4a 100644 --- a/src/main/ssh/orcad-update-plan.ts +++ b/src/main/ssh/orcad-update-plan.ts @@ -3,6 +3,7 @@ import type { OrcadActivationRecord } from '../managed-server/activation/orcad-a import type { OrcadTerminalCensus } from '../../shared/orcad-terminal-census' import { assessOrcadLiveDaemonCrossing, + describeDaemonProtocols, type OrcadDaemonProtocolFacts } from './orcad-daemon-protocol-crossing' @@ -55,18 +56,18 @@ export function planOrcadUpdate(input: { } const crossing = assessOrcadLiveDaemonCrossing(input.census, input.candidateDaemonProtocol) // Force can accept mixed builds, never unreachable terminals. - if (crossing === 'strands-live-terminals') { + if (crossing.state === 'strands-live-terminals') { return { action: 'defer', code: 'orcad_update_strands_live_terminals', reason: - `The live terminal daemon speaks protocol ${String(input.census.daemonProtocolVersion)}, ` + + `A live terminal daemon speaks ${describeDaemonProtocols(crossing.unattachable)}, ` + `which orcad ${input.candidateVersion} cannot attach to. Restarting now would leave ` + - 'every running terminal unreachable. Update when no terminals are running.' + 'its running terminals unreachable. Update when no terminals are running.' } } const { liveSessions } = input.census - if (input.force && crossing === 'unverifiable') { + if (input.force && crossing.state === 'unverifiable') { return { action: 'defer', code: 'orcad_update_daemon_protocol_unverifiable', @@ -209,7 +210,7 @@ export function assessOrcadRollback(input: { } } const crossing = assessOrcadLiveDaemonCrossing(input.census, input.targetDaemonProtocol) - if (crossing === 'unverifiable') { + if (crossing.state === 'unverifiable') { return { safety: 'unsafe', code: 'orcad_rollback_daemon_protocol_unverifiable', @@ -218,13 +219,13 @@ export function assessOrcadRollback(input: { `${target} would reach the terminals still running. Retry when the daemon answers.` } } - if (crossing === 'strands-live-terminals') { + if (crossing.state === 'strands-live-terminals') { return { safety: 'unsafe', code: 'orcad_rollback_strands_live_terminals', reason: - `The live terminal daemon speaks protocol ${String(input.census.daemonProtocolVersion)}, ` + - `which ${target} does not list. Rolling back now would leave every running terminal ` + + `A live terminal daemon speaks ${describeDaemonProtocols(crossing.unattachable)}, ` + + `which ${target} does not list. Rolling back now would leave its running terminals ` + 'unreachable. Roll back when no terminals are running, or deploy forward.' } } diff --git a/src/shared/orcad-terminal-census.ts b/src/shared/orcad-terminal-census.ts index 73b0c6f4384..bc50929a75c 100644 --- a/src/shared/orcad-terminal-census.ts +++ b/src/shared/orcad-terminal-census.ts @@ -9,8 +9,10 @@ export const OrcadTerminalCensusSchema = z.object({ liveSessions: CountSchema, /** Of those, how many started at or after the active version's activation. */ startedSinceActivation: CountSchema, - /** Protocol of the daemon that owns those sessions. */ + /** Protocol of the daemon that owns those sessions; null unless exactly one generation owns them. */ daemonProtocolVersion: z.number().int().positive().nullable(), + /** Protocol of every daemon generation owning those sessions; absent on older hosts. */ + daemonProtocolVersions: z.array(z.number().int().positive()).optional(), /** In-process fallback sessions end with orcad, unlike daemon sessions; absent on older hosts. */ inProcessSessions: CountSchema.optional(), /** Admission-fence expiry, epoch ms; 0 means reopened. Absence never proves older servers fenced admission. */