From be6b2b84592588f463e62047d19d646ded60520e Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Fri, 21 Aug 2026 15:15:42 +0800 Subject: [PATCH 1/5] fix(desktop): show armed goals waiting for their first Turn Generated-by: Codex --- .../runtime-host-client-operations.test.ts | 1 + ...runtime-host-session-domains-ipc-main.test.ts | 2 ++ .../runtime-host-session-observer.test.ts | 2 ++ .../runtime-host-session-domains-ipc-main.ts | 1 + .../renderer/__tests__/use-session-goal.test.ts | 9 +++++++++ packages/cli/src/__tests__/pi-goal.test.ts | 1 + packages/cli/src/__tests__/pi-transcript.test.ts | 1 + packages/cli/src/__tests__/pi-tui-runner.test.ts | 1 + .../runtime-host-session-driver.test.ts | 1 + .../src/__tests__/goal-coordinator.test.ts | 2 ++ .../src/__tests__/goal-protocol.test.ts | 3 +++ .../runtime-host/src/__tests__/protocol.test.ts | 6 ++++++ packages/runtime-host/src/protocol/goal.ts | 4 ++++ packages/runtime-host/src/protocol/index.ts | 5 ++++- .../runtime-host/src/server/goal-projection.ts | 2 ++ .../session-context-layer-goal.test.tsx | 16 ++++++++++++++++ packages/ui/src/conversation-copy.ts | 5 +++-- packages/ui/src/session-context-layer.tsx | 9 +++++++-- 18 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/renderer/__tests__/use-session-goal.test.ts diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts index 9ef2232e37..2c7211328c 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts @@ -892,6 +892,7 @@ function goalProjection(revision: number) { lastReason: null, achievedAt: null, pausedAt: null, + armedAt: null, }; } diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts index ee817a5027..0d0db0f323 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts @@ -490,6 +490,7 @@ test('adapts Host Goal, Task, Deep Research, and Resource projections', async () tokensAtStart: 0, tokensNow: 120, tokensBaselinePending: false, + armedAt: 7, }); await ipc.invoke('goal:clear', 'session-1'); await ipc.invoke('goal:pause', 'session-1'); @@ -1217,6 +1218,7 @@ function baseGoalProjection() { lastReason: null, achievedAt: null, pausedAt: null, + armedAt: 7, }; } diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts index b022782d46..d3bebfac3f 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts @@ -2402,6 +2402,7 @@ test("publishes Host sidecar and graph invalidations without inventing Session s lastReason: null, achievedAt: null, pausedAt: null, + armedAt: null, }, }), }); @@ -2510,6 +2511,7 @@ function activeGoal() { lastReason: null, achievedAt: null, pausedAt: null, + armedAt: null, }; } diff --git a/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts index c0fd5aaa38..0c2fa06aad 100644 --- a/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts @@ -477,6 +477,7 @@ function toDesktopGoal(goal: GoalProjection): GoalState { ...(goal.lastReason === null ? {} : { lastReason: goal.lastReason }), ...(goal.achievedAt === null ? {} : { achievedAt: goal.achievedAt }), ...(goal.pausedAt === null ? {} : { pausedAt: goal.pausedAt }), + ...(goal.armedAt === null ? {} : { armedAt: goal.armedAt }), }; } diff --git a/apps/desktop/src/renderer/__tests__/use-session-goal.test.ts b/apps/desktop/src/renderer/__tests__/use-session-goal.test.ts new file mode 100644 index 0000000000..efff01ba63 --- /dev/null +++ b/apps/desktop/src/renderer/__tests__/use-session-goal.test.ts @@ -0,0 +1,9 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { isGoalArmedAwaitingFirstTurn } from '../use-session-goal.js'; + +test('an armed Goal stops awaiting its first Turn once that Turn is active', () => { + assert.equal(isGoalArmedAwaitingFirstTurn({ armedAt: 1 }, false), true); + assert.equal(isGoalArmedAwaitingFirstTurn({ armedAt: 1 }, true), false); + assert.equal(isGoalArmedAwaitingFirstTurn({}, false), false); +}); diff --git a/packages/cli/src/__tests__/pi-goal.test.ts b/packages/cli/src/__tests__/pi-goal.test.ts index 0dcf8484bb..994502733e 100644 --- a/packages/cli/src/__tests__/pi-goal.test.ts +++ b/packages/cli/src/__tests__/pi-goal.test.ts @@ -51,6 +51,7 @@ function goal(overrides: Partial = {}): GoalProjection { lastReason: null, achievedAt: null, pausedAt: null, + armedAt: null, ...overrides, }; } diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 72003776aa..06ae0399f2 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -155,6 +155,7 @@ describe('Maka Pi TUI transcript', () => { lastReason: null, achievedAt: null, pausedAt: null, + armedAt: null, } as const; const active = stripAnsi( renderMakaPiStatusLine({ ...meta(), goal: { ...base, status: 'active' as const } }, 120), diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 930513d668..b8d058ce34 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -5730,6 +5730,7 @@ describe('Maka Pi TUI runner', () => { lastReason: 'tests still failing', achievedAt: null, pausedAt: null, + armedAt: null, }; test('/goal prints the live goal summary and the status line carries the indicator', async () => { diff --git a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts index 1b9dc7abb1..e9e3496a28 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -1844,6 +1844,7 @@ function goalProjection(overrides: Partial = {}): GoalProjection lastReason: null, achievedAt: null, pausedAt: null, + armedAt: null, ...overrides, }; } diff --git a/packages/runtime-host/src/__tests__/goal-coordinator.test.ts b/packages/runtime-host/src/__tests__/goal-coordinator.test.ts index 497aeb1290..1b0ae5497b 100644 --- a/packages/runtime-host/src/__tests__/goal-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/goal-coordinator.test.ts @@ -599,6 +599,7 @@ test('goal.arm creates one Goal per Session and refuses a second while it is unf if (!armed.ok) return; assert.equal(armed.result.goal.goalId, 'goal-armed'); assert.equal(armed.result.goal.status, 'active'); + assert.equal(armed.result.goal.armedAt, 10); assert.equal(armed.result.goal.maxIterations, 20); assert.equal(armed.result.goal.tokenBudget, 50_000); assert.deepEqual( @@ -751,6 +752,7 @@ test('a Goal armed but never carried by a Turn does not start itself after a res const goal = restarted.readProjection(session.id); assert.equal(goal?.status, 'active', 'the armed Goal survives the restart untouched'); assert.equal(goal?.iterations, 0, 'and no Turn ran for it'); + assert.ok(goal?.armedAt !== null, 'the projection keeps its armed state'); await restarted.close(); } finally { await goalStore.close(); diff --git a/packages/runtime-host/src/__tests__/goal-protocol.test.ts b/packages/runtime-host/src/__tests__/goal-protocol.test.ts index f922dad0ce..470354b7a6 100644 --- a/packages/runtime-host/src/__tests__/goal-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/goal-protocol.test.ts @@ -45,6 +45,7 @@ const goal = { lastReason: 'Waiting for an exact resume', achievedAt: null, pausedAt: 2, + armedAt: null, }; test('Goal query and exact-revision control frames round-trip', () => { @@ -135,6 +136,8 @@ test('Goal projection is part of the exact Session continuity schema', () => { }); test('Goal projection rejects unknown fields and text beyond the shared UTF-8 boundary', () => { + const { armedAt: _armedAt, ...legacyGoal } = goal; + assert.throws(() => decodeGoalProjection(legacyGoal)); assert.throws(() => decodeGoalProjection({ ...goal, extra: true })); assert.throws(() => decodeGoalProjection({ ...goal, condition: '界'.repeat(501) })); assert.throws(() => decodeGoalProjection({ ...goal, lastReason: '界'.repeat(501) })); diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 146c199f07..fae4c963fa 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -97,6 +97,12 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 22); }); + test('publishes a new compatibility epoch for the Goal armed-state projection', () => { + // GoalProjection has an exact key set, so adding armedAt requires mixed + // Client-Host peers to fail during the handshake. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 31); + }); + test('rejects the legacy connection update result in the current compatibility epoch', () => { assert.throws( () => diff --git a/packages/runtime-host/src/protocol/goal.ts b/packages/runtime-host/src/protocol/goal.ts index cab1ff1b3a..b2bff18969 100644 --- a/packages/runtime-host/src/protocol/goal.ts +++ b/packages/runtime-host/src/protocol/goal.ts @@ -65,6 +65,8 @@ export interface GoalProjection { readonly lastReason: string | null; readonly achievedAt: number | null; readonly pausedAt: number | null; + /** Epoch ms while a user-armed Goal is waiting for its first Turn. */ + readonly armedAt: number | null; } export interface GoalQueryInput { @@ -169,6 +171,7 @@ export function decodeGoalProjection(value: unknown): GoalProjection { 'lastReason', 'achievedAt', 'pausedAt', + 'armedAt', ]); const condition = requireUtf8String( record.condition, @@ -195,6 +198,7 @@ export function decodeGoalProjection(value: unknown): GoalProjection { lastReason, achievedAt: requireNullableCount(record.achievedAt, 'Goal achievedAt'), pausedAt: requireNullableCount(record.pausedAt, 'Goal pausedAt'), + armedAt: requireNullableCount(record.armedAt, 'Goal armedAt'), }; } diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index eb63d7d0ab..be0877cd26 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -91,7 +91,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 44 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 45 as const; +// 45: Goal projections carry armedAt and boundTurnId. The exact projection +// schema makes this a closed wire change, so mixed-version peers must fail +// before decoding it. // 44: Session continuity and inspection stop carrying the retired Session // last-used timestamp. Older peers reject those strict projection shapes. // 43: Session tool-start events correlate hidden shell polls with `shellRunRef`. diff --git a/packages/runtime-host/src/server/goal-projection.ts b/packages/runtime-host/src/server/goal-projection.ts index f695841643..9bedb4180d 100644 --- a/packages/runtime-host/src/server/goal-projection.ts +++ b/packages/runtime-host/src/server/goal-projection.ts @@ -41,6 +41,7 @@ export function projectGoalState(goal: GoalState): GoalProjection { lastReason: goal.lastReason ?? null, achievedAt: goal.achievedAt ?? null, pausedAt: goal.pausedAt ?? null, + armedAt: goal.armedAt ?? null, }); } @@ -62,5 +63,6 @@ export function worstCaseGoalProjection(sessionId: string): GoalProjection { lastReason: '界'.repeat(GOAL_REASON_TEXT_LIMIT.codeUnits), achievedAt: Number.MAX_SAFE_INTEGER, pausedAt: Number.MAX_SAFE_INTEGER, + armedAt: Number.MAX_SAFE_INTEGER, }; } diff --git a/packages/ui/src/__tests__/session-context-layer-goal.test.tsx b/packages/ui/src/__tests__/session-context-layer-goal.test.tsx index 087b30a82a..12658e3d73 100644 --- a/packages/ui/src/__tests__/session-context-layer-goal.test.tsx +++ b/packages/ui/src/__tests__/session-context-layer-goal.test.tsx @@ -104,3 +104,19 @@ test('a waiting goal reads as waiting without looking active or paused', () => { assert.ok(!markup.includes('Resume autonomous goal')); assert.ok(markup.includes('12k / 100k')); }); + +test('an armed goal waits for its first Turn without looking like it is running', () => { + const markup = renderGoalChip({ + condition: 'Ship the feature', + status: 'active', + armedAt: Date.now() - 30_000, + iterations: 0, + maxIterations: 50, + setAt: Date.now() - 30_000, + onPause: () => undefined, + onClear: () => undefined, + }); + assert.ok(markup.includes('Autonomous goal set; takes hold on the next Turn')); + assert.ok(!markup.includes('Autonomous goal running')); + assert.ok(!markup.includes('Autonomous goal paused')); +}); diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index 983089caa1..9752af0d99 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -313,6 +313,7 @@ export interface ConversationCopy { clearGoalAriaLabel: (iteration: number, max: number) => string; goalProgress: (iteration: number, max: number) => string; goalRunningAriaLabel: string; + goalArmedAriaLabel: string; goalWaitingAriaLabel: string; goalPausedAriaLabel: string; pauseGoalAriaLabel: (iteration: number, max: number) => string; @@ -505,7 +506,7 @@ const CONVERSATION_COPY = { verification: '验证', }, }, - clearGoal: (condition, iteration, max, status) => `自主执行目标进行中:「${condition}」(第 ${iteration}/${max} 轮,${status})。系统每轮后自动续行;点击可清除目标、停止续行。`, clearGoalAriaLabel: (iteration, max) => `清除自主执行目标(已进行 ${iteration}/${max} 轮)`, goalProgress: (iteration, max) => `目标 ${iteration} / ${max}`, goalRunningAriaLabel: '自主目标正在运行', goalWaitingAriaLabel: '自主目标正在等待条件变化', + clearGoal: (condition, iteration, max, status) => `自主执行目标进行中:「${condition}」(第 ${iteration}/${max} 轮,${status})。系统每轮后自动续行;点击可清除目标、停止续行。`, clearGoalAriaLabel: (iteration, max) => `清除自主执行目标(已进行 ${iteration}/${max} 轮)`, goalProgress: (iteration, max) => `目标 ${iteration} / ${max}`, goalRunningAriaLabel: '自主目标正在运行', goalArmedAriaLabel: '自主目标已设置,将在下一轮接管', goalWaitingAriaLabel: '自主目标正在等待条件变化', goalPausedAriaLabel: '自主目标已暂停', pauseGoalAriaLabel: (iteration, max) => `暂停自主执行目标(已进行 ${iteration}/${max} 轮)`, resumeGoalAriaLabel: (iteration, max) => `恢复自主执行目标(已进行 ${iteration}/${max} 轮)`, pauseGoal: (condition, iteration, max, status) => `暂停自主执行目标:「${condition}」(第 ${iteration}/${max} 轮,${status})。暂停后立即停止自动续行,不再消耗令牌;可随时恢复。`, resumeGoal: (condition, iteration, max) => `恢复自主执行目标:「${condition}」(第 ${iteration}/${max} 轮)。恢复后立即继续自动续行。`, goalElapsed: (elapsedMs) => formatGoalElapsedUnits(elapsedMs, { second: ' 秒', minute: ' 分钟', hour: ' 小时', day: ' 天' }), goalTokens: (spent, budget) => `${formatCompactTokenCount(spent)} / ${formatCompactTokenCount(budget)}`, loadFailed: '任务载入失败', loading: '载入中…', retryLoad: '重试载入', quoteSelection: '引用', askInSidePanel: '在侧栏追问', noMessages: '暂无消息', branchBeforeInterrupt: '从中断前分支', sessionContextAriaLabel: '任务上下文', sessionLineageAriaLabel: '任务来源', sessionContextMore: (count) => `更多任务上下文(${count})`, @@ -653,7 +654,7 @@ const CONVERSATION_COPY = { verification: 'Verification', }, }, - clearGoal: (condition, iteration, max, status) => `Autonomous goal in progress: “${condition}” (iteration ${iteration}/${max}, ${status}). Maka continues after each iteration; click to clear the goal and stop continuing.`, clearGoalAriaLabel: (iteration, max) => `Clear autonomous goal after ${iteration}/${max} iterations`, goalProgress: (iteration, max) => `Goal ${iteration} of ${max}`, goalRunningAriaLabel: 'Autonomous goal running', goalWaitingAriaLabel: 'Autonomous goal waiting for conditions to change', + clearGoal: (condition, iteration, max, status) => `Autonomous goal in progress: “${condition}” (iteration ${iteration}/${max}, ${status}). Maka continues after each iteration; click to clear the goal and stop continuing.`, clearGoalAriaLabel: (iteration, max) => `Clear autonomous goal after ${iteration}/${max} iterations`, goalProgress: (iteration, max) => `Goal ${iteration} of ${max}`, goalRunningAriaLabel: 'Autonomous goal running', goalArmedAriaLabel: 'Autonomous goal set; takes hold on the next Turn', goalWaitingAriaLabel: 'Autonomous goal waiting for conditions to change', goalPausedAriaLabel: 'Autonomous goal paused', pauseGoalAriaLabel: (iteration, max) => `Pause autonomous goal after ${iteration}/${max} iterations`, resumeGoalAriaLabel: (iteration, max) => `Resume autonomous goal after ${iteration}/${max} iterations`, pauseGoal: (condition, iteration, max, status) => `Pause autonomous goal: “${condition}” (iteration ${iteration}/${max}, ${status}). Pausing stops autonomous continuation immediately — no more tokens burn; resume any time.`, resumeGoal: (condition, iteration, max) => `Resume autonomous goal: “${condition}” (iteration ${iteration}/${max}). Resuming continues autonomous iteration immediately.`, goalElapsed: (elapsedMs) => formatGoalElapsedUnits(elapsedMs, { second: 's', minute: 'm', hour: 'h', day: 'd' }), goalTokens: (spent, budget) => `${formatCompactTokenCount(spent)} / ${formatCompactTokenCount(budget)}`, loadFailed: 'Task failed to load', loading: 'Loading…', retryLoad: 'Retry', quoteSelection: 'Quote', askInSidePanel: 'Ask in side panel', noMessages: 'No messages yet', branchBeforeInterrupt: 'Branched before interruption', sessionContextAriaLabel: 'Task context', sessionLineageAriaLabel: 'Task origin', sessionContextMore: (count) => `More task context (${count})`, diff --git a/packages/ui/src/session-context-layer.tsx b/packages/ui/src/session-context-layer.tsx index ddc9b62e28..ea86cf4453 100644 --- a/packages/ui/src/session-context-layer.tsx +++ b/packages/ui/src/session-context-layer.tsx @@ -55,8 +55,10 @@ interface SessionContextGoalBase { condition: string; iterations: number; maxIterations: number; - /** Epoch ms when the goal was armed; the chip derives wall-clock elapsed. */ + /** Epoch ms when the goal was set; the chip derives wall-clock elapsed. */ setAt: number; + /** Present while a user-armed Goal waits for its first Turn. */ + armedAt?: number; tokensSpent?: number; /** When present (a budget exists), the chip shows spent / budget. */ tokenBudget?: number; @@ -117,6 +119,7 @@ export function SessionContextLayer(props: { // attention tone. const paused = goal.status === 'paused'; const waiting = goal.status === 'waiting'; + const armed = goal.armedAt !== undefined; const elapsedMs = paused ? Math.max(0, goal.pausedAt - goal.setAt) : Math.max(0, Date.now() - goal.setAt); @@ -158,11 +161,13 @@ export function SessionContextLayer(props: { label={ paused ? copy.goalPausedAriaLabel + : armed + ? copy.goalArmedAriaLabel : waiting ? copy.goalWaitingAriaLabel : copy.goalRunningAriaLabel } - isPulsing={!paused && !waiting} + isPulsing={!paused && !armed && !waiting} /> {goalText} From 682ac6ea9c3aec2bd3d78875a57d2722ba5aa18b Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Sun, 23 Aug 2026 21:09:04 +0800 Subject: [PATCH 2/5] fix(goal): distinguish armed goals from running turns --- .../runtime-host-client-operations.test.ts | 1 + ...time-host-session-domains-ipc-main.test.ts | 3 ++ .../runtime-host-session-observer.test.ts | 2 + .../runtime-host-session-domains-ipc-main.ts | 6 ++- apps/desktop/src/preload/bridge-contract.d.ts | 2 +- apps/desktop/src/preload/preload.ts | 4 +- .../__tests__/use-session-goal.test.ts | 11 +++-- apps/desktop/src/shared/desktop-goal-state.ts | 25 ++++++++++ apps/desktop/src/shared/goal-arm.ts | 6 +-- packages/cli/src/__tests__/pi-goal.test.ts | 17 +++++++ .../cli/src/__tests__/pi-transcript.test.ts | 1 + .../cli/src/__tests__/pi-tui-runner.test.ts | 1 + .../runtime-host-session-driver.test.ts | 1 + packages/cli/src/pi-goal.ts | 23 +++++++-- packages/cli/src/pi-tui-runner.ts | 11 ++--- packages/core/src/goal.ts | 17 +++---- .../src/__tests__/goal-coordinator.test.ts | 3 ++ .../src/__tests__/goal-projection.test.ts | 47 +++++++++++++++++++ .../src/__tests__/goal-protocol.test.ts | 3 ++ .../src/__tests__/protocol.test.ts | 6 +-- packages/runtime-host/src/protocol/goal.ts | 10 +++- .../src/server/goal-coordinator.ts | 9 +++- .../src/server/goal-projection.ts | 7 ++- .../runtime/src/__tests__/goal-state.test.ts | 30 ++++++++++++ packages/runtime/src/goal-continuation.ts | 15 ++++++ packages/runtime/src/goal-state.ts | 10 +++- .../session-context-layer-goal.test.tsx | 4 ++ packages/ui/src/conversation-copy.ts | 18 ++++--- packages/ui/src/session-context-layer.tsx | 17 +++++-- 29 files changed, 258 insertions(+), 52 deletions(-) create mode 100644 apps/desktop/src/shared/desktop-goal-state.ts create mode 100644 packages/runtime-host/src/__tests__/goal-projection.test.ts diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts index 2c7211328c..b3e9aceb56 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts @@ -893,6 +893,7 @@ function goalProjection(revision: number) { achievedAt: null, pausedAt: null, armedAt: null, + boundTurnId: null, }; } diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts index 0d0db0f323..582cc5023a 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts @@ -117,6 +117,7 @@ test('goal:arm reconciles a lost dispatched response without dispatching again', tokensAtStart: 0, tokensNow: 120, tokensBaselinePending: false, + armedAt: 7, }, matchesRequestedState: true, }, @@ -311,6 +312,7 @@ test('goal:arm reconciliation reports different, missing, and unavailable author tokensAtStart: 0, tokensNow: 120, tokensBaselinePending: false, + armedAt: 7, }, matchesRequestedState: false, }, @@ -1219,6 +1221,7 @@ function baseGoalProjection() { achievedAt: null, pausedAt: null, armedAt: 7, + boundTurnId: null, }; } diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts index d3bebfac3f..17e788b9fa 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts @@ -2403,6 +2403,7 @@ test("publishes Host sidecar and graph invalidations without inventing Session s achievedAt: null, pausedAt: null, armedAt: null, + boundTurnId: null, }, }), }); @@ -2512,6 +2513,7 @@ function activeGoal() { achievedAt: null, pausedAt: null, armedAt: null, + boundTurnId: null, }; } diff --git a/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts index 0c2fa06aad..e86b379262 100644 --- a/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts @@ -24,7 +24,7 @@ import type { AgentGraphClientSnapshotOptions, AgentGraphOperatorInspection, } from '@maka/runtime/stream-graph-read-model'; -import { DEFAULT_MAX_ITERATIONS, type GoalState } from '@maka/runtime/goal-state'; +import { DEFAULT_MAX_ITERATIONS } from '@maka/runtime/goal-state'; import type { ShellRunPtyDataEvent } from '@maka/runtime/shell-run-contract'; import type { GoalProjection, @@ -37,6 +37,7 @@ import { GOAL_ARM_REQUEST_KEYS, type GoalArmOutcome, } from '../shared/goal-arm.js'; +import type { DesktopGoalState } from '../shared/desktop-goal-state.js'; import { projectHostedDeepResearch } from './deep-research-desktop-projection.js'; import { handleReconciledControl, @@ -458,7 +459,7 @@ function optionalCount(value: unknown, label: string): number | null { return value; } -function toDesktopGoal(goal: GoalProjection): GoalState { +function toDesktopGoal(goal: GoalProjection): DesktopGoalState { return { id: goal.goalId, revision: goal.revision, @@ -478,6 +479,7 @@ function toDesktopGoal(goal: GoalProjection): GoalState { ...(goal.achievedAt === null ? {} : { achievedAt: goal.achievedAt }), ...(goal.pausedAt === null ? {} : { pausedAt: goal.pausedAt }), ...(goal.armedAt === null ? {} : { armedAt: goal.armedAt }), + ...(goal.boundTurnId === null ? {} : { boundTurnId: goal.boundTurnId }), }; } diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 5d42106c01..36e99c5e8d 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -914,7 +914,7 @@ export interface MakaBridge { }; goal: { /** The session's current goal (null when none is set). */ - get(sessionId: string): Promise; + get(sessionId: string): Promise; /** * Arm a goal for this session. It drives the session from the next turn * on; arming alone starts nothing. Rejects when the session already has an diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 883adb1f93..7c93a45ea4 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -181,7 +181,7 @@ import type { } from '@maka/runtime/stream-graph-read-model'; import type { BotStatus, WechatBridgeQrCodeResult } from '@maka/runtime/bots'; import type { ShellRunPtyDataEvent, ShellRunPtySnapshot } from '@maka/runtime/shell-run-contract'; -import type { GoalState } from '@maka/runtime/goal-state'; +import type { DesktopGoalState } from '../shared/desktop-goal-state.js'; import type { BundledSkillCatalogEntry, ManagedSkillSourceEntry, ManagedSkillUpdatePreview, SkillEntry } from '@maka/ui'; import type { ConfigCategory } from '@maka/storage'; import { @@ -2122,7 +2122,7 @@ const makaBridge = { }, }, goal: { - get(sessionId: string): Promise { + get(sessionId: string): Promise { return invokeProjectedSessionRuntimeHost('goal:get', sessionId); }, arm(sessionId: string, goal: GoalArmRequest): Promise { diff --git a/apps/desktop/src/renderer/__tests__/use-session-goal.test.ts b/apps/desktop/src/renderer/__tests__/use-session-goal.test.ts index efff01ba63..9ed4c3569e 100644 --- a/apps/desktop/src/renderer/__tests__/use-session-goal.test.ts +++ b/apps/desktop/src/renderer/__tests__/use-session-goal.test.ts @@ -2,8 +2,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { isGoalArmedAwaitingFirstTurn } from '../use-session-goal.js'; -test('an armed Goal stops awaiting its first Turn once that Turn is active', () => { - assert.equal(isGoalArmedAwaitingFirstTurn({ armedAt: 1 }, false), true); - assert.equal(isGoalArmedAwaitingFirstTurn({ armedAt: 1 }, true), false); - assert.equal(isGoalArmedAwaitingFirstTurn({}, false), false); +test('only the Turn bound to an armed Goal ends its awaiting phase', () => { + assert.equal(isGoalArmedAwaitingFirstTurn({ armedAt: 1 }), true); + assert.equal( + isGoalArmedAwaitingFirstTurn({ armedAt: 1, boundTurnId: 'goal-turn-1' }), + false, + ); + assert.equal(isGoalArmedAwaitingFirstTurn({}), false); }); diff --git a/apps/desktop/src/shared/desktop-goal-state.ts b/apps/desktop/src/shared/desktop-goal-state.ts new file mode 100644 index 0000000000..fb3e4344e8 --- /dev/null +++ b/apps/desktop/src/shared/desktop-goal-state.ts @@ -0,0 +1,25 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { GoalState } from '@maka/runtime/goal-state'; + +/** Desktop-only runtime detail; it is transient and never persisted with a Goal. */ +export type DesktopGoalState = GoalState & { + readonly boundTurnId?: string; +}; diff --git a/apps/desktop/src/shared/goal-arm.ts b/apps/desktop/src/shared/goal-arm.ts index cf5d5d24f4..2a76f4078b 100644 --- a/apps/desktop/src/shared/goal-arm.ts +++ b/apps/desktop/src/shared/goal-arm.ts @@ -17,7 +17,7 @@ * under the License. */ -import type { GoalState } from '@maka/runtime/goal-state'; +import type { DesktopGoalState } from './desktop-goal-state.js'; /** * What the renderer sends to arm a Goal. @@ -34,10 +34,10 @@ export interface GoalArmRequest { } export type GoalArmOutcome = - | { readonly kind: 'armed'; readonly goal: GoalState } + | { readonly kind: 'armed'; readonly goal: DesktopGoalState } | { readonly kind: 'reconciled'; - readonly currentGoal: GoalState | null; + readonly currentGoal: DesktopGoalState | null; readonly matchesRequestedState: boolean; } | { readonly kind: 'reconciliation_unavailable' }; diff --git a/packages/cli/src/__tests__/pi-goal.test.ts b/packages/cli/src/__tests__/pi-goal.test.ts index 994502733e..c076496400 100644 --- a/packages/cli/src/__tests__/pi-goal.test.ts +++ b/packages/cli/src/__tests__/pi-goal.test.ts @@ -32,6 +32,7 @@ import { goalStatusLineText, goalSummaryLines, isLiveGoalStatus, + shouldAnnounceGoalAttachment, } from '../pi-goal.js'; function goal(overrides: Partial = {}): GoalProjection { @@ -52,6 +53,7 @@ function goal(overrides: Partial = {}): GoalProjection { achievedAt: null, pausedAt: null, armedAt: null, + boundTurnId: null, ...overrides, }; } @@ -100,6 +102,21 @@ describe('pi-goal display helpers', () => { ); }); + test('armed Goals remain set until their first bound Turn, without a running notice or elapsed time', () => { + const armed = goal({ armedAt: 1_000 }); + assert.equal(goalStatusLineText(armed, 61_000), 'goal set 3/50'); + assert.deepEqual(goalSummaryLines(armed, 61_000).slice(0, 2), [ + 'Goal: Ship the feature', + 'Status: set · 3/50 iterations', + ]); + assert.equal( + goalAttachedNoticeText(armed), + 'Autonomous goal is set (3/50): Ship the feature — it takes hold on the next Turn.', + ); + assert.equal(shouldAnnounceGoalAttachment(armed), false); + assert.equal(shouldAnnounceGoalAttachment(goal()), true); + }); + test('summary lines include budget only when set and the evaluator note only when present', () => { const plain = goalSummaryLines(goal(), 61_000); assert.equal(plain.length, 2); diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 06ae0399f2..a762768b0a 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -156,6 +156,7 @@ describe('Maka Pi TUI transcript', () => { achievedAt: null, pausedAt: null, armedAt: null, + boundTurnId: null, } as const; const active = stripAnsi( renderMakaPiStatusLine({ ...meta(), goal: { ...base, status: 'active' as const } }, 120), diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index b8d058ce34..e79878bb78 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -5731,6 +5731,7 @@ describe('Maka Pi TUI runner', () => { achievedAt: null, pausedAt: null, armedAt: null, + boundTurnId: null, }; test('/goal prints the live goal summary and the status line carries the indicator', async () => { diff --git a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts index e9e3496a28..d75a5e0248 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -1845,6 +1845,7 @@ function goalProjection(overrides: Partial = {}): GoalProjection achievedAt: null, pausedAt: null, armedAt: null, + boundTurnId: null, ...overrides, }; } diff --git a/packages/cli/src/pi-goal.ts b/packages/cli/src/pi-goal.ts index fa972d3f08..d86c34079b 100644 --- a/packages/cli/src/pi-goal.ts +++ b/packages/cli/src/pi-goal.ts @@ -103,11 +103,12 @@ export function formatGoalElapsed(elapsedMs: number): string { export function goalStatusLineText( goal: Pick< GoalProjection, - 'status' | 'iterations' | 'maxIterations' | 'setAt' | 'pausedAt' | 'achievedAt' + 'status' | 'iterations' | 'maxIterations' | 'setAt' | 'pausedAt' | 'achievedAt' | 'armedAt' >, now: number, ): string { const counter = `${goal.iterations}/${goal.maxIterations}`; + if (isArmedGoal(goal)) return `goal set ${counter}`; if (goal.status === 'active') { return `goal ${counter} ${formatGoalElapsed(goalElapsedMs(goal, now))}`; } @@ -139,20 +140,30 @@ export function goalPausedNoticeText( * auto-continuing after recovery — a token-burning loop never resumes silently. */ export function goalAttachedNoticeText( - goal: Pick, + goal: Pick, ): string { const condition = inlineGoalText(goal.condition); const short = condition.length > 120 ? `${condition.slice(0, 119)}…` : condition; + if (isArmedGoal(goal)) { + return `Autonomous goal is set (${goal.iterations}/${goal.maxIterations}): ${short} — it takes hold on the next Turn.`; + } return `Autonomous goal is running (${goal.iterations}/${goal.maxIterations}): ${short} — /goal shows details, /goal pause pauses it.`; } +export function shouldAnnounceGoalAttachment( + goal: Pick, +): boolean { + return (goal.status === 'active' || goal.status === 'waiting') && !isArmedGoal(goal); +} + /** Full `/goal` summary. Terminal goals are as welcome here as live ones. */ export function goalSummaryLines(goal: GoalProjection, now: number): string[] { - const status = `Status: ${goalStatusLabel(goal.status)} · ${goal.iterations}/${goal.maxIterations} iterations`; + const status = `Status: ${isArmedGoal(goal) ? 'set' : goalStatusLabel(goal.status)} · ${goal.iterations}/${goal.maxIterations} iterations`; // Terminal verdicts other than `achieved` carry no freeze timestamp, so a // wall-clock elapsed would keep growing for a loop that already ended. const elapsedMeaningful = - isLiveGoalStatus(goal.status) || (goal.status === 'achieved' && goal.achievedAt !== null); + (!isArmedGoal(goal) && isLiveGoalStatus(goal.status)) || + (goal.status === 'achieved' && goal.achievedAt !== null); const lines = [ // A cleared goal keeps its terminal record, so say "cleared" up front // instead of presenting the condition as if it were still armed. @@ -171,3 +182,7 @@ export function goalSummaryLines(goal: GoalProjection, now: number): string[] { if (goal.lastReason) lines.push(`Last evaluator note: ${inlineGoalText(goal.lastReason)}`); return lines; } + +function isArmedGoal(goal: Pick): boolean { + return goal.status === 'active' && goal.armedAt !== null; +} diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 79c67bd6ed..55accee6b8 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -144,6 +144,7 @@ import { goalStatusLabel, goalSummaryLines, isLiveGoalStatus, + shouldAnnounceGoalAttachment, } from './pi-goal.js'; import { getTuiPrimaryGuidance } from './tui-primary-guidance.js'; import type { GoalControlAction, GoalProjection } from '@maka/runtime-host/protocol'; @@ -428,10 +429,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // must never resume a token-burning loop silently. This covers a driver // that is already attached at startup; a resumeSessionId attach happens // later, so switchSession repeats the check after adopting the session. - if ( - currentGoal !== null && - (currentGoal.status === 'active' || currentGoal.status === 'waiting') - ) { + if (currentGoal !== null && shouldAnnounceGoalAttachment(currentGoal)) { state.entries.push({ kind: 'notice', level: 'info', @@ -1562,10 +1560,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // would erase a notice from adoption time — keeps an auto-continuing // token-burning loop from resuming silently. currentGoal = input.driver.getGoal?.() ?? null; - if ( - currentGoal !== null && - (currentGoal.status === 'active' || currentGoal.status === 'waiting') - ) { + if (currentGoal !== null && shouldAnnounceGoalAttachment(currentGoal)) { state.entries.push({ kind: 'notice', level: 'info', diff --git a/packages/core/src/goal.ts b/packages/core/src/goal.ts index eb4460cb20..5c35fe1d83 100644 --- a/packages/core/src/goal.ts +++ b/packages/core/src/goal.ts @@ -61,23 +61,24 @@ export interface GoalState { readonly achievedAt?: number; readonly pausedAt?: number; /** - * When `goal.arm` created this Goal, and absent once the Goal drives itself. + * When `goal.arm` created this Goal, retained through its first bound Turn. * * A Goal the model sets drives from the moment it exists: the Turn that set * it is already bound to it and settles into its first continuation. Arming * happens outside every Turn and deliberately starts nothing, so an armed - * Goal waits — for a Turn to carry it into a continuation, or for the user - * to resume it — and this records that wait. Both events clear it, so its - * absence is the whole fact `isDrivingGoal` reads. Absence is also what - * every Goal written before arming existed carries, which is what those - * Goals mean. + * Goal waits for a Turn to carry it. The Host pairs this durable marker with + * the transient bound Turn identity: no identity means still waiting; an + * identity means that first Turn is running. Settlement, pause, clear, and + * terminal verdicts all clear the marker. Its absence is also what every + * Goal written before arming existed carries. */ readonly armedAt?: number; } /** - * Whether this Goal admits its own continuation Turns, which a restart or a - * resume has to put back. + * Whether an active or waiting Goal admits its own continuation Turns, which + * a restart or resume has to put back. Callers must still check the status: + * a paused Goal also has no armed marker, but it must not schedule work. * * `status` cannot answer this: `active` covers both a Goal between * continuations and an armed one still waiting for its first Turn. Neither diff --git a/packages/runtime-host/src/__tests__/goal-coordinator.test.ts b/packages/runtime-host/src/__tests__/goal-coordinator.test.ts index 1b0ae5497b..a452aa8995 100644 --- a/packages/runtime-host/src/__tests__/goal-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/goal-coordinator.test.ts @@ -600,6 +600,7 @@ test('goal.arm creates one Goal per Session and refuses a second while it is unf assert.equal(armed.result.goal.goalId, 'goal-armed'); assert.equal(armed.result.goal.status, 'active'); assert.equal(armed.result.goal.armedAt, 10); + assert.equal(armed.result.goal.boundTurnId, null); assert.equal(armed.result.goal.maxIterations, 20); assert.equal(armed.result.goal.tokenBudget, 50_000); assert.deepEqual( @@ -610,6 +611,7 @@ test('goal.arm creates one Goal per Session and refuses a second while it is unf armed, 'every client reads the Goal the Host just armed', ); + await waitForAsync(async () => (await goalStore.read(session.id)) !== null); const second = await coordinator.handlers['goal.arm']( @@ -653,6 +655,7 @@ test('goal.arm creates one Goal per Session and refuses a second while it is unf await coordinator.close(); } finally { + await stores.sessionStore.close?.(); await goalStore.close(); await owner.close(); await rm(base, { recursive: true, force: true }); diff --git a/packages/runtime-host/src/__tests__/goal-projection.test.ts b/packages/runtime-host/src/__tests__/goal-projection.test.ts new file mode 100644 index 0000000000..4a1e975680 --- /dev/null +++ b/packages/runtime-host/src/__tests__/goal-projection.test.ts @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { projectGoalState } from '../server/goal-projection.js'; + +test('projects the identity of the Turn bound to an armed Goal', () => { + const projection = projectGoalState( + { + id: 'goal-1', + revision: 0, + sessionId: 'session-1', + condition: 'Ship the feature', + status: 'active', + setAt: 1, + iterations: 0, + maxIterations: 50, + consecutiveNoProgress: 0, + blockCap: 8, + tokensAtStart: 0, + tokensNow: 0, + tokensBaselinePending: true, + armedAt: 1, + }, + 'turn-after-arm', + ); + + assert.equal(projection.armedAt, 1); + assert.equal(projection.boundTurnId, 'turn-after-arm'); +}); diff --git a/packages/runtime-host/src/__tests__/goal-protocol.test.ts b/packages/runtime-host/src/__tests__/goal-protocol.test.ts index 470354b7a6..766d3a6aa9 100644 --- a/packages/runtime-host/src/__tests__/goal-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/goal-protocol.test.ts @@ -46,6 +46,7 @@ const goal = { achievedAt: null, pausedAt: 2, armedAt: null, + boundTurnId: null, }; test('Goal query and exact-revision control frames round-trip', () => { @@ -138,6 +139,8 @@ test('Goal projection is part of the exact Session continuity schema', () => { test('Goal projection rejects unknown fields and text beyond the shared UTF-8 boundary', () => { const { armedAt: _armedAt, ...legacyGoal } = goal; assert.throws(() => decodeGoalProjection(legacyGoal)); + const { boundTurnId: _boundTurnId, ...unboundGoal } = goal; + assert.throws(() => decodeGoalProjection(unboundGoal)); assert.throws(() => decodeGoalProjection({ ...goal, extra: true })); assert.throws(() => decodeGoalProjection({ ...goal, condition: '界'.repeat(501) })); assert.throws(() => decodeGoalProjection({ ...goal, lastReason: '界'.repeat(501) })); diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index fae4c963fa..1e6b0ba1b8 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -98,9 +98,9 @@ describe('Runtime Host bootstrap protocol', () => { }); test('publishes a new compatibility epoch for the Goal armed-state projection', () => { - // GoalProjection has an exact key set, so adding armedAt requires mixed - // Client-Host peers to fail during the handshake. - assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 31); + // GoalProjection has an exact key set, so the armedAt and boundTurnId + // additions must reject mixed Client-Host peers during the handshake. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 42); }); test('rejects the legacy connection update result in the current compatibility epoch', () => { diff --git a/packages/runtime-host/src/protocol/goal.ts b/packages/runtime-host/src/protocol/goal.ts index b2bff18969..38e02a8677 100644 --- a/packages/runtime-host/src/protocol/goal.ts +++ b/packages/runtime-host/src/protocol/goal.ts @@ -65,8 +65,13 @@ export interface GoalProjection { readonly lastReason: string | null; readonly achievedAt: number | null; readonly pausedAt: number | null; - /** Epoch ms while a user-armed Goal is waiting for its first Turn. */ + /** + * Epoch ms from arming through the first bound Turn. A null `boundTurnId` + * means the Goal is still waiting; a value means that Turn is running. + */ readonly armedAt: number | null; + /** The currently running Turn that observed this armed Goal, if any. */ + readonly boundTurnId: string | null; } export interface GoalQueryInput { @@ -172,6 +177,7 @@ export function decodeGoalProjection(value: unknown): GoalProjection { 'achievedAt', 'pausedAt', 'armedAt', + 'boundTurnId', ]); const condition = requireUtf8String( record.condition, @@ -199,6 +205,8 @@ export function decodeGoalProjection(value: unknown): GoalProjection { achievedAt: requireNullableCount(record.achievedAt, 'Goal achievedAt'), pausedAt: requireNullableCount(record.pausedAt, 'Goal pausedAt'), armedAt: requireNullableCount(record.armedAt, 'Goal armedAt'), + boundTurnId: + record.boundTurnId === null ? null : requireEntityId(record.boundTurnId, 'Goal boundTurnId'), }; } diff --git a/packages/runtime-host/src/server/goal-coordinator.ts b/packages/runtime-host/src/server/goal-coordinator.ts index 9949bae516..b4dc330033 100644 --- a/packages/runtime-host/src/server/goal-coordinator.ts +++ b/packages/runtime-host/src/server/goal-coordinator.ts @@ -238,11 +238,15 @@ export class HostGoalCoordinator { readProjection(sessionId: string): GoalProjection | null { const goal = this.manager.get(sessionId); - return goal ? projectGoalState(goal) : null; + return goal + ? projectGoalState(goal, this.continuation.observedGoalTurnId(sessionId, goal.id)) + : null; } beginObservedTurn(sessionId: string, turnId: string): GoalObservedTurnStart { - return this.continuation.beginObservedTurn(sessionId, turnId); + const registration = this.continuation.beginObservedTurn(sessionId, turnId); + if (registration.kind === 'registered') this.#onProjectionChanged(sessionId); + return registration; } begin(input: HostedExecutionObservation): HostedExecutionCompletionObserver | undefined { @@ -251,6 +255,7 @@ export class HostGoalCoordinator { } const registration = this.continuation.beginObservedTurn(input.sessionId, input.turnId); if (registration.kind !== 'registered') return undefined; + this.#onProjectionChanged(input.sessionId); return (completion) => registration.settle(goalOutcomeFromCompletion(completion)).catch((error) => { this.#persistenceFailure ??= error; diff --git a/packages/runtime-host/src/server/goal-projection.ts b/packages/runtime-host/src/server/goal-projection.ts index 9bedb4180d..7aadd030e4 100644 --- a/packages/runtime-host/src/server/goal-projection.ts +++ b/packages/runtime-host/src/server/goal-projection.ts @@ -24,7 +24,10 @@ import { } from '@maka/runtime/goal-state'; import { decodeGoalProjection, type GoalProjection } from '../protocol/index.js'; -export function projectGoalState(goal: GoalState): GoalProjection { +export function projectGoalState( + goal: GoalState, + boundTurnId: string | null = null, +): GoalProjection { return decodeGoalProjection({ goalId: goal.id, revision: goal.revision, @@ -42,6 +45,7 @@ export function projectGoalState(goal: GoalState): GoalProjection { achievedAt: goal.achievedAt ?? null, pausedAt: goal.pausedAt ?? null, armedAt: goal.armedAt ?? null, + boundTurnId, }); } @@ -64,5 +68,6 @@ export function worstCaseGoalProjection(sessionId: string): GoalProjection { achievedAt: Number.MAX_SAFE_INTEGER, pausedAt: Number.MAX_SAFE_INTEGER, armedAt: Number.MAX_SAFE_INTEGER, + boundTurnId: 't'.repeat(128), }; } diff --git a/packages/runtime/src/__tests__/goal-state.test.ts b/packages/runtime/src/__tests__/goal-state.test.ts index 6ea022b89f..382f1c8cdc 100644 --- a/packages/runtime/src/__tests__/goal-state.test.ts +++ b/packages/runtime/src/__tests__/goal-state.test.ts @@ -269,6 +269,36 @@ describe('GoalManager arming', () => { assert.ok(resumed); assert.equal(isDrivingGoal(resumed), true); }); + + test('leaving the armed phase clears its marker, including terminal verdicts and clear', () => { + const { mgr } = createManager(); + const armed = createGoal(mgr, 'x', { armed: true }); + assert.equal(mgr.clear(SESSION)?.armedAt, undefined); + + const pausable = createGoal(mgr, 'x', { armed: true }); + assert.equal(mgr.pause(SESSION, { checkpoint: goalCheckpoint(pausable) })?.armedAt, undefined); + mgr.clear(SESSION); + + const achieved = createGoal(mgr, 'x', { armed: true }); + assert.equal( + mgr.settleTurn(SESSION, { + checkpoint: goalCheckpoint(achieved), + verdict: 'achieved', + reason: 'done', + })?.armedAt, + undefined, + ); + + const impossible = createGoal(mgr, 'x', { armed: true }); + assert.equal( + mgr.settleTurn(SESSION, { + checkpoint: goalCheckpoint(impossible), + verdict: 'impossible', + reason: 'blocked', + })?.armedAt, + undefined, + ); + }); }); describe('GoalManager atomic turn settlement', () => { diff --git a/packages/runtime/src/goal-continuation.ts b/packages/runtime/src/goal-continuation.ts index 9abe61dfdc..369f4789fd 100644 --- a/packages/runtime/src/goal-continuation.ts +++ b/packages/runtime/src/goal-continuation.ts @@ -249,6 +249,21 @@ export class GoalContinuationCoordinator { }; } + /** The in-flight Turn that actually observed this armed Goal, if any. */ + observedGoalTurnId(sessionId: string, goalId: string): string | null { + const goal = this.deps.goalManager.get(sessionId); + const controlLease = this.deps.goalManager.getControlLease(sessionId); + if (!goal || goal.id !== goalId || goal.armedAt === undefined || !controlLease) return null; + const lane = this.lanes.get(sessionId); + if (!lane) return null; + for (const registration of lane.turns.values()) { + if (sameGoalControlLease(registration.controlLease, controlLease)) { + return registration.turnId; + } + } + return null; + } + /** * Why this turn may not arm a Goal, or that it may. * diff --git a/packages/runtime/src/goal-state.ts b/packages/runtime/src/goal-state.ts index 097f139b57..964bc5b530 100644 --- a/packages/runtime/src/goal-state.ts +++ b/packages/runtime/src/goal-state.ts @@ -328,9 +328,10 @@ export class GoalManager { status: 'achieved', lastReason: input.reason, achievedAt: this.deps.now(), + armedAt: undefined, }; } else if (input.verdict === 'impossible') { - patch = { status: 'impossible', lastReason: input.reason }; + patch = { status: 'impossible', lastReason: input.reason, armedAt: undefined }; } else { let tokensAtStart = current.tokensAtStart; let tokensNow = current.tokensNow; @@ -409,6 +410,7 @@ export class GoalManager { { status: 'paused', pausedAt: this.deps.now(), + armedAt: undefined, ...(options?.reason !== undefined ? { lastReason: options.reason } : {}), }, { renewControlLease: true }, @@ -440,7 +442,11 @@ export class GoalManager { clear(sessionId: string): GoalState | undefined { const record = this.goals.get(sessionId); if (!record || TERMINAL_GOAL_STATUSES.has(record.state.status)) return undefined; - return this.commit(record, { status: 'cleared' }, { renewControlLease: true }); + return this.commit( + record, + { status: 'cleared', armedAt: undefined }, + { renewControlLease: true }, + ); } remove(sessionId: string): boolean { diff --git a/packages/ui/src/__tests__/session-context-layer-goal.test.tsx b/packages/ui/src/__tests__/session-context-layer-goal.test.tsx index 12658e3d73..3d07c89d1f 100644 --- a/packages/ui/src/__tests__/session-context-layer-goal.test.tsx +++ b/packages/ui/src/__tests__/session-context-layer-goal.test.tsx @@ -119,4 +119,8 @@ test('an armed goal waits for its first Turn without looking like it is running' assert.ok(markup.includes('Autonomous goal set; takes hold on the next Turn')); assert.ok(!markup.includes('Autonomous goal running')); assert.ok(!markup.includes('Autonomous goal paused')); + assert.ok(markup.includes('The goal has not started; click to clear it.')); + assert.ok(markup.includes('It will not take hold on the next Turn unless resumed.')); + assert.ok(!markup.includes('Maka continues after each iteration')); + assert.ok(!markup.includes('no more tokens burn')); }); diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index 9752af0d99..070fb8ce6e 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -33,6 +33,7 @@ import { } from '@maka/core/explore-agent'; export type DayPeriod = 'morning' | 'noon' | 'afternoon' | 'evening'; +export type GoalDisplayPhase = 'armed' | 'running' | 'waiting' | 'paused'; type ResearchItem = Readonly<{ title: string; body: string }>; type ResearchOption = Readonly<{ label: string; body: string }>; type ResearchStarter = Readonly<{ label: string; prompt: string }>; @@ -309,7 +310,7 @@ export interface ConversationCopy { noBlockers: string; sectionLabels: Record; }; - clearGoal: (condition: string, iteration: number, max: number, status: string) => string; + clearGoal: (condition: string, iteration: number, max: number, phase: GoalDisplayPhase) => string; clearGoalAriaLabel: (iteration: number, max: number) => string; goalProgress: (iteration: number, max: number) => string; goalRunningAriaLabel: string; @@ -318,7 +319,12 @@ export interface ConversationCopy { goalPausedAriaLabel: string; pauseGoalAriaLabel: (iteration: number, max: number) => string; resumeGoalAriaLabel: (iteration: number, max: number) => string; - pauseGoal: (condition: string, iteration: number, max: number, status: string) => string; + pauseGoal: ( + condition: string, + iteration: number, + max: number, + phase: GoalDisplayPhase, + ) => string; resumeGoal: (condition: string, iteration: number, max: number) => string; /** Wall-clock elapsed label for the goal chip, e.g. "12m". */ goalElapsed: (elapsedMs: number) => string; @@ -506,8 +512,8 @@ const CONVERSATION_COPY = { verification: '验证', }, }, - clearGoal: (condition, iteration, max, status) => `自主执行目标进行中:「${condition}」(第 ${iteration}/${max} 轮,${status})。系统每轮后自动续行;点击可清除目标、停止续行。`, clearGoalAriaLabel: (iteration, max) => `清除自主执行目标(已进行 ${iteration}/${max} 轮)`, goalProgress: (iteration, max) => `目标 ${iteration} / ${max}`, goalRunningAriaLabel: '自主目标正在运行', goalArmedAriaLabel: '自主目标已设置,将在下一轮接管', goalWaitingAriaLabel: '自主目标正在等待条件变化', - goalPausedAriaLabel: '自主目标已暂停', pauseGoalAriaLabel: (iteration, max) => `暂停自主执行目标(已进行 ${iteration}/${max} 轮)`, resumeGoalAriaLabel: (iteration, max) => `恢复自主执行目标(已进行 ${iteration}/${max} 轮)`, pauseGoal: (condition, iteration, max, status) => `暂停自主执行目标:「${condition}」(第 ${iteration}/${max} 轮,${status})。暂停后立即停止自动续行,不再消耗令牌;可随时恢复。`, resumeGoal: (condition, iteration, max) => `恢复自主执行目标:「${condition}」(第 ${iteration}/${max} 轮)。恢复后立即继续自动续行。`, goalElapsed: (elapsedMs) => formatGoalElapsedUnits(elapsedMs, { second: ' 秒', minute: ' 分钟', hour: ' 小时', day: ' 天' }), goalTokens: (spent, budget) => `${formatCompactTokenCount(spent)} / ${formatCompactTokenCount(budget)}`, + clearGoal: (condition, iteration, max, phase) => phase === 'armed' ? `自主目标已设置:「${condition}」(第 ${iteration}/${max} 轮)。尚未开始;点击可清除目标。` : `自主执行目标进行中:「${condition}」(第 ${iteration}/${max} 轮,${phase})。系统每轮后自动续行;点击可清除目标、停止续行。`, clearGoalAriaLabel: (iteration, max) => `清除自主执行目标(已进行 ${iteration}/${max} 轮)`, goalProgress: (iteration, max) => `目标 ${iteration} / ${max}`, goalRunningAriaLabel: '自主目标正在运行', goalArmedAriaLabel: '自主目标已设置,将在下一轮接管', goalWaitingAriaLabel: '自主目标正在等待条件变化', + goalPausedAriaLabel: '自主目标已暂停', pauseGoalAriaLabel: (iteration, max) => `暂停自主执行目标(已进行 ${iteration}/${max} 轮)`, resumeGoalAriaLabel: (iteration, max) => `恢复自主执行目标(已进行 ${iteration}/${max} 轮)`, pauseGoal: (condition, iteration, max, phase) => phase === 'armed' ? `暂停自主目标:「${condition}」(第 ${iteration}/${max} 轮)。除非恢复,否则不会在下一轮接管。` : `暂停自主执行目标:「${condition}」(第 ${iteration}/${max} 轮,${phase})。暂停后立即停止自动续行,不再消耗令牌;可随时恢复。`, resumeGoal: (condition, iteration, max) => `恢复自主执行目标:「${condition}」(第 ${iteration}/${max} 轮)。恢复后立即继续自动续行。`, goalElapsed: (elapsedMs) => formatGoalElapsedUnits(elapsedMs, { second: ' 秒', minute: ' 分钟', hour: ' 小时', day: ' 天' }), goalTokens: (spent, budget) => `${formatCompactTokenCount(spent)} / ${formatCompactTokenCount(budget)}`, loadFailed: '任务载入失败', loading: '载入中…', retryLoad: '重试载入', quoteSelection: '引用', askInSidePanel: '在侧栏追问', noMessages: '暂无消息', branchBeforeInterrupt: '从中断前分支', sessionContextAriaLabel: '任务上下文', sessionLineageAriaLabel: '任务来源', sessionContextMore: (count) => `更多任务上下文(${count})`, titlebarIdentityAriaLabel: '当前任务', openProjectFolder: (name) => `在文件管理器中打开「${name}」`, openProjectFolderAction: '打开项目文件夹', @@ -654,8 +660,8 @@ const CONVERSATION_COPY = { verification: 'Verification', }, }, - clearGoal: (condition, iteration, max, status) => `Autonomous goal in progress: “${condition}” (iteration ${iteration}/${max}, ${status}). Maka continues after each iteration; click to clear the goal and stop continuing.`, clearGoalAriaLabel: (iteration, max) => `Clear autonomous goal after ${iteration}/${max} iterations`, goalProgress: (iteration, max) => `Goal ${iteration} of ${max}`, goalRunningAriaLabel: 'Autonomous goal running', goalArmedAriaLabel: 'Autonomous goal set; takes hold on the next Turn', goalWaitingAriaLabel: 'Autonomous goal waiting for conditions to change', - goalPausedAriaLabel: 'Autonomous goal paused', pauseGoalAriaLabel: (iteration, max) => `Pause autonomous goal after ${iteration}/${max} iterations`, resumeGoalAriaLabel: (iteration, max) => `Resume autonomous goal after ${iteration}/${max} iterations`, pauseGoal: (condition, iteration, max, status) => `Pause autonomous goal: “${condition}” (iteration ${iteration}/${max}, ${status}). Pausing stops autonomous continuation immediately — no more tokens burn; resume any time.`, resumeGoal: (condition, iteration, max) => `Resume autonomous goal: “${condition}” (iteration ${iteration}/${max}). Resuming continues autonomous iteration immediately.`, goalElapsed: (elapsedMs) => formatGoalElapsedUnits(elapsedMs, { second: 's', minute: 'm', hour: 'h', day: 'd' }), goalTokens: (spent, budget) => `${formatCompactTokenCount(spent)} / ${formatCompactTokenCount(budget)}`, + clearGoal: (condition, iteration, max, phase) => phase === 'armed' ? `Autonomous goal is set: “${condition}” (iteration ${iteration}/${max}). The goal has not started; click to clear it.` : `Autonomous goal in progress: “${condition}” (iteration ${iteration}/${max}, ${phase}). Maka continues after each iteration; click to clear the goal and stop continuing.`, clearGoalAriaLabel: (iteration, max) => `Clear autonomous goal after ${iteration}/${max} iterations`, goalProgress: (iteration, max) => `Goal ${iteration} of ${max}`, goalRunningAriaLabel: 'Autonomous goal running', goalArmedAriaLabel: 'Autonomous goal set; takes hold on the next Turn', goalWaitingAriaLabel: 'Autonomous goal waiting for conditions to change', + goalPausedAriaLabel: 'Autonomous goal paused', pauseGoalAriaLabel: (iteration, max) => `Pause autonomous goal after ${iteration}/${max} iterations`, resumeGoalAriaLabel: (iteration, max) => `Resume autonomous goal after ${iteration}/${max} iterations`, pauseGoal: (condition, iteration, max, phase) => phase === 'armed' ? `Pause autonomous goal: “${condition}” (iteration ${iteration}/${max}). It will not take hold on the next Turn unless resumed.` : `Pause autonomous goal: “${condition}” (iteration ${iteration}/${max}, ${phase}). Pausing stops autonomous continuation immediately — no more tokens burn; resume any time.`, resumeGoal: (condition, iteration, max) => `Resume autonomous goal: “${condition}” (iteration ${iteration}/${max}). Resuming continues autonomous iteration immediately.`, goalElapsed: (elapsedMs) => formatGoalElapsedUnits(elapsedMs, { second: 's', minute: 'm', hour: 'h', day: 'd' }), goalTokens: (spent, budget) => `${formatCompactTokenCount(spent)} / ${formatCompactTokenCount(budget)}`, loadFailed: 'Task failed to load', loading: 'Loading…', retryLoad: 'Retry', quoteSelection: 'Quote', askInSidePanel: 'Ask in side panel', noMessages: 'No messages yet', branchBeforeInterrupt: 'Branched before interruption', sessionContextAriaLabel: 'Task context', sessionLineageAriaLabel: 'Task origin', sessionContextMore: (count) => `More task context (${count})`, titlebarIdentityAriaLabel: 'Current task', openProjectFolder: (name) => `Open “${name}” in the file manager`, openProjectFolderAction: 'Open project folder', diff --git a/packages/ui/src/session-context-layer.tsx b/packages/ui/src/session-context-layer.tsx index ea86cf4453..e0c3be263e 100644 --- a/packages/ui/src/session-context-layer.tsx +++ b/packages/ui/src/session-context-layer.tsx @@ -33,7 +33,7 @@ import { Tooltip, type DropdownMenuOption, } from '@astryxdesign/core'; -import { getConversationCopy } from './conversation-copy.js'; +import { getConversationCopy, type GoalDisplayPhase } from './conversation-copy.js'; import { ICON_SIZE, Pause, Play } from './icons.js'; import { useUiLocale } from './locale-context.js'; import { dotForStatus } from './status-vocabulary.js'; @@ -119,13 +119,20 @@ export function SessionContextLayer(props: { // attention tone. const paused = goal.status === 'paused'; const waiting = goal.status === 'waiting'; - const armed = goal.armedAt !== undefined; + const armed = goal.status === 'active' && goal.armedAt !== undefined; + const phase: GoalDisplayPhase = paused + ? 'paused' + : armed + ? 'armed' + : waiting + ? 'waiting' + : 'running'; const elapsedMs = paused ? Math.max(0, goal.pausedAt - goal.setAt) : Math.max(0, Date.now() - goal.setAt); const goalText = [ copy.goalProgress(goal.iterations, goal.maxIterations), - copy.goalElapsed(elapsedMs), + ...(armed ? [] : [copy.goalElapsed(elapsedMs)]), goal.tokenBudget !== undefined && goal.tokensSpent !== undefined ? copy.goalTokens(goal.tokensSpent, goal.tokenBudget) : null, @@ -184,7 +191,7 @@ export function SessionContextLayer(props: { goal.condition, goal.iterations, goal.maxIterations, - goal.status, + phase, )} /> ) : null} @@ -210,7 +217,7 @@ export function SessionContextLayer(props: { goal.condition, goal.iterations, goal.maxIterations, - goal.status, + phase, )} /> From be3560630970f7ce4ecd8d26565346b7de1d4a21 Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Mon, 24 Aug 2026 09:40:58 +0800 Subject: [PATCH 3/5] fix(goal): align armed state across clients --- .../main/__tests__/goal-controller.test.ts | 23 +++++++++++++++++++ .../__tests__/use-session-goal.test.ts | 12 ---------- .../goals/controller/use-goal-controller.ts | 4 ++++ .../features/goals/model/live-goal.ts | 9 ++++---- .../src/renderer/features/goals/ports.ts | 4 ++-- packages/cli/src/__tests__/pi-goal.test.ts | 7 ++++++ packages/cli/src/pi-goal.ts | 22 +++++++++++++----- packages/core/src/goal.ts | 14 +++++++++++ .../session-context-layer-goal.test.tsx | 16 +++++++++++++ packages/ui/src/session-context-layer.tsx | 5 +++- 10 files changed, 91 insertions(+), 25 deletions(-) delete mode 100644 apps/desktop/src/renderer/__tests__/use-session-goal.test.ts diff --git a/apps/desktop/src/main/__tests__/goal-controller.test.ts b/apps/desktop/src/main/__tests__/goal-controller.test.ts index d063a15fef..166ad21e31 100644 --- a/apps/desktop/src/main/__tests__/goal-controller.test.ts +++ b/apps/desktop/src/main/__tests__/goal-controller.test.ts @@ -247,6 +247,29 @@ describe('useGoalController', () => { assert.equal(pauseCalls, 2); }); + it('shows an armed marker only while the first Turn is unbound', async () => { + const { root } = installReactRenderer(); + const defaults = createFakeGoalServices(); + const services = createFakeGoalServices({ + goal: { + ...defaults.goal, + get: async () => ({ ...goal('a'), armedAt: 150 }), + }, + }); + + await act(async () => renderController(root, services, input('a'))); + assert.equal(controller().selectors.indicator?.armedAt, 150); + + const boundServices = createFakeGoalServices({ + goal: { + ...defaults.goal, + get: async () => ({ ...goal('a'), armedAt: 150, boundTurnId: 'turn-1' }), + }, + }); + await act(async () => renderController(root, boundServices, input('a'))); + assert.equal(controller().selectors.indicator?.armedAt, undefined); + }); + it('routes resume and clear controls for paused Goals', async () => { const { root } = installReactRenderer(); const calls: string[] = []; diff --git a/apps/desktop/src/renderer/__tests__/use-session-goal.test.ts b/apps/desktop/src/renderer/__tests__/use-session-goal.test.ts deleted file mode 100644 index 9ed4c3569e..0000000000 --- a/apps/desktop/src/renderer/__tests__/use-session-goal.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { isGoalArmedAwaitingFirstTurn } from '../use-session-goal.js'; - -test('only the Turn bound to an armed Goal ends its awaiting phase', () => { - assert.equal(isGoalArmedAwaitingFirstTurn({ armedAt: 1 }), true); - assert.equal( - isGoalArmedAwaitingFirstTurn({ armedAt: 1, boundTurnId: 'goal-turn-1' }), - false, - ); - assert.equal(isGoalArmedAwaitingFirstTurn({}), false); -}); diff --git a/apps/desktop/src/renderer/features/goals/controller/use-goal-controller.ts b/apps/desktop/src/renderer/features/goals/controller/use-goal-controller.ts index 09ad21e35a..e3aafeb623 100644 --- a/apps/desktop/src/renderer/features/goals/controller/use-goal-controller.ts +++ b/apps/desktop/src/renderer/features/goals/controller/use-goal-controller.ts @@ -25,6 +25,7 @@ import { useState, type ComponentProps, } from 'react'; +import { isGoalArmedAwaitingFirstTurn } from '@maka/core/goal'; import { useUiLocale, type ChatView } from '@maka/ui'; import { getShellCopy, @@ -144,6 +145,9 @@ export function useGoalController( iterations: activeGoal.iterations, maxIterations: activeGoal.maxIterations, setAt: activeGoal.setAt, + ...(isGoalArmedAwaitingFirstTurn(activeGoal) + ? { armedAt: activeGoal.armedAt } + : {}), tokensSpent: activeGoal.tokensNow, ...(activeGoal.tokenBudget !== undefined ? { tokenBudget: activeGoal.tokenBudget } diff --git a/apps/desktop/src/renderer/features/goals/model/live-goal.ts b/apps/desktop/src/renderer/features/goals/model/live-goal.ts index 2acac13506..4b832d206a 100644 --- a/apps/desktop/src/renderer/features/goals/model/live-goal.ts +++ b/apps/desktop/src/renderer/features/goals/model/live-goal.ts @@ -17,13 +17,14 @@ * under the License. */ -import type { GoalState, GoalStatus } from '@maka/core/goal'; +import type { GoalStatus } from '@maka/core/goal'; +import type { DesktopGoalState } from '../../../../shared/desktop-goal-state.js'; type LiveGoalStatus = Extract; export type LiveGoalState = - | (GoalState & { readonly status: LiveGoalStatus }) - | (GoalState & { readonly status: 'paused'; readonly pausedAt: number }); + | (DesktopGoalState & { readonly status: LiveGoalStatus }) + | (DesktopGoalState & { readonly status: 'paused'; readonly pausedAt: number }); const LIVE_GOAL_STATUSES: ReadonlySet = new Set([ 'active', @@ -31,7 +32,7 @@ const LIVE_GOAL_STATUSES: ReadonlySet = new Set([ 'paused', ]); -export function isLiveGoal(goal: GoalState): goal is LiveGoalState { +export function isLiveGoal(goal: DesktopGoalState): goal is LiveGoalState { return ( LIVE_GOAL_STATUSES.has(goal.status) && (goal.status !== 'paused' || diff --git a/apps/desktop/src/renderer/features/goals/ports.ts b/apps/desktop/src/renderer/features/goals/ports.ts index 486ea6d3f6..a4bad456c3 100644 --- a/apps/desktop/src/renderer/features/goals/ports.ts +++ b/apps/desktop/src/renderer/features/goals/ports.ts @@ -17,7 +17,7 @@ * under the License. */ -import type { GoalState } from '@maka/core/goal'; +import type { DesktopGoalState } from '../../../shared/desktop-goal-state.js'; import type { GoalArmOutcome } from '../../../shared/goal-arm.js'; export type { GoalArmOutcome } from '../../../shared/goal-arm.js'; @@ -32,7 +32,7 @@ export interface GoalArmInput { /** The minimum environment capability needed by the Goals feature. */ export interface GoalService { - get(sessionId: string): Promise; + get(sessionId: string): Promise; arm(sessionId: string, goal: GoalArmInput): Promise; clear(sessionId: string): Promise; pause(sessionId: string): Promise; diff --git a/packages/cli/src/__tests__/pi-goal.test.ts b/packages/cli/src/__tests__/pi-goal.test.ts index c076496400..3edd157d96 100644 --- a/packages/cli/src/__tests__/pi-goal.test.ts +++ b/packages/cli/src/__tests__/pi-goal.test.ts @@ -117,6 +117,13 @@ describe('pi-goal display helpers', () => { assert.equal(shouldAnnounceGoalAttachment(goal()), true); }); + test('a bound first Turn makes the same Goal running again', () => { + const running = goal({ armedAt: 1_000, boundTurnId: 'turn-1' }); + assert.equal(goalStatusLineText(running, 61_000), 'goal 3/50 1m'); + assert.equal(shouldAnnounceGoalAttachment(running), true); + assert.match(goalAttachedNoticeText(running), /Autonomous goal is running/); + }); + test('summary lines include budget only when set and the evaluator note only when present', () => { const plain = goalSummaryLines(goal(), 61_000); assert.equal(plain.length, 2); diff --git a/packages/cli/src/pi-goal.ts b/packages/cli/src/pi-goal.ts index d86c34079b..e44074c141 100644 --- a/packages/cli/src/pi-goal.ts +++ b/packages/cli/src/pi-goal.ts @@ -25,7 +25,7 @@ * Codex parallel: codex-rs/tui/src/goal_display.rs. */ -import type { GoalStatus } from '@maka/core/goal'; +import { isGoalArmedAwaitingFirstTurn, type GoalStatus } from '@maka/core/goal'; import type { GoalProjection } from '@maka/runtime-host/protocol'; import { formatTokenCount } from './pi-transcript-format.js'; import { stripAnsi } from './tui-ansi.js'; @@ -103,7 +103,14 @@ export function formatGoalElapsed(elapsedMs: number): string { export function goalStatusLineText( goal: Pick< GoalProjection, - 'status' | 'iterations' | 'maxIterations' | 'setAt' | 'pausedAt' | 'achievedAt' | 'armedAt' + | 'status' + | 'iterations' + | 'maxIterations' + | 'setAt' + | 'pausedAt' + | 'achievedAt' + | 'armedAt' + | 'boundTurnId' >, now: number, ): string { @@ -140,7 +147,10 @@ export function goalPausedNoticeText( * auto-continuing after recovery — a token-burning loop never resumes silently. */ export function goalAttachedNoticeText( - goal: Pick, + goal: Pick< + GoalProjection, + 'condition' | 'iterations' | 'maxIterations' | 'status' | 'armedAt' | 'boundTurnId' + >, ): string { const condition = inlineGoalText(goal.condition); const short = condition.length > 120 ? `${condition.slice(0, 119)}…` : condition; @@ -151,7 +161,7 @@ export function goalAttachedNoticeText( } export function shouldAnnounceGoalAttachment( - goal: Pick, + goal: Pick, ): boolean { return (goal.status === 'active' || goal.status === 'waiting') && !isArmedGoal(goal); } @@ -183,6 +193,6 @@ export function goalSummaryLines(goal: GoalProjection, now: number): string[] { return lines; } -function isArmedGoal(goal: Pick): boolean { - return goal.status === 'active' && goal.armedAt !== null; +function isArmedGoal(goal: Pick): boolean { + return isGoalArmedAwaitingFirstTurn(goal); } diff --git a/packages/core/src/goal.ts b/packages/core/src/goal.ts index 5c35fe1d83..eb121791ba 100644 --- a/packages/core/src/goal.ts +++ b/packages/core/src/goal.ts @@ -90,6 +90,20 @@ export function isDrivingGoal(goal: Pick): boolean { return goal.armedAt === undefined; } +/** Whether an armed Goal is still waiting for its first bound Turn. */ +export function isGoalArmedAwaitingFirstTurn(goal: { + readonly status: GoalStatus; + readonly armedAt?: number | null; + readonly boundTurnId?: string | null; +}): boolean { + return ( + goal.status === 'active' && + goal.armedAt !== undefined && + goal.armedAt !== null && + (goal.boundTurnId === undefined || goal.boundTurnId === null) + ); +} + export interface GoalCheckpoint { readonly goalId: string; readonly revision: number; diff --git a/packages/ui/src/__tests__/session-context-layer-goal.test.tsx b/packages/ui/src/__tests__/session-context-layer-goal.test.tsx index 3d07c89d1f..c953aa7996 100644 --- a/packages/ui/src/__tests__/session-context-layer-goal.test.tsx +++ b/packages/ui/src/__tests__/session-context-layer-goal.test.tsx @@ -124,3 +124,19 @@ test('an armed goal waits for its first Turn without looking like it is running' assert.ok(!markup.includes('Maka continues after each iteration')); assert.ok(!markup.includes('no more tokens burn')); }); + +test('a bound first Turn makes an armed goal read as running', () => { + const markup = renderGoalChip({ + condition: 'Ship the feature', + status: 'active', + armedAt: Date.now() - 30_000, + boundTurnId: 'turn-1', + iterations: 0, + maxIterations: 50, + setAt: Date.now() - 30_000, + onPause: () => undefined, + onClear: () => undefined, + }); + assert.ok(markup.includes('Autonomous goal running')); + assert.ok(!markup.includes('Autonomous goal set; takes hold on the next Turn')); +}); diff --git a/packages/ui/src/session-context-layer.tsx b/packages/ui/src/session-context-layer.tsx index e0c3be263e..a67a548aca 100644 --- a/packages/ui/src/session-context-layer.tsx +++ b/packages/ui/src/session-context-layer.tsx @@ -18,6 +18,7 @@ */ import { useEffect, useReducer, type ReactElement } from 'react'; +import { isGoalArmedAwaitingFirstTurn } from '@maka/core/goal'; import { BreadcrumbItem, Breadcrumbs, @@ -59,6 +60,8 @@ interface SessionContextGoalBase { setAt: number; /** Present while a user-armed Goal waits for its first Turn. */ armedAt?: number; + /** Present while the first Turn carrying an armed Goal is running. */ + boundTurnId?: string | null; tokensSpent?: number; /** When present (a budget exists), the chip shows spent / budget. */ tokenBudget?: number; @@ -119,7 +122,7 @@ export function SessionContextLayer(props: { // attention tone. const paused = goal.status === 'paused'; const waiting = goal.status === 'waiting'; - const armed = goal.status === 'active' && goal.armedAt !== undefined; + const armed = isGoalArmedAwaitingFirstTurn(goal); const phase: GoalDisplayPhase = paused ? 'paused' : armed From 762559133de03f8dfd3370ed3da640a7a4e5b65f Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Mon, 24 Aug 2026 10:46:29 +0800 Subject: [PATCH 4/5] test(ui): align armed goal SSR assertion Generated-by: Codex --- .../ui/src/__tests__/session-context-layer-goal.test.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/ui/src/__tests__/session-context-layer-goal.test.tsx b/packages/ui/src/__tests__/session-context-layer-goal.test.tsx index c953aa7996..95cfbcc7e9 100644 --- a/packages/ui/src/__tests__/session-context-layer-goal.test.tsx +++ b/packages/ui/src/__tests__/session-context-layer-goal.test.tsx @@ -119,10 +119,7 @@ test('an armed goal waits for its first Turn without looking like it is running' assert.ok(markup.includes('Autonomous goal set; takes hold on the next Turn')); assert.ok(!markup.includes('Autonomous goal running')); assert.ok(!markup.includes('Autonomous goal paused')); - assert.ok(markup.includes('The goal has not started; click to clear it.')); - assert.ok(markup.includes('It will not take hold on the next Turn unless resumed.')); - assert.ok(!markup.includes('Maka continues after each iteration')); - assert.ok(!markup.includes('no more tokens burn')); + assert.ok(markup.includes('Clear autonomous goal after 0/50 iterations')); }); test('a bound first Turn makes an armed goal read as running', () => { From c40633bfb4563a4c73be06fe0e1ad8034ae7894e Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Mon, 24 Aug 2026 21:07:06 +0800 Subject: [PATCH 5/5] fix(goal): redact conditions in client displays --- .../src/main/__tests__/goal-dialog.test.ts | 15 +++++++++++++++ .../renderer/features/goals/ui/goal-dialog.tsx | 6 +++--- packages/cli/src/__tests__/pi-goal.test.ts | 10 ++++++++++ packages/cli/src/pi-goal.ts | 3 ++- .../__tests__/session-context-layer-goal.test.tsx | 15 +++++++++++++++ packages/ui/src/session-context-layer.tsx | 12 +++++++----- 6 files changed, 52 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/main/__tests__/goal-dialog.test.ts b/apps/desktop/src/main/__tests__/goal-dialog.test.ts index 30c18dc378..fd4352cd26 100644 --- a/apps/desktop/src/main/__tests__/goal-dialog.test.ts +++ b/apps/desktop/src/main/__tests__/goal-dialog.test.ts @@ -120,6 +120,21 @@ test('closes only for armed and locks reconciled state until reopen', async () = assert.equal(harness.closed, 1); }); +test('redacts secrets in a reconciled Goal condition', async () => { + const secret = 'sk-ant-api03-abc123def456ghi789jkl0mn1opq'; + const harness = installGoalDialog(async () => ({ + kind: 'reconciled', + currentGoal: { ...goalState(), condition: `Use Authorization: Bearer ${secret}` }, + matchesRequestedState: true, + })); + await harness.render('session-1'); + await setInputValue(harness.document, 'textarea', 'Finish session one'); + await clickButton(harness.document, 'Start'); + + assert.equal(harness.document.body.textContent.includes(secret), false); + assert.match(harness.document.body.textContent, /Authorization: Bearer /); +}); + test('keeps the Goal form editable after a deterministic rejection', async () => { const harness = installGoalDialog(async () => { throw new Error('Goal already exists'); diff --git a/apps/desktop/src/renderer/features/goals/ui/goal-dialog.tsx b/apps/desktop/src/renderer/features/goals/ui/goal-dialog.tsx index 36b5a56904..adb05670a3 100644 --- a/apps/desktop/src/renderer/features/goals/ui/goal-dialog.tsx +++ b/apps/desktop/src/renderer/features/goals/ui/goal-dialog.tsx @@ -42,7 +42,7 @@ import { GOAL_MAX_ITERATIONS_LIMIT, GOAL_TOKEN_BUDGET_MINIMUM, } from '@maka/core/goal'; -import { useUiLocale } from '@maka/ui'; +import { redactSecrets, useUiLocale } from '@maka/ui'; import { getShellCopy, localizedShellErrorMessage, @@ -109,12 +109,12 @@ export function GoalDialog(props: GoalDialogProps) { switch (reconciliation.kind) { case 'matching_goal': return copy.reconciledMatching( - reconciliation.goal.condition, + redactSecrets(reconciliation.goal.condition), copy.statusLabels[reconciliation.goal.status], ); case 'different_goal': return copy.reconciledDifferent( - reconciliation.goal.condition, + redactSecrets(reconciliation.goal.condition), copy.statusLabels[reconciliation.goal.status], ); case 'no_goal': diff --git a/packages/cli/src/__tests__/pi-goal.test.ts b/packages/cli/src/__tests__/pi-goal.test.ts index 3edd157d96..a63f003e86 100644 --- a/packages/cli/src/__tests__/pi-goal.test.ts +++ b/packages/cli/src/__tests__/pi-goal.test.ts @@ -209,4 +209,14 @@ describe('pi-goal display helpers', () => { const long = goalAttachedNoticeText(goal({ condition: 'x'.repeat(200) })); assert.ok(long.includes('…') && long.length <= 210); }); + + test('redacts secrets from condition text in CLI goal displays', () => { + const secret = 'sk-ant-api03-abc123def456ghi789jkl0mn1opq'; + const current = goal({ condition: `Use Authorization: Bearer ${secret}` }); + + for (const text of [goalAttachedNoticeText(current), goalSummaryLines(current, 61_000)[0]!]) { + assert.equal(text.includes(secret), false); + assert.match(text, //); + } + }); }); diff --git a/packages/cli/src/pi-goal.ts b/packages/cli/src/pi-goal.ts index e44074c141..283441dff4 100644 --- a/packages/cli/src/pi-goal.ts +++ b/packages/cli/src/pi-goal.ts @@ -26,6 +26,7 @@ */ import { isGoalArmedAwaitingFirstTurn, type GoalStatus } from '@maka/core/goal'; +import { redactSecrets } from '@maka/core/display-redaction'; import type { GoalProjection } from '@maka/runtime-host/protocol'; import { formatTokenCount } from './pi-transcript-format.js'; import { stripAnsi } from './tui-ansi.js'; @@ -124,7 +125,7 @@ export function goalStatusLineText( /** Conditions and evaluator notes may legally embed newlines; collapse whitespace so notices stay one line per field. */ function inlineGoalText(value: string): string { - return stripAnsi(value) + return redactSecrets(stripAnsi(value)) .replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' ') .replace(/\s+/g, ' ') .trim(); diff --git a/packages/ui/src/__tests__/session-context-layer-goal.test.tsx b/packages/ui/src/__tests__/session-context-layer-goal.test.tsx index 95cfbcc7e9..449a0a5b6b 100644 --- a/packages/ui/src/__tests__/session-context-layer-goal.test.tsx +++ b/packages/ui/src/__tests__/session-context-layer-goal.test.tsx @@ -137,3 +137,18 @@ test('a bound first Turn makes an armed goal read as running', () => { assert.ok(markup.includes('Autonomous goal running')); assert.ok(!markup.includes('Autonomous goal set; takes hold on the next Turn')); }); + +test('redacts secrets from the visible Goal condition', () => { + const secret = 'sk-ant-api03-abc123def456ghi789jkl0mn1opq'; + const markup = renderGoalChip({ + condition: `Use Authorization: Bearer ${secret}`, + status: 'waiting', + iterations: 4, + maxIterations: 50, + setAt: Date.now() - 30_000, + onClear: () => undefined, + }); + + assert.equal(markup.includes(secret), false); + assert.ok(markup.includes('Authorization: Bearer <redacted>')); +}); diff --git a/packages/ui/src/session-context-layer.tsx b/packages/ui/src/session-context-layer.tsx index a67a548aca..fd7dd35955 100644 --- a/packages/ui/src/session-context-layer.tsx +++ b/packages/ui/src/session-context-layer.tsx @@ -37,6 +37,7 @@ import { import { getConversationCopy, type GoalDisplayPhase } from './conversation-copy.js'; import { ICON_SIZE, Pause, Play } from './icons.js'; import { useUiLocale } from './locale-context.js'; +import { redactSecrets } from './redact.js'; import { dotForStatus } from './status-vocabulary.js'; export interface SessionContextBranch { @@ -117,6 +118,7 @@ export function SessionContextLayer(props: { if (props.goal) { const goal = props.goal; + const condition = redactSecrets(goal.condition); // A paused goal burns nothing, while waiting remains live but is not // currently executing. Both must be visually still; only paused needs an // attention tone. @@ -191,7 +193,7 @@ export function SessionContextLayer(props: { size="sm" onClick={goal.onPause} tooltip={copy.pauseGoal( - goal.condition, + condition, goal.iterations, goal.maxIterations, phase, @@ -206,7 +208,7 @@ export function SessionContextLayer(props: { variant="ghost" size="sm" onClick={goal.onResume} - tooltip={copy.resumeGoal(goal.condition, goal.iterations, goal.maxIterations)} + tooltip={copy.resumeGoal(condition, goal.iterations, goal.maxIterations)} /> ) : null} {props.goal ? ( - +
- {props.goal.condition} + {redactSecrets(props.goal.condition)}