chore: merge main into cw/small-change-flow
Integrates main branch changes (headquarters dashboard, task retry count, agent prompt persistence, remote sync improvements) with the initiative's errand agent feature. Both features coexist in the merged result. Key resolutions: - Schema: take main's errands table (nullable projectId, no conflictFiles, with errandsRelations); migrate to 0035_faulty_human_fly - Router: keep both errandProcedures and headquartersProcedures - Errand prompt: take main's simpler version (no question-asking flow) - Manager: take main's status check (running|idle only, no waiting_for_input) - Tests: update to match removed conflictFiles field and undefined vs null
This commit is contained in:
@@ -88,4 +88,10 @@ export interface BranchManager {
|
||||
* (i.e. the branches have diverged).
|
||||
*/
|
||||
fastForwardBranch(repoPath: string, branch: string, remote?: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Force-update a branch ref to point at a specific commit.
|
||||
* Used to roll back a merge when a subsequent push fails.
|
||||
*/
|
||||
updateRef(repoPath: string, branch: string, commitHash: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -453,6 +453,58 @@ describe('SimpleGitWorktreeManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Cross-Agent Isolation
|
||||
// ==========================================================================
|
||||
|
||||
describe('cross-agent isolation', () => {
|
||||
it('get() only matches worktrees in its own worktreesDir', async () => {
|
||||
// Simulate two agents with separate worktree base dirs but same repo
|
||||
const agentADir = path.join(repoPath, 'workdirs', 'agent-a');
|
||||
const agentBDir = path.join(repoPath, 'workdirs', 'agent-b');
|
||||
await mkdir(agentADir, { recursive: true });
|
||||
await mkdir(agentBDir, { recursive: true });
|
||||
|
||||
const managerA = new SimpleGitWorktreeManager(repoPath, undefined, agentADir);
|
||||
const managerB = new SimpleGitWorktreeManager(repoPath, undefined, agentBDir);
|
||||
|
||||
// Both create worktrees with the same id (project name)
|
||||
await managerA.create('my-project', 'agent/agent-a');
|
||||
await managerB.create('my-project', 'agent/agent-b');
|
||||
|
||||
// Each manager should only see its own worktree
|
||||
const wtA = await managerA.get('my-project');
|
||||
const wtB = await managerB.get('my-project');
|
||||
|
||||
expect(wtA).not.toBeNull();
|
||||
expect(wtB).not.toBeNull();
|
||||
expect(wtA!.path).toContain('agent-a');
|
||||
expect(wtB!.path).toContain('agent-b');
|
||||
expect(wtA!.path).not.toBe(wtB!.path);
|
||||
});
|
||||
|
||||
it('remove() only removes worktrees in its own worktreesDir', async () => {
|
||||
const agentADir = path.join(repoPath, 'workdirs', 'agent-a');
|
||||
const agentBDir = path.join(repoPath, 'workdirs', 'agent-b');
|
||||
await mkdir(agentADir, { recursive: true });
|
||||
await mkdir(agentBDir, { recursive: true });
|
||||
|
||||
const managerA = new SimpleGitWorktreeManager(repoPath, undefined, agentADir);
|
||||
const managerB = new SimpleGitWorktreeManager(repoPath, undefined, agentBDir);
|
||||
|
||||
await managerA.create('my-project', 'agent/agent-a');
|
||||
await managerB.create('my-project', 'agent/agent-b');
|
||||
|
||||
// Remove agent A's worktree
|
||||
await managerA.remove('my-project');
|
||||
|
||||
// Agent B's worktree should still exist
|
||||
const wtB = await managerB.get('my-project');
|
||||
expect(wtB).not.toBeNull();
|
||||
expect(wtB!.path).toContain('agent-b');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Edge Cases
|
||||
// ==========================================================================
|
||||
|
||||
@@ -61,11 +61,30 @@ export class SimpleGitWorktreeManager implements WorktreeManager {
|
||||
const worktreePath = path.join(this.worktreesDir, id);
|
||||
log.info({ id, branch, baseBranch }, 'creating worktree');
|
||||
|
||||
// Safety: never force-reset a branch to its own base — this would nuke
|
||||
// shared branches like the initiative branch if passed as both branch and baseBranch.
|
||||
if (branch === baseBranch) {
|
||||
throw new Error(`Worktree branch and baseBranch are the same (${branch}). Use a unique branch name.`);
|
||||
}
|
||||
|
||||
// Create worktree — reuse existing branch or create new one
|
||||
const branchExists = await this.branchExists(branch);
|
||||
if (branchExists) {
|
||||
// Branch exists from a previous run — reset it to baseBranch and check it out
|
||||
await this.git.raw(['branch', '-f', branch, baseBranch]);
|
||||
// Branch exists from a previous run. Check if it has commits beyond baseBranch
|
||||
// before resetting — a previous agent may have done real work on this branch.
|
||||
try {
|
||||
const aheadCount = await this.git.raw(['rev-list', '--count', `${baseBranch}..${branch}`]);
|
||||
if (parseInt(aheadCount.trim(), 10) > 0) {
|
||||
log.warn({ branch, baseBranch, aheadBy: aheadCount.trim() }, 'branch has commits beyond base, preserving');
|
||||
} else {
|
||||
await this.git.raw(['branch', '-f', branch, baseBranch]);
|
||||
}
|
||||
} catch {
|
||||
// If rev-list fails (e.g. baseBranch doesn't exist yet), fall back to reset
|
||||
await this.git.raw(['branch', '-f', branch, baseBranch]);
|
||||
}
|
||||
// Prune stale worktree references before adding new one
|
||||
await this.git.raw(['worktree', 'prune']);
|
||||
await this.git.raw(['worktree', 'add', worktreePath, branch]);
|
||||
} else {
|
||||
// git worktree add -b <branch> <path> <base-branch>
|
||||
@@ -140,8 +159,14 @@ export class SimpleGitWorktreeManager implements WorktreeManager {
|
||||
* Finds worktree by matching path ending with id.
|
||||
*/
|
||||
async get(id: string): Promise<Worktree | null> {
|
||||
const expectedSuffix = path.join(path.basename(this.worktreesDir), id);
|
||||
const worktrees = await this.list();
|
||||
return worktrees.find((wt) => wt.path.endsWith(id)) ?? null;
|
||||
// Match on the worktreesDir + id suffix to avoid cross-agent collisions.
|
||||
// Multiple agents may have worktrees ending with the same project name
|
||||
// (e.g., ".../agent-A/codewalk-district" vs ".../agent-B/codewalk-district").
|
||||
// We match on basename(worktreesDir)/id to handle symlink differences
|
||||
// (e.g., macOS /var → /private/var) while still being unambiguous.
|
||||
return worktrees.find((wt) => wt.path.endsWith(expectedSuffix)) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
172
apps/server/git/remote-sync.test.ts
Normal file
172
apps/server/git/remote-sync.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ProjectSyncManager, type SyncResult } from './remote-sync.js'
|
||||
import type { ProjectRepository } from '../db/repositories/project-repository.js'
|
||||
|
||||
vi.mock('simple-git', () => ({
|
||||
simpleGit: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./project-clones.js', () => ({
|
||||
ensureProjectClone: vi.fn().mockResolvedValue('/tmp/fake-clone'),
|
||||
}))
|
||||
|
||||
vi.mock('../logger/index.js', () => ({
|
||||
createModuleLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
function makeRepo(overrides: Partial<ProjectRepository> = {}): ProjectRepository {
|
||||
return {
|
||||
findAll: vi.fn().mockResolvedValue([]),
|
||||
findById: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
delete: vi.fn(),
|
||||
findProjectsByInitiativeId: vi.fn().mockResolvedValue([]),
|
||||
setInitiativeProjects: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
} as unknown as ProjectRepository
|
||||
}
|
||||
|
||||
const project1 = {
|
||||
id: 'proj-1',
|
||||
name: 'alpha',
|
||||
url: 'https://github.com/org/alpha',
|
||||
defaultBranch: 'main',
|
||||
lastFetchedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
const project2 = {
|
||||
id: 'proj-2',
|
||||
name: 'beta',
|
||||
url: 'https://github.com/org/beta',
|
||||
defaultBranch: 'main',
|
||||
lastFetchedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
describe('ProjectSyncManager', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let simpleGitMock: any
|
||||
|
||||
beforeEach(async () => {
|
||||
const mod = await import('simple-git')
|
||||
simpleGitMock = vi.mocked(mod.simpleGit)
|
||||
simpleGitMock.mockReset()
|
||||
})
|
||||
|
||||
describe('syncAllProjects', () => {
|
||||
it('returns empty array when no projects exist', async () => {
|
||||
const repo = makeRepo({ findAll: vi.fn().mockResolvedValue([]) })
|
||||
const manager = new ProjectSyncManager(repo, '/workspace')
|
||||
const results = await manager.syncAllProjects()
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
it('returns success result for each project when all succeed', async () => {
|
||||
const mockGit = {
|
||||
fetch: vi.fn().mockResolvedValue({}),
|
||||
raw: vi.fn().mockResolvedValue(''),
|
||||
}
|
||||
simpleGitMock.mockReturnValue(mockGit)
|
||||
|
||||
const repo = makeRepo({
|
||||
findAll: vi.fn().mockResolvedValue([project1, project2]),
|
||||
findById: vi.fn()
|
||||
.mockResolvedValueOnce(project1)
|
||||
.mockResolvedValueOnce(project2),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
})
|
||||
const manager = new ProjectSyncManager(repo, '/workspace')
|
||||
const results = await manager.syncAllProjects()
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0]).toMatchObject({
|
||||
projectId: 'proj-1',
|
||||
projectName: 'alpha',
|
||||
success: true,
|
||||
fetched: true,
|
||||
})
|
||||
expect(results[1]).toMatchObject({
|
||||
projectId: 'proj-2',
|
||||
projectName: 'beta',
|
||||
success: true,
|
||||
fetched: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns partial failure when the second project fetch throws', async () => {
|
||||
const mockGitSuccess = {
|
||||
fetch: vi.fn().mockResolvedValue({}),
|
||||
raw: vi.fn().mockResolvedValue(''),
|
||||
}
|
||||
const mockGitFail = {
|
||||
fetch: vi.fn().mockRejectedValue(new Error('network error')),
|
||||
raw: vi.fn().mockResolvedValue(''),
|
||||
}
|
||||
simpleGitMock
|
||||
.mockReturnValueOnce(mockGitSuccess)
|
||||
.mockReturnValueOnce(mockGitFail)
|
||||
|
||||
const repo = makeRepo({
|
||||
findAll: vi.fn().mockResolvedValue([project1, project2]),
|
||||
findById: vi.fn()
|
||||
.mockResolvedValueOnce(project1)
|
||||
.mockResolvedValueOnce(project2),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
})
|
||||
const manager = new ProjectSyncManager(repo, '/workspace')
|
||||
const results = await manager.syncAllProjects()
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0]).toMatchObject({ projectId: 'proj-1', success: true })
|
||||
expect(results[1]).toMatchObject({
|
||||
projectId: 'proj-2',
|
||||
success: false,
|
||||
error: expect.any(String),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SyncResult shape', () => {
|
||||
it('result always contains projectId and success fields', async () => {
|
||||
const mockGit = {
|
||||
fetch: vi.fn().mockResolvedValue({}),
|
||||
raw: vi.fn().mockResolvedValue(''),
|
||||
}
|
||||
simpleGitMock.mockReturnValue(mockGit)
|
||||
|
||||
const repo = makeRepo({
|
||||
findAll: vi.fn().mockResolvedValue([project1]),
|
||||
findById: vi.fn().mockResolvedValue(project1),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
})
|
||||
const manager = new ProjectSyncManager(repo, '/workspace')
|
||||
const results = await manager.syncAllProjects()
|
||||
|
||||
expect(results[0]).toMatchObject({
|
||||
projectId: expect.any(String),
|
||||
success: expect.any(Boolean),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('failure counting logic', () => {
|
||||
it('counts failures from SyncResult array', () => {
|
||||
const results: Pick<SyncResult, 'success'>[] = [
|
||||
{ success: true },
|
||||
{ success: false },
|
||||
{ success: true },
|
||||
{ success: false },
|
||||
]
|
||||
const failed = results.filter(r => !r.success)
|
||||
expect(failed.length).toBe(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,7 @@
|
||||
* on project clones without requiring a worktree.
|
||||
*/
|
||||
|
||||
import { join } from 'node:path';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { simpleGit } from 'simple-git';
|
||||
@@ -39,6 +39,9 @@ export class SimpleGitBranchManager implements BranchManager {
|
||||
const tempBranch = `cw-merge-${Date.now()}`;
|
||||
|
||||
try {
|
||||
// Capture the target branch ref before merge so callers can roll back on push failure
|
||||
const previousRef = (await repoGit.raw(['rev-parse', targetBranch])).trim();
|
||||
|
||||
// Create worktree with a temp branch starting at targetBranch's commit
|
||||
await repoGit.raw(['worktree', 'add', '-b', tempBranch, tmpPath, targetBranch]);
|
||||
|
||||
@@ -53,7 +56,7 @@ export class SimpleGitBranchManager implements BranchManager {
|
||||
await repoGit.raw(['update-ref', `refs/heads/${targetBranch}`, mergeCommit]);
|
||||
|
||||
log.info({ repoPath, sourceBranch, targetBranch }, 'merge completed cleanly');
|
||||
return { success: true, message: `Merged ${sourceBranch} into ${targetBranch}` };
|
||||
return { success: true, message: `Merged ${sourceBranch} into ${targetBranch}`, previousRef };
|
||||
} catch (mergeErr) {
|
||||
// Check for merge conflicts
|
||||
const status = await wtGit.status();
|
||||
@@ -161,7 +164,26 @@ export class SimpleGitBranchManager implements BranchManager {
|
||||
|
||||
async pushBranch(repoPath: string, branch: string, remote = 'origin'): Promise<void> {
|
||||
const git = simpleGit(repoPath);
|
||||
await git.push(remote, branch);
|
||||
try {
|
||||
await git.push(remote, branch);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (!msg.includes('branch is currently checked out')) throw err;
|
||||
|
||||
// Local non-bare repo with the branch checked out — temporarily allow it.
|
||||
// receive.denyCurrentBranch=updateInstead updates the remote's working tree
|
||||
// and index to match, or rejects if the working tree is dirty.
|
||||
const remoteUrl = (await git.remote(['get-url', remote]))?.trim();
|
||||
if (!remoteUrl) throw err;
|
||||
const remotePath = resolve(repoPath, remoteUrl);
|
||||
const remoteGit = simpleGit(remotePath);
|
||||
await remoteGit.addConfig('receive.denyCurrentBranch', 'updateInstead');
|
||||
try {
|
||||
await git.push(remote, branch);
|
||||
} finally {
|
||||
await remoteGit.raw(['config', '--unset', 'receive.denyCurrentBranch']);
|
||||
}
|
||||
}
|
||||
log.info({ repoPath, branch, remote }, 'branch pushed to remote');
|
||||
}
|
||||
|
||||
@@ -205,7 +227,24 @@ export class SimpleGitBranchManager implements BranchManager {
|
||||
async fastForwardBranch(repoPath: string, branch: string, remote = 'origin'): Promise<void> {
|
||||
const git = simpleGit(repoPath);
|
||||
const remoteBranch = `${remote}/${branch}`;
|
||||
await git.raw(['merge', '--ff-only', remoteBranch, branch]);
|
||||
|
||||
// Verify it's a genuine fast-forward (branch is ancestor of remote)
|
||||
try {
|
||||
await git.raw(['merge-base', '--is-ancestor', branch, remoteBranch]);
|
||||
} catch {
|
||||
throw new Error(`Cannot fast-forward ${branch}: it has diverged from ${remoteBranch}`);
|
||||
}
|
||||
|
||||
// Use update-ref instead of git merge so dirty working trees don't block it.
|
||||
// The clone may have uncommitted agent work; we only need to advance the ref.
|
||||
const targetCommit = (await git.raw(['rev-parse', remoteBranch])).trim();
|
||||
await git.raw(['update-ref', `refs/heads/${branch}`, targetCommit]);
|
||||
log.info({ repoPath, branch, remoteBranch }, 'fast-forwarded branch');
|
||||
}
|
||||
|
||||
async updateRef(repoPath: string, branch: string, commitHash: string): Promise<void> {
|
||||
const git = simpleGit(repoPath);
|
||||
await git.raw(['update-ref', `refs/heads/${branch}`, commitHash]);
|
||||
log.info({ repoPath, branch, commitHash: commitHash.slice(0, 7) }, 'branch ref updated');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,8 @@ export interface MergeResult {
|
||||
conflicts?: string[];
|
||||
/** Human-readable message describing the result */
|
||||
message: string;
|
||||
/** The target branch's commit hash before the merge (for rollback on push failure) */
|
||||
previousRef?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user