Skip to content

Commit f7fd464

Browse files
authored
feat(runtime-host): enable ShellRun event waits (#6206)
* feat(runtime-host): enable ShellRun event waits Verify managed background Bash provenance and deliver terminal facts through the existing Goal and Root admission flow. Add bounded wait projections, precise cancellation, and Desktop/CLI controls without taking ownership of background processes. Cover provider parity, real Bash and PTY flows, cancellation, process recovery, and cross-layer projection behavior. Generated-by: OpenAI Codex * fix(cli): localize goal event wait notices Move event wait cancellation notices and goal usage into the TUI copy catalog with English, Simplified Chinese, and Traditional Chinese resources. Remove the stale legacy usage allowance. Generated-by: OpenAI Codex * docs(ui): refresh Astryx surface inventory for goal waits Record the Button used by the goal event wait cancellation control in the generated surface inventory. Generated-by: OpenAI Codex * fix(runtime-host): preserve ShellRun waits after main rebase Keep Goal projection callbacks strictly decoded across the shared observer boundary and accept the observer record type in ShellRun source adapters. Use the Node 22 test-isolation flag for the crash recovery harness, and restore the CLI goal-control JSDoc to its own member. The rebased feature commit advances the Host compatibility epoch to 227 while retaining main's epoch 226 history. Generated-by: OpenAI Codex
1 parent 2701647 commit f7fd464

61 files changed

Lines changed: 3394 additions & 114 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/desktop/src/main/__tests__/goal-controller.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,3 +265,26 @@ describe('useGoalController', () => {
265265
assert.deepEqual(calls, ['resume:a', 'clear:a']);
266266
});
267267
});
268+
269+
270+
it('cancellation captures the displayed wait and refreshes a conflict without retargeting', async () => {
271+
const { root } = installReactRenderer();
272+
const defaults = createFakeGoalServices();
273+
const targets: unknown[] = [];
274+
const errors: string[] = [];
275+
let reads = 0;
276+
const eventWait = { waitId: 'wait-a', controlGeneration: 3, waitRevision: 4,
277+
resource: { providerId: 'shell_run', resourceType: 'background_task', resourceId: 'maka://runtime/background-tasks/task' },
278+
reason: 'Inspect output', deadlineAt: 10000, phase: 'observing' as const,
279+
observation: { state: 'unknown' as const, lastObservedAt: 100, reasonCode: 'source_read_failed' }, outcome: null };
280+
const services = createFakeGoalServices({ goal: { ...defaults.goal,
281+
get: async () => { reads++; return { ...goal('a'), eventWait: { ...eventWait, waitId: reads === 1 ? 'wait-a' : 'wait-b' } }; },
282+
cancelEventWait: async (target) => { targets.push(target); return { sessionId: 'a', outcome: 'conflict', goal: null }; },
283+
} });
284+
await act(async () => renderController(root, services, input('a', errors)));
285+
assert.equal(controller().selectors.indicator?.eventWait?.label, 'Background task state temporarily unverifiable');
286+
await act(async () => controller().selectors.indicator?.eventWait?.onCancel());
287+
assert.deepEqual(targets, [{ sessionId: 'a', goalId: 'goal-a', expectedGoalRevision: 1, controlGeneration: 3, waitId: 'wait-a' }]);
288+
assert.equal(reads, 2);
289+
assert.equal(errors.length, 1);
290+
});

‎apps/desktop/src/main/__tests__/goal-preload-bridge.test.ts‎

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,23 @@
1818
*/
1919

