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:
Lukas May
2026-03-06 16:48:12 +01:00
parent da3218b530
commit 28521e1c20
100 changed files with 9054 additions and 973 deletions

View File

@@ -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>;
}

View File

@@ -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
// ==========================================================================

View File

@@ -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;
}
/**

View 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)
})
})
})

View File

@@ -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');
}
}

View File

@@ -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;
}
// =============================================================================