Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,55 @@ test('retries Goal clear only while the same Goal generation remains active', as
);
});

test('controlGoalWithRetry applies pause/resume with the queried revision', async () => {
const active = goalProjection(1);
const paused = { ...goalProjection(2), status: 'paused' as const, pausedAt: 5 };
const { client, requests } = clientWithResponses([
{ sessionId: 'session-1', goal: active }, // queryGoal
{ sessionId: 'session-1', goal: paused }, // goal.control pause result
{ sessionId: 'session-1', goal: paused }, // queryGoal for resume
{ sessionId: 'session-1', goal: { ...goalProjection(3) } }, // goal.control resume result
]);

await client.controlGoalWithRetry('session-1', 'pause');
await client.controlGoalWithRetry('session-1', 'resume');

assert.deepEqual(
requests.filter(({ operation }) => operation === 'goal.control').map(({ input }) => input),
[
{ sessionId: 'session-1', goalId: 'goal-1', expectedRevision: 1, action: 'pause' },
{ sessionId: 'session-1', goalId: 'goal-1', expectedRevision: 2, action: 'resume' },
],
);
});

test('controlGoalWithRetry rethrows a status refusal instead of retrying it away', async () => {
// The host folds invalid transitions into operation_conflict. Every accepted
// transition bumps the revision, so a conflict at an unchanged revision is a
// status refusal — the reason must surface, not a retry-exhaustion error.
const paused = { ...goalProjection(2), status: 'paused' as const, pausedAt: 5 };
const refusal = new RuntimeHostOperationError(
'goal.control',
'operation_conflict',
'Goal cannot pause from status paused',
);
const { client, requests } = clientWithResponses([
{ sessionId: 'session-1', goal: paused }, // queryGoal
refusal, // goal.control conflict
{ sessionId: 'session-1', goal: paused }, // re-query: SAME revision
]);

await assert.rejects(
() => client.controlGoalWithRetry('session-1', 'pause'),
/Goal cannot pause from status paused/,
);
// No futile retries: exactly one control attempt.
assert.equal(
requests.filter(({ operation }) => operation === 'goal.control').length,
1,
);
});

