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
23 changes: 23 additions & 0 deletions apps/desktop/src/main/__tests__/goal-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,3 +265,26 @@ describe('useGoalController', () => {
assert.deepEqual(calls, ['resume:a', 'clear:a']);
});
});


it('cancellation captures the displayed wait and refreshes a conflict without retargeting', async () => {
const { root } = installReactRenderer();
const defaults = createFakeGoalServices();
const targets: unknown[] = [];
const errors: string[] = [];
let reads = 0;
const eventWait = { waitId: 'wait-a', controlGeneration: 3, waitRevision: 4,
resource: { providerId: 'shell_run', resourceType: 'background_task', resourceId: 'maka://runtime/background-tasks/task' },
reason: 'Inspect output', deadlineAt: 10000, phase: 'observing' as const,
observation: { state: 'unknown' as const, lastObservedAt: 100, reasonCode: 'source_read_failed' }, outcome: null };
const services = createFakeGoalServices({ goal: { ...defaults.goal,
get: async () => { reads++; return { ...goal('a'), eventWait: { ...eventWait, waitId: reads === 1 ? 'wait-a' : 'wait-b' } }; },
cancelEventWait: async (target) => { targets.push(target); return { sessionId: 'a', outcome: 'conflict', goal: null }; },
} });
await act(async () => renderController(root, services, input('a', errors)));
assert.equal(controller().selectors.indicator?.eventWait?.label, 'Background task state temporarily unverifiable');
await act(async () => controller().selectors.indicator?.eventWait?.onCancel());
assert.deepEqual(targets, [{ sessionId: 'a', goalId: 'goal-a', expectedGoalRevision: 1, controlGeneration: 3, waitId: 'wait-a' }]);
assert.equal(reads, 2);
assert.equal(errors.length, 1);
});
141 changes: 141 additions & 0 deletions apps/desktop/src/main/__tests__/goal-preload-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,23 @@
*/

import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { runInNewContext } from 'node:vm';
import test from 'node:test';
import { build } from 'esbuild';
import type { GoalEventWaitCancelInput, GoalEventWaitCancelResult, GoalProjection } from '@maka/runtime-host/protocol';
import type { MakaBridge } from '../../preload/bridge-contract.js';
import {
invokeProjectedSessionRuntimeHost,
} from '../../preload/projected-session-runtime-host.js';
import { desktopSessionKey } from '../../shared/runtime-host-identity.js';
import {
registerRuntimeHostSessionDomainsIpc,
type RuntimeHostSessionDomainsIpcDeps,
} from '../runtime-host-session-domains-ipc-main.js';
import type { IpcHandler } from '../ipc-reconnect-policy.js';
import { TerminalCloseIntents } from '../terminal-close-intents.js';

test('Goal arm preload routing preserves target scope and projects every outcome', async () => {
const scope = { hostId: 'host-1', targetEpoch: 'epoch-2' };
Expand Down Expand Up @@ -87,3 +99,132 @@ test('Goal arm preload routing preserves target scope and projects every outcome
assert.deepEqual(result, scenario.projected);
}
});

