From 46d68834e99aad22bb13b6e033c59631882b7c3c Mon Sep 17 00:00:00 2001 From: Alessandro Pogliaghi Date: Thu, 30 Jul 2026 13:02:19 +0100 Subject: [PATCH 1/4] fix(agent): keep sibling PR branches out of task resume state Only persist signed-commit branch metadata when the commit targets the task repository. This prevents a branch created in a sibling clone from breaking subsequent task resumption. Generated-By: PostHog Code Task-Id: 3721ab47-bf20-4d0a-b474-8a622badccd2 --- .../local-tools/tools/signed-commit.test.ts | 17 ++++++++++++++ .../local-tools/tools/signed-git-tool.ts | 1 + .../src/adapters/signed-commit-shared.ts | 22 ++++++++++++++----- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/packages/agent/src/adapters/local-tools/tools/signed-commit.test.ts b/packages/agent/src/adapters/local-tools/tools/signed-commit.test.ts index 33378c3f1f..da4ab569d0 100644 --- a/packages/agent/src/adapters/local-tools/tools/signed-commit.test.ts +++ b/packages/agent/src/adapters/local-tools/tools/signed-commit.test.ts @@ -117,6 +117,23 @@ describe("signed-commit tool handler", () => { }); }); + it("does not persist a branch created in a sibling repository", async () => { + await signedCommitTool.handler( + { + cwd: "/tmp/workspace/repos/posthog/code", + token: "ghs_x", + taskId: "task-1", + taskRunId: "run-1", + }, + { + message: "chore: bump", + cwd: "/tmp/workspace/repos/posthog/grafana-dashboards", + }, + ); + + expect(reportTaskRunBranch).not.toHaveBeenCalled(); + }); + it("returns the no-token error without invoking createSignedCommit", async () => { const savedGh = process.env.GH_TOKEN; const savedGithub = process.env.GITHUB_TOKEN; diff --git a/packages/agent/src/adapters/local-tools/tools/signed-git-tool.ts b/packages/agent/src/adapters/local-tools/tools/signed-git-tool.ts index 0b72c25875..691b2dd384 100644 --- a/packages/agent/src/adapters/local-tools/tools/signed-git-tool.ts +++ b/packages/agent/src/adapters/local-tools/tools/signed-git-tool.ts @@ -47,6 +47,7 @@ export function defineSignedGitTool(opts: { return opts.run( { cwd, + taskRepositoryCwd: ctx.cwd, token, taskId: ctx.taskId, taskRunId: ctx.taskRunId, diff --git a/packages/agent/src/adapters/signed-commit-shared.ts b/packages/agent/src/adapters/signed-commit-shared.ts index aa7e8523b4..2cdd83eb7d 100644 --- a/packages/agent/src/adapters/signed-commit-shared.ts +++ b/packages/agent/src/adapters/signed-commit-shared.ts @@ -135,7 +135,11 @@ export interface SignedCommitToolResult { [key: string]: unknown; } -export type SignedCommitToolCtx = SignedCommitCtx & { taskRunId?: string }; +export type SignedCommitToolCtx = SignedCommitCtx & { + taskRunId?: string; + /** The task repository cwd, before a tool-call `cwd` override is applied. */ + taskRepositoryCwd: string; +}; async function runSignedTool( toolName: string, @@ -176,11 +180,17 @@ export function runSignedCommitTool( SIGNED_COMMIT_TOOL_NAME, async (c, a: SignedCommitInput) => { const result = await createSignedCommit(c, a); - await reportTaskRunBranch({ - taskId: ctx.taskId, - taskRunId: ctx.taskRunId, - branch: result.branch, - }); + // TaskRun.branch is the branch that provisioning checks out in the task's + // repository on resume. A task can also commit to sibling repositories by + // passing `cwd`; persisting one of those branches here makes the next run + // try to clone the task repository at a branch that only exists elsewhere. + if (ctx.cwd === ctx.taskRepositoryCwd) { + await reportTaskRunBranch({ + taskId: ctx.taskId, + taskRunId: ctx.taskRunId, + branch: result.branch, + }); + } // The "commit hook": every pushed commit becomes a `commit` artefact on the signal // reports this task is associated with. Best-effort and awaited inside the tool's // try/catch-free success path — reportCommitArtefacts never throws, so a failed From e604ce6436134a4976064cf4cc6ba58ef41d4027 Mon Sep 17 00:00:00 2001 From: Alessandro Pogliaghi Date: Thu, 30 Jul 2026 16:19:36 +0100 Subject: [PATCH 2/4] feat(agent): restore every repository on resume Generated-By: PostHog Code Task-Id: 3721ab47-bf20-4d0a-b474-8a622badccd2 --- packages/agent/README.md | 2 +- packages/agent/src/handoff-checkpoint.test.ts | 81 +++++- packages/agent/src/handoff-checkpoint.ts | 272 +++++++++++++++++- packages/agent/src/server/agent-server.ts | 35 ++- packages/agent/src/server/bin.ts | 5 + packages/agent/src/server/types.ts | 1 + packages/agent/src/types.ts | 13 + packages/git/src/handoff.ts | 31 +- 8 files changed, 418 insertions(+), 22 deletions(-) diff --git a/packages/agent/README.md b/packages/agent/README.md index 337d6e38fb..b8fbd3de9b 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -259,7 +259,7 @@ ACP defines standard methods like `session/prompt`, `session/update`, and `sessi **State synchronization** — events that keep the client's view of the agent's state in sync. These are essential for the cloud↔local handoff flow and for the client to render accurate UI. - `_posthog/branch_created` — `{ branch }` — agent created a git branch (client can update branch display) -- `_posthog/git_checkpoint` — `{ checkpointId, checkpointRef, branch, head, indexTree, worktreeTree, ... }` — git checkpoint captured for resume and handoff. This is the key event for session resume — the resume saga scans backwards for the latest checkpoint to restore files +- `_posthog/git_checkpoint` — `{ checkpointId, checkpointRef, branch, head, indexTree, worktreeTree, repositories?, ... }` — git checkpoint captured for resume and handoff. Cloud workspace checkpoints include a versioned `repositories` manifest with independent artifacts and workspace-relative paths for every cloned repository. The top-level fields remain the primary repository checkpoint for compatibility with older agents. - `_posthog/mode_change` — `{ mode, previous_mode }` — permission mode changed (client updates mode selector) - `_posthog/compact_boundary` — `{ sessionId, timestamp }` — marks where context compaction occurred, so the client knows the conversation was summarized at this point - `_posthog/task_notification` — `{ sessionId, type, message?, data? }` — generic extensible notification for adapter-specific events diff --git a/packages/agent/src/handoff-checkpoint.test.ts b/packages/agent/src/handoff-checkpoint.test.ts index 0f4fa49cbd..28bfb2f782 100644 --- a/packages/agent/src/handoff-checkpoint.test.ts +++ b/packages/agent/src/handoff-checkpoint.test.ts @@ -1,5 +1,8 @@ -import { readdir } from "node:fs/promises"; -import path from "node:path"; +import { execFile } from "node:child_process"; +import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path, { join } from "node:path"; +import { promisify } from "node:util"; import { afterEach, describe, expect, it, vi } from "vitest"; import { decodeHandoffArtifact, @@ -12,6 +15,13 @@ import { } from "./sagas/test-fixtures"; import type { HandoffLocalGitState } from "./types"; +const execFileAsync = promisify(execFile); + +async function git(cwd: string, args: string[]): Promise { + const result = await execFileAsync("git", args, { cwd }); + return result.stdout.trim(); +} + interface BundleStore { artifacts: Record; storagePath: string; @@ -256,6 +266,73 @@ describe("HandoffCheckpointTracker", () => { expect(localRepo.exists(".posthog/tmp")).toBe(false); }); + it("restores missing sibling repositories from a workspace manifest", async () => { + const primaryRemote = await createTestRepo("manifest-primary-remote"); + const siblingRemote = await createTestRepo("manifest-sibling-remote"); + cleanups.push(primaryRemote.cleanup, siblingRemote.cleanup); + await seedCloudRepo(primaryRemote); + await seedCloudRepo(siblingRemote); + + const sourceWorkspace = await mkdtemp(join(tmpdir(), "manifest-source-")); + const targetWorkspace = await mkdtemp(join(tmpdir(), "manifest-target-")); + cleanups.push( + () => rm(sourceWorkspace, { recursive: true, force: true }), + () => rm(targetWorkspace, { recursive: true, force: true }), + ); + const sourcePrimary = join(sourceWorkspace, "repos/acme/primary"); + const sourceSibling = join(sourceWorkspace, "repos/acme/sibling"); + await execFileAsync("git", ["clone", primaryRemote.path, sourcePrimary]); + await execFileAsync("git", ["clone", siblingRemote.path, sourceSibling]); + + await git(sourceSibling, ["checkout", "-b", "feature/resume-me"]); + await writeFile(join(sourceSibling, "feature.txt"), "feature commit\n"); + await git(sourceSibling, ["add", "feature.txt"]); + await git(sourceSibling, ["commit", "-m", "Add sibling feature"]); + await git(sourceSibling, ["push", "-u", "origin", "feature/resume-me"]); + await writeFile(join(sourceSibling, "unstaged.txt"), "sibling resumed\n"); + await writeFile( + join(sourceSibling, "untracked.txt"), + "untracked sibling\n", + ); + + const store = createBundleStore(); + const apiClient = createMockApi(store); + const captureTracker = createTracker(sourcePrimary, apiClient); + const checkpoint = + await captureTracker.captureWorkspaceForHandoff(sourceWorkspace); + expect(checkpoint?.repositories).toHaveLength(2); + if (!checkpoint) throw new Error("Workspace checkpoint was not captured"); + + // The manifest must not depend on a feature branch continuing to exist remotely. + await git(siblingRemote.path, [ + "update-ref", + "-d", + "refs/heads/feature/resume-me", + ]); + + const targetPrimary = join(targetWorkspace, "repos/acme/primary"); + await execFileAsync("git", ["clone", primaryRemote.path, targetPrimary]); + const applyTracker = createTracker(targetPrimary, apiClient); + await applyTracker.applyWorkspaceFromHandoff(checkpoint, targetWorkspace); + + const targetSibling = join(targetWorkspace, "repos/acme/sibling"); + expect(await readFile(join(targetSibling, "unstaged.txt"), "utf8")).toBe( + "sibling resumed\n", + ); + expect(await readFile(join(targetSibling, "untracked.txt"), "utf8")).toBe( + "untracked sibling\n", + ); + expect(await readFile(join(targetSibling, "feature.txt"), "utf8")).toBe( + "feature commit\n", + ); + expect(await git(targetSibling, ["branch", "--show-current"])).toBe( + "feature/resume-me", + ); + expect(await git(targetSibling, ["status", "--short"])).toContain( + "?? untracked.txt", + ); + }); + it("round-trips a cloud capture without local git state via direct-to-storage uploads", async () => { const originRepo = await createTestRepo("handoff-origin"); cleanups.push(originRepo.cleanup); diff --git a/packages/agent/src/handoff-checkpoint.ts b/packages/agent/src/handoff-checkpoint.ts index 75470acf11..b63c8411a3 100644 --- a/packages/agent/src/handoff-checkpoint.ts +++ b/packages/agent/src/handoff-checkpoint.ts @@ -1,6 +1,17 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import type { Dirent } from "node:fs"; +import { + access, + mkdir, + mkdtemp, + readdir, + readFile, + rm, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { promisify } from "node:util"; import { type GitHandoffBranchDivergence, type GitHandoffCheckpoint, @@ -10,7 +21,12 @@ import type { PostHogAPIClient, PreparedTaskArtifactUpload, } from "./posthog-api"; -import type { GitCheckpoint, HandoffLocalGitState } from "./types"; +import type { + GitCheckpoint, + GitCheckpointEvent, + HandoffLocalGitState, + RepositoryGitCheckpoint, +} from "./types"; import { Logger } from "./utils/logger"; /** Server-side cap on a single task-run artifact; larger files are skipped, not failed. */ @@ -20,6 +36,15 @@ const MAX_INLINE_UPLOAD_BYTES = 10 * 1024 * 1024; const PACK_MAGIC = Buffer.from("PACK"); const INDEX_MAGIC = Buffer.from("DIRC"); +const execFileAsync = promisify(execFile); +const IGNORED_WORKSPACE_DIRECTORIES = new Set([ + ".git", + "node_modules", + ".pnpm-store", + ".venv", + "venv", +]); +const MAX_WORKSPACE_REPOSITORIES = 50; /** * Handoff artifacts used to be stored as base64 text (inline uploads without @@ -96,6 +121,7 @@ export class HandoffCheckpointTracker { async captureForHandoff( localGitState?: HandoffLocalGitState, + options?: { durableDefaultBranchBaseline?: boolean }, ): Promise { if (!this.apiClient) { throw new Error( @@ -104,7 +130,7 @@ export class HandoffCheckpointTracker { } const gitTracker = this.createGitTracker(); - const capture = await gitTracker.captureForHandoff(localGitState); + const capture = await gitTracker.captureForHandoff(localGitState, options); try { const uploads = await this.uploadArtifacts([ @@ -156,10 +182,80 @@ export class HandoffCheckpointTracker { } } + /** + * Capture every Git repository below a workspace root. The legacy top-level + * checkpoint remains the primary repository so older agents can still resume. + */ + async captureWorkspaceForHandoff( + workspacePath: string, + localGitState?: HandoffLocalGitState, + ): Promise { + const workspaceRoot = resolve(workspacePath); + const primaryPath = resolve(this.repositoryPath); + const repositoryPaths = await discoverGitRepositories(workspaceRoot); + if (!repositoryPaths.includes(primaryPath)) { + repositoryPaths.unshift(primaryPath); + } + + const repositories: RepositoryGitCheckpoint[] = []; + const incompleteRepositories: string[] = []; + for (const repositoryPath of repositoryPaths) { + const relativePath = safeRelativeRepositoryPath( + workspaceRoot, + repositoryPath, + ); + if (relativePath === null) { + this.logger.warn("Skipping checkpoint outside workspace", { + workspaceRoot, + repositoryPath, + }); + continue; + } + const primary = repositoryPath === primaryPath; + try { + const tracker = new HandoffCheckpointTracker({ + repositoryPath, + taskId: this.taskId, + runId: this.runId, + apiClient: this.apiClient, + logger: this.logger, + }); + const checkpoint = await tracker.captureForHandoff( + primary ? localGitState : undefined, + { durableDefaultBranchBaseline: true }, + ); + if (checkpoint) { + repositories.push({ ...checkpoint, path: relativePath, primary }); + } else { + incompleteRepositories.push(relativePath); + } + } catch (error) { + this.logger.warn("Failed to capture repository checkpoint", { + repositoryPath, + primary, + error: error instanceof Error ? error.message : String(error), + }); + if (primary) throw error; + incompleteRepositories.push(relativePath); + } + } + + const primary = repositories.find((repository) => repository.primary); + if (!primary) return null; + return { + ...primary, + manifestVersion: 1, + repositories, + incompleteRepositories: + incompleteRepositories.length > 0 ? incompleteRepositories : undefined, + }; + } + async applyFromHandoff( checkpoint: GitCheckpoint, options?: { localGitState?: HandoffLocalGitState; + skipUpstreamBaselineFetch?: boolean; onDivergedBranch?: ( divergence: GitHandoffBranchDivergence, ) => Promise; @@ -200,6 +296,7 @@ export class HandoffCheckpointTracker { headPackPath: downloads.pack?.filePath, indexPath: downloads.index?.filePath, localGitState: options?.localGitState, + skipUpstreamBaselineFetch: options?.skipUpstreamBaselineFetch, onDivergedBranch: options?.onDivergedBranch, }); @@ -217,6 +314,75 @@ export class HandoffCheckpointTracker { } } + /** Restore a multi-repository event, cloning missing sibling repositories. */ + async applyWorkspaceFromHandoff( + event: GitCheckpointEvent, + workspacePath: string, + ): Promise<{ + repositories: number; + failedRepositories: number; + totalBytes: number; + }> { + if (!event.repositories?.length) { + const metrics = await this.applyFromHandoff(event); + return { + repositories: 1, + failedRepositories: 0, + totalBytes: metrics.totalBytes, + }; + } + + const workspaceRoot = resolve(workspacePath); + let totalBytes = 0; + let restored = 0; + let failed = event.incompleteRepositories?.length ?? 0; + const ordered = [...event.repositories].sort( + (left, right) => Number(right.primary) - Number(left.primary), + ); + for (const repository of ordered) { + const repositoryPath = resolveRepositoryPath( + workspaceRoot, + repository.path, + ); + if ( + repository.primary && + repositoryPath !== resolve(this.repositoryPath) + ) { + throw new Error( + "Checkpoint primary repository path does not match task repository", + ); + } + const tracker = new HandoffCheckpointTracker({ + repositoryPath, + taskId: this.taskId, + runId: this.runId, + apiClient: this.apiClient, + logger: this.logger, + }); + try { + await ensureGitRepository(repositoryPath, repository.remoteUrl); + const metrics = await tracker.applyFromHandoff(repository, { + skipUpstreamBaselineFetch: true, + }); + totalBytes += metrics.totalBytes; + restored += 1; + } catch (error) { + this.logger.warn("Failed to restore repository checkpoint", { + repositoryPath, + primary: repository.primary, + error: error instanceof Error ? error.message : String(error), + }); + if (repository.primary) throw error; + failed += 1; + } + } + return { + repositories: restored, + failedRepositories: failed, + totalBytes, + }; + } + private toGitCheckpoint(checkpoint: GitCheckpoint): GitHandoffCheckpoint { return { checkpointId: checkpoint.checkpointId, @@ -521,3 +687,101 @@ export class HandoffCheckpointTracker { await rm(filePath, { force: true }).catch(() => {}); } } + +export async function discoverGitRepositories( + workspacePath: string, +): Promise { + const repositories: string[] = []; + const visit = async (directory: string): Promise => { + if (repositories.length >= MAX_WORKSPACE_REPOSITORIES) return; + let entries: Dirent[]; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch { + return; + } + if (entries.some((entry) => entry.name === ".git")) { + repositories.push(resolve(directory)); + return; + } + await Promise.all( + entries + .filter( + (entry) => + entry.isDirectory() && + !entry.isSymbolicLink() && + !IGNORED_WORKSPACE_DIRECTORIES.has(entry.name), + ) + .map((entry) => visit(join(directory, entry.name))), + ); + }; + await visit(resolve(workspacePath)); + return repositories.sort(); +} + +function safeRelativeRepositoryPath( + workspaceRoot: string, + repositoryPath: string, +): string | null { + const value = relative(workspaceRoot, repositoryPath); + if (!value || value === ".") return "."; + if ( + isAbsolute(value) || + value === ".." || + value.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) + ) { + return null; + } + return value; +} + +function resolveRepositoryPath(workspaceRoot: string, path: string): string { + if (isAbsolute(path)) + throw new Error("Repository checkpoint path must be relative"); + const resolved = resolve(workspaceRoot, path); + if (safeRelativeRepositoryPath(workspaceRoot, resolved) === null) { + throw new Error("Repository checkpoint path escapes workspace"); + } + return resolved; +} + +async function ensureGitRepository( + repositoryPath: string, + remoteUrl: string | null | undefined, +): Promise { + try { + await access(join(repositoryPath, ".git")); + return; + } catch { + // Restore from the checkpoint after creating a normal Git object store. + } + if (!remoteUrl) { + throw new Error( + `Cannot restore missing repository without a remote URL: ${repositoryPath}`, + ); + } + await mkdir(dirname(repositoryPath), { recursive: true }); + try { + await access(repositoryPath); + await execFileAsync("git", ["init", repositoryPath]); + await execFileAsync("git", ["remote", "add", "origin", remoteUrl], { + cwd: repositoryPath, + }); + await execFileAsync("git", ["fetch", "--no-tags", "origin"], { + cwd: repositoryPath, + maxBuffer: 10 * 1024 * 1024, + }); + } catch (error) { + try { + await access(repositoryPath); + } catch { + await execFileAsync( + "git", + ["clone", "--no-checkout", remoteUrl, repositoryPath], + { maxBuffer: 10 * 1024 * 1024 }, + ); + return; + } + throw error; + } +} diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 274fe25b7b..75555f138f 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -2499,17 +2499,26 @@ export class AgentServer { apiClient: this.posthogAPI, logger: this.logger.child("HandoffCheckpoint"), }); - const metrics = await checkpointTracker.applyFromHandoff( - this.resumeState.latestGitCheckpoint, - ); + const workspacePath = this.getCheckpointWorkspacePath(); + const metrics = workspacePath + ? await checkpointTracker.applyWorkspaceFromHandoff( + this.resumeState.latestGitCheckpoint, + workspacePath, + ) + : await checkpointTracker.applyFromHandoff( + this.resumeState.latestGitCheckpoint, + ); this.logger.debug("Git checkpoint applied", { branch: this.resumeState.latestGitCheckpoint.branch, head: this.resumeState.latestGitCheckpoint.head, - packBytes: metrics.packBytes, - indexBytes: metrics.indexBytes, totalBytes: metrics.totalBytes, + repositories: "repositories" in metrics ? metrics.repositories : 1, + failedRepositories: + "failedRepositories" in metrics ? metrics.failedRepositories : 0, }); - return true; + return !( + "failedRepositories" in metrics && metrics.failedRepositories > 0 + ); } catch (error) { this.logger.warn("Failed to apply git checkpoint", { error: error instanceof Error ? error.message : String(error), @@ -4683,7 +4692,10 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions} logger: this.logger.child("HandoffCheckpoint"), }); - const checkpoint = await tracker.captureForHandoff(localGitState); + const workspacePath = this.getCheckpointWorkspacePath(); + const checkpoint = workspacePath + ? await tracker.captureWorkspaceForHandoff(workspacePath, localGitState) + : await tracker.captureForHandoff(localGitState); if (!checkpoint) return; const checkpointWithDevice: GitCheckpointEvent = { @@ -4709,6 +4721,15 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions} ); } + private getCheckpointWorkspacePath(): string | undefined { + if (this.config.workspacePath) return this.config.workspacePath; + if (!this.config.repositoryPath) return undefined; + const reposDirectory = dirname(dirname(this.config.repositoryPath)); + return basename(reposDirectory) === "repos" + ? dirname(reposDirectory) + : undefined; + } + private extractHandoffLocalGitState( params: Record, ): HandoffLocalGitState | null { diff --git a/packages/agent/src/server/bin.ts b/packages/agent/src/server/bin.ts index 91411b241a..b1671b5f64 100644 --- a/packages/agent/src/server/bin.ts +++ b/packages/agent/src/server/bin.ts @@ -136,6 +136,10 @@ program "interactive", ) .option("--repositoryPath ", "Path to the repository") + .option( + "--workspacePath ", + "Workspace root containing all repositories to checkpoint", + ) .option( "--repoReadyFile ", "Sentinel file; session creation blocks until it exists (set while cloning concurrently)", @@ -255,6 +259,7 @@ program otelLogsToken: env.POSTHOG_AGENT_OTEL_LOGS_TOKEN, otelTracesUrl: env.POSTHOG_AGENT_OTEL_TRACES_URL, repositoryPath: options.repositoryPath, + workspacePath: options.workspacePath, repoReadyFile: options.repoReadyFile, apiUrl: env.POSTHOG_API_URL, apiKey: env.POSTHOG_PERSONAL_API_KEY, diff --git a/packages/agent/src/server/types.ts b/packages/agent/src/server/types.ts index 50a24c095e..fe44faa471 100644 --- a/packages/agent/src/server/types.ts +++ b/packages/agent/src/server/types.ts @@ -15,6 +15,7 @@ export interface AgentServerConfig { port: number; agentStateDir?: string; repositoryPath?: string; + workspacePath?: string; repoReadyFile?: string; apiUrl: string; apiKey: string; diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 8bbffe5e70..275a856c13 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -134,8 +134,21 @@ export interface GitCheckpoint extends GitHandoffCheckpoint { indexArtifactPath?: string; } +export interface RepositoryGitCheckpoint extends GitCheckpoint { + /** Path relative to the workspace root. Never absolute. */ + path: string; + /** Whether this is the repository configured for the task. */ + primary: boolean; +} + export interface GitCheckpointEvent extends GitCheckpoint { device?: DeviceInfo; + /** Versioned, portable state for every Git repository in the workspace. */ + manifestVersion?: 1; + workspacePath?: string; + repositories?: RepositoryGitCheckpoint[]; + /** Repositories discovered but not captured, so resume can report partial state. */ + incompleteRepositories?: string[]; } /** diff --git a/packages/git/src/handoff.ts b/packages/git/src/handoff.ts index be706e297f..9c3f003a2f 100644 --- a/packages/git/src/handoff.ts +++ b/packages/git/src/handoff.ts @@ -36,6 +36,7 @@ export interface GitHandoffApplyInput { headPackPath?: string; indexPath?: string; localGitState?: HandoffLocalGitState; + skipUpstreamBaselineFetch?: boolean; onDivergedBranch?: ( divergence: GitHandoffBranchDivergence, ) => Promise; @@ -81,6 +82,7 @@ export class GitHandoffTracker { async captureForHandoff( localGitState?: HandoffLocalGitState, + options?: { durableDefaultBranchBaseline?: boolean }, ): Promise { const captureSaga = new CaptureCheckpointSaga(this.logger); const result = await captureSaga.run({ baseDir: this.repositoryPath }); @@ -105,9 +107,14 @@ export class GitHandoffTracker { ); const tracking = await getTrackingMetadata(git, checkpoint.branch); - const baselineRefs = localGitState?.upstreamHead - ? [localGitState.upstreamHead] - : await this.resolveDefaultPackBaseline(git, tracking); + const baselineRefs = + !options?.durableDefaultBranchBaseline && localGitState?.upstreamHead + ? [localGitState.upstreamHead] + : await this.resolveDefaultPackBaseline( + git, + tracking, + options?.durableDefaultBranchBaseline ?? false, + ); const packRefs = [ checkpoint.head, reconciledIndex.indexTree, @@ -160,12 +167,17 @@ export class GitHandoffTracker { headPackPath, indexPath, localGitState, + skipUpstreamBaselineFetch, onDivergedBranch, } = input; const git = createGitClient(this.repositoryPath); if (headPackPath) { - await this.ensureBaselineForApply(git, checkpoint, localGitState); + // Durable workspace manifests pack against the remote default branch, + // which a fresh clone already has. Their feature branch may be deleted. + if (!skipUpstreamBaselineFetch) { + await this.ensureBaselineForApply(git, checkpoint, localGitState); + } await this.unpackPackFile(headPackPath); } @@ -228,8 +240,13 @@ export class GitHandoffTracker { private async resolveDefaultPackBaseline( git: GitClient, tracking: GitTrackingMetadata, + durableDefaultBranchBaseline: boolean, ): Promise { - if (tracking.upstreamRemote && tracking.upstreamMergeRef) { + if ( + !durableDefaultBranchBaseline && + tracking.upstreamRemote && + tracking.upstreamMergeRef + ) { const branchName = tracking.upstreamMergeRef.replace( /^refs\/heads\//, "", @@ -808,9 +825,7 @@ async function getTrackingMetadata( git, `branch.${branch}.merge`, ); - const remoteUrl = upstreamRemote - ? await getRemoteUrl(git, upstreamRemote) - : null; + const remoteUrl = await getRemoteUrl(git, upstreamRemote ?? "origin"); return { upstreamRemote, upstreamMergeRef, remoteUrl }; } From 2e18b9f23822cfa3ab9f5f6516c8897ce42077c3 Mon Sep 17 00:00:00 2001 From: Alessandro Pogliaghi Date: Thu, 30 Jul 2026 17:16:02 +0100 Subject: [PATCH 3/4] revert(agent): keep cloud resume snapshot-based Remove the multi-repository Git checkpoint implementation. Cloud-to-cloud resume restores the sandbox directory snapshot; Git handoff state remains unchanged. Generated-By: PostHog Code Task-Id: 3721ab47-bf20-4d0a-b474-8a622badccd2 --- packages/agent/README.md | 2 +- packages/agent/src/handoff-checkpoint.test.ts | 81 +----- packages/agent/src/handoff-checkpoint.ts | 272 +----------------- packages/agent/src/server/agent-server.ts | 35 +-- packages/agent/src/server/bin.ts | 5 - packages/agent/src/server/types.ts | 1 - packages/agent/src/types.ts | 13 - packages/git/src/handoff.ts | 31 +- 8 files changed, 22 insertions(+), 418 deletions(-) diff --git a/packages/agent/README.md b/packages/agent/README.md index b8fbd3de9b..337d6e38fb 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -259,7 +259,7 @@ ACP defines standard methods like `session/prompt`, `session/update`, and `sessi **State synchronization** — events that keep the client's view of the agent's state in sync. These are essential for the cloud↔local handoff flow and for the client to render accurate UI. - `_posthog/branch_created` — `{ branch }` — agent created a git branch (client can update branch display) -- `_posthog/git_checkpoint` — `{ checkpointId, checkpointRef, branch, head, indexTree, worktreeTree, repositories?, ... }` — git checkpoint captured for resume and handoff. Cloud workspace checkpoints include a versioned `repositories` manifest with independent artifacts and workspace-relative paths for every cloned repository. The top-level fields remain the primary repository checkpoint for compatibility with older agents. +- `_posthog/git_checkpoint` — `{ checkpointId, checkpointRef, branch, head, indexTree, worktreeTree, ... }` — git checkpoint captured for resume and handoff. This is the key event for session resume — the resume saga scans backwards for the latest checkpoint to restore files - `_posthog/mode_change` — `{ mode, previous_mode }` — permission mode changed (client updates mode selector) - `_posthog/compact_boundary` — `{ sessionId, timestamp }` — marks where context compaction occurred, so the client knows the conversation was summarized at this point - `_posthog/task_notification` — `{ sessionId, type, message?, data? }` — generic extensible notification for adapter-specific events diff --git a/packages/agent/src/handoff-checkpoint.test.ts b/packages/agent/src/handoff-checkpoint.test.ts index 28bfb2f782..0f4fa49cbd 100644 --- a/packages/agent/src/handoff-checkpoint.test.ts +++ b/packages/agent/src/handoff-checkpoint.test.ts @@ -1,8 +1,5 @@ -import { execFile } from "node:child_process"; -import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path, { join } from "node:path"; -import { promisify } from "node:util"; +import { readdir } from "node:fs/promises"; +import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { decodeHandoffArtifact, @@ -15,13 +12,6 @@ import { } from "./sagas/test-fixtures"; import type { HandoffLocalGitState } from "./types"; -const execFileAsync = promisify(execFile); - -async function git(cwd: string, args: string[]): Promise { - const result = await execFileAsync("git", args, { cwd }); - return result.stdout.trim(); -} - interface BundleStore { artifacts: Record; storagePath: string; @@ -266,73 +256,6 @@ describe("HandoffCheckpointTracker", () => { expect(localRepo.exists(".posthog/tmp")).toBe(false); }); - it("restores missing sibling repositories from a workspace manifest", async () => { - const primaryRemote = await createTestRepo("manifest-primary-remote"); - const siblingRemote = await createTestRepo("manifest-sibling-remote"); - cleanups.push(primaryRemote.cleanup, siblingRemote.cleanup); - await seedCloudRepo(primaryRemote); - await seedCloudRepo(siblingRemote); - - const sourceWorkspace = await mkdtemp(join(tmpdir(), "manifest-source-")); - const targetWorkspace = await mkdtemp(join(tmpdir(), "manifest-target-")); - cleanups.push( - () => rm(sourceWorkspace, { recursive: true, force: true }), - () => rm(targetWorkspace, { recursive: true, force: true }), - ); - const sourcePrimary = join(sourceWorkspace, "repos/acme/primary"); - const sourceSibling = join(sourceWorkspace, "repos/acme/sibling"); - await execFileAsync("git", ["clone", primaryRemote.path, sourcePrimary]); - await execFileAsync("git", ["clone", siblingRemote.path, sourceSibling]); - - await git(sourceSibling, ["checkout", "-b", "feature/resume-me"]); - await writeFile(join(sourceSibling, "feature.txt"), "feature commit\n"); - await git(sourceSibling, ["add", "feature.txt"]); - await git(sourceSibling, ["commit", "-m", "Add sibling feature"]); - await git(sourceSibling, ["push", "-u", "origin", "feature/resume-me"]); - await writeFile(join(sourceSibling, "unstaged.txt"), "sibling resumed\n"); - await writeFile( - join(sourceSibling, "untracked.txt"), - "untracked sibling\n", - ); - - const store = createBundleStore(); - const apiClient = createMockApi(store); - const captureTracker = createTracker(sourcePrimary, apiClient); - const checkpoint = - await captureTracker.captureWorkspaceForHandoff(sourceWorkspace); - expect(checkpoint?.repositories).toHaveLength(2); - if (!checkpoint) throw new Error("Workspace checkpoint was not captured"); - - // The manifest must not depend on a feature branch continuing to exist remotely. - await git(siblingRemote.path, [ - "update-ref", - "-d", - "refs/heads/feature/resume-me", - ]); - - const targetPrimary = join(targetWorkspace, "repos/acme/primary"); - await execFileAsync("git", ["clone", primaryRemote.path, targetPrimary]); - const applyTracker = createTracker(targetPrimary, apiClient); - await applyTracker.applyWorkspaceFromHandoff(checkpoint, targetWorkspace); - - const targetSibling = join(targetWorkspace, "repos/acme/sibling"); - expect(await readFile(join(targetSibling, "unstaged.txt"), "utf8")).toBe( - "sibling resumed\n", - ); - expect(await readFile(join(targetSibling, "untracked.txt"), "utf8")).toBe( - "untracked sibling\n", - ); - expect(await readFile(join(targetSibling, "feature.txt"), "utf8")).toBe( - "feature commit\n", - ); - expect(await git(targetSibling, ["branch", "--show-current"])).toBe( - "feature/resume-me", - ); - expect(await git(targetSibling, ["status", "--short"])).toContain( - "?? untracked.txt", - ); - }); - it("round-trips a cloud capture without local git state via direct-to-storage uploads", async () => { const originRepo = await createTestRepo("handoff-origin"); cleanups.push(originRepo.cleanup); diff --git a/packages/agent/src/handoff-checkpoint.ts b/packages/agent/src/handoff-checkpoint.ts index b63c8411a3..75470acf11 100644 --- a/packages/agent/src/handoff-checkpoint.ts +++ b/packages/agent/src/handoff-checkpoint.ts @@ -1,17 +1,6 @@ -import { execFile } from "node:child_process"; -import type { Dirent } from "node:fs"; -import { - access, - mkdir, - mkdtemp, - readdir, - readFile, - rm, - writeFile, -} from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { dirname, isAbsolute, join, relative, resolve } from "node:path"; -import { promisify } from "node:util"; +import { join } from "node:path"; import { type GitHandoffBranchDivergence, type GitHandoffCheckpoint, @@ -21,12 +10,7 @@ import type { PostHogAPIClient, PreparedTaskArtifactUpload, } from "./posthog-api"; -import type { - GitCheckpoint, - GitCheckpointEvent, - HandoffLocalGitState, - RepositoryGitCheckpoint, -} from "./types"; +import type { GitCheckpoint, HandoffLocalGitState } from "./types"; import { Logger } from "./utils/logger"; /** Server-side cap on a single task-run artifact; larger files are skipped, not failed. */ @@ -36,15 +20,6 @@ const MAX_INLINE_UPLOAD_BYTES = 10 * 1024 * 1024; const PACK_MAGIC = Buffer.from("PACK"); const INDEX_MAGIC = Buffer.from("DIRC"); -const execFileAsync = promisify(execFile); -const IGNORED_WORKSPACE_DIRECTORIES = new Set([ - ".git", - "node_modules", - ".pnpm-store", - ".venv", - "venv", -]); -const MAX_WORKSPACE_REPOSITORIES = 50; /** * Handoff artifacts used to be stored as base64 text (inline uploads without @@ -121,7 +96,6 @@ export class HandoffCheckpointTracker { async captureForHandoff( localGitState?: HandoffLocalGitState, - options?: { durableDefaultBranchBaseline?: boolean }, ): Promise { if (!this.apiClient) { throw new Error( @@ -130,7 +104,7 @@ export class HandoffCheckpointTracker { } const gitTracker = this.createGitTracker(); - const capture = await gitTracker.captureForHandoff(localGitState, options); + const capture = await gitTracker.captureForHandoff(localGitState); try { const uploads = await this.uploadArtifacts([ @@ -182,80 +156,10 @@ export class HandoffCheckpointTracker { } } - /** - * Capture every Git repository below a workspace root. The legacy top-level - * checkpoint remains the primary repository so older agents can still resume. - */ - async captureWorkspaceForHandoff( - workspacePath: string, - localGitState?: HandoffLocalGitState, - ): Promise { - const workspaceRoot = resolve(workspacePath); - const primaryPath = resolve(this.repositoryPath); - const repositoryPaths = await discoverGitRepositories(workspaceRoot); - if (!repositoryPaths.includes(primaryPath)) { - repositoryPaths.unshift(primaryPath); - } - - const repositories: RepositoryGitCheckpoint[] = []; - const incompleteRepositories: string[] = []; - for (const repositoryPath of repositoryPaths) { - const relativePath = safeRelativeRepositoryPath( - workspaceRoot, - repositoryPath, - ); - if (relativePath === null) { - this.logger.warn("Skipping checkpoint outside workspace", { - workspaceRoot, - repositoryPath, - }); - continue; - } - const primary = repositoryPath === primaryPath; - try { - const tracker = new HandoffCheckpointTracker({ - repositoryPath, - taskId: this.taskId, - runId: this.runId, - apiClient: this.apiClient, - logger: this.logger, - }); - const checkpoint = await tracker.captureForHandoff( - primary ? localGitState : undefined, - { durableDefaultBranchBaseline: true }, - ); - if (checkpoint) { - repositories.push({ ...checkpoint, path: relativePath, primary }); - } else { - incompleteRepositories.push(relativePath); - } - } catch (error) { - this.logger.warn("Failed to capture repository checkpoint", { - repositoryPath, - primary, - error: error instanceof Error ? error.message : String(error), - }); - if (primary) throw error; - incompleteRepositories.push(relativePath); - } - } - - const primary = repositories.find((repository) => repository.primary); - if (!primary) return null; - return { - ...primary, - manifestVersion: 1, - repositories, - incompleteRepositories: - incompleteRepositories.length > 0 ? incompleteRepositories : undefined, - }; - } - async applyFromHandoff( checkpoint: GitCheckpoint, options?: { localGitState?: HandoffLocalGitState; - skipUpstreamBaselineFetch?: boolean; onDivergedBranch?: ( divergence: GitHandoffBranchDivergence, ) => Promise; @@ -296,7 +200,6 @@ export class HandoffCheckpointTracker { headPackPath: downloads.pack?.filePath, indexPath: downloads.index?.filePath, localGitState: options?.localGitState, - skipUpstreamBaselineFetch: options?.skipUpstreamBaselineFetch, onDivergedBranch: options?.onDivergedBranch, }); @@ -314,75 +217,6 @@ export class HandoffCheckpointTracker { } } - /** Restore a multi-repository event, cloning missing sibling repositories. */ - async applyWorkspaceFromHandoff( - event: GitCheckpointEvent, - workspacePath: string, - ): Promise<{ - repositories: number; - failedRepositories: number; - totalBytes: number; - }> { - if (!event.repositories?.length) { - const metrics = await this.applyFromHandoff(event); - return { - repositories: 1, - failedRepositories: 0, - totalBytes: metrics.totalBytes, - }; - } - - const workspaceRoot = resolve(workspacePath); - let totalBytes = 0; - let restored = 0; - let failed = event.incompleteRepositories?.length ?? 0; - const ordered = [...event.repositories].sort( - (left, right) => Number(right.primary) - Number(left.primary), - ); - for (const repository of ordered) { - const repositoryPath = resolveRepositoryPath( - workspaceRoot, - repository.path, - ); - if ( - repository.primary && - repositoryPath !== resolve(this.repositoryPath) - ) { - throw new Error( - "Checkpoint primary repository path does not match task repository", - ); - } - const tracker = new HandoffCheckpointTracker({ - repositoryPath, - taskId: this.taskId, - runId: this.runId, - apiClient: this.apiClient, - logger: this.logger, - }); - try { - await ensureGitRepository(repositoryPath, repository.remoteUrl); - const metrics = await tracker.applyFromHandoff(repository, { - skipUpstreamBaselineFetch: true, - }); - totalBytes += metrics.totalBytes; - restored += 1; - } catch (error) { - this.logger.warn("Failed to restore repository checkpoint", { - repositoryPath, - primary: repository.primary, - error: error instanceof Error ? error.message : String(error), - }); - if (repository.primary) throw error; - failed += 1; - } - } - return { - repositories: restored, - failedRepositories: failed, - totalBytes, - }; - } - private toGitCheckpoint(checkpoint: GitCheckpoint): GitHandoffCheckpoint { return { checkpointId: checkpoint.checkpointId, @@ -687,101 +521,3 @@ export class HandoffCheckpointTracker { await rm(filePath, { force: true }).catch(() => {}); } } - -export async function discoverGitRepositories( - workspacePath: string, -): Promise { - const repositories: string[] = []; - const visit = async (directory: string): Promise => { - if (repositories.length >= MAX_WORKSPACE_REPOSITORIES) return; - let entries: Dirent[]; - try { - entries = await readdir(directory, { withFileTypes: true }); - } catch { - return; - } - if (entries.some((entry) => entry.name === ".git")) { - repositories.push(resolve(directory)); - return; - } - await Promise.all( - entries - .filter( - (entry) => - entry.isDirectory() && - !entry.isSymbolicLink() && - !IGNORED_WORKSPACE_DIRECTORIES.has(entry.name), - ) - .map((entry) => visit(join(directory, entry.name))), - ); - }; - await visit(resolve(workspacePath)); - return repositories.sort(); -} - -function safeRelativeRepositoryPath( - workspaceRoot: string, - repositoryPath: string, -): string | null { - const value = relative(workspaceRoot, repositoryPath); - if (!value || value === ".") return "."; - if ( - isAbsolute(value) || - value === ".." || - value.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) - ) { - return null; - } - return value; -} - -function resolveRepositoryPath(workspaceRoot: string, path: string): string { - if (isAbsolute(path)) - throw new Error("Repository checkpoint path must be relative"); - const resolved = resolve(workspaceRoot, path); - if (safeRelativeRepositoryPath(workspaceRoot, resolved) === null) { - throw new Error("Repository checkpoint path escapes workspace"); - } - return resolved; -} - -async function ensureGitRepository( - repositoryPath: string, - remoteUrl: string | null | undefined, -): Promise { - try { - await access(join(repositoryPath, ".git")); - return; - } catch { - // Restore from the checkpoint after creating a normal Git object store. - } - if (!remoteUrl) { - throw new Error( - `Cannot restore missing repository without a remote URL: ${repositoryPath}`, - ); - } - await mkdir(dirname(repositoryPath), { recursive: true }); - try { - await access(repositoryPath); - await execFileAsync("git", ["init", repositoryPath]); - await execFileAsync("git", ["remote", "add", "origin", remoteUrl], { - cwd: repositoryPath, - }); - await execFileAsync("git", ["fetch", "--no-tags", "origin"], { - cwd: repositoryPath, - maxBuffer: 10 * 1024 * 1024, - }); - } catch (error) { - try { - await access(repositoryPath); - } catch { - await execFileAsync( - "git", - ["clone", "--no-checkout", remoteUrl, repositoryPath], - { maxBuffer: 10 * 1024 * 1024 }, - ); - return; - } - throw error; - } -} diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 75555f138f..274fe25b7b 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -2499,26 +2499,17 @@ export class AgentServer { apiClient: this.posthogAPI, logger: this.logger.child("HandoffCheckpoint"), }); - const workspacePath = this.getCheckpointWorkspacePath(); - const metrics = workspacePath - ? await checkpointTracker.applyWorkspaceFromHandoff( - this.resumeState.latestGitCheckpoint, - workspacePath, - ) - : await checkpointTracker.applyFromHandoff( - this.resumeState.latestGitCheckpoint, - ); + const metrics = await checkpointTracker.applyFromHandoff( + this.resumeState.latestGitCheckpoint, + ); this.logger.debug("Git checkpoint applied", { branch: this.resumeState.latestGitCheckpoint.branch, head: this.resumeState.latestGitCheckpoint.head, + packBytes: metrics.packBytes, + indexBytes: metrics.indexBytes, totalBytes: metrics.totalBytes, - repositories: "repositories" in metrics ? metrics.repositories : 1, - failedRepositories: - "failedRepositories" in metrics ? metrics.failedRepositories : 0, }); - return !( - "failedRepositories" in metrics && metrics.failedRepositories > 0 - ); + return true; } catch (error) { this.logger.warn("Failed to apply git checkpoint", { error: error instanceof Error ? error.message : String(error), @@ -4692,10 +4683,7 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions} logger: this.logger.child("HandoffCheckpoint"), }); - const workspacePath = this.getCheckpointWorkspacePath(); - const checkpoint = workspacePath - ? await tracker.captureWorkspaceForHandoff(workspacePath, localGitState) - : await tracker.captureForHandoff(localGitState); + const checkpoint = await tracker.captureForHandoff(localGitState); if (!checkpoint) return; const checkpointWithDevice: GitCheckpointEvent = { @@ -4721,15 +4709,6 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions} ); } - private getCheckpointWorkspacePath(): string | undefined { - if (this.config.workspacePath) return this.config.workspacePath; - if (!this.config.repositoryPath) return undefined; - const reposDirectory = dirname(dirname(this.config.repositoryPath)); - return basename(reposDirectory) === "repos" - ? dirname(reposDirectory) - : undefined; - } - private extractHandoffLocalGitState( params: Record, ): HandoffLocalGitState | null { diff --git a/packages/agent/src/server/bin.ts b/packages/agent/src/server/bin.ts index b1671b5f64..91411b241a 100644 --- a/packages/agent/src/server/bin.ts +++ b/packages/agent/src/server/bin.ts @@ -136,10 +136,6 @@ program "interactive", ) .option("--repositoryPath ", "Path to the repository") - .option( - "--workspacePath ", - "Workspace root containing all repositories to checkpoint", - ) .option( "--repoReadyFile ", "Sentinel file; session creation blocks until it exists (set while cloning concurrently)", @@ -259,7 +255,6 @@ program otelLogsToken: env.POSTHOG_AGENT_OTEL_LOGS_TOKEN, otelTracesUrl: env.POSTHOG_AGENT_OTEL_TRACES_URL, repositoryPath: options.repositoryPath, - workspacePath: options.workspacePath, repoReadyFile: options.repoReadyFile, apiUrl: env.POSTHOG_API_URL, apiKey: env.POSTHOG_PERSONAL_API_KEY, diff --git a/packages/agent/src/server/types.ts b/packages/agent/src/server/types.ts index fe44faa471..50a24c095e 100644 --- a/packages/agent/src/server/types.ts +++ b/packages/agent/src/server/types.ts @@ -15,7 +15,6 @@ export interface AgentServerConfig { port: number; agentStateDir?: string; repositoryPath?: string; - workspacePath?: string; repoReadyFile?: string; apiUrl: string; apiKey: string; diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 275a856c13..8bbffe5e70 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -134,21 +134,8 @@ export interface GitCheckpoint extends GitHandoffCheckpoint { indexArtifactPath?: string; } -export interface RepositoryGitCheckpoint extends GitCheckpoint { - /** Path relative to the workspace root. Never absolute. */ - path: string; - /** Whether this is the repository configured for the task. */ - primary: boolean; -} - export interface GitCheckpointEvent extends GitCheckpoint { device?: DeviceInfo; - /** Versioned, portable state for every Git repository in the workspace. */ - manifestVersion?: 1; - workspacePath?: string; - repositories?: RepositoryGitCheckpoint[]; - /** Repositories discovered but not captured, so resume can report partial state. */ - incompleteRepositories?: string[]; } /** diff --git a/packages/git/src/handoff.ts b/packages/git/src/handoff.ts index 9c3f003a2f..be706e297f 100644 --- a/packages/git/src/handoff.ts +++ b/packages/git/src/handoff.ts @@ -36,7 +36,6 @@ export interface GitHandoffApplyInput { headPackPath?: string; indexPath?: string; localGitState?: HandoffLocalGitState; - skipUpstreamBaselineFetch?: boolean; onDivergedBranch?: ( divergence: GitHandoffBranchDivergence, ) => Promise; @@ -82,7 +81,6 @@ export class GitHandoffTracker { async captureForHandoff( localGitState?: HandoffLocalGitState, - options?: { durableDefaultBranchBaseline?: boolean }, ): Promise { const captureSaga = new CaptureCheckpointSaga(this.logger); const result = await captureSaga.run({ baseDir: this.repositoryPath }); @@ -107,14 +105,9 @@ export class GitHandoffTracker { ); const tracking = await getTrackingMetadata(git, checkpoint.branch); - const baselineRefs = - !options?.durableDefaultBranchBaseline && localGitState?.upstreamHead - ? [localGitState.upstreamHead] - : await this.resolveDefaultPackBaseline( - git, - tracking, - options?.durableDefaultBranchBaseline ?? false, - ); + const baselineRefs = localGitState?.upstreamHead + ? [localGitState.upstreamHead] + : await this.resolveDefaultPackBaseline(git, tracking); const packRefs = [ checkpoint.head, reconciledIndex.indexTree, @@ -167,17 +160,12 @@ export class GitHandoffTracker { headPackPath, indexPath, localGitState, - skipUpstreamBaselineFetch, onDivergedBranch, } = input; const git = createGitClient(this.repositoryPath); if (headPackPath) { - // Durable workspace manifests pack against the remote default branch, - // which a fresh clone already has. Their feature branch may be deleted. - if (!skipUpstreamBaselineFetch) { - await this.ensureBaselineForApply(git, checkpoint, localGitState); - } + await this.ensureBaselineForApply(git, checkpoint, localGitState); await this.unpackPackFile(headPackPath); } @@ -240,13 +228,8 @@ export class GitHandoffTracker { private async resolveDefaultPackBaseline( git: GitClient, tracking: GitTrackingMetadata, - durableDefaultBranchBaseline: boolean, ): Promise { - if ( - !durableDefaultBranchBaseline && - tracking.upstreamRemote && - tracking.upstreamMergeRef - ) { + if (tracking.upstreamRemote && tracking.upstreamMergeRef) { const branchName = tracking.upstreamMergeRef.replace( /^refs\/heads\//, "", @@ -825,7 +808,9 @@ async function getTrackingMetadata( git, `branch.${branch}.merge`, ); - const remoteUrl = await getRemoteUrl(git, upstreamRemote ?? "origin"); + const remoteUrl = upstreamRemote + ? await getRemoteUrl(git, upstreamRemote) + : null; return { upstreamRemote, upstreamMergeRef, remoteUrl }; } From fad940a527ded99f05240c25b55c05bf2f788252 Mon Sep 17 00:00:00 2001 From: Alessandro Pogliaghi Date: Thu, 30 Jul 2026 18:00:07 +0100 Subject: [PATCH 4/4] fix(agent): normalize signed commit repository paths Generated-By: PostHog Code Task-Id: 3721ab47-bf20-4d0a-b474-8a622badccd2 --- .../local-tools/tools/signed-commit.test.ts | 18 ++++++++++++++++++ .../local-tools/tools/signed-git-tool.ts | 7 +++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/agent/src/adapters/local-tools/tools/signed-commit.test.ts b/packages/agent/src/adapters/local-tools/tools/signed-commit.test.ts index da4ab569d0..5502dacd3b 100644 --- a/packages/agent/src/adapters/local-tools/tools/signed-commit.test.ts +++ b/packages/agent/src/adapters/local-tools/tools/signed-commit.test.ts @@ -117,6 +117,24 @@ describe("signed-commit tool handler", () => { }); }); + it("persists the branch when cwd uses an equivalent path representation", async () => { + await signedCommitTool.handler( + { + cwd: "/tmp/workspace/repos/posthog/code/.", + token: "ghs_x", + taskId: "task-1", + taskRunId: "run-1", + }, + { message: "chore: bump", cwd: "." }, + ); + + expect(reportTaskRunBranch).toHaveBeenCalledWith({ + taskId: "task-1", + taskRunId: "run-1", + branch: "posthog-code/feature", + }); + }); + it("does not persist a branch created in a sibling repository", async () => { await signedCommitTool.handler( { diff --git a/packages/agent/src/adapters/local-tools/tools/signed-git-tool.ts b/packages/agent/src/adapters/local-tools/tools/signed-git-tool.ts index 691b2dd384..cdeae219d0 100644 --- a/packages/agent/src/adapters/local-tools/tools/signed-git-tool.ts +++ b/packages/agent/src/adapters/local-tools/tools/signed-git-tool.ts @@ -43,11 +43,14 @@ export function defineSignedGitTool(opts: { string, unknown >; - const cwd = argCwd ? path.resolve(ctx.cwd, argCwd) : ctx.cwd; + const taskRepositoryCwd = path.resolve(ctx.cwd); + const cwd = argCwd + ? path.resolve(taskRepositoryCwd, argCwd) + : taskRepositoryCwd; return opts.run( { cwd, - taskRepositoryCwd: ctx.cwd, + taskRepositoryCwd, token, taskId: ctx.taskId, taskRunId: ctx.taskRunId,