diff --git a/.gitattributes b/.gitattributes index 1aad5a3cf6..860f6cebf5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,6 +5,9 @@ # repository needs CRLF, so the working tree stays LF everywhere. * text=auto eol=lf +# Unified diffs prefix upstream tab-indented context with a required space. +/patches/*.patch whitespace=-space-before-tab + # Repository-local agent configuration, visual review evidence, and incubation # working notes are not source-release inputs. /.claude export-ignore diff --git a/.github/workflows/release-windows-check.yml b/.github/workflows/release-windows-check.yml index 6bc9bef82e..f961cfa14f 100644 --- a/.github/workflows/release-windows-check.yml +++ b/.github/workflows/release-windows-check.yml @@ -81,6 +81,7 @@ on: - 'packages/runtime/src/file-stable-write.ts' - 'packages/runtime/src/image-file.ts' - 'packages/runtime/src/process-tree-terminator.ts' + - 'packages/runtime/src/text-line-window.ts' - 'packages/runtime/src/unified-diff.ts' - 'packages/core/src/absolute-path.ts' - 'packages/core/src/artifacts.ts' diff --git a/LICENSE b/LICENSE index c3e2f7f43c..568a613557 100644 --- a/LICENSE +++ b/LICENSE @@ -261,6 +261,112 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +Pi TUI dependency patch + +Source: https://www.npmjs.com/package/@earendil-works/pi-tui/v/0.84.4 +Repository: https://github.com/earendil-works/pi/tree/v0.84.4/packages/tui +Version: 0.84.4 +Dependency patch: patches/@earendil-works+pi-tui+0.84.4.patch +Copyright (c) 2025 Mario Zechner +License: MIT + +Maka redistributes a source patch that copies mutable editor undo state while +sharing immutable pasted strings. The following MIT License applies to that +material: + +MIT License + +Copyright (c) 2025 Mario Zechner + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Zod dependency patch + +Source: https://www.npmjs.com/package/zod/v/4.5.4 +Repository: https://github.com/colinhacks/zod +Version: 4.5.4 +Dependency patch: patches/zod+4.5.4.patch +Copyright (c) 2025 Colin McDonnell +License: MIT + +Maka redistributes a source patch that releases recursive parse state after +completion or failure. The following MIT License applies to that material: + +MIT License + +Copyright (c) 2025 Colin McDonnell + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Model Context Protocol client dependency patch + +Source: https://www.npmjs.com/package/@modelcontextprotocol/client/v/2.0.0 +Repository: https://github.com/modelcontextprotocol/typescript-sdk +Version: 2.0.0 +Dependency patch: patches/@modelcontextprotocol+client+2.0.0.patch +Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC. +Declared package license: MIT + +Maka redistributes a source patch that releases settled request observers under +transport backpressure. The upstream package's LICENSE describes a transition +from MIT to Apache-2.0: contributions with relicensing consent use Apache-2.0, +while contributions without that consent retain their original MIT license. +The Apache License, Version 2.0 is reproduced above. The upstream MIT notice is: + +MIT License + +Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + Sigstore dependency patch Source: https://www.npmjs.com/package/@sigstore/core/v/4.0.1 diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 9808914dc4..93dd6b5ced 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -592,8 +592,7 @@ "importDeclarations": 2, "bridgePaths": {}, "environmentCapabilities": { - "requestAnimationFrame": 1, - "window.setTimeout": 2 + "window.setTimeout": 1 }, "hookCalls": {}, "lifecycleMethods": {}, @@ -603,12 +602,13 @@ "createAppShellSessionEventHandlers" ], "dependencyPaths": { + "./features/conversation/index.js": 1, "./locales/conversation-copy.js": 1, "./model-connection-errors.js": 1, "@maka/ui": 1 }, "importSpecifiers": 8, - "nonTriviaTokens": 2808 + "nonTriviaTokens": 2755 }, "src/renderer/app-shell-session-start-actions.ts": { "importDeclarations": 2, @@ -872,7 +872,7 @@ "react": 1 }, "importSpecifiers": 108, - "nonTriviaTokens": 13696 + "nonTriviaTokens": 13695 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, diff --git a/apps/desktop/src/main/__tests__/session-local.test.ts b/apps/desktop/src/main/__tests__/session-local.test.ts index 7f3596c968..0f54f9df22 100644 --- a/apps/desktop/src/main/__tests__/session-local.test.ts +++ b/apps/desktop/src/main/__tests__/session-local.test.ts @@ -122,7 +122,10 @@ test('local acceptance survives restart with attachment bytes and an immutable d const db = await database(t); const record = db.store.enqueue('authority-1', intent()); assert.equal(record.state, 'saved'); - assert.equal((await stat(db.path)).mode & 0o777, 0o600); + // POSIX permission bits do not describe Windows ACLs. + if (process.platform !== 'win32') { + assert.equal((await stat(db.path)).mode & 0o777, 0o600); + } db.store.update({ ...record, state: 'sending', diff --git a/apps/desktop/src/main/__tests__/workhub-presentation.test.ts b/apps/desktop/src/main/__tests__/workhub-presentation.test.ts index a755cf1465..60d2615c5c 100644 --- a/apps/desktop/src/main/__tests__/workhub-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-presentation.test.ts @@ -276,6 +276,7 @@ test('animates from the current height, keeps the bottom anchored and survives r const h = await harness(true); await h.command(h.main.webContents, 'host', { visible: true, rect: { x: 0, y: 40, width: 1000, height: 760 } }); const view = h.views[0]!; + await h.command(view.webContents, 'ready'); await h.command(view.webContents, 'conversation-layout', { expanded: false, compactHeight: 110 }); await h.command(view.webContents, 'detach'); const floating = h.windows[1]!; @@ -318,6 +319,7 @@ test('reparents one live conversation across docking, floating, hide and main-wi h.main.show(); await h.command(h.main.webContents, 'host', { visible: true, rect: { x: 100, y: 40, width: 900, height: 760 } }); const view = h.views[0]!; + await h.command(view.webContents, 'ready'); await h.command(view.webContents, 'conversation-layout', { expanded: true, compactHeight: 96 }); assert.ok(h.main.children.has(view)); await h.command(view.webContents, 'detach'); @@ -515,6 +517,7 @@ test('all WorkHub entries obey the client enable setting and disabling retains t await h.controller.show(); const view = h.views[0]!; const floating = h.windows[1]!; + await h.command(view.webContents, 'ready'); assert.equal(floating.visible, true); const opened = deferred(); const opening = h.deferOpening(opened.promise); @@ -550,13 +553,21 @@ test('all WorkHub entries obey the client enable setting and disabling retains t }); -test('prewarms once and the shortcut shows and hides synchronously', async () => { +test('creates on first shortcut, then shows and hides synchronously', async () => { const h = await harness(); await h.controller.refreshSettings(); + assert.equal(h.views.length, 0, 'enabling alone must not create the renderer'); + h.shortcut(); + assert.equal(h.windows[1]!.visible, false, 'a cold summon waits for the composer to mount'); + assert.equal(h.windows[1]!.focused, 0, 'loading must not steal keyboard input'); + await h.command(h.views[0]!.webContents, 'ready'); + assert.equal(h.windows[1]!.visible, true); + assert.equal(h.views[0]!.webContents.sent.some(([channel]) => channel === 'workhub-presentation:focus-composer'), true); + h.shortcut(); const floating = h.windows[1]!; const view = h.views[0]!; assert.equal(floating.visible, false); - assert.equal(view.visible, false); + assert.equal(view.visible, true); assert.ok(floating.children.has(view)); await h.controller.refreshSettings(); assert.equal(h.windows.length, 2); @@ -570,6 +581,26 @@ test('prewarms once and the shortcut shows and hides synchronously', async () => h.controller.dispose(); }); +test('a second shortcut or disabling cancels a cold summon before ready', async () => { + for (const cancel of ['shortcut', 'disable'] as const) { + const h = await harness(); + await h.controller.refreshSettings(); + h.shortcut(); + const view = h.views[0]!; + const floating = h.windows[1]!; + if (cancel === 'shortcut') h.shortcut(); + else { + h.setEnabled(false); + await h.controller.refreshSettings(); + } + await h.command(view.webContents, 'ready'); + assert.equal(floating.visible, false, cancel); + assert.equal(floating.focused, 0, cancel); + assert.equal(view.webContents.sent.some(([channel]) => channel === 'workhub-presentation:focus-composer'), false, cancel); + h.controller.dispose(); + } +}); + test('a pending backdrop capture and older hide cannot delay or undo the shortcut', async () => { const h = await harness(); await h.controller.refreshSettings(); @@ -600,6 +631,7 @@ test('the shortcut supersedes a pending dock without waiting for the main window await h.controller.refreshSettings(); h.shortcut(); const view = h.views[0]!; + await h.command(view.webContents, 'ready'); const floating = h.windows[1]!; await h.command(view.webContents, 'conversation-layout', { expanded: true, compactHeight: 96 }); h.advance(80); @@ -627,6 +659,7 @@ test('native resize callbacks do not submit duplicate view bounds and follow dis const h = await harness(true, 120); await h.controller.show(); const view = h.views[0]!; + await h.command(view.webContents, 'ready'); view.webContents.getZoomFactor = () => 2; await h.command(view.webContents, 'conversation-layout', { expanded: true, compactHeight: 96 }); const before = view.boundsUpdates.length; @@ -654,6 +687,7 @@ test('hiding returns the live view to Desktop and preserves floating geometry fo h.shortcut(); const floating = h.windows[1]!; const view = h.views[0]!; + await h.command(view.webContents, 'ready'); await h.command(view.webContents, 'conversation-layout', { expanded: false, compactHeight: 144 }); h.shortcut(); assert.equal(floating.visible, false); @@ -676,6 +710,7 @@ test('hiding with Desktop closed keeps the conversation alive without reopening const h = await harness(); await h.controller.show(); const view = h.views[0]!; + await h.command(view.webContents, 'ready'); h.main.destroy(); await h.controller.toggle(); assert.equal(h.controller.getSnapshot().placement, 'docked'); @@ -768,6 +803,7 @@ test('closing progress suppresses the current turn and old paint acknowledgement test('the shortcut opens the normal composer from progress without waiting for its paint', async () => { const h = await harness(); await h.controller.prepareControl('turn'); + await h.command(h.views[0]!.webContents, 'ready'); const request = h.controller.getSnapshot().progressRequest!; await h.controller.toggle(true); const floating = h.windows[1]!; diff --git a/apps/desktop/src/main/app-update-attestation.ts b/apps/desktop/src/main/app-update-attestation.ts index bc2d01478f..a4988c751f 100644 --- a/apps/desktop/src/main/app-update-attestation.ts +++ b/apps/desktop/src/main/app-update-attestation.ts @@ -17,13 +17,14 @@ * under the License. */ -import { bundleFromJSON, type Bundle } from '@sigstore/bundle'; -import { getTrustedRoot } from '@sigstore/tuf'; -import { toSignedEntity, toTrustMaterial, Verifier } from '@sigstore/verify'; +import type { Bundle } from '@sigstore/bundle'; import { createHash } from 'node:crypto'; import { createReadStream, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; import { basename, join } from 'node:path'; +const require = createRequire(import.meta.url); + const PRODUCT_REPOSITORY = 'apache/maka'; const PRODUCT_RELEASE_WORKFLOW = '.github/workflows/release-cli-finalize.yml'; const PRODUCT_NIGHTLY_WORKFLOW = '.github/workflows/desktop-nightly.yml'; @@ -228,6 +229,7 @@ function parseBundle(bytes: Uint8Array): Bundle { throw new Error('Update attestation is not valid JSON', { cause: error }); } try { + const { bundleFromJSON } = require('@sigstore/bundle') as typeof import('@sigstore/bundle'); return bundleFromJSON(serialized as Parameters[0]); } catch (error) { throw new Error('Update attestation is not a valid Sigstore bundle', { cause: error }); @@ -290,6 +292,9 @@ export async function verifyDownloadedUpdateAttestation( if (options.verifyBundle) { await options.verifyBundle(bundle); } else { + const { getTrustedRoot } = require('@sigstore/tuf') as typeof import('@sigstore/tuf'); + const { toSignedEntity, toTrustMaterial, Verifier } = + require('@sigstore/verify') as typeof import('@sigstore/verify'); const trustedRoot = await getTrustedRoot({ cachePath: options.trustRootCacheDirectory, timeout: 10_000, diff --git a/apps/desktop/src/main/browser/session.ts b/apps/desktop/src/main/browser/session.ts index e0a9b5a7ad..712e59d64b 100644 --- a/apps/desktop/src/main/browser/session.ts +++ b/apps/desktop/src/main/browser/session.ts @@ -136,11 +136,12 @@ const bySession = new Map(); // so two concurrent first calls for one conversation must share one attempt // instead of racing into a second connection (which the bridge would reject). const pendingAcquires = new Map>(); -// Release epoch per conversation. A delete/archive cannot reliably see an +// Release epoch per in-flight acquire. A delete/archive cannot reliably see an // in-flight acquire, so instead of the release waiting on the acquire, the // acquire notices the bump after connecting and unwinds itself — otherwise its // resolveEndpoint would resurrect the just-disposed view and the connection -// would outlive the conversation with nothing left to ever clean it up. +// would outlive the conversation with nothing left to ever clean it up. The +// entry only lives until the acquire settles, not for every released session. const releaseEpochs = new Map(); // In-flight actions per conversation, so the visible lease can REVOKE — not just // preflight. canDrive gates the START on screen; this severs an action that was @@ -244,8 +245,11 @@ async function acquire(sessionId: string): Promise { // call retries fresh; concurrent callers share the same outcome either way. const inflight = pendingAcquires.get(sessionId); if (inflight) return inflight; + const epoch = 0; + // Register before resolveEndpoint, which may synchronously release the session + // before this attempt can be registered in pendingAcquires. + releaseEpochs.set(sessionId, epoch); const promise = (async () => { - const epoch = releaseEpochs.get(sessionId); const endpoint = await browserViewHost().resolveEndpoint(sessionId); let conn: Connection; try { @@ -272,7 +276,10 @@ async function acquire(sessionId: string): Promise { } bySession.set(sessionId, conn); return conn; - })().finally(() => pendingAcquires.delete(sessionId)); + })().finally(() => { + pendingAcquires.delete(sessionId); + releaseEpochs.delete(sessionId); + }); pendingAcquires.set(sessionId, promise); return promise; } @@ -412,7 +419,8 @@ export async function releaseBrowserSession(sessionId: string): Promise { // when it sees the new epoch (see acquire) — it cannot be awaited here because // it may not have registered in pendingAcquires yet, and a hung endpoint // resolution must not block the session's deletion. - releaseEpochs.set(sessionId, (releaseEpochs.get(sessionId) ?? 0) + 1); + const epoch = releaseEpochs.get(sessionId); + if (epoch !== undefined) releaseEpochs.set(sessionId, epoch + 1); const conn = bySession.get(sessionId); if (conn) { bySession.delete(sessionId); diff --git a/apps/desktop/src/main/runtime-host-session-subscription-owner.ts b/apps/desktop/src/main/runtime-host-session-subscription-owner.ts index cc9af9b5ff..e2304bc4ad 100644 --- a/apps/desktop/src/main/runtime-host-session-subscription-owner.ts +++ b/apps/desktop/src/main/runtime-host-session-subscription-owner.ts @@ -65,7 +65,10 @@ export interface RuntimeHostSessionSubscriptionOwnerDeps { interface SubscriptionAttempt { readonly handle: DesktopRuntimeHostSession; readonly pendingFrames: SubscriptionFrame[]; - readonly failed: Promise; + preparationFailure?: { + readonly promise: Promise; + readonly resolve: (error: Error) => void; + }; pendingFrameBytes: number; replica?: DesktopTranscriptReplica; phase: 'preparing' | 'active' | 'retiring'; @@ -169,6 +172,7 @@ export class RuntimeHostSessionSubscriptionOwner { await previous.handle.close().catch(() => undefined); await this.#drainPendingFrames(attempt); attempt.phase = 'active'; + attempt.preparationFailure = undefined; } catch (error) { const failure = asError(error); if (attempt && this.#attempt === attempt) { @@ -250,6 +254,7 @@ export class RuntimeHostSessionSubscriptionOwner { this.#attempt = attempt; await this.#drainPendingFrames(attempt); attempt.phase = "active"; + attempt.preparationFailure = undefined; } catch (error) { if (this.#candidate === attempt) this.#candidate = undefined; if (this.#attempt === attempt) this.#attempt = undefined; @@ -282,20 +287,16 @@ export class RuntimeHostSessionSubscriptionOwner { throw ownerClosed(); } - let fail!: (error: Error) => void; - const failed = new Promise((resolve) => { - fail = resolve; - }); const attempt: SubscriptionAttempt = { handle, pendingFrames: [], - failed, + preparationFailure: createPreparationFailure(), pendingFrameBytes: 0, phase: "preparing", fail(error) { if (attempt.failure) return; attempt.failure = error; - fail(error); + attempt.preparationFailure?.resolve(error); }, }; if (this.#candidate) { @@ -320,7 +321,7 @@ export class RuntimeHostSessionSubscriptionOwner { (replica) => ({ kind: "replica" as const, replica }), (error: unknown) => ({ kind: "failure" as const, error: asError(error) }), ), - failed.then((error) => ({ kind: "failure" as const, error })), + attempt.preparationFailure!.promise.then((error) => ({ kind: "failure" as const, error })), ]); if (loaded.kind === "failure") throw loaded.error; attempt.replica = loaded.replica; @@ -353,7 +354,7 @@ export class RuntimeHostSessionSubscriptionOwner { (activate) => ({ kind: 'ready' as const, activate }), (error: unknown) => ({ kind: 'failure' as const, error: asError(error) }), ), - attempt.failed.then((error) => ({ kind: 'failure' as const, error })), + attempt.preparationFailure!.promise.then((error) => ({ kind: 'failure' as const, error })), ]); if (result.kind === 'failure') throw result.error; return result.activate; @@ -421,6 +422,14 @@ export class RuntimeHostSessionSubscriptionOwner { } } +function createPreparationFailure(): NonNullable { + // Keep both roots together so activation can release the completed race results. + // In particular, the attempt's fail method must not capture this resolver. + let resolve!: (error: Error) => void; + const promise = new Promise((settle) => { resolve = settle; }); + return { promise, resolve }; +} + function subscriptionClosedError( reason: "slow_consumer" | "session_removed" | 'access_revoked', ): Error { diff --git a/apps/desktop/src/main/session-local-service.ts b/apps/desktop/src/main/session-local-service.ts index 06392d98b5..0295a09431 100644 --- a/apps/desktop/src/main/session-local-service.ts +++ b/apps/desktop/src/main/session-local-service.ts @@ -363,6 +363,18 @@ export class DesktopSessionLocalService { ); } + #scheduleRetry(key: string): void { + // Keep the timer outside the delivery context, which owns the full message + // record. SQLite already owns the intent while it waits for another attempt. + const timer = setTimeout(() => { + this.#retries.delete(key); + this.#probed.delete(key); + this.wake(); + }, 5000); + timer.unref(); + this.#retries.set(key, timer); + } + async #deliver(target: DesktopSessionLocalTarget, original: LocalOutboxRecord): Promise { const client = target.client!; let record = original; @@ -459,13 +471,7 @@ export class DesktopSessionLocalService { : 'Message preparation failed. The local copy is retained.', }); if ((uncertain || retryable) && !this.#closed && !this.#retries.has(key)) { - const timer = setTimeout(() => { - this.#retries.delete(key); - this.#probed.delete(key); - this.wake(); - }, 5000); - timer.unref(); - this.#retries.set(key, timer); + this.#scheduleRetry(key); } else if (!uncertain && !retryable) this.#probed.delete(key); } this.deps.changed(target.scope, record.sessionId); diff --git a/apps/desktop/src/main/workhub-presentation.ts b/apps/desktop/src/main/workhub-presentation.ts index 982abe97db..29e8091c72 100644 --- a/apps/desktop/src/main/workhub-presentation.ts +++ b/apps/desktop/src/main/workhub-presentation.ts @@ -98,7 +98,15 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { function focusComposer(): void { focusPending = true; - if (!view || view.webContents.isDestroyed() || !rendererReady || !parent || parent.isDestroyed() || !parent.isVisible()) return; + if (!view || view.webContents.isDestroyed() || !rendererReady || !parent || parent.isDestroyed()) return; + // A cold summon stays hidden until the renderer has mounted its composer. + // Reuse focusPending so hide/disable can cancel it before ready arrives. + if (placement === 'floating' && progressRequest === undefined) { + if (parent.isMinimized()) parent.restore(); + parent.show(); + parent.focus(); + } + if (!parent.isVisible()) return; if (placement === 'docked' && (!host.visible || host.occluded)) return; view.webContents.focus(); view.webContents.send('workhub-presentation:focus-composer', expandOnFocus); @@ -336,9 +344,6 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { const current = target.getBounds(); if (bounds.x !== current.x || bounds.y !== current.y || bounds.width !== current.width || bounds.height !== current.height) target.setBounds(bounds); fitFloating(); - if (target.isMinimized()) target.restore(); - target.show(); - target.focus(); focusComposer(); changed(); } @@ -348,7 +353,7 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { const dockVisible = placement === 'docked' && parent === main && main?.isVisible() && !main.isMinimized() && host.visible && !host.occluded && view?.getVisible(); if (!controlTurnId || dismissedTurnId === controlTurnId || progressRequest !== undefined || dockVisible - || (placement === 'floating' && floating?.isVisible()) || !deps.isEnabled() || disposed) return; + || (placement === 'floating' && (floating?.isVisible() || focusPending)) || !deps.isEnabled() || disposed) return; ensureView(); const target = ensureFloating(); cancelFloatingAnimation(); @@ -468,7 +473,7 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { switch (command) { case 'snapshot': return getSnapshot(); case 'ready': - if (!isMain) { rendererReady = true; if (focusPending) focusComposer(); } + if (!isMain) { rendererReady = true; if (focusPending) { focusComposer(); changed(); } } else { mainReady.add(event.sender); const pending = pendingNavigation.get(event.sender); @@ -599,7 +604,7 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { async function toggle(positionAtDefault = false): Promise { if (disposed) throw new Error('WorkHub presentation is disposed'); ++presentationRevision; - if (progressRequest === undefined && placement === 'floating' && floating?.isVisible()) { + if (progressRequest === undefined && placement === 'floating' && (floating?.isVisible() || focusPending)) { hideFloating(); } else detach(positionAtDefault); } @@ -608,13 +613,9 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { const enabled = deps.isEnabled(); if (disposed) return; if (enabled) { - // Prepare the reusable native window and renderer while enabling WorkHub, - // before a shortcut needs them. Never restart a crashed renderer implicitly. - const target = ensureFloating(); - if (!rendererCrashed) { - ensureView(); - if (!parent) { attach(target); fitFloating(); } - } + // Enabling only registers the shortcut. The dock, shortcut or control + // request creates the renderer on first use; settings alone must not + // load a second application in the background. if (!shortcutRegistered) shortcutRegistered = globalShortcut.register(SHORTCUT, () => { void toggle(true).catch(reportError); }); } else { if (shortcutRegistered) globalShortcut.unregister(SHORTCUT); @@ -642,6 +643,7 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { view = undefined; viewBounds = undefined; rendererReady = false; + focusPending = false; // Release this renderer's subscriptions and broadcasts before another view // can register. A delayed destroyed event must not release its replacement. previous?.webContents.removeListener('destroyed', releaseViewRegistration); diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/app-shell-session-events.ts index b5ecd3d2ad..6e8c3407bf 100644 --- a/apps/desktop/src/renderer/app-shell-session-events.ts +++ b/apps/desktop/src/renderer/app-shell-session-events.ts @@ -34,6 +34,7 @@ import type { RefreshMessagesOptions } from './app-shell-chat-actions.js'; import type { MessageQueueUiState } from './app-shell-session-ui-state.js'; import * as modelConnectionErrors from './model-connection-errors.js'; import { getDesktopConversationCopy } from './locales/conversation-copy.js'; +import { createConversationDisplayFrameScheduler } from './features/conversation/index.js'; type RefBox = { current: T }; type StateUpdater = (updater: (current: T) => T) => void; @@ -115,20 +116,7 @@ export function createAppShellSessionEventHandlers(options: { toastApi, notifyRunEnded, } = options; - const scheduleFrame = options.scheduleFrame ?? ( - typeof requestAnimationFrame === 'function' - ? (callback: () => void) => { - let pending = true; - const run = () => { - if (!pending) return; - pending = false; - callback(); - }; - requestAnimationFrame(run); - window.setTimeout(run, 100); - } - : undefined - ); + const scheduleFrame = options.scheduleFrame ?? createConversationDisplayFrameScheduler(); const displayBatch = options.displayBatch ?? createAppShellSessionDisplayBatch(); function applyProjectionEvents( diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index b9be86eff0..01cb74b7e5 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -341,10 +341,11 @@ function AppShellContent({ const activeHostSession = activeCatalogSession?.localState !== 'pending' ? activeCatalogSession : undefined; const sharedSessionActive = activeCatalogSession?.shared === true; const ownerActiveId = sharedSessionActive ? undefined : activeHostSession?.id; - const interactionHydrationEpochRef = useRef(new Map()); + // Only the outstanding read needs a fence; past Sessions leave no hydration metadata. + const interactionHydrationRef = useRef<{ sessionId: string } | null>(null); const markInteractionChanged = useCallback((sessionId: string) => { - const epochs = interactionHydrationEpochRef.current; - epochs.set(sessionId, (epochs.get(sessionId) ?? 0) + 1); + const pending = interactionHydrationRef.current; + if (pending?.sessionId === sessionId) interactionHydrationRef.current = null; }, []); const { @@ -1029,23 +1030,20 @@ function AppShellContent({ // active session changes (#2072). useEffect(() => { if (!ownerActiveId) return; - let cancelled = false; - const hydrationEpoch = interactionHydrationEpochRef.current.get(ownerActiveId) ?? 0; + const pending = { sessionId: ownerActiveId }; + interactionHydrationRef.current = pending; + const release = () => { + if (interactionHydrationRef.current === pending) interactionHydrationRef.current = null; + }; void window.maka.sessions .listActiveInteractions(ownerActiveId) .then((requests) => { - if ( - cancelled || - (interactionHydrationEpochRef.current.get(ownerActiveId) ?? 0) !== hydrationEpoch - ) { - return; - } + if (interactionHydrationRef.current !== pending) return; sessionUiController.setInteractionBySession((current) => reconcileInteractions(current, ownerActiveId, requests)); }) - .catch(() => {}); - return () => { - cancelled = true; - }; + .catch(() => {}) + .finally(release); + return release; }, [ownerActiveId, sessionUiController.setInteractionBySession]); useEffect( () => diff --git a/apps/desktop/src/renderer/features/conversation/controller/display-frame-scheduler.ts b/apps/desktop/src/renderer/features/conversation/controller/display-frame-scheduler.ts new file mode 100644 index 0000000000..5c9a5f2f77 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/controller/display-frame-scheduler.ts @@ -0,0 +1,35 @@ +/* + * 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. + */ + +export function createConversationDisplayFrameScheduler(): ((callback: () => void) => void) | undefined { + if (typeof requestAnimationFrame !== 'function') return undefined; + return (callback) => { + let pending = true; + const run = () => { + if (!pending) return; + pending = false; + // Hidden windows can suspend frames while the timeout keeps flushing. + cancelAnimationFrame(frameId); + window.clearTimeout(timeoutId); + callback(); + }; + const frameId = requestAnimationFrame(run); + const timeoutId = window.setTimeout(run, 100); + }; +} diff --git a/apps/desktop/src/renderer/features/conversation/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts index 141ad9ddc0..5708cc0c59 100644 --- a/apps/desktop/src/renderer/features/conversation/index.ts +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -45,6 +45,7 @@ export * from './model/session-ui-state.js'; export type { ConversationServices } from './ports.js'; export { ConversationServicesProvider } from './services.js'; export { SessionLocalMessages } from './controller/session-local-messages.js'; +export { createConversationDisplayFrameScheduler } from './controller/display-frame-scheduler.js'; export { useComposerAttachments, type ComposerAttachmentService } from './controller/use-composer-attachments.js'; export { type PendingAttachment, toComposerIngestItems, retainedAttachmentRefs } from '@maka/ui/composer-attachments'; diff --git a/native/runtime-host-peer/src/webrtc_direct/lifetime.rs b/native/runtime-host-peer/src/webrtc_direct/lifetime.rs new file mode 100644 index 0000000000..aeca7da9b1 --- /dev/null +++ b/native/runtime-host-peer/src/webrtc_direct/lifetime.rs @@ -0,0 +1,70 @@ +/* + * 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. + */ + +use std::{io, sync::Arc}; + +use tokio_util::sync::CancellationToken; +use webrtc::peer_connection::PeerConnection; + +// The upgrade owns this guard until it transfers ownership to the muxer. Workers +// receive only cancellation clones, so they cannot keep the owner alive. +pub(super) struct PeerConnectionLifetime { + pub(super) peer_connection: Arc, + pub(super) cancellation: CancellationToken, + closed: bool, +} + +impl PeerConnectionLifetime { + pub(super) fn new( + peer_connection: Arc, + cancellation: CancellationToken, + ) -> Self { + Self { + peer_connection, + cancellation, + closed: false, + } + } + + pub(super) async fn close(&mut self) -> io::Result<()> { + // PC close alone can leave DataChannel::poll blocked: the channel owns + // a PC reference which retains its own event senders. + self.cancellation.cancel(); + self.peer_connection + .close() + .await + .map_err(|error| io::Error::other(error.to_string()))?; + self.closed = true; + Ok(()) + } +} + +impl Drop for PeerConnectionLifetime { + fn drop(&mut self) { + self.cancellation.cancel(); + if !self.closed { + let peer_connection = Arc::clone(&self.peer_connection); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(async move { + let _ = peer_connection.close().await; + }); + } + } + } +} diff --git a/native/runtime-host-peer/src/webrtc_direct/mod.rs b/native/runtime-host-peer/src/webrtc_direct/mod.rs index df23ac8ba9..b21fa457d1 100644 --- a/native/runtime-host-peer/src/webrtc_direct/mod.rs +++ b/native/runtime-host-peer/src/webrtc_direct/mod.rs @@ -17,6 +17,7 @@ * under the License. */ +mod lifetime; mod muxer; mod signaling; mod transport; diff --git a/native/runtime-host-peer/src/webrtc_direct/muxer.rs b/native/runtime-host-peer/src/webrtc_direct/muxer.rs index 4a91623988..9a45d9d3b3 100644 --- a/native/runtime-host-peer/src/webrtc_direct/muxer.rs +++ b/native/runtime-host-peer/src/webrtc_direct/muxer.rs @@ -36,18 +36,21 @@ use futures::{ use libp2p::core::muxing::{StreamMuxer, StreamMuxerEvent}; use libp2p_webrtc_utils::{DropListener, Stream as Libp2pWebRtcStream}; use tokio::sync::OwnedSemaphorePermit; +use tokio_util::sync::CancellationToken; use webrtc::{ data_channel::{DataChannel, DataChannelEvent, RTCDataChannelState}, - peer_connection::{PeerConnection, RTCPeerConnectionState}, + peer_connection::RTCPeerConnectionState, }; +use super::lifetime::PeerConnectionLifetime; + const DATA_CHANNEL_QUEUE_CAPACITY: usize = 16; const MAX_DATA_CHANNEL_MESSAGE_BYTES: usize = 16 * 1024; pub(crate) type ReadySubstream = (WebRtcSubstream, DropListener); pub struct WebRtcConnection { - peer_connection: Arc, + lifetime: Option, incoming: mpsc::Receiver>, states: mpsc::Receiver, outbound: Option>>, @@ -57,13 +60,13 @@ pub struct WebRtcConnection { } impl WebRtcConnection { - pub(crate) fn new( - peer_connection: Arc, + pub(super) fn new( + lifetime: PeerConnectionLifetime, incoming: mpsc::Receiver>, states: mpsc::Receiver, ) -> Self { Self { - peer_connection, + lifetime: Some(lifetime), incoming, states, outbound: None, @@ -82,12 +85,7 @@ impl WebRtcConnection { } pub(crate) fn close_in_background(self) { - let peer_connection = Arc::clone(&self.peer_connection); - if let Ok(runtime) = tokio::runtime::Handle::try_current() { - runtime.spawn(async move { - let _ = peer_connection.close().await; - }); - } + drop(self); } } @@ -113,7 +111,14 @@ impl StreamMuxer for WebRtcConnection { mut self: Pin<&mut Self>, cx: &mut Context<'_>, ) -> Poll> { - let peer_connection = Arc::clone(&self.peer_connection); + let Some(lifetime) = self.lifetime.as_ref() else { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::ConnectionAborted, + "WebRTC peer connection closing", + ))); + }; + let peer_connection = Arc::clone(&lifetime.peer_connection); + let cancellation = lifetime.cancellation.clone(); let future = self.outbound.get_or_insert_with(|| { async move { let channel = peer_connection @@ -121,7 +126,7 @@ impl StreamMuxer for WebRtcConnection { .await .map_err(webrtc_io_error)?; data_channel_diagnostic("created", channel.id(), Some("outbound")); - ready_substream(channel, "outbound", None).await + ready_substream(channel, "outbound", None, cancellation).await } .boxed() }); @@ -138,11 +143,16 @@ impl StreamMuxer for WebRtcConnection { } fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let peer_connection = Arc::clone(&self.peer_connection); - let future = self.closing.get_or_insert_with(|| { - async move { peer_connection.close().await.map_err(webrtc_io_error) }.boxed() - }); - future.poll_unpin(cx) + if self.closing.is_none() { + let mut lifetime = self.lifetime.take().expect("connection lifetime exists"); + lifetime.cancellation.cancel(); + self.outbound = None; + self.closing = Some(async move { lifetime.close().await }.boxed()); + } + self.closing + .as_mut() + .expect("close future exists") + .poll_unpin(cx) } fn poll( @@ -217,18 +227,21 @@ pub(crate) async fn ready_substream( data_channel: Arc, origin: &'static str, inbound_permit: Option, + cancellation: CancellationToken, ) -> Result { let (outgoing, outgoing_receiver) = mpsc::channel(DATA_CHANNEL_QUEUE_CAPACITY); let (incoming_sender, incoming) = mpsc::channel(DATA_CHANNEL_QUEUE_CAPACITY); let io = MessageIo::new(outgoing, incoming); let (ready_sender, ready_receiver) = oneshot::channel(); - tokio::spawn(drive_data_channel( - data_channel, - outgoing_receiver, - incoming_sender, - ready_sender, - origin, - )); + tokio::spawn(async move { + tokio::select! { + biased; + _ = cancellation.cancelled() => {}, + _ = drive_data_channel( + data_channel, outgoing_receiver, incoming_sender, ready_sender, origin, + ) => {}, + } + }); ready_receiver .await .map_err(|_| io::Error::new(io::ErrorKind::ConnectionAborted, "data channel stopped"))??; @@ -245,22 +258,29 @@ pub(crate) async fn ready_substream( pub(crate) fn keep_init_channel( data_channel: Arc, mut opened: mpsc::Sender>, + cancellation: CancellationToken, ) { tokio::spawn(async move { - data_channel_diagnostic("waiting", data_channel.id(), Some("init")); - let result = wait_for_data_channel_open(&data_channel).await; - let is_open = result.is_ok(); - data_channel_diagnostic( - if is_open { "opened" } else { "open-failed" }, - data_channel.id(), - Some("init"), - ); - let _ = opened.try_send(result); - if is_open { - // Keep the initial channel allocated for the connection lifetime. Closing it lets - // current native WebRTC stacks reuse its SCTP stream id before both peers have - // retired it, causing the first real libp2p substream to fail immediately. - while data_channel.poll().await.is_some() {} + tokio::select! { + biased; + _ = cancellation.cancelled() => {}, + _ = async move { + data_channel_diagnostic("waiting", data_channel.id(), Some("init")); + let result = wait_for_data_channel_open(&data_channel).await; + let is_open = result.is_ok(); + data_channel_diagnostic( + if is_open { "opened" } else { "open-failed" }, + data_channel.id(), + Some("init"), + ); + let _ = opened.try_send(result); + if is_open { + // Keep the initial channel allocated for the connection lifetime. Closing it lets + // current native WebRTC stacks reuse its SCTP stream id before both peers have + // retired it, causing the first real libp2p substream to fail immediately. + while data_channel.poll().await.is_some() {} + } + } => {}, } }); } diff --git a/native/runtime-host-peer/src/webrtc_direct/tests.rs b/native/runtime-host-peer/src/webrtc_direct/tests.rs index 9519272753..f6183b1cc2 100644 --- a/native/runtime-host-peer/src/webrtc_direct/tests.rs +++ b/native/runtime-host-peer/src/webrtc_direct/tests.rs @@ -127,6 +127,75 @@ async fn a_slow_reader_backpressures_without_losing_bytes() { assert_eq!(received, expected); } +#[tokio::test] +async fn connection_teardown_releases_a_backpressured_writer() { + for explicit_close in [true, false] { + let (mut connection_a, mut connection_b) = connected_pair().await; + let (outbound, inbound) = tokio::join!( + poll_fn(|cx| Pin::new(&mut connection_a).poll_outbound(cx)), + poll_fn(|cx| Pin::new(&mut connection_b).poll_inbound(cx)), + ); + let mut outbound = outbound.expect("outbound stream"); + let inbound = inbound.expect("inbound stream"); + // An idle application reader does not stop the transport from buffering. + // Fill incrementally until one write actually stays pending, retaining + // that same future through teardown. Bound setup time and total traffic. + let payload = [0x5a; 8 * 1024]; // Leave room for libp2p framing below 16 KiB. + let setup_deadline = tokio::time::Instant::now() + Duration::from_secs(5); + let mut sent = 0; + loop { + assert!( + sent < 64 * 1024 * 1024, + "backpressure setup exceeded 64 MiB" + ); + assert!( + tokio::time::Instant::now() < setup_deadline, + "backpressure setup timed out" + ); + let mut writing = Box::pin(async { + outbound.write_all(&payload).await?; + outbound.flush().await + }); + if let Ok(result) = tokio::time::timeout(Duration::from_millis(100), &mut writing).await + { + result.expect("write before teardown"); + sent += payload.len(); + continue; + } + if explicit_close { + tokio::time::timeout(Duration::from_secs(5), async { + let (a, b) = tokio::join!( + poll_fn(|cx| Pin::new(&mut connection_a).poll_close(cx)), + poll_fn(|cx| Pin::new(&mut connection_b).poll_close(cx)), + ); + a.expect("close offerer"); + b.expect("close answerer"); + }) + .await + .expect("connection close timeout"); + } + drop((connection_a, connection_b)); + // Already queued bytes may finish during close. Either result is + // valid, but the pending operation must settle and new writes fail. + let _ = tokio::time::timeout(Duration::from_secs(5), &mut writing) + .await + .expect("teardown must release the writer"); + drop(writing); + assert!( + tokio::time::timeout(Duration::from_secs(5), async { + outbound.write_all(&payload).await?; + outbound.flush().await + }) + .await + .expect("write after teardown must settle") + .is_err() + ); + break; + } + drop(inbound); + } +} + #[tokio::test] async fn inbound_substreams_are_bounded_per_connection() { let (mut connection_a, mut connection_b) = connected_pair().await; diff --git a/native/runtime-host-peer/src/webrtc_direct/upgrade.rs b/native/runtime-host-peer/src/webrtc_direct/upgrade.rs index 09a7f61a29..62d0817af3 100644 --- a/native/runtime-host-peer/src/webrtc_direct/upgrade.rs +++ b/native/runtime-host-peer/src/webrtc_direct/upgrade.rs @@ -44,6 +44,7 @@ use webrtc::{ use super::{ Signal, SignalingError, WebRtcConnection, + lifetime::PeerConnectionLifetime, muxer::{ReadySubstream, data_channel_diagnostic, keep_init_channel, ready_substream}, read_signal, write_signal, }; @@ -103,31 +104,6 @@ pub enum UpgradeError { Cancelled, } -struct PeerConnectionGuard(Option>); - -impl PeerConnectionGuard { - fn new(peer_connection: Arc) -> Self { - Self(Some(peer_connection)) - } - - fn disarm(&mut self) { - self.0 = None; - } -} - -impl Drop for PeerConnectionGuard { - fn drop(&mut self) { - let Some(peer_connection) = self.0.take() else { - return; - }; - if let Ok(runtime) = tokio::runtime::Handle::try_current() { - runtime.spawn(async move { - let _ = peer_connection.close().await; - }); - } - } -} - pub async fn upgrade_connection( signaling: S, authenticated_peer: PeerId, @@ -146,28 +122,29 @@ where } let channels = EventChannels::new(); - let peer_connection = build_peer_connection(&options, channels.handler()).await?; - let mut peer_connection_guard = PeerConnectionGuard::new(Arc::clone(&peer_connection)); - let negotiation = negotiate(signaling, role, Arc::clone(&peer_connection), channels); - tokio::pin!(negotiation); - let deadline = tokio::time::sleep(options.deadline); - tokio::pin!(deadline); - - let result = tokio::select! { - biased; - _ = options.cancellation.cancelled() => Err(UpgradeError::Cancelled), - result = &mut negotiation => result, - _ = &mut deadline => Err(UpgradeError::Deadline), + // Retiring the signaling attempt after success must not cancel the connection. + let cancellation = CancellationToken::new(); + let peer_connection = + build_peer_connection(&options, channels.handler(cancellation.clone())).await?; + let mut lifetime = PeerConnectionLifetime::new(peer_connection, cancellation); + let result = { + let negotiation = negotiate(signaling, role, &lifetime, channels); + tokio::pin!(negotiation); + tokio::select! { + biased; + _ = options.cancellation.cancelled() => Err(UpgradeError::Cancelled), + result = &mut negotiation => result, + _ = tokio::time::sleep(options.deadline) => Err(UpgradeError::Deadline), + } }; match result { - Ok(connection) => { - peer_connection_guard.disarm(); - Ok((authenticated_peer, connection)) - } + Ok((incoming, states)) => Ok(( + authenticated_peer, + WebRtcConnection::new(lifetime, incoming, states), + )), Err(error) => { - let _ = peer_connection.close().await; - peer_connection_guard.disarm(); + let _ = lifetime.close().await; Err(error) } } @@ -201,12 +178,19 @@ async fn build_peer_connection( async fn negotiate( signaling: S, role: UpgradeRole, - peer_connection: Arc, + lifetime: &PeerConnectionLifetime, channels: EventChannels, -) -> Result +) -> Result< + ( + mpsc::Receiver>, + mpsc::Receiver, + ), + UpgradeError, +> where S: AsyncRead + AsyncWrite + Unpin, { + let peer_connection = Arc::clone(&lifetime.peer_connection); let (mut reader, mut writer) = signaling.split(); let EventChannels { mut candidates, @@ -234,7 +218,7 @@ where .create_data_channel("init", None) .await .map_err(webrtc_upgrade_error)?; - keep_init_channel(init, init_sender); + keep_init_channel(init, init_sender, lifetime.cancellation.clone()); let offer = peer_connection .create_offer(None) .await @@ -279,7 +263,7 @@ where diagnostic(role, "signaling-write-closed", None); } if direct_connection_ready && !remote_signaling_open { - return Ok(WebRtcConnection::new(peer_connection, incoming, states)); + return Ok((incoming, states)); } tokio::select! { signal = read_signal(&mut reader), if remote_signaling_open => { @@ -421,7 +405,7 @@ impl EventChannels { } } - fn handler(&self) -> Arc { + fn handler(&self, cancellation: CancellationToken) -> Arc { Arc::new(EventHandler { candidates: self.candidate_sender.clone(), states: self.state_sender.clone(), @@ -430,6 +414,7 @@ impl EventChannels { failures: self.failure_sender.clone(), init_claimed: Arc::clone(&self.init_claimed), inbound_substreams: Arc::clone(&self.inbound_substreams), + cancellation, }) } } @@ -443,6 +428,7 @@ struct EventHandler { failures: mpsc::Sender, init_claimed: Arc, inbound_substreams: Arc, + cancellation: CancellationToken, } #[async_trait::async_trait] @@ -460,6 +446,12 @@ impl PeerConnectionEventHandler for EventHandler { } async fn on_connection_state_change(&self, state: RTCPeerConnectionState) { + if matches!( + state, + RTCPeerConnectionState::Failed | RTCPeerConnectionState::Closed + ) { + self.cancellation.cancel(); + } let mut states = self.states.clone(); if states.try_send(state).is_err() { self.report_failure("connection state delivery overflowed"); @@ -475,7 +467,11 @@ impl PeerConnectionEventHandler for EventHandler { .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .is_ok() { - keep_init_channel(data_channel, self.init_opened.clone()); + keep_init_channel( + data_channel, + self.init_opened.clone(), + self.cancellation.clone(), + ); } else { data_channel_diagnostic("rejected", data_channel.id(), Some("duplicate-init")); let _ = data_channel.close().await; @@ -495,9 +491,18 @@ impl PeerConnectionEventHandler for EventHandler { } }; let mut incoming = self.incoming.clone(); + let cancellation = self.cancellation.clone(); tokio::spawn(async move { - let stream = ready_substream(data_channel, "incoming", Some(permit)).await; - let _ = incoming.send(stream).await; + tokio::select! { + biased; + _ = cancellation.cancelled() => {}, + _ = async { + let stream = ready_substream( + data_channel, "incoming", Some(permit), cancellation.clone(), + ).await; + let _ = incoming.send(stream).await; + } => {}, + } }); } Err(error) => { diff --git a/packages/cli/src/runtime-host-run-command.ts b/packages/cli/src/runtime-host-run-command.ts index 9bb0da7e93..7af73261c2 100644 --- a/packages/cli/src/runtime-host-run-command.ts +++ b/packages/cli/src/runtime-host-run-command.ts @@ -780,8 +780,7 @@ class NonInteractiveInteractionController { readonly #tasks = new Set>(); readonly #unsubscribe: () => void; #failure: Error | undefined; - readonly #failureSignal: Promise; - #publishFailure!: (error: Error) => void; + readonly #failureWaiters = new Set<(error: Error) => void>(); constructor( driver: RuntimeHostMakaSessionDriver, @@ -789,15 +788,25 @@ class NonInteractiveInteractionController { ) { this.#driver = driver; this.#stop = stop; - this.#failureSignal = new Promise((resolve) => { - this.#publishFailure = resolve; - }); this.#unsubscribe = driver.subscribePendingInteractions((pending) => this.#accept(pending)); } race(operation: Promise): Promise { this.throwIfFailed(); - return Promise.race([operation, this.#failureSignal.then((error) => Promise.reject(error))]); + return new Promise((resolve, reject) => { + this.#failureWaiters.add(reject); + // Detach completed waits so the controller does not retain consumed event payloads. + operation.then( + (value) => { + this.#failureWaiters.delete(reject); + resolve(value); + }, + (error) => { + this.#failureWaiters.delete(reject); + reject(error); + }, + ); + }); } async settle(): Promise { @@ -844,7 +853,8 @@ class NonInteractiveInteractionController { #fail(error: Error): void { if (this.#failure) return; this.#failure = error; - this.#publishFailure(error); + for (const reject of this.#failureWaiters) reject(error); + this.#failureWaiters.clear(); } } diff --git a/packages/computer-use/src/__tests__/maka-cu-backend.test.ts b/packages/computer-use/src/__tests__/maka-cu-backend.test.ts index e2f2112594..1c3c7b0265 100644 --- a/packages/computer-use/src/__tests__/maka-cu-backend.test.ts +++ b/packages/computer-use/src/__tests__/maka-cu-backend.test.ts @@ -34,6 +34,7 @@ import { randomUUID } from 'node:crypto'; import { after, before, describe, it } from 'node:test'; import type { CuaBoundAction } from '@maka/runtime/cua-frame-state'; +import { deferred, withTimeout } from '@maka/core/test-only/async-primitives'; import type { CuObservation, CuRunContext } from '@maka/runtime/computer-use-types'; import { @@ -417,6 +418,7 @@ function makeBackend( windowOriginY?: number; physicalInputRecentlyActive?: MakaCuBackendOptions['physicalInputRecentlyActive']; onTrace?: MakaCuBackendOptions['onTrace']; + onSessionInvalidated?: MakaCuBackendOptions['onSessionInvalidated']; } = {}, ): { backend: ReturnType; logPath: string; imageDir: string } { const logPath = join(workDir, 'log-' + randomUUID() + '.ndjson'); @@ -462,6 +464,7 @@ function makeBackend( ? { physicalInputRecentlyActive: opts.physicalInputRecentlyActive } : {}), ...(opts.onTrace ? { onTrace: opts.onTrace } : {}), + ...(opts.onSessionInvalidated ? { onSessionInvalidated: opts.onSessionInvalidated } : {}), }); disposers.push(() => backend.dispose()); return { backend, logPath, imageDir }; @@ -1107,8 +1110,15 @@ describe('maka-cu backend', () => { assert.equal(result.outcome.verified, true); }); - it('ends the executor session when the host clears it', async () => { - const { backend, logPath } = makeBackend(); + it('ends cleared sessions without re-notifying them and invalidates known sessions on generation loss', async () => { + const invalidated: string[] = []; + const generationReleased = deferred(); + const { backend, logPath } = makeBackend({ + onSessionInvalidated: ({ sessionId }) => { + invalidated.push(sessionId); + if (sessionId === 'still-known') generationReleased.resolve(); + }, + }); await observeFixture(backend); // clearSession fires session.end without exposing the round-trip, so wait // on the delivered record itself instead of guessing scheduler timing. @@ -1120,6 +1130,30 @@ describe('maka-cu backend', () => { ); const records = await readRecords(logPath); assert.deepEqual(received(records, 'session.end')[0], { session: RUN_CONTEXT.sessionId }); + assert.deepEqual(invalidated, [RUN_CONTEXT.sessionId]); + backend.clearSession(RUN_CONTEXT.sessionId); + backend.clearSession('never-begun'); + assert.deepEqual( + invalidated, + [RUN_CONTEXT.sessionId], + 'unknown cleanup must not notify observers', + ); + + // Completed work still owns a begun session even after its operation fence + // is released. Losing the generation must invalidate that session once. + await backend.observeApp!({ app: FIXTURE_APP_ID, includeScreenshot: true }, signal(), { + ...RUN_CONTEXT, + sessionId: 'still-known', + }); + const pid: unknown = records.find((record) => record.kind === 'start')?.pid; + assert.equal(typeof pid, 'number'); + process.kill(pid as number, 'SIGKILL'); + await withTimeout( + generationReleased.promise, + 5_000, + 'generation loss did not invalidate the live session', + ); + assert.deepEqual(invalidated, [RUN_CONTEXT.sessionId, 'still-known']); }); it('maps apps.list onto CuAppSummary without a rendered catalogue', async () => { diff --git a/packages/computer-use/src/maka-cu-backend.ts b/packages/computer-use/src/maka-cu-backend.ts index e26157e61f..ea2cdfcd3b 100644 --- a/packages/computer-use/src/maka-cu-backend.ts +++ b/packages/computer-use/src/maka-cu-backend.ts @@ -792,7 +792,7 @@ export function createMakaCuBackend(opts: MakaCuBackendOptions): MakaCuBackend { /** Why each forgotten observation id stopped resolving; see `forgetSnapshot`. */ const forgotten = new Map(); const begunSessions = new Set(); - const sessionGenerations = new Map(); + const sessionGenerations = new Map(); const operationQueues = new Map>(); let sessionClearReleaseEvents: MakaCuReleaseEvent[] | undefined; let disposed = false; @@ -812,7 +812,8 @@ export function createMakaCuBackend(opts: MakaCuBackendOptions): MakaCuBackend { } snapshotIdsBySession.delete(sessionId); begunSessions.delete(sessionId); - sessionGenerations.set(sessionId, (sessionGenerations.get(sessionId) ?? 0) + 1); + const active = sessionGenerations.get(sessionId); + if (active) active.generation += 1; } function applyServiceRelease(events: readonly MakaCuReleaseEvent[]): void { @@ -830,7 +831,13 @@ export function createMakaCuBackend(opts: MakaCuBackendOptions): MakaCuBackend { ]), ]; for (const sessionId of sessions) { + // Unknown cleanup must not create tool-layer state through the observer. + const known = + begunSessions.has(sessionId) || + snapshotIdsBySession.has(sessionId) || + sessionGenerations.has(sessionId); clearLocalSession(sessionId); + if (!known) continue; try { opts.onSessionInvalidated?.({ sessionId, @@ -878,8 +885,14 @@ export function createMakaCuBackend(opts: MakaCuBackendOptions): MakaCuBackend { // §9 gives the executor per-target lanes; the host queue stays upstream of // them so one Maka turn never has two dispatches in flight at once. const queueKey = '__executor__'; - const sessionGeneration = - sessionId === undefined ? undefined : (sessionGenerations.get(sessionId) ?? 0); + // Queued and in-flight operations own the fence, including before the first await. + let active: { generation: number; pending: number } | undefined; + if (sessionId !== undefined) { + active = sessionGenerations.get(sessionId) ?? { generation: 0, pending: 0 }; + active.pending += 1; + sessionGenerations.set(sessionId, active); + } + const sessionGeneration = active?.generation; const previous = operationQueues.get(queueKey) ?? Promise.resolve(); let release!: () => void; const gate = new Promise((resolve) => { @@ -891,10 +904,7 @@ export function createMakaCuBackend(opts: MakaCuBackendOptions): MakaCuBackend { try { if (disposed) throw new Error('maka-cu backend disposed'); if (signal.aborted) throw new Error('aborted'); - if ( - sessionId !== undefined && - (sessionGenerations.get(sessionId) ?? 0) !== sessionGeneration - ) { + if (active?.generation !== sessionGeneration) { throw new MakaCuSessionCleared(); } if (!sessionId) return await operation(); @@ -902,6 +912,14 @@ export function createMakaCuBackend(opts: MakaCuBackendOptions): MakaCuBackend { } finally { release(); if (operationQueues.get(queueKey) === current) operationQueues.delete(queueKey); + if ( + sessionId !== undefined && + active && + --active.pending === 0 && + sessionGenerations.get(sessionId) === active + ) { + sessionGenerations.delete(sessionId); + } } } diff --git a/packages/core/src/diagnostic-log.ts b/packages/core/src/diagnostic-log.ts index 09e0077f38..5259204363 100644 --- a/packages/core/src/diagnostic-log.ts +++ b/packages/core/src/diagnostic-log.ts @@ -104,9 +104,12 @@ function requireMinimumInteger(value: number, label: string, minimum: number): n } function truncateCodePoints(value: string, maximum: number): string { - const codePoints = [...value]; - if (codePoints.length <= maximum) return value; - return `${codePoints.slice(0, maximum).join('')}\n`; + const codePoints: string[] = []; + for (const point of value) { + if (codePoints.length === maximum) return `${codePoints.join('')}\n`; + codePoints.push(point); + } + return value; } function truncateEncodedString(value: string, maximumBytes: number): string { @@ -127,9 +130,18 @@ function truncateEncodedString(value: string, maximumBytes: number): string { export function truncateUtf8(value: string, maximumBytes: number, marker = ''): string { requirePositiveInteger(maximumBytes, 'maximumBytes'); - if (new TextEncoder().encode(value).byteLength <= maximumBytes) return value; - const suffix = new TextEncoder().encode(marker).byteLength <= maximumBytes ? marker : ''; - const codePoints = [...value]; + if (value.length <= maximumBytes && new TextEncoder().encode(value).byteLength <= maximumBytes) + return value; + const suffix = + marker.length <= maximumBytes && new TextEncoder().encode(marker).byteLength <= maximumBytes + ? marker + : ''; + // Every code point requires at least one UTF-8 byte, so only this prefix can fit. + const codePoints: string[] = []; + for (const point of value) { + if (codePoints.length === maximumBytes) break; + codePoints.push(point); + } let low = 0; let high = codePoints.length; while (low < high) { diff --git a/packages/core/src/text-sanitize.ts b/packages/core/src/text-sanitize.ts index 92a41690f7..f0e8d897af 100644 --- a/packages/core/src/text-sanitize.ts +++ b/packages/core/src/text-sanitize.ts @@ -91,7 +91,7 @@ export interface SanitizeUnicodeOptions { * * Pipeline (applied in order): NFC → control chars → space, bidi format → * space, zero-width/invisible → removed, whitespace collapse, trim, then - * code-point cap. The cap uses `Array.from(...)` to iterate by code points so a + * code-point cap. The cap iterates by code points so a * surrogate pair (e.g. `🦊` = U+1F98A, two UTF-16 code units) counts as one and * is never split in half. * @@ -107,7 +107,12 @@ export function sanitizeUnicodeText(text: string, opts: SanitizeUnicodeOptions): .replace(ZERO_WIDTH_REGEX, '') .replace(/\s+/g, ' ') .trim(); - const points = Array.from(cleaned); + const points: string[] = []; + for (const point of cleaned) { + points.push(point); + // One extra point detects truncation without expanding the entire input. + if (opts.maxCodePoints >= 0 && points.length > opts.maxCodePoints) break; + } if (points.length <= opts.maxCodePoints) return cleaned; return points.slice(0, opts.maxCodePoints).join('') + suffix; } diff --git a/packages/runtime-host/protocol-compatible-changes/base64-length-allocation.json b/packages/runtime-host/protocol-compatible-changes/base64-length-allocation.json new file mode 100644 index 0000000000..448ab8b1a9 --- /dev/null +++ b/packages/runtime-host/protocol-compatible-changes/base64-length-allocation.json @@ -0,0 +1,8 @@ +{ + "epoch": 142, + "files": [ + "packages/runtime-host/src/protocol/artifact.ts", + "packages/runtime-host/src/protocol/session-transcript.ts" + ], + "reason": "Replace decoded Buffer allocation used only for byte counts with Buffer.byteLength after the existing Base64 grammar checks. Transcript canonical decode/re-encode validation remains unchanged; artifact padding acceptance, decoded lengths, bounds, errors and all message shapes are identical. No peer can observe the removed temporary buffers." +} diff --git a/packages/runtime-host/src/__tests__/goal-coordinator.test.ts b/packages/runtime-host/src/__tests__/goal-coordinator.test.ts index 19758f04f6..1974bf1d8f 100644 --- a/packages/runtime-host/src/__tests__/goal-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/goal-coordinator.test.ts @@ -22,6 +22,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; +import { setImmediate as immediate } from 'node:timers/promises'; import type { GoalAuthorityRecord } from '@maka/core/goal'; import { seedInvocation } from '@maka/runtime/test-only/invocation-fixture'; import type { GoalTurnOutcome } from '@maka/runtime/goal-continuation'; @@ -31,7 +32,7 @@ import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storag import { HostGoalCoordinator } from '../server/goal-coordinator.js'; import { HostedExecutionProjectionReader } from '../server/hosted-execution-projection.js'; import { SessionAdmissionGate } from '../server/session-admission-gate.js'; -import { waitFor as pollFor } from '@maka/core/test-only/async-primitives'; +import { deferred, waitFor as pollFor, withTimeout } from '@maka/core/test-only/async-primitives'; test('one Host Goal is shared across clients with CAS control and crash-clear residency', async () => { const base = await mkdtemp(join(tmpdir(), 'maka-host-goal-')); @@ -43,9 +44,9 @@ test('one Host Goal is shared across clients with CAS control and crash-clear re assert.ok(owner); if (!owner) return; + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + const goalStore = await openInteractiveGoalAuthorityForWrite(owner.lease); try { - const stores = await openInteractiveExecutionStoresForWrite(owner.lease); - const goalStore = await openInteractiveGoalAuthorityForWrite(owner.lease); const session = await stores.sessionStore.create({ cwd: capability.canonicalPath, llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', @@ -225,8 +226,9 @@ test('one Host Goal is shared across clients with CAS control and crash-clear re await recovered.prepareRecovery(); assert.equal(recovered.manager.get(session.id)?.condition, 'A second Host-epoch Goal'); await recovered.close(); - await goalStore.close(); } finally { + await goalStore.close(); + await stores.sessionStore.close?.(); await owner.close(); await rm(base, { recursive: true, force: true }); } @@ -298,6 +300,12 @@ test('session retirement forgets a terminal Goal without recreating deleted auth }); await coordinator.prepareRecovery(); + const beforeRetirement = coordinator.readProjection(session.id); + const rolledBack = await coordinator.beginSessionRetirement([session.id], 'archive'); + rolledBack.rollback(); + assert.deepEqual(coordinator.readProjection(session.id), beforeRetirement); + assert.ok(await goalStore.read(session.id), 'rollback preserves durable Goal authority'); + const retirement = await coordinator.beginSessionRetirement([session.id], 'archive'); const header = await stores.sessionStore.readHeaderRecordSnapshot(session.id); await stores.sessionStore.setSessionsArchivedVersioned( @@ -313,11 +321,128 @@ test('session retirement forgets a terminal Goal without recreating deleted auth assert.deepEqual(projectionChanges, [session.id]); } finally { await goalStore.close(); + await stores.sessionStore.close?.(); await owner.close(); await rm(base, { recursive: true, force: true }); } }); +test('a retired context read cannot replace the new Goal token baseline', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-host-goal-token-')); + const capability = await resolveStorageRoot({ path: join(base, 'root'), kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + const goalStore = await openInteractiveGoalAuthorityForWrite(owner.lease); + const readStarted = deferred(); + const releaseOldRead = deferred(); + const evaluationStarted = deferred(); + const releaseEvaluation = deferred(); + let firstRead = true; + const coordinator = new HostGoalCoordinator({ + store: goalStore, + stores, + sessionAdmission: new SessionAdmissionGate(), + readSessionMessages: async (sessionId) => { + const messages = await stores.sessionStore.readMessagesSnapshot(sessionId); + if (firstRead) { + firstRead = false; + readStarted.resolve(); + await releaseOldRead.promise; + } + return messages; + }, + executions: { + reconcile: async () => assert.fail('No recovery expected'), + subscribe: () => () => {}, + }, + evaluator: { + evaluate: async () => { + evaluationStarted.resolve(); + await releaseEvaluation.promise; + return '{"met":false,"impossible":false,"progress":true,"waiting":false,"reason":"continue"}'; + }, + close: async () => {}, + }, + admitTurn: () => assert.fail('The one-iteration Goal must stop after evaluation'), + acquireResidency: () => ({ release() {} }), + onProjectionChanged: () => {}, + requestDrain: () => assert.fail('No drain expected'), + }); + t.after(async () => { + releaseOldRead.resolve(); + releaseEvaluation.resolve(); + await coordinator.close(); + await goalStore.close(); + await stores.sessionStore.close?.(); + await owner.close(); + await rm(base, { recursive: true, force: true }); + }); + await coordinator.prepareRecovery(); + const session = await stores.sessionStore.create({ + cwd: capability.canonicalPath, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + }); + const appendUsage = (id: string, total: number) => + stores.sessionStore.appendMessage(session.id, { + type: 'token_usage', + id, + turnId: id, + ts: 1, + input: total, + output: 0, + total, + }); + const settle = (turnId: string) => { + const turn = coordinator.beginObservedTurn(session.id, turnId); + assert.equal(turn.kind, 'registered'); + if (turn.kind !== 'registered') throw new Error('turn not registered'); + return turn.settle({ kind: 'completed', turnId }); + }; + await appendUsage('old-usage', 30); + const old = coordinator.manager.create(session.id, 'Old Goal').goal; + const oldSettlement = settle('old-turn'); + await withTimeout(readStarted.promise, 5_000, 'old context read did not start'); + const cleared = await coordinator.handlers['goal.control']( + { + sessionId: session.id, + goalId: old.id, + expectedRevision: old.revision, + action: 'clear', + }, + operationContext('test'), + ); + assert.equal(cleared.ok, true); + const retirement = await coordinator.beginSessionRetirement([session.id], 'archive'); + const setArchived = async (archived: boolean) => { + const header = await stores.sessionStore.readHeaderRecordSnapshot(session.id); + await stores.sessionStore.setSessionsArchivedVersioned( + [{ sessionId: session.id, expectedVersion: header.revision }], + archived, + ); + }; + await setArchived(true); + retirement.commit(); + await setArchived(false); + coordinator.unarchiveSessions([session.id]); + await appendUsage('new-usage', 90); + coordinator.manager.create(session.id, 'New Goal', { maxIterations: 1, tokenBudget: 50 }); + const newSettlement = settle('new-turn'); + await withTimeout(evaluationStarted.promise, 5_000, 'new Goal did not reach evaluation'); + // Finish the stale read while the new evaluation owns the current token count. + releaseOldRead.resolve(); + await oldSettlement; + await immediate(); + releaseEvaluation.resolve(); + await withTimeout(newSettlement, 5_000, 'new Goal did not settle'); + assert.equal(coordinator.manager.get(session.id)?.tokensAtStart, 120); + assert.equal(coordinator.manager.get(session.id)?.tokensNow, 120); + assert.equal(coordinator.manager.get(session.id)?.status, 'max_iterations'); +}); + test('restart settles the durable current Goal execution through Hosted Execution authority', async () => { const base = await mkdtemp(join(tmpdir(), 'maka-host-goal-recovery-')); const capability = await resolveStorageRoot({ @@ -435,6 +560,7 @@ test('restart settles the durable current Goal execution through Hosted Executio assert.equal(drainRequested, false); } finally { await goalStore.close(); + await stores.sessionStore.close?.(); await owner.close(); await rm(base, { recursive: true, force: true }); } @@ -522,6 +648,7 @@ test('restart replaces a stale current execution with the current durable Goal i await coordinator.close(); } finally { await goalStore.close(); + await stores.sessionStore.close?.(); await owner.close(); await rm(base, { recursive: true, force: true }); } @@ -669,6 +796,7 @@ test('goal.arm creates one Goal per Session and refuses a second while it is unf await coordinator.close(); } finally { await goalStore.close(); + await stores.sessionStore.close?.(); await owner.close(); await rm(base, { recursive: true, force: true }); } @@ -771,6 +899,7 @@ test('a Goal armed but never carried by a Turn does not start itself after a res await restarted.close(); } finally { await goalStore.close(); + await stores.sessionStore.close?.(); await owner.close(); await rm(base, { recursive: true, force: true }); } @@ -917,6 +1046,7 @@ test('resuming an armed Goal drives it, and a restart puts that drive back', asy await restarted.close(); } finally { await goalStore.close(); + await stores.sessionStore.close?.(); await owner.close(); await rm(base, { recursive: true, force: true }); } @@ -998,6 +1128,7 @@ test('an arm admitted before the drain creates no Goal after it', async () => { await host.close(); } finally { await goalStore.close(); + await stores.sessionStore.close?.(); await owner.close(); await rm(base, { recursive: true, force: true }); } diff --git a/packages/runtime-host/src/__tests__/root-admission-owner.test.ts b/packages/runtime-host/src/__tests__/root-admission-owner.test.ts index c513d8b598..0de6c24376 100644 --- a/packages/runtime-host/src/__tests__/root-admission-owner.test.ts +++ b/packages/runtime-host/src/__tests__/root-admission-owner.test.ts @@ -534,9 +534,11 @@ async function withStore( run: (store: ReturnType) => Promise, ): Promise { const root = await mkdtemp(join(tmpdir(), 'maka-root-admission-owner-')); + const store = createSqliteAgentRunStore(root); try { - await run(createSqliteAgentRunStore(root)); + await run(store); } finally { + store.close?.(); await rm(root, { recursive: true, force: true }); } } diff --git a/packages/runtime-host/src/peer-mesh/node.ts b/packages/runtime-host/src/peer-mesh/node.ts index f95cd237e9..f00e7f6a71 100644 --- a/packages/runtime-host/src/peer-mesh/node.ts +++ b/packages/runtime-host/src/peer-mesh/node.ts @@ -25,7 +25,6 @@ import type { RuntimeHostPeerTransitSnapshot, } from '../transport/peer-native.js'; import { createHash } from 'node:crypto'; -import { setTimeout as delay } from 'node:timers/promises'; import { performance } from 'node:perf_hooks'; import { canonicalPeerMeshMemberAdvertisement, @@ -1166,16 +1165,20 @@ class PeerMeshNodeImpl implements PeerMeshNode { observedGeneration: number, signal: AbortSignal, ): Promise { - if (this.#reconcileGeneration !== observedGeneration) return; + if (this.#reconcileGeneration !== observedGeneration || signal.aborted) return; let wake!: () => void; const triggered = new Promise((resolve) => { wake = resolve; this.#reconcileWaiters.add(wake); }); - if (this.#reconcileGeneration !== observedGeneration) wake(); + const timeout = setTimeout(wake, RECONCILE_INTERVAL_MS); + signal.addEventListener('abort', wake, { once: true }); + if (this.#reconcileGeneration !== observedGeneration || signal.aborted) wake(); try { - await Promise.race([triggered, delay(RECONCILE_INTERVAL_MS, undefined, { signal })]); + await triggered; } finally { + clearTimeout(timeout); + signal.removeEventListener('abort', wake); this.#reconcileWaiters.delete(wake); } } diff --git a/packages/runtime-host/src/protocol/artifact.ts b/packages/runtime-host/src/protocol/artifact.ts index 0c78f530ee..08e45820c0 100644 --- a/packages/runtime-host/src/protocol/artifact.ts +++ b/packages/runtime-host/src/protocol/artifact.ts @@ -511,7 +511,7 @@ export function decodeArtifactQueryResult(value: unknown): ArtifactQueryResult { if (!isCanonicalBase64(chunkBase64)) { throw invalidProtocolFrame('Invalid artifact chunk'); } - const chunkBytes = Buffer.from(chunkBase64, 'base64').byteLength; + const chunkBytes = Buffer.byteLength(chunkBase64, 'base64'); if (chunkBytes > ARTIFACT_READ_CHUNK_MAX_BYTES || offset + chunkBytes > totalBytes) { throw invalidProtocolFrame('Invalid artifact chunk bounds'); } @@ -640,7 +640,7 @@ function decodeBinaryPreview(value: unknown): ArtifactBinaryPreview { const base64 = boundedText(exact.base64, 'artifact binary base64', base64MaxBytes(), true); if ( !isCanonicalBase64(base64) || - Buffer.from(base64, 'base64').byteLength > ARTIFACT_PREVIEW_MAX_BYTES + Buffer.byteLength(base64, 'base64') > ARTIFACT_PREVIEW_MAX_BYTES ) { throw invalidProtocolFrame('Invalid artifact binary base64'); } diff --git a/packages/runtime-host/src/protocol/session-transcript.ts b/packages/runtime-host/src/protocol/session-transcript.ts index a1e6285c43..8cdfc8ebd7 100644 --- a/packages/runtime-host/src/protocol/session-transcript.ts +++ b/packages/runtime-host/src/protocol/session-transcript.ts @@ -268,10 +268,8 @@ export function decodeSessionTranscriptPage(value: unknown): SessionTranscriptPa const rawBytes = requireCount(result.rawBytes, 'Session transcript page bytes'); if ( rawBytes > SESSION_TRANSCRIPT_PAGE_MAX_BYTES || - fragments.reduce( - (total, fragment) => total + Buffer.from(fragment.data, 'base64').byteLength, - 0, - ) !== rawBytes + fragments.reduce((total, fragment) => total + Buffer.byteLength(fragment.data, 'base64'), 0) !== + rawBytes ) { throw invalidProtocolFrame('Invalid Session transcript page byte count'); } @@ -360,7 +358,7 @@ function decodeSessionTranscriptFragment( const byteOffset = requireCount(exact.byteOffset, 'Session transcript fragment byte offset'); const totalBytes = requireCount(exact.totalBytes, 'Session transcript fragment total bytes'); const data = requireBase64Fragment(exact.data); - const dataBytes = Buffer.from(data, 'base64').byteLength; + const dataBytes = Buffer.byteLength(data, 'base64'); if ( totalBytes === 0 || dataBytes === 0 || diff --git a/packages/runtime-host/src/server/artifact-coordinator.ts b/packages/runtime-host/src/server/artifact-coordinator.ts index 9b507ba550..12a8beab78 100644 --- a/packages/runtime-host/src/server/artifact-coordinator.ts +++ b/packages/runtime-host/src/server/artifact-coordinator.ts @@ -146,6 +146,8 @@ export class HostArtifactCoordinator { ): Promise> { try { if ((await this.#sessions.probeSessionRemoval(input.sessionId)).kind !== 'present') { + // Session removal can race an upload; release only this owner's staged bytes. + this.#uploads.abort(uploadKey(input.sessionId, input.uploadId), context); return ingestFailure('not_found', 'Session was not found'); } switch (input.kind) { diff --git a/packages/runtime-host/src/server/goal-coordinator.ts b/packages/runtime-host/src/server/goal-coordinator.ts index 65efa58b4b..28e63a6214 100644 --- a/packages/runtime-host/src/server/goal-coordinator.ts +++ b/packages/runtime-host/src/server/goal-coordinator.ts @@ -160,8 +160,11 @@ export class HostGoalCoordinator { acquireActivity: () => this.#acquireResidency(), evaluator: options.evaluator, getRecentContext: async (sessionId) => { + const controlLease = this.manager.getControlLease(sessionId); const messages = await options.readSessionMessages(sessionId); - tokenCache.set(sessionId, tokenCount(messages)); + if (controlLease && this.manager.matchesControlLease(sessionId, controlLease)) { + tokenCache.set(sessionId, tokenCount(messages)); + } return recentContext(messages); }, getTokenCount: (sessionId) => tokenCache.get(sessionId) ?? 0, @@ -298,6 +301,7 @@ export class HostGoalCoordinator { for (const sessionId of unique) { operations.get(sessionId)?.commit(); this.#authorityBySession.delete(sessionId); + this.#tokenCache.delete(sessionId); if (this.manager.remove(sessionId)) this.#onProjectionChanged(sessionId); } }, @@ -366,6 +370,7 @@ export class HostGoalCoordinator { this.#recoveryAbort.abort(); this.continuation.dispose(); this.manager.dispose(); + this.#tokenCache.clear(); for (const residency of this.#residencies.values()) residency.release(); this.#residencies.clear(); } diff --git a/packages/runtime-host/src/server/interactive-run-composer.ts b/packages/runtime-host/src/server/interactive-run-composer.ts index add44e004a..92bb45f797 100644 --- a/packages/runtime-host/src/server/interactive-run-composer.ts +++ b/packages/runtime-host/src/server/interactive-run-composer.ts @@ -17,6 +17,7 @@ * under the License. */ +import { isDeepStrictEqual } from 'node:util'; import { buildSideConversationSystemPromptFragment, isSideConversationSession, @@ -210,6 +211,9 @@ export function createInteractiveRunComposer(input: InteractiveRunComposerInput) const childInstruction = input.childInstruction?.trim(); const runProfile = hostedExecutionRunProfile(input.toolProfile); const resolvedBaseSystemPrompts = new Map>(); + let latestCompletedPromptText: + | { readonly key: string; readonly text: string | undefined } + | undefined; const resolveBaseSystemPrompt = (context: HostModelPromptContext): Promise => { if (runProfile) { return ( @@ -261,8 +265,14 @@ export function createInteractiveRunComposer(input: InteractiveRunComposerInput) input.deepResearch ? buildDeepResearchSystemPromptFragment() : undefined, input.sideConversation ? buildSideConversationSystemPromptFragment() : undefined, ]); - return Object.freeze({ - text, + // Keep each turn's source revisions independent while sharing identical + // immutable text already retained by the turn cache. + const sharedText = + latestCompletedPromptText !== undefined && latestCompletedPromptText.text === text + ? latestCompletedPromptText.text + : text; + const resolvedPrompt = Object.freeze({ + text: sharedText, sourceRevisions: interactiveSourceRevisions({ runtimePolicyRevision: promptState.runtimePolicyRevision, memoryBundleRevision: promptState.memoryBundleRevision, @@ -270,6 +280,10 @@ export function createInteractiveRunComposer(input: InteractiveRunComposerInput) skillCatalogRevision: inventory.revision, }), }); + if (resolvedBaseSystemPrompts.get(key) === pending) { + latestCompletedPromptText = { key, text: sharedText }; + } + return resolvedPrompt; }) .catch((error: unknown) => { if (resolvedBaseSystemPrompts.get(key) === pending) resolvedBaseSystemPrompts.delete(key); @@ -278,7 +292,10 @@ export function createInteractiveRunComposer(input: InteractiveRunComposerInput) resolvedBaseSystemPrompts.set(key, pending); if (resolvedBaseSystemPrompts.size > 100) { const oldest = resolvedBaseSystemPrompts.keys().next().value; - if (typeof oldest === 'string' && oldest !== key) resolvedBaseSystemPrompts.delete(oldest); + if (typeof oldest === 'string' && oldest !== key) { + resolvedBaseSystemPrompts.delete(oldest); + if (latestCompletedPromptText?.key === oldest) latestCompletedPromptText = undefined; + } } return pending; }; @@ -629,6 +646,7 @@ function createTurnSkillInventorySnapshotResolver( context: Pick, ) => Promise { const inventoryByTurn = new Map>(); + let latestCompleted: { key: string; snapshot: CanonicalSkillInventorySnapshot } | undefined; return async (context) => { const key = `${context.sessionId}\u0000${context.turnId}`; const cached = inventoryByTurn.get(key); @@ -668,11 +686,27 @@ function createTurnSkillInventorySnapshotResolver( .digest('hex') as typeof base.revision, inventory: Object.freeze([...additions, ...base.inventory]), }); + }) + .then((snapshot) => { + // An evicted late read still resolves its caller without acquiring another owner. + if (inventoryByTurn.get(key) !== pending) return snapshot; + // Revisions omit some raw paths and ordering, so sharing requires full equality. + const shared = + latestCompleted !== undefined && + latestCompleted.snapshot.revision === snapshot.revision && + isDeepStrictEqual(latestCompleted.snapshot, snapshot) + ? latestCompleted.snapshot + : snapshot; + latestCompleted = { key, snapshot: shared }; + return shared; }); inventoryByTurn.set(key, pending); if (inventoryByTurn.size > 100) { const oldest = inventoryByTurn.keys().next().value; - if (typeof oldest === 'string' && oldest !== key) inventoryByTurn.delete(oldest); + if (typeof oldest === 'string' && oldest !== key) { + inventoryByTurn.delete(oldest); + if (latestCompleted?.key === oldest) latestCompleted = undefined; + } } try { return await pending; diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index 8222435817..40c4f44ab3 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -17,7 +17,7 @@ * under the License. */ -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; import type { SteeringLease } from '@maka/core/backend-types'; import { @@ -311,7 +311,7 @@ interface PendingQueuedMutation { } interface CompletedOperation { - readonly payload: object; + readonly payloadIdentity: object; readonly result: object; } @@ -1222,7 +1222,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { if (isCurrentEpoch) { const receipt = await this.#readCompletedSubmit(input.sessionId, input.messageId); if (receipt) { - return samePayload(receipt.payload, payload) + return samePayload(receipt.payloadIdentity, completedPayloadIdentity('submit', payload)) ? success(receipt.result) : failure('operation_conflict', 'Message identity has a different payload'); } @@ -1705,7 +1705,10 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { } const receipt = await this.#readCompletedQueuedMutation(options); if (receipt) { - return samePayload(receipt.payload, options.input) + return samePayload( + receipt.payloadIdentity, + completedPayloadIdentity(options.operationKind, options.input), + ) ? success(receipt.result) : failure('operation_conflict', `${options.verb} identity has a different payload`); } @@ -1718,14 +1721,14 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { R, >( options: QueuedMutationOptions, - ): Promise<{ readonly payload: I; readonly result: R } | undefined> { + ): Promise<{ readonly payloadIdentity: object; readonly result: R } | undefined> { const receipt = this.#completedOperations.get( queuedMutationKey(options.operationKind, options.input.sessionId, options.operationId), ); if (!receipt) return undefined; try { return { - payload: options.spec.decodeInput(receipt.payload), + payloadIdentity: receipt.payloadIdentity, result: options.spec.decodeOutput(receipt.result), }; } catch (error) { @@ -2031,7 +2034,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { } const completed = await this.#readCompletedInterrupt(input.sessionId, input.interruptId); if (completed) { - return samePayload(completed.payload, input) + return samePayload(completed.payloadIdentity, input) ? completed.result : failure('operation_conflict', 'Interrupt identity has a different payload'); } @@ -2247,16 +2250,14 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { async #readCompletedSubmit( sessionId: string, messageId: string, - ): Promise<{ payload: CanonicalSubmitPayload; result: TurnMessageSubmitResult } | undefined> { + ): Promise<{ payloadIdentity: object; result: TurnMessageSubmitResult } | undefined> { const receipt = this.#completedOperations.get( queuedMutationKey('submit', sessionId, messageId), ); if (!receipt) return undefined; try { return { - payload: canonicalSubmitPayload( - MESSAGE_OPERATION_SPECS['turn.message.submit'].decodeInput(receipt.payload), - ), + payloadIdentity: receipt.payloadIdentity, result: MESSAGE_OPERATION_SPECS['turn.message.submit'].decodeOutput(receipt.result), }; } catch (error) { @@ -2269,16 +2270,14 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { async #readCompletedInterrupt( sessionId: string, interruptId: string, - ): Promise< - { payload: TurnInterruptInput; result: MessageOutcome } | undefined - > { + ): Promise<{ payloadIdentity: object; result: MessageOutcome } | undefined> { const receipt = this.#completedOperations.get( queuedMutationKey('interrupt', sessionId, interruptId), ); if (!receipt) return undefined; try { return { - payload: MESSAGE_OPERATION_SPECS['turn.interrupt'].decodeInput(receipt.payload), + payloadIdentity: receipt.payloadIdentity, result: decodeCompletedInterruptOutcome(receipt.result), }; } catch (error) { @@ -2296,7 +2295,10 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { result: object, ): void { const key = queuedMutationKey(operation, sessionId, operationId); - const receipt = { payload: structuredClone(payload), result: structuredClone(result) }; + const receipt = { + payloadIdentity: structuredClone(completedPayloadIdentity(operation, payload)), + result: structuredClone(result), + }; const committed = this.#completedOperations.get(key); if (committed && !isDeepStrictEqual(committed, receipt)) { throw new RuntimeMessageAuthorityInvariantError( @@ -2868,6 +2870,20 @@ interface CanonicalSubmitPayload { readonly turnOrchestration?: TurnOrchestration; } +// Epoch-long replay needs the original result and request identity, not historical message bodies. +function completedPayloadIdentity(operation: MessageOperationKind, payload: object): object { + if (operation === 'submit') { + const { content, ...identity } = payload as CanonicalSubmitPayload; + return { ...identity, contentDigest: messageContentDigest(content) }; + } + if (operation === 'update_entry') { + const { text, ...identity } = payload as QueueEntryUpdateInput; + // UTF-16 preserves distinct JS strings even when they contain unpaired surrogates. + return { ...identity, textDigest: createHash('sha256').update(text, 'utf16le').digest('hex') }; + } + return payload; +} + function canonicalSubmitPayload(input: TurnMessageSubmitInput): CanonicalSubmitPayload { return { originHostEpoch: input.originHostEpoch, diff --git a/packages/runtime-host/src/server/root-admission-owner.ts b/packages/runtime-host/src/server/root-admission-owner.ts index 7cda7d7c35..873ec2d625 100644 --- a/packages/runtime-host/src/server/root-admission-owner.ts +++ b/packages/runtime-host/src/server/root-admission-owner.ts @@ -19,7 +19,7 @@ import { isDeepStrictEqual } from 'node:util'; import { - messageContentsEqual, + messageContentDigest, normalizeMessageContent, type MessageContent, } from '@maka/core/events'; @@ -43,8 +43,15 @@ type Immutable = T extends (...args: never[]) => unknown export type ValidatedRootTurnAdmission = Immutable; +type AdmissionIdentity = Omit & { + normalizedInputDigest: string | null; + sourceMessages: readonly (Omit & { + contentDigest: string; + })[]; +}; + export class RootAdmissionOwner { - readonly #admissionsBySession = new Map>(); + readonly #admissionsBySession = new Map>(); readonly #tips = new Map(); readonly #poisonedSessions = new Set(); @@ -67,8 +74,8 @@ export class RootAdmissionOwner { } const admissions = await this.store.listRootTurnAdmissionsForRecovery(sessionId); const snapshots = Object.freeze(admissions.map(snapshotAdmission)); - const byTurnId = new Map(); - for (const admission of snapshots) byTurnId.set(admission.turnId, admission); + const byTurnId = new Map(); + for (const admission of snapshots) byTurnId.set(admission.turnId, admissionIdentity(admission)); this.#admissionsBySession.set(sessionId, byTurnId); const tip = snapshots.at(-1); if (tip) this.#tips.set(sessionId, tip); @@ -91,7 +98,7 @@ export class RootAdmissionOwner { if (!known || !sameRootAdmission(known, admission)) { throw new Error('Durable Root Turn conflict is outside the owned chain'); } - return Object.freeze({ kind: 'conflict', admission: known }); + return Object.freeze({ kind: 'conflict', admission: snapshotAdmission(admission) }); } if ( admission.sessionId !== input.sessionId || @@ -107,7 +114,7 @@ export class RootAdmissionOwner { throw new Error('Root Turn admission identity changed within one Host Epoch'); } const snapshot = snapshotAdmission(admission); - byTurnId.set(admission.turnId, snapshot); + byTurnId.set(admission.turnId, admissionIdentity(snapshot)); this.#admissionsBySession.set(input.sessionId, byTurnId); this.#tips.set(input.sessionId, snapshot); return Object.freeze({ ...result, admission: snapshot }); @@ -118,7 +125,22 @@ export class RootAdmissionOwner { } } -function sameRootAdmission(left: RootTurnAdmission, right: RootTurnAdmission): boolean { +// Historical admissions must remain verifiable throughout the Host Epoch, but +// only the current tip needs to own the full message bodies. +function admissionIdentity(admission: RootTurnAdmission): AdmissionIdentity { + const { normalizedInput, sourceMessages, ...metadata } = admission; + return Object.freeze({ + ...structuredClone(metadata), + normalizedInputDigest: normalizedInput === null ? null : messageContentDigest(normalizedInput), + sourceMessages: Object.freeze( + sourceMessages.map(({ content, ...source }) => + Object.freeze({ ...structuredClone(source), contentDigest: messageContentDigest(content) }), + ), + ), + }); +} + +function sameRootAdmission(left: AdmissionIdentity, right: RootTurnAdmission): boolean { return ( left.schemaVersion === right.schemaVersion && left.sessionId === right.sessionId && @@ -130,9 +152,8 @@ function sameRootAdmission(left: RootTurnAdmission, right: RootTurnAdmission): b isDeepStrictEqual(left.skillInvocation, right.skillInvocation) && isDeepStrictEqual(left.authorization, right.authorization) && left.previousRootTurnId === right.previousRootTurnId && - (left.normalizedInput === null || right.normalizedInput === null - ? left.normalizedInput === right.normalizedInput - : messageContentsEqual(left.normalizedInput, right.normalizedInput)) && + left.normalizedInputDigest === + (right.normalizedInput === null ? null : messageContentDigest(right.normalizedInput)) && left.sourceMessages.length === right.sourceMessages.length && left.sourceMessages.every((source, index) => { const other = right.sourceMessages[index]; @@ -146,7 +167,7 @@ function sameRootAdmission(left: RootTurnAdmission, right: RootTurnAdmission): b (other.submittedPlacement ?? other.placement) && submittedTurnIntentsEqual(source.submittedIntent, other.submittedIntent) && isDeepStrictEqual(source.skillInvocation, other.skillInvocation) && - messageContentsEqual(source.content, other.content) + source.contentDigest === messageContentDigest(other.content) ); }) && left.admittedAt === right.admittedAt diff --git a/packages/runtime-host/src/server/runtime-resource-projection.ts b/packages/runtime-host/src/server/runtime-resource-projection.ts index bf823ee0fc..770ea61c3d 100644 --- a/packages/runtime-host/src/server/runtime-resource-projection.ts +++ b/packages/runtime-host/src/server/runtime-resource-projection.ts @@ -46,10 +46,9 @@ export function canonicalRuntimeResources(resources: readonly ShellRunUpdate[]): } function boundedRuntimeResourceUpdate(update: ShellRunUpdate): ShellRunUpdate { - return { - ...structuredClone(update), - result: boundedState(update.result), - }; + const bounded = structuredClone(update); + shrinkStateToFit(bounded.result); + return bounded; } export function runtimeResourceRevision( @@ -115,12 +114,6 @@ export function runtimeResourceSnapshotFromResult( return snapshot; } -function boundedState(state: ShellRunStateResult): ShellRunStateResult { - const bounded = structuredClone(state); - shrinkStateToFit(bounded); - return bounded; -} - function shrinkStateToFit(state: ShellRunStateResult): void { while (Buffer.byteLength(JSON.stringify(state), 'utf8') > RUNTIME_RESOURCE_SNAPSHOT_MAX_BYTES) { const fields = mutableTextFields(state).sort( diff --git a/packages/runtime-host/src/server/session-continuity-coordinator.ts b/packages/runtime-host/src/server/session-continuity-coordinator.ts index c8874c74ca..30be74bd72 100644 --- a/packages/runtime-host/src/server/session-continuity-coordinator.ts +++ b/packages/runtime-host/src/server/session-continuity-coordinator.ts @@ -147,8 +147,7 @@ interface ConnectionState { sink: SessionContinuityFrameSink; subscriptionIds: Set; pendingOpenCount: number; - readonly closed: Promise; - resolveClosed(): void; + readonly closed: AbortController; } interface QueuedSubscriptionFrame { @@ -354,13 +353,11 @@ export class SessionContinuityCoordinator implements SessionContinuityService { if (this.#connections.has(connectionId)) { throw new Error(`Duplicate Runtime Host connection: ${connectionId}`); } - const closed = signal(); this.#connections.set(connectionId, { sink, subscriptionIds: new Set(), pendingOpenCount: 0, - closed: closed.promise, - resolveClosed: closed.resolve, + closed: new AbortController(), }); let attached = true; return { @@ -952,12 +949,10 @@ export class SessionContinuityCoordinator implements SessionContinuityService { preparationPermit, ); cachedTranscriptOverlay.pendingConsumers += 1; - retainedTranscriptOverlay = await Promise.race([ + retainedTranscriptOverlay = await waitForConnectionOpen( cachedTranscriptOverlay.prepared, - connection.closed.then(() => { - throw new Error('Runtime Host connection closed during subscription open'); - }), - ]); + connection.closed.signal, + ); const snapshot = projectSessionSnapshot(committed.value, identity.principalKind); const created = await createSessionTranscriptBootstrap({ reader: this.#transcriptReader, @@ -1242,12 +1237,7 @@ export class SessionContinuityCoordinator implements SessionContinuityService { const ticket = this.#queueTranscriptOverlayPreparation(); let release: () => void; try { - release = await Promise.race([ - ticket.ready, - connection.closed.then(() => { - throw new Error('Runtime Host connection closed during subscription open'); - }), - ]); + release = await waitForConnectionOpen(ticket.ready, connection.closed.signal); } catch (error) { this.#cancelTranscriptOverlayPreparation(ticket.waiter); throw error; @@ -1472,7 +1462,7 @@ export class SessionContinuityCoordinator implements SessionContinuityService { #closeConnection(connectionId: string): void { const connection = this.#connections.get(connectionId); if (!connection) return; - connection.resolveClosed(); + connection.closed.abort(new Error('Runtime Host connection closed during subscription open')); for (const subscriptionId of [...connection.subscriptionIds]) { const subscriber = this.#ownedSubscriber(connectionId, subscriptionId); if (subscriber) this.#removeSubscriber(subscriber); @@ -2271,10 +2261,22 @@ function toolStartShellRunRef( } } -function signal(): { readonly promise: Promise; resolve(): void } { - let resolve!: () => void; - const promise = new Promise((settle) => { - resolve = settle; +function waitForConnectionOpen(task: Promise, closed: AbortSignal): Promise { + // A race against a connection-lifetime Promise retains every winning overlay + // until disconnect. Remove the close listener as soon as this wait finishes. + return new Promise((resolve, reject) => { + const onClose = () => reject(closed.reason); + if (closed.aborted) onClose(); + else closed.addEventListener('abort', onClose, { once: true }); + void task.then( + (value) => { + closed.removeEventListener('abort', onClose); + resolve(value); + }, + (error: unknown) => { + closed.removeEventListener('abort', onClose); + reject(error); + }, + ); }); - return { promise, resolve }; } diff --git a/packages/runtime-host/src/server/session-transcript-pager.ts b/packages/runtime-host/src/server/session-transcript-pager.ts index 0d29e12b88..dfaf8f29e9 100644 --- a/packages/runtime-host/src/server/session-transcript-pager.ts +++ b/packages/runtime-host/src/server/session-transcript-pager.ts @@ -432,7 +432,7 @@ async function readRangeEdges(input: { return { fragments, rawBytes: fragments.reduce( - (sum, fragment) => sum + Buffer.from(fragment.data, 'base64').byteLength, + (sum, fragment) => sum + Buffer.byteLength(fragment.data, 'base64'), 0, ), next: { position: rangeRecords[retainedEnd]!.sequence, byteOffset: null }, diff --git a/packages/runtime-host/src/transport/framed-byte-stream-transport.ts b/packages/runtime-host/src/transport/framed-byte-stream-transport.ts index a8de9cff58..376493b14d 100644 --- a/packages/runtime-host/src/transport/framed-byte-stream-transport.ts +++ b/packages/runtime-host/src/transport/framed-byte-stream-transport.ts @@ -184,7 +184,10 @@ export class FramedByteStreamTransport implements RuntimeHostMessageTransport { break; } const encoded = this.#buffered.subarray(0, encodedBytes); - this.#buffered = this.#buffered.subarray(encodedBytes); + this.#buffered = + encodedBytes === this.#buffered.byteLength + ? Buffer.alloc(0) + : this.#buffered.subarray(encodedBytes); const frames = this.#decoder.push(encoded); if (frames.length !== 1) { throw new Error('Runtime Host decoder did not produce one complete frame'); diff --git a/packages/runtime-host/src/transport/local-ipc-framing.ts b/packages/runtime-host/src/transport/local-ipc-framing.ts index af4b5d54f2..54317eb8a4 100644 --- a/packages/runtime-host/src/transport/local-ipc-framing.ts +++ b/packages/runtime-host/src/transport/local-ipc-framing.ts @@ -42,7 +42,10 @@ export class LocalIpcProtocolFrameDecoder { 'Runtime Host message exceeds the byte limit', ); } - if (segment.byteLength > 0) this.#pending = Buffer.concat([this.#pending, segment]); + if (segment.byteLength > 0) { + this.#pending = + this.#pending.byteLength === 0 ? segment : Buffer.concat([this.#pending, segment]); + } if (newline === -1) break; frames.push(this.#decodePending()); this.#pending = Buffer.alloc(0); diff --git a/packages/runtime-host/src/transport/peer-native.ts b/packages/runtime-host/src/transport/peer-native.ts index e8313d104b..3174ad99dd 100644 --- a/packages/runtime-host/src/transport/peer-native.ts +++ b/packages/runtime-host/src/transport/peer-native.ts @@ -410,7 +410,8 @@ async function readBoundedJsonLine( const encoded = buffered.subarray(0, newline); return { value: JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(encoded)) as unknown, - remainder: buffered.subarray(newline + 1), + remainder: + newline + 1 === buffered.length ? Buffer.alloc(0) : buffered.subarray(newline + 1), }; } if (buffered.byteLength >= maxBytes) { @@ -430,7 +431,6 @@ export class RuntimeHostPeerByteStream implements RuntimeHostByteStream { readonly #endListeners = new Set<() => void>(); readonly #errorListeners = new Set<(error: Error) => void>(); readonly #stream: RuntimeHostPeerNativeStream; - readonly #initialData: Buffer; #resolveClosed!: () => void; #resume: (() => void) | undefined; #paused = false; @@ -438,11 +438,10 @@ export class RuntimeHostPeerByteStream implements RuntimeHostByteStream { constructor(stream: RuntimeHostPeerNativeStream, initialData: Buffer = Buffer.alloc(0)) { this.#stream = stream; - this.#initialData = initialData; this.closed = new Promise((resolve) => { this.#resolveClosed = resolve; }); - queueMicrotask(() => void this.#pump()); + queueMicrotask(() => void this.#pump(initialData)); } onData(listener: (chunk: Buffer) => void): void { @@ -484,9 +483,10 @@ export class RuntimeHostPeerByteStream implements RuntimeHostByteStream { this.#resume = undefined; } - async #pump(): Promise { + async #pump(initialData: Buffer): Promise { try { - if (this.#initialData.byteLength > 0) this.#emitData(this.#initialData); + if (initialData.byteLength > 0) this.#emitData(initialData); + initialData = Buffer.alloc(0); while (!this.#closed) { if (this.#paused) { await new Promise((resolve) => { @@ -494,12 +494,14 @@ export class RuntimeHostPeerByteStream implements RuntimeHostByteStream { }); if (this.#closed) return; } - const chunk = await this.#stream.read(); + let chunk = await this.#stream.read(); if (!chunk) { for (const listener of this.#endListeners) listener(); return; } this.#emitData(chunk); + // Listeners own any data they retain; the read loop no longer needs it. + chunk = null; } } catch (error) { this.#emitError(normalizePeerError(error)); diff --git a/packages/runtime-host/src/transport/resumable-peer-stream.ts b/packages/runtime-host/src/transport/resumable-peer-stream.ts index f1221f9690..9e5adc0622 100644 --- a/packages/runtime-host/src/transport/resumable-peer-stream.ts +++ b/packages/runtime-host/src/transport/resumable-peer-stream.ts @@ -51,7 +51,7 @@ export type ReconnectPeerStream = ( upgrade: boolean, ) => Promise; -interface Path extends PeerStreamAttachment { +interface Path extends Omit { sent: number; acknowledged: number; pong: number | undefined; @@ -152,7 +152,8 @@ export class ResumablePeerStream implements RuntimeHostPeerNativeStream { } const old = this.#path; const path: Path = { - ...attachment, + stream: attachment.stream, + received: attachment.received, sent: this.#acknowledged, acknowledged: -1, pong: undefined, @@ -164,7 +165,7 @@ export class ResumablePeerStream implements RuntimeHostPeerNativeStream { this.#recoveryStarted = undefined; old?.stream.abort(); this.#notify(); - void this.#pumpRead(path).catch((error) => this.#pathFailed(path, error)); + void this.#pumpRead(path, attachment.remainder).catch((error) => this.#pathFailed(path, error)); void this.#pumpWrite(path).catch((error) => this.#pathFailed(path, error)); } @@ -267,14 +268,15 @@ export class ResumablePeerStream implements RuntimeHostPeerNativeStream { this.#notify(); } - async #pumpRead(path: Path): Promise { - let buffered = path.remainder; + async #pumpRead(path: Path, buffered: Buffer): Promise { + // Suspended reads retain locals: drop consumed chunks and backing-buffer views before awaiting. while (this.#path === path && !this.#ended) { if (buffered.length < HEADER_BYTES) { - const chunk = await path.stream.read(); + let chunk = await path.stream.read(); if (!chunk) throw new Error('Peer path ended'); if (this.#path !== path) return; buffered = Buffer.concat([buffered, chunk]); + chunk = null; continue; } const type = buffered[0]; @@ -288,15 +290,14 @@ export class ResumablePeerStream implements RuntimeHostPeerNativeStream { throw new PeerResumeRejectedError('Invalid peer stream frame'); } if (buffered.length < HEADER_BYTES + size) { - const chunk = await path.stream.read(); + let chunk = await path.stream.read(); if (!chunk) throw new Error('Peer path ended inside frame'); if (this.#path !== path) return; buffered = Buffer.concat([buffered, chunk]); + chunk = null; continue; } const offset = Number(wideOffset); - const bytes = buffered.subarray(HEADER_BYTES, HEADER_BYTES + size); - buffered = buffered.subarray(HEADER_BYTES + size); switch (type) { case DATA: if (size === 0 || this.#remoteFin) @@ -304,7 +305,7 @@ export class ResumablePeerStream implements RuntimeHostPeerNativeStream { if (offset + size <= this.#received) break; // Lost ACK: already retained/delivered. if (offset !== this.#received || this.#received - this.#consumed + size > WINDOW_BYTES) throw new PeerResumeRejectedError('Peer stream receive window violated'); - this.#incoming.push(Buffer.from(bytes)); + this.#incoming.push(Buffer.from(buffered.subarray(HEADER_BYTES, HEADER_BYTES + size))); this.#received += size; break; case ACK: @@ -329,6 +330,10 @@ export class ResumablePeerStream implements RuntimeHostPeerNativeStream { default: throw new PeerResumeRejectedError('Unknown peer stream frame'); } + buffered = + buffered.length === HEADER_BYTES + size + ? Buffer.alloc(0) + : buffered.subarray(HEADER_BYTES + size); this.#notify(); } } diff --git a/packages/runtime/src/__tests__/request-customization-fetch.test.ts b/packages/runtime/src/__tests__/request-customization-fetch.test.ts index 5dda1aa769..0948cd84d5 100644 --- a/packages/runtime/src/__tests__/request-customization-fetch.test.ts +++ b/packages/runtime/src/__tests__/request-customization-fetch.test.ts @@ -18,10 +18,92 @@ */ import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; import { describe, test } from 'node:test'; import { createRequestCustomizationFetch } from '../request-customization-fetch.js'; describe('createRequestCustomizationFetch', () => { + test('preserves caller cancellation, including signal override and explicit null', async () => { + for (const selection of ['inherited', 'override', 'null'] as const) { + const original = new AbortController(); + const override = new AbortController(); + const request = new Request('https://example.test/', { signal: original.signal }); + let forwarded: AbortSignal | null | undefined; + const fetch = createRequestCustomizationFetch( + async (_input, init) => { + forwarded = init?.signal; + return new Response(); + }, + { headers: { 'x-test': 'abort' } }, + ); + await fetch( + request, + selection === 'inherited' + ? undefined + : { + signal: selection === 'override' ? override.signal : null, + }, + ); + original.abort(new Error('original abort')); + if (selection === 'null') assert.equal(forwarded, null); + else if (selection === 'inherited') assert.equal(forwarded?.reason, original.signal.reason); + else { + assert.equal(forwarded?.aborted, false); + override.abort(new Error('override abort')); + assert.equal(forwarded?.reason, override.signal.reason); + } + } + }); + + test('native response still aborts after temporary customization Requests are collected', () => { + const child = spawnSync( + process.execPath, + [ + '--expose-gc', + '--input-type=module', + '--eval', + ` + import assert from 'node:assert/strict'; + import { createServer } from 'node:http'; + import { setImmediate } from 'node:timers/promises'; + import { createRequestCustomizationFetch } from ${JSON.stringify(new URL('../request-customization-fetch.js', import.meta.url).href)}; + const server = createServer(async (req, res) => { + for await (const chunk of req) {} + res.writeHead(200); + res.write('first'); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + try { + const controller = new AbortController(); + const fetch = createRequestCustomizationFetch(globalThis.fetch, { bodyOverlay: { extra: true } }); + const response = await fetch('http://127.0.0.1:' + server.address().port, { + method: 'POST', body: '{}', signal: controller.signal, + }); + const reader = response.body.getReader(); + assert.equal((await reader.read()).done, false); + // Collection exposes native Request's weak abort forwarding; no heap-size assertion. + for (let i = 0; i < 8; i++) { await setImmediate(); global.gc(); } + controller.abort(); + let timer; + try { + const result = await Promise.race([ + reader.read().then(() => 'resolved', error => error.name), + new Promise(resolve => { timer = setTimeout(() => resolve('timeout'), 2000); }), + ]); + assert.equal(result, 'AbortError'); + } finally { clearTimeout(timer); } + } finally { + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); + } + `, + ], + { encoding: 'utf8', timeout: 10_000 }, + ); + assert.ifError(child.error); + assert.equal(child.status, 0, child.stderr || child.stdout); + }); + test('applies generic headers and extra fields to a JSON POST request', async () => { const requests: Request[] = []; const fetch = createRequestCustomizationFetch( diff --git a/packages/runtime/src/__tests__/shell-run-tool-result.test.ts b/packages/runtime/src/__tests__/shell-run-tool-result.test.ts index 8f01f16def..ff6cfe7041 100644 --- a/packages/runtime/src/__tests__/shell-run-tool-result.test.ts +++ b/packages/runtime/src/__tests__/shell-run-tool-result.test.ts @@ -131,8 +131,8 @@ describe('shell run sandbox denial projection', () => { test('round-trips the producer sandbox denial through strict FileSessionStore recovery', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-shell-result-recovery-')); + const store = createSessionStore(root); try { - const store = createSessionStore(root); const session = await store.create({ cwd: '/workspace', llmConnectionSlug: 'fake', @@ -158,6 +158,7 @@ describe('shell run sandbox denial projection', () => { const result = messages.find((message) => message.id === 'tool-result-1'); assert.deepEqual(result?.type === 'tool_result' ? result.content : undefined, content); } finally { + await store.close?.(); await rm(root, { recursive: true, force: true }); } }); diff --git a/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts b/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts index e35b0e2d65..e6fe52335f 100644 --- a/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts +++ b/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts @@ -24,6 +24,7 @@ import { join } from 'node:path'; import { describe, test } from 'node:test'; import { randomUUID } from 'node:crypto'; import { z } from 'zod'; +import { deferred, withTimeout } from '@maka/core/test-only/async-primitives'; import { AGENT_GRAPH_INTENT_CLAIM_SCHEMA_VERSION, type AgentGraphIntentClaim, @@ -975,13 +976,16 @@ describe('host-managed agent graph coordinator', () => { } }); - test('advances only a finished and quiescent graph to a deterministic next epoch', async () => { + test('advances only a finished and quiescent graph without waiting for old operator cleanup', async (t) => { const root = await mkdtemp(join(tmpdir(), 'maka-graph-epoch-cutover-')); const controlStore = createSqliteSessionMetadataStore( join(root, OPERATIONAL_STATE_DATABASE_NAME), ); const rootSessionId = 'root-session'; const graphId = agentGraphIdForRootSession(rootSessionId); + const stopStarted = deferred(); + const releaseStop = deferred(); + let stopping: Promise | undefined; const coordinator = new AgentGraphCoordinator({ sessionStore: { listForRecovery: async () => [], @@ -1006,7 +1010,10 @@ describe('host-managed agent graph coordinator', () => { runClaimedAgentGraphIntent: async () => { throw new Error('epoch cutover cannot dispatch operators'); }, - stopSession: async () => {}, + stopSession: async () => { + stopStarted.resolve(); + await releaseStop.promise; + }, }, newId: randomUUID, }); @@ -1032,7 +1039,33 @@ describe('host-managed agent graph coordinator', () => { context: toolContext(rootSessionId, 'run-root', 'turn-root', 'tool-finish'), }), ); - const next = await coordinator.beginNextGraphEpoch(rootSessionId, withWakesSuppressed); + t.mock.method(controlStore, 'listAgentGraphOperatorProvisions', async (id: string) => + id === graphId + ? [ + { + schemaVersion: AGENT_GRAPH_OPERATOR_PROVISION_SCHEMA_VERSION, + graphId, + provisionId: `graph_provision_${'1'.repeat(32)}`, + provisionFingerprint: `sha256:${'2'.repeat(64)}`, + workId: `graph_work_${'3'.repeat(32)}`, + agentId: 'agent', + operatorId: `graph_operator_${'4'.repeat(32)}`, + targetSessionId: 'child-session', + initialTurnId: 'child-turn', + initialRunId: 'child-run', + provisionedAt: 1, + edges: [], + }, + ] + : [], + ); + stopping = coordinator.stop(rootSessionId); + await stopStarted.promise; + const next = await withTimeout( + coordinator.beginNextGraphEpoch(rootSessionId, withWakesSuppressed), + 5_000, + 'a terminal epoch must not block the next turn on old operator cleanup', + ); assert.equal(next.epoch, 2); assert.equal(next.graphId, agentGraphIdForRootSessionEpoch(rootSessionId, 2)); assert.equal(suppressions, 1); @@ -1051,6 +1084,8 @@ describe('host-managed agent graph coordinator', () => { ], ); } finally { + releaseStop.resolve(); + await stopping; await coordinator.close(); controlStore.close(); await rm(root, { recursive: true, force: true }); diff --git a/packages/runtime/src/__tests__/zod-recursive-contract.test.ts b/packages/runtime/src/__tests__/zod-recursive-contract.test.ts new file mode 100644 index 0000000000..59016baf9c --- /dev/null +++ b/packages/runtime/src/__tests__/zod-recursive-contract.test.ts @@ -0,0 +1,99 @@ +/* + * 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 { createRequire } from 'node:module'; +import { test } from 'node:test'; +import type { ZodType } from 'zod'; + +// Both shipped entry points must preserve identity across nested parses. +for (const format of ['esm', 'cjs'] as const) { + test(`Zod ${format} preserves recursive identity across reentry and thrown parses`, async () => { + const { z }: typeof import('zod') = + format === 'esm' ? await import('zod') : createRequire(import.meta.url)('zod'); + type Node = { label: string; children: Node[] }; + const schema: ZodType = z.object({ + label: z.string(), + get children() { + return z.array(schema); + }, + }); + const cycle: Node = { label: 'root', children: [] }; + cycle.children.push(cycle); + const shared: Node = { label: 'child', children: [] }; + for (const parse of [ + (input: Node) => schema.parse(input), + (input: Node) => schema.parseAsync(input), + ]) { + const output = await parse(cycle); + assert.equal(output.children[0], output); + const aliases = await parse({ label: 'root', children: [shared, shared] }); + assert.equal(aliases.children[0], aliases.children[1]); + } + + type RecursiveArray = RecursiveArray[]; + const arraySchema: ZodType = z.array(z.lazy(() => arraySchema)); + arraySchema.parse([]); + for (const throws of [false, true]) { + let entered = false; + const target: RecursiveArray = []; + const proxy = new Proxy(target, { + get(array, key, receiver) { + // Reenter before the outer array allocates its output. + if (key === 'length' && !entered) { + entered = true; + if (throws) { + assert.throws( + () => + arraySchema.parse( + new Proxy([], { + get() { + throw new Error('nested length'); + }, + }), + ), + /nested length/, + ); + } else { + schema.parse({ label: 'nested', children: [] }); + } + } + return Reflect.get(array, key, receiver); + }, + }); + target.push(proxy); + const output = arraySchema.parse(proxy); + assert.equal(entered, true); + assert.equal(output[0], output); + } + + const throwing: ZodType = z.object({ + label: z.string().transform(() => { + throw new Error('nested transform'); + }), + get children() { + return z.array(throwing); + }, + }); + assert.throws(() => throwing.parse(cycle), /nested transform/); + await assert.rejects(throwing.parseAsync(cycle), /nested transform/); + const recovered = schema.parse(cycle); + assert.equal(recovered.children[0], recovered); + }); +} diff --git a/packages/runtime/src/ai-sdk-turn.ts b/packages/runtime/src/ai-sdk-turn.ts index b9a436d850..37960a3e48 100644 --- a/packages/runtime/src/ai-sdk-turn.ts +++ b/packages/runtime/src/ai-sdk-turn.ts @@ -2342,14 +2342,11 @@ export class AiSdkTurn { (outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected', ); if (rejectedSettlement) throw rejectedSettlement.reason; - const settlements = settlementOutcomes.map((outcome) => { - // A rejected settlement was handled above, so preserving the - // original array shape also preserves tool-call identity by index. + settlementOutcomes.forEach((outcome, index) => { + // All settlements completed and rejection was checked above; + // preserve provider order for Plan and Yield result handling. if (outcome.status === 'rejected') throw outcome.reason; - return outcome.value; - }); - for (let index = 0; index < settlements.length; index += 1) { - const settlement = settlements[index]!; + const settlement = outcome.value; const toolCall = returnedToolCalls[index]; if (isPlanToolResult(settlement.result)) { this.handlePlanToolResult(settlement.result, queue); @@ -2361,7 +2358,10 @@ export class AiSdkTurn { ) { this.handleAgentGraphYieldToolResult(settlement.result); } - } + }); + // Continuation reads durable events, not raw results. Do not retain + // an entire completed batch across the next provider request. + settlementOutcomes.length = 0; await queue.waitUntilConsumedThroughCurrent(); const continuationWillRun = diff --git a/packages/runtime/src/archive-read-tool.ts b/packages/runtime/src/archive-read-tool.ts index d84cbc3c08..92d27df6aa 100644 --- a/packages/runtime/src/archive-read-tool.ts +++ b/packages/runtime/src/archive-read-tool.ts @@ -112,7 +112,10 @@ export function buildArchiveReadTool(reader: ToolResultArchiveResourceReader): M }, }), impl: async (input, ctx) => - readToolResultArchiveResource(reader, ctx.sessionId, input, ctx.abortSignal), + // Detach bounded slices before active tool settlements retain their archive backing. + structuredClone( + await readToolResultArchiveResource(reader, ctx.sessionId, input, ctx.abortSignal), + ), }; } diff --git a/packages/runtime/src/bots/__tests__/bot-registry.test.ts b/packages/runtime/src/bots/__tests__/bot-registry.test.ts index 47cce90318..9f2ccceefc 100644 --- a/packages/runtime/src/bots/__tests__/bot-registry.test.ts +++ b/packages/runtime/src/bots/__tests__/bot-registry.test.ts @@ -18,6 +18,7 @@ */ import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; import { describe, test } from 'node:test'; import { createDefaultBotChannel } from '@maka/core/settings'; import type { BotChatSettings, BotProvider } from '@maka/core/bot-chat-settings'; @@ -25,6 +26,58 @@ import { BotRegistry } from '../bot-registry.js'; import type { BotStatus } from '../types.js'; describe('BotRegistry', () => { + test('reports lazy SDK loading failures through bot status', async () => { + const statuses: BotStatus[] = []; + const registry = new BotRegistry({ + onIncomingMessage: () => {}, + onStatusChange: (status) => statuses.push(status), + }); + const sdkNames = new Set([ + '@slack/web-api', + '@larksuiteoapi/node-sdk', + '@wecom/aibot-node-sdk', + ]); + const hooks = registerHooks({ + resolve(specifier, context, nextResolve) { + if (sdkNames.has(specifier)) throw new Error('SDK unavailable'); + return nextResolve(specifier, context); + }, + }); + try { + await registry.applySettings( + settingsWith( + Object.fromEntries( + (['slack', 'feishu', 'wecom'] as const).map((platform) => [ + platform, + { + enabled: true, + token: 'test-token', + appId: 'test-app', + appSecret: 'test-secret', + }, + ]), + ), + ), + ); + for (const platform of ['slack', 'feishu', 'wecom'] as const) { + assert.equal(registry.getStatus(platform).running, false); + assert.equal(registry.getStatus(platform).reason, 'connection_failed'); + assert.equal( + registry.getStatus(platform).readiness, + platform === 'slack' ? 'degraded' : 'configured', + ); + assert.ok( + statuses.some( + (status) => status.platform === platform && status.reason === 'connection_failed', + ), + ); + } + } finally { + hooks.deregister(); + await registry.stopAll(); + } + }); + test('reports disabled and missing-credential statuses without opening network connections', async () => { const statuses: BotStatus[] = []; const registry = new BotRegistry({ diff --git a/packages/runtime/src/bots/bot-test.ts b/packages/runtime/src/bots/bot-test.ts index 124b28ee8b..82a6ebd144 100644 --- a/packages/runtime/src/bots/bot-test.ts +++ b/packages/runtime/src/bots/bot-test.ts @@ -17,8 +17,8 @@ * under the License. */ +import { createRequire } from 'node:module'; import { type BotChannelSettings, type BotProvider } from '@maka/core/bot-chat-settings'; -import { WebClient } from '@slack/web-api'; import type { BotTestResult } from './types.js'; import { proxiedFetch } from './proxied-fetch.js'; import { botDiagnosticMessage } from './base-adapter.js'; @@ -94,6 +94,9 @@ async function testSlack(channel: BotChannelSettings): Promise { return { ok: false, errorCode: 'slack_tokens_missing' }; } try { + const { WebClient } = createRequire(import.meta.url)( + '@slack/web-api', + ) as typeof import('@slack/web-api'); const identity = await new WebClient(botToken).auth.test(); if (!identity.ok) return { ok: false, error: identity.error ?? 'Slack auth.test failed' }; const socket = await new WebClient(appToken).apps.connections.open(); diff --git a/packages/runtime/src/bots/feishu-bridge.ts b/packages/runtime/src/bots/feishu-bridge.ts index df2e5005bc..82fe9553f0 100644 --- a/packages/runtime/src/bots/feishu-bridge.ts +++ b/packages/runtime/src/bots/feishu-bridge.ts @@ -17,13 +17,8 @@ * under the License. */ -import { - Domain, - LoggerLevel, - createLarkChannel, - type LarkChannel, - type NormalizedMessage, -} from '@larksuiteoapi/node-sdk'; +import { createRequire } from 'node:module'; +import type { LarkChannel, NormalizedMessage } from '@larksuiteoapi/node-sdk'; import type { BotChannelSettings } from '@maka/core/bot-chat-settings'; import { BaseBotAdapter, botReadinessFromSettings } from './base-adapter.js'; import type { BotSendOptions, BotStatus, SendCapable } from './types.js'; @@ -99,36 +94,40 @@ export class FeishuBotBridge extends BaseBotAdapter implements SendCapable { } this.explicitlyStopped = false; - const isLark = this.settings.domain?.trim() === 'larksuite.com'; - const channel = createLarkChannel({ - appId, - appSecret, - domain: isLark ? Domain.Lark : Domain.Feishu, - transport: 'websocket', - source: 'maka', - loggerLevel: LoggerLevel.error, - handshakeTimeoutMs: HANDSHAKE_TIMEOUT_MS, - policy: { - dmMode: this.settings.allowedUserIds?.length ? 'allowlist' : 'open', - dmAllowlist: [...(this.settings.allowedUserIds ?? [])], - requireMention: false, - }, - }); - this.channel = channel; - this.wire(channel, appId); - + let channel: LarkChannel | undefined; try { + // Keep lazy loading synchronous so stop() cannot race a module-loading await. + const { Domain, LoggerLevel, createLarkChannel } = createRequire(import.meta.url)( + '@larksuiteoapi/node-sdk', + ) as typeof import('@larksuiteoapi/node-sdk'); + const isLark = this.settings.domain?.trim() === 'larksuite.com'; + channel = createLarkChannel({ + appId, + appSecret, + domain: isLark ? Domain.Lark : Domain.Feishu, + transport: 'websocket', + source: 'maka', + loggerLevel: LoggerLevel.error, + handshakeTimeoutMs: HANDSHAKE_TIMEOUT_MS, + policy: { + dmMode: this.settings.allowedUserIds?.length ? 'allowlist' : 'open', + dmAllowlist: [...(this.settings.allowedUserIds ?? [])], + requireMention: false, + }, + }); + this.channel = channel; + this.wire(channel, appId); await channel.connect(); if (this.explicitlyStopped || this.channel !== channel) return; this.startedAt = Date.now(); this.markConnected(channel, appId); } catch (error) { - if (this.explicitlyStopped || this.channel !== channel) return; + if (this.explicitlyStopped || (channel && this.channel !== channel)) return; this.running = false; this.recordFailure(error); this.readiness = 'configured'; this.emitStatusChange(); - await this.disconnectChannel(channel); + if (channel) await this.disconnectChannel(channel); } } diff --git a/packages/runtime/src/bots/slack-bridge.ts b/packages/runtime/src/bots/slack-bridge.ts index cefa882a30..977520891b 100644 --- a/packages/runtime/src/bots/slack-bridge.ts +++ b/packages/runtime/src/bots/slack-bridge.ts @@ -17,9 +17,10 @@ * under the License. */ +import { createRequire } from 'node:module'; import type { BotChannelSettings } from '@maka/core/bot-chat-settings'; -import { SocketModeClient } from '@slack/socket-mode'; -import { WebClient } from '@slack/web-api'; +import type { SocketModeClient } from '@slack/socket-mode'; +import type { WebClient } from '@slack/web-api'; import { BaseBotAdapter, botReadinessFromSettings } from './base-adapter.js'; import type { BotSendOptions, SendCapable } from './types.js'; @@ -86,8 +87,12 @@ export class SlackBotBridge extends BaseBotAdapter implements SendCapable { return; } - this.web = new WebClient(botToken); try { + const require = createRequire(import.meta.url); + const { WebClient } = require('@slack/web-api') as typeof import('@slack/web-api'); + const { SocketModeClient } = + require('@slack/socket-mode') as typeof import('@slack/socket-mode'); + this.web = new WebClient(botToken); const identity = await this.web.auth.test(); if (!identity.ok) throw new Error(identity.error ?? 'Slack auth.test failed'); this.identity = { diff --git a/packages/runtime/src/bots/wechat-bridge.ts b/packages/runtime/src/bots/wechat-bridge.ts index ce6f91253a..ab9fc63d53 100644 --- a/packages/runtime/src/bots/wechat-bridge.ts +++ b/packages/runtime/src/bots/wechat-bridge.ts @@ -181,11 +181,12 @@ export class WechatBridge extends BaseBotAdapter implements SendCapable { private async streamLiveMessages(sinceEpochSeconds: number): Promise { const baseUrl = normalizeWechatBridgeUrl(this.settings.webhookUrl); if (!baseUrl) return; + const cursor = { sinceEpochSeconds }; while (this.running) { this.abortController = new AbortController(); try { const response = await proxiedFetch( - `${baseUrl}/messages/stream?since=${sinceEpochSeconds}`, + `${baseUrl}/messages/stream?since=${cursor.sinceEpochSeconds}`, { method: 'GET', headers: wechatBridgeHeaders(this.settings), @@ -195,18 +196,8 @@ export class WechatBridge extends BaseBotAdapter implements SendCapable { ); if (!response.ok || !response.body) throw new Error(`WeChat stream HTTP ${response.status}`); - for await (const raw of readSseJsonObjects(response.body)) { - const messages = Array.isArray(raw) ? raw : [raw]; - for (const message of messages) { - const event = mapWechatBridgeMessage(message); - if (!event) continue; - sinceEpochSeconds = Math.max(sinceEpochSeconds, Math.floor(event.receivedAt / 1000)); - this.readiness = 'operational'; - this.reason = undefined; - this.emitIncomingMessage(event); - this.emitStatusChange(); - } - } + const messages = readSseJsonObjects(response.body); + while (await this.processNextLiveMessages(messages, cursor)) {} if (this.running) await sleep(1_000); } catch (error) { if (!this.running) return; @@ -220,6 +211,35 @@ export class WechatBridge extends BaseBotAdapter implements SendCapable { } } + private async processNextLiveMessages( + stream: AsyncGenerator, + cursor: { sinceEpochSeconds: number }, + ): Promise { + // Keep the iterator result and mapped events out of the next idle read's frame. + const next = await stream.next(); + if (next.done) return false; + try { + const messages = Array.isArray(next.value) ? next.value : [next.value]; + for (const message of messages) { + const event = mapWechatBridgeMessage(message); + if (!event) continue; + cursor.sinceEpochSeconds = Math.max( + cursor.sinceEpochSeconds, + Math.floor(event.receivedAt / 1000), + ); + this.readiness = 'operational'; + this.reason = undefined; + this.emitIncomingMessage(event); + this.emitStatusChange(); + } + return true; + } catch (error) { + // Match for-await: close after processing failures, preserving the original error. + await stream.return(undefined).catch(() => {}); + throw error; + } + } + private async streamIlinkMessages(cursor: string): Promise { const baseUrl = normalizeWechatIlinkBaseUrl(this.settings.webhookUrl); const token = this.settings.token.trim(); @@ -425,25 +445,65 @@ export async function* readSseJsonObjects( body: AsyncIterable, ): AsyncGenerator { const decoder = new TextDecoder(); + const iterator = body[Symbol.asyncIterator](); let buffer = ''; - for await (const chunk of body) { - buffer += decoder.decode(chunk, { stream: true }); - let boundary = findSseBoundary(buffer); - while (boundary) { - const event = buffer.slice(0, boundary.index); - buffer = buffer.slice(boundary.index + boundary.length); - const data = event - .split(/\r?\n/) - .filter((line) => line.startsWith('data:')) - .map((line) => line.slice(5).trimStart()) - .join('\n') - .trim(); - if (data) yield JSON.parse(data); - boundary = findSseBoundary(buffer); + let closeIterator = false; + try { + while (true) { + // A rejected next() does not close its iterator under for-await semantics. + closeIterator = false; + let text = await readSseTextChunk(iterator, decoder); + if (text === undefined) return; + closeIterator = true; + buffer += text; + text = undefined; + let boundary = findSseBoundary(buffer); + while (boundary) { + let data = sseEventData(buffer.slice(0, boundary.index)); + buffer = buffer.slice(boundary.index + boundary.length); + if (data) yield JSON.parse(data); + data = ''; + boundary = findSseBoundary(buffer); + } } + } catch (error) { + if (closeIterator) { + closeIterator = false; + try { + await iterator.return?.(); + } catch {} + } + throw error; + } finally { + if (closeIterator) await iterator.return?.(); } } +async function readSseTextChunk( + iterator: AsyncIterator, + decoder: TextDecoder, +): Promise { + const next = await iterator.next(); + if (next.done) return; + try { + return decoder.decode(next.value, { stream: true }); + } catch (error) { + try { + await iterator.return?.(); + } catch {} + throw error; + } +} + +function sseEventData(event: string): string { + return event + .split(/\r?\n/) + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice(5).trimStart()) + .join('\n') + .trim(); +} + export async function testWechatBridge(channel: BotChannelSettings): Promise { const baseUrl = normalizeWechatBridgeUrl(channel.webhookUrl); if (!baseUrl) { diff --git a/packages/runtime/src/bots/wecom-bridge.ts b/packages/runtime/src/bots/wecom-bridge.ts index 215ac172f7..07cf6ddf5d 100644 --- a/packages/runtime/src/bots/wecom-bridge.ts +++ b/packages/runtime/src/bots/wecom-bridge.ts @@ -17,7 +17,8 @@ * under the License. */ -import { WSClient, type TextMessage, type WsFrame } from '@wecom/aibot-node-sdk'; +import { createRequire } from 'node:module'; +import type { WSClient, TextMessage, WsFrame } from '@wecom/aibot-node-sdk'; import type { BotChannelSettings } from '@maka/core/bot-chat-settings'; import { BaseBotAdapter, botReadinessFromSettings } from './base-adapter.js'; import type { BotSendOptions, BotStatus, SendCapable } from './types.js'; @@ -90,56 +91,60 @@ export class WeComBotBridge extends BaseBotAdapter implements SendCapable { } this.explicitlyStopped = false; - const client = new WSClient({ - botId, - secret, - maxReconnectAttempts: -1, - logger: quietLogger, - }); - this.client = client; - this.wire(client, botId); - - await new Promise((resolve, reject) => { - let settled = false; - const onAuthenticated = () => finish(resolve); - const onError = (error: Error) => finish(() => reject(error)); - const cleanup = () => { - clearTimeout(timer); - client.off('authenticated', onAuthenticated); - client.off('error', onError); - }; - const finish = (fn: () => void) => { - if (settled) return; - settled = true; - cleanup(); - fn(); - }; - const timer = setTimeout( - () => finish(() => reject(new Error('WeCom authentication timed out'))), - AUTH_TIMEOUT_MS, - ); - client.once('authenticated', onAuthenticated); - client.once('error', onError); - client.connect(); - }) - .then(() => { - if (this.explicitlyStopped || this.client !== client) return; - this.running = true; - this.startedAt = Date.now(); - this.identity = { id: botId, username: botId, displayName: botId }; - this.reason = undefined; - this.readiness = 'credentials_valid'; - this.emitStatusChange(); - }) - .catch((error) => { - if (this.explicitlyStopped || this.client !== client) return; - this.running = false; - this.recordFailure(error); - this.readiness = 'configured'; - this.emitStatusChange(); - this.client = null; - closeWeComClient(client); + let startedClient: WSClient | undefined; + try { + const { WSClient } = createRequire(import.meta.url)( + '@wecom/aibot-node-sdk', + ) as typeof import('@wecom/aibot-node-sdk'); + const client = new WSClient({ + botId, + secret, + maxReconnectAttempts: -1, + logger: quietLogger, }); + startedClient = client; + this.client = client; + this.wire(client, botId); + + await new Promise((resolve, reject) => { + let settled = false; + const onAuthenticated = () => finish(resolve); + const onError = (error: Error) => finish(() => reject(error)); + const cleanup = () => { + clearTimeout(timer); + client.off('authenticated', onAuthenticated); + client.off('error', onError); + }; + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + cleanup(); + fn(); + }; + const timer = setTimeout( + () => finish(() => reject(new Error('WeCom authentication timed out'))), + AUTH_TIMEOUT_MS, + ); + client.once('authenticated', onAuthenticated); + client.once('error', onError); + client.connect(); + }); + if (this.explicitlyStopped || this.client !== client) return; + this.running = true; + this.startedAt = Date.now(); + this.identity = { id: botId, username: botId, displayName: botId }; + this.reason = undefined; + this.readiness = 'credentials_valid'; + this.emitStatusChange(); + } catch (error) { + if (this.explicitlyStopped || (startedClient && this.client !== startedClient)) return; + this.running = false; + this.recordFailure(error); + this.readiness = 'configured'; + this.emitStatusChange(); + this.client = null; + if (startedClient) closeWeComClient(startedClient); + } } async stop(): Promise { diff --git a/packages/runtime/src/computer-use-tools.ts b/packages/runtime/src/computer-use-tools.ts index a838342fc8..84c6ac5631 100644 --- a/packages/runtime/src/computer-use-tools.ts +++ b/packages/runtime/src/computer-use-tools.ts @@ -1281,6 +1281,7 @@ export function buildComputerUseTools(deps: { release(); if (invocationQueues.get(sessionId) === current) { invocationQueues.delete(sessionId); + presentationGenerations.delete(sessionId); } } } @@ -2821,7 +2822,9 @@ export function buildComputerUseTools(deps: { } const tools = [tool] as ComputerUseToolSet; tools.clearSession = (sessionId: string) => { - presentationGenerations.set(sessionId, (presentationGenerations.get(sessionId) ?? 0) + 1); + if (invocationQueues.has(sessionId)) { + presentationGenerations.set(sessionId, (presentationGenerations.get(sessionId) ?? 0) + 1); + } for (const wake of presentationQueueWaiters.get(sessionId) ?? []) wake(); for (const wake of presentationWaiters.get(sessionId) ?? []) wake(); const current = sessionStates.get(sessionId); diff --git a/packages/runtime/src/deep-research-tools.ts b/packages/runtime/src/deep-research-tools.ts index f544c30591..018776ac85 100644 --- a/packages/runtime/src/deep-research-tools.ts +++ b/packages/runtime/src/deep-research-tools.ts @@ -226,14 +226,19 @@ function buildReadArtifactTool(deps: BuildDeepResearchToolsDeps): MakaTool< } const offset = input.offset_chars ?? 0; const maxChars = input.max_chars ?? DEEP_RESEARCH_ARTIFACT_READ_DEFAULT_CHARS; - const characters = Array.from(read.text); - const end = Math.min(characters.length, offset + maxChars); - const chunk = safeResearchArtifactContent(characters.slice(offset, end).join('')); + const selected: string[] = []; + let total = 0; + for (const character of read.text) { + if (total >= offset && total < offset + maxChars) selected.push(character); + total += 1; + } + const end = Math.min(total, offset + maxChars); + const chunk = safeResearchArtifactContent(selected.join('')); return [ - ``, + ``, `Name: ${normalizeInlineText(ref.name)}`, ...(ref.locator ? [`Locator: ${normalizeInlineText(ref.locator)}`] : []), - `Truncated: ${end < characters.length}`, + `Truncated: ${end < total}`, '', chunk, '', diff --git a/packages/runtime/src/edit-replace.ts b/packages/runtime/src/edit-replace.ts index 634f53098e..c367096828 100644 --- a/packages/runtime/src/edit-replace.ts +++ b/packages/runtime/src/edit-replace.ts @@ -186,10 +186,10 @@ export function computeEditedSource( ): EditMatch { const index = content.indexOf(span); const before = content.slice(0, index); - const startLine = before.split('\n').length; + const startLine = countOccurrences(before, '\n') + 1; // A trailing newline in the span is the last line's terminator, not an // extra line, so it must not bump endLine. - const spanLineCount = span.split('\n').length - (span.endsWith('\n') ? 1 : 0); + const spanLineCount = countOccurrences(span, '\n') + 1 - (span.endsWith('\n') ? 1 : 0); const endLine = startLine + Math.max(spanLineCount, 1) - 1; // slice-join (not String.replace) so `$&`/`$1` in newString are literal. const next = before + replacement + content.slice(index + span.length); diff --git a/packages/runtime/src/filesystem-worker/operations.ts b/packages/runtime/src/filesystem-worker/operations.ts index 40f956aeeb..5b7f92b794 100644 --- a/packages/runtime/src/filesystem-worker/operations.ts +++ b/packages/runtime/src/filesystem-worker/operations.ts @@ -31,6 +31,7 @@ import { } from '../apply-patch-file.js'; import { computeEditedSource } from '../edit-replace.js'; +import { readTextLineWindow } from '../text-line-window.js'; import { createEditUnifiedDiff, createUnifiedDiff } from '../unified-diff.js'; import { compareAndDeleteEntry, @@ -152,12 +153,10 @@ export async function executeFilesystemOperation( } } const content = await fs.readFile(path, 'utf8'); - if (operation.offset === undefined && operation.limit === undefined) - return { kind: 'read', content }; - const lines = content.split('\n'); - const start = operation.offset ?? 0; - const end = operation.limit ? start + operation.limit : lines.length; - return { kind: 'read', content: lines.slice(start, end).join('\n') }; + return { + kind: 'read', + content: readTextLineWindow(content, operation.offset, operation.limit), + }; } case 'write': { const path = await resolveWritableAllowed( diff --git a/packages/runtime/src/memory-extraction-evidence.ts b/packages/runtime/src/memory-extraction-evidence.ts index aaafd20077..eedb106e32 100644 --- a/packages/runtime/src/memory-extraction-evidence.ts +++ b/packages/runtime/src/memory-extraction-evidence.ts @@ -363,7 +363,12 @@ function historyRole(event: RuntimeEvent): 'user' | 'assistant' { } function sliceCodePoints(value: string, maximum: number): string { - return Array.from(value).slice(0, maximum).join(''); + const prefix: string[] = []; + for (const point of value) { + if (prefix.length >= maximum) break; + prefix.push(point); + } + return prefix.join(''); } function uniqueSorted(values: readonly number[]): number[] { diff --git a/packages/runtime/src/memory-extraction.ts b/packages/runtime/src/memory-extraction.ts index 194b3dfd57..e2a9dc8c03 100644 --- a/packages/runtime/src/memory-extraction.ts +++ b/packages/runtime/src/memory-extraction.ts @@ -625,30 +625,28 @@ export class MemoryExtractionEngine { if (requestedTurnStart <= 0) return undefined; maximumSplitIndex = requestedTurnStart; } - const split = memoryRangeSplitCandidates(pendingEntries, maximumSplitIndex).find( - ({ first, second }) => { - const firstThroughOrdinal = first.at(-1)!.ordinal; - const firstTrigger: MemoryExtractionTrigger = 'extract'; - const firstPrepared = this.prepareRange({ - ...input, - trigger: firstTrigger, - targetBoundaryOrdinal: firstThroughOrdinal, - prioritizeCurrentTurn: false, - pendingEntries: first, - coverageHash: memoryCoverageHash(first), - }); - const secondPrepared = this.prepareRange({ - ...input, - expectedCursorOrdinal: firstThroughOrdinal, - pendingEntries: second, - coverageHash: memoryCoverageHash(second), - }); - return ( - preparedMemoryRangeFits(firstPrepared, firstTrigger) && - preparedMemoryRangeFits(secondPrepared, input.trigger) - ); - }, - ); + const split = findMemoryRangeSplit(pendingEntries, maximumSplitIndex, ({ first, second }) => { + const firstThroughOrdinal = first.at(-1)!.ordinal; + const firstTrigger: MemoryExtractionTrigger = 'extract'; + const firstPrepared = this.prepareRange({ + ...input, + trigger: firstTrigger, + targetBoundaryOrdinal: firstThroughOrdinal, + prioritizeCurrentTurn: false, + pendingEntries: first, + coverageHash: memoryCoverageHash(first), + }); + const secondPrepared = this.prepareRange({ + ...input, + expectedCursorOrdinal: firstThroughOrdinal, + pendingEntries: second, + coverageHash: memoryCoverageHash(second), + }); + return ( + preparedMemoryRangeFits(firstPrepared, firstTrigger) && + preparedMemoryRangeFits(secondPrepared, input.trigger) + ); + }); if (!split) { return undefined; } @@ -1347,14 +1345,20 @@ function memorySegmentOperationId( .digest('hex')}`; } -function memoryRangeSplitCandidates( +function findMemoryRangeSplit( entries: readonly MemoryExtractionEventEntry[], maximumSplitIndex = entries.length - 1, -): readonly { - readonly first: readonly MemoryExtractionEventEntry[]; - readonly second: readonly MemoryExtractionEventEntry[]; -}[] { - if (entries.length < 2) return []; + accepts: (split: { + readonly first: readonly MemoryExtractionEventEntry[]; + readonly second: readonly MemoryExtractionEventEntry[]; + }) => boolean, +): + | { + readonly first: readonly MemoryExtractionEventEntry[]; + readonly second: readonly MemoryExtractionEventEntry[]; + } + | undefined { + if (entries.length < 2) return undefined; const weights = entries.map(memoryRangeEventWeight); const total = weights.reduce((sum, weight) => sum + weight, 0); let prefix = 0; @@ -1370,14 +1374,18 @@ function memoryRangeSplitCandidates( }; }) .filter(({ index }) => index <= maximumSplitIndex); - return candidates - .sort( - (left, right) => - Number(right.turnBoundary) - Number(left.turnBoundary) || - left.distance - right.distance || - left.index - right.index, - ) - .map(({ index }) => ({ first: entries.slice(0, index), second: entries.slice(index) })); + candidates.sort( + (left, right) => + Number(right.turnBoundary) - Number(left.turnBoundary) || + left.distance - right.distance || + left.index - right.index, + ); + // Keep only the candidate being checked; each pair copies the entire range. + for (const { index } of candidates) { + const split = { first: entries.slice(0, index), second: entries.slice(index) }; + if (accepts(split)) return split; + } + return undefined; } function preparedMemoryRangeFits( diff --git a/packages/runtime/src/openai-responses-websocket.ts b/packages/runtime/src/openai-responses-websocket.ts index 413d62e9fc..71aa69f504 100644 --- a/packages/runtime/src/openai-responses-websocket.ts +++ b/packages/runtime/src/openai-responses-websocket.ts @@ -297,11 +297,8 @@ function prepareWireRequest( const { previous_response_id: _previousResponseId, ...currentProperties } = body; const fullBody = { ...currentProperties, - input: [ - ...structuredClone(baseline.fullBody.input), - ...structuredClone(baseline.responseOutput), - ...structuredClone(body.input), - ], + // These JSON values are privately owned; only the combined input array needs to be new. + input: [...baseline.fullBody.input, ...baseline.responseOutput, ...body.input], }; const sameProperties = isDeepStrictEqual( requestProperties(baseline.fullBody), diff --git a/packages/runtime/src/plugin-composition-loader.ts b/packages/runtime/src/plugin-composition-loader.ts index 9ec17c2fa5..a43bad808d 100644 --- a/packages/runtime/src/plugin-composition-loader.ts +++ b/packages/runtime/src/plugin-composition-loader.ts @@ -17,6 +17,8 @@ * under the License. */ +/// + import { Context, type Fiber, FiberState, type Inject, type Plugin } from './plugin-kernel.js'; import { fiberStateName, @@ -70,7 +72,15 @@ export class MakaCompositionLoader { readonly #packages = new Map(); readonly #roots = new Map(); readonly #entries = new Map(); - readonly #isolationLabels = new Map(); + // Contexts and services can outlive their entries, so intern by symbol lifetime. + readonly #isolationLabels = new Map>(); + readonly #isolationLabelFinalizer = new FinalizationRegistry<{ + label: string; + reference: WeakRef; + }>(({ label, reference }) => { + // An old finalizer must not evict a newer incarnation of the same label. + if (this.#isolationLabels.get(label) === reference) this.#isolationLabels.delete(label); + }); readonly #transaction?: (context: Context) => MakaPluginTransaction | undefined; #compositionGeneration = 0; #fiberGeneration = 0; @@ -974,10 +984,12 @@ export class MakaCompositionLoader { } #isolationLabel(label: string): symbol { - let symbol = this.#isolationLabels.get(label); + let symbol = this.#isolationLabels.get(label)?.deref(); if (!symbol) { symbol = Symbol(label); - this.#isolationLabels.set(label, symbol); + const reference = new WeakRef(symbol); + this.#isolationLabels.set(label, reference); + this.#isolationLabelFinalizer.register(symbol, { label, reference }); } return symbol; } diff --git a/packages/runtime/src/request-customization-fetch.ts b/packages/runtime/src/request-customization-fetch.ts index 9fcddabdac..ff0039036d 100644 --- a/packages/runtime/src/request-customization-fetch.ts +++ b/packages/runtime/src/request-customization-fetch.ts @@ -46,6 +46,14 @@ export function createRequestCustomizationFetch( } return async (input, init) => { + // A temporary Request's derived signal can lose abort forwarding after GC. + // Keep the caller's effective signal; explicit null disconnects input.signal. + const signal = + init?.signal !== undefined + ? init.signal + : input instanceof Request + ? input.signal + : undefined; const request = new Request(input, init); const nextHeaders = new Headers(request.headers); for (const [name, value] of Object.entries(headers)) { @@ -62,10 +70,10 @@ export function createRequestCustomizationFetch( if (customization.finalizeBody) { throw new Error('Request body finalizer requires a JSON object request body'); } - return upstream(request.url, requestInit(request, nextHeaders, body)); + return upstream(request.url, requestInit(request, nextHeaders, body, signal)); } const generatedBody = await parseRequestBody( - request, + body as ArrayBuffer, customization.finalizeBody ? 'Request body finalizer requires a JSON object request body' : 'Extra request body can only be applied to a JSON object request', @@ -81,16 +89,21 @@ export function createRequestCustomizationFetch( ); nextHeaders.delete('content-length'); } - return upstream(request.url, requestInit(request, nextHeaders, body)); + return upstream(request.url, requestInit(request, nextHeaders, body, signal)); }; } -function requestInit(request: Request, headers: Headers, body: BodyInit | null): RequestInit { +function requestInit( + request: Request, + headers: Headers, + body: BodyInit | null, + signal: AbortSignal | null | undefined, +): RequestInit { return { method: request.method, headers: [...headers.entries()], ...(body === null ? {} : { body, duplex: 'half' }), - signal: request.signal, + signal, cache: request.cache, credentials: request.credentials, integrity: request.integrity, @@ -111,12 +124,12 @@ function requestHasJsonBody(request: Request): boolean { } async function parseRequestBody( - request: Request, + body: ArrayBuffer, invalidBodyMessage: string, ): Promise> { let parsed: unknown; try { - parsed = JSON.parse(await request.clone().text()); + parsed = JSON.parse(new TextDecoder().decode(body)); } catch { throw new Error(invalidBodyMessage); } diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 911e7955fe..51fcb07ccb 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -5299,7 +5299,11 @@ function buildAgentOutputCommittedResult(input: { }; } - const codePoints = Array.from(text); + const codePoints: string[] = []; + for (const point of text) { + if (codePoints.length >= input.maxBytes) break; + codePoints.push(point); + } let low = 0; let high = codePoints.length; let best: AgentOutputCommittedResult = { ...withoutText, textTruncated: true }; diff --git a/packages/runtime/src/shell-run-manager.ts b/packages/runtime/src/shell-run-manager.ts index 6ab56d666e..c044e1faa1 100644 --- a/packages/runtime/src/shell-run-manager.ts +++ b/packages/runtime/src/shell-run-manager.ts @@ -2035,13 +2035,16 @@ function normalizeBackgroundTimeoutMs(value: number | undefined): number | undef return value; } -function splitPtyData(data: string): string[] { - const codePoints = Array.from(data); - const chunks: string[] = []; - for (let offset = 0; offset < codePoints.length; offset += PTY_RAW_INPUT_CHUNK_CODE_POINTS) { - chunks.push(codePoints.slice(offset, offset + PTY_RAW_INPUT_CHUNK_CODE_POINTS).join('')); +function* splitPtyData(data: string): Generator { + // Bound each published piece without materializing the entire callback as code points. + let offset = 0; + while (offset < data.length) { + const start = offset; + for (let count = 0; count < PTY_RAW_INPUT_CHUNK_CODE_POINTS && offset < data.length; count++) { + offset += data.codePointAt(offset)! > 0xffff ? 2 : 1; + } + yield data.slice(start, offset); } - return chunks; } function encodedPtyDataBytes(data: string): number { diff --git a/packages/runtime/src/shell-run-tool-result.ts b/packages/runtime/src/shell-run-tool-result.ts index 113360a002..0601b88143 100644 --- a/packages/runtime/src/shell-run-tool-result.ts +++ b/packages/runtime/src/shell-run-tool-result.ts @@ -265,15 +265,21 @@ function takeTailText(text: string, budget: number): { text: string; truncated: } function sliceUtf8Tail(text: string, budget: number): string { - const characters = Array.from(text); - let result = ''; + const characters: string[] = []; let bytes = 0; - for (let index = characters.length - 1; index >= 0; index -= 1) { - const character = characters[index]; + for (let end = text.length; end > 0; ) { + let start = end - 1; + const last = text.charCodeAt(start); + if (last >= 0xdc00 && last <= 0xdfff && start > 0) { + const previous = text.charCodeAt(start - 1); + if (previous >= 0xd800 && previous <= 0xdbff) start -= 1; + } + const character = text.slice(start, end); const size = Buffer.byteLength(character, 'utf8'); if (bytes + size > budget) break; - result = character + result; + characters.push(character); bytes += size; + end = start; } - return result; + return characters.reverse().join(''); } diff --git a/packages/runtime/src/stream-graph-coordinator.ts b/packages/runtime/src/stream-graph-coordinator.ts index 3eb3378e11..5e9fcdd3cc 100644 --- a/packages/runtime/src/stream-graph-coordinator.ts +++ b/packages/runtime/src/stream-graph-coordinator.ts @@ -154,6 +154,7 @@ interface GraphDriver { driveGeneration: number; activeDriveGeneration?: number; closed: boolean; + reconciliationReaders: number; abortController?: AbortController; task?: Promise; stopTask?: Promise; @@ -526,16 +527,25 @@ export class AgentGraphCoordinator { /** Reconcile now and surface any host-level failure to explicit callers. */ async reconcile(rootSessionId: string): Promise { await this.#assertRootSupervisor(rootSessionId); - const driver = await this.#driver(rootSessionId); - driver.lastError = undefined; - driver.paused = false; - this.#requestDrive(driver); - await this.waitForIdle(rootSessionId); - if (driver.lastError !== undefined) throw driver.lastError; - if (!driver.lastResult) { - throw new Error(`Agent graph ${driver.graphId} produced no reconciliation result`); + let driver = await this.#driver(rootSessionId); + // A lookup started before handover can return after its driver retired. + while (driver.closed && !this.#closed) driver = await this.#driver(rootSessionId); + driver.reconciliationReaders += 1; + try { + driver.lastError = undefined; + driver.paused = false; + this.#requestDrive(driver); + // An epoch handover must not redirect this caller to the next driver. + while (driver.task) await driver.task; + if (driver.lastError !== undefined) throw driver.lastError; + if (!driver.lastResult) { + throw new Error(`Agent graph ${driver.graphId} produced no reconciliation result`); + } + return driver.lastResult; + } finally { + driver.reconciliationReaders -= 1; + if (driver.closed && driver.reconciliationReaders === 0) driver.lastResult = undefined; } - return driver.lastResult; } async waitForIdle(rootSessionId: string): Promise { @@ -1424,16 +1434,31 @@ export class AgentGraphCoordinator { const latest = await this.currentGraphEpoch(rootSessionId); if (latest.graphId !== current.graphId) { selected = latest; + if (driver) void this.#retireDriver(driver); return; } if ((await this.#readSessionStateForGraph(rootSessionId, current.graphId)) !== 'terminal') { return; } selected = await this.advanceGraphEpoch(rootSessionId, current); + if (driver) void this.#retireDriver(driver); }); return selected; } + async #retireDriver(driver: GraphDriver): Promise { + // Tool closures can outlive their epoch. Fence them and let already + // admitted operations finish before releasing their complete snapshots. + // Cleanup belongs to the old epoch; its teardown I/O must not block the next. + driver.closed = true; + driver.requested = false; + await Promise.allSettled([driver.task, driver.stopTask]); + await this.#waitForClientProjectionUpdates(driver); + if (driver.reconciliationReaders === 0) driver.lastResult = undefined; + driver.runtimeFailureRunIds.clear(); + // Keep the lightweight driver for projection repair and close diagnostics. + } + async #readSessionStateForGraph( rootSessionId: string, graphId: string, @@ -1532,6 +1557,7 @@ export class AgentGraphCoordinator { stopGeneration: 0, driveGeneration: 0, closed: false, + reconciliationReaders: 0, clientProjectionDirty: false, runtimeFailureRunIds: new Set(), yieldWaiters: new Set(), @@ -1566,6 +1592,7 @@ export class AgentGraphCoordinator { } #requestDrive(driver: GraphDriver): void { + if (driver.closed || this.#closed) return; driver.requested = true; if (driver.task) return; const residency = this.#input.acquireResidency?.(driver.rootSessionId); diff --git a/packages/runtime/src/stream-graph-handoff.ts b/packages/runtime/src/stream-graph-handoff.ts index df9eb9da3f..372a8a1229 100644 --- a/packages/runtime/src/stream-graph-handoff.ts +++ b/packages/runtime/src/stream-graph-handoff.ts @@ -219,7 +219,11 @@ function truncateUtf8(text: string, maxBytes: number): string { const ellipsis = '…'; const ellipsisBytes = Buffer.byteLength(ellipsis, 'utf8'); if (maxBytes < ellipsisBytes) return ''; - const codePoints = Array.from(text); + const codePoints: string[] = []; + for (const point of text) { + if (codePoints.length >= maxBytes) break; + codePoints.push(point); + } let low = 0; let high = codePoints.length; let best = ellipsis; diff --git a/packages/runtime/src/subscription-model-fetch.ts b/packages/runtime/src/subscription-model-fetch.ts index 348ba19876..3707a4ad1e 100644 --- a/packages/runtime/src/subscription-model-fetch.ts +++ b/packages/runtime/src/subscription-model-fetch.ts @@ -194,10 +194,7 @@ async function checkedOpenAiCodexFetch( for (;;) { const response = await fetchFn(url, requestInit); if (response.ok) return response; - const detail = await response - .clone() - .text() - .catch(() => ''); + const detail = await response.text().catch(() => ''); if ( response.status === 401 && !authorizationReplayUsed && diff --git a/packages/runtime/src/system-prompt/workspace-instructions.ts b/packages/runtime/src/system-prompt/workspace-instructions.ts index 71042cabd3..e7a6f237bc 100644 --- a/packages/runtime/src/system-prompt/workspace-instructions.ts +++ b/packages/runtime/src/system-prompt/workspace-instructions.ts @@ -136,12 +136,12 @@ async function readWorkspaceInstructions( const digest = createHash('sha256').update(cleaned).digest('hex'); if (seenDigests.has(digest)) continue; seenDigests.add(digest); - const chars = Array.from(cleaned).length; + const text = truncateCodepoints(cleaned, MAX_WORKSPACE_INSTRUCTION_FILE_CHARS); out.push({ file, scope, - text: truncateCodepoints(cleaned, MAX_WORKSPACE_INSTRUCTION_FILE_CHARS), - truncated: chars > MAX_WORKSPACE_INSTRUCTION_FILE_CHARS, + text, + truncated: text.length < cleaned.length, }); } catch { continue; @@ -155,7 +155,11 @@ function cleanPromptText(text: string): string { } function truncateCodepoints(text: string, max: number): string { - const chars = Array.from(text); - if (chars.length <= max) return text; - return chars.slice(0, Math.max(0, max)).join(''); + if (text.length <= max) return text; + const chars: string[] = []; + for (const char of text) { + if (chars.length >= max) return chars.join(''); + chars.push(char); + } + return text; } diff --git a/packages/runtime/src/tavily-search.ts b/packages/runtime/src/tavily-search.ts index b70f3663d4..ec80b43175 100644 --- a/packages/runtime/src/tavily-search.ts +++ b/packages/runtime/src/tavily-search.ts @@ -153,9 +153,12 @@ function mapTavilyRows(raw: unknown, limit: number): WebSearchResultRow[] | null if (!location) continue; rows.push({ provider: 'tavily', - title: safeString(rawRow.title, location.url).slice(0, TAVILY_RESULT_TITLE_MAX_CHARS), + title: truncateResultText( + safeString(rawRow.title, location.url), + TAVILY_RESULT_TITLE_MAX_CHARS, + ), url: location.url, - snippet: safeString(rawRow.content).slice(0, TAVILY_RESULT_SNIPPET_MAX_CHARS), + snippet: truncateResultText(safeString(rawRow.content), TAVILY_RESULT_SNIPPET_MAX_CHARS), source: location.source, }); if (rows.length >= limit) break; @@ -199,6 +202,12 @@ function safeString(value: unknown, fallback = ''): string { return typeof value === 'string' ? value : fallback; } +function truncateResultText(value: string, maxChars: number): string { + if (value.length <= maxChars) return value; + // Detach bounded results from large provider strings, preserving even unpaired UTF-16 surrogates. + return Buffer.from(value.slice(0, maxChars), 'utf16le').toString('utf16le'); +} + function normalizeResultLocation( value: unknown, ): { readonly url: string; readonly source: string } | null { diff --git a/packages/runtime/src/text-line-window.ts b/packages/runtime/src/text-line-window.ts new file mode 100644 index 0000000000..845dbc7d54 --- /dev/null +++ b/packages/runtime/src/text-line-window.ts @@ -0,0 +1,53 @@ +/* + * 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. + */ + +// Match split('\n').slice(offset, end).join('\n'), without creating entries +// for lines outside the requested window. Empty and trailing lines count too. +// A missing or zero limit is unbounded: end is the total line count. +export function readTextLineWindow(content: string, offset?: number, limit?: number): string { + if (offset === undefined && limit === undefined) return content; + let lineCount = 1; + for ( + let cursor = content.indexOf('\n'); + cursor !== -1; + cursor = content.indexOf('\n', cursor + 1) + ) { + lineCount++; + } + const start = offset ?? 0; + const end = limit ? start + limit : lineCount; + const from = sliceIndex(start, lineCount); + const to = sliceIndex(end, lineCount); + if (from >= to) return ''; + + const selected: string[] = []; + let cursor = 0; + for (let line = 0; line < to; line++) { + const newline = content.indexOf('\n', cursor); + const lineEnd = newline === -1 ? content.length : newline; + if (line >= from) selected.push(content.slice(cursor, lineEnd)); + cursor = lineEnd + 1; + } + return selected.join('\n'); +} + +function sliceIndex(value: number, length: number): number { + const integer = Math.trunc(value) || 0; + return integer < 0 ? Math.max(length + integer, 0) : Math.min(integer, length); +} diff --git a/packages/runtime/src/tool-output.ts b/packages/runtime/src/tool-output.ts index 7d01c08884..decb86ef97 100644 --- a/packages/runtime/src/tool-output.ts +++ b/packages/runtime/src/tool-output.ts @@ -76,8 +76,17 @@ function utf8Len(text: string): number { * multi-byte sequence at the boundary with U+FFFD, which we strip. */ function sliceLineByBytes(line: string, maxBytes: number, keep: 'head' | 'tail'): string { - const buf = Buffer.from(line, 'utf8'); - if (buf.length <= maxBytes) return line; + if (utf8Len(line) <= maxBytes) return line; + // One UTF-16 unit needs at least one UTF-8 byte. The extra unit keeps a + // surrogate pair crossing the window boundary outside the retained bytes. + // Preserve Buffer.subarray's original behavior for unusual numeric budgets. + const window = + Number.isInteger(maxBytes) && maxBytes >= 0 + ? keep === 'head' + ? line.slice(0, maxBytes + 1) + : line.slice(-(maxBytes + 1)) + : line; + const buf = Buffer.from(window, 'utf8'); const slice = keep === 'head' ? buf.subarray(0, maxBytes) : buf.subarray(buf.length - maxBytes); const decoded = slice.toString('utf8'); return keep === 'head' ? decoded.replace(/�+$/, '') : decoded.replace(/^�+/, ''); @@ -101,8 +110,11 @@ export function truncateToolOutput( // A single trailing newline terminates the last line; it is not an extra // empty line, so it must not count against the line budget. const body = text.endsWith('\n') ? text.slice(0, -1) : text; - const lines = body.split('\n'); - if (lines.length <= maxLines && totalBytes <= maxBytes) { + let lineCount = 1; + for (let index = body.indexOf('\n'); index !== -1; index = body.indexOf('\n', index + 1)) { + lineCount++; + } + if (lineCount <= maxLines && totalBytes <= maxBytes) { return { content: text, truncated: false, removed: 0, unit: 'lines' }; } @@ -111,24 +123,33 @@ export function truncateToolOutput( let hitBytes = false; if (direction === 'head') { - for (let i = 0; i < lines.length && i < maxLines; i++) { - const size = utf8Len(lines[i]) + (i > 0 ? 1 : 0); + let start = 0; + for (let i = 0; i < lineCount && i < maxLines; i++) { + const newline = body.indexOf('\n', start); + const end = newline === -1 ? body.length : newline; + const line = body.slice(start, end); + const size = utf8Len(line) + (i > 0 ? 1 : 0); if (bytes + size > maxBytes) { hitBytes = true; break; } - out.push(lines[i]); + out.push(line); bytes += size; + start = end + 1; } } else { - for (let i = lines.length - 1; i >= 0 && out.length < maxLines; i--) { - const size = utf8Len(lines[i]) + (out.length > 0 ? 1 : 0); + let end = body.length; + for (let i = lineCount - 1; i >= 0 && out.length < maxLines; i--) { + const start = end > 0 ? body.lastIndexOf('\n', end - 1) + 1 : 0; + const line = body.slice(start, end); + const size = utf8Len(line) + (out.length > 0 ? 1 : 0); if (bytes + size > maxBytes) { hitBytes = true; break; } - out.unshift(lines[i]); + out.unshift(line); bytes += size; + end = start - 1; } } @@ -137,7 +158,11 @@ export function truncateToolOutput( // JSON/stack trace), keep a byte-safe slice of that line. let preview: string; if (out.length === 0) { - const line = direction === 'head' ? lines[0] : lines[lines.length - 1]; + const firstNewline = body.indexOf('\n'); + const line = + direction === 'head' + ? body.slice(0, firstNewline === -1 ? body.length : firstNewline) + : body.slice(body.lastIndexOf('\n') + 1); preview = sliceLineByBytes(line, maxBytes, direction); bytes = utf8Len(preview); hitBytes = true; @@ -145,7 +170,7 @@ export function truncateToolOutput( preview = out.join('\n'); } - const removed = hitBytes ? Math.max(0, totalBytes - bytes) : lines.length - out.length; + const removed = hitBytes ? Math.max(0, totalBytes - bytes) : lineCount - out.length; if (removed <= 0) { // Nothing was actually dropped — e.g. content fits but a lone trailing // newline pushed totalBytes one over the byte budget. Don't emit a diff --git a/packages/runtime/src/unified-diff.ts b/packages/runtime/src/unified-diff.ts index 3354996cc3..e9120d8e5b 100644 --- a/packages/runtime/src/unified-diff.ts +++ b/packages/runtime/src/unified-diff.ts @@ -70,26 +70,29 @@ export function createEditUnifiedDiff( match: { startLine: number; endLine: number }, ): string | undefined { if (oldContent.includes('\0') || newContent.includes('\0')) return undefined; - const oldLines = splitLines(oldContent); - const newLines = splitLines(newContent); + const oldLineCount = countLines(oldContent); + const newLineCount = countLines(newContent); const startIndex = match.startLine - 1; const oldEndIndex = match.endLine - 1; - if (startIndex < 0 || oldEndIndex < startIndex || oldEndIndex >= oldLines.length) { + if (startIndex < 0 || oldEndIndex < startIndex || oldEndIndex >= oldLineCount) { return undefined; } const windowStart = Math.max(0, startIndex - CONTEXT_LINES); const oldAfterStart = oldEndIndex + 1; - const newAfterStart = oldAfterStart + newLines.length - oldLines.length; - const oldWindow = oldLines.slice( + const newAfterStart = oldAfterStart + newLineCount - oldLineCount; + const oldWindow = readDiffWindow( + oldContent, windowStart, - Math.min(oldLines.length, oldAfterStart + CONTEXT_LINES), + Math.min(oldLineCount, oldAfterStart + CONTEXT_LINES), ); - const newWindow = newLines.slice( + if (!oldWindow) return undefined; + const newWindow = readDiffWindow( + newContent, windowStart, - Math.min(newLines.length, Math.max(windowStart, newAfterStart) + CONTEXT_LINES), + Math.min(newLineCount, Math.max(windowStart, newAfterStart) + CONTEXT_LINES), ); - if (isDiffWindowTooLarge(oldWindow) || isDiffWindowTooLarge(newWindow)) return undefined; + if (!newWindow) return undefined; const ops = diffLines(oldWindow, newWindow); if (ops.every((op) => op.kind === 'keep')) return undefined; const diff = [ @@ -104,10 +107,6 @@ function isUndiffable(content: string): boolean { return content.includes('\0') || Buffer.byteLength(content, 'utf8') > MAX_DIFF_SOURCE_BYTES; } -function isDiffWindowTooLarge(lines: string[]): boolean { - return lines.length > MAX_DIFF_SOURCE_LINES || isUndiffable(lines.join('\n')); -} - function splitLines(content: string): string[] { const lines = content.split('\n'); // A trailing newline terminates the last line rather than starting an empty @@ -116,6 +115,37 @@ function splitLines(content: string): string[] { return lines; } +function countLines(content: string): number { + let count = content.length > 0 && !content.endsWith('\n') ? 1 : 0; + for (let index = content.indexOf('\n'); index !== -1; index = content.indexOf('\n', index + 1)) { + count += 1; + } + return count; +} + +/** Collect only the requested lines, failing before an oversized window is materialized. */ +function readDiffWindow(content: string, startLine: number, endLine: number): string[] | undefined { + // Preserve Array.slice's fractional/NaN index normalization. + const from = Math.trunc(startLine) || 0; + const until = Math.trunc(endLine) || 0; + const lines: string[] = []; + let bytes = 0; + let offset = 0; + for (let lineIndex = 0; offset < content.length && lineIndex < until; lineIndex += 1) { + const newline = content.indexOf('\n', offset); + const end = newline === -1 ? content.length : newline; + if (lineIndex >= from) { + if (lines.length === MAX_DIFF_SOURCE_LINES) return undefined; + const line = content.slice(offset, end); + bytes += Buffer.byteLength(line, 'utf8') + (lines.length > 0 ? 1 : 0); + if (bytes > MAX_DIFF_SOURCE_BYTES) return undefined; + lines.push(line); + } + offset = end + 1; + } + return lines; +} + type DiffOp = { kind: 'keep' | 'del' | 'add'; oldIndex: number; newIndex: number; line: string }; /** Line-level edit script via LCS dynamic programming, bounded by the caps above. */ diff --git a/packages/runtime/src/web-fetch-tool.ts b/packages/runtime/src/web-fetch-tool.ts index 18abc15bcf..8268e4c816 100644 --- a/packages/runtime/src/web-fetch-tool.ts +++ b/packages/runtime/src/web-fetch-tool.ts @@ -74,8 +74,10 @@ export function routeWebFetchTools( function truncateWebFetchOutput(content: string): string { if (Buffer.byteLength(content, 'utf8') <= WEB_FETCH_MODEL_OUTPUT_MAX_BYTES) return content; const markerBytes = Buffer.byteLength(WEB_FETCH_TRUNCATION_MARKER, 'utf8'); - const kept = Buffer.from(content, 'utf8') - .subarray(0, WEB_FETCH_MODEL_OUTPUT_MAX_BYTES - markerBytes) + const contentBytes = WEB_FETCH_MODEL_OUTPUT_MAX_BYTES - markerBytes; + // The extra UTF-16 unit keeps a split surrogate outside the retained bytes. + const kept = Buffer.from(content.slice(0, contentBytes + 1), 'utf8') + .subarray(0, contentBytes) .toString('utf8') .replace(/�+$/, ''); return kept + WEB_FETCH_TRUNCATION_MARKER; diff --git a/packages/runtime/src/workspace-executor.ts b/packages/runtime/src/workspace-executor.ts index f6fb22c7c9..65f1940a27 100644 --- a/packages/runtime/src/workspace-executor.ts +++ b/packages/runtime/src/workspace-executor.ts @@ -40,6 +40,7 @@ import type { ChildFdInput } from './child-fd-input.js'; import type { ShellPlan } from './shell-detect.js'; import { isSupportedImagePath, readWorkspaceImage } from './image-file.js'; import type { ImageMimeType } from './image-file.js'; +import { readTextLineWindow } from './text-line-window.js'; const execAsync = promisify(exec); const execFileAsync = promisify(execFile); @@ -324,11 +325,7 @@ export class LocalWorkspaceExecutor implements WorkspaceExecutor { return await readWorkspaceImage(input.path); } const content = await fs.readFile(input.path, 'utf8'); - if (input.offset === undefined && input.limit === undefined) return { content }; - const lines = content.split('\n'); - const start = input.offset ?? 0; - const end = input.limit ? start + input.limit : lines.length; - return { content: lines.slice(start, end).join('\n') }; + return { content: readTextLineWindow(content, input.offset, input.limit) }; } async writeFile(input: WorkspaceWriteFileInput): Promise { diff --git a/packages/storage/src/session-message-projection.ts b/packages/storage/src/session-message-projection.ts index 8188f50b8b..2bc9b0486e 100644 --- a/packages/storage/src/session-message-projection.ts +++ b/packages/storage/src/session-message-projection.ts @@ -84,9 +84,14 @@ function normalizePreviewText(text: string): string { } function truncatePreview(text: string, maxLength = 96): string { - const chars = Array.from(text); - if (chars.length <= maxLength) return text; - return `${chars.slice(0, maxLength - 1).join('')}…`; + const chars: string[] = []; + for (const point of text) { + if (chars.length === maxLength) { + return `${chars.slice(0, maxLength - 1).join('')}…`; + } + chars.push(point); + } + return text; } /** One Turn's summary, folded message by message in transcript order. */ diff --git a/packages/storage/src/stable-storage.ts b/packages/storage/src/stable-storage.ts index 50d515bf83..95356b80ae 100644 --- a/packages/storage/src/stable-storage.ts +++ b/packages/storage/src/stable-storage.ts @@ -68,7 +68,8 @@ export async function readStableBoundedFile( } try { const initial = await stableFileSnapshot(handle, input, deps); - const bytes = Buffer.allocUnsafe(input.maxBytes + 1); + // One extra byte detects growth even if the file shrinks before the final stat. + const bytes = Buffer.allocUnsafe(Number(initial.size) + 1); let offset = 0; while (offset < bytes.length) { const result = await handle.read(bytes, offset, bytes.length - offset, offset); diff --git a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx index cb96e0f2bc..fb734db38a 100644 --- a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx +++ b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx @@ -217,6 +217,9 @@ test('preserves a model-authored single newline in plain reasoning', async () => }, ])); + const header = container.querySelector('[data-slot="activity-card-header"]'); + assert.ok(header); + await act(() => { header.dispatchEvent(new window.Event('click', { bubbles: true })); }); const body = container.querySelector('.maka-chat-reasoning-content'); assert.ok(body); assert.match(body.textContent ?? '', /First observation\nSecond observation/); diff --git a/packages/ui/src/astryx-chat-reasoning.tsx b/packages/ui/src/astryx-chat-reasoning.tsx index 515e959ab1..c87cb20497 100644 --- a/packages/ui/src/astryx-chat-reasoning.tsx +++ b/packages/ui/src/astryx-chat-reasoning.tsx @@ -8,13 +8,15 @@ * (commit c9fe437). The lab package is canary-only and declares an exact * canary core peer even though this release is the stable 0.1.9 source. Maka * therefore uses Astryx's supported swizzle/eject seam instead of forcing an - * invalid dependency tree. DOM, state, keyboard behavior, icons, and compiled - * StyleX atoms below are the official component; only the build-time StyleX - * call has already been compiled, matching the published package output. + * invalid dependency tree. The build-time StyleX call has already been + * compiled, matching the published package output. Maka deliberately defers + * body children until the first expansion, then keeps them mounted on close; + * never-opened bodies also omit their descendants from the accessibility tree. + * The wrapper DOM, header, and keyboard behavior remain the official component. * * Product dialect lives in chat-message.css (cursor default, hover wash, - * chevron size). This file keeps the ejected lab DOM/behavior, except that - * the chevron is Astryx `Icon` rather than the lab's own 12-viewBox SVG: at + * chevron size). In addition to the first-open body rendering change, the + * chevron is Astryx `Icon` rather than the lab's own 12-viewBox SVG: at * the 10x10 chat-message.css forces, that glyph drew 1.25px of stroke beside * the tool rows' 0.73px. One registry, one chevron. */ @@ -60,6 +62,10 @@ export function ChatReasoning(props: ChatReasoningProps) { const [internalExpanded, setInternalExpanded] = useState(defaultIsExpanded); const isControlled = controlledExpanded !== undefined; const isExpanded = isControlled ? controlledExpanded : internalExpanded; + const [hasExpanded, setHasExpanded] = useState(isExpanded); + // Track controlled expansion too, before rendering children. Once opened, + // retain their state and streaming updates through the existing CSS collapse. + if (isExpanded && !hasExpanded) setHasExpanded(true); const toggle = useCallback(() => { const next = !isExpanded; if (!isControlled) setInternalExpanded(next); @@ -139,7 +145,9 @@ export function ChatReasoning(props: ChatReasoningProps) { collapses every newline in the thinking text. Maka restores the pre-wrap reading contract on this class — see `.maka-chat-reasoning-content` in styles.css. */} -
{children}
+
+ {isExpanded || hasExpanded ? children : null} +
diff --git a/packages/ui/src/chat-input-behavior.ts b/packages/ui/src/chat-input-behavior.ts index 5ef6fd2793..45c2ac2c6f 100644 --- a/packages/ui/src/chat-input-behavior.ts +++ b/packages/ui/src/chat-input-behavior.ts @@ -43,7 +43,9 @@ export function isChatInputComposing( * character no tool splitting a path on ASCII whitespace will match. */ export function composerWireText(draft: string): string { - return draft.replace(/ /g, ' ').trim(); + const wire = draft.replace(/ /g, ' ').trim(); + // Sent text and recall history outlive the editor's original whitespace. + return wire.length < draft.length ? structuredClone(wire) : wire; } /** diff --git a/packages/ui/src/composer-helpers.ts b/packages/ui/src/composer-helpers.ts index 96b47935e3..e48e82d203 100644 --- a/packages/ui/src/composer-helpers.ts +++ b/packages/ui/src/composer-helpers.ts @@ -111,8 +111,9 @@ export function rememberComposerDraft(store: Map, key: string | return; } + // Detach the tail from the oversized backing while preserving every UTF-16 code unit. const bounded = value.length > COMPOSER_DRAFT_MAX_CHARS - ? value.slice(value.length - COMPOSER_DRAFT_MAX_CHARS) + ? structuredClone(value.slice(value.length - COMPOSER_DRAFT_MAX_CHARS)) : value; store.delete(key); store.set(key, bounded); diff --git a/packages/ui/src/stream-delta.ts b/packages/ui/src/stream-delta.ts index 9406f397b1..31d0dd5ee7 100644 --- a/packages/ui/src/stream-delta.ts +++ b/packages/ui/src/stream-delta.ts @@ -56,6 +56,7 @@ import { redactSecrets } from './redact.js'; import { appendStreamingDisplayRedaction, + copyStreamingDisplayRedactionState, createStreamingDisplayRedactionState, truncateStreamingDisplayAppend, truncateStreamingDisplayTail, @@ -191,6 +192,15 @@ export function applyStreamDelta( } } + // Reconnect seeds can contain the full stream. Copy only the final bounded + // display and recovery state so their slices cannot keep that payload alive. + if (rawDelta.length > maxDeltaChars) { + result = structuredClone(result); + if (!(recovery === 'head' && totalTruncated)) { + capped = { ...capped, state: copyStreamingDisplayRedactionState(capped.state) }; + } + } + return { text: result, redacted: perDeltaRedactionHappened || appended.redacted, @@ -231,7 +241,8 @@ export function applyStreamComplete( } return { - text: result, + // Detach the bounded display from a slice's potentially much larger backing string. + text: totalTruncated ? structuredClone(result) : result, redacted: redacted !== rawText, truncated: totalTruncated, }; diff --git a/packages/ui/src/streaming-display-redaction.ts b/packages/ui/src/streaming-display-redaction.ts index 392d16e9f6..9c5cf65317 100644 --- a/packages/ui/src/streaming-display-redaction.ts +++ b/packages/ui/src/streaming-display-redaction.ts @@ -211,6 +211,26 @@ function reversibleInvalidated(delta: string, continuationChars: RegExp): boolea return false; } +/** Detach only the carried strings after an oversized append has been bounded. */ +export function copyStreamingDisplayRedactionState( + state: StreamingDisplayRedactionState, +): StreamingDisplayRedactionState { + const current = PRIVATE_STATE.get(state); + if (current === undefined) return state; + const overflow = current.overflow === undefined ? undefined : { + ...current.overflow, + sourceHead: structuredClone(current.overflow.sourceHead), + sourceTail: structuredClone(current.overflow.sourceTail), + compactedToken: structuredClone(current.overflow.compactedToken), + }; + return stateFor( + structuredClone(current.settledText), + structuredClone(current.pendingRaw), + current.continuationTerminator, + { ...configFor(current), ...(overflow === undefined ? {} : { overflow }) }, + ); +} + /** * Apply the existing per-delta tail cap to an already-safe append. The mutable * suffix begins at the first character whose whole-prefix redaction changed; diff --git a/packages/ui/src/use-composer-attachments.ts b/packages/ui/src/use-composer-attachments.ts index e5110fb283..087401d73c 100644 --- a/packages/ui/src/use-composer-attachments.ts +++ b/packages/ui/src/use-composer-attachments.ts @@ -74,9 +74,17 @@ type ComposerPendingState = { }; class ComposerAttachmentLifecycle { + mounted = true; stagedKeys = new Set(); + readonly previewUrls = new Map(); readonly #shownOwners = new Set(); + releasePreview(stagingKey: string): void { + const url = this.previewUrls.get(stagingKey); + if (url) URL.revokeObjectURL(url); + this.previewUrls.delete(stagingKey); + } + claimImageNotice(ownerKey: string): boolean { if (this.#shownOwners.has(ownerKey)) return false; this.#shownOwners.add(ownerKey); @@ -167,10 +175,6 @@ async function probeImageUrl(url: string): Promise { } } -function releasePreviewUrl(url: string | undefined): void { - if (url?.startsWith('blob:')) URL.revokeObjectURL(url); -} - export interface ComposerAttachmentCopy { attachmentFailedTitle: string; tryAgain: string; @@ -207,7 +211,20 @@ export function useComposerAttachments(options: { const [previewByStagingKey, setPreviewByStagingKey] = useState>({}); // Live mirror of every staged item's key, for async preview arrivals to // check before writing: state snapshots inside a .then are stale by design. - const lifecycleRef = useRef(new ComposerAttachmentLifecycle()); + const [lifecycle] = useState(() => new ComposerAttachmentLifecycle()); + useEffect(() => { + lifecycle.mounted = true; + return () => { + lifecycle.mounted = false; + // StrictMode replays setup immediately. Keep live previews in that case, + // but release even URLs still awaiting image.decode() on real unmount. + queueMicrotask(() => { + if (lifecycle.mounted) return; + lifecycle.stagedKeys.clear(); + for (const key of lifecycle.previewUrls.keys()) lifecycle.releasePreview(key); + }); + }; + }, [lifecycle]); function updateAttachments( update: (current: PendingByKey) => PendingByKey, ): void { @@ -256,23 +273,25 @@ export function useComposerAttachments(options: { for (const items of Object.values(pendingByKey)) { for (const item of items) liveKeys.add(item.stagingKey); } - lifecycleRef.current.stagedKeys = liveKeys; + lifecycle.stagedKeys = liveKeys; + for (const key of lifecycle.previewUrls.keys()) { + if (!liveKeys.has(key)) lifecycle.releasePreview(key); + } setPreviewByStagingKey((current) => { const deadKeys = Object.keys(current).filter((key) => !liveKeys.has(key)); if (deadKeys.length === 0) return current; const next = { ...current }; for (const key of deadKeys) { - releasePreviewUrl(next[key]); delete next[key]; } return next; }); - }, [pendingByKey]); + }, [lifecycle, pendingByKey]); function commitPreview(stagingKey: string, url: string): void { - if (!lifecycleRef.current.stagedKeys.has(stagingKey)) { - // The item was removed or sent while the preview was in flight. - releasePreviewUrl(url); + if (!lifecycle.mounted || !lifecycle.stagedKeys.has(stagingKey)) { + // The item was removed, sent, or its composer unmounted during decoding. + lifecycle.releasePreview(stagingKey); return; } setPreviewByStagingKey((current) => ({ ...current, [stagingKey]: url })); @@ -286,7 +305,7 @@ export function useComposerAttachments(options: { const { copy: liveCopy, imageNotice: notice } = liveOptionsRef.current; if (!notice) return; if (notice.supportsVision() !== false) return; - if (!lifecycleRef.current.claimImageNotice(ownerKey)) return; + if (!lifecycle.claimImageNotice(ownerKey)) return; notice.notify( liveCopy.imageAttachmentNotDirectTitle, liveCopy.imageAttachmentNotDirectDescription, @@ -300,17 +319,21 @@ export function useComposerAttachments(options: { * shows named file cards until each thumbnail lands. */ async function loadPreviewsSequentially(staged: readonly PendingAttachment[]): Promise { for (const item of staged) { + if (!lifecycle.mounted) return; if (item.kind !== 'image') continue; - if (!lifecycleRef.current.stagedKeys.has(item.stagingKey)) continue; + if (!lifecycle.stagedKeys.has(item.stagingKey)) continue; try { if (item.source.type === 'file') { const url = URL.createObjectURL(item.source.file); + lifecycle.previewUrls.set(item.stagingKey, url); if (await probeImageUrl(url)) commitPreview(item.stagingKey, url); - else releasePreviewUrl(url); + else lifecycle.releasePreview(item.stagingKey); continue; } if (item.source.type === 'retained') continue; const preview = await options.service.previewApproval(item.source.approvalId); + if (!lifecycle.mounted) return; + if (!lifecycle.stagedKeys.has(item.stagingKey)) continue; if (!preview.ok) continue; const url = `data:${preview.mimeType};base64,${preview.base64}`; if (await probeImageUrl(url)) commitPreview(item.stagingKey, url); @@ -321,8 +344,10 @@ export function useComposerAttachments(options: { } async function pickAttachments(): Promise { + if (!lifecycle.mounted) return; try { const result = await options.service.pickFiles(); + if (!lifecycle.mounted) return; if (!result.ok) return; // Resolved after the dialog closes, never captured before it opens: the // surface can change while a native dialog is up, and files the user just @@ -331,10 +356,11 @@ export function useComposerAttachments(options: { const ownerKey = liveOptionsRef.current.draftKey; const staged = result.files.map(approvalToPending); updateAttachments((map) => appendPending(map, ownerKey, staged)); - for (const item of staged) lifecycleRef.current.stagedKeys.add(item.stagingKey); + for (const item of staged) lifecycle.stagedKeys.add(item.stagingKey); notifyStagedImages(ownerKey, staged); void loadPreviewsSequentially(staged); } catch (error) { + if (!lifecycle.mounted) return; options.toastApi.error( copy.attachmentFailedTitle, options.formatError(error, copy.tryAgain), @@ -343,11 +369,13 @@ export function useComposerAttachments(options: { } async function pickDirectory(): Promise { + if (!lifecycle.mounted) return; const owner = liveOptionsRef.current.directoryOwner; if (!owner.directoryHostId || !owner.service.pickDirectory) return; const ownerKey = `${owner.draftKey}:${owner.directoryHostId}`; try { const result = await owner.service.pickDirectory(); + if (!lifecycle.mounted) return; if (!result.ok) return; const current = liveOptionsRef.current.directoryOwner; if ( @@ -368,6 +396,7 @@ export function useComposerAttachments(options: { return { ...all, [ownerKey]: [...previous, result.reference] }; }); } catch (error) { + if (!lifecycle.mounted) return; owner.toastApi.error( copy.attachmentFailedTitle, options.formatError(error, copy.tryAgain), @@ -376,7 +405,7 @@ export function useComposerAttachments(options: { } async function attachFilePaths(files: File[]): Promise { - if (files.length === 0) return; + if (!lifecycle.mounted || files.length === 0) return; // Bind the owner AFTER the sniff reads resolve, never before: fileToPending // became async to read each file's leading bytes, so the surface can change // during that I/O (a network volume or spun-down drive makes it seconds). @@ -384,18 +413,19 @@ export function useComposerAttachments(options: { // they have since left, where they would be invisible but still sendable. // Same reasoning as pickAttachments above. const staged = await Promise.all(files.map(fileToPending)); + if (!lifecycle.mounted) return; const ownerKey = liveOptionsRef.current.draftKey; updateAttachments((map) => appendPending(map, ownerKey, staged)); - for (const item of staged) lifecycleRef.current.stagedKeys.add(item.stagingKey); + for (const item of staged) lifecycle.stagedKeys.add(item.stagingKey); notifyStagedImages(ownerKey, staged); void loadPreviewsSequentially(staged); } function restoreAttachments(ownerKey: string, attachments: readonly AttachmentRef[]): void { - if (attachments.length === 0) return; + if (!lifecycle.mounted || attachments.length === 0) return; const staged = attachments.map(retainedToPending); updateAttachments((map) => appendPending(map, ownerKey, staged)); - for (const item of staged) lifecycleRef.current.stagedKeys.add(item.stagingKey); + for (const item of staged) lifecycle.stagedKeys.add(item.stagingKey); } function removeAttachment(index: number): void { @@ -467,6 +497,6 @@ export function useComposerAttachments(options: { clearSubmittedContext, clearSubmittedAttachments, clearAllAttachments, - imageNoticeLifecycle: lifecycleRef.current, + imageNoticeLifecycle: lifecycle, }; } diff --git a/packages/ui/src/use-composer-history.ts b/packages/ui/src/use-composer-history.ts index dd232b22f4..d429490ce3 100644 --- a/packages/ui/src/use-composer-history.ts +++ b/packages/ui/src/use-composer-history.ts @@ -83,7 +83,12 @@ export function useComposerHistory(input: { /** Persist the applied value under the active draft key. */ saveCurrentDraft(value?: string): void; }): ComposerHistoryApi { - const promptHistoryRef = useRef({ entries: readGlobalInputHistory() ?? [], index: -1, savedDraft: '' }); + const promptHistoryRef = useRef(null); + // Capture the mount snapshot without reading/parsing a discarded initializer + // on every text update. The ref stays initialized for callbacks below. + if (promptHistoryRef.current === null) { + promptHistoryRef.current = { entries: readGlobalInputHistory() ?? [], index: -1, savedDraft: '' }; + } // The subscription is registered once, so anything it calls must be reached // through the latest render rather than captured from the first. Today the // pieces that matter happen to be ref-backed — the text port is created once @@ -107,7 +112,7 @@ export function useComposerHistory(input: { // arrow key happened to reconcile it. The pure state machine already knows // all of this — including when the draft is owed back. const { state, restoreDraft } = reconcileHistorySync( - promptHistoryRef.current, + promptHistoryRef.current!, readGlobalInputHistory(), ); promptHistoryRef.current = state; @@ -116,7 +121,7 @@ export function useComposerHistory(input: { function resetNavigation() { promptHistoryRef.current = { - entries: promptHistoryRef.current.entries, + entries: promptHistoryRef.current!.entries, index: -1, savedDraft: '', }; @@ -127,7 +132,7 @@ export function useComposerHistory(input: { // survives page reloads and is shared across all input surfaces. saveGlobalInputHistoryEntry(text); promptHistoryRef.current = { - entries: rememberComposerHistoryEntry(promptHistoryRef.current.entries, text), + entries: rememberComposerHistoryEntry(promptHistoryRef.current!.entries, text), index: -1, savedDraft: '', }; @@ -143,7 +148,7 @@ export function useComposerHistory(input: { const plainArrow = !event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey; if (!plainArrow && !explicit) return false; const current = input.text.getValue(); - const isNavigatingHistory = promptHistoryRef.current.index >= 0; + const isNavigatingHistory = promptHistoryRef.current!.index >= 0; const canStartHistory = !current.trim(); if (!(explicit || isNavigatingHistory || canStartHistory)) return false; // Re-read global history from localStorage on every navigation so @@ -153,7 +158,7 @@ export function useComposerHistory(input: { // reconcileHistorySync restores the saved draft if a clear happened // mid-navigation (so the user doesn't lose what they were typing). const synced = readGlobalInputHistory(); - const { state, restoreDraft } = reconcileHistorySync(promptHistoryRef.current, synced); + const { state, restoreDraft } = reconcileHistorySync(promptHistoryRef.current!, synced); promptHistoryRef.current = state; if (restoreDraft) { applyValue(state.savedDraft); diff --git a/patches/@astryxdesign+core+0.5.2.patch b/patches/@astryxdesign+core+0.5.2.patch index 56906b13ba..51d956e14d 100644 --- a/patches/@astryxdesign+core+0.5.2.patch +++ b/patches/@astryxdesign+core+0.5.2.patch @@ -203,6 +203,21 @@ index e1d2ebd..1f8337e 100644 if (!state.isActive || state.activeTrigger !== trigger) { triggerStartRef.current = triggerStart; setState(prev => ({ +diff --git a/node_modules/@astryxdesign/core/dist/CodeBlock/tokenizer.js b/node_modules/@astryxdesign/core/dist/CodeBlock/tokenizer.js +index eac4c4b..a21c0c4 100644 +--- a/node_modules/@astryxdesign/core/dist/CodeBlock/tokenizer.js ++++ b/node_modules/@astryxdesign/core/dist/CodeBlock/tokenizer.js +@@ -39,7 +39,9 @@ function buildLanguage(lang) { + return cached; + } + const def = buildLanguageUncached(lang); +- langCache.set(lang, def); ++ if (def !== null) { ++ langCache.set(lang, def); ++ } + return def; + } + function buildLanguageUncached(lang) { diff --git a/node_modules/@astryxdesign/core/dist/DropdownMenu/DropdownMenuItem.d.ts b/node_modules/@astryxdesign/core/dist/DropdownMenu/DropdownMenuItem.d.ts index 82ae62c..a97eff5 100644 --- a/node_modules/@astryxdesign/core/dist/DropdownMenu/DropdownMenuItem.d.ts diff --git a/patches/@earendil-works+pi-tui+0.84.4.patch b/patches/@earendil-works+pi-tui+0.84.4.patch new file mode 100644 index 0000000000..4c687196b2 --- /dev/null +++ b/patches/@earendil-works+pi-tui+0.84.4.patch @@ -0,0 +1,45 @@ +diff --git a/node_modules/@earendil-works/pi-tui/dist/components/editor.js b/node_modules/@earendil-works/pi-tui/dist/components/editor.js +index 4786b46..25ed0a9 100644 +--- a/node_modules/@earendil-works/pi-tui/dist/components/editor.js ++++ b/node_modules/@earendil-works/pi-tui/dist/components/editor.js +@@ -2,7 +2,6 @@ import { getKeybindings } from "../keybindings.js"; + import { decodePrintableKey, matchesKey } from "../keys.js"; + import { KillRing } from "../kill-ring.js"; + import { CURSOR_MARKER } from "../tui.js"; +-import { UndoStack } from "../undo-stack.js"; + import { cjkBreakRegex, getGraphemeSegmenter, getWordSegmenter, isWhitespaceChar, sliceByColumn, visibleWidth, } from "../utils.js"; + import { findWordBackward, findWordForward } from "../word-navigation.js"; + import { SelectList } from "./select-list.js"; +@@ -243,7 +242,7 @@ export class Editor { + // to. + snappedFromCursorCol = null; + // Undo support +- undoStack = new UndoStack(); ++ undoStack = []; + onSubmit; + onChange; + disableSubmit = false; +@@ -1073,7 +1072,7 @@ export class Editor { + this.pasteCounter = 0; + this.exitHistoryBrowsing(); + this.scrollOffset = 0; +- this.undoStack.clear(); ++ this.undoStack.length = 0; + this.lastAction = null; + if (this.onChange) + this.onChange(""); +@@ -1700,7 +1699,13 @@ export class Editor { + } + } + pushUndoSnapshot() { +- this.undoStack.push({ state: this.state, pastes: this.pastes, pasteCounter: this.pasteCounter }); ++ // Detach mutable containers, but share immutable line and paste strings. ++ // structuredClone copies every large paste again for each typed word. ++ this.undoStack.push({ ++ state: { ...this.state, lines: [...this.state.lines] }, ++ pastes: new Map(this.pastes), ++ pasteCounter: this.pasteCounter, ++ }); + } + undo() { + this.exitHistoryBrowsing(); diff --git a/patches/@modelcontextprotocol+client+2.0.0.patch b/patches/@modelcontextprotocol+client+2.0.0.patch new file mode 100644 index 0000000000..21ce543ca0 --- /dev/null +++ b/patches/@modelcontextprotocol+client+2.0.0.patch @@ -0,0 +1,132 @@ +diff --git a/node_modules/@modelcontextprotocol/client/dist/src-D_zzAWoS.mjs b/node_modules/@modelcontextprotocol/client/dist/src-D_zzAWoS.mjs +index 6ac3d53..641f9ae 100644 +--- a/node_modules/@modelcontextprotocol/client/dist/src-D_zzAWoS.mjs ++++ b/node_modules/@modelcontextprotocol/client/dist/src-D_zzAWoS.mjs +@@ -5552,6 +5552,22 @@ function requestStateAccessor(value) { + /** Shared no-state accessor: the common case allocates nothing per request. */ + const NO_REQUEST_STATE = requestStateAccessor(void 0); + let writeNegotiatedProtocolVersion; ++// Separate scopes keep pending transport sends from retaining request arguments. ++function makaCancellationErrorObserver(protocol) { ++ return (error) => protocol._onerror(new Error(`Failed to send cancellation: ${error}`)); ++} ++function makaRequestErrorObserver(protocol, messageId, reject) { ++ return { ++ onError(error) { ++ protocol._progressHandlers.delete(messageId); ++ reject?.(error); ++ }, ++ release() { ++ // Native reject retains the settled promise graph until explicitly revoked. ++ reject = void 0; ++ } ++ }; ++} + /** + * Implements MCP protocol framing on top of a pluggable transport, including + * features like request/response linking, notifications, and progress. +@@ -6055,6 +6071,7 @@ var Protocol = class { + const flowStartedAt = Date.now(); + let onAbort; + let cleanupMessageId; ++ let sendObserver; + return new Promise((resolve, reject) => { + const earlyReject = (error) => { + reject(error); +@@ -6107,7 +6124,7 @@ var Protocol = class { + relatedRequestId, + resumptionToken, + onresumptiontoken +- }).catch((error) => this._onerror(/* @__PURE__ */ new Error(`Failed to send cancellation: ${error}`))); ++ }).catch(makaCancellationErrorObserver(this)); + else requestAbort.abort(); + reject(reason instanceof SdkError ? reason : new SdkError(SdkErrorCode.RequestTimeout, String(reason))); + }; +@@ -6148,17 +6165,17 @@ var Protocol = class { + const timeout = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC; + const timeoutHandler = () => cancel(new SdkError(SdkErrorCode.RequestTimeout, "Request timed out", { timeout })); + this._setupTimeout(messageId, timeout, options?.maxTotalTimeout, timeoutHandler, options?.resetTimeoutOnProgress ?? false); ++ sendObserver = makaRequestErrorObserver(this, messageId, reject); + this._transport.send(outbound, { + relatedRequestId, + resumptionToken, + onresumptiontoken, + headers, + requestSignal: requestAbort?.signal +- }).catch((error) => { +- this._progressHandlers.delete(messageId); +- reject(error); +- }); ++ }).catch(sendObserver.onError); + }).finally(() => { ++ // Release request state without dropping queued frames or closing the connection. ++ sendObserver?.release(); + if (onAbort) options?.signal?.removeEventListener("abort", onAbort); + if (cleanupMessageId !== void 0) { + this._responseHandlers.delete(cleanupMessageId); +diff --git a/node_modules/@modelcontextprotocol/client/dist/src-NAgB4Mp8.cjs b/node_modules/@modelcontextprotocol/client/dist/src-NAgB4Mp8.cjs +index e8f2499..cc129ff 100644 +--- a/node_modules/@modelcontextprotocol/client/dist/src-NAgB4Mp8.cjs ++++ b/node_modules/@modelcontextprotocol/client/dist/src-NAgB4Mp8.cjs +@@ -5553,6 +5553,22 @@ function requestStateAccessor(value) { + /** Shared no-state accessor: the common case allocates nothing per request. */ + const NO_REQUEST_STATE = requestStateAccessor(void 0); + let writeNegotiatedProtocolVersion; ++// Separate scopes keep pending transport sends from retaining request arguments. ++function makaCancellationErrorObserver(protocol) { ++ return (error) => protocol._onerror(new Error(`Failed to send cancellation: ${error}`)); ++} ++function makaRequestErrorObserver(protocol, messageId, reject) { ++ return { ++ onError(error) { ++ protocol._progressHandlers.delete(messageId); ++ reject?.(error); ++ }, ++ release() { ++ // Native reject retains the settled promise graph until explicitly revoked. ++ reject = void 0; ++ } ++ }; ++} + /** + * Implements MCP protocol framing on top of a pluggable transport, including + * features like request/response linking, notifications, and progress. +@@ -6056,6 +6072,7 @@ var Protocol = class { + const flowStartedAt = Date.now(); + let onAbort; + let cleanupMessageId; ++ let sendObserver; + return new Promise((resolve, reject) => { + const earlyReject = (error) => { + reject(error); +@@ -6108,7 +6125,7 @@ var Protocol = class { + relatedRequestId, + resumptionToken, + onresumptiontoken +- }).catch((error) => this._onerror(/* @__PURE__ */ new Error(`Failed to send cancellation: ${error}`))); ++ }).catch(makaCancellationErrorObserver(this)); + else requestAbort.abort(); + reject(reason instanceof SdkError ? reason : new SdkError(SdkErrorCode.RequestTimeout, String(reason))); + }; +@@ -6149,17 +6166,17 @@ var Protocol = class { + const timeout = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC; + const timeoutHandler = () => cancel(new SdkError(SdkErrorCode.RequestTimeout, "Request timed out", { timeout })); + this._setupTimeout(messageId, timeout, options?.maxTotalTimeout, timeoutHandler, options?.resetTimeoutOnProgress ?? false); ++ sendObserver = makaRequestErrorObserver(this, messageId, reject); + this._transport.send(outbound, { + relatedRequestId, + resumptionToken, + onresumptiontoken, + headers, + requestSignal: requestAbort?.signal +- }).catch((error) => { +- this._progressHandlers.delete(messageId); +- reject(error); +- }); ++ }).catch(sendObserver.onError); + }).finally(() => { ++ // Release request state without dropping queued frames or closing the connection. ++ sendObserver?.release(); + if (onAbort) options?.signal?.removeEventListener("abort", onAbort); + if (cleanupMessageId !== void 0) { + this._responseHandlers.delete(cleanupMessageId); diff --git a/patches/README.md b/patches/README.md index b3725e97c4..f80e43147e 100644 --- a/patches/README.md +++ b/patches/README.md @@ -30,6 +30,45 @@ Keep this directory small. Prefer product code that uses the dependency's published API; only patch for bugs that block shipping and cannot be worked around at the call site. +## `@earendil-works/pi-tui@0.84.4` + +Editor undo snapshots deep-clone all stored paste strings for each typed word, +so a 1 MiB paste followed by 60 words retains roughly 60 MiB of duplicate text. +The editor now copies its mutable state, lines array, and paste Map while +sharing immutable strings. All undo steps, paste renumbering, and submission +cleanup are preserved; the generic undo stack used by Input stays unchanged. +Snapshot creation and storage are private, with no published clone policy +that product code can configure. + +Delete the patch when upstream shares immutable paste strings across undo snapshots. + +## `zod@4.5.4` + +Recursive schemas retain their last parse context and bucket in schema closures, +keeping the input and output graphs alive for the schema's lifetime. Containers +also leave entries on the global allocation stack when synchronous parsing +throws, including cycles through transforms. The patch keeps memoization in the +parse context and restores allocation state in `finally`, including a pending +outer allocation during reentrant parsing. Recursive cycles and shared aliases +still use the existing per-parse memoization. + +Delete the patch when upstream releases completed parse state in both ESM and CJS. +Before upgrading Zod, re-verify allocation handoff, reentrant parsing, and cycle/alias +identity against the new memoizer and container implementations. +The Runtime `zod-recursive-contract.test.ts` suite covers both shipped entry points. + +## `@modelcontextprotocol/client@2.0.0` + +Pending transport sends retain settled request arguments and results through +error observers, even after response, abort, timeout, or connection close. +The ESM and CJS patches scope cancellation observers independently and revoke +the request observer's native `reject` reference in request cleanup. Late send +errors still remove progress handlers, and cancellation send errors still reach +`onerror`; queued frames and connection behavior stay intact. The private SDK +request funnel has no public observer-lifetime hook for a call-site fix. + +Delete the patch when upstream releases settled request observers despite transport backpressure. + ## `@tufjs/models@5.0.0` and `@sigstore/core@4.0.1` The published ECDSA verification paths rely on Node choosing a digest when @@ -62,6 +101,14 @@ Delete when that guard passes against an unpatched package. ## `@astryxdesign/core@0.5.2` +The shared code tokenizer caches only valid language definitions. Caching `null` +for arbitrary unsupported fence labels grows a process-lifetime map; a short +label can also be a sliced string retaining its entire Markdown message after +unmount. Unknown labels keep their plain-text fallback, and known languages +keep reusing compiled regexes. A call-site language filter would duplicate the +dependency's language list, discard the displayed label, and miss the shared +CodeEditor path. Delete this hunk when upstream stops caching unsupported labels. + `ChatComposerInput` synchronizes external controlled values into its editable DOM in a layout effect. A passive effect can leave the old multiline draft visible for a frame after the sent message is rendered; clearing it later diff --git a/patches/zod+4.5.4.patch b/patches/zod+4.5.4.patch new file mode 100644 index 0000000000..ed77d44be3 --- /dev/null +++ b/patches/zod+4.5.4.patch @@ -0,0 +1,163 @@ +diff --git a/node_modules/zod/src/v4/core/memoizer.ts b/node_modules/zod/src/v4/core/memoizer.ts +index 61eb6bf..c2989c1 100644 +--- a/node_modules/zod/src/v4/core/memoizer.ts ++++ b/node_modules/zod/src/v4/core/memoizer.ts +@@ -196,9 +196,6 @@ const memo: $ZodMemoizer = { + + attach(inst) { + let isRecursiveInst: boolean | undefined; +- // `bucket` memoized for one parse; a recursive schema is re-entered many times and its bucket never changes +- let lastCtx: object | undefined; +- let lastBucket: Map | undefined; + + // Wraps `parse` in a deferred so it sees the container's final parse. Core's own deferred copies `parse` into `run` when there are no checks, and it ran first, so `run` is patched to match; with checks, `run` reads `parse` dynamically. + inst._zod.deferred ??= []; +@@ -225,14 +222,7 @@ const memo: $ZodMemoizer = { + (ctx as WithState)[STATE] = state; + } + +- let bucket: Map; +- if (lastCtx === ctx) { +- bucket = lastBucket!; +- } else { +- bucket = bucketFor(state, inst); +- lastCtx = ctx; +- lastBucket = bucket; +- } ++ const bucket = bucketFor(state, inst); + + const hit = bucket.get(input); + if (hit) { +@@ -248,12 +238,18 @@ const memo: $ZodMemoizer = { + return payload; + } + ++ const previousHandoff = handoff; + handoff = bucket; + const depth = open.length; +- const result = base(payload, ctx); +- handoff = undefined; +- // A container that rejected its input outright allocated nothing. +- const entry = open.length > depth ? open.pop()! : undefined; ++ let result: util.MaybeAsync; ++ let entry: Entry | undefined; ++ try { ++ result = base(payload, ctx); ++ } finally { ++ handoff = previousHandoff; ++ // A container that rejected its input outright allocated nothing. ++ entry = open.length > depth ? open.pop()! : undefined; ++ } + + // Both paths written out so the sync one allocates no closure. It runs once per node, and capturing here cost more than everything else combined. + if (result instanceof Promise) { +diff --git a/node_modules/zod/v4/core/memoizer.cjs b/node_modules/zod/v4/core/memoizer.cjs +index b659b44..c8d5ff5 100644 +--- a/node_modules/zod/v4/core/memoizer.cjs ++++ b/node_modules/zod/v4/core/memoizer.cjs +@@ -187,9 +187,6 @@ const memo = { + attach(inst) { + var _a; + let isRecursiveInst; +- // `bucket` memoized for one parse; a recursive schema is re-entered many times and its bucket never changes +- let lastCtx; +- let lastBucket; + // Wraps `parse` in a deferred so it sees the container's final parse. Core's own deferred copies `parse` into `run` when there are no checks, and it ran first, so `run` is patched to match; with checks, `run` reads `parse` dynamically. + (_a = inst._zod).deferred ?? (_a.deferred = []); + inst._zod.deferred.push(() => { +@@ -213,15 +210,7 @@ const memo = { + state = { buckets: new Map(), backEdges: undefined }; + ctx[STATE] = state; + } +- let bucket; +- if (lastCtx === ctx) { +- bucket = lastBucket; +- } +- else { +- bucket = bucketFor(state, inst); +- lastCtx = ctx; +- lastBucket = bucket; +- } ++ const bucket = bucketFor(state, inst); + const hit = bucket.get(input); + if (hit) { + payload.value = hit.value; +@@ -237,12 +226,19 @@ const memo = { + } + return payload; + } ++ const previousHandoff = handoff; + handoff = bucket; + const depth = open.length; +- const result = base(payload, ctx); +- handoff = undefined; +- // A container that rejected its input outright allocated nothing. +- const entry = open.length > depth ? open.pop() : undefined; ++ let result; ++ let entry; ++ try { ++ result = base(payload, ctx); ++ } ++ finally { ++ handoff = previousHandoff; ++ // A container that rejected its input outright allocated nothing. ++ entry = open.length > depth ? open.pop() : undefined; ++ } + // Both paths written out so the sync one allocates no closure. It runs once per node, and capturing here cost more than everything else combined. + if (result instanceof Promise) { + return result.then((r) => { +diff --git a/node_modules/zod/v4/core/memoizer.js b/node_modules/zod/v4/core/memoizer.js +index ce6e4b3..5081884 100644 +--- a/node_modules/zod/v4/core/memoizer.js ++++ b/node_modules/zod/v4/core/memoizer.js +@@ -180,9 +180,6 @@ const memo = { + attach(inst) { + var _a; + let isRecursiveInst; +- // `bucket` memoized for one parse; a recursive schema is re-entered many times and its bucket never changes +- let lastCtx; +- let lastBucket; + // Wraps `parse` in a deferred so it sees the container's final parse. Core's own deferred copies `parse` into `run` when there are no checks, and it ran first, so `run` is patched to match; with checks, `run` reads `parse` dynamically. + (_a = inst._zod).deferred ?? (_a.deferred = []); + inst._zod.deferred.push(() => { +@@ -206,15 +203,7 @@ const memo = { + state = { buckets: new Map(), backEdges: undefined }; + ctx[STATE] = state; + } +- let bucket; +- if (lastCtx === ctx) { +- bucket = lastBucket; +- } +- else { +- bucket = bucketFor(state, inst); +- lastCtx = ctx; +- lastBucket = bucket; +- } ++ const bucket = bucketFor(state, inst); + const hit = bucket.get(input); + if (hit) { + payload.value = hit.value; +@@ -230,12 +219,19 @@ const memo = { + } + return payload; + } ++ const previousHandoff = handoff; + handoff = bucket; + const depth = open.length; +- const result = base(payload, ctx); +- handoff = undefined; +- // A container that rejected its input outright allocated nothing. +- const entry = open.length > depth ? open.pop() : undefined; ++ let result; ++ let entry; ++ try { ++ result = base(payload, ctx); ++ } ++ finally { ++ handoff = previousHandoff; ++ // A container that rejected its input outright allocated nothing. ++ entry = open.length > depth ? open.pop() : undefined; ++ } + // Both paths written out so the sync one allocates no closure. It runs once per node, and capturing here cost more than everything else combined. + if (result instanceof Promise) { + return result.then((r) => {