Skip to content
Open
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
53 changes: 51 additions & 2 deletions apps/cli/src/agent/runtime/startupSideEffects.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { describe, expect, it, vi } from 'vitest';

import { primeAgentStateForUi, reportSessionToDaemonIfRunning } from '@/agent/runtime/startupSideEffects';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import {
persistTerminalAttachmentInfoIfNeeded,
primeAgentStateForUi,
reportSessionToDaemonIfRunning,
} from '@/agent/runtime/startupSideEffects';
import { configuration } from '@/configuration';
import { readTerminalAttachmentState } from '@/terminal/attachment/terminalAttachmentInfo';
import { executeTerminalHostDisposition } from '@/terminal/attachment/terminalHostDisposition';
import type { Metadata } from '@/api/types';

const metadataStub = {} as Metadata;
Expand Down Expand Up @@ -287,3 +297,42 @@ describe('startup side effects: daemon session reporting retry', () => {
}
});
});

describe('startup side effects: terminal attachment persistence', () => {
it('persists a bound v2 record for a tmux spawn that the stop-path disposition retires instead of parking as legacy', async () => {
const happyHomeDir = await mkdtemp(join(tmpdir(), 'happier-attachment-'));
const originalHappyHomeDir = configuration.happyHomeDir;
// Narrow test-harness override: happyHomeDir is resolved from env once at process start.
(configuration as { happyHomeDir: string }).happyHomeDir = happyHomeDir;
try {
const sessionId = 'sess-tmux-spawn-v2';
await persistTerminalAttachmentInfoIfNeeded({
sessionId,
terminal: {
mode: 'tmux',
requested: 'tmux',
tmux: { target: 'happier:happy-window-1', tmpDir: '/tmp/happier-tmux' },
} as NonNullable<Metadata['terminal']>,
});

const state = await readTerminalAttachmentState({ happyHomeDir, sessionId });
if (state.status !== 'present' || state.info.version !== 2) {
throw new Error(`Expected a bound version-2 attachment record, got ${JSON.stringify(state)}`);
}
expect(state.info.handle.kind).toBe('tmux');
expect(state.info.handle.attachmentId).toBe(state.info.attachmentId);

const disposition = await executeTerminalHostDisposition({
happyHomeDir,
sessionId,
expectedAttachmentId: state.info.attachmentId,
intent: { kind: 'retire_confirmed_dead_attachment', reason: 'positive_dead_recovery' },
});
expect(disposition).toEqual({ status: 'retired', attachmentId: state.info.attachmentId });
await expect(readTerminalAttachmentState({ happyHomeDir, sessionId })).resolves.toEqual({ status: 'absent' });
} finally {
(configuration as { happyHomeDir: string }).happyHomeDir = originalHappyHomeDir;
await rm(happyHomeDir, { recursive: true, force: true });
}
});
});
20 changes: 2 additions & 18 deletions apps/cli/src/agent/runtime/startupSideEffects.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { ApiSessionClient } from '@/api/session/sessionClient';
import type { Metadata } from '@/api/types';
import { configuration } from '@/configuration';
import { notifyDaemonSessionStarted } from '@/daemon/controlClient';
import { writeTerminalAttachmentInfo } from '@/terminal/attachment/terminalAttachmentInfo';
import { buildTerminalFallbackMessage } from '@/terminal/attachment/terminalFallbackMessage';
import { logger } from '@/ui/logger';
import { updateAgentStateBestEffort } from '@/api/session/sessionWritesBestEffort';

export { persistTerminalAttachmentInfoIfNeeded } from '@/agent/runtime/terminal/persistTerminalAttachmentInfo';

type DaemonReportDeps = {
notifyDaemonSessionStartedFn?: typeof notifyDaemonSessionStarted;
sleepFn?: (ms: number) => Promise<void>;
Expand Down Expand Up @@ -67,22 +67,6 @@ export function primeAgentStateForUi(session: ApiSessionClient, logPrefix: strin
);
}

