From 98b586fd1fb7dc3aa3f810cdc054a489acaaf967 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:12:41 +0800 Subject: [PATCH 1/4] fix(runtime): preserve interrupted plugin executor answers Retain streamed text before cancellation or failure seals the runtime ledger, and preserve interrupted metadata during recovery decoding. Add SQLite reopen regressions for cancellation and provider failures. Refs #5103 Generated-by: Codex --- .../core/src/__tests__/runtime-event.test.ts | 10 ++ packages/core/src/runtime-event.ts | 1 + .../__tests__/plugin-executor-backend.test.ts | 8 +- .../__tests__/plugin-executor-history.test.ts | 135 ++++++++++++++++++ .../runtime/src/plugin-executor-backend.ts | 16 +++ 5 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 packages/runtime/src/__tests__/plugin-executor-history.test.ts diff --git a/packages/core/src/__tests__/runtime-event.test.ts b/packages/core/src/__tests__/runtime-event.test.ts index 48c8e6c9f87..19a7a61c7e4 100644 --- a/packages/core/src/__tests__/runtime-event.test.ts +++ b/packages/core/src/__tests__/runtime-event.test.ts @@ -57,6 +57,16 @@ function baseEvent(overrides: Partial = {}): RuntimeEvent { }; } +test('interrupted assistant text survives recovery decoding without becoming model context', () => { + const event = baseEvent({ + modelVisibility: 'hidden', + content: { kind: 'text', text: 'unfinished answer', interrupted: true }, + }); + const decoded = decodeRuntimeEvent(JSON.parse(JSON.stringify(event))); + assert.deepEqual(decoded, event); + assert.equal(runtimeEventHasModelVisibleContent(decoded), false); +}); + test('Stored assistant reasoning parts survive recovery decoding', () => { const parts = [ { diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index 3e5ade5b7b0..a6dd656b674 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -1038,6 +1038,7 @@ export function decodeRuntimeEvent(value: unknown): RuntimeEvent { content: { kind: 'text', ...normalizeMessageContent(value.content as unknown as MessageContent), + ...(value.content.interrupted === true ? { interrupted: true as const } : {}), ...(value.content.origin !== undefined ? { origin: decodeTurnOrigin(value.content.origin) } : {}), diff --git a/packages/runtime/src/__tests__/plugin-executor-backend.test.ts b/packages/runtime/src/__tests__/plugin-executor-backend.test.ts index 97d8a2c5dff..02f4f593f11 100644 --- a/packages/runtime/src/__tests__/plugin-executor-backend.test.ts +++ b/packages/runtime/src/__tests__/plugin-executor-backend.test.ts @@ -194,10 +194,12 @@ test('oversized executor completion fails without emitting a large text_complete const events = await collect(backend.send({ turnId: 'turn-a', text: 'task' })); assert.deepEqual( events.map((event) => event.type), - ['text_delta', 'error', 'complete'], + ['text_delta', 'text_complete', 'error', 'complete'], ); - assert.match(events[1]?.type === 'error' ? events[1].message : '', /completion text exceeds/u); - assert.equal(events[2]?.type === 'complete' ? events[2].stopReason : undefined, 'error'); + assert.equal(events[1]?.type === 'text_complete' ? events[1].text : undefined, 'streamed'); + assert.equal(events[1]?.type === 'text_complete' ? events[1].interrupted : undefined, true); + assert.match(events[2]?.type === 'error' ? events[2].message : '', /completion text exceeds/u); + assert.equal(events[3]?.type === 'complete' ? events[3].stopReason : undefined, 'error'); await root.fiber.dispose(); }); diff --git a/packages/runtime/src/__tests__/plugin-executor-history.test.ts b/packages/runtime/src/__tests__/plugin-executor-history.test.ts new file mode 100644 index 00000000000..42fd2cae791 --- /dev/null +++ b/packages/runtime/src/__tests__/plugin-executor-history.test.ts @@ -0,0 +1,135 @@ +/* + * 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 { randomUUID } from 'node:crypto'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { createSessionStore } from '@maka/storage/session-store'; +import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; +import { createWorkspaceRuntimeStore } from '@maka/storage/runtime-event-persistence'; +import { PluginExecutorBackend } from '../plugin-executor-backend.js'; +import { PluginExecutorService } from '../plugin-executor-service.js'; +import { Context } from '../plugin-kernel.js'; +import { BackendRegistry, SessionManager } from '../session-manager.js'; +import { projectRuntimeEventsToStoredMessages } from '../runtime-event-read-model.js'; + +for (const outcome of ['cancelled', 'abort-error', 'failed', 'transport-error'] as const) { + test(`interrupted executor output survives SQLite reopen: ${outcome}`, { + timeout: 10_000, + }, async () => { + const directory = await mkdtemp(join(tmpdir(), 'maka-executor-history-')); + const sessions = createSessionStore(directory); + const runs = createSqliteAgentRunStore(directory); + const events = createWorkspaceRuntimeStore(directory); + const root = new Context(); + const service = new PluginExecutorService(root); + const cancelling = outcome === 'cancelled' || outcome === 'abort-error'; + root + .extend({ + maka: { rootId: 'profile', packageId: 'fixture', entryId: 'provider', generation: 1 }, + }) + .executors.register({ + id: 'remote', + execute: async (_request, context) => { + context.emit({ type: 'output_delta', text: '保留 before stop' }); + if (cancelling) { + await new Promise((resolve) => + context.signal.addEventListener('abort', () => resolve(), { once: true }), + ); + } + // Cancellation still drains updates until the provider settles. + context.emit({ type: 'output_delta', text: ' and final update.' }); + if (outcome === 'abort-error' || outcome === 'transport-error') + throw new Error('transport ended'); + return outcome === 'cancelled' + ? { status: 'cancelled', providerStopReason: 'cancelled' } + : { status: 'failed', message: 'provider failed' }; + }, + }); + const backends = new BackendRegistry(); + backends.register( + 'plugin-executor', + (context) => + new PluginExecutorBackend({ + sessionId: context.sessionId, + cwd: directory, + binding: service.bind(context.sessionId, 'remote'), + }), + ); + const manager = new SessionManager({ + store: sessions, + runStore: runs, + runtimeEventStore: events, + backends, + newId: randomUUID, + now: Date.now, + }); + try { + const session = await manager.createSession({ + cwd: directory, + executorId: 'remote', + llmConnectionSlug: 'executor:remote', + permissionMode: 'bypass', + }); + let stopping: Promise | undefined; + for await (const event of manager.sendMessage(session.id, { + turnId: 'turn-a', + text: 'write a long answer', + })) { + if (event.type === 'text_delta' && cancelling && !stopping) + stopping = manager.stopSession(session.id); + } + await stopping; + events.close(); + const reopened = createWorkspaceRuntimeStore(directory); + try { + const ledger = await reopened.readSessionRuntimeEvents(session.id); + const invocations = await reopened.listSessionInvocations(session.id); + const answers = projectRuntimeEventsToStoredMessages(ledger, { + invocations, + }).messages.filter((message) => message.type === 'assistant'); + assert.equal(answers.length, 1, 'the visible partial answer survives exactly once'); + assert.equal(answers[0]?.text, '保留 before stop and final update.'); + const saved = ledger.filter( + (event) => event.role === 'model' && event.content?.kind === 'text', + ); + assert.equal(saved.length, 1); + assert.equal(saved[0]?.partial, false); + assert.equal( + saved[0]?.modelVisibility, + 'hidden', + 'interrupted output is display history, not completed model context', + ); + assert.equal(saved[0]?.content?.kind === 'text' && saved[0].content.interrupted, true); + assert.equal(invocations[0]?.terminalEvent?.status, cancelling ? 'aborted' : 'failed'); + } finally { + reopened.close(); + } + } finally { + await root.fiber.dispose(); + events.close(); + runs.close?.(); + await sessions.close?.(); + await rm(directory, { recursive: true, force: true }); + } + }); +} diff --git a/packages/runtime/src/plugin-executor-backend.ts b/packages/runtime/src/plugin-executor-backend.ts index 10e490aaaa5..1ad3469e06f 100644 --- a/packages/runtime/src/plugin-executor-backend.ts +++ b/packages/runtime/src/plugin-executor-backend.ts @@ -165,6 +165,7 @@ export class PluginExecutorBackend implements AgentBackend { ): Promise { const turnId = input.turnId; let thinkingText = ''; + let outputText = ''; const toolUseIds = new Map(); const toolOutputSequences = new Map(); let result: PluginExecutorResult | undefined; @@ -195,6 +196,7 @@ export class PluginExecutorBackend implements AgentBackend { }, onEvent: (event) => { if (event.type === 'thinking_delta') thinkingText += event.text; + if (event.type === 'output_delta') outputText += event.text; this.#publishOutputEvent( turnId, messageId, @@ -214,6 +216,20 @@ export class PluginExecutorBackend implements AgentBackend { } this.#closeOptionalOutput(turnId, messageId, thinkingText, toolUseIds, queue); + // Deltas are only live presentation. Close the interrupted text before the + // terminal event seals the ledger, including updates drained during cancel. + // Successful results keep their provider-authoritative completion text. + if (outputText && (failed || result?.status !== 'completed')) { + queue.push({ + type: 'text_complete', + id: this.#newId(), + turnId, + ts: this.#now(), + messageId, + text: outputText, + interrupted: true, + }); + } if (failed) { if (signal.aborted) this.#publishCancellation(turnId, { status: 'cancelled', source: 'caller' }, queue); From 8644aa058b8cab43b2fe133193858095e7771581 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:17:00 +0800 Subject: [PATCH 2/4] docs: record ACP cancellation recovery acceptance Refs #5103 Generated-by: Codex --- .../archive/antigravity-acp-pr4-acceptance.md | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/docs/archive/antigravity-acp-pr4-acceptance.md b/docs/archive/antigravity-acp-pr4-acceptance.md index 1b299e51a44..300c0f12373 100644 --- a/docs/archive/antigravity-acp-pr4-acceptance.md +++ b/docs/archive/antigravity-acp-pr4-acceptance.md @@ -22,6 +22,57 @@ PR 4 follows [issue #5103](https://github.com/apache/maka/issues/5103). This record separates controlled protocol fixtures from official Agent results. +## Issue #5103 Desktop reacceptance and cancellation repair, 2026-10-17 + +The baseline was main `d14cec5719352f77d324e1065e0c1943c33772c7` (including +#5823). The cancellation repair was tested at `98b586fd1`. Environment: macOS +15.6 arm64, Node.js 24.19.0, official Antigravity ACP 1.1.1, two disposable +projects and one isolated Desktop profile. The existing official Google login +was verified in Settings; no fresh credential-entry flow is claimed. + +On the baseline, Settings program selection/check/login, shared A/B catalog +availability, both real task prompts, distinct external Sessions, confirmed idle +model/mode changes, coding and three passing Node tests, tool output/file diffs, +permission choices, a structured question and a follow-up all passed. Project B +used `gemini-3.8-flash-high`/`default`; A began with +`gemini-3.8-flash-low`/`auto_edit`, then confirmed +`gemini-3.8-flash-medium`/`default`. A production executor query separately +confirmed that A and B shared one candidate snapshot. + +That run exposed a blocker: a stopped response remained visible live but became +empty after normal Desktop restart. The repair closes streamed text as an +interrupted completion before cancellation/failure seals the ledger, and retains +the existing interrupted marker during event decoding. It adds no new wire field. +Previously missing canonical text is not reconstructed by this change. + +On the repaired build, a real streamed response was stopped after text appeared. +Its 6,580 characters were stored once, with `interrupted: true`, hidden model +visibility and an aborted terminal state. Two normal Cmd-Q/reopen/explicit-restore +cycles retained the identical text hash, a usable Copy action and the interrupted +response marker. The same external Session fingerprint and Medium/Default +configuration survived both cycles. A no-file-access recall prompt, which omitted +the answer, returned the original A code. Restoring B separately returned its +original B code and retained High/Default. No raw external Session ID or credential +was recorded in the acceptance artifacts. + +Four new production SessionManager → SQLite-close/reopen → transcript regressions +failed on the baseline and pass with the repair. They cover successful cancellation, +cancellation throwing, a provider failure result and a transport exception, including +updates drained after Stop. A Core decoding regression preserves the interrupted +marker. The focused backend/service/Core/reopen suite passed 66 tests. + +The repaired build passed all 13 workspace suites: 16,243 JavaScript tests, +16,205 passed, 38 skipped, zero failed or cancelled. The Python evaluation +suite also passed (94 tests, 13 skipped). Full build, typecheck, lint, formatting, +ASF headers, locale hygiene, 168 renderer architecture tests and both Desktop/UI +knip checks passed. Standards and spec reviews reported no findings. + +This evidence resolves the cancellation blocker and supplements the earlier +controlled catalog/invalidation, attachment-validation and ACP server-path tests. +It does not claim a fresh Google authorization flow or a new manual run of every +keyboard, attachment and narrow-window case. Closing #5103 still requires this +repair to be integrated and the tracking checklist to be updated. + ## P3 stable-directory and invalidation follow-up, 2026-10-02 Tested implementation commit: `4fefdc1e7fae53bcc2e8412c03be5eadd9e0de4b`. From 16b82447062cdfb6b5ad604c25ed856cd2133f26 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:49:58 +0800 Subject: [PATCH 3/4] fix(runtime): bound interrupted executor output and drain cancellation Cap retained interrupted text at the existing UTF-8 completion limit with an explicit truncation marker. Wait for queued events to be consumed before Stop returns so late updates reach the ledger before it seals. Cover overflow, Unicode boundaries, detachment, and SQLite recovery. Refs #5103 Generated-by: Codex --- .../src/__tests__/acp-process.test.ts | 5 +- .../__tests__/plugin-executor-backend.test.ts | 117 ++++++++++++++++++ .../__tests__/plugin-executor-history.test.ts | 27 +++- .../runtime/src/plugin-executor-backend.ts | 36 +++++- .../runtime/src/plugin-executor-service.ts | 2 +- 5 files changed, 178 insertions(+), 9 deletions(-) diff --git a/packages/acp-executor-plugin/src/__tests__/acp-process.test.ts b/packages/acp-executor-plugin/src/__tests__/acp-process.test.ts index e1c2c963295..a2c2eb05822 100644 --- a/packages/acp-executor-plugin/src/__tests__/acp-process.test.ts +++ b/packages/acp-executor-plugin/src/__tests__/acp-process.test.ts @@ -313,12 +313,15 @@ for (const prompt of ['wait', 'refusal', 'ignore-cancel', 'crash']) { } assert.equal(values.get('task')?.phase, 'committed'); const terminalEvents: string[] = []; + let stopping: Promise | undefined; for await (const event of backend.send({ turnId: 'stopped', text: prompt })) { terminalEvents.push(event.type); - if (event.type === 'text_delta') await backend.stop('user_stop'); + // Keep draining while Stop waits for the final updates to be consumed. + if (event.type === 'text_delta' && !stopping) stopping = backend.stop('user_stop'); if (event.type === 'complete') assert.equal(values.get('task')?.phase, 'prompt_pending', 'delivery alone cannot commit'); } + await stopping; assert.equal(terminalEvents.at(-1), 'complete'); assert.ok(terminalEvents.includes(['crash', 'refusal'].includes(prompt) ? 'error' : 'abort')); const settled = prompt === 'wait' || prompt === 'refusal'; diff --git a/packages/runtime/src/__tests__/plugin-executor-backend.test.ts b/packages/runtime/src/__tests__/plugin-executor-backend.test.ts index 02f4f593f11..822b4b6c96b 100644 --- a/packages/runtime/src/__tests__/plugin-executor-backend.test.ts +++ b/packages/runtime/src/__tests__/plugin-executor-backend.test.ts @@ -203,6 +203,97 @@ test('oversized executor completion fails without emitting a large text_complete await root.fiber.dispose(); }); +for (const outcome of ['cancelled', 'failed', 'transport-error', 'oversized-completion'] as const) { + test(`interrupted executor text stays bounded after ${outcome}`, async () => { + const chunk = 'x'.repeat(16 * 1024); + const { root, binding } = fixture(async (_request, context) => { + for (let index = 0; index < 17; index++) context.emit({ type: 'output_delta', text: chunk }); + context.emit({ type: 'output_delta', text: 'late update' }); + if (outcome === 'transport-error') throw new Error('transport ended'); + if (outcome === 'oversized-completion') + return { status: 'completed', text: chunk.repeat(17) }; + return outcome === 'cancelled' + ? { status: 'cancelled' } + : { status: 'failed', message: 'provider failed' }; + }); + const backend = new PluginExecutorBackend({ + sessionId: 'session-a', + cwd: '/workspace', + binding, + }); + try { + const events = await collect(backend.send({ turnId: 'turn-a', text: 'task' })); + const answers = events.filter((event) => event.type === 'text_complete'); + assert.equal(answers.length, 1); + const answer = answers[0]!; + assert.equal(Buffer.byteLength(answer.text), 256 * 1024); + assert.equal(answer.interrupted, true); + const marker = '\n[Response truncated]'; + assert.equal(answer.text, 'x'.repeat(256 * 1024 - Buffer.byteLength(marker)) + marker); + assert.equal(events.filter((event) => event.type === 'text_delta').length, 18); + const terminal = events.find((event) => event.type === 'complete'); + assert.equal(terminal?.stopReason, outcome === 'cancelled' ? 'user_stop' : 'error'); + } finally { + await backend.dispose(); + await root.fiber.dispose(); + } + }); +} + +for (const scenario of [ + { + name: 'one oversized Unicode delta', + chunks: ['中🙂'.repeat(100_000)], + expected: '中🙂'.repeat(Math.floor((256 * 1024 - 21) / 7)) + '\n[Response truncated]', + }, + { + name: 'a surrogate pair split across deltas at the exact limit', + chunks: ['x'.repeat(256 * 1024 - 4), '\ud83d', '\ude42'], + expected: 'x'.repeat(256 * 1024 - 4) + '🙂', + }, + { + name: 'a Unicode boundary before the truncation marker', + chunks: ['x'.repeat(256 * 1024 - 25), '\ud83d', '\ude42', 'z'.repeat(26)], + expected: 'x'.repeat(256 * 1024 - 25) + '🙂\n[Response truncated]', + }, +]) { + test(`interrupted executor text preserves ${scenario.name}`, async () => { + const { root, backend } = recordingFixture(async (_request, context) => { + for (const text of scenario.chunks) context.emit({ type: 'output_delta', text }); + return { status: 'cancelled' }; + }); + try { + const events = await collect(backend.send({ turnId: 'turn-a', text: 'task' })); + const answers = events.filter((event) => event.type === 'text_complete'); + assert.equal(answers.length, 1); + assert.equal(answers[0]?.text, scenario.expected); + assert.ok(Buffer.byteLength(answers[0]!.text) <= 256 * 1024); + assert.equal(Buffer.from(answers[0]!.text, 'utf8').toString('utf8'), answers[0]?.text); + } finally { + await backend.dispose(); + await root.fiber.dispose(); + } + }); +} + +test('a bounded interrupted buffer does not replace a successful provider answer', async () => { + const { root, backend, acknowledged } = recordingFixture(async (_request, context) => { + context.emit({ type: 'output_delta', text: 'x'.repeat(256 * 1024 + 1) }); + return { status: 'completed', text: 'authoritative answer' }; + }); + try { + const events = await collect(backend.send({ turnId: 'turn-a', text: 'task' })); + const answers = events.filter((event) => event.type === 'text_complete'); + assert.equal(answers.length, 1); + assert.equal(answers[0]?.text, 'authoritative answer'); + assert.equal(answers[0]?.interrupted, undefined); + assert.deepEqual(acknowledged, ['session-a/turn-a']); + } finally { + await backend.dispose(); + await root.fiber.dispose(); + } +}); + test('executor backend turns stop into abort and terminal events', async () => { let started!: () => void; const ready = new Promise((resolve) => { @@ -235,6 +326,32 @@ test('executor backend turns stop into abort and terminal events', async () => { await root.fiber.dispose(); }); +test('executor stop releases its drain wait when the consumer detaches', { + timeout: 5_000, +}, async () => { + const { root, backend, acknowledged, abandoned } = recordingFixture(async (_request, context) => { + context.emit({ type: 'output_delta', text: 'before stop' }); + await new Promise((resolve) => + context.signal.addEventListener('abort', () => resolve(), { once: true }), + ); + context.emit({ type: 'output_delta', text: 'after stop' }); + return { status: 'cancelled' }; + }); + const iterator = backend.send({ turnId: 'turn-a', text: 'task' })[Symbol.asyncIterator](); + try { + assert.equal((await iterator.next()).value?.type, 'text_delta'); + const stopping = backend.stop('user_stop'); + await iterator.return?.(); + await stopping; + assert.deepEqual(acknowledged, []); + assert.deepEqual(abandoned, ['session-a/turn-a']); + } finally { + await iterator.return?.(); + await backend.dispose(); + await root.fiber.dispose(); + } +}); + test('executor backend projects optional thinking and external tool activity', async () => { const { root, binding } = fixture( async (_request, context) => { diff --git a/packages/runtime/src/__tests__/plugin-executor-history.test.ts b/packages/runtime/src/__tests__/plugin-executor-history.test.ts index 42fd2cae791..9778194612c 100644 --- a/packages/runtime/src/__tests__/plugin-executor-history.test.ts +++ b/packages/runtime/src/__tests__/plugin-executor-history.test.ts @@ -32,7 +32,13 @@ import { Context } from '../plugin-kernel.js'; import { BackendRegistry, SessionManager } from '../session-manager.js'; import { projectRuntimeEventsToStoredMessages } from '../runtime-event-read-model.js'; -for (const outcome of ['cancelled', 'abort-error', 'failed', 'transport-error'] as const) { +for (const outcome of [ + 'cancelled', + 'cancelled-oversized', + 'abort-error', + 'failed', + 'transport-error', +] as const) { test(`interrupted executor output survives SQLite reopen: ${outcome}`, { timeout: 10_000, }, async () => { @@ -42,7 +48,8 @@ for (const outcome of ['cancelled', 'abort-error', 'failed', 'transport-error'] const events = createWorkspaceRuntimeStore(directory); const root = new Context(); const service = new PluginExecutorService(root); - const cancelling = outcome === 'cancelled' || outcome === 'abort-error'; + const cancelling = + outcome === 'cancelled' || outcome === 'cancelled-oversized' || outcome === 'abort-error'; root .extend({ maka: { rootId: 'profile', packageId: 'fixture', entryId: 'provider', generation: 1 }, @@ -57,10 +64,14 @@ for (const outcome of ['cancelled', 'abort-error', 'failed', 'transport-error'] ); } // Cancellation still drains updates until the provider settles. + if (outcome === 'cancelled-oversized') { + for (let index = 0; index < 17; index++) + context.emit({ type: 'output_delta', text: 'x'.repeat(16 * 1024) }); + } context.emit({ type: 'output_delta', text: ' and final update.' }); if (outcome === 'abort-error' || outcome === 'transport-error') throw new Error('transport ended'); - return outcome === 'cancelled' + return cancelling ? { status: 'cancelled', providerStopReason: 'cancelled' } : { status: 'failed', message: 'provider failed' }; }, @@ -108,7 +119,15 @@ for (const outcome of ['cancelled', 'abort-error', 'failed', 'transport-error'] invocations, }).messages.filter((message) => message.type === 'assistant'); assert.equal(answers.length, 1, 'the visible partial answer survives exactly once'); - assert.equal(answers[0]?.text, '保留 before stop and final update.'); + const marker = '\n[Response truncated]'; + const expected = + outcome === 'cancelled-oversized' + ? '保留 before stop' + + 'x'.repeat(256 * 1024 - Buffer.byteLength('保留 before stop' + marker)) + + marker + : '保留 before stop and final update.'; + assert.equal(answers[0]?.text, expected); + assert.ok(Buffer.byteLength(answers[0]!.text) <= 256 * 1024); const saved = ledger.filter( (event) => event.role === 'model' && event.content?.kind === 'text', ); diff --git a/packages/runtime/src/plugin-executor-backend.ts b/packages/runtime/src/plugin-executor-backend.ts index 1ad3469e06f..1c8cabe5e2e 100644 --- a/packages/runtime/src/plugin-executor-backend.ts +++ b/packages/runtime/src/plugin-executor-backend.ts @@ -19,6 +19,7 @@ import type { ExecutorConfiguration } from '@maka/core/executor-catalog'; import { randomUUID } from 'node:crypto'; +import { truncateUtf8 } from '@maka/core/diagnostic-log'; import type { SessionEvent } from '@maka/core/events'; import { redactSecrets } from '@maka/core/redaction'; import type { @@ -31,6 +32,7 @@ import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { UserQuestionResponse } from '@maka/core/user-question'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import { AsyncEventQueue } from './async-queue.js'; +import { MAX_EXECUTOR_COMPLETION_TEXT_BYTES } from './plugin-executor-service.js'; import type { PluginExecutorBinding, PluginExecutorOutputEvent, @@ -42,6 +44,7 @@ import type { interface ActiveExecution { readonly abort: AbortController; readonly settled: Promise; + readonly queue: AsyncEventQueue; } /** @@ -97,7 +100,7 @@ export class PluginExecutorBackend implements AgentBackend { const producer = this.#produce(input, messageId, abort.signal, queue).finally(() => queue.close(), ); - const active: ActiveExecution = { abort, settled: producer.then(() => undefined) }; + const active: ActiveExecution = { abort, settled: producer.then(() => undefined), queue }; this.#active.add(active); let acknowledged = false; try { @@ -140,7 +143,14 @@ export class PluginExecutorBackend implements AgentBackend { async stop(reason: 'user_stop' | 'redirect'): Promise { const active = [...this.#active]; for (const execution of active) execution.abort.abort(new Error(reason)); - await Promise.allSettled(active.map((execution) => execution.settled)); + await Promise.allSettled( + active.map(async (execution) => { + await execution.settled; + // Runtime may seal the ledger when stop returns. Provider settlement + // alone does not mean its queued final updates have been persisted. + await execution.queue.waitUntilConsumedThroughCurrent(); + }), + ); } async respondToSandboxBoundary(_response: SandboxBoundaryResponse): Promise { @@ -166,6 +176,8 @@ export class PluginExecutorBackend implements AgentBackend { const turnId = input.turnId; let thinkingText = ''; let outputText = ''; + let outputBytes = 0; + let outputTruncated = false; const toolUseIds = new Map(); const toolOutputSequences = new Map(); let result: PluginExecutorResult | undefined; @@ -196,7 +208,25 @@ export class PluginExecutorBackend implements AgentBackend { }, onEvent: (event) => { if (event.type === 'thinking_delta') thinkingText += event.text; - if (event.type === 'output_delta') outputText += event.text; + if (event.type === 'output_delta' && !outputTruncated) { + // Bound even a single oversized delta before retaining it. Live + // delivery and the provider's final result remain authoritative. + const chunk = event.text.slice(0, MAX_EXECUTOR_COMPLETION_TEXT_BYTES + 1); + outputText += chunk; + outputBytes += Buffer.byteLength(chunk, 'utf8'); + if (outputBytes > MAX_EXECUTOR_COMPLETION_TEXT_BYTES) { + // A surrogate pair split across deltas uses fewer bytes joined. + outputBytes = Buffer.byteLength(outputText, 'utf8'); + if (outputBytes > MAX_EXECUTOR_COMPLETION_TEXT_BYTES) { + outputText = truncateUtf8( + outputText, + MAX_EXECUTOR_COMPLETION_TEXT_BYTES, + '\n[Response truncated]', + ); + outputTruncated = true; + } + } + } this.#publishOutputEvent( turnId, messageId, diff --git a/packages/runtime/src/plugin-executor-service.ts b/packages/runtime/src/plugin-executor-service.ts index a3c0b0b611c..123196b410c 100644 --- a/packages/runtime/src/plugin-executor-service.ts +++ b/packages/runtime/src/plugin-executor-service.ts @@ -44,7 +44,7 @@ import { } from './plugin-runtime.js'; import { PluginScopeRegistry } from './plugin-scope-registry.js'; -const MAX_EXECUTOR_COMPLETION_TEXT_BYTES = 256 * 1024; +export const MAX_EXECUTOR_COMPLETION_TEXT_BYTES = 256 * 1024; declare module './plugin-kernel.js' { interface Context { From 36e3e0eeeb519d36b11abfd56280809fe5d5894c Mon Sep 17 00:00:00 2001 From: Sun-GLiang <81428141+Sun-GLiang@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:31:39 +0800 Subject: [PATCH 4/4] docs(runtime): clarify executor stop and transcript contracts Document consumer progress required by stop and the existing transcript ordering limit. Remove the added archive acceptance section from this PR's diff; keep verification evidence in the PR description and historical commit. Generated-by: Codex --- .../archive/antigravity-acp-pr4-acceptance.md | 51 ------------------- .../runtime/src/plugin-executor-backend.ts | 8 +++ 2 files changed, 8 insertions(+), 51 deletions(-) diff --git a/docs/archive/antigravity-acp-pr4-acceptance.md b/docs/archive/antigravity-acp-pr4-acceptance.md index 300c0f12373..1b299e51a44 100644 --- a/docs/archive/antigravity-acp-pr4-acceptance.md +++ b/docs/archive/antigravity-acp-pr4-acceptance.md @@ -22,57 +22,6 @@ PR 4 follows [issue #5103](https://github.com/apache/maka/issues/5103). This record separates controlled protocol fixtures from official Agent results. -## Issue #5103 Desktop reacceptance and cancellation repair, 2026-10-17 - -The baseline was main `d14cec5719352f77d324e1065e0c1943c33772c7` (including -#5823). The cancellation repair was tested at `98b586fd1`. Environment: macOS -15.6 arm64, Node.js 24.19.0, official Antigravity ACP 1.1.1, two disposable -projects and one isolated Desktop profile. The existing official Google login -was verified in Settings; no fresh credential-entry flow is claimed. - -On the baseline, Settings program selection/check/login, shared A/B catalog -availability, both real task prompts, distinct external Sessions, confirmed idle -model/mode changes, coding and three passing Node tests, tool output/file diffs, -permission choices, a structured question and a follow-up all passed. Project B -used `gemini-3.8-flash-high`/`default`; A began with -`gemini-3.8-flash-low`/`auto_edit`, then confirmed -`gemini-3.8-flash-medium`/`default`. A production executor query separately -confirmed that A and B shared one candidate snapshot. - -That run exposed a blocker: a stopped response remained visible live but became -empty after normal Desktop restart. The repair closes streamed text as an -interrupted completion before cancellation/failure seals the ledger, and retains -the existing interrupted marker during event decoding. It adds no new wire field. -Previously missing canonical text is not reconstructed by this change. - -On the repaired build, a real streamed response was stopped after text appeared. -Its 6,580 characters were stored once, with `interrupted: true`, hidden model -visibility and an aborted terminal state. Two normal Cmd-Q/reopen/explicit-restore -cycles retained the identical text hash, a usable Copy action and the interrupted -response marker. The same external Session fingerprint and Medium/Default -configuration survived both cycles. A no-file-access recall prompt, which omitted -the answer, returned the original A code. Restoring B separately returned its -original B code and retained High/Default. No raw external Session ID or credential -was recorded in the acceptance artifacts. - -Four new production SessionManager → SQLite-close/reopen → transcript regressions -failed on the baseline and pass with the repair. They cover successful cancellation, -cancellation throwing, a provider failure result and a transport exception, including -updates drained after Stop. A Core decoding regression preserves the interrupted -marker. The focused backend/service/Core/reopen suite passed 66 tests. - -The repaired build passed all 13 workspace suites: 16,243 JavaScript tests, -16,205 passed, 38 skipped, zero failed or cancelled. The Python evaluation -suite also passed (94 tests, 13 skipped). Full build, typecheck, lint, formatting, -ASF headers, locale hygiene, 168 renderer architecture tests and both Desktop/UI -knip checks passed. Standards and spec reviews reported no findings. - -This evidence resolves the cancellation blocker and supplements the earlier -controlled catalog/invalidation, attachment-validation and ACP server-path tests. -It does not claim a fresh Google authorization flow or a new manual run of every -keyboard, attachment and narrow-window case. Closing #5103 still requires this -repair to be integrated and the tracking checklist to be updated. - ## P3 stable-directory and invalidation follow-up, 2026-10-02 Tested implementation commit: `4fefdc1e7fae53bcc2e8412c03be5eadd9e0de4b`. diff --git a/packages/runtime/src/plugin-executor-backend.ts b/packages/runtime/src/plugin-executor-backend.ts index 1c8cabe5e2e..16083e7080b 100644 --- a/packages/runtime/src/plugin-executor-backend.ts +++ b/packages/runtime/src/plugin-executor-backend.ts @@ -140,6 +140,12 @@ export class PluginExecutorBackend implements AgentBackend { } } + /** + * Cancel execution and wait for its queued events to be consumed or the consumer + * to detach. Keep consuming send() while stopping: awaiting stop() inside that + * consumer loop prevents progress. Start it without awaiting in the loop, then + * await it after the loop exits. + */ async stop(reason: 'user_stop' | 'redirect'): Promise { const active = [...this.#active]; for (const execution of active) execution.abort.abort(new Error(reason)); @@ -249,6 +255,8 @@ export class PluginExecutorBackend implements AgentBackend { // Deltas are only live presentation. Close the interrupted text before the // terminal event seals the ledger, including updates drained during cancel. // Successful results keep their provider-authoritative completion text. + // Like successful completions, history stores one text block after tool + // results; it does not preserve the live text/tool interleaving. if (outputText && (failed || result?.status !== 'completed')) { queue.push({ type: 'text_complete',