Skip to content
Merged
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
19 changes: 17 additions & 2 deletions packages/server/src/agents/AcpAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { spawn as cpSpawn, type ChildProcess } from 'node:child_process';
import { Readable, Writable } from 'node:stream';
import { randomUUID } from 'node:crypto';
import { dirname, resolve, join } from 'node:path';

Check warning on line 4 in packages/server/src/agents/AcpAdapter.ts

View workflow job for this annotation

GitHub Actions / lint

'join' is defined but never used. Allowed unused vars must match /^_/u
import { fileURLToPath } from 'node:url';
import { existsSync } from 'node:fs';

Expand Down Expand Up @@ -233,7 +233,7 @@
session.onOutputChunk?.(update);
self.onAnySessionOutput?.(session.agentId, update);
if (self.onToolCall) {
let toolName = (update as any).title ?? (update as any).name ?? (update as any).toolName ?? 'unknown';

Check warning on line 236 in packages/server/src/agents/AcpAdapter.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type

Check warning on line 236 in packages/server/src/agents/AcpAdapter.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type

Check failure on line 236 in packages/server/src/agents/AcpAdapter.ts

View workflow job for this annotation

GitHub Actions / lint

'toolName' is never reassigned. Use 'const' instead
self.onToolCall(session.agentId, { toolName, status: 'completed' });
}
break;
Expand Down Expand Up @@ -600,8 +600,23 @@