test('Goal wait cancellation resolves Desktop identity once and projects every result', async (t) => {
const bundle = await build({
entryPoints: [fileURLToPath(new URL('../../../src/preload/preload.ts', import.meta.url))],
bundle: true, write: false, platform: 'node', format: 'cjs', external: ['electron'],
});
for (const profileKind of ['local', 'remote'] as const) {
for (const outcome of ['cancelled', 'already_cancelled', 'conflict', 'already_admitted'] as const) {
await t.test(`${profileKind} owner: ${outcome}`, async () => {
const host = {
hostId: `${profileKind}-host`, targetEpoch: `${profileKind}-epoch`,
profileId: profileKind, profileName: profileKind, profileKind,
profileAccess: 'owner', readiness: 'ready',
};
const scope = { hostId: host.hostId, targetEpoch: host.targetEpoch };
const desktopSessionId = desktopSessionKey({ hostId: host.hostId, sessionId: 'session-1' });
const goal: GoalProjection = {
goalId: 'goal-1', sessionId: 'session-1', revision: 3,
condition: 'Inspect the background tests', status: 'active', setAt: 1,
iterations: 1, maxIterations: 20, consecutiveNoProgress: 0, blockCap: 8,
tokenBudget: null, tokensSpent: 100, lastReason: null, achievedAt: null, pausedAt: null,
eventWait: {
waitId: 'wait-1', controlGeneration: 5, waitRevision: 2,
resource: { providerId: 'shell_run', resourceType: 'background_task', resourceId: 'maka://runtime/background-tasks/task-1' },
reason: 'Inspect the test result', deadlineAt: 60_000, phase: 'observing',
observation: { state: 'pending', lastObservedAt: 1, reasonCode: null }, outcome: null,
},
};
const cancelled = outcome === 'cancelled' || outcome === 'already_cancelled';
const response: GoalEventWaitCancelResult = {
sessionId: 'session-1', outcome,
goal: {
...goal, revision: 4,
status: cancelled ? 'paused' : 'active',
pausedAt: cancelled ? 2 : null,
eventWait: outcome === 'conflict'
? { ...goal.eventWait!, waitId: 'wait-new', controlGeneration: 6 }
: null,
},
};
const handlers = new Map<string, IpcHandler>();
const cancellations: GoalEventWaitCancelInput[] = [];
const ipcCancellations: unknown[] = [];
const pauses: string[] = [];
let queries = 0;
const domains = registerRuntimeHostSessionDomainsIpc({
client: {
queryGoal: async (sessionId: string) => {
assert.equal(sessionId, 'session-1');
queries++;
return { sessionId, goal };
},
cancelGoalEventWait: async (input: GoalEventWaitCancelInput) => {
cancellations.push(input);
return response;
},
controlGoalWithRetry: async (sessionId: string, action: string) => {
assert.equal(action, 'pause');
pauses.push(sessionId);
},
} as RuntimeHostSessionDomainsIpcDeps['client'],
terminalCloses: new TerminalCloseIntents(),
sessionObserver: { async observe() {}, async unobserve() {} },
emitModeChanged() {},
}, {
handle(channel, handler) { handlers.set(channel, handler); },
});
const ipcRenderer = {
on() {}, off() {}, send() {},
async invoke(channel: string, ...vmArgs: unknown[]) {
if (channel === 'app:bootstrapReady' || channel === 'runtime-host:awaitReady') return { ready: true };
if (channel === 'runtime-host:identities') return [
...(profileKind === 'remote' ? [{
...host, hostId: 'local-host', targetEpoch: 'local-epoch', epoch: 'local-epoch',
profileId: 'local', profileKind: 'local', isDefault: true,
}] : []),
{ ...host, epoch: host.targetEpoch, isDefault: profileKind === 'local' },
];
// Electron IPC clones arguments out of the preload's realm.
const [targetScope, rawSessionId, ...args] = structuredClone(vmArgs);
assert.deepEqual(targetScope, scope);
assert.equal(rawSessionId, 'session-1');
if (channel === 'goal:cancel-event-wait') ipcCancellations.push(args[0]);
const handler = handlers.get(channel);
assert.ok(handler, `Unexpected channel: ${channel}`);
return handler({} as never, rawSessionId, ...args);
},
};
let bridge: MakaBridge | undefined;
const require = createRequire(import.meta.url);
runInNewContext(bundle.outputFiles[0]!.text, {
require: (id: string) => id === 'electron' ? {
ipcRenderer,
contextBridge: { exposeInMainWorld(name: string, value: MakaBridge) {
if (name === 'maka') bridge = value;
} },
} : require(id),
process: { env: {} }, Buffer, console, setTimeout, clearTimeout, TextEncoder, TextDecoder,
crypto: globalThis.crypto,
});
assert.ok(bridge);
try {
const displayed = await bridge.goal.get(desktopSessionId);
assert.equal(displayed?.sessionId, desktopSessionId);
assert.ok(displayed?.eventWait);
const target = Object.freeze({
sessionId: displayed.sessionId, goalId: displayed.id,
controlGeneration: displayed.eventWait.controlGeneration,
waitId: displayed.eventWait.waitId, expectedGoalRevision: displayed.revision,
});
const result = await bridge.goal.cancelEventWait(target);
const expectedTarget = { ...target, sessionId: 'session-1' };
assert.deepEqual(ipcCancellations, [expectedTarget]);
assert.deepEqual(cancellations, [expectedTarget]);
assert.equal(target.sessionId, desktopSessionId, 'do not mutate the displayed target');
assert.equal(queries, 1, 'do not query a newer wait to retarget cancellation');
assert.deepEqual(structuredClone(result), {
...response, sessionId: desktopSessionId,
goal: { ...response.goal, sessionId: desktopSessionId },
});
await bridge.goal.pause(desktopSessionId);
assert.deepEqual(pauses, ['session-1']);
} finally {
await domains.close();
}
});
}
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ test('goal:arm reconciles a lost dispatched response without dispatching again',
tokensAtStart: 0,
tokensNow: 120,
tokensBaselinePending: false,
eventWait: null,
},
matchesRequestedState: true,
},
Expand Down Expand Up @@ -307,6 +308,7 @@ test('goal:arm reconciliation reports different, missing, and unavailable author
tokensAtStart: 0,
tokensNow: 120,
tokensBaselinePending: false,
eventWait: null,
},
matchesRequestedState: false,
},
Expand Down Expand Up @@ -485,6 +487,7 @@ test('adapts Host Goal, Task, and Resource projections', async () => {
tokensAtStart: 0,
tokensNow: 120,
tokensBaselinePending: false,
eventWait: null,
});
await ipc.invoke('goal:clear', 'session-1');
await ipc.invoke('goal:pause', 'session-1');
Expand Down Expand Up @@ -1152,6 +1155,7 @@ function baseGoalProjection() {
lastReason: null,
achievedAt: null,
pausedAt: null,
eventWait: null,
};
}