2020
import assert from 'node:assert/strict';
21+
import { createRequire } from 'node:module';
22+
import { fileURLToPath } from 'node:url';
23+
import { runInNewContext } from 'node:vm';
2124
import test from 'node:test';
25+
import { build } from 'esbuild';
26+
import type { GoalEventWaitCancelInput, GoalEventWaitCancelResult, GoalProjection } from '@maka/runtime-host/protocol';
27+
import type { MakaBridge } from '../../preload/bridge-contract.js';
2228
import {
2329
invokeProjectedSessionRuntimeHost,
2430
} from '../../preload/projected-session-runtime-host.js';
2531
import { desktopSessionKey } from '../../shared/runtime-host-identity.js';
32+
import {
33+
registerRuntimeHostSessionDomainsIpc,
34+
type RuntimeHostSessionDomainsIpcDeps,
35+
} from '../runtime-host-session-domains-ipc-main.js';
36+
import type { IpcHandler } from '../ipc-reconnect-policy.js';
37+
import { TerminalCloseIntents } from '../terminal-close-intents.js';
2638

2739
test('Goal arm preload routing preserves target scope and projects every outcome', async () => {
2840
const scope = { hostId: 'host-1', targetEpoch: 'epoch-2' };
@@ -87,3 +99,132 @@ test('Goal arm preload routing preserves target scope and projects every outcome
8799
assert.deepEqual(result, scenario.projected);
88100
}
89101
});
102+
103+
test('Goal wait cancellation resolves Desktop identity once and projects every result', async (t) => {
104+
const bundle = await build({
105+
entryPoints: [fileURLToPath(new URL('../../../src/preload/preload.ts', import.meta.url))],
106+
bundle: true, write: false, platform: 'node', format: 'cjs', external: ['electron'],
107+
});
108+
for (const profileKind of ['local', 'remote'] as const) {
109+
for (const outcome of ['cancelled', 'already_cancelled', 'conflict', 'already_admitted'] as const) {
110+
await t.test(`${profileKind} owner: ${outcome}`, async () => {
111+
const host = {
112+
hostId: `${profileKind}-host`, targetEpoch: `${profileKind}-epoch`,
113+
profileId: profileKind, profileName: profileKind, profileKind,
114+
profileAccess: 'owner', readiness: 'ready',
115+
};
116+
const scope = { hostId: host.hostId, targetEpoch: host.targetEpoch };
117+
const desktopSessionId = desktopSessionKey({ hostId: host.hostId, sessionId: 'session-1' });
118+
const goal: GoalProjection = {
119+
goalId: 'goal-1', sessionId: 'session-1', revision: 3,
120+
condition: 'Inspect the background tests', status: 'active', setAt: 1,
121+
iterations: 1, maxIterations: 20, consecutiveNoProgress: 0, blockCap: 8,
122+
tokenBudget: null, tokensSpent: 100, lastReason: null, achievedAt: null, pausedAt: null,
123+
eventWait: {
124+
waitId: 'wait-1', controlGeneration: 5, waitRevision: 2,
125+
resource: { providerId: 'shell_run', resourceType: 'background_task', resourceId: 'maka://runtime/background-tasks/task-1' },
126+
reason: 'Inspect the test result', deadlineAt: 60_000, phase: 'observing',
127+
observation: { state: 'pending', lastObservedAt: 1, reasonCode: null }, outcome: null,
128+
},
129+
};
130+
const cancelled = outcome === 'cancelled' || outcome === 'already_cancelled';
131+
const response: GoalEventWaitCancelResult = {
132+
sessionId: 'session-1', outcome,
133+
goal: {
134+
...goal, revision: 4,
135+
status: cancelled ? 'paused' : 'active',
136+
pausedAt: cancelled ? 2 : null,
137+
eventWait: outcome === 'conflict'
138+
? { ...goal.eventWait!, waitId: 'wait-new', controlGeneration: 6 }
139+
: null,
140+
},
141+
};
142+
const handlers = new Map<string, IpcHandler>();
143+
const cancellations: GoalEventWaitCancelInput[] = [];
144+
const ipcCancellations: unknown[] = [];
145+
const pauses: string[] = [];
146+
let queries = 0;
147+
const domains = registerRuntimeHostSessionDomainsIpc({
148+
client: {
149+
queryGoal: async (sessionId: string) => {
150+
assert.equal(sessionId, 'session-1');
151+
queries++;
152+
return { sessionId, goal };
153+
},
154+
cancelGoalEventWait: async (input: GoalEventWaitCancelInput) => {
155+
cancellations.push(input);
156+
return response;
157+
},
158+
controlGoalWithRetry: async (sessionId: string, action: string) => {
159+
assert.equal(action, 'pause');
160+
pauses.push(sessionId);
161+
},
162+
} as RuntimeHostSessionDomainsIpcDeps['client'],
163+
terminalCloses: new TerminalCloseIntents(),
164+
sessionObserver: { async observe() {}, async unobserve() {} },
165+
emitModeChanged() {},
166+
}, {
167+
handle(channel, handler) { handlers.set(channel, handler); },
168+
});
169+
const ipcRenderer = {
170+
on() {}, off() {}, send() {},
171+
async invoke(channel: string, ...vmArgs: unknown[]) {
172+
if (channel === 'app:bootstrapReady' || channel === 'runtime-host:awaitReady') return { ready: true };
173+
if (channel === 'runtime-host:identities') return [
174+
...(profileKind === 'remote' ? [{
175+
...host, hostId: 'local-host', targetEpoch: 'local-epoch', epoch: 'local-epoch',
176+
profileId: 'local', profileKind: 'local', isDefault: true,
177+
}] : []),
178+
{ ...host, epoch: host.targetEpoch, isDefault: profileKind === 'local' },
179+
];
180+
// Electron IPC clones arguments out of the preload's realm.
181+
const [targetScope, rawSessionId, ...args] = structuredClone(vmArgs);
182+
assert.deepEqual(targetScope, scope);
183+
assert.equal(rawSessionId, 'session-1');
184+
if (channel === 'goal:cancel-event-wait') ipcCancellations.push(args[0]);
185+
const handler = handlers.get(channel);
186+
assert.ok(handler, `Unexpected channel: ${channel}`);
187+
return handler({} as never, rawSessionId, ...args);
188+
},
189+
};
190+
let bridge: MakaBridge | undefined;
191+
const require = createRequire(import.meta.url);
192+
runInNewContext(bundle.outputFiles[0]!.text, {
193+
require: (id: string) => id === 'electron' ? {
194+
ipcRenderer,
195+
contextBridge: { exposeInMainWorld(name: string, value: MakaBridge) {
196+
if (name === 'maka') bridge = value;
197+
} },
198+
} : require(id),
199+
process: { env: {} }, Buffer, console, setTimeout, clearTimeout, TextEncoder, TextDecoder,
200+
crypto: globalThis.crypto,
201+
});
202+
assert.ok(bridge);
203+
try {
204+
const displayed = await bridge.goal.get(desktopSessionId);
205+
assert.equal(displayed?.sessionId, desktopSessionId);
206+
assert.ok(displayed?.eventWait);
207+
const target = Object.freeze({
208+
sessionId: displayed.sessionId, goalId: displayed.id,
209+
controlGeneration: displayed.eventWait.controlGeneration,
210+
waitId: displayed.eventWait.waitId, expectedGoalRevision: displayed.revision,
211+
});
212+
const result = await bridge.goal.cancelEventWait(target);
213+
const expectedTarget = { ...target, sessionId: 'session-1' };
214+
assert.deepEqual(ipcCancellations, [expectedTarget]);
215+
assert.deepEqual(cancellations, [expectedTarget]);
216+
assert.equal(target.sessionId, desktopSessionId, 'do not mutate the displayed target');
217+
assert.equal(queries, 1, 'do not query a newer wait to retarget cancellation');
218+
assert.deepEqual(structuredClone(result), {
219+
...response, sessionId: desktopSessionId,
220+
goal: { ...response.goal, sessionId: desktopSessionId },
221+
});
222+
await bridge.goal.pause(desktopSessionId);
223+
assert.deepEqual(pauses, ['session-1']);
224+
} finally {
225+
await domains.close();
226+
}
227+
});
228+
}
229+
}
230+
});

