fix(terminal): reconcile project action executions across clients (#3362)
This commit is contained in:
committed by
GitHub
parent
4e0eed717d
commit
d37ce34a2e
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user