Expand Down Expand Up @@ -1406,3 +1410,23 @@ test('plan control channels return the Host error code across the IPC boundary',
assert.deepEqual(changed, [], `${scenario.channel} must not report a mode change`);
}
});


test('wait cancellation preserves the exact renderer target and rejects cross-Session routing', async () => {
const calls: unknown[] = [];
const wait = { waitId: 'wait-1', controlGeneration: 5, waitRevision: 2,
resource: { providerId: 'shell_run', resourceType: 'background_task', resourceId: 'maka://runtime/background-tasks/task' },
reason: 'Inspect tests', deadlineAt: 100, phase: 'observing' as const,
observation: { state: 'pending' as const, lastObservedAt: 10, reasonCode: null }, outcome: null };
const ipc = ipcHarness();
registerDomainsIpc({ client: domainClient({
queryGoal: async () => ({ sessionId: 'session-1', goal: { ...goalProjection(), eventWait: wait } }),
cancelGoalEventWait: async (target) => { calls.push(target); return { sessionId: target.sessionId, outcome: 'conflict', goal: null }; },
}), emitModeChanged() {} }, ipc);
assert.deepEqual((await ipc.invoke('goal:get', 'session-1') as { eventWait: unknown }).eventWait, wait);
const target = { sessionId: 'session-1', goalId: 'goal-1', controlGeneration: 5, waitId: 'wait-1', expectedGoalRevision: 3 };
assert.deepEqual(await ipc.invoke('goal:cancel-event-wait', 'session-1', target), { sessionId: 'session-1', outcome: 'conflict', goal: null });
await assert.rejects(ipc.invoke('goal:cancel-event-wait', 'another', target));
await assert.rejects(ipc.invoke('goal:cancel-event-wait', 'session-1', { ...target, waitId: undefined }));
assert.deepEqual(calls, [target]);
});
Original file line number Diff line number Diff line change
Expand Up @@ -3357,6 +3357,7 @@ test("publishes Host sidecar and graph invalidations without inventing Session s
lastReason: null,
achievedAt: null,
pausedAt: null,
eventWait: null,
},
}),
});
Expand Down Expand Up @@ -3853,6 +3854,7 @@ function activeGoal() {
lastReason: null,
achievedAt: null,
pausedAt: null,
eventWait: null,
};
}

Expand Down Expand Up @@ -4291,3 +4293,26 @@ test('a latched replica reseeds through recovery for the next reader', async ()
await pollFor(() => batches.some((batch) => batch.fragments.length > 0));
await observer.close();
});


test('wait-only projection changes invalidate the Goal without a Goal revision change', async () => {
const events = new AsyncFrameQueue();
const changes: string[] = [];
const wait = { waitId: 'wait', controlGeneration: 1, waitRevision: 1,
resource: { providerId: 'shell_run', resourceType: 'background_task', resourceId: 'maka://runtime/background-tasks/task' },
reason: 'Inspect tests', deadlineAt: 100, phase: 'observing' as const,
observation: { state: 'pending' as const, lastObservedAt: 10, reasonCode: null }, outcome: null };
const goal = { ...activeGoal(), eventWait: wait };
const observer = new RuntimeHostSessionObserver({ client: { openSession: async () => runtimeHostSessionFixture({
snapshot: continuitySnapshot({ goal }), events, async close() { events.end(); },
}) }, emitSessionsChanged: (reason) => changes.push(reason) });
try {
await observer.observe('session-1', 'observer-1', eventTarget(11));
changes.length = 0;
const ready = { ...goal, eventWait: { ...wait, waitRevision: 2, phase: 'awaiting_admission' as const, observation: null, outcome: 'satisfied' as const } };
events.push({ kind: 'subscription.session_projection', hostEpoch: 'host-1', subscriptionId: 'subscription-1', sequence: 1,
snapshot: continuitySnapshot({ goal: ready, projectionRevision: 2 }) });
await waitFor(() => changes.includes('goal-change'));
assert.equal(changes.filter((r) => r === 'goal-change').length, 1);
} finally { await observer.close(); }
});
4 changes: 4 additions & 0 deletions apps/desktop/src/main/runtime-host-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1489,6 +1489,10 @@ export class DesktopRuntimeHostClient {
return this.request("usage.query", input);
}