‎apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ test('goal:arm reconciles a lost dispatched response without dispatching again',
113113
tokensAtStart: 0,
114114
tokensNow: 120,
115115
tokensBaselinePending: false,
116+
eventWait: null,
116117
},
117118
matchesRequestedState: true,
118119
},
@@ -307,6 +308,7 @@ test('goal:arm reconciliation reports different, missing, and unavailable author
307308
tokensAtStart: 0,
308309
tokensNow: 120,
309310
tokensBaselinePending: false,
311+
eventWait: null,
310312
},
311313
matchesRequestedState: false,
312314
},
@@ -485,6 +487,7 @@ test('adapts Host Goal, Task, and Resource projections', async () => {
485487
tokensAtStart: 0,
486488
tokensNow: 120,
487489
tokensBaselinePending: false,
490+
eventWait: null,
488491
});
489492
await ipc.invoke('goal:clear', 'session-1');
490493
await ipc.invoke('goal:pause', 'session-1');
@@ -1152,6 +1155,7 @@ function baseGoalProjection() {
11521155
lastReason: null,
11531156
achievedAt: null,
11541157
pausedAt: null,
1158+
eventWait: null,
11551159
};
11561160
}
11571161

@@ -1406,3 +1410,23 @@ test('plan control channels return the Host error code across the IPC boundary',
14061410
assert.deepEqual(changed, [], `${scenario.channel} must not report a mode change`);
14071411
}
14081412
});
1413+
1414+
1415+
test('wait cancellation preserves the exact renderer target and rejects cross-Session routing', async () => {
1416+
const calls: unknown[] = [];
1417+
const wait = { waitId: 'wait-1', controlGeneration: 5, waitRevision: 2,
1418+
resource: { providerId: 'shell_run', resourceType: 'background_task', resourceId: 'maka://runtime/background-tasks/task' },
1419+
reason: 'Inspect tests', deadlineAt: 100, phase: 'observing' as const,
1420+
observation: { state: 'pending' as const, lastObservedAt: 10, reasonCode: null }, outcome: null };
1421+
const ipc = ipcHarness();
1422+
registerDomainsIpc({ client: domainClient({
1423+
queryGoal: async () => ({ sessionId: 'session-1', goal: { ...goalProjection(), eventWait: wait } }),
1424+
cancelGoalEventWait: async (target) => { calls.push(target); return { sessionId: target.sessionId, outcome: 'conflict', goal: null }; },
1425+
}), emitModeChanged() {} }, ipc);
1426+
assert.deepEqual((await ipc.invoke('goal:get', 'session-1') as { eventWait: unknown }).eventWait, wait);
1427+
const target = { sessionId: 'session-1', goalId: 'goal-1', controlGeneration: 5, waitId: 'wait-1', expectedGoalRevision: 3 };
1428+
assert.deepEqual(await ipc.invoke('goal:cancel-event-wait', 'session-1', target), { sessionId: 'session-1', outcome: 'conflict', goal: null });
1429+
await assert.rejects(ipc.invoke('goal:cancel-event-wait', 'another', target));
1430+
await assert.rejects(ipc.invoke('goal:cancel-event-wait', 'session-1', { ...target, waitId: undefined }));
1431+
assert.deepEqual(calls, [target]);
1432+
});

