fix(terminal): reconcile project action executions across clients (#3362)

This commit is contained in:
Bohdan Triapitsyn
2026-09-05 12:05:39 +03:00
committed by GitHub
parent 4e0eed717d
commit d37ce34a2e
16 changed files with 640 additions and 216 deletions
+12
View File
@@ -124,6 +124,18 @@ Invariants to preserve when editing:
projection while both are referentially unchanged, and the storage adapter skips a write
for an unchanged projection, so streaming output performs no persistence work.
- Consumers that react to output must subscribe to `buffers`, not `sessions`.
- Action tab IDs remain stable while each command execution receives a fresh terminal ID.
Starting or adopting a different execution resets its buffer sequence and preview together;
reconnecting to the same execution and observing its exit preserve scrollback.
- Reconciliation selects one record per action before updating tabs. A running execution wins
over retained exited records independently of listing order. An in-progress stop remains
stopping until the same execution exits or explicit termination failure restores running.
- `terminalSessionObserver` shares one five-second refresh loop per terminal adapter and
demanded directory. Only visible, online headers/panels demand refreshes; focus and online
recovery refresh immediately. Failed reads preserve state, the last consumer stops the loop,
and responses from a replaced runtime cannot publish into the new runtime.
- Passive action adoption may restore output but has no launch-time authority to open browser
tabs. Preview navigation belongs to the initiating host directory even for a parent action.
- Server session listings capture the directory's per-action mutation revisions when the
request starts. Coalesced callers share that first snapshot. A response cannot replace or
remove an action execution mutated after its request began, while a fresh successful empty
@@ -331,7 +331,7 @@ describe('terminal state reconciliation', () => {
const tab = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0]!;
expect(tab.lifecycle).toBe('starting');
expect(tab.purpose).toEqual({ type: 'project-action', actionId: 'build', executionId });
expect(tab.terminalSessionId).toBe('srv-old');
expect(tab.terminalSessionId).toBeNull();
});
test('an old listed action session does not overwrite a newer running session after setTabSessionId', () => {
@@ -556,3 +556,49 @@ describe('directoryMayHaveActiveProjectAction', () => {
expect(directoryMayHaveActiveProjectAction(undefined)).toBe(false);
});
});
test('a retained exited run cannot replace the live run of the same action', () => {
const live: TerminalServerSession = { sessionId: 'a', cwd: '/repo', status: 'running', createdAt: 1, mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'live' } };
const old: TerminalServerSession = { sessionId: 'b', cwd: '/repo', status: 'exited', createdAt: 2, mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'old' } };
for (const sessions of [[live, old], [old, live]]) {
useTerminalStore.getState().clearAll();
useTerminalStore.getState().reconcileServerSessions('/repo', sessions);
const tabs = useTerminalStore.getState().getDirectoryState('/repo')?.tabs;
expect(tabs?.find((tab) => tab.purpose.type === 'project-action')?.terminalSessionId).toBe('a');
}
});
test('a fresh running listing does not cancel an in-progress stop of the same execution', () => {
useTerminalStore.getState().clearAll();
const session: TerminalServerSession = { sessionId: 'run', cwd: '/repo', status: 'running', createdAt: 1, purpose: { type: 'project-action', actionId: 'build', executionId: 'run' } };
const store = useTerminalStore.getState();
store.reconcileServerSessions('/repo', [session]);
store.setTabLifecycle('/repo', 'run', 'stopping');
store.reconcileServerSessions('/repo', [session], { startedActionMutationRevisions: store.captureStartedActionMutationRevisions('/repo') });
expect(store.getActiveTab('/repo')?.lifecycle).toBe('stopping');
});
test('adopting a replacement resets its buffer while reconnecting to the same run preserves it', () => {
useTerminalStore.getState().clearAll();
const store = useTerminalStore.getState();
const session: TerminalServerSession = { sessionId: 'run', cwd: '/repo', status: 'running', createdAt: 1, purpose: { type: 'project-action', actionId: 'build', executionId: 'run' } };
store.reconcileServerSessions('/repo', [session]);
store.replaceBuffer('/repo', 'run', 'old output', 5);
store.reconcileServerSessions('/repo', [session]);
expect(store.getBuffer('/repo', 'run').lastSequence).toBe(5);
store.reconcileServerSessions('/repo', [{ ...session, sessionId: 'replacement', purpose: { type: 'project-action', actionId: 'build', executionId: 'replacement' } }]);
store.replaceBuffer('/repo', 'run', 'new output', 1);
expect(store.getBuffer('/repo', 'run').chunks.map(chunk => chunk.data).join('')).toBe('new output');
});
test('a listing started before closing an action cannot resurrect its tab', () => {
useTerminalStore.getState().clearAll();
const store = useTerminalStore.getState();
const session: TerminalServerSession = { sessionId: 'run', cwd: '/repo', status: 'exited', createdAt: 1, purpose: { type: 'project-action', actionId: 'build', executionId: 'run' } };
store.reconcileServerSessions('/repo', [session]);
const startedActionMutationRevisions = store.captureStartedActionMutationRevisions('/repo');
store.closeTab('/repo', 'run');
store.reconcileServerSessions('/repo', [session], { startedActionMutationRevisions });
expect(store.getDirectoryState('/repo')?.tabs.some(tab => tab.purpose.type === 'project-action')).toBe(false);
});
+68 -9
View File
@@ -195,6 +195,27 @@ const toActionPurposeFromSession = (
executionId: status === 'running' ? purpose.executionId : null,
});
const authoritativeTerminalSessions = (sessions: TerminalServerSession[]): TerminalServerSession[] => {
const actions = new Map<string, TerminalServerSession>();
const terminals: TerminalServerSession[] = [];
for (const session of sessions) {
if (session.purpose?.type !== 'project-action') {
terminals.push(session);
continue;
}
const previous = actions.get(session.purpose.actionId);
if (!previous
|| (session.status === 'running' && previous.status !== 'running')
|| (session.status === previous.status && (
(session.createdAt ?? 0) > (previous.createdAt ?? 0)
|| (session.createdAt === previous.createdAt && session.sessionId > previous.sessionId)
))) actions.set(session.purpose.actionId, session);
}
return [...terminals, ...actions.values()];
};
const resetActionPreview = { previewUrl: null, previewAutoOpened: false, previewUrlLocked: false };
const toAdoptedActionLabel = (actionId: string): string => {
const trimmed = actionId.trim();
return trimmed || 'Action';
@@ -452,6 +473,7 @@ export const useTerminalStore = create<TerminalStore>()(
return state;
}
const tabs = [...(existing?.tabs ?? [])];
let buffers = state.buffers;
let tabsChanged = false;
let activationCandidate: { tabId: string; createdAt: number; index: number } | null = null;
const listedActionIds = new Set<string>();
@@ -465,7 +487,7 @@ export const useTerminalStore = create<TerminalStore>()(
: null;
if (placeholder && serverSessions.length > 0) tabs.length = 0;
for (const session of serverSessions) {
for (const session of authoritativeTerminalSessions(serverSessions)) {
let matchIndex = tabs.findIndex((tab) => tab.terminalSessionId === session.sessionId || tab.id === session.sessionId);
const sessionPurpose = session.purpose;
const staleActionAuthority = sessionPurpose?.type === 'project-action'
@@ -492,10 +514,21 @@ export const useTerminalStore = create<TerminalStore>()(
if (matchIndex >= 0) {
const current = tabs[matchIndex]!;
const nextLifecycle = current.purpose.type === 'project-action' || sessionPurpose?.type === 'project-action'
let nextLifecycle = current.purpose.type === 'project-action' || sessionPurpose?.type === 'project-action'
? toActionLifecycle(session.status)
: session.status;
if (current.lifecycle === 'stopping' && session.status === 'running'
&& current.purpose.type === 'project-action' && nextPurpose.type === 'project-action'
&& current.purpose.executionId === nextPurpose.executionId) nextLifecycle = 'stopping';
const nextCreatedAt = session.createdAt ?? current.createdAt;
const executionChanged = sessionPurpose?.type === 'project-action'
&& (current.terminalSessionId !== session.sessionId
|| (nextPurpose.type === 'project-action' && nextPurpose.executionId !== null
&& (current.purpose.type !== 'project-action' || current.purpose.executionId !== nextPurpose.executionId)));
if (executionChanged) {
const keyToDrop = bufferKey(key, current.id);
buffers = dropBufferKeys(buffers, (entry) => entry === keyToDrop) ?? buffers;
}
const activatesRunningAction = sessionPurpose?.type === 'project-action'
&& session.status === 'running'
&& (current.terminalSessionId !== session.sessionId || current.lifecycle !== 'running');
@@ -517,6 +550,7 @@ export const useTerminalStore = create<TerminalStore>()(
isConnecting: false,
createdAt: nextCreatedAt,
};
if (executionChanged) Object.assign(tabs[matchIndex], resetActionPreview);
tabsChanged = true;
}
if (activatesRunningAction) {
@@ -607,7 +641,7 @@ export const useTerminalStore = create<TerminalStore>()(
const newSessions = new Map(state.sessions);
newSessions.set(key, { tabs: reconciledTabs, activeTabId });
return { sessions: newSessions };
return { sessions: newSessions, buffers };
});
},
@@ -715,6 +749,15 @@ export const useTerminalStore = create<TerminalStore>()(
return state;
}
const closedPurpose = existing.tabs[idx].purpose;
let actionMutationRevisions = state.actionMutationRevisions;
let nextActionMutationRevision = state.nextActionMutationRevision;
if (closedPurpose.type === 'project-action') {
actionMutationRevisions = new Map(actionMutationRevisions);
updateActionMutationRevision(actionMutationRevisions, key, closedPurpose.actionId, nextActionMutationRevision);
nextActionMutationRevision += 1;
}
const mutationState = { actionMutationRevisions, nextActionMutationRevision };
const nextTabs = existing.tabs.filter((t) => t.id !== tabId);
const closedBufferKey = bufferKey(key, tabId);
const nextBuffers = state.buffers.has(closedBufferKey)
@@ -732,7 +775,7 @@ export const useTerminalStore = create<TerminalStore>()(
if (nextBuffers) {
nextState.buffers = nextBuffers;
}
return nextState;
return { ...nextState, ...mutationState };
}
let nextActive = existing.activeTabId;
@@ -753,7 +796,7 @@ export const useTerminalStore = create<TerminalStore>()(
if (nextBuffers) {
nextState.buffers = nextBuffers;
}
return nextState;
return { ...nextState, ...mutationState };
});
},
@@ -769,7 +812,14 @@ export const useTerminalStore = create<TerminalStore>()(
return state;
}
const nextTabs = [...existing.tabs];
const executionChanged = purpose.type === 'project-action' && purpose.executionId !== null
&& (current.purpose.type !== 'project-action' || current.purpose.executionId !== purpose.executionId);
nextTabs[idx] = { ...current, purpose };
if (executionChanged) Object.assign(nextTabs[idx], resetActionPreview);
const keyToDrop = bufferKey(key, tabId);
const buffers = executionChanged
? dropBufferKeys(state.buffers, (entry) => entry === keyToDrop) ?? state.buffers
: state.buffers;
const sessions = new Map(state.sessions);
sessions.set(key, { ...existing, tabs: nextTabs });
if (purpose.type !== 'project-action') {
@@ -777,7 +827,7 @@ export const useTerminalStore = create<TerminalStore>()(
}
const actionMutationRevisions = new Map(state.actionMutationRevisions);
updateActionMutationRevision(actionMutationRevisions, key, purpose.actionId, state.nextActionMutationRevision);
return { sessions, actionMutationRevisions, nextActionMutationRevision: state.nextActionMutationRevision + 1 };
return { sessions, buffers, actionMutationRevisions, nextActionMutationRevision: state.nextActionMutationRevision + 1 };
});
},
@@ -796,13 +846,17 @@ export const useTerminalStore = create<TerminalStore>()(
...nextTabs[idx]!,
purpose: { type: 'project-action', actionId, executionId },
lifecycle: 'starting',
terminalSessionId: null,
...resetActionPreview,
isConnecting: false,
};
const sessions = new Map(state.sessions);
sessions.set(key, { ...current, tabs: nextTabs });
const actionMutationRevisions = new Map(state.actionMutationRevisions);
updateActionMutationRevision(actionMutationRevisions, key, actionId, state.nextActionMutationRevision);
return { sessions, actionMutationRevisions, nextActionMutationRevision: state.nextActionMutationRevision + 1 };
const keyToDrop = bufferKey(key, tabId);
const buffers = dropBufferKeys(state.buffers, (entry) => entry === keyToDrop) ?? state.buffers;
return { sessions, buffers, actionMutationRevisions, nextActionMutationRevision: state.nextActionMutationRevision + 1 };
});
return executionId;
},
@@ -884,10 +938,15 @@ export const useTerminalStore = create<TerminalStore>()(
return state;
}
const current = existing.tabs[idx]!;
if (current.lifecycle === lifecycle && !current.isConnecting) return state;
const nextTabs = [...existing.tabs];
nextTabs[idx] = { ...nextTabs[idx], lifecycle, isConnecting: false };
nextTabs[idx] = { ...current, lifecycle, isConnecting: false };
newSessions.set(key, { ...existing, tabs: nextTabs });
return { sessions: newSessions };
if (current.purpose.type !== 'project-action' || current.lifecycle === lifecycle) return { sessions: newSessions };
const actionMutationRevisions = new Map(state.actionMutationRevisions);
updateActionMutationRevision(actionMutationRevisions, key, current.purpose.actionId, state.nextActionMutationRevision);
return { sessions: newSessions, actionMutationRevisions, nextActionMutationRevision: state.nextActionMutationRevision + 1 };
});
},