cancelGoalEventWait(input: OperationInput<"goal.event-wait.cancel">): Promise<OperationOutput<"goal.event-wait.cancel">> {
return this.request("goal.event-wait.cancel", input);
}

queryGoal(sessionId: string): Promise<OperationOutput<"goal.query">> {
return this.request("goal.query", { sessionId });
}
Expand Down
13 changes: 11 additions & 2 deletions apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,19 @@
*/

import { randomUUID } from 'node:crypto';
import type { DesktopGoal } from '../shared/goal-arm.js';
import type { AgentGraphClientChangedEvent } from '@maka/runtime/stream-graph-coordinator';
import type {
AgentGraphClientSnapshot,
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 { PlanSessionState } from '@maka/core/plan';
import {
type GoalProjection,
HOST_OPERATION_SPECS,
type OperationOutput,
type PlanControlErrorCode,
type SessionDomainChange,
Expand Down Expand Up @@ -58,6 +60,7 @@ type RuntimeHostSessionDomainClient = RuntimeHostShellRunsClient &
Pick<
DesktopRuntimeHostClient,
| 'armGoal'
| 'cancelGoalEventWait'
| 'clearGoal'
| 'controlGoalWithRetry'
| 'controlPlan'
Expand Down Expand Up @@ -126,6 +129,11 @@ export function registerRuntimeHostSessionDomainsIpc(
const result = await deps.client.queryGoal(requiredId(sessionId, 'Session'));
return result.goal === null ? null : toDesktopGoal(result.goal);
});
ipcMain.handle('goal:cancel-event-wait', async (_event, sessionId: unknown, value: unknown) => {
const input = HOST_OPERATION_SPECS['goal.event-wait.cancel'].decodeInput(value);
if (input.sessionId !== requiredId(sessionId, 'Session')) throw new Error('Wait cancellation Session mismatch');
return deps.client.cancelGoalEventWait(input);
});
ipcMain.handle('goal:clear', async (_event, sessionId: unknown) => {
await deps.client.clearGoal(requiredId(sessionId, 'Session'));
});
Expand Down Expand Up @@ -476,9 +484,10 @@ function optionalCount(value: unknown, label: string): number | null {
return value;
}

function toDesktopGoal(goal: GoalProjection): GoalState {
function toDesktopGoal(goal: GoalProjection): DesktopGoal {
return {
id: goal.goalId,
eventWait: goal.eventWait,
revision: goal.revision,
sessionId: goal.sessionId,
condition: goal.condition,
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/main/runtime-host-session-observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1952,7 +1952,8 @@ function sameGoal(
return (
next !== null &&
previous.goalId === next.goalId &&
previous.revision === next.revision
previous.revision === next.revision &&
JSON.stringify(previous.eventWait) === JSON.stringify(next.eventWait)
);
}

Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/preload/bridge-contract.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1587,7 +1587,8 @@ export interface MakaBridge {
};
goal: {
/** The session's current goal (null when none is set). */
get(sessionId: string): Promise<import('@maka/runtime/goal-state').GoalState | null>;
get(sessionId: string): Promise<import('../shared/goal-arm').DesktopGoal | null>;
cancelEventWait(input: import('@maka/runtime-host/protocol').GoalEventWaitCancelInput): Promise<import('@maka/runtime-host/protocol').GoalEventWaitCancelResult>;
/**
* 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
Expand Down
13 changes: 12 additions & 1 deletion apps/desktop/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3210,12 +3210,23 @@ const makaBridge = {
},
},
goal: {
get(sessionId: string): Promise<GoalState | null> {
get(sessionId: string): Promise<import('../shared/goal-arm.js').DesktopGoal | null> {
return invokeProjectedSessionRuntimeHost('goal:get', sessionId);
},
arm(sessionId: string, goal: GoalArmRequest): Promise<GoalArmOutcome> {
return invokeProjectedSessionRuntimeHost('goal:arm', sessionId, goal);
},
async cancelEventWait(input: import('@maka/runtime-host/protocol').GoalEventWaitCancelInput): Promise<import('@maka/runtime-host/protocol').GoalEventWaitCancelResult> {
const target = { ...input };
const session = await runtimeHostSessionRef(target.sessionId);
const result = await invokeWhenReady(
'goal:cancel-event-wait',
session.scope,
session.sessionId,
{ ...target, sessionId: session.sessionId },
) as import('@maka/runtime-host/protocol').GoalEventWaitCancelResult;
return projectProtocolSessionIds(session.scope.hostId, result);
},
clear(sessionId: string): Promise<void> {
return invokeSessionRuntimeHost('goal:clear', sessionId);
},
Expand Down
Loading