‎apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3357,6 +3357,7 @@ test("publishes Host sidecar and graph invalidations without inventing Session s
33573357
lastReason: null,
33583358
achievedAt: null,
33593359
pausedAt: null,
3360+
eventWait: null,
33603361
},
33613362
}),
33623363
});
@@ -3853,6 +3854,7 @@ function activeGoal() {
38533854
lastReason: null,
38543855
achievedAt: null,
38553856
pausedAt: null,
3857+
eventWait: null,
38563858
};
38573859
}
38583860

@@ -4291,3 +4293,26 @@ test('a latched replica reseeds through recovery for the next reader', async ()
42914293
await pollFor(() => batches.some((batch) => batch.fragments.length > 0));
42924294
await observer.close();
42934295
});
4296+
4297+
4298+
test('wait-only projection changes invalidate the Goal without a Goal revision change', async () => {
4299+
const events = new AsyncFrameQueue();
4300+
const changes: string[] = [];
4301+
const wait = { waitId: 'wait', controlGeneration: 1, waitRevision: 1,
4302+
resource: { providerId: 'shell_run', resourceType: 'background_task', resourceId: 'maka://runtime/background-tasks/task' },
4303+
reason: 'Inspect tests', deadlineAt: 100, phase: 'observing' as const,
4304+
observation: { state: 'pending' as const, lastObservedAt: 10, reasonCode: null }, outcome: null };
4305+
const goal = { ...activeGoal(), eventWait: wait };
4306+
const observer = new RuntimeHostSessionObserver({ client: { openSession: async () => runtimeHostSessionFixture({
4307+
snapshot: continuitySnapshot({ goal }), events, async close() { events.end(); },
4308+
}) }, emitSessionsChanged: (reason) => changes.push(reason) });
4309+
try {
4310+
await observer.observe('session-1', 'observer-1', eventTarget(11));
4311+
changes.length = 0;
4312+
const ready = { ...goal, eventWait: { ...wait, waitRevision: 2, phase: 'awaiting_admission' as const, observation: null, outcome: 'satisfied' as const } };
4313+
events.push({ kind: 'subscription.session_projection', hostEpoch: 'host-1', subscriptionId: 'subscription-1', sequence: 1,
4314+
snapshot: continuitySnapshot({ goal: ready, projectionRevision: 2 }) });
4315+
await waitFor(() => changes.includes('goal-change'));
4316+
assert.equal(changes.filter((r) => r === 'goal-change').length, 1);
4317+
} finally { await observer.close(); }
4318+
});

