From 819d60a20379e99d1e8b5416291f4b08cfe51d28 Mon Sep 17 00:00:00 2001 From: sunheyi <1061867552@qq.com> Date: Thu, 20 Aug 2026 11:29:08 +0800 Subject: [PATCH 1/9] fix(desktop): recover runtime host reads after reconnect --- .../runtime-host-session-observer.test.ts | 72 ++++++++++++++++++- apps/desktop/src/main/app-ipc-main.ts | 2 +- .../src/main/runtime-host-desktop-manager.ts | 3 + .../src/main/runtime-host-session-observer.ts | 7 +- packages/runtime-host/src/candidate-entry.ts | 3 +- packages/runtime-host/src/client/launcher.ts | 13 ++++ 6 files changed, 94 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts index c8b9c283cf..e9c2a255e9 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts @@ -646,7 +646,7 @@ test('keeps a bounded transcript batch window in flight until the renderer ackno await observer.close(); }); -test('finishes transcript open against a replacement that arrives while reset delivery waits', async () => { +test('finishes transcript open and replays a stale range request after replacement', async () => { const firstEvents = new AsyncFrameQueue(); const secondEvents = new AsyncFrameQueue(); const message: StoredMessage = { @@ -658,6 +658,7 @@ test('finishes transcript open against a replacement that arrives while reset de modelId: 'test-model', }; let opens = 0; + let rangeLoads = 0; const observer = new RuntimeHostSessionObserver({ client: { openSession: async () => { @@ -667,6 +668,48 @@ test('finishes transcript open against a replacement that arrives while reset de snapshot: continuitySnapshot(), transcript: Promise.resolve([message]), events, + transcriptBootstrap: { + throughSequence: 0, + overlayMessageCount: 0, + durable: { + kind: 'page', + sessionId: 'session-1', + source: 'durable', + direction: 'older', + throughSequence: 0, + rawBytes: 1, + fragments: [], + nextCursor: 'older', + }, + overlay: { + kind: 'page', + sessionId: 'session-1', + source: 'overlay', + direction: 'older', + throughSequence: null, + rawBytes: 0, + fragments: [], + nextCursor: null, + }, + }, + loadTranscriptOverlay: async () => [], + decodeTranscriptPage: async (page) => ({ + messages: page.rawBytes === 1 ? [{ identity: 0, message }] : [], + nextCursor: page.nextCursor, + }), + loadTranscriptPage: async (input) => { + rangeLoads += 1; + return { + kind: 'page', + sessionId: 'session-1', + source: input.source, + direction: input.direction, + throughSequence: input.throughSequence, + rawBytes: 0, + fragments: [], + nextCursor: null, + }; + }, async close() { events.end(); }, @@ -676,10 +719,21 @@ test('finishes transcript open against a replacement that arrives while reset de emitSessionsChanged() {}, }); const batches: DesktopTranscriptBatch[] = []; + let autoAcknowledge = false; const opening = observer.openTranscript('session-1', 'consumer-recovery', { id: 22, send(_channel, batch) { batches.push(batch); + if (autoAcknowledge) { + queueMicrotask(() => + observer.acknowledgeTranscript( + 'consumer-recovery', + batch.generation, + batch.deliverySequence, + 22, + ), + ); + } }, once() {}, off() {}, @@ -690,6 +744,7 @@ test('finishes transcript open against a replacement that arrives while reset de ); await waitFor(() => batches.length === 4); + const staleGeneration = batches[0]!.generation; firstEvents.push({ kind: 'subscription.closed', hostEpoch: 'host-1', @@ -722,6 +777,21 @@ test('finishes transcript open against a replacement that arrives while reset de const opened = await result; assert.equal(opened.error, undefined); assert.equal(opened.value?.generation, batches.at(-1)?.generation); + assert.notEqual(opened.value?.generation, staleGeneration); + rangeLoads = 0; + autoAcknowledge = true; + await assert.doesNotReject(() => + observer.loadTranscriptBefore( + { + consumerId: 'consumer-recovery', + generation: staleGeneration, + anchorSequence: 0, + maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + }, + 22, + ), + ); + assert.equal(rangeLoads, 1); await observer.close(); }); diff --git a/apps/desktop/src/main/app-ipc-main.ts b/apps/desktop/src/main/app-ipc-main.ts index 93a696cee7..32f1ed55a5 100644 --- a/apps/desktop/src/main/app-ipc-main.ts +++ b/apps/desktop/src/main/app-ipc-main.ts @@ -77,7 +77,7 @@ export function registerAppIpc( // observe the latest selection, not a snapshot taken at registration. const currentProjectRoot = (): Promise => projectRoot.current(); - targetIpc.handle('app:info', async () => { + handleReconnectableRead(targetIpc, 'app:info', async () => { const selection = await deps.projectManagement.current(); const projectPath = allowLocalProjectPaths ? selection.path : ''; return { diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index 89e1f93cd9..1bc5e6e1d7 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -455,6 +455,9 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { signal, starting ? target.input.remote?.sshInteraction : 'batch', ), + onReconnectError: (error) => { + console.warn('[runtime-host] reconnect attempt failed:', error); + }, onFatalError: (error) => { if (!starting && target.valid) { target.valid = false; diff --git a/apps/desktop/src/main/runtime-host-session-observer.ts b/apps/desktop/src/main/runtime-host-session-observer.ts index b77990d844..6c021c695c 100644 --- a/apps/desktop/src/main/runtime-host-session-observer.ts +++ b/apps/desktop/src/main/runtime-host-session-observer.ts @@ -1302,9 +1302,10 @@ export class RuntimeHostSessionObserver { if (targetId !== undefined && consumer.target.id !== targetId) { throw new Error('Desktop transcript consumer belongs to another renderer'); } - if (consumer.generation !== request.generation || replica.generation !== request.generation) { - throw new Error('Desktop transcript generation changed'); - } + // Durable transcript sequence identities belong to the Session, not to a + // Desktop replica generation. Recovery may install a replacement after + // the renderer dispatches a range request; continue that read against the + // current replica so the user's navigation completes across reconnect. return { state, replica, consumer }; } diff --git a/packages/runtime-host/src/candidate-entry.ts b/packages/runtime-host/src/candidate-entry.ts index a9a3af2555..6b0b7f14c4 100644 --- a/packages/runtime-host/src/candidate-entry.ts +++ b/packages/runtime-host/src/candidate-entry.ts @@ -67,7 +67,8 @@ export async function runExecutionCandidateEntry( const stopWatch = hooks.onWon?.(result.host); try { await runRuntimeHostProcessLifecycle(result.host); - } catch { + } catch (error) { + console.error('[runtime-host] lifecycle failed:', error); process.exitCode = 1; } finally { stopWatch?.(); diff --git a/packages/runtime-host/src/client/launcher.ts b/packages/runtime-host/src/client/launcher.ts index 5059e42b35..d890d681c5 100644 --- a/packages/runtime-host/src/client/launcher.ts +++ b/packages/runtime-host/src/client/launcher.ts @@ -40,6 +40,7 @@ export function launchDetachedRuntimeHostCandidate( ): DetachedCandidateLaunch { const startupAttemptId = randomUUID(); const child = spawnCandidate(input, true, startupAttemptId); + observeCandidateExit(child); const startupFailure = readStartupFailure(child, startupAttemptId); const spawned = spawnedPid(child).then(({ pid }) => { child.unref(); @@ -53,6 +54,7 @@ export function launchOwnedRuntimeHostCandidate(input: DetachedCandidateInput): } { const startupAttemptId = randomUUID(); const child = spawnCandidate(input, false, startupAttemptId); + observeCandidateExit(child); const startupFailure = readStartupFailure(child, startupAttemptId); const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { child.once('exit', (code, signal) => resolve({ code, signal })); @@ -130,6 +132,17 @@ function spawnedPid(child: ReturnType): Promise<{ pid: number }> { }); } +function observeCandidateExit(child: ReturnType): void { + child.once('exit', (code, signal) => { + const details = { pid: child.pid, code, signal }; + if (code === 0 && signal === null) { + console.info('[runtime-host] candidate exited cleanly', details); + return; + } + console.error('[runtime-host] candidate exited unexpectedly', details); + }); +} + function readStartupFailure( child: ReturnType, startupAttemptId: string, From a89b317ed42291d31beb8cb3ebd75b217b94a99c Mon Sep 17 00:00:00 2001 From: sunheyi <50973219+sunheyi6@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:07:51 +0800 Subject: [PATCH 2/9] fix(desktop): address runtime host recovery review feedback - narrow the transcript range guard to the Session and host epoch, dropping generation from the range request contract so reads keep working across replica replacement and stale Hosts reject instead of reading a different slice - route candidate-exit diagnostics through a caller-provided onExit sink instead of console in the launcher library; Desktop manager owns the sink and candidate-entry logs the generalized lifecycle error message --- .../runtime-host-session-observer.test.ts | 41 ++++++++++++++++- .../main/runtime-host-desktop-candidate.ts | 4 ++ .../src/main/runtime-host-desktop-manager.ts | 16 +++++++ ...runtime-host-session-execution-ipc-main.ts | 3 +- .../src/main/runtime-host-session-observer.ts | 18 ++++++-- apps/desktop/src/preload/preload.ts | 3 +- .../src/preload/transcript-contract.ts | 3 +- .../src/__tests__/owned-candidate.test.ts | 44 ++++++++++++++++++- packages/runtime-host/src/candidate-entry.ts | 7 ++- .../src/client/connect-or-spawn.ts | 6 +++ packages/runtime-host/src/client/index.ts | 1 + packages/runtime-host/src/client/launcher.ts | 25 +++++++---- 12 files changed, 152 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts index e9c2a255e9..70a47653ef 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts @@ -659,6 +659,7 @@ test('finishes transcript open and replays a stale range request after replaceme }; let opens = 0; let rangeLoads = 0; + const requestedAnchors: Array = []; const observer = new RuntimeHostSessionObserver({ client: { openSession: async () => { @@ -699,6 +700,7 @@ test('finishes transcript open and replays a stale range request after replaceme }), loadTranscriptPage: async (input) => { rangeLoads += 1; + requestedAnchors.push(input.anchorSequence); return { kind: 'page', sessionId: 'session-1', @@ -779,12 +781,17 @@ test('finishes transcript open and replays a stale range request after replaceme assert.equal(opened.value?.generation, batches.at(-1)?.generation); assert.notEqual(opened.value?.generation, staleGeneration); rangeLoads = 0; + requestedAnchors.length = 0; autoAcknowledge = true; + // The renderer dispatched this range request before the replacement replica + // was installed; the same Session and Host epoch continue the read against + // the current replica, and the requested slice is what the replica loads. await assert.doesNotReject(() => observer.loadTranscriptBefore( { consumerId: 'consumer-recovery', - generation: staleGeneration, + sessionId: 'session-1', + hostEpoch: 'host-1', anchorSequence: 0, maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, }, @@ -792,6 +799,38 @@ test('finishes transcript open and replays a stale range request after replaceme ), ); assert.equal(rangeLoads, 1); + assert.deepEqual(requestedAnchors, [0]); + // Durable sequence identity is Session- and Host-epoch-scoped: a request + // for a different Session or Host epoch must reject instead of silently + // reading a different slice of the transcript. + await assert.rejects( + () => + observer.loadTranscriptBefore( + { + consumerId: 'consumer-recovery', + sessionId: 'session-1', + hostEpoch: 'other-host', + anchorSequence: 0, + maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + }, + 22, + ), + /Desktop transcript host epoch changed/, + ); + await assert.rejects( + () => + observer.loadTranscriptBefore( + { + consumerId: 'consumer-recovery', + sessionId: 'other-session', + hostEpoch: 'host-1', + anchorSequence: 0, + maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + }, + 22, + ), + /Desktop transcript consumer belongs to another session/, + ); await observer.close(); }); diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index b6b3069fe7..3fd6cb669d 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -14,6 +14,7 @@ import { type ConnectOrSpawnRuntimeHostResult, type RuntimeHostConnection, type RemoteRuntimeHostProfile, + type CandidateExitDetails, } from "@maka/runtime-host/client"; import { INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, @@ -138,6 +139,8 @@ export interface DesktopRuntimeHostCandidateStartInput readonly generation?: string; readonly takeoverHostEpoch?: string; readonly signal?: AbortSignal; + /** Candidate-exit sink forwarded to the launcher; the Desktop owns the sink. */ + readonly onExit?: (details: CandidateExitDetails) => void; readonly remote?: { readonly profile: RemoteRuntimeHostProfile; readonly credential: string; @@ -693,6 +696,7 @@ function connectInput( ? {} : { handshakeTimeoutMs: input.handshakeTimeoutMs }), ...(input.signal === undefined ? {} : { signal: input.signal }), + ...(input.onExit === undefined ? {} : { onExit: input.onExit }), }; } diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index 1bc5e6e1d7..08ba583ae1 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -6,6 +6,7 @@ import { LOCAL_RUNTIME_HOST_PROFILE, sameResolvedRuntimeHostProfileTarget, startRuntimeHostReconnectLifecycle, + type CandidateExitDetails, type ResolvedRuntimeHostProfile, type RuntimeHostReconnectBackoff, type RuntimeHostReconnectLifecycle, @@ -486,10 +487,12 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { sshInteraction: RuntimeHostSshInteraction | undefined, ): Promise { let takeoverHostEpoch: string | undefined; + const inheritedExit = target.input.onExit; while (true) { const result = await this.startCandidate( { ...target.input, + onExit: (details) => this.#reportCandidateExit(inheritedExit, details), ...(target.input.remote ? { remote: { @@ -562,6 +565,19 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { return target.lifecycle; } + /** Desktop-owned candidate-exit diagnostics; honors an embedder-supplied sink. */ + #reportCandidateExit( + inherited: ((details: CandidateExitDetails) => void) | undefined, + details: CandidateExitDetails, + ): void { + inherited?.(details); + if (details.code === 0 && details.signal === null) { + console.info('[runtime-host] candidate exited cleanly', details); + return; + } + console.error('[runtime-host] candidate exited unexpectedly', details); + } + async #waitForReadyCandidate( lifecycle: RuntimeHostReconnectLifecycle, ): Promise { diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index d383d91ec9..c62903a173 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -505,7 +505,8 @@ function normalizeTranscriptRangeRequest(input: unknown): DesktopTranscriptRange } return { consumerId: requiredId(value.consumerId, 'Transcript consumer'), - generation: requiredId(value.generation, 'Transcript generation'), + sessionId: requiredId(value.sessionId, 'Session'), + hostEpoch: requiredId(value.hostEpoch, 'Host epoch'), anchorSequence: anchorSequence as number | null, maxBytes: maxBytes as number, }; diff --git a/apps/desktop/src/main/runtime-host-session-observer.ts b/apps/desktop/src/main/runtime-host-session-observer.ts index 6c021c695c..f28915dd67 100644 --- a/apps/desktop/src/main/runtime-host-session-observer.ts +++ b/apps/desktop/src/main/runtime-host-session-observer.ts @@ -1302,10 +1302,20 @@ export class RuntimeHostSessionObserver { if (targetId !== undefined && consumer.target.id !== targetId) { throw new Error('Desktop transcript consumer belongs to another renderer'); } - // Durable transcript sequence identities belong to the Session, not to a - // Desktop replica generation. Recovery may install a replacement after - // the renderer dispatches a range request; continue that read against the - // current replica so the user's navigation completes across reconnect. + // Durable transcript sequence identities belong to the Session and the + // Runtime Host epoch, not to a Desktop replica generation. Recovery may + // install a replacement replica (new generation, same session and host + // epoch) after the renderer dispatches a range request; continue that + // read against the current replica so navigation completes across + // reconnect. When the Host itself is replaced, sequence identity is not + // preserved, so reject the stale request instead of silently reading a + // different slice. + if (state.sessionId !== request.sessionId) { + throw new Error('Desktop transcript consumer belongs to another session'); + } + if (replica.hostEpoch !== request.hostEpoch) { + throw new Error('Desktop transcript host epoch changed; reopen the transcript'); + } return { state, replica, consumer }; } diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 60863bf20f..995cb35e5b 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1666,7 +1666,8 @@ const makaBridge = { ): Promise => ipcRenderer.invoke(operation, consumerScope, { consumerId, - generation, + sessionId, + hostEpoch: opened.hostEpoch, anchorSequence, maxBytes, }) as Promise; diff --git a/apps/desktop/src/preload/transcript-contract.ts b/apps/desktop/src/preload/transcript-contract.ts index 99b0dd44db..b7205c39de 100644 --- a/apps/desktop/src/preload/transcript-contract.ts +++ b/apps/desktop/src/preload/transcript-contract.ts @@ -41,7 +41,8 @@ export interface DesktopTranscriptOpenResult { export interface DesktopTranscriptRangeRequest { readonly consumerId: string; - readonly generation: string; + readonly sessionId: string; + readonly hostEpoch: string; readonly anchorSequence: number | null; readonly maxBytes: number; } diff --git a/packages/runtime-host/src/__tests__/owned-candidate.test.ts b/packages/runtime-host/src/__tests__/owned-candidate.test.ts index 4eecc4016c..6e91ff2add 100644 --- a/packages/runtime-host/src/__tests__/owned-candidate.test.ts +++ b/packages/runtime-host/src/__tests__/owned-candidate.test.ts @@ -12,7 +12,7 @@ import { connectOwnedRuntimeHostWithDependencies, } from '../client/connect-or-spawn.js'; import { runHostedExecution } from '../client/hosted-execution.js'; -import { launchOwnedRuntimeHostCandidate, type OwnedCandidateAttempt } from '../client/launcher.js'; +import { launchOwnedRuntimeHostCandidate, type CandidateExitDetails, type OwnedCandidateAttempt } from '../client/launcher.js'; test('owned connection keeps a fresh Host alive for its full election window', async () => { const rootPath = await mkdtemp(join(tmpdir(), 'maka-owned-first-connection-')); @@ -250,6 +250,37 @@ test('owned candidate settlement requires a clean process exit', async () => { assert.equal(await candidate.settle(2_000), false); }); +test('reports unexpected candidate exit through the caller-provided onExit sink', async () => { + const rootPath = await mkdtemp(join(tmpdir(), 'maka-owned-on-exit-')); + const exited = deferred(); + const launch = launchOwnedRuntimeHostCandidate({ + rootPath, + expectedRootId: '00000000-0000-4000-8000-000000000001', + entrypoint: new URL('./fixtures/owned-candidate-exit.js', import.meta.url), + env: { MAKA_TEST_EXIT_CODE: '1' }, + onExit: (details) => exited.resolve(details), + }); + + const candidate = await launch.spawned; + assert.equal(await candidate.settle(2_000), false); + assert.deepEqual(await exited.promise, { pid: candidate.pid, code: 1, signal: null }); +}); + +test('reports clean candidate exit through the caller-provided onExit sink', async () => { + const rootPath = await mkdtemp(join(tmpdir(), 'maka-owned-on-exit-clean-')); + const exited = deferred(); + const launch = launchOwnedRuntimeHostCandidate({ + rootPath, + expectedRootId: '00000000-0000-4000-8000-000000000001', + entrypoint: new URL('./fixtures/owned-candidate-exit.js', import.meta.url), + onExit: (details) => exited.resolve(details), + }); + + const candidate = await launch.spawned; + assert.equal(await candidate.settle(2_000), true); + assert.deepEqual(await exited.promise, { pid: candidate.pid, code: 0, signal: null }); +}); + test('owned candidate can be released to the enclosing environment without termination', async () => { const rootPath = await mkdtemp(join(tmpdir(), 'maka-owned-candidate-')); const launch = launchOwnedRuntimeHostCandidate({ @@ -333,3 +364,14 @@ async function waitForDefined( }); } } + +function deferred(): { + readonly promise: Promise; + resolve(value: T): void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((settle) => { + resolve = settle; + }); + return { promise, resolve }; +} diff --git a/packages/runtime-host/src/candidate-entry.ts b/packages/runtime-host/src/candidate-entry.ts index 6b0b7f14c4..aa22b72fe9 100644 --- a/packages/runtime-host/src/candidate-entry.ts +++ b/packages/runtime-host/src/candidate-entry.ts @@ -1,3 +1,4 @@ +import { generalizedErrorMessage } from '@maka/core/redaction'; import { candidateStartupFailureExitCode, classifyCandidateStartupFailure, @@ -68,7 +69,11 @@ export async function runExecutionCandidateEntry( try { await runRuntimeHostProcessLifecycle(result.host); } catch (error) { - console.error('[runtime-host] lifecycle failed:', error); + // Log the redacted, generalized message only: a full error object can + // carry paths and spawn arguments in its message or stack. + console.error( + `[runtime-host] lifecycle failed: ${generalizedErrorMessage(error, 'Runtime Host lifecycle failed')}`, + ); process.exitCode = 1; } finally { stopWatch?.(); diff --git a/packages/runtime-host/src/client/connect-or-spawn.ts b/packages/runtime-host/src/client/connect-or-spawn.ts index 9e7bb3072a..73ecccba86 100644 --- a/packages/runtime-host/src/client/connect-or-spawn.ts +++ b/packages/runtime-host/src/client/connect-or-spawn.ts @@ -20,9 +20,12 @@ import { import { launchDetachedRuntimeHostCandidate, launchOwnedRuntimeHostCandidate, + type CandidateExitDetails, type CandidateLauncher, type OwnedCandidateAttempt, } from './launcher.js'; + +export type { CandidateExitDetails } from './launcher.js'; import { isPermanentCandidateStartupFailure, type CandidateStartupFailure, @@ -51,6 +54,8 @@ export interface ConnectOrSpawnRuntimeHostInput { handshakeTimeoutMs?: number; candidateEntrypoint: string | URL; signal?: AbortSignal; + /** Candidate-exit sink forwarded to the launcher; the embedder owns the sink. */ + onExit?: (details: CandidateExitDetails) => void; } interface ConnectOrSpawnRuntimeHostDependencies { @@ -277,6 +282,7 @@ export async function connectOrSpawnRuntimeHostWithDependencies( entrypoint: input.candidateEntrypoint, initialConnectionTimeoutMs: Math.ceil(remaining), ...(input.generation === undefined ? {} : { generation: input.generation }), + ...(input.onExit === undefined ? {} : { onExit: input.onExit }), }); const attempt = await settleBeforeDeadline(launch.spawned, deadline, input.signal); if (attempt.startupFailure) { diff --git a/packages/runtime-host/src/client/index.ts b/packages/runtime-host/src/client/index.ts index 41f6c0254b..42403bbb08 100644 --- a/packages/runtime-host/src/client/index.ts +++ b/packages/runtime-host/src/client/index.ts @@ -67,6 +67,7 @@ export { } from './catalog-reader.js'; export { connectOrSpawnRuntimeHost, + type CandidateExitDetails, type ConnectOrSpawnRuntimeHostInput, type ConnectOrSpawnRuntimeHostResult, } from './connect-or-spawn.js'; diff --git a/packages/runtime-host/src/client/launcher.ts b/packages/runtime-host/src/client/launcher.ts index d890d681c5..4dce4d6c1c 100644 --- a/packages/runtime-host/src/client/launcher.ts +++ b/packages/runtime-host/src/client/launcher.ts @@ -7,6 +7,12 @@ import { type CandidateStartupFailureReport, } from '../candidate-startup-failure.js'; +export interface CandidateExitDetails { + readonly pid: number | undefined; + readonly code: number | null; + readonly signal: NodeJS.Signals | null; +} + export interface DetachedCandidateInput { rootPath: string; expectedRootId: string; @@ -17,6 +23,8 @@ export interface DetachedCandidateInput { executable?: string; entrypoint: string | URL; env?: NodeJS.ProcessEnv; + /** Called with the candidate's exit details; the embedder owns the sink. */ + readonly onExit?: (details: CandidateExitDetails) => void; } export interface DetachedCandidateAttempt { @@ -40,7 +48,7 @@ export function launchDetachedRuntimeHostCandidate( ): DetachedCandidateLaunch { const startupAttemptId = randomUUID(); const child = spawnCandidate(input, true, startupAttemptId); - observeCandidateExit(child); + observeCandidateExit(child, input.onExit); const startupFailure = readStartupFailure(child, startupAttemptId); const spawned = spawnedPid(child).then(({ pid }) => { child.unref(); @@ -54,7 +62,7 @@ export function launchOwnedRuntimeHostCandidate(input: DetachedCandidateInput): } { const startupAttemptId = randomUUID(); const child = spawnCandidate(input, false, startupAttemptId); - observeCandidateExit(child); + observeCandidateExit(child, input.onExit); const startupFailure = readStartupFailure(child, startupAttemptId); const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { child.once('exit', (code, signal) => resolve({ code, signal })); @@ -132,14 +140,13 @@ function spawnedPid(child: ReturnType): Promise<{ pid: number }> { }); } -function observeCandidateExit(child: ReturnType): void { +function observeCandidateExit( + child: ReturnType, + onExit: DetachedCandidateInput['onExit'], +): void { + if (!onExit) return; child.once('exit', (code, signal) => { - const details = { pid: child.pid, code, signal }; - if (code === 0 && signal === null) { - console.info('[runtime-host] candidate exited cleanly', details); - return; - } - console.error('[runtime-host] candidate exited unexpectedly', details); + onExit({ pid: child.pid, code, signal }); }); } From 3748213b46cf51ba3742dd4b2cd2a65342992b5f Mon Sep 17 00:00:00 2001 From: sunheyi <50973219+sunheyi6@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:54:53 +0800 Subject: [PATCH 3/9] fix(desktop): track host epoch across replacement in transcript handle The range request closure captured the open-time host epoch forever, so after a reset installed a replacement Host every subsequent range request sent the stale epoch and the Main-process guard rejected it. Track a mutable generation+hostEpoch identity alongside the handle: initialized from the open result, adopted from accepted reset batches, and used by every range request. Requests already dispatched with the old epoch still fail closed on the guard. --- .../__tests__/transcript-identity.test.ts | 66 +++++++++++++++++++ apps/desktop/src/preload/preload.ts | 17 +++-- .../src/preload/transcript-identity.ts | 30 +++++++++ 3 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/transcript-identity.test.ts create mode 100644 apps/desktop/src/preload/transcript-identity.ts diff --git a/apps/desktop/src/main/__tests__/transcript-identity.test.ts b/apps/desktop/src/main/__tests__/transcript-identity.test.ts new file mode 100644 index 0000000000..b5dc6ce85a --- /dev/null +++ b/apps/desktop/src/main/__tests__/transcript-identity.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { DesktopTranscriptBatch } from '../../preload/transcript-contract.js'; +import { + adoptTranscriptIdentity, + type DesktopTranscriptIdentity, +} from '../../preload/transcript-identity.js'; + +function batch(overrides: Partial = {}): DesktopTranscriptBatch { + return { + sessionId: 'session-1', + generation: 'generation-1', + hostEpoch: 'host-1', + durableThrough: null, + fragments: [], + evictedDurableSequences: [], + completedOverlayMessageIds: [], + hasOlder: false, + hasNewer: false, + reset: false, + ready: true, + deliverySequence: 1, + ...overrides, + }; +} + +test('initializes the tracked identity from the first accepted batch', () => { + const first = batch({ generation: 'generation-1', hostEpoch: 'host-1' }); + assert.deepEqual(adoptTranscriptIdentity(undefined, first), { + generation: 'generation-1', + hostEpoch: 'host-1', + }); +}); + +test('keeps the current identity for a regular batch', () => { + const current: DesktopTranscriptIdentity = { generation: 'generation-1', hostEpoch: 'host-1' }; + assert.equal(adoptTranscriptIdentity(current, batch()), current); +}); + +test('adopts a reset batch identity so the next range request targets the replacement Host', () => { + const current: DesktopTranscriptIdentity = { generation: 'generation-1', hostEpoch: 'host-1' }; + const replacement = batch({ + generation: 'generation-2', + hostEpoch: 'host-2', + reset: true, + }); + const next = adoptTranscriptIdentity(current, replacement); + assert.deepEqual(next, { generation: 'generation-2', hostEpoch: 'host-2' }); + // The adopted identity stays in effect for later regular batches. + assert.equal( + adoptTranscriptIdentity(next, batch({ generation: 'generation-2', hostEpoch: 'host-2' })), + next, + ); +}); + +test('does not mutate an identity already captured by a dispatched request', () => { + const dispatched: DesktopTranscriptIdentity = { generation: 'generation-1', hostEpoch: 'host-1' }; + const replacement = batch({ + generation: 'generation-2', + hostEpoch: 'host-2', + reset: true, + }); + adoptTranscriptIdentity(dispatched, replacement); + // The host-1 request keeps its epoch; the Main-process guard fails it closed. + assert.deepEqual(dispatched, { generation: 'generation-1', hostEpoch: 'host-1' }); +}); diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 995cb35e5b..6da92fcf21 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -43,6 +43,10 @@ import { type DesktopTranscriptHandle, type DesktopTranscriptOpenResult, } from './transcript-contract.js'; +import { + adoptTranscriptIdentity, + type DesktopTranscriptIdentity, +} from './transcript-identity.js'; import type { DesktopDiagnosticCopyResult, DesktopErrorDiagnosticInput, @@ -1588,7 +1592,7 @@ const makaBridge = { ): Promise { const consumerId = crypto.randomUUID(); const channel = `sessions:transcript:${consumerId}`; - let generation: string | undefined; + let identity: DesktopTranscriptIdentity | undefined; let closed = false; let requestClose = () => {}; let consumerScope: DesktopTargetScope | undefined; @@ -1607,11 +1611,12 @@ const makaBridge = { host.targetEpoch !== consumerScope.targetEpoch ) return; batch = assertDesktopTranscriptBatch(value); - if (batch.reset || generation === undefined) { - generation = batch.generation; + const adopted = adoptTranscriptIdentity(identity, batch); + if (adopted !== identity) { + identity = adopted; consumerScope = host; } - if (batch.generation === generation) handler(batch); + if (identity !== undefined && batch.generation === identity.generation) handler(batch); } catch (error) { requestClose(); throw error; @@ -1658,7 +1663,7 @@ const makaBridge = { throw error; } if (closed) throw new Error('Desktop transcript open was cancelled'); - generation ??= opened.generation; + identity ??= { generation: opened.generation, hostEpoch: opened.hostEpoch }; const range = ( operation: 'sessions:transcript:load-before' | 'sessions:transcript:load-around', anchorSequence: number | null, @@ -1667,7 +1672,7 @@ const makaBridge = { ipcRenderer.invoke(operation, consumerScope, { consumerId, sessionId, - hostEpoch: opened.hostEpoch, + hostEpoch: identity.hostEpoch, anchorSequence, maxBytes, }) as Promise; diff --git a/apps/desktop/src/preload/transcript-identity.ts b/apps/desktop/src/preload/transcript-identity.ts new file mode 100644 index 0000000000..f6e8887996 --- /dev/null +++ b/apps/desktop/src/preload/transcript-identity.ts @@ -0,0 +1,30 @@ +import type { DesktopTranscriptBatch } from './transcript-contract.js'; + +/** + * Durable transcript sequence identity: the Session's generation and the + * Runtime Host epoch that produced it. + * + * The renderer keeps this alongside the open handle so range requests always + * carry the epoch of the Host the renderer currently accepts batches from. + * A replacement Host sends a reset batch with a new generation and epoch; + * adopting that identity lets the next range request pass the Main-process + * guard, while requests already dispatched with the previous epoch still fail + * closed on the old Host. + */ +export interface DesktopTranscriptIdentity { + readonly generation: string; + readonly hostEpoch: string; +} + +/** + * Adopts a batch's identity when none is tracked yet or when the batch is a + * reset; otherwise keeps the current identity. Returns the current identity + * by reference when nothing changed so callers can detect adoption. + */ +export function adoptTranscriptIdentity( + current: DesktopTranscriptIdentity | undefined, + batch: DesktopTranscriptBatch, +): DesktopTranscriptIdentity { + if (current !== undefined && !batch.reset) return current; + return { generation: batch.generation, hostEpoch: batch.hostEpoch }; +} From 9f7a41a59c3d1fff534a125854547159fb9b9ab6 Mon Sep 17 00:00:00 2001 From: sunheyi <50973219+sunheyi6@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:02:43 +0800 Subject: [PATCH 4/9] chore: keep workflow files at the branch base The rebase onto upstream main pulled in workflow updates; keeping the fork branch free of workflow changes so this PR does not touch .github/workflows. --- .github/workflows/asf-source-candidate.yml | 137 ------------------- .github/workflows/ci.yml | 16 +-- .github/workflows/cli-package-validation.yml | 2 +- .github/workflows/windows-baseline.yml | 2 - .github/workflows/windows-recovery.yml | 2 - .github/workflows/windows-sandbox-w0.yml | 21 +-- 6 files changed, 11 insertions(+), 169 deletions(-) delete mode 100644 .github/workflows/asf-source-candidate.yml diff --git a/.github/workflows/asf-source-candidate.yml b/.github/workflows/asf-source-candidate.yml deleted file mode 100644 index 88f93f54d5..0000000000 --- a/.github/workflows/asf-source-candidate.yml +++ /dev/null @@ -1,137 +0,0 @@ -name: Prepare ASF source candidate - -on: - workflow_dispatch: - inputs: - version: - description: Exact version from the root package.json - required: true - type: string - -permissions: - contents: read - -concurrency: - group: asf-source-${{ github.sha }} - cancel-in-progress: false - -jobs: - candidate: - name: Build and test source archive - runs-on: ubuntu-24.04 - timeout-minutes: 60 - env: - CANDIDATE_PATH: release/asf/apache-maka-${{ inputs.version }}-incubating-src.tar.gz - RELEASE_VERSION: ${{ inputs.version }} - steps: - - name: Require main - env: - RELEASE_REF: ${{ github.ref }} - run: | - if [[ "$RELEASE_REF" != "refs/heads/main" ]]; then - echo "ASF source candidates must be dispatched from main; found $RELEASE_REF" >&2 - exit 1 - fi - - - name: Check out the candidate commit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.sha }} - fetch-depth: 0 - persist-credentials: false - - - name: Set up Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '24' - cache: npm - - - name: Select the repository npm toolchain - run: npm install --global --no-audit --no-fund "$(node -p 'require("./package.json").packageManager')" - - - name: Install Linux runtime dependencies - run: | - sudo apt-get update - sudo apt-get install -y bubblewrap ripgrep - if [[ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]]; then - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - fi - if [[ -e /proc/sys/kernel/unprivileged_userns_clone ]]; then - sudo sysctl -w kernel.unprivileged_userns_clone=1 - fi - - - name: Create the unsigned source candidate - run: | - npm run release:asf:source -- \ - --version "$RELEASE_VERSION" \ - --revision "$GITHUB_SHA" - - - name: Extract the exact candidate - run: | - mkdir candidate-source - tar -xzf "$CANDIDATE_PATH" \ - --strip-components=1 \ - -C candidate-source - - - name: Install from the committed lockfile - working-directory: candidate-source - run: npm ci - - - name: Audit production dependencies - working-directory: candidate-source - run: npm audit --omit=dev --audit-level=moderate - - - name: Verify source attribution inventories - working-directory: candidate-source - run: | - npm run check:third-party-notices - npm run check:cli-third-party-notices - npm run check:windows-cargo-notices - - - name: Lint and format-check the extracted source - working-directory: candidate-source - run: | - npm run lint - npm run format:check - - - name: Build and type-check the extracted source - working-directory: candidate-source - run: | - npm run build - npm run typecheck - - - name: Run ASF source checks from the extracted source - working-directory: candidate-source - run: npm run check:asf-source - - - name: Test the extracted source - working-directory: candidate-source - run: npm test - - - name: Upload the verified unsigned candidate - id: candidate - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: apache-maka-${{ inputs.version }}-incubating-${{ github.sha }}-unsigned - path: | - ${{ env.CANDIDATE_PATH }} - ${{ env.CANDIDATE_PATH }}.sha512 - if-no-files-found: error - compression-level: 0 - retention-days: 30 - - - name: Record Release Manager handoff - env: - ARTIFACT_URL: ${{ steps.candidate.outputs.artifact-url }} - RUNBOOK_URL: ${{ github.server_url }}/${{ github.repository }}/blob/${{ github.sha }}/.github/ASF_SOURCE_RELEASE.md - run: | - { - echo "## Unsigned ASF source candidate" - echo - echo "- Version: \`$RELEASE_VERSION\`" - echo "- Commit: \`$GITHUB_SHA\`" - echo "- Artifact: $ARTIFACT_URL" - echo "- Release Manager runbook: $RUNBOOK_URL" - echo - echo "This workflow does not create an ASF release candidate by itself." - } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84dcadb1fa..8fd38d58a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,22 +44,12 @@ jobs: - name: Test CI planner run: node --test --test-concurrency=1 scripts/ci-test-plan.test.mjs - # Same shape and the same needs: a regenerate-and-diff contract that runs - # on Node alone, so it belongs beside the planner test rather than behind - # an install. - - name: Check Windows test inventory - run: npm run windows:inventory - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - if: steps.plan.outputs.code == 'true' || steps.plan.outputs.astryx_surface == 'true' || steps.plan.outputs.asf_source == 'true' || steps.plan.outputs.cli_package == 'true' + if: steps.plan.outputs.code == 'true' || steps.plan.outputs.astryx_surface == 'true' || steps.plan.outputs.cli_package == 'true' with: node-version: '24' cache: npm - - name: Verify ASF source release mechanics - if: steps.plan.outputs.asf_source == 'true' - run: npm run check:asf-source - - name: Select the release npm toolchain if: steps.plan.outputs.cli_package == 'true' run: npm install --global --no-audit --no-fund "$(node -p 'require("./package.json").packageManager')" @@ -100,6 +90,10 @@ jobs: if: steps.plan.outputs.code == 'true' run: npm run format:check + - name: Check Windows test inventory + continue-on-error: true + run: npm run windows:inventory + - name: Build if: steps.plan.outputs.code == 'true' run: npm run build diff --git a/.github/workflows/cli-package-validation.yml b/.github/workflows/cli-package-validation.yml index a66e8ecbf7..d7d3dcd441 100644 --- a/.github/workflows/cli-package-validation.yml +++ b/.github/workflows/cli-package-validation.yml @@ -109,7 +109,7 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.12' - name: Select the release npm toolchain diff --git a/.github/workflows/windows-baseline.yml b/.github/workflows/windows-baseline.yml index e2b5aca90d..0772d4af2b 100644 --- a/.github/workflows/windows-baseline.yml +++ b/.github/workflows/windows-baseline.yml @@ -26,8 +26,6 @@ jobs: WINDOWS_BASELINE_LOG_DIR: artifacts/windows-baseline steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.github/workflows/windows-recovery.yml b/.github/workflows/windows-recovery.yml index b1a3741714..ec70595c40 100644 --- a/.github/workflows/windows-recovery.yml +++ b/.github/workflows/windows-recovery.yml @@ -16,8 +16,6 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.github/workflows/windows-sandbox-w0.yml b/.github/workflows/windows-sandbox-w0.yml index 0d8cf8f0cc..605da92129 100644 --- a/.github/workflows/windows-sandbox-w0.yml +++ b/.github/workflows/windows-sandbox-w0.yml @@ -1,32 +1,22 @@ name: Windows sandbox W0 evidence -# The paths below are a pre-filter, not this lane's real input. The real input -# is the import closure of the sandbox and filesystem-worker sources, which -# reaches well past any list worth hand-maintaining. So they name the -# directories that own the sandbox, which keeps a change there blocking before -# merge, and the nightly run is what covers transitive edits once they land. on: pull_request: paths: - 'experiments/windows-sandbox/**' - 'packages/runtime/src/sandbox/**' + - 'packages/runtime/**' + - 'packages/core/**' - 'packages/runtime/src/filesystem-worker/**' - - 'packages/runtime/src/__tests__/filesystem-worker-windows-smoke.test.ts' + - 'scripts/package-windows-x64.mjs' + - 'scripts/verify-packaged-app.mjs' + - 'apps/desktop/electron-builder.config.mjs' - '.github/workflows/windows-sandbox-w0.yml' - schedule: - # Offset from windows-baseline so the two Windows lanes do not overlap. - - cron: '17 7 * * *' workflow_dispatch: permissions: contents: read -# Pull request pushes supersede each other. Scheduled and manual runs each get -# a unique group, so neither can discard the other while pending or running. -concurrency: - group: windows-sandbox-w0-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - jobs: protocol: name: windows_sandbox_w0_protocol @@ -39,7 +29,6 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' - cache: npm - name: Record atomic launcher capability shell: pwsh run: ./experiments/windows-sandbox/atomic-launch-capability.ps1 From 97c45fc4460d56fe581b89d43e98db518d4479ca Mon Sep 17 00:00:00 2001 From: sunheyi <1061867552@qq.com> Date: Fri, 21 Aug 2026 14:13:58 +0800 Subject: [PATCH 5/9] ci: restore workflow files to the PR base The branch-base revert left .github/workflows stale relative to the CI planning contract tests (scripts/ci-test-plan.test.mjs): the planner emits an asf_source surface, the contract steps must precede dependency setup, the sandbox lane needs its nightly schedule, and every checkout must disable credential persistence. Restoring the workflow files to the upstream base versions satisfies the contracts and keeps this PR free of workflow diffs. --- .github/workflows/asf-source-candidate.yml | 137 +++++++++++++++++++ .github/workflows/ci.yml | 16 ++- .github/workflows/cli-package-validation.yml | 2 +- .github/workflows/windows-baseline.yml | 2 + .github/workflows/windows-recovery.yml | 2 + .github/workflows/windows-sandbox-w0.yml | 21 ++- 6 files changed, 169 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/asf-source-candidate.yml diff --git a/.github/workflows/asf-source-candidate.yml b/.github/workflows/asf-source-candidate.yml new file mode 100644 index 0000000000..88f93f54d5 --- /dev/null +++ b/.github/workflows/asf-source-candidate.yml @@ -0,0 +1,137 @@ +name: Prepare ASF source candidate + +on: + workflow_dispatch: + inputs: + version: + description: Exact version from the root package.json + required: true + type: string + +permissions: + contents: read + +concurrency: + group: asf-source-${{ github.sha }} + cancel-in-progress: false + +jobs: + candidate: + name: Build and test source archive + runs-on: ubuntu-24.04 + timeout-minutes: 60 + env: + CANDIDATE_PATH: release/asf/apache-maka-${{ inputs.version }}-incubating-src.tar.gz + RELEASE_VERSION: ${{ inputs.version }} + steps: + - name: Require main + env: + RELEASE_REF: ${{ github.ref }} + run: | + if [[ "$RELEASE_REF" != "refs/heads/main" ]]; then + echo "ASF source candidates must be dispatched from main; found $RELEASE_REF" >&2 + exit 1 + fi + + - name: Check out the candidate commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + cache: npm + + - name: Select the repository npm toolchain + run: npm install --global --no-audit --no-fund "$(node -p 'require("./package.json").packageManager')" + + - name: Install Linux runtime dependencies + run: | + sudo apt-get update + sudo apt-get install -y bubblewrap ripgrep + if [[ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]]; then + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + fi + if [[ -e /proc/sys/kernel/unprivileged_userns_clone ]]; then + sudo sysctl -w kernel.unprivileged_userns_clone=1 + fi + + - name: Create the unsigned source candidate + run: | + npm run release:asf:source -- \ + --version "$RELEASE_VERSION" \ + --revision "$GITHUB_SHA" + + - name: Extract the exact candidate + run: | + mkdir candidate-source + tar -xzf "$CANDIDATE_PATH" \ + --strip-components=1 \ + -C candidate-source + + - name: Install from the committed lockfile + working-directory: candidate-source + run: npm ci + + - name: Audit production dependencies + working-directory: candidate-source + run: npm audit --omit=dev --audit-level=moderate + + - name: Verify source attribution inventories + working-directory: candidate-source + run: | + npm run check:third-party-notices + npm run check:cli-third-party-notices + npm run check:windows-cargo-notices + + - name: Lint and format-check the extracted source + working-directory: candidate-source + run: | + npm run lint + npm run format:check + + - name: Build and type-check the extracted source + working-directory: candidate-source + run: | + npm run build + npm run typecheck + + - name: Run ASF source checks from the extracted source + working-directory: candidate-source + run: npm run check:asf-source + + - name: Test the extracted source + working-directory: candidate-source + run: npm test + + - name: Upload the verified unsigned candidate + id: candidate + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: apache-maka-${{ inputs.version }}-incubating-${{ github.sha }}-unsigned + path: | + ${{ env.CANDIDATE_PATH }} + ${{ env.CANDIDATE_PATH }}.sha512 + if-no-files-found: error + compression-level: 0 + retention-days: 30 + + - name: Record Release Manager handoff + env: + ARTIFACT_URL: ${{ steps.candidate.outputs.artifact-url }} + RUNBOOK_URL: ${{ github.server_url }}/${{ github.repository }}/blob/${{ github.sha }}/.github/ASF_SOURCE_RELEASE.md + run: | + { + echo "## Unsigned ASF source candidate" + echo + echo "- Version: \`$RELEASE_VERSION\`" + echo "- Commit: \`$GITHUB_SHA\`" + echo "- Artifact: $ARTIFACT_URL" + echo "- Release Manager runbook: $RUNBOOK_URL" + echo + echo "This workflow does not create an ASF release candidate by itself." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fd38d58a7..84dcadb1fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,12 +44,22 @@ jobs: - name: Test CI planner run: node --test --test-concurrency=1 scripts/ci-test-plan.test.mjs + # Same shape and the same needs: a regenerate-and-diff contract that runs + # on Node alone, so it belongs beside the planner test rather than behind + # an install. + - name: Check Windows test inventory + run: npm run windows:inventory + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - if: steps.plan.outputs.code == 'true' || steps.plan.outputs.astryx_surface == 'true' || steps.plan.outputs.cli_package == 'true' + if: steps.plan.outputs.code == 'true' || steps.plan.outputs.astryx_surface == 'true' || steps.plan.outputs.asf_source == 'true' || steps.plan.outputs.cli_package == 'true' with: node-version: '24' cache: npm + - name: Verify ASF source release mechanics + if: steps.plan.outputs.asf_source == 'true' + run: npm run check:asf-source + - name: Select the release npm toolchain if: steps.plan.outputs.cli_package == 'true' run: npm install --global --no-audit --no-fund "$(node -p 'require("./package.json").packageManager')" @@ -90,10 +100,6 @@ jobs: if: steps.plan.outputs.code == 'true' run: npm run format:check - - name: Check Windows test inventory - continue-on-error: true - run: npm run windows:inventory - - name: Build if: steps.plan.outputs.code == 'true' run: npm run build diff --git a/.github/workflows/cli-package-validation.yml b/.github/workflows/cli-package-validation.yml index d7d3dcd441..a66e8ecbf7 100644 --- a/.github/workflows/cli-package-validation.yml +++ b/.github/workflows/cli-package-validation.yml @@ -109,7 +109,7 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.12' - name: Select the release npm toolchain diff --git a/.github/workflows/windows-baseline.yml b/.github/workflows/windows-baseline.yml index 0772d4af2b..e2b5aca90d 100644 --- a/.github/workflows/windows-baseline.yml +++ b/.github/workflows/windows-baseline.yml @@ -26,6 +26,8 @@ jobs: WINDOWS_BASELINE_LOG_DIR: artifacts/windows-baseline steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.github/workflows/windows-recovery.yml b/.github/workflows/windows-recovery.yml index ec70595c40..b1a3741714 100644 --- a/.github/workflows/windows-recovery.yml +++ b/.github/workflows/windows-recovery.yml @@ -16,6 +16,8 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.github/workflows/windows-sandbox-w0.yml b/.github/workflows/windows-sandbox-w0.yml index 605da92129..0d8cf8f0cc 100644 --- a/.github/workflows/windows-sandbox-w0.yml +++ b/.github/workflows/windows-sandbox-w0.yml @@ -1,22 +1,32 @@ name: Windows sandbox W0 evidence +# The paths below are a pre-filter, not this lane's real input. The real input +# is the import closure of the sandbox and filesystem-worker sources, which +# reaches well past any list worth hand-maintaining. So they name the +# directories that own the sandbox, which keeps a change there blocking before +# merge, and the nightly run is what covers transitive edits once they land. on: pull_request: paths: - 'experiments/windows-sandbox/**' - 'packages/runtime/src/sandbox/**' - - 'packages/runtime/**' - - 'packages/core/**' - 'packages/runtime/src/filesystem-worker/**' - - 'scripts/package-windows-x64.mjs' - - 'scripts/verify-packaged-app.mjs' - - 'apps/desktop/electron-builder.config.mjs' + - 'packages/runtime/src/__tests__/filesystem-worker-windows-smoke.test.ts' - '.github/workflows/windows-sandbox-w0.yml' + schedule: + # Offset from windows-baseline so the two Windows lanes do not overlap. + - cron: '17 7 * * *' workflow_dispatch: permissions: contents: read +# Pull request pushes supersede each other. Scheduled and manual runs each get +# a unique group, so neither can discard the other while pending or running. +concurrency: + group: windows-sandbox-w0-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + jobs: protocol: name: windows_sandbox_w0_protocol @@ -29,6 +39,7 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' + cache: npm - name: Record atomic launcher capability shell: pwsh run: ./experiments/windows-sandbox/atomic-launch-capability.ps1 From 01e87ed14608b1e8ee0a2ffd6a1fd21e09468a75 Mon Sep 17 00:00:00 2001 From: sunheyi <1061867552@qq.com> Date: Fri, 21 Aug 2026 15:04:02 +0800 Subject: [PATCH 6/9] test(runtime-host): format owned-candidate import with biome --- packages/runtime-host/src/__tests__/owned-candidate.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/runtime-host/src/__tests__/owned-candidate.test.ts b/packages/runtime-host/src/__tests__/owned-candidate.test.ts index 6e91ff2add..1e944f011a 100644 --- a/packages/runtime-host/src/__tests__/owned-candidate.test.ts +++ b/packages/runtime-host/src/__tests__/owned-candidate.test.ts @@ -12,7 +12,11 @@ import { connectOwnedRuntimeHostWithDependencies, } from '../client/connect-or-spawn.js'; import { runHostedExecution } from '../client/hosted-execution.js'; -import { launchOwnedRuntimeHostCandidate, type CandidateExitDetails, type OwnedCandidateAttempt } from '../client/launcher.js'; +import { + launchOwnedRuntimeHostCandidate, + type CandidateExitDetails, + type OwnedCandidateAttempt, +} from '../client/launcher.js'; test('owned connection keeps a fresh Host alive for its full election window', async () => { const rootPath = await mkdtemp(join(tmpdir(), 'maka-owned-first-connection-')); From 625bcc84c5fad311ed6e7cb3a97e31d0ae04d449 Mon Sep 17 00:00:00 2001 From: sunheyi <1061867552@qq.com> Date: Mon, 24 Aug 2026 16:20:10 +0800 Subject: [PATCH 7/9] chore(desktop): add ASF license headers to transcript identity files --- .../__tests__/transcript-identity.test.ts | 19 +++++++++++++++++++ .../src/preload/transcript-identity.ts | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/apps/desktop/src/main/__tests__/transcript-identity.test.ts b/apps/desktop/src/main/__tests__/transcript-identity.test.ts index b5dc6ce85a..bd853f7083 100644 --- a/apps/desktop/src/main/__tests__/transcript-identity.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-identity.test.ts @@ -1,3 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + import assert from 'node:assert/strict'; import test from 'node:test'; import type { DesktopTranscriptBatch } from '../../preload/transcript-contract.js'; diff --git a/apps/desktop/src/preload/transcript-identity.ts b/apps/desktop/src/preload/transcript-identity.ts index f6e8887996..d02ec35d53 100644 --- a/apps/desktop/src/preload/transcript-identity.ts +++ b/apps/desktop/src/preload/transcript-identity.ts @@ -1,3 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + import type { DesktopTranscriptBatch } from './transcript-contract.js'; /** From 02de2a9e689cc688e110719b743222b79705afbe Mon Sep 17 00:00:00 2001 From: sunheyi <50973219+sunheyi6@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:02:29 +0800 Subject: [PATCH 8/9] fix(desktop): use host session id for transcript ranges --- apps/desktop/src/preload/preload.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index f26ba59d97..4c507c0937 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1889,7 +1889,7 @@ const makaBridge = { } return ipcRenderer.invoke(operation, consumerScope, { consumerId, - sessionId, + sessionId: opened.sessionId, hostEpoch: currentIdentity.hostEpoch, anchorSequence, maxBytes, From 6dc5767d9f8c5f5c57c86a025041dd63ea8fede6 Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Wed, 26 Aug 2026 15:51:09 +0800 Subject: [PATCH 9/9] test(desktop): update transcript range identity after merge Generated-by: Codex --- .../src/main/__tests__/runtime-host-session-observer.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts index 956130f1b0..5d0fb4e4fb 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts @@ -1337,7 +1337,8 @@ test('keeps a transcript consumer available after a delivery fails', async () => observer.loadTranscriptAround( { consumerId, - generation: opened.generation, + sessionId: opened.sessionId, + hostEpoch: opened.hostEpoch, anchorSequence: 0, maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, },