Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions migrations/000_combined.sql
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ CREATE TABLE IF NOT EXISTS remote_agent_codebases (
name VARCHAR(255) NOT NULL,
repository_url VARCHAR(500),
default_cwd VARCHAR(500) NOT NULL,
default_branch TEXT, -- NULL means "not yet detected" (auto-detect at sync); see migration 022
ai_assistant_type VARCHAR(20) DEFAULT 'claude',
allow_env_keys BOOLEAN NOT NULL DEFAULT FALSE,
commands JSONB DEFAULT '{}'::jsonb,
Expand Down
8 changes: 8 additions & 0 deletions migrations/022_add_default_branch_to_codebases.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- Add default_branch column to remote_agent_codebases.
-- NULL means "not yet detected"; syncWorkspace falls back to auto-detection
-- (pre-existing behaviour). New clones set this via the branch-detect path in
-- clone.ts. Using no DEFAULT so existing rows stay NULL rather than being
-- silently set to 'main' (which could trigger an unwanted hard-reset for
-- managed clones on a non-main branch).
ALTER TABLE remote_agent_codebases
ADD COLUMN IF NOT EXISTS default_branch TEXT;
16 changes: 15 additions & 1 deletion packages/core/src/db/adapters/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,20 @@ export class SqliteAdapter implements IDatabase {
} catch (e: unknown) {
getLog().warn({ err: e as Error }, 'db.sqlite_migration_session_columns_failed');
}

// Codebases columns
try {
const codebaseCols = this.db.prepare("PRAGMA table_info('remote_agent_codebases')").all() as {
name: string;
}[];
const codebaseColNames = new Set(codebaseCols.map(c => c.name));

if (!codebaseColNames.has('default_branch')) {
this.db.run('ALTER TABLE remote_agent_codebases ADD COLUMN default_branch TEXT');
}
} catch (e: unknown) {
getLog().warn({ err: e as Error }, 'db.sqlite_migration_codebases_columns_failed');
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

/**
Expand All @@ -234,7 +248,7 @@ export class SqliteAdapter implements IDatabase {
name TEXT NOT NULL,
repository_url TEXT,
default_cwd TEXT NOT NULL,
default_branch TEXT DEFAULT 'main',
default_branch TEXT,
ai_assistant_type TEXT DEFAULT 'claude',
commands TEXT DEFAULT '{}',
created_at TEXT DEFAULT (datetime('now')),
Expand Down
9 changes: 5 additions & 4 deletions packages/core/src/db/codebases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ describe('codebases', () => {
name: 'test-project',
repository_url: 'https://github.com/user/repo',
default_cwd: '/workspace/test-project',
default_branch: null,
ai_assistant_type: 'claude',
commands: { plan: { path: '.claude/commands/plan.md', description: 'Plan feature' } },
created_at: new Date(),
Expand All @@ -54,8 +55,8 @@ describe('codebases', () => {

expect(result).toEqual(mockCodebase);
expect(mockQuery).toHaveBeenCalledWith(
'INSERT INTO remote_agent_codebases (name, repository_url, default_cwd, ai_assistant_type) VALUES ($1, $2, $3, $4) RETURNING *',
['test-project', 'https://github.com/user/repo', '/workspace/test-project', 'claude']
'INSERT INTO remote_agent_codebases (name, repository_url, default_cwd, default_branch, ai_assistant_type) VALUES ($1, $2, $3, $4, $5) RETURNING *',
['test-project', 'https://github.com/user/repo', '/workspace/test-project', null, 'claude']
);
});

Expand All @@ -73,8 +74,8 @@ describe('codebases', () => {

expect(result).toEqual(codebaseWithoutOptional);
expect(mockQuery).toHaveBeenCalledWith(
'INSERT INTO remote_agent_codebases (name, repository_url, default_cwd, ai_assistant_type) VALUES ($1, $2, $3, $4) RETURNING *',
['test-project', null, '/workspace/test-project', 'claude']
'INSERT INTO remote_agent_codebases (name, repository_url, default_cwd, default_branch, ai_assistant_type) VALUES ($1, $2, $3, $4, $5) RETURNING *',
['test-project', null, '/workspace/test-project', null, 'claude']
);
});

Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/db/codebases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ export async function createCodebase(data: {
name: string;
repository_url?: string;
default_cwd: string;
default_branch?: string | null;
ai_assistant_type?: string;
}): Promise<Codebase> {
const assistantType = data.ai_assistant_type ?? 'claude';
const defaultBranch = data.default_branch ?? null;
const result = await pool.query<Codebase>(
'INSERT INTO remote_agent_codebases (name, repository_url, default_cwd, ai_assistant_type) VALUES ($1, $2, $3, $4) RETURNING *',
[data.name, data.repository_url ?? null, data.default_cwd, assistantType]
'INSERT INTO remote_agent_codebases (name, repository_url, default_cwd, default_branch, ai_assistant_type) VALUES ($1, $2, $3, $4, $5) RETURNING *',
[data.name, data.repository_url ?? null, data.default_cwd, defaultBranch, assistantType]
);
if (!result.rows[0]) {
throw new Error('Failed to create codebase: INSERT succeeded but no row returned');
Expand Down
28 changes: 26 additions & 2 deletions packages/core/src/handlers/clone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ export interface RegisterResult {
async function registerRepoAtPath(
targetPath: string,
name: string,
repositoryUrl: string | null
repositoryUrl: string | null,
defaultBranch?: string
): Promise<RegisterResult> {
// Auto-detect assistant type based on SDK folder conventions.
// Built-in providers use well-known folders (.claude/, .codex/).
Expand Down Expand Up @@ -125,6 +126,7 @@ async function registerRepoAtPath(
name,
repository_url: repositoryUrl ?? undefined,
default_cwd: targetPath,
default_branch: defaultBranch ?? undefined,
ai_assistant_type: suggestedAssistant,
});

Expand Down Expand Up @@ -279,7 +281,29 @@ export async function cloneRepository(repoUrl: string): Promise<RegisterResult>
await execFileAsync('git', ['config', '--global', '--add', 'safe.directory', targetPath]);
getLog().debug({ path: targetPath }, 'safe_directory_added');

const result = await registerRepoAtPath(targetPath, `${ownerName}/${repoName}`, workingUrl);
// Detect the actual branch checked out after clone (may differ from 'main' for repos with
// a non-default HEAD). Non-fatal: falls back to DB schema default if detection fails.
let detectedBranch: string | undefined;
try {
const { stdout } = await execFileAsync(
'git',
['-C', targetPath, 'rev-parse', '--abbrev-ref', 'HEAD'],
{ timeout: 5000 }
);
const branch = stdout.trim();
if (branch && branch !== 'HEAD') {
detectedBranch = branch;
}
} catch {
// Non-fatal — leave undefined so DB default applies
}

const result = await registerRepoAtPath(
targetPath,
`${ownerName}/${repoName}`,
workingUrl,
detectedBranch
);
getLog().info({ url: workingUrl, targetPath }, 'clone_completed');
return result;
}
Expand Down
34 changes: 15 additions & 19 deletions packages/core/src/orchestrator/orchestrator-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const mockSyncWorkspace = mock(() =>
Promise.resolve({
branch: 'main',
synced: true,
state: 'in_sync' as const,
previousHead: 'abc12345',
newHead: 'abc12345',
updated: false,
Expand Down Expand Up @@ -194,7 +195,9 @@ mock.module('../utils/worktree-sync', () => ({

mock.module('@archon/git', () => ({
syncWorkspace: mockSyncWorkspace,
getCurrentBranch: mock(() => Promise.resolve('main')),
toRepoPath: mockToRepoPath,
toBranchName: (s: string) => s,
}));

mock.module('fs', () => ({
Expand All @@ -213,6 +216,7 @@ function makeCodebase(name: string, id = `id-${name}`): Codebase {
name,
repository_url: null,
default_cwd: `/repos/${name}`,
default_branch: null,
ai_assistant_type: 'claude',
commands: {},
created_at: new Date(),
Expand Down Expand Up @@ -836,6 +840,7 @@ function makeCodebaseForSync() {
name: 'test-repo',
repository_url: 'https://github.com/test/repo',
default_cwd: '/repos/test-repo',
default_branch: null,
ai_assistant_type: 'claude',
commands: {},
created_at: new Date(),
Expand Down Expand Up @@ -930,32 +935,25 @@ describe('discoverAllWorkflows — remote sync', () => {
const platform = makePlatform();
await handleMessage(platform, 'conv-1', 'What is the latest commit?');

// /repos/test-repo is NOT under ~/.archon/workspaces/ so resetAfterFetch=false
expect(mockSyncWorkspace).toHaveBeenCalledWith('/repos/test-repo', undefined, {
resetAfterFetch: false,
});
// Regression guard: orchestrator must resolve cwd through the ensure variant
// so the workspaces dir is created before the AI provider spawn (issue #1528).
// Chat-tick uses default mode 'fast-forward' — non-destructive.
// No third arg passed; default kicks in inside syncWorkspace.
expect(mockSyncWorkspace).toHaveBeenCalledWith('/repos/test-repo', undefined);
// Regression guard from #1528: orchestrator must resolve cwd through the
// ensure variant so the workspaces dir is created before the AI provider
// spawn.
expect(mockEnsureArchonWorkspacesPath).toHaveBeenCalled();
});

test('passes resetAfterFetch=true for managed clones', async () => {
test('passes configured default_branch as second arg', async () => {
const conversation = makeConversation({ codebase_id: 'codebase-1' });
const codebase = {
...makeCodebaseForSync(),
default_cwd: '/home/test/.archon/workspaces/owner/repo/source',
};
const codebase = { ...makeCodebaseForSync(), default_branch: 'develop' };
mockGetOrCreateConversation.mockReturnValueOnce(Promise.resolve(conversation));
mockGetCodebase.mockReturnValueOnce(Promise.resolve(codebase));

const platform = makePlatform();
await handleMessage(platform, 'conv-1', 'What is the latest commit?');

expect(mockSyncWorkspace).toHaveBeenCalledWith(
'/home/test/.archon/workspaces/owner/repo/source',
undefined,
{ resetAfterFetch: true }
);
expect(mockSyncWorkspace).toHaveBeenCalledWith('/repos/test-repo', 'develop');
});

test('proceeds without throwing when syncWorkspace rejects', async () => {
Expand All @@ -970,9 +968,7 @@ describe('discoverAllWorkflows — remote sync', () => {
await expect(
handleMessage(platform, 'conv-1', 'What is the latest commit?')
).resolves.toBeUndefined();
expect(mockSyncWorkspace).toHaveBeenCalledWith('/repos/test-repo', undefined, {
resetAfterFetch: false,
});
expect(mockSyncWorkspace).toHaveBeenCalledWith('/repos/test-repo', undefined);
});

test('does not call syncWorkspace when conversation has no codebase_id', async () => {
Expand Down
80 changes: 62 additions & 18 deletions packages/core/src/orchestrator/orchestrator-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { toError } from '../utils/error';
import { getAgentProvider, getProviderCapabilities } from '@archon/providers';
import { getArchonWorkspacesPath, ensureArchonWorkspacesPath } from '@archon/paths';
import { syncArchonToWorktree } from '../utils/worktree-sync';
import { syncWorkspace, toRepoPath } from '@archon/git';
import { syncWorkspace, getCurrentBranch, toRepoPath, toBranchName } from '@archon/git';
import type { WorkspaceSyncResult } from '@archon/git';
import { discoverWorkflowsWithConfig } from '@archon/workflows/workflow-discovery';
import { findWorkflow } from '@archon/workflows/router';
Expand All @@ -42,6 +42,7 @@ import { loadConfig } from '../config/config-loader';
import type { MergedConfig } from '../config/config-types';
import { generateAndSetTitle } from '../services/title-generator';
import { validateAndResolveIsolation, dispatchBackgroundWorkflow } from './orchestrator';
import { reportUnpushedWorkInSource } from './post-message-reminder';
import { IsolationBlockedError } from '@archon/isolation';
import {
buildOrchestratorPrompt,
Expand Down Expand Up @@ -426,22 +427,20 @@ async function discoverAllWorkflows(conversation: Conversation): Promise<Discove
try {
const codebase = await codebaseDb.getCodebase(conversation.codebase_id);
if (codebase) {
// Sync canonical source with remote before the AI reads codebase state.
// Only hard-reset for Archon-managed clones (under ~/.archon/workspaces/).
// Locally-registered repos get fetch-only to avoid destroying uncommitted work.
// Refresh the canonical source clone from origin before the AI reads
// codebase state. Default mode is 'fast-forward' — never destructive:
// local commits, uncommitted modifications, and non-default branches
// are preserved (state is reported via SoftSyncResult.state for the UI).
// Non-fatal: if fetch fails (network, no remote), proceed with local state.
try {
const isManagedClone = codebase.default_cwd
.replace(/\\/g, '/')
.startsWith(getArchonWorkspacesPath().replace(/\\/g, '/'));
syncResult = await syncWorkspace(toRepoPath(codebase.default_cwd), undefined, {
resetAfterFetch: isManagedClone,
});
syncResult = await syncWorkspace(
toRepoPath(codebase.default_cwd),
codebase.default_branch ? toBranchName(codebase.default_branch) : undefined
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
getLog().debug(
{
codebaseId: codebase.id,
repoPath: codebase.default_cwd,
isManagedClone,
...syncResult,
},
'workspace.sync_completed'
Expand Down Expand Up @@ -759,18 +758,28 @@ export async function handleMessage(
);
}

// Emit workspace sync status only when something noteworthy happened
// (HEAD moved or sync failed). Skip the "up to date" case to avoid noise.
// Emit workspace sync status only when something noteworthy happened.
// Silent cases (in_sync, ahead, dirty) match the previous "no message if
// not updated" behaviour. The diverged case surfaces a state where local
// work and remote both moved \u2014 user must decide how to reconcile.
if (syncError && platform.sendStructuredEvent) {
await platform.sendStructuredEvent(conversationId, {
type: 'system',
content: 'Sync failed \u2014 using local state',
});
} else if (syncResult?.updated && platform.sendStructuredEvent) {
await platform.sendStructuredEvent(conversationId, {
type: 'system',
content: `Synced with origin/${syncResult.branch} \u2014 updated ${syncResult.previousHead} \u2192 ${syncResult.newHead}`,
});
} else if (syncResult && platform.sendStructuredEvent) {
if (syncResult.state === 'behind' && syncResult.updated) {
await platform.sendStructuredEvent(conversationId, {
type: 'system',
content: `Fast-forwarded to origin/${syncResult.branch} \u2014 ${syncResult.previousHead} \u2192 ${syncResult.newHead}`,
});
} else if (syncResult.state === 'diverged') {
await platform.sendStructuredEvent(conversationId, {
type: 'system',
content: `Local source/ has diverged from origin/${syncResult.branch} \u2014 manual merge or rebase needed`,
});
}
// in_sync, ahead, dirty: silent
}

// Build workflow context for follow-up awareness
Expand Down Expand Up @@ -904,6 +913,27 @@ export async function handleMessage(
);
}

// Post-message advisory: warn if the agent (or anyone) left unpushed work
// in source/. Non-destructive sync default preserves such work, but it is
// still local-only and at risk if a /worktree create or external git op
// runs next. Only meaningful when a codebase is attached.
//
// dispatchOrchestratorWorkflow may have just persisted codebase_id (auto-
// attach on first turn), so the in-memory `conversation` can be stale. Re-
// read by platform id when codebase_id isn't set in our snapshot.
const reminderCodebaseId =
conversation.codebase_id ??
(await db
.getConversationByPlatformId(platform.getPlatformType(), conversationId)
.then(c => c?.codebase_id ?? null)
.catch(() => null));
if (reminderCodebaseId) {
const attachedCodebase = codebases.find(c => c.id === reminderCodebaseId);
if (attachedCodebase) {
await reportUnpushedWorkInSource(platform, conversationId, attachedCodebase);
}
}

getLog().debug({ conversationId }, 'orchestrator_message_completed');
} catch (error) {
const err = toError(error);
Expand Down Expand Up @@ -1360,9 +1390,23 @@ async function handleRegisterProject(

// Use config default provider instead of hardcoding 'claude'
const config = await loadConfig();
let detectedBranch: string | undefined;
try {
const branch = await getCurrentBranch(toRepoPath(projectPath));
// Detached HEAD returns the literal string "HEAD" (not an exception) —
// treat as "not detected" so the codebase row gets NULL and syncWorkspace
// auto-detects the default branch on first sync. Mirrors the same guard
// already used in clone.ts:cloneRepository.
if (branch && branch !== 'HEAD') {
detectedBranch = branch;
}
} catch {
// non-git directory — leave undefined so DB stores NULL (auto-detect at sync)
}
const codebase = await codebaseDb.createCodebase({
name: projectName,
default_cwd: projectPath,
default_branch: detectedBranch,
ai_assistant_type: config.assistant,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

Expand Down
Loading
Loading