export async function persistTerminalAttachmentInfoIfNeeded(opts: {
sessionId: string;
terminal: Metadata['terminal'] | undefined;
}): Promise<void> {
if (!opts.terminal) return;
try {
await writeTerminalAttachmentInfo({
happyHomeDir: configuration.happyHomeDir,
sessionId: opts.sessionId,
terminal: opts.terminal,
});
} catch (error) {
logger.debug('[START] Failed to persist terminal attachment info', error);
}
}

export function sendTerminalFallbackMessageIfNeeded(opts: {
session: ApiSessionClient;
terminal: Metadata['terminal'] | undefined;
Expand Down
28 changes: 28 additions & 0 deletions apps/cli/src/agent/runtime/terminal/attachmentMetadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,34 @@ describe('buildTerminalAttachmentMetadataFromHostHandle', () => {
expect(legacyHandle?.socketDir).toBeUndefined();
});

it('reconstructs a tmux handle with socketDir from persisted tmpDir', () => {
const handle = buildTerminalHostHandleFromAttachmentMetadata({
mode: 'tmux',
tmux: {
target: 'happy:unified-window',
tmpDir: '/tmp/happier-tmux-root',
},
});
expect(handle).toMatchObject({
kind: 'tmux',
sessionName: 'happy',
paneId: 'unified-window',
socketDir: '/tmp/happier-tmux-root',
});
});

it('does not reconstruct a windows_console handle: persisted metadata carries no host identity', () => {
// The canonical PTY handle is keyed by the spawn-time session name (also its paneId),
// which windows_console terminal metadata does not persist. A fabricated identity would
// probe a nonexistent host, so reconstruction must refuse and leave the mode on the
// fail-closed legacy path.
expect(buildTerminalHostHandleFromAttachmentMetadata({
mode: 'windows_console',
requested: 'console',
windows: { host: 'console' },
})).toBeNull();
});

it('builds non-focusable Windows console metadata from a PTY host handle', () => {
const handle: TerminalHostHandle = {
kind: 'windows_console',
Expand Down
6 changes: 6 additions & 0 deletions apps/cli/src/agent/runtime/terminal/attachmentMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,12 @@ export function buildTerminalHostHandleFromAttachmentMetadata(
const sessionName = separatorIndex >= 0 ? target.slice(0, separatorIndex).trim() : target;
const paneId = separatorIndex >= 0 ? target.slice(separatorIndex + 1).trim() : '';
if (!sessionName) return null;
const socketDir = typeof terminal.tmux?.tmpDir === 'string' ? terminal.tmux.tmpDir.trim() : '';
return {
kind: 'tmux',
sessionName,
...(paneId ? { paneId } : {}),
...(socketDir ? { socketDir } : {}),
attachMetadata: {
attachStrategy: 'terminal_host',
topology: 'shared',
Expand All @@ -93,5 +95,9 @@ export function buildTerminalHostHandleFromAttachmentMetadata(
};
}

// windows_console is deliberately not reconstructable: the canonical PTY handle is keyed
// by the spawn-time session name (also its paneId), which this metadata does not persist.
// A fabricated identity would probe a nonexistent host, so that mode stays on the
// fail-closed legacy path until the metadata carries real host identity.
return null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from 'vitest';

import type { Metadata } from '@/api/types';
import type { writeTerminalAttachmentInfo } from '@/terminal/attachment/terminalAttachmentInfo';

import { persistTerminalAttachmentInfoIfNeeded } from './persistTerminalAttachmentInfo';

type WriteInput = Parameters<typeof writeTerminalAttachmentInfo>[0];

const tmuxTerminal = {
mode: 'tmux',
requested: 'tmux',
tmux: { target: 'happier:happy-window-1', tmpDir: '/tmp/happier-tmux' },
} as NonNullable<Metadata['terminal']>;

describe('persistTerminalAttachmentInfoIfNeeded', () => {
it('binds tmux metadata as a version-2 write with a derived handle and attachment id', async () => {
const writes: WriteInput[] = [];
const writeAttachmentInfo = vi.fn(async (input: WriteInput) => {
writes.push(input);
});

await persistTerminalAttachmentInfoIfNeeded({
sessionId: 'sess-bound-write',
terminal: tmuxTerminal,
writeAttachmentInfo,
});

expect(writes).toHaveLength(1);
expect(writes[0]?.attachmentId).toEqual(expect.any(String));
expect(writes[0]?.handle).toMatchObject({ kind: 'tmux', sessionName: 'happier' });
});

it('falls back to the unbound record when the bound write fails, so a record always exists', async () => {
const writes: WriteInput[] = [];
const writeAttachmentInfo = vi.fn(async (input: WriteInput) => {
writes.push(input);
if (input.attachmentId) {
throw new Error('Terminal attachment root does not match its bound host handle');
}
});

await persistTerminalAttachmentInfoIfNeeded({
sessionId: 'sess-fallback-write',
terminal: tmuxTerminal,
writeAttachmentInfo,
});

expect(writes).toHaveLength(2);
expect(writes[1]?.attachmentId).toBeUndefined();
expect(writes[1]?.handle).toBeUndefined();
expect(writes[1]?.terminal).toEqual(tmuxTerminal);
});

it('writes the unbound record directly for modes without reconstructable host identity', async () => {
const writes: WriteInput[] = [];
const writeAttachmentInfo = vi.fn(async (input: WriteInput) => {
writes.push(input);
});

await persistTerminalAttachmentInfoIfNeeded({
sessionId: 'sess-windows-console',
terminal: {
mode: 'windows_console',
requested: 'console',
windows: { host: 'console' },
} as NonNullable<Metadata['terminal']>,
writeAttachmentInfo,
});

expect(writes).toHaveLength(1);
expect(writes[0]?.attachmentId).toBeUndefined();
expect(writes[0]?.handle).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { Metadata } from '@/api/types';
import { configuration } from '@/configuration';
import {
createTerminalAttachmentId,
writeTerminalAttachmentInfo,
} from '@/terminal/attachment/terminalAttachmentInfo';
import { logger } from '@/ui/logger';

import { buildTerminalHostHandleFromAttachmentMetadata } from './attachmentMetadata';

/**
* Persist the session's terminal attachment record from spawn-path terminal metadata.
*
* Modes whose metadata carries full host identity (tmux, zellij) are bound as version-2
* records with an immutable attachment id. Other modes persist the version-1 record.
* A failed bound write falls back to the version-1 write so a readable record always
* exists whenever the filesystem write itself succeeds; the stop path fails closed on
* missing evidence, so silently persisting nothing would strand the session.
*/
export async function persistTerminalAttachmentInfoIfNeeded(opts: {
sessionId: string;
terminal: Metadata['terminal'] | undefined;
logPrefix?: string;
writeAttachmentInfo?: typeof writeTerminalAttachmentInfo;
}): Promise<void> {
if (!opts.terminal) return;
const logPrefix = opts.logPrefix ?? '[START]';
const writeAttachmentInfo = opts.writeAttachmentInfo ?? writeTerminalAttachmentInfo;
try {
const handle = buildTerminalHostHandleFromAttachmentMetadata(opts.terminal);
const attachmentId = handle ? createTerminalAttachmentId() : undefined;
if (handle && attachmentId) {
try {
await writeAttachmentInfo({
happyHomeDir: configuration.happyHomeDir,
sessionId: opts.sessionId,
attachmentId,
handle,
terminal: opts.terminal,
});
return;
} catch (error) {
logger.warn(`${logPrefix} Bound terminal attachment write failed; falling back to unbound record`, error);
}
}
await writeAttachmentInfo({
happyHomeDir: configuration.happyHomeDir,
sessionId: opts.sessionId,
terminal: opts.terminal,
});
} catch (error) {
logger.debug(`${logPrefix} Failed to persist terminal attachment info`, error);
}
}
Loading
Loading