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
4 changes: 4 additions & 0 deletions packages/cli/src/runtime-host-session-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
createSessionCopyCleanupAuthority,
type SessionCopyCleanupAuthority,
} from '@maka/storage/session-copy-cleanup';
import type { ProcessLifetimeOwner } from '@maka/storage/process-lifetime-owner';

import type { OrchestrationMode } from '@maka/core/orchestration';
import type { PermissionMode } from '@maka/core/permission';
Expand Down Expand Up @@ -128,6 +129,8 @@ export interface RuntimeHostMakaSessionDriverInput {
executionLocation?: { readonly kind: 'client_path' } | { readonly kind: 'host' };
/** Client-local durable lease parent for temporary TUI conversation copies. */
sessionCopyCleanupRoot?: string;
/** Process-incarnation owner for temporary TUI conversation copies. */
sessionCopyCleanupOwner?: ProcessLifetimeOwner;
}

type RuntimeHostSessionDriverConnection = Pick<
Expand Down Expand Up @@ -229,6 +232,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver {
resumeSessionCopy: (creation) => this.#resumeSessionCopy(creation),
processId: `tui:${process.pid}`,
isOwnerProcessActive: isTuiProcessActive,
processLifetimeOwner: input.sessionCopyCleanupOwner,
})
: undefined;
this.moveSession =
Expand Down
26 changes: 22 additions & 4 deletions packages/cli/src/runtime-host-tui-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ import { findProjectByIdentity } from '@maka/core/project';
import type { ConnectionCatalogEntry, ConnectionCatalogSnapshot } from '@maka/core/runtime-policy';
import { SessionActivityRegistry } from '@maka/runtime/goal-turn-lifecycle';
import { type InvocableSkillEntry } from '@maka/runtime/skill-invocation';
import {
acquireProcessLifetimeOwner,
type ProcessLifetimeOwner,
} from '@maka/storage/process-lifetime-owner';
import {
readRuntimeHostAgentGraphEpochs,
readRuntimeHostInvocableSkills,
Expand Down Expand Up @@ -112,6 +116,7 @@ export async function createRuntimeHostTuiContext(
});
const connection = connected.connection;
let mcp: TuiMcpController | undefined;
let sessionCopyCleanupOwner: ProcessLifetimeOwner | undefined;
try {
const catalog = connected.catalog;
const workspace = await resolveRuntimeHostTuiWorkspace(connection, connected.profile, input);
Expand All @@ -126,13 +131,19 @@ export async function createRuntimeHostTuiContext(
executionBoundaryDisplayMode(
createGenesisExecutionBoundary(await readHostChatDefaultPermissionMode(connection)),
) ?? 'ask';
const sessionCopyCleanupRoot = join(input.clientDataRoot, 'tui-session-copies');
const owner = await acquireProcessLifetimeOwner(
join(sessionCopyCleanupRoot, connection.rootId),
);
sessionCopyCleanupOwner = owner;
const driverInput: RuntimeHostMakaSessionDriverInput = {
connection,
cwd: input.cwd,
llmConnectionSlug: target.connection.slug,
model: target.model,
prospectivePermissionMode,
sessionCopyCleanupRoot: join(input.clientDataRoot, 'tui-session-copies'),
sessionCopyCleanupRoot,
sessionCopyCleanupOwner: owner,
executionLocation:
connected.profile.kind === 'local' ? { kind: 'client_path' } : { kind: 'host' },
...(workspace ? { workspace } : {}),
Expand Down Expand Up @@ -174,17 +185,19 @@ export async function createRuntimeHostTuiContext(
onboarding: createRuntimeHostOnboardingSurface(connection),
...(mcp ? { mcp } : {}),
profile: connected.profile,
close: () => closeRuntimeHostTuiContext(mcp, connected.close),
close: () => closeRuntimeHostTuiContext(mcp, owner, connected.close),
};
} catch (error) {
await mcp?.close().catch(() => undefined);
await connected.close().catch(() => undefined);
await closeRuntimeHostTuiContext(mcp, sessionCopyCleanupOwner, connected.close).catch(
() => undefined,
);
throw error;
}
}

async function closeRuntimeHostTuiContext(
mcp: TuiMcpController | undefined,
sessionCopyCleanupOwner: ProcessLifetimeOwner | undefined,
closeConnection: () => Promise<void>,
): Promise<void> {
const errors: unknown[] = [];
Expand All @@ -198,6 +211,11 @@ async function closeRuntimeHostTuiContext(
} catch (error) {
errors.push(error);
}
try {
await sessionCopyCleanupOwner?.close();
} catch (error) {
errors.push(error);
}
if (errors.length === 1) throw errors[0];
if (errors.length > 1)
throw new AggregateError(errors, 'Unable to close Runtime Host TUI context');
Expand Down
1 change: 1 addition & 0 deletions packages/storage/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"./pet-pack-store": "./dist/pet-pack-store.js",
"./plan-authority": "./dist/plan-authority.js",
"./process-lifetime-file-update-lock": "./dist/process-lifetime-file-update-lock.js",
"./process-lifetime-owner": "./dist/process-lifetime-owner.js",
"./project-catalog": "./dist/project-catalog.js",
"./project-catalog-authority": "./dist/project-catalog-authority.js",
"./root-authority": "./dist/root-authority.js",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* 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 { acquireProcessLifetimeOwner } from '../../process-lifetime-owner.js';

const root = process.argv[2];
if (!root) throw new Error('Missing process lifetime owner root');

const owner = await acquireProcessLifetimeOwner(root);
process.send?.({ reference: owner.reference });
await new Promise<never>(() => setInterval(() => undefined, 1_000));
132 changes: 132 additions & 0 deletions packages/storage/src/__tests__/process-lifetime-owner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/*
* 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 { strict as assert } from 'node:assert';
import { fork } from 'node:child_process';
import { mkdtemp, readdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test, type TestContext } from 'node:test';
import { acquireProcessLifetimeOwner } from '../process-lifetime-owner.js';

test('claims an owner only after its process exits', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'maka-process-lifetime-owner-'));
const child = fork(
new URL('./fixtures/process-lifetime-owner-holder.js', import.meta.url),
[root],
{ stdio: ['ignore', 'ignore', 'inherit', 'ipc'] },
);
const observer = await acquireProcessLifetimeOwner(root);
const competingObserver = await acquireProcessLifetimeOwner(root);
t.after(async () => {
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
await observer.close().catch(() => undefined);
await competingObserver.close().catch(() => undefined);
await rm(root, { recursive: true, force: true });
});

const reference = await waitForReference(t, child);
assert.equal(await observer.tryClaimReleased(reference), undefined);

child.kill('SIGKILL');
await new Promise<void>((resolve) => child.once('exit', () => resolve()));

const claims = await Promise.all([
observer.tryClaimReleased(reference),
competingObserver.tryClaimReleased(reference),
]);
const acquired = claims.filter((claim) => claim !== undefined);
assert.equal(acquired.length, 1);
await acquired[0]?.retire();
});

test('releases its owner reference on graceful close', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'maka-process-lifetime-owner-'));
const owner = await acquireProcessLifetimeOwner(root);
const observer = await acquireProcessLifetimeOwner(root);
t.after(async () => {
await owner.close().catch(() => undefined);
await observer.close().catch(() => undefined);
await rm(root, { recursive: true, force: true });
});

assert.equal(await observer.tryClaimReleased(owner.reference), undefined);
await owner.close();
const claim = await observer.tryClaimReleased(owner.reference);
assert.ok(claim);
await claim.retire();
});

test('retires an unreferenced owner file after an unclean process exit', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'maka-process-lifetime-owner-'));
const child = fork(
new URL('./fixtures/process-lifetime-owner-holder.js', import.meta.url),
[root],
{ stdio: ['ignore', 'ignore', 'inherit', 'ipc'] },
);
t.after(async () => {
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
await rm(root, { recursive: true, force: true });
});

const deadReference = await waitForReference(t, child);
child.kill('SIGKILL');
await new Promise<void>((resolve) => child.once('exit', () => resolve()));

const successor = await acquireProcessLifetimeOwner(root);
t.after(() => successor.close());
await successor.retireUnreferencedReleasedOwners(new Set());
const files = await readdir(join(root, 'owners'));
assert.deepEqual(files, [`${successor.reference.slice('lock-v1:'.length)}.lease`]);
assert.equal(files.includes(`${deadReference.slice('lock-v1:'.length)}.lease`), false);
});

test('rejects owner references outside its versioned namespace', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'maka-process-lifetime-owner-'));
const owner = await acquireProcessLifetimeOwner(root);
t.after(async () => {
await owner.close().catch(() => undefined);
await rm(root, { recursive: true, force: true });
});

await assert.rejects(owner.tryClaimReleased('../other'), /Invalid process lifetime owner/);
});

async function waitForReference(t: TestContext, child: ReturnType<typeof fork>): Promise<string> {
return new Promise<string>((resolve, reject) => {
const onMessage = (message: unknown) => {
if (
typeof message === 'object' &&
message !== null &&
'reference' in message &&
typeof message.reference === 'string'
) {
resolve(message.reference);
} else {
reject(new Error(`Unexpected child message: ${String(message)}`));
}
};
child.once('message', onMessage);
child.once('error', reject);
child.once('exit', (code, signal) => {
reject(new Error(`Owner exited before acquisition (${String(code)}, ${signal})`));
});
t.after(() => child.off('message', onMessage));
});
}
Loading