// Set model if specified and different from default
if (session.model && result.models?.currentModelId !== session.model) {
// Use configOptions for model setting
const modelConfigOption = result.configOptions?.find(
let applied = false;
// Prefer the standard session model API when the runtime advertises
// the requested model — configOptions alone silently drops the
// selection on runtimes that don't expose a 'model' config option
if (result.models?.availableModels?.some((m: { modelId: string }) => m.modelId === session.model)) {
try {
await session.connection.unstable_setSessionModel({
sessionId: result.sessionId,
modelId: session.model,
});
applied = true;
} catch {
// Fall through to configOptions
}
}
// Fallback: use configOptions for model setting
const modelConfigOption = applied ? undefined : result.configOptions?.find(
(opt: { id: string }) => opt.id === 'model'
);
if (modelConfigOption) {
Expand Down
56 changes: 47 additions & 9 deletions packages/server/src/agents/AgentManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,10 @@ export class AgentManager {
/** If set, only these runtimes are allowed for this project. */
allowedRuntimes: string[] | null = null;

/** Project working directory (config cwd or internal storage). Used as the
* fallback cwd for restarts and model-config resolution. */
projectCwd: string | null = null;

/** Callback fired when a DM message is stored (outgoing steer or agent response) */
onDmMessage: ((projectName: string, message: any) => void) | null = null;

Expand Down Expand Up @@ -297,7 +301,11 @@ export class AgentManager {
let resolvedRuntime = opts.runtime;
try {
const { ModelConfig } = await import('./ModelConfig.js');
const mc = new ModelConfig(opts.cwd);
const { FD_HOME } = await import('../cli/constants.js');
// opts.cwd may be absent (e.g. orchestrator auto-spawn) — fall back to
// the project cwd, then internal storage, so role model config is still resolved
const configDir = opts.cwd ?? this.projectCwd ?? join(FD_HOME, 'projects', opts.projectName ?? this.projectName);
const mc = new ModelConfig(configDir);
const enabledModels = mc.getRoleEnabledModelsWithDiscovery(opts.role);
const disabledRts: string[] = (opts as any).disabledRuntimes ?? [];
const activeModels = enabledModels.filter(m => m.enabled && !disabledRts.includes(m.runtime));
Expand Down Expand Up @@ -349,7 +357,7 @@ export class AgentManager {
// 6. Spawn via adapter
// For Claude Code runtime, inject role instructions via _meta.systemPrompt (append mode)
// This provides stronger guidance than AGENTS.md alone
const isClaudeCode = opts.runtime === 'claude' || opts.runtime === 'claude-agent';
const isClaudeCode = resolvedRuntime === 'claude' || resolvedRuntime === 'claude-agent';
try {
const meta = await this.adapter.spawn({
agentId: newId,
Expand All @@ -369,6 +377,11 @@ export class AgentManager {
this.store.updateAgentStatus(newId, 'idle');
if (displayModel) this.store.updateAgentModel(newId, displayModel);
else if (meta.model) this.store.updateAgentModel(newId, meta.model);
// Persist the resolved runtime when it was auto-resolved (not passed in)
if (resolvedRuntime && resolvedRuntime !== opts.runtime) {
this.store.updateAgentRuntimeName(newId, resolvedRuntime);
agent.runtimeName = resolvedRuntime;
}
agent.acpSessionId = meta.sessionId;
agent.status = 'idle';

Expand Down Expand Up @@ -538,14 +551,34 @@ export class AgentManager {
this.audit(agentId, agent.role, 'agent:steer', `Steered ${agent.role}`, { messageLength: message.length });
}

async setAgentModel(agentId: AgentId, model: string): Promise<void> {
async setAgentModel(agentId: AgentId, model: string, runtime?: string): Promise<void> {
const agent = this.store.getAgent(agentId);
if (!agent) throw new Error(`Agent not found: ${agentId}`);
// Persist model to DB
this.store.updateAgentModel(agentId, model);
// Also update live session if available

// The model may belong to a different runtime than the agent currently
// runs on (the UI lists models across all runtimes). Resolve the owning
// runtime so restarts route to the right provider.
let targetRuntime = runtime;
if (!targetRuntime) {
try {
const { modelRegistry } = await import('./ModelRegistry.js');
const current = agent.runtimeName ?? undefined;
const owners = modelRegistry.getRuntimes().filter(rt =>
modelRegistry.getModels(rt).some(m => m.modelId === model));
// Prefer the agent's current runtime when it also offers the model
targetRuntime = (current && owners.includes(current)) ? current : owners[0];
} catch { /* registry unavailable — keep current runtime */ }
}
const runtimeChanged = !!targetRuntime && targetRuntime !== (agent.runtimeName ?? undefined);
if (runtimeChanged) this.store.updateAgentRuntimeName(agentId, targetRuntime!);

// Update the live session only when the model belongs to the current
// runtime — a foreign model ID can't be applied to a running session
// and takes effect on the next restart instead.
const sessionId = this.agentToSession.get(agentId) ?? agent.acpSessionId;
if (sessionId && typeof (this.adapter as any).setModel === 'function') {
if (sessionId && !runtimeChanged && typeof (this.adapter as any).setModel === 'function') {
try {
await (this.adapter as any).setModel(sessionId, model);
} catch { /* live session may not support it — that's OK, persisted for next spawn */ }
Expand All @@ -566,22 +599,27 @@ export class AgentManager {
}
this.agentToSession.delete(agentId);

// Re-spawn with same role/config
// Re-spawn with same role/config in the project cwd (not the daemon's
// working directory, which differs from where the agent originally ran)
const restartCwd = this.projectCwd ?? process.cwd();
const role = this.roleRegistry.get(agent.role);
const systemPrompt = buildSystemPrompt({
roleName: role?.name ?? agent.role,
roleInstructions: role?.instructions ?? `You are a ${agent.role} agent.`,
agentId,
projectName: this.projectName,
permissions: role?.permissions ?? {},
cwd: process.cwd(),
cwd: restartCwd,
});

const meta = await this.adapter.spawn({
agentId,
role: agent.role,
cwd: process.cwd(),
model: undefined,
cwd: restartCwd,
// Respawn with the agent's persisted model/runtime — otherwise the
// restart silently lands on the adapter's default provider
model: agent.model ?? undefined,
runtime: agent.runtimeName ?? undefined,
systemPrompt,
});

Expand Down
5 changes: 4 additions & 1 deletion packages/server/src/api/HttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,10 @@ export function createHttpServer(deps: HttpServerDeps): Server {
let mc = modelCfgCache.get(projName);
if (!mc) {
const { ModelConfig: MC } = await import('../agents/ModelConfig.js');
mc = new MC(fd.project.subpath('.'));
// Must match the directory agent spawn paths read from (project cwd,
// falling back to internal storage) — otherwise UI model selections
// are written to a config.yaml that spawn never reads.
mc = new MC(fd.status().config.cwd ?? fd.project.subpath('.'));
modelCfgCache.set(projName, mc);
}
return mc;
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/api/routes/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export async function handleAgentRoutes(
try {
const body = await readBody();
if (!body.model) { json(400, { error: 'Missing required field: model' }); return true; }
await am.setAgentModel(agentId as import('@flightdeck-ai/shared').AgentId, body.model);
await am.setAgentModel(agentId as import('@flightdeck-ai/shared').AgentId, body.model, body.runtime);
json(200, { success: true });
} catch (e: unknown) {
json(500, { error: `Failed to set agent model: ${e instanceof Error ? e.message : String(e)}` });
Expand Down
11 changes: 7 additions & 4 deletions packages/server/src/cli/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,8 +441,10 @@ export async function startGateway(deps: GatewayDeps): Promise<void> {
}
}

// Read per-role runtime config from .flightdeck/config.yaml in project cwd
const projectCwd = fd.status().config.cwd ?? process.cwd();
// Read per-role runtime config from .flightdeck/config.yaml in project cwd,
// falling back to internal project storage (same resolution as the web API
// writes — never process.cwd(), which depends on where the server started).
const projectCwd = fd.status().config.cwd ?? fd.project.subpath('.');
const { ModelConfig } = await import('../agents/ModelConfig.js');
const modelConfig = new ModelConfig(projectCwd);
const leadRoleConfig = modelConfig.getRoleConfig('lead');
Expand Down Expand Up @@ -591,8 +593,9 @@ export async function startGateway(deps: GatewayDeps): Promise<void> {
const profile = fd.status().config.governance;
console.error(`\n── Hot-register project: ${name} (profile: ${profile}) ──`);

// ModelConfig — read from project cwd, not internal storage
const projectCwd = fd.status().config.cwd ?? process.cwd();
// ModelConfig — read from project cwd, falling back to internal storage
// (same resolution as the web API writes — never process.cwd())
const projectCwd = fd.status().config.cwd ?? fd.project.subpath('.');
const { ModelConfig } = await import('../agents/ModelConfig.js');
const modelConfig = new ModelConfig(projectCwd);
const leadRoleConfig = modelConfig.getRoleConfig('lead');
Expand Down
3 changes: 3 additions & 0 deletions packages/server/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ export const agents = sqliteTable('agents', {
currentSpecId: text('current_spec_id'),
costAccumulated: real('cost_accumulated').notNull().default(0),
lastHeartbeat: text('last_heartbeat'),
model: text('model'),
contextWindowTokens: integer('context_window_tokens'),
contextWindowLimit: integer('context_window_limit'),
}, (table) => [
index('idx_agents_status').on(table.status),
index('idx_agents_role').on(table.role),
Expand Down
7 changes: 6 additions & 1 deletion packages/server/src/facade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ export class Flightdeck {
this.sqlite = new SqliteStore(this.project.subpath('state.sqlite'));
this.specs = new SpecStore(this.project.subpath('specs'));
this.decisions = new DecisionLog(this.project.subpath('decisions'));
this.memory = new MemoryStore(this.project.subpath('memory'), this.project.subpath('state.sqlite'));
// Share the SqliteStore connection — a second handle to state.sqlite
// keeps the file locked on Windows after close() (EBUSY on cleanup)
this.memory = new MemoryStore(this.project.subpath('memory'), this.sqlite.rawClient);

this.reports = new ReportStore(this.project.subpath('reports'));
this.dag = new TaskDAG(this.sqlite);
Expand Down Expand Up @@ -87,6 +89,8 @@ export class Flightdeck {
if (projectConfig.allowedRuntimes && projectConfig.allowedRuntimes.length > 0) {
this.agentManager.allowedRuntimes = projectConfig.allowedRuntimes;
}
// Project cwd for restarts and model-config resolution
this.agentManager.projectCwd = projectConfig.cwd ?? this.project.subpath('.');
this.messages = new MessageStore(this.sqlite.db);
this.agentManager.setMessageStore(this.messages);

Expand Down Expand Up @@ -348,6 +352,7 @@ export class Flightdeck {
close(): void {
this.orchestrator.stop();
this.timers.clearAll();
this.memory.close();
this.sqlite.close();
}
}
4 changes: 4 additions & 0 deletions packages/server/src/orchestrator/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,10 @@ Continuation behavior:
model: task.model,
runtime: task.runtime,
task: task.id as string,
cwd: this.config.cwd,
// Unattended spawn: fall back to the role's configured default
// model when the task doesn't pin one, instead of erroring
autoResolve: true,
};
this.agentManager.spawnAgent(spawnOpts as any).then(agent => {
log('Orchestrator', `Auto-spawned agent ${agent.id} for task "${truncate(task.title, 50)}"`);
Expand Down
11 changes: 11 additions & 0 deletions packages/server/src/storage/MemoryStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface MemorySearchResult {

export class MemoryStore {
private db: DatabaseInstance | null = null;
/** True when this store opened its own connection (vs. one passed in). */
private ownsDb = false;

constructor(
private memoryDir: string,
Expand All @@ -28,6 +30,7 @@ export class MemoryStore {
const BetterSqlite3 = require('better-sqlite3') as any;
this.db = new BetterSqlite3(dbPathOrDb) as DatabaseInstance;
this.db!.pragma('journal_mode = WAL');
this.ownsDb = true;
} else {
this.db = dbPathOrDb;
}
Expand All @@ -36,6 +39,14 @@ export class MemoryStore {
}
}

/** Close the FTS connection if this store owns it (no-op for shared connections). */
close(): void {
if (this.ownsDb && this.db) {
try { this.db.close(); } catch { /* already closed */ }
}
this.db = null;
}

private initFts(): void {
if (!this.db) return;
this.db.exec(`
Expand Down
18 changes: 15 additions & 3 deletions packages/server/src/storage/SqliteStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,13 @@ export class SqliteStore extends EventEmitter {
this._db.run(sql`UPDATE agents SET model = ${model} WHERE id = ${agentId}`);
}

updateAgentRuntimeName(agentId: AgentId, runtimeName: string): void {
this._db.update(agents)
.set({ runtimeName })
.where(eq(agents.id, agentId))
.run();
}

updateTaskDescription(taskId: TaskId, description: string): void {
this._db.update(tasks)
.set({ description, updatedAt: new Date().toISOString() })
Expand Down Expand Up @@ -513,7 +520,7 @@ export class SqliteStore extends EventEmitter {
currentSpecId: (row.currentSpecId ?? null) as SpecId | null,
costAccumulated: row.costAccumulated,
lastHeartbeat: (row.lastHeartbeat ?? null) as string | null,
model: (row as any).model ?? undefined,
model: row.model ?? undefined,
};
}

Expand Down Expand Up @@ -543,9 +550,14 @@ export class SqliteStore extends EventEmitter {
return row?.total ?? 0;
}

close(): void {
/** The underlying better-sqlite3 connection (for stores sharing this DB file). */
get rawClient(): import('better-sqlite3').Database {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accessing internal drizzle client property
(this._db as any).$client.close();
return (this._db as any).$client;
}

close(): void {
this.rawClient.close();
}

// ── Spec Hashes (FR-008) ──
Expand Down
41 changes: 41 additions & 0 deletions packages/server/tests/agents/agent-manager.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

Check failure on line 1 in packages/server/tests/agents/agent-manager.test.ts

View workflow job for this annotation

GitHub Actions / lint

'vi' is defined but never used
import { existsSync, rmSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { homedir, tmpdir } from 'node:os';

Check failure on line 4 in packages/server/tests/agents/agent-manager.test.ts

View workflow job for this annotation

GitHub Actions / lint

'tmpdir' is defined but never used
Expand All @@ -7,7 +7,7 @@
import { RoleRegistry } from '../../src/roles/RoleRegistry.js';
import { MessageStore } from '../../src/comms/MessageStore.js';
import type { AgentAdapter, SpawnOptions, SteerMessage, AgentMetadata } from '../../src/agents/AgentAdapter.js';
import type { AgentId, AgentRuntime, MessageId } from '@flightdeck-ai/shared';

Check failure on line 10 in packages/server/tests/agents/agent-manager.test.ts

View workflow job for this annotation

GitHub Actions / lint

'MessageId' is defined but never used

/** Minimal mock adapter for testing */
class MockAdapter implements AgentAdapter {
Expand Down Expand Up @@ -128,6 +128,47 @@
expect(restarted.acpSessionId).toBe('mock-session-2');
});

it('restartAgent re-spawns with the persisted model and runtime', async () => {
// Regression: restart used to pass model: undefined and no runtime,
// silently re-routing the agent to the adapter's default provider.
const agent = await manager.spawnAgent({ role: 'worker', cwd: '/tmp', autoResolve: true, model: 'gpt-4', runtime: 'codex' });
await manager.restartAgent(agent.id);

expect(adapter.spawnCalls).toHaveLength(2);
expect(adapter.spawnCalls[1].model).toBe('gpt-4');
expect(adapter.spawnCalls[1].runtime).toBe('codex');
});

it('restartAgent re-spawns in the project cwd, not the daemon cwd', async () => {
manager.projectCwd = '/tmp/my-project';
const agent = await manager.spawnAgent({ role: 'worker', cwd: '/tmp/my-project', autoResolve: true });
await manager.restartAgent(agent.id);

expect(adapter.spawnCalls[1].cwd).toBe('/tmp/my-project');
});

it('setAgentModel persists the model so it survives reads', async () => {
const agent = await manager.spawnAgent({ role: 'worker', cwd: '/tmp', autoResolve: true });
await manager.setAgentModel(agent.id, 'claude-sonnet-4.6');
expect(store.getAgent(agent.id)!.model).toBe('claude-sonnet-4.6');
});

it('setAgentModel with explicit runtime updates the agent runtime for respawn routing', async () => {
const agent = await manager.spawnAgent({ role: 'worker', cwd: '/tmp', autoResolve: true, runtime: 'codex' });
await manager.setAgentModel(agent.id, 'claude-sonnet-4.6', 'claude');
const updated = store.getAgent(agent.id)!;
expect(updated.model).toBe('claude-sonnet-4.6');
expect(updated.runtimeName).toBe('claude');
});

it('spawnAgent without cwd still resolves and does not crash model resolution', async () => {
// Regression: orchestrator auto-spawn passes no cwd; new ModelConfig(undefined)
// threw and silently skipped runtime/model resolution.
const agent = await manager.spawnAgent({ role: 'worker', autoResolve: true } as any);
expect(agent.status).toBe('idle');
expect(adapter.spawnCalls).toHaveLength(1);
});

it('terminateAgent throws for unknown agent', async () => {
await expect(manager.terminateAgent('nonexistent' as AgentId))
.rejects.toThrow('Agent not found');
Expand Down Expand Up @@ -219,7 +260,7 @@

// Spawn a NEW agent (same role) — it gets a DIFFERENT ID
// so DMs to old agent won't be delivered to new agent (correct behavior)
const newAgent = await manager.spawnAgent({ role: 'worker', cwd: '/tmp', autoResolve: true });

Check failure on line 263 in packages/server/tests/agents/agent-manager.test.ts

View workflow job for this annotation

GitHub Actions / lint

'newAgent' is assigned a value but never used
await new Promise(r => setTimeout(r, 50));

// New agent should NOT receive old agent's DMs
Expand Down
4 changes: 2 additions & 2 deletions packages/server/tests/agents/worktree.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync, existsSync, writeFileSync } from 'node:fs';
import { mkdtempSync, rmSync, existsSync, writeFileSync, realpathSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { WorktreeManager } from '../../src/agents/WorktreeManager.js';
import { detectFileConflicts } from '../../src/agents/fileConflicts.js';

function createTempGitRepo(): string {
const dir = mkdtempSync(join(tmpdir(), 'fd-wt-test-'));
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'fd-wt-test-')));
execFileSync('git', ['init', '-b', 'main'], { cwd: dir });
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir });
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir });
Expand Down
9 changes: 9 additions & 0 deletions packages/server/tests/core/ids.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ describe('ID Generation', () => {
expect(m1).not.toBe(m2);
});

it('never collides for agents spawned in a tight loop (same role, same timestamp)', () => {
// Guards the entropy in agentId — coarse Date.now() resolution (notably
// on Windows) must not produce duplicate agents.id values.
const now = Date.now().toString();
const ids = new Set<string>();
for (let i = 0; i < 5000; i++) ids.add(agentId('worker', now));
expect(ids.size).toBe(5000);
});

it('generates different IDs for different inputs', () => {
expect(taskId('hello')).not.toBe(taskId('world'));
});
Expand Down
Loading
Loading