You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by lu...@apache.org on 2026/10/17 21:24:27 UTC
(maka) 01/05: feat(desktop): continue a failed run
This is an automated email from the ASF dual-hosted git repository.
UncertaintyDeterminesYou4ndMe pushed a commit to branch feat/desktop-conversation-tools
in repository https://gitbox.apache.org/repos/asf/maka.git
commit c82e66c3bcabfcc7dd161199719123285a0e3966
Author: Xun Lu <lu...@apache.org>
AuthorDate: Sun Oct 18 03:09:10 2026 +0800
feat(desktop): continue a failed run
The failed bar offers Continue first: agent.resume picks the run that
failed the turn up where it ended, with the composer's model. The failed
run's invocation id comes from the watch, or from agent.diagnostics when
the run ended before the conversation was opened; its high water from
agent.execution. Each parked reason shows as one sentence in the
composer's notice, and a run the Host will not continue (not resumable,
already continued, or not found, as in a fork) loses the button. A task
an executor runs has no Continue: the desktop starts no runs there. When
the Host does not answer, the notice says the run may or may not have
continued, and Continue stays off until the conversation shows a run or
the task is opened again. Send again stays after it when the turn has a
message to send. A turn started by a continued run has no message of its
own; it still gets the bar.
Generated-by: Claude Code
---
products/maka/desktop-shell/app/composer.js | 110 ++++++++++++++++++++++++++
products/maka/desktop-shell/app/main.js | 56 ++++++++-----
products/maka/desktop-shell/app/messages.js | 67 ++++++++++++++++
products/maka/desktop-shell/app/transcript.js | 36 ++++++++-
4 files changed, 248 insertions(+), 21 deletions(-)
diff --git a/products/maka/desktop-shell/app/composer.js b/products/maka/desktop-shell/app/composer.js
index 0b24275be1..d0fecf5eaf 100644
--- a/products/maka/desktop-shell/app/composer.js
+++ b/products/maka/desktop-shell/app/composer.js
@@ -40,6 +40,20 @@ import {
} from './theme.js';
import { t, onLanguageChange } from './locale.js';
+/** Why the Host did not continue a failed run (`AgentResumeParkReason`), in words. */
+const PARKED = {
+ closed: 'resumeClosed',
+ archived: 'resumeArchived',
+ busy: 'resumeBusy',
+ source_missing: 'resumeSourceMissing',
+ source_changed: 'resumeSourceChanged',
+ source_not_resumable: 'resumeNotResumable',
+ source_unreadable: 'resumeSourceUnreadable',
+ already_continued: 'resumeAlreadyContinued',
+ workspace_changed: 'resumeWorkspaceChanged',
+ safety_check_failed: 'resumeSafetyCheckFailed',
+};
+
/** The most files one message carries (`agent.send` `attachments`). */
const MAX_ATTACHMENTS = 8;
@@ -90,6 +104,13 @@ export class Composer {
this.choices = [];
this.sending = false;
this.stopping = false;
+ /** Whether Continue for a failed run is under way. */
+ this.resuming = false;
+ /**
+ * The last Continue the Host did not answer, `{ id, transcript, runsEnded }`,
+ * or null: it may have started a run, so Continue waits (awaitingResume).
+ */
+ this.unanswered = null;
/** Whether the model menu is open. */
this.menuOpen = false;
/** sessionId (or '' for a new task) -> the model chosen in the menu, which the next send uses. */
@@ -468,6 +489,95 @@ export class Composer {
}
}
+ /**
+ * Continue on a failed turn: `agent.resume` picks the run that failed it up
+ * where it ended, with the model the composer would send with. The run
+ * shows up through the watch. When the Host parks the request, the notice
+ * says why; a run it will not continue loses the button. When the Host
+ * does not answer, the run may have started, so the notice says so and
+ * Continue waits until the conversation shows a run (awaitingResume).
+ */
+ async resume(transcript, turnId) {
+ if (this.resuming || this.sending || this.awaitingResume(transcript)) return;
+ this.resuming = true;
+ this.hooks.changed();
+ try {
+ const reason = await this.continueRun(transcript, turnId);
+ if (reason === null) return;
+ log(`composer: resume parked: ${reason}`);
+ if (
+ reason === 'source_not_resumable' ||
+ reason === 'already_continued' ||
+ reason === 'source_missing'
+ ) {
+ transcript.noContinue.add(turnId);
+ }
+ this.hooks.report(t(PARKED[reason] ?? 'resumeParked', { value: reason }));
+ } catch (error) {
+ if (error.unanswered) {
+ this.unanswered = { id: transcript.id, transcript, runsEnded: transcript.runsEnded };
+ this.hooks.report(t('resumeUnknown'));
+ } else {
+ this.hooks.report(t('resumeFailed', { value: error.message }));
+ }
+ } finally {
+ this.resuming = false;
+ this.hooks.changed();
+ }
+ }
+
+ /** Asks the Host to continue the run that failed `turnId`: null once it started, else why it did not. */
+ async continueRun(transcript, turnId) {
+ const invocationId = await transcript.failedRun(turnId);
+ if (invocationId === null) return 'source_missing';
+ const found = await query(transcript.endpoint, 'execution', {
+ sessionId: transcript.id,
+ invocationId,
+ });
+ if (found.kind !== 'found') return 'source_missing';
+ const { model, thinkingLevel } = await this.selection(transcript);
+ let result;
+ try {
+ result = await operation(transcript.endpoint, 'resume', {
+ sessionId: transcript.id,
+ sourceInvocationId: invocationId,
+ sourceHighWater: found.execution.highWater,
+ model,
+ thinkingLevel,
+ });
+ } catch (error) {
+ log(`composer: resume of ${invocationId} got no answer: ${error.message}`);
+ throw Object.assign(new Error(error.message), { unanswered: true });
+ }
+ if (result.kind === 'parked') return result.reason;
+ log(`composer: resume started ${result.execution.invocationId} from ${invocationId}`);
+ // The new run shows up through the watch; this reads it now so the
+ // failed bar gives way before the first fact arrives.
+ await transcript.observeRun();
+ return null;
+ }
+
+ /**
+ * Whether the failed bar offers Continue for `turnId`: not for a run the
+ * Host will not continue, nor in a task an executor runs, where the
+ * composer starts no runs (selection).
+ */
+ offersContinue(transcript, turnId) {
+ return !transcript.noContinue.has(turnId) && transcript.backend?.kind !== 'executor';
+ }
+
+ /**
+ * Whether Continue in `transcript` waits after a Continue the Host did not
+ * answer: until the conversation shows a run start or end, or until it is
+ * opened again (a new Transcript, once it has read its rows).
+ */
+ awaitingResume(transcript) {
+ const unanswered = this.unanswered;
+ if (unanswered === null || transcript === null || transcript.id !== unanswered.id) return false;
+ if (transcript !== unanswered.transcript) return !transcript.ready;
+ return transcript.run === null && transcript.runsEnded === unanswered.runsEnded;
+ }
+
async stop(transcript) {
const run = transcript?.run;
if (!run || this.stopping) return;
diff --git a/products/maka/desktop-shell/app/main.js b/products/maka/desktop-shell/app/main.js
index d022318bec..98bf142284 100644
--- a/products/maka/desktop-shell/app/main.js
+++ b/products/maka/desktop-shell/app/main.js
@@ -726,21 +726,35 @@ export default class MakaDesktop extends View {
/**
* Above the composer when the newest turn failed: says so and offers to
- * send its message again. Picking the failed run up where it ended
- * (`agent.resume`) is not offered yet.
+ * continue the failed run where it stopped (`agent.resume`), unless the
+ * Host said it will not, and to send the turn's message again when there
+ * is one to send.
*/
failedBar(transcript, turn, cx) {
const m = palette(cx);
- return composerBar(
- icon('status-failed', 16, m.destructive),
- t('turnFailedNote'),
- turn.message === null
- ? null
- : controlButton('send-again', t('sendAgain'), 'outline', this.composer.sending, cx)
- .tooltip(t('sendAgainTip'))
- .on_click(() => this.composer.send(transcript, turn.message)),
- cx,
- );
+ const busy = this.composer.sending || this.composer.resuming;
+ const buttons = [];
+ if (this.composer.offersContinue(transcript, turn.turnId)) {
+ buttons.push(
+ controlButton(
+ 'continue-run',
+ t('continueRun'),
+ 'primary',
+ busy || this.composer.awaitingResume(transcript),
+ cx,
+ )
+ .tooltip(t('continueRunTip'))
+ .on_click(() => this.composer.resume(transcript, turn.turnId)),
+ );
+ }
+ if (turn.message !== null) {
+ buttons.push(
+ controlButton('send-again', t('sendAgain'), 'outline', busy, cx)
+ .tooltip(t('sendAgainTip'))
+ .on_click(() => this.composer.send(transcript, turn.message)),
+ );
+ }
+ return composerBar(icon('status-failed', 16, m.destructive), t('turnFailedNote'), buttons, cx);
}
/**
@@ -752,9 +766,15 @@ export default class MakaDesktop extends View {
return composerBar(
icon('triangle-alert', 16, m.warning),
t('workhubNeedsSubagent'),
- controlButton('workhub-subagents', t('workhubSetUpSubagents'), 'outline', false, cx).on_click(
- () => this.settings.show('subagents'),
- ),
+ [
+ controlButton(
+ 'workhub-subagents',
+ t('workhubSetUpSubagents'),
+ 'outline',
+ false,
+ cx,
+ ).on_click(() => this.settings.show('subagents')),
+ ],
cx,
);
}
@@ -971,8 +991,8 @@ function runState(transcript) {
return transcript.run ? t('working') : null;
}
-/** A bar above the composer, in its column: a glyph, a line, and a button at its end, or none. */
-function composerBar(glyph, words, button, cx) {
+/** A bar above the composer, in its column: a glyph, a line, and the buttons at its end. */
+function composerBar(glyph, words, buttons, cx) {
const m = palette(cx);
return h_flex()
.flex_none()
@@ -995,7 +1015,7 @@ function composerBar(glyph, words, button, cx) {
.bg(m.plate)
.child(glyph)
.child(text(words, 'body', m.ink).flex_1().min_w_0())
- .when(button !== null, (el) => el.child(button)),
+ .children(buttons),
);
}
diff --git a/products/maka/desktop-shell/app/messages.js b/products/maka/desktop-shell/app/messages.js
index 607a016a80..c39da4ab8f 100644
--- a/products/maka/desktop-shell/app/messages.js
+++ b/products/maka/desktop-shell/app/messages.js
@@ -328,6 +328,73 @@ export const MESSAGES = {
'把这一轮的消息再发送一次',
'把這一輪的訊息再傳送一次',
],
+ continueRun: ['Continue', '继续', '繼續'],
+ continueRunTip: [
+ 'Continue the failed run from where it stopped',
+ '从失败的运行中断处继续',
+ '從失敗的執行中斷處繼續',
+ ],
+ resumeFailed: ['Continue: {value}', '继续失败:{value}', '繼續失敗:{value}'],
+ resumeParked: [
+ 'The Host did not continue the run ({value}).',
+ 'Host 没有继续这次运行({value})。',
+ 'Host 沒有繼續這次執行({value})。',
+ ],
+ resumeClosed: [
+ 'The Host is closing, so the run cannot continue.',
+ 'Host 正在关闭,无法继续运行。',
+ 'Host 正在關閉,無法繼續執行。',
+ ],
+ resumeUnknown: [
+ 'The Host did not answer, so the run may or may not have continued. Continue comes back once this task shows a run, or after you open the task again.',
+ 'Host 没有回应,运行可能已经继续,也可能没有。任务显示出运行后,或重新打开任务后,才能再次继续。',
+ 'Host 沒有回應,執行可能已經繼續,也可能沒有。任務顯示出執行後,或重新開啟任務後,才能再次繼續。',
+ ],
+ resumeArchived: [
+ 'This task is archived, so the run cannot continue.',
+ '这个任务已归档,无法继续运行。',
+ '這個任務已封存,無法繼續執行。',
+ ],
+ resumeBusy: [
+ 'Another run is going in this task. Try again when it ends.',
+ '这个任务正有一次运行在进行,结束后再试。',
+ '這個任務正有一次執行在進行,結束後再試。',
+ ],
+ resumeSourceMissing: [
+ 'The failed run could not be found.',
+ '找不到失败的那次运行。',
+ '找不到失敗的那次執行。',
+ ],
+ resumeSourceChanged: [
+ 'The failed run changed since it was read. Try again.',
+ '失败的运行在读取后有了变化,请再试一次。',
+ '失敗的執行在讀取後有了變化,請再試一次。',
+ ],
+ resumeNotResumable: [
+ 'This run cannot be continued. Send the message again instead.',
+ '这次运行无法继续,请改为重新发送消息。',
+ '這次執行無法繼續,請改為重新傳送訊息。',
+ ],
+ resumeSourceUnreadable: [
+ 'The failed run could not be read, so it cannot continue.',
+ '无法读取失败的运行,因此不能继续。',
+ '無法讀取失敗的執行,因此不能繼續。',
+ ],
+ resumeAlreadyContinued: [
+ 'This run was already continued.',
+ '这次运行已经继续过了。',
+ '這次執行已經繼續過了。',
+ ],
+ resumeWorkspaceChanged: [
+ "The task's folder changed since the run, so it cannot continue.",
+ '任务的文件夹在运行后有了变化,无法继续。',
+ '任務的資料夾在執行後有了變化,無法繼續。',
+ ],
+ resumeSafetyCheckFailed: [
+ 'The Host could not confirm it is safe to continue this run.',
+ 'Host 无法确认继续这次运行是安全的。',
+ 'Host 無法確認繼續這次執行是安全的。',
+ ],
// How long ago, in the transcript (transcript.js relativeTime)
justNow: ['just now', '刚刚', '剛剛'],
diff --git a/products/maka/desktop-shell/app/transcript.js b/products/maka/desktop-shell/app/transcript.js
index a33a6229b7..43acbd6cfc 100644
--- a/products/maka/desktop-shell/app/transcript.js
+++ b/products/maka/desktop-shell/app/transcript.js
@@ -226,6 +226,10 @@ export class Transcript {
* status on each).
*/
this.runsEnded = 0;
+ /** turnId -> the invocation id of its run, from the watch's `opened` and `ended` facts. */
+ this.invocations = new Map();
+ /** Turns whose failed run the Host will not continue: Continue is not offered for them. */
+ this.noContinue = new Set();
this.latestKnown = 0;
this.watchHandle = null;
this.watchLast = 0;
@@ -380,8 +384,10 @@ export class Transcript {
* `{ turnId, message }`, with the whole text of the message that started
* it, or null as `message` when only part of that text is loaded, it has
* none, it came with files (the files are not at hand to send again), or a
- * WorkHub notice started the turn. Null otherwise, and while newer history
- * is still to be read. A stopped turn is left alone: the user stopped it.
+ * WorkHub notice started the turn. A turn whose message is not loaded, or
+ * that a continued run started (it has no message of its own), has a null
+ * `message` too. Null otherwise, and while newer history is still to be
+ * read. A stopped turn is left alone: the user stopped it.
*/
failedTurn() {
if (!this.ready || this.after !== null) return null;
@@ -389,7 +395,7 @@ export class Transcript {
const last = this.rows.at(-1);
if (last?.timing?.finished?.outcome !== 'failed') return null;
const start = this.rows.find((row) => row.turnId === last.turnId && row.role === 'user');
- if (!start) return null;
+ if (!start) return { turnId: last.turnId, message: null };
// A turn a WorkHub notice started is WorkHub's to start again, not the user's.
const notice = originOf(start) !== null;
const whole = start.fragments[0]?.offset === 0 && utf8Length(start.text) === start.totalBytes;
@@ -398,6 +404,28 @@ export class Transcript {
return { turnId: last.turnId, message: message.trim() !== '' ? message : null };
}
+ /**
+ * The invocation id of the run that failed `turnId`, for `agent.resume`:
+ * from the watch when the run ended while this was open, else from the
+ * newest execution entries of `agent.diagnostics` (a few pages at most).
+ * Null when neither has it.
+ */
+ async failedRun(turnId) {
+ const seen = this.invocations.get(turnId);
+ if (seen !== undefined) return seen;
+ let position = { kind: 'latest' };
+ for (let pages = 0; pages < 4; pages += 1) {
+ const page = await query(this.endpoint, 'diagnostics', { sessionId: this.id, position });
+ const entry = page.entries.find(
+ (each) => each.detail?.kind === 'execution' && each.detail.invocation?.turn_id === turnId,
+ );
+ if (entry) return entry.detail.invocation.invocation_id;
+ if (page.nextBefore === null || page.nextBefore === undefined) return null;
+ position = { kind: 'before', through: page.through, sequence: page.nextBefore };
+ }
+ return null;
+ }
+
/** Stops the live updates. A pending `next()` resolves with null. */
async close() {
this.closed = true;
@@ -442,6 +470,7 @@ export class Transcript {
const fact = event.fact;
switch (fact.kind) {
case 'opened':
+ if (event.turnId) this.invocations.set(event.turnId, event.invocationId);
if (event.sequence > this.runThrough) {
this.run = { invocationId: event.invocationId, turnId: event.turnId, state: 'running' };
this.runThrough = event.sequence;
@@ -505,6 +534,7 @@ export class Transcript {
canonicalChanged = true;
break;
case 'ended':
+ if (event.turnId) this.invocations.set(event.turnId, event.invocationId);
this.closeLive(event.sequence, event.invocationId);
this.parts = this.parts.filter((open) => open.invocationId !== event.invocationId);
if (event.sequence > this.runThrough) this.runsEnded += 1;
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org