test('rejects an invalid sidecar continuation without misclassifying it as revision churn', async () => {
const revision = catalogRevision('7');
const { client, requests } = clientWithResponses([
Expand Down
20 changes: 17 additions & 3 deletions apps/desktop/src/main/runtime-host-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1148,14 +1148,15 @@ export class DesktopRuntimeHostClient {
});
}

async clearGoal(sessionId: string): Promise<void> {
async controlGoalWithRetry(sessionId: string, action: GoalControlAction): Promise<void> {
const initial = await this.queryGoal(sessionId);
if (initial.goal === null) return;
const goalId = initial.goal.goalId;
let goal = initial.goal;
let conflict: RuntimeHostOperationError | null = null;
for (let attempt = 0; attempt < MAX_OPTIMISTIC_ATTEMPTS; attempt += 1) {
try {
await this.controlGoal(goal, "clear");
await this.controlGoal(goal, action);
return;
} catch (error) {
if (
Expand All @@ -1164,12 +1165,25 @@ export class DesktopRuntimeHostClient {
) {
throw error;
}
conflict = error;
if (attempt === MAX_OPTIMISTIC_ATTEMPTS - 1) break; // a re-query would have no retry to serve
}
const current = await this.queryGoal(sessionId);
if (current.goal === null || current.goal.goalId !== goalId) return;
if (current.goal.revision === goal.revision) {
// The host folds invalid transitions into operation_conflict too
// ("Goal cannot pause from status paused"). Every accepted transition
// bumps the revision, so a conflict at an unchanged revision is a
// status refusal, not a race — retrying is futile; surface the reason.
throw conflict;
}
goal = current.goal;
}
throw revisionConflict("Goal clear", sessionId);
throw revisionConflict(`Goal ${action}`, sessionId);
}

async clearGoal(sessionId: string): Promise<void> {
await this.controlGoalWithRetry(sessionId, "clear");
}

async getPlanState(sessionId: string): Promise<PlanSessionState> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type RuntimeHostSessionDomainClient = RuntimeHostShellRunsClient &
Pick<
DesktopRuntimeHostClient,
| 'clearGoal'
| 'controlGoalWithRetry'
| 'controlPlan'
| 'getRuntimeResource'
| 'getPlanState'
Expand Down Expand Up @@ -87,6 +88,12 @@ export function registerRuntimeHostSessionDomainsIpc(
ipcMain.handle('goal:clear', async (_event, sessionId: unknown) => {
await deps.client.clearGoal(requiredId(sessionId, 'Session'));
});
ipcMain.handle('goal:pause', async (_event, sessionId: unknown) => {
await deps.client.controlGoalWithRetry(requiredId(sessionId, 'Session'), 'pause');
});
ipcMain.handle('goal:resume', async (_event, sessionId: unknown) => {
await deps.client.controlGoalWithRetry(requiredId(sessionId, 'Session'), 'resume');
});

handleReconnectableRead(ipcMain, 'plan-mode:getState', (_event, sessionId: unknown) =>
deps.client.getPlanState(requiredId(sessionId, 'Session')),
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/preload/bridge-contract.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,10 @@ export interface MakaBridge {
get(sessionId: string): Promise<import('@maka/runtime/goal-state').GoalState | null>;
/** Clear the active goal, stopping autonomous continuation. */
clear(sessionId: string): Promise<void>;
/** Pause the active goal without spending a model turn. */
pause(sessionId: string): Promise<void>;
/** Resume a paused goal without spending a model turn. */
resume(sessionId: string): Promise<void>;
};
connections: {
getSnapshot(sessionId?: string, host?: DesktopRuntimeHostRef): Promise<DesktopConnectionSnapshot>;
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1863,6 +1863,12 @@ const makaBridge = {
clear(sessionId: string): Promise<void> {
return invokeSessionRuntimeHost('goal:clear', sessionId);
},
pause(sessionId: string): Promise<void> {
return invokeSessionRuntimeHost('goal:pause', sessionId);
},
resume(sessionId: string): Promise<void> {
return invokeSessionRuntimeHost('goal:resume', sessionId);
},
},
connections: {
getSnapshot(sessionId?: string, host?: DesktopRuntimeHostRef) {
Expand Down
95 changes: 80 additions & 15 deletions apps/desktop/src/renderer/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,22 @@ function AppShellContent({
// Active autonomous goal for the current session drives the header
// kill-switch pill (visible indicator + one-click clear).
const activeGoal = useSessionGoal(activeId);
const pendingGoalControlSessionIdsRef = useRef(new Set<string>());
const runGoalControl = useCallback(
(
sessionId: string,
operation: () => Promise<unknown>,
reportFailure: (error: unknown) => void,
): void => {
const pending = pendingGoalControlSessionIdsRef.current;
if (pending.has(sessionId)) return;
pending.add(sessionId);
void operation()
.catch(reportFailure)
.finally(() => pending.delete(sessionId));
},
[],
);
// Set of session ids whose backend / connection is no longer usable —
// drives the sidebar "已过期" pill (PR108g, paired with the PR108e chat
// header banner). Derivation is pure (see `stale-sessions.ts`) so the
Expand Down Expand Up @@ -3290,27 +3306,76 @@ function AppShellContent({
memoryActive={memoryActive}
onOpenMemorySettings={() => openSettingsSection('memory')}
goalIndicator={
activeGoal
? {
condition: activeGoal.condition,
status: activeGoal.status,
iterations: activeGoal.iterations,
maxIterations: activeGoal.maxIterations,
onClear: () => {
void window.maka.goal.clear(activeGoal.sessionId).catch((error) => {
activeGoal
? (() => {
const common = {
condition: activeGoal.condition,
iterations: activeGoal.iterations,
maxIterations: activeGoal.maxIterations,
setAt: activeGoal.setAt,
tokensSpent: activeGoal.tokensNow,
...(activeGoal.tokenBudget !== undefined
? { tokenBudget: activeGoal.tokenBudget }
: {}),
onClear: () => {
void window.maka.goal.clear(activeGoal.sessionId).catch((error) => {
toastApi.error(
shellCopy.goalClearFailedTitle,
localizedShellErrorMessage(
error,
shellCopy.goalClearFailedFallback,
uiLocale,
),
);
});
},
};
if (activeGoal.status === 'paused') {
return {
...common,
status: 'paused' as const,
pausedAt: activeGoal.pausedAt,
onResume: () => {
runGoalControl(
activeGoal.sessionId,
() => window.maka.goal.resume(activeGoal.sessionId),
(error) => {
toastApi.error(
shellCopy.goalResumeFailedTitle,
localizedShellErrorMessage(
error,
shellCopy.goalResumeFailedFallback,
uiLocale,
),
);
},
);
},
};
}
return {
...common,
status: activeGoal.status,
onPause: () => {
runGoalControl(
activeGoal.sessionId,
() => window.maka.goal.pause(activeGoal.sessionId),
(error) => {
toastApi.error(
shellCopy.goalClearFailedTitle,
shellCopy.goalPauseFailedTitle,
localizedShellErrorMessage(
error,
shellCopy.goalClearFailedFallback,
shellCopy.goalPauseFailedFallback,
uiLocale,
),
);
});
},
}
: undefined
}
},
);
},
};
})()
: undefined
}
messageLoadError={activeId ? messageLoadErrorBySession[activeId] : undefined}
messageLoadRetryPending={activeId ? messageRetryPendingBySession[activeId] === true : false}
onRetryMessages={activeId ? () => void retryMessages(activeId) : undefined}
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src/renderer/locales/shell-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,10 @@ type ShellCopy = {
resumeFailedFallback: string;
goalClearFailedTitle: string;
goalClearFailedFallback: string;
goalPauseFailedTitle: string;
goalPauseFailedFallback: string;
goalResumeFailedTitle: string;
goalResumeFailedFallback: string;
appearanceLoadErrorTitle: string;
appearanceLoadErrorFallback: string;
memoryRefreshErrorTitle: string;
Expand Down Expand Up @@ -1084,6 +1088,10 @@ const SHELL_COPY_BY_LOCALE = {
resumeFailedFallback: '无法启动安全恢复,请检查任务状态后重试。',
goalClearFailedTitle: '停止目标失败',
goalClearFailedFallback: '目标仍可能继续运行,请立即重试。',
goalPauseFailedTitle: '暂停目标失败',
goalPauseFailedFallback: '目标可能仍在自动续行,请立即重试。',
goalResumeFailedTitle: '恢复目标失败',
goalResumeFailedFallback: '目标仍处于暂停状态,请重试。',
appearanceLoadErrorTitle: '载入外观设置失败',
appearanceLoadErrorFallback: '外观设置暂时无法载入,请稍后重试。',
memoryRefreshErrorTitle: '刷新本地记忆状态失败',
Expand Down Expand Up @@ -1602,6 +1610,10 @@ const SHELL_COPY_BY_LOCALE = {
resumeFailedFallback: 'Safe recovery could not start. Check the task state and try again.',
goalClearFailedTitle: 'Could not stop the goal',
goalClearFailedFallback: 'The goal may still be running. Try again now.',
goalPauseFailedTitle: 'Could not pause the goal',
goalPauseFailedFallback: 'The goal may still be continuing. Try again now.',
goalResumeFailedTitle: 'Could not resume the goal',
goalResumeFailedFallback: 'The goal is still paused. Try again.',
appearanceLoadErrorTitle: 'Could not load appearance settings',
appearanceLoadErrorFallback: 'Appearance settings are temporarily unavailable. Try again later.',
memoryRefreshErrorTitle: 'Could not refresh local memory status',
Expand Down
33 changes: 25 additions & 8 deletions apps/desktop/src/renderer/use-session-goal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
*
* Reads the session's goal via the preload bridge and re-fetches whenever the
* main process emits a `goal-change` session event (goal set / continue /
* terminal / clear). Only surfaces goals that are still running (active or
* waiting, or paused); a settled goal returns null so the session context disappears.
* pause / resume / terminal / clear). Only surfaces goals that are still
* running (active or waiting, or paused); a settled goal returns null so the
* session context disappears.
*
* Kept as a tiny standalone hook (no app-shell coupling) so an autonomous,
* token-burning loop always has a visible indicator and a one-click stop,
Expand All @@ -13,26 +14,41 @@
import { useEffect, useState } from 'react';
import type { GoalState, GoalStatus } from '@maka/runtime/goal-state';

const RUNNING_GOAL_STATUSES: ReadonlySet<GoalStatus> = new Set(['active', 'waiting', 'paused']);
type LiveGoalStatus = Extract<GoalStatus, 'active' | 'waiting'>;
type LiveGoalState =
| (GoalState & { readonly status: LiveGoalStatus })
| (GoalState & { readonly status: 'paused'; readonly pausedAt: number });

export function useSessionGoal(sessionId: string | undefined): GoalState | null {
const [goal, setGoal] = useState<GoalState | null>(null);
const LIVE_GOAL_STATUSES: ReadonlySet<GoalStatus> = new Set(['active', 'waiting', 'paused']);

function isLiveGoal(goal: GoalState): goal is LiveGoalState {
return (
LIVE_GOAL_STATUSES.has(goal.status) &&
(goal.status !== 'paused' ||
(typeof goal.pausedAt === 'number' && Number.isFinite(goal.pausedAt)))
);
}

export function useSessionGoal(sessionId: string | undefined): LiveGoalState | null {
const [goal, setGoal] = useState<LiveGoalState | null>(null);

useEffect(() => {
if (!sessionId) {
setGoal(null);
return;
}
let cancelled = false;
let refreshSequence = 0;
const refresh = (): void => {
const sequence = ++refreshSequence;
void window.maka.goal
.get(sessionId)
.then((g) => {
if (cancelled) return;
setGoal(g && RUNNING_GOAL_STATUSES.has(g.status) ? g : null);
if (cancelled || sequence !== refreshSequence) return;
setGoal(g && isLiveGoal(g) ? g : null);
Comment thread
me2seeks marked this conversation as resolved.
})
.catch(() => {
if (!cancelled) setGoal(null);
if (!cancelled && sequence === refreshSequence) setGoal(null);
});
};
refresh();
Expand All @@ -45,6 +61,7 @@ export function useSessionGoal(sessionId: string | undefined): GoalState | null
});
return () => {
cancelled = true;
refreshSequence += 1;
unsubscribe();
};
}, [sessionId]);
Expand Down
Loading
Loading