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
46 changes: 45 additions & 1 deletion templates/vscode/guardex-active-agents/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -2399,6 +2399,19 @@ function normalizeAbsolutePath(value) {
return typeof value === 'string' && value.trim() ? path.resolve(value) : '';
}

function isManagedWorktreePath(worktreePath) {
const normalizedWorktreePath = normalizeAbsolutePath(worktreePath);
if (!normalizedWorktreePath) {
return false;
}

return MANAGED_WORKTREE_RELATIVE_ROOTS.some((relativeRoot) => {
const normalizedRelativeRoot = path.normalize(relativeRoot);
const marker = `${path.sep}${normalizedRelativeRoot}${path.sep}`;
return normalizedWorktreePath.includes(marker);
});
}

function removeDeletedWorktreeWorkspaceFolder(worktreePath) {
if (typeof vscode.workspace.updateWorkspaceFolders !== 'function') {
return false;
Expand Down Expand Up @@ -2440,6 +2453,16 @@ async function closeDeletedWorktreeRepository(worktreePath) {
return true;
}

function findDeletedManagedWorkspaceFolders() {
return (vscode.workspace.workspaceFolders || [])
.map((folder) => normalizeAbsolutePath(folder?.uri?.fsPath))
.filter((workspacePath) => (
workspacePath
&& !fs.existsSync(workspacePath)
&& isManagedWorktreePath(workspacePath)
));
}

function localizeChangeForSession(session, change) {
if (!change?.absolutePath || !isPathWithin(session.worktreePath, change.absolutePath)) {
return null;
Expand Down Expand Up @@ -3480,6 +3503,7 @@ class ActiveAgentsRefreshController {
this.refreshTimer = null;
this.sessionWatchers = new Map();
this.closedMissingWorktreeRepositories = new Set();
this.observedWorktreePaths = new Set();
}

scheduleRefresh() {
Expand All @@ -3502,6 +3526,10 @@ class ActiveAgentsRefreshController {
const repoEntries = await findRepoSessionEntries();
const liveSessionKeys = new Set();

for (const workspacePath of findDeletedManagedWorkspaceFolders()) {
await this.closeMissingWorktreeRepository(workspacePath);
}

for (const entry of repoEntries) {
for (const session of entry.sessions) {
const worktreePath = sessionWorktreePath(session);
Expand All @@ -3512,6 +3540,7 @@ class ActiveAgentsRefreshController {
}
if (normalizedWorktreePath) {
this.closedMissingWorktreeRepositories.delete(normalizedWorktreePath);
this.observedWorktreePaths.add(normalizedWorktreePath);
}

const sessionKey = resolveSessionWatcherKey(session);
Expand All @@ -3524,15 +3553,30 @@ class ActiveAgentsRefreshController {
resolveSessionGitIndexPath(session.worktreePath),
);
const disposables = bindRefreshWatcher(watcher, () => this.scheduleRefresh());
this.sessionWatchers.set(sessionKey, { watcher, disposables });
this.sessionWatchers.set(sessionKey, {
watcher,
disposables,
worktreePath: normalizedWorktreePath,
});
}
}

for (const observedWorktreePath of this.observedWorktreePaths) {
if (fs.existsSync(observedWorktreePath)) {
this.closedMissingWorktreeRepositories.delete(observedWorktreePath);
continue;
}
await this.closeMissingWorktreeRepository(observedWorktreePath);
}

for (const [sessionKey, entry] of this.sessionWatchers) {
if (liveSessionKeys.has(sessionKey)) {
continue;
}

if (entry.worktreePath && !fs.existsSync(entry.worktreePath)) {
await this.closeMissingWorktreeRepository(entry.worktreePath);
}
disposeAll(entry.disposables);
entry.watcher.dispose();
this.sessionWatchers.delete(sessionKey);
Expand Down
86 changes: 86 additions & 0 deletions test/vscode-active-agents-session-state.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1567,6 +1567,92 @@ test('active-agents extension closes deleted worktree repositories during refres
}
});

test('active-agents extension closes deleted worktrees after session records disappear', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-close-deleted-no-session-'));
const worktreePath = path.join(tempRoot, '.omx', 'agent-worktrees', 'deleted-task');
fs.mkdirSync(worktreePath, { recursive: true });
const sessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({
repoRoot: tempRoot,
branch: 'agent/codex/deleted-task',
taskName: 'deleted-task',
agentName: 'codex',
worktreePath,
pid: process.pid,
cliName: 'codex',
}));
let currentSessionFiles = [{ fsPath: sessionPath }];

const { registrations, vscode } = createMockVscode(tempRoot);
vscode.workspace.workspaceFolders = [
{ uri: { fsPath: tempRoot } },
{ uri: { fsPath: worktreePath } },
];
vscode.workspace.findFiles = async () => currentSessionFiles;
const extension = loadExtensionWithMockVscode(vscode);
const context = { subscriptions: [] };

extension.activate(context);
await flushAsyncWork();

const gitCloseCalls = () => registrations.executedCommands.filter((entry) => (
entry.command === 'git.close'
));
assert.equal(gitCloseCalls().length, 0);

fs.rmSync(worktreePath, { recursive: true, force: true });
fs.rmSync(sessionPath, { force: true });
currentSessionFiles = [];
await registrations.commands.get('gitguardex.activeAgents.refresh')();
await flushAsyncWork();

assert.equal(gitCloseCalls().length, 1);
assert.equal(gitCloseCalls()[0].args[0].fsPath, path.resolve(worktreePath));
assert.deepEqual(registrations.workspaceFolderUpdates, [
{ start: 1, deleteCount: 1, folders: [] },
]);
assert.deepEqual(
vscode.workspace.workspaceFolders.map((folder) => folder.uri.fsPath),
[tempRoot],
);

for (const subscription of context.subscriptions) {
subscription.dispose?.();
}
});

test('active-agents extension closes deleted managed workspace folders without session state', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-close-stale-folder-'));
const worktreePath = path.join(tempRoot, '.omc', 'agent-worktrees', 'deleted-task');
const { registrations, vscode } = createMockVscode(tempRoot);
vscode.workspace.workspaceFolders = [
{ uri: { fsPath: tempRoot } },
{ uri: { fsPath: worktreePath } },
];
vscode.workspace.findFiles = async () => [];
const extension = loadExtensionWithMockVscode(vscode);
const context = { subscriptions: [] };

extension.activate(context);
await flushAsyncWork();

const gitCloseCalls = registrations.executedCommands.filter((entry) => (
entry.command === 'git.close'
));
assert.equal(gitCloseCalls.length, 1);
assert.equal(gitCloseCalls[0].args[0].fsPath, path.resolve(worktreePath));
assert.deepEqual(registrations.workspaceFolderUpdates, [
{ start: 1, deleteCount: 1, folders: [] },
]);
assert.deepEqual(
vscode.workspace.workspaceFolders.map((folder) => folder.uri.fsPath),
[tempRoot],
);

for (const subscription of context.subscriptions) {
subscription.dispose?.();
}
});

test('active-agents restart command restarts the extension host for this extension only', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-restart-command-'));
const { registrations, vscode } = createMockVscode(tempRoot);
Expand Down
46 changes: 45 additions & 1 deletion vscode/guardex-active-agents/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -2399,6 +2399,19 @@ function normalizeAbsolutePath(value) {
return typeof value === 'string' && value.trim() ? path.resolve(value) : '';
}

function isManagedWorktreePath(worktreePath) {
const normalizedWorktreePath = normalizeAbsolutePath(worktreePath);
if (!normalizedWorktreePath) {
return false;
}

return MANAGED_WORKTREE_RELATIVE_ROOTS.some((relativeRoot) => {
const normalizedRelativeRoot = path.normalize(relativeRoot);
const marker = `${path.sep}${normalizedRelativeRoot}${path.sep}`;
return normalizedWorktreePath.includes(marker);
});
}

function removeDeletedWorktreeWorkspaceFolder(worktreePath) {
if (typeof vscode.workspace.updateWorkspaceFolders !== 'function') {
return false;
Expand Down Expand Up @@ -2440,6 +2453,16 @@ async function closeDeletedWorktreeRepository(worktreePath) {
return true;
}

function findDeletedManagedWorkspaceFolders() {
return (vscode.workspace.workspaceFolders || [])
.map((folder) => normalizeAbsolutePath(folder?.uri?.fsPath))
.filter((workspacePath) => (
workspacePath
&& !fs.existsSync(workspacePath)
&& isManagedWorktreePath(workspacePath)
));
}

function localizeChangeForSession(session, change) {
if (!change?.absolutePath || !isPathWithin(session.worktreePath, change.absolutePath)) {
return null;
Expand Down Expand Up @@ -3480,6 +3503,7 @@ class ActiveAgentsRefreshController {
this.refreshTimer = null;
this.sessionWatchers = new Map();
this.closedMissingWorktreeRepositories = new Set();
this.observedWorktreePaths = new Set();
}

scheduleRefresh() {
Expand All @@ -3502,6 +3526,10 @@ class ActiveAgentsRefreshController {
const repoEntries = await findRepoSessionEntries();
const liveSessionKeys = new Set();

for (const workspacePath of findDeletedManagedWorkspaceFolders()) {
await this.closeMissingWorktreeRepository(workspacePath);
}

for (const entry of repoEntries) {
for (const session of entry.sessions) {
const worktreePath = sessionWorktreePath(session);
Expand All @@ -3512,6 +3540,7 @@ class ActiveAgentsRefreshController {
}
if (normalizedWorktreePath) {
this.closedMissingWorktreeRepositories.delete(normalizedWorktreePath);
this.observedWorktreePaths.add(normalizedWorktreePath);
}

const sessionKey = resolveSessionWatcherKey(session);
Expand All @@ -3524,15 +3553,30 @@ class ActiveAgentsRefreshController {
resolveSessionGitIndexPath(session.worktreePath),
);
const disposables = bindRefreshWatcher(watcher, () => this.scheduleRefresh());
this.sessionWatchers.set(sessionKey, { watcher, disposables });
this.sessionWatchers.set(sessionKey, {
watcher,
disposables,
worktreePath: normalizedWorktreePath,
});
}
}

for (const observedWorktreePath of this.observedWorktreePaths) {
if (fs.existsSync(observedWorktreePath)) {
this.closedMissingWorktreeRepositories.delete(observedWorktreePath);
continue;
}
await this.closeMissingWorktreeRepository(observedWorktreePath);
}

for (const [sessionKey, entry] of this.sessionWatchers) {
if (liveSessionKeys.has(sessionKey)) {
continue;
}

if (entry.worktreePath && !fs.existsSync(entry.worktreePath)) {
await this.closeMissingWorktreeRepository(entry.worktreePath);
}
disposeAll(entry.disposables);
entry.watcher.dispose();
this.sessionWatchers.delete(sessionKey);
Expand Down
Loading