diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 4d05f64cd3e665..3cf0ec824c6c95 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -1366,6 +1366,12 @@ export interface ISessionGitState { readonly hasGitHubRemote?: boolean; /** Current branch name. */ readonly branchName?: string; + /** + * Whether `HEAD` is detached, which is why {@link branchName} is absent. + * Distinguishes a legitimately branch-less checkout from git state left + * behind by a probe that failed before it could resolve the branch. + */ + readonly isDetachedHead?: boolean; /** Base branch the work targets (e.g. `main`). */ readonly baseBranchName?: string; /** Upstream tracking branch (e.g. `origin/feature`). */ @@ -1581,6 +1587,7 @@ export function readSessionGitState(meta: SessionMeta | undefined): ISessionGitS const result: { hasGitHubRemote?: boolean; branchName?: string; + isDetachedHead?: boolean; baseBranchName?: string; upstreamBranchName?: string; incomingChanges?: number; @@ -1593,6 +1600,7 @@ export function readSessionGitState(meta: SessionMeta | undefined): ISessionGitS } = {}; if (typeof raw['hasGitHubRemote'] === 'boolean') { result.hasGitHubRemote = raw['hasGitHubRemote']; } if (typeof raw['branchName'] === 'string') { result.branchName = raw['branchName']; } + if (typeof raw['isDetachedHead'] === 'boolean') { result.isDetachedHead = raw['isDetachedHead']; } if (typeof raw['baseBranchName'] === 'string') { result.baseBranchName = raw['baseBranchName']; } if (typeof raw['upstreamBranchName'] === 'string') { result.upstreamBranchName = raw['upstreamBranchName']; } if (typeof raw['incomingChanges'] === 'number') { result.incomingChanges = raw['incomingChanges']; } @@ -1605,6 +1613,23 @@ export function readSessionGitState(meta: SessionMeta | undefined): ISessionGitS return result; } +/** + * Whether a session's git state should be recomputed because it does not + * describe a usable checkout. + * + * A state that was never computed obviously qualifies. So does one that is + * missing its branch without a detached `HEAD` to explain it: `git status` is + * the only probe that reports the branch, so such a state is the residue of a + * probe that failed, and consumers that key off the branch (Agent Merge binds + * its pull request that way) stay stranded until it is recomputed. A detached + * `HEAD` is a legitimate branch-less checkout and must not be mistaken for it, + * or every caller would refresh in a loop against a repository that will never + * report a branch. + */ +export function needsSessionGitStateRefresh(gitState: ISessionGitState | undefined): boolean { + return gitState === undefined || (gitState.branchName === undefined && !gitState.isDetachedHead); +} + /** * Returns a new {@link SessionMeta} with the git-state payload set to * `gitState`, or with the git slot removed if `gitState` is `undefined`. diff --git a/src/vs/platform/agentHost/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts index 31b502b3995a58..73bc92ef3c7aca 100644 --- a/src/vs/platform/agentHost/node/agentHostGitService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitService.ts @@ -947,6 +947,18 @@ export class AgentHostGitService implements IAgentHostGitService { configuredBaseBranch ? undefined : this._runGit(repositoryRoot, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']), ]); + // `git status` is the only probe that reports the branch, so a state + // computed without it is not merely incomplete — it is misleading. + // Callers persist the result wholesale, so returning a branch-less + // object here would overwrite the last known good branch and strand + // every consumer that keys off it (Agent Merge binds its pull request + // by branch). Report the failure instead and let callers keep what + // they already had. + if (statusOutput === undefined) { + this._logService.warn(`[agentHostGitService] Not reporting session git state because git status failed: ${repositoryRoot.fsPath}`); + return undefined; + } + const status = parseGitStatusV2(statusOutput); const hasGitHubRemote = parseHasGitHubRemote(remotesOutput); const baseBranchName = configuredBaseBranch ?? parseDefaultBranchRef(defaultBranchRef); @@ -982,6 +994,7 @@ export class AgentHostGitService implements IAgentHostGitService { const result: ISessionGitState = { hasGitHubRemote, branchName: status.branchName, + isDetachedHead: status.isDetachedHead, baseBranchName, upstreamBranchName: status.upstreamBranchName, incomingChanges: status.incomingChanges, @@ -1048,6 +1061,16 @@ export class AgentHostGitService implements IAgentHostGitService { // raw progress/diagnostic text is still available. if (stderr) { this._logService.warn(`[agentHostGitService] > git ${args.join(' ')} failed; full stderr:\n${stderr}`); + } else if (didTimeOut || error.killed) { + // A timed-out or signalled git writes nothing to stderr, + // so this is the only trace such a failure ever leaves. + // Callers that degrade quietly on `undefined` are then + // impossible to diagnose from logs alone. + this._logService.warn(`[agentHostGitService] > git ${args.join(' ')} failed: ${formatGitError(args, timeoutMs, didTimeOut, error, stderr)}`); + } else { + // A silent non-zero exit is how the `--quiet` probes + // report "not found", so this stays below `warn`. + this._logService.trace(`[agentHostGitService] > git ${args.join(' ')} failed: ${formatGitError(args, timeoutMs, didTimeOut, error, stderr)}`); } if (options?.throwOnError) { reject(new Error(formatGitError(args, timeoutMs, didTimeOut, error, stderr), { cause: error })); @@ -1504,6 +1527,7 @@ export function parseGitDiffRawNumstat(output: string, repositoryRoot: URI, sess */ export function parseGitStatusV2(output: string | undefined): { branchName?: string; + isDetachedHead?: boolean; upstreamBranchName?: string; outgoingChanges?: number; incomingChanges?: number; @@ -1513,6 +1537,7 @@ export function parseGitStatusV2(output: string | undefined): { return {}; } let branchName: string | undefined; + let isDetachedHead: boolean | undefined; let upstreamBranchName: string | undefined; let outgoingChanges: number | undefined; let incomingChanges: number | undefined; @@ -1522,8 +1547,11 @@ export function parseGitStatusV2(output: string | undefined): { if (!line) { continue; } if (line.startsWith('# branch.head ')) { const head = line.substring('# branch.head '.length).trim(); - // `(detached)` is what git emits for a detached HEAD. Treat as no branch. - branchName = head === '(detached)' ? undefined : head; + // `(detached)` is what git emits for a detached HEAD. Treat as no + // branch, but report why so consumers can tell an intentionally + // branch-less checkout from a status probe that never ran. + isDetachedHead = head === '(detached)' ? true : undefined; + branchName = isDetachedHead ? undefined : head; } else if (line.startsWith('# branch.upstream ')) { upstreamBranchName = line.substring('# branch.upstream '.length).trim(); } else if (line.startsWith('# branch.ab ')) { @@ -1536,7 +1564,7 @@ export function parseGitStatusV2(output: string | undefined): { uncommittedChanges++; } } - return { branchName, upstreamBranchName, outgoingChanges, incomingChanges, uncommittedChanges }; + return { branchName, isDetachedHead, upstreamBranchName, outgoingChanges, incomingChanges, uncommittedChanges }; } /** Exported for tests. */ diff --git a/src/vs/platform/agentHost/node/agentMergeController.ts b/src/vs/platform/agentHost/node/agentMergeController.ts index 7563e195929d97..9fab75587c62b6 100644 --- a/src/vs/platform/agentHost/node/agentMergeController.ts +++ b/src/vs/platform/agentHost/node/agentMergeController.ts @@ -21,7 +21,7 @@ import { deriveGitHubEndpoints } from '../common/githubEndpoints.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import { ActionType } from '../common/state/protocol/common/actions.js'; import { AuthRequiredReason } from '../common/state/sessionActions.js'; -import { getSessionRelatedPullRequestUrls, isAhpChatChannel, isSessionStatusArchived, parseRequiredSessionUriFromChatUri, readSessionGitHubState, readSessionGitState, SessionLifecycle, TurnState } from '../common/state/sessionState.js'; +import { getSessionRelatedPullRequestUrls, isAhpChatChannel, isSessionStatusArchived, needsSessionGitStateRefresh, parseRequiredSessionUriFromChatUri, readSessionGitHubState, readSessionGitState, SessionLifecycle, TurnState } from '../common/state/sessionState.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; import { IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; @@ -47,6 +47,13 @@ class AgentMergeRuntime extends Disposable { readonly evaluationScheduler: RunOnceScheduler; readonly backstopScheduler: RunOnceScheduler; ref: PullRequestRef | undefined; + /** + * Whether this runtime already tried to recompute git state that reported + * no usable branch. Caps that repair at one git call per runtime so a + * checkout that can never report a branch does not spawn one on every + * backstop. + */ + didRefreshForMissingBranch = false; constructor( readonly session: string, @@ -337,8 +344,10 @@ export class AgentMergeController extends Disposable { if (!runtime || !state || !agentMerge?.enabled || this._stateManager.hasActiveTurn(session)) { return; } - const gitState = readSessionGitState(state._meta); - const branchName = gitState?.branchName; + const branchName = await this._resolveCurrentBranch(session, runtime, state); + if (!this._isCurrentRuntime(session, runtime)) { + return; + } if (!branchName) { this._logService.trace(`[AgentMergeController] Waiting for a current branch: session=${session}`); runtime.backstopScheduler.schedule(); @@ -479,6 +488,46 @@ export class AgentMergeController extends Disposable { } } + /** + * Resolves the branch Agent Merge should act on, repairing session git + * state that does not report one. + * + * A failed git probe can leave persisted git state without a branch. The + * refresh that would repair it normally rides along with a client watching + * the session or an edit landing in the worktree, and neither happens for a + * session this controller is holding resident on its own. Every later step + * — binding the pull request, subscribing to it, acting on its feedback — + * is gated on the branch, so without this the session idles on the backstop + * indefinitely and Agent Merge silently never runs. + * + * A detached `HEAD` is excluded: it reports no branch by design, so + * refreshing would never produce one. The attempt is capped at once per + * runtime regardless, so any other checkout that cannot report a branch + * costs a single git call rather than one per backstop. + */ + private async _resolveCurrentBranch(session: string, runtime: AgentMergeRuntime, state: NonNullable>): Promise { + const gitState = readSessionGitState(state._meta); + if (gitState?.branchName) { + return gitState.branchName; + } + if (runtime.didRefreshForMissingBranch || !needsSessionGitStateRefresh(gitState)) { + return undefined; + } + runtime.didRefreshForMissingBranch = true; + this._logService.debug(`[AgentMergeController] Refreshing git state because the session reports no branch: session=${session}`); + await this._gitStateService.refreshSessionGitState(session, state.workingDirectories?.[0] ? URI.parse(state.workingDirectories[0]) : undefined); + if (!this._isCurrentRuntime(session, runtime)) { + return undefined; + } + const refreshed = readSessionGitState(this._stateManager.getSessionState(session)?._meta)?.branchName; + if (refreshed) { + this._logService.info(`[AgentMergeController] Recovered the session branch after refreshing git state: session=${session}`); + } else { + this._logService.warn(`[AgentMergeController] Session still reports no branch after refreshing git state: session=${session}`); + } + return refreshed; + } + private async _resolveRef(parsed: IParsedPullRequestUrl, signal: AbortSignal): Promise { const credential = await this._gitHubService.credentials.getCredential(signal); // The bound pull request URL carries its own host: after a restore or an diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 954cc39b78ccde..50c00e764b8fa7 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -37,7 +37,7 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } f import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, ResourceChangeType, ResourceType, ResourceWriteMode, type CreateResourceWatchParams, type CreateResourceWatchResult, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { ChangesSummary, ChatInteractivity, ChatOriginKind, MessageAttachmentKind, type Annotation, type AnnotationEntry, type AnnotationsState, type ChatOrigin, type Customization, type Message, type MessageAttachment, type MessageResourceAttachment } from '../common/state/protocol/state.js'; import type { ChatPendingMessageSetAction, ChatTurnStartedAction, SessionConfigChangedAction } from '../common/state/protocol/actions.js'; -import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_ORCHESTRATION_DB_KEY, readSessionSpawnDepth, parseSessionOrchestration, withSessionSpawnDepth, withSessionOrchestration, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, hostBuildInfoFromProduct, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn, type UsageInfo, chatStorageUri, hasReportedUsage } from '../common/state/sessionState.js'; +import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_ORCHESTRATION_DB_KEY, readSessionSpawnDepth, parseSessionOrchestration, withSessionSpawnDepth, withSessionOrchestration, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, hostBuildInfoFromProduct, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn, type UsageInfo, chatStorageUri, hasReportedUsage } from '../common/state/sessionState.js'; import { readToolCallMeta } from '../common/meta/agentToolCallMeta.js'; import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../common/meta/agentEphemeralSessionMeta.js'; import { readChatSurfaceMeta, withChatSurfaceMeta } from '../common/meta/agentChatSurfaceMeta.js'; @@ -3842,9 +3842,12 @@ export class AgentService extends Disposable implements IAgentService { // restore path that normally calls `_attachGitState` is skipped — so // trigger it lazily here for the first subscriber. `_attachGitState` // is async and updates `_meta.git` once ready, which clients see via - // the normal state-update stream. + // the normal state-update stream. State that does not describe a + // usable checkout counts as missing too: a failed probe can persist + // a branch-less remnant, and it would otherwise mask the very + // repair this lazy refresh exists to perform. const sessionState = this._stateManager.getSessionState(resourceStr); - if (!isAhpChatChannel(resourceStr) && sessionState && readSessionGitState(sessionState._meta) === undefined) { + if (!isAhpChatChannel(resourceStr) && sessionState && needsSessionGitStateRefresh(readSessionGitState(sessionState._meta))) { const workingDirectory = sessionState.workingDirectories?.[0] ? URI.parse(sessionState.workingDirectories[0]) : undefined; diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts index 58d13f186737d0..58ad9a32297ff4 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts @@ -192,6 +192,20 @@ suite('AgentHostGitService - getSessionGitState (real git)', () => { assert.strictEqual(result.hasGitHubRemote, false); }); + (hasGit ? test : test.skip)('reports no state at all when the status probe fails', async () => { + const dir = initRepo({ remote: 'https://github.com/owner/repo.git' }); + const before = await svc!.getSessionGitState(URI.file(dir)); + // The repository root is cached from the call above, so the probes still + // run against a repository that can no longer answer them — the same + // shape a probe takes when it times out under load. A partial state + // would be persisted over the branch this session still depends on. + rmDirWithRetry(join(dir, '.git')); + + const after = await svc!.getSessionGitState(URI.file(dir)); + + assert.deepStrictEqual({ before: before?.branchName, after }, { before: 'main', after: undefined }); + }); + (hasGit ? test : test.skip)('reports outgoingChanges relative to base branch when local branch has no upstream', async () => { // Create a bare "remote" repo and set up the working repo so that // `refs/remotes/origin/HEAD` exists (required for baseBranchName parsing). diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts index e5261c6c7376d1..7cd274656835ce 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts @@ -9,6 +9,7 @@ import { formatGitError, getRemoteTrackingRef, GitCheckoutProgressParser, isRetr import { buildGitBlobUri } from '../../node/gitDiffContent.js'; import { URI } from '../../../../base/common/uri.js'; import { EMPTY_TREE_OBJECT, getBranchCompletions, resolveDiffBaseBranchName } from '../../common/agentHostGitService.js'; +import { needsSessionGitStateRefresh } from '../../common/state/sessionState.js'; suite('AgentHostGitService', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -104,6 +105,7 @@ suite('AgentHostGitService', () => { ].join('\n'); assert.deepStrictEqual(parseGitStatusV2(out), { branchName: 'main', + isDetachedHead: undefined, upstreamBranchName: 'origin/main', outgoingChanges: 0, incomingChanges: 0, @@ -123,6 +125,7 @@ suite('AgentHostGitService', () => { ].join('\n'); assert.deepStrictEqual(parseGitStatusV2(out), { branchName: 'feature', + isDetachedHead: undefined, upstreamBranchName: 'origin/feature', outgoingChanges: 3, incomingChanges: 2, @@ -137,6 +140,7 @@ suite('AgentHostGitService', () => { ].join('\n'); assert.deepStrictEqual(parseGitStatusV2(out), { branchName: undefined, + isDetachedHead: true, upstreamBranchName: undefined, outgoingChanges: undefined, incomingChanges: undefined, @@ -149,6 +153,24 @@ suite('AgentHostGitService', () => { }); }); + suite('needsSessionGitStateRefresh', () => { + test('separates a branch-less probe failure from a detached HEAD', () => { + assert.deepStrictEqual({ + neverComputed: needsSessionGitStateRefresh(undefined), + // The residue of a failed `git status`, as persisted before the + // probe learned to withhold state it could not compute. + probeFailureRemnant: needsSessionGitStateRefresh({ baseBranchName: 'main' }), + detachedHead: needsSessionGitStateRefresh({ isDetachedHead: true, baseBranchName: 'main' }), + onABranch: needsSessionGitStateRefresh({ branchName: 'feature', baseBranchName: 'main' }), + }, { + neverComputed: true, + probeFailureRemnant: true, + detachedHead: false, + onABranch: false, + }); + }); + }); + suite('parseHasGitHubRemote', () => { test('detects ssh github remote', () => { assert.strictEqual(parseHasGitHubRemote('origin\tgit@github.com:owner/repo.git (fetch)\n'), true); diff --git a/src/vs/platform/agentHost/test/node/agentMergeController.test.ts b/src/vs/platform/agentHost/test/node/agentMergeController.test.ts index 149dba8e090e0a..d49b0e0c00d9cd 100644 --- a/src/vs/platform/agentHost/test/node/agentMergeController.test.ts +++ b/src/vs/platform/agentHost/test/node/agentMergeController.test.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; -import { Event } from '../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { timeout } from '../../../../base/common/async.js'; import { NullLogService } from '../../../log/common/log.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { mock } from '../../../../base/test/common/mock.js'; @@ -13,7 +14,7 @@ import { AgentHostAutoApprovePolicyRestrictedConfigKey, platformRootSchema, plat import { IAgentHostGitStateService } from '../../common/agentHostGitStateService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ActionType } from '../../common/state/protocol/common/actions.js'; -import { SessionStatus, buildDefaultChatUri, MessageKind, type SessionSummary } from '../../common/state/sessionState.js'; +import { SessionStatus, buildDefaultChatUri, MessageKind, withSessionGitState, type SessionSummary } from '../../common/state/sessionState.js'; import { IGitHubService } from '../../../github/common/githubService.js'; import { AgentConfigurationService } from '../../node/agentConfigurationService.js'; import { AgentHostGitHubEndpointService } from '../../node/agentHostGitHubEndpointService.js'; @@ -218,6 +219,136 @@ suite('AgentMergeController', () => { }); }); + test('recovers a session whose persisted git state lost its branch', async () => { + const logService = new NullLogService(); + const stateManager = disposables.add(new AgentHostStateManager(logService)); + const configurationService = disposables.add(new AgentConfigurationService(stateManager, logService)); + configurationService.updateRootConfig({ [AgentMergeConfigKey.Enabled]: true }); + const session = `copilot:/agent-merge-controller-${++sessionCounter}`; + let refreshCount = 0; + const gitStateService = new class extends mock() { + override readonly onDidRefreshSessionGitState = Event.None; + override readonly onDidChangeSessionGitHubState = Event.None; + override async refreshSessionGitState(sessionKey: string): Promise { + refreshCount++; + stateManager.setSessionMeta(sessionKey, withSessionGitState(stateManager.getSessionState(sessionKey)?._meta, { branchName: 'feature', baseBranchName: 'main' })); + } + // The follow-up evaluation triggered by capturing the target reaches + // this; it finds no pull request and idles on the backstop. + override async attachSessionGitHubPullRequest(): Promise { } + }(); + const endpointService = disposables.add(new AgentHostGitHubEndpointService(configurationService, logService)); + disposables.add(new AgentMergeController( + { + startTurn: () => false, + cancelTurn: () => { }, + getAutonomousSessionConfig: () => ({}), + }, + stateManager, + configurationService, + gitStateService, + new class extends mock() { }(), + endpointService, + logService, + )); + stateManager.createSession(summary(session)); + stateManager.setSessionConfig(session, { + schema: platformSessionSchema.toProtocol(), + values: {}, + }); + // A failed git probe leaves the branch behind but keeps the base branch, + // which is exactly the state that used to stall Agent Merge forever. + stateManager.setSessionMeta(session, withSessionGitState(undefined, { baseBranchName: 'main' })); + configurationService.updateSessionConfig(session, { + [SessionConfigKey.AgentMerge]: { enabled: true }, + }); + + const captured = new Promise(resolve => { + disposables.add(stateManager.onDidChangeSessionConfig(event => { + if (event.session.toString() === session && readAgentMergeSessionState(event.current?.values)?.target) { + resolve(); + } + })); + }); + stateManager.dispatchServerAction(session, { type: ActionType.SessionReady }); + await captured; + + assert.deepStrictEqual({ + refreshCount, + branchName: readAgentMergeSessionState(configurationService.getSessionConfigValues(session))?.target?.branchName, + }, { + refreshCount: 1, + branchName: 'feature', + }); + }); + + test('recomputes git state at most once per runtime and never for a detached HEAD', async () => { + const logService = new NullLogService(); + const stateManager = disposables.add(new AgentHostStateManager(logService)); + const configurationService = disposables.add(new AgentConfigurationService(stateManager, logService)); + configurationService.updateRootConfig({ [AgentMergeConfigKey.Enabled]: true }); + const detached = `copilot:/agent-merge-controller-${++sessionCounter}`; + const stranded = `copilot:/agent-merge-controller-${++sessionCounter}`; + const refreshCounts = new Map(); + const onDidRefreshSessionGitState = disposables.add(new Emitter()); + const gitStateService = new class extends mock() { + override readonly onDidRefreshSessionGitState = onDidRefreshSessionGitState.event; + override readonly onDidChangeSessionGitHubState = Event.None; + // Stands in for a checkout that cannot report a branch however often + // it is probed, which is what makes an unbounded retry expensive. + override async refreshSessionGitState(sessionKey: string): Promise { + refreshCounts.set(sessionKey, (refreshCounts.get(sessionKey) ?? 0) + 1); + } + override async attachSessionGitHubPullRequest(): Promise { } + }(); + const endpointService = disposables.add(new AgentHostGitHubEndpointService(configurationService, logService)); + disposables.add(new AgentMergeController( + { + startTurn: () => false, + cancelTurn: () => { }, + getAutonomousSessionConfig: () => ({}), + }, + stateManager, + configurationService, + gitStateService, + new class extends mock() { }(), + endpointService, + logService, + )); + for (const [session, gitState] of [ + [detached, { isDetachedHead: true, baseBranchName: 'main' }], + [stranded, { baseBranchName: 'main' }], + ] as const) { + stateManager.createSession(summary(session)); + stateManager.setSessionConfig(session, { + schema: platformSessionSchema.toProtocol(), + values: {}, + }); + stateManager.setSessionMeta(session, withSessionGitState(undefined, gitState)); + configurationService.updateSessionConfig(session, { + [SessionConfigKey.AgentMerge]: { enabled: true }, + }); + stateManager.dispatchServerAction(session, { type: ActionType.SessionReady }); + } + + // Drive several evaluation cycles; without a guard each one would spawn + // another git call for both sessions. + for (let cycle = 0; cycle < 3; cycle++) { + onDidRefreshSessionGitState.fire(detached); + onDidRefreshSessionGitState.fire(stranded); + await timeout(0); + await timeout(0); + } + + assert.deepStrictEqual({ + detached: refreshCounts.get(detached) ?? 0, + stranded: refreshCounts.get(stranded) ?? 0, + }, { + detached: 0, + stranded: 1, + }); + }); + function createControllerHarness(disposables: ReturnType): { readonly stateManager: AgentHostStateManager; readonly configurationService: AgentConfigurationService;