‎apps/desktop/src/main/runtime-host-client.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1489,6 +1489,10 @@ export class DesktopRuntimeHostClient {
14891489
return this.request("usage.query", input);
14901490
}
14911491

1492+
cancelGoalEventWait(input: OperationInput<"goal.event-wait.cancel">): Promise<OperationOutput<"goal.event-wait.cancel">> {
1493+
return this.request("goal.event-wait.cancel", input);
1494+
}
1495+
14921496
queryGoal(sessionId: string): Promise<OperationOutput<"goal.query">> {
14931497
return this.request("goal.query", { sessionId });
14941498
}

‎apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,19 @@
1818
*/
1919

2020
import { randomUUID } from 'node:crypto';
21+
import type { DesktopGoal } from '../shared/goal-arm.js';
2122
import type { AgentGraphClientChangedEvent } from '@maka/runtime/stream-graph-coordinator';
2223
import type {
2324
AgentGraphClientSnapshot,
2425
AgentGraphClientSnapshotOptions,
2526
AgentGraphOperatorInspection,
2627
} from '@maka/runtime/stream-graph-read-model';
27-
import { DEFAULT_MAX_ITERATIONS, type GoalState } from '@maka/runtime/goal-state';
28+
import { DEFAULT_MAX_ITERATIONS } from '@maka/runtime/goal-state';
2829
import type { ShellRunPtyDataEvent } from '@maka/runtime/shell-run-contract';
2930
import type { PlanSessionState } from '@maka/core/plan';
3031
import {
3132
type GoalProjection,
33+
HOST_OPERATION_SPECS,
3234
type OperationOutput,
3335
type PlanControlErrorCode,
3436
type SessionDomainChange,
@@ -58,6 +60,7 @@ type RuntimeHostSessionDomainClient = RuntimeHostShellRunsClient &
5860
Pick<
5961
DesktopRuntimeHostClient,
6062
| 'armGoal'
63+
| 'cancelGoalEventWait'
6164
| 'clearGoal'
6265
| 'controlGoalWithRetry'
6366
| 'controlPlan'
@@ -126,6 +129,11 @@ export function registerRuntimeHostSessionDomainsIpc(
126129
const result = await deps.client.queryGoal(requiredId(sessionId, 'Session'));
127130
return result.goal === null ? null : toDesktopGoal(result.goal);
128131
});
132+
ipcMain.handle('goal:cancel-event-wait', async (_event, sessionId: unknown, value: unknown) => {
133+
const input = HOST_OPERATION_SPECS['goal.event-wait.cancel'].decodeInput(value);
134+
if (input.sessionId !== requiredId(sessionId, 'Session')) throw new Error('Wait cancellation Session mismatch');
135+
return deps.client.cancelGoalEventWait(input);
136+
});
129137
ipcMain.handle('goal:clear', async (_event, sessionId: unknown) => {
130138
await deps.client.clearGoal(requiredId(sessionId, 'Session'));
131139
});
@@ -476,9 +484,10 @@ function optionalCount(value: unknown, label: string): number | null {
476484
return value;
477485
}
478486

479-
function toDesktopGoal(goal: GoalProjection): GoalState {
487+
function toDesktopGoal(goal: GoalProjection): DesktopGoal {
480488
return {
481489
id: goal.goalId,
490+
eventWait: goal.eventWait,
482491
revision: goal.revision,
483492
sessionId: goal.sessionId,
484493
condition: goal.condition,

‎apps/desktop/src/main/runtime-host-session-observer.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1952,7 +1952,8 @@ function sameGoal(
19521952
return (
19531953
next !== null &&
19541954
previous.goalId === next.goalId &&
1955-
previous.revision === next.revision
1955+
previous.revision === next.revision &&
1956+
JSON.stringify(previous.eventWait) === JSON.stringify(next.eventWait)
19561957
);
19571958
}
19581959

‎apps/desktop/src/preload/bridge-contract.d.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1587,7 +1587,8 @@ export interface MakaBridge {
15871587
};
15881588
goal: {
15891589
/** The session's current goal (null when none is set). */
1590-
get(sessionId: string): Promise<import('@maka/runtime/goal-state').GoalState | null>;
1590+
get(sessionId: string): Promise<import('../shared/goal-arm').DesktopGoal | null>;
1591+
cancelEventWait(input: import('@maka/runtime-host/protocol').GoalEventWaitCancelInput): Promise<import('@maka/runtime-host/protocol').GoalEventWaitCancelResult>;
15911592
/**
15921593
* Arm a goal for this session. It drives the session from the next turn
15931594
* on; arming alone starts nothing. Rejects when the session already has an

‎apps/desktop/src/preload/preload.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3210,12 +3210,23 @@ const makaBridge = {
32103210
},
32113211
},
32123212
goal: {
3213-
get(sessionId: string): Promise<GoalState | null> {
3213+
get(sessionId: string): Promise<import('../shared/goal-arm.js').DesktopGoal | null> {
32143214
return invokeProjectedSessionRuntimeHost('goal:get', sessionId);
32153215
},
32163216
arm(sessionId: string, goal: GoalArmRequest): Promise<GoalArmOutcome> {
32173217
return invokeProjectedSessionRuntimeHost('goal:arm', sessionId, goal);
32183218
},
3219+
async cancelEventWait(input: import('@maka/runtime-host/protocol').GoalEventWaitCancelInput): Promise<import('@maka/runtime-host/protocol').GoalEventWaitCancelResult> {
3220+
const target = { ...input };
3221+
const session = await runtimeHostSessionRef(target.sessionId);
3222+
const result = await invokeWhenReady(
3223+
'goal:cancel-event-wait',
3224+
session.scope,
3225+
session.sessionId,
3226+
{ ...target, sessionId: session.sessionId },
3227+
) as import('@maka/runtime-host/protocol').GoalEventWaitCancelResult;
3228+
return projectProtocolSessionIds(session.scope.hostId, result);
3229+
},
32193230
clear(sessionId: string): Promise<void> {
32203231
return invokeSessionRuntimeHost('goal:clear', sessionId);
32213232
},

0 commit comments

Comments
 (0)