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
1 change: 1 addition & 0 deletions apps/mcp-server/src/tools/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ function toContextLane(session: HivemindSession): HivemindContextLane {
}

function laneRisk(session: HivemindSession): string {
if (session.source === 'managed-worktree') return 'stranded lane';
if (session.activity === 'dead') return 'dead session';
if (session.activity === 'stalled') return 'stale telemetry';
if (session.activity === 'unknown') return 'unknown runtime state';
Expand Down
41 changes: 41 additions & 0 deletions apps/mcp-server/test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,47 @@ describe('MCP server', () => {
});
});

it('hivemind_context marks bare managed worktrees as stranded lanes', async () => {
const repoRoot = join(dir, 'repo-stranded');
const worktreePath = join(
repoRoot,
'.omx',
'agent-worktrees',
'recodee__codex__create-public-terms-page-2026-04-27-12-13',
);
mkdirSync(join(worktreePath, '.git'), { recursive: true });
writeFileSync(
join(worktreePath, '.git', 'HEAD'),
'ref: refs/heads/agent/codex/create-public-terms-page-2026-04-27-12-13\n',
'utf8',
);

const res = await client.callTool({
name: 'hivemind_context',
arguments: { repo_root: repoRoot, limit: 5 },
});
const text = (res.content as Array<{ type: string; text: string }>)[0]?.text ?? '{}';
const payload = JSON.parse(text) as {
summary: { lane_count: number; needs_attention_count: number; next_action: string };
counts: Record<string, number>;
lanes: Array<Record<string, unknown>>;
};

expect(payload.summary.lane_count).toBe(1);
expect(payload.summary.needs_attention_count).toBe(1);
expect(payload.summary.next_action).toMatch(/needs_attention/);
expect(payload.counts.stalled).toBe(1);
expect(payload.lanes[0]).toMatchObject({
branch: 'agent/codex/create-public-terms-page-2026-04-27-12-13',
task: 'Stranded lane: create-public-terms-page-2026-04-27-12-13',
owner: 'codex/codex',
source: 'managed-worktree',
activity: 'stalled',
risk: 'stranded lane',
needs_attention: true,
});
});

it('search returns compact hits (id, snippet, score, ts)', async () => {
await seed();
const res = await client.callTool({ name: 'search', arguments: { query: 'cargo' } });
Expand Down
75 changes: 73 additions & 2 deletions packages/core/src/hivemind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export interface HivemindOptions {

export interface HivemindSession {
repo_root: string;
source: 'active-session' | 'worktree-lock' | 'file-lock';
source: 'active-session' | 'worktree-lock' | 'file-lock' | 'managed-worktree';
branch: string;
task: string;
task_name: string;
Expand Down Expand Up @@ -131,7 +131,15 @@ function readRepoSessions(repoRoot: string, now: number): HivemindSession[] {
(session) => !activeWorktrees.has(resolve(session.worktree_path)),
);

return mergeFileLockSessions(repoRoot, [...activeSessions, ...lockSessions], now);
const mergedSessions = mergeFileLockSessions(repoRoot, [...activeSessions, ...lockSessions], now);
const coveredWorktrees = new Set(
mergedSessions.map((session) => resolve(session.worktree_path)).filter(Boolean),
);
const strandedSessions = readManagedWorktreeSessions(repoRoot, now).filter(
(session) => !coveredWorktrees.has(resolve(session.worktree_path)),
);

return [...mergedSessions, ...strandedSessions];
}

function readActiveSessionFiles(repoRoot: string, now: number): HivemindSession[] {
Expand Down Expand Up @@ -421,6 +429,56 @@ function buildFileLockSession(
};
}

function readManagedWorktreeSessions(repoRoot: string, now: number): HivemindSession[] {
const sessions: HivemindSession[] = [];
for (const relativeRoot of MANAGED_WORKTREE_ROOTS) {
const managedRoot = join(repoRoot, relativeRoot);
if (!existsSync(managedRoot)) continue;

for (const entry of safeReadDir(managedRoot)) {
if (!entry.isDirectory()) continue;
const worktreePath = join(managedRoot, entry.name);
const branch = readWorktreeBranch(worktreePath);
if (!branch.startsWith('agent/')) continue;

const updatedAt = readWorktreeUpdatedAt(worktreePath);
const startedAt = updatedAt || new Date(now).toISOString();
const taskName = taskNameFromBranch(branch) || entry.name;
sessions.push({
repo_root: resolve(repoRoot),
source: 'managed-worktree',
branch,
task: `Stranded lane: ${taskName}`,
task_name: taskName,
latest_task_preview: '',
agent: deriveAgentName(branch),
cli: branch.startsWith('agent/claude/') ? 'claude-code' : deriveAgentName(branch),
state: '',
activity: 'stalled',
activity_summary:
'Stranded managed worktree; no active session, AGENT.lock, or GX file locks found.',
worktree_path: resolve(worktreePath),
pid: null,
pid_alive: null,
started_at: startedAt,
last_heartbeat_at: '',
updated_at: updatedAt,
elapsed_seconds: elapsedSeconds(startedAt, now),
task_mode: '',
openspec_tier: '',
routing_reason: 'stranded managed worktree',
snapshot_name: '',
project_name: '',
session_key: '',
locked_file_count: 0,
locked_file_preview: [],
file_path: worktreePath,
});
}
}
return sessions;
}

function resolveFileLockWorktreePath(
repoRoot: string,
branch: string,
Expand Down Expand Up @@ -637,6 +695,19 @@ function readWorktreeBranch(worktreePath: string): string {
}
}

function readWorktreeUpdatedAt(worktreePath: string): string {
try {
return statSync(worktreePath).mtime.toISOString();
} catch {
return '';
}
}

function taskNameFromBranch(branch: string): string {
const parts = branch.split('/').filter(Boolean);
return parts.length >= 3 ? parts.slice(2).join('/') : branch;
}

function resolveGitDir(worktreePath: string): string {
const dotGitPath = join(worktreePath, '.git');
try {
Expand Down
36 changes: 36 additions & 0 deletions packages/core/test/hivemind.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,40 @@ describe('readHivemind', () => {
cli: 'codex',
});
});

it('surfaces bare managed worktrees as stranded lanes', () => {
dir = mkdtempSync(join(tmpdir(), 'colony-hivemind-'));
const repoRoot = join(dir, 'repo');
const worktreePath = join(
repoRoot,
'.omx',
'agent-worktrees',
'recodee__codex__create-public-terms-page-2026-04-27-12-13',
);
mkdirSync(join(worktreePath, '.git'), { recursive: true });
writeFileSync(
join(worktreePath, '.git', 'HEAD'),
'ref: refs/heads/agent/codex/create-public-terms-page-2026-04-27-12-13\n',
'utf8',
);

const snapshot = readHivemind({
repoRoot,
now: Date.parse('2026-04-27T12:30:00.000Z'),
});

expect(snapshot.session_count).toBe(1);
expect(snapshot.counts.stalled).toBe(1);
expect(snapshot.sessions[0]).toMatchObject({
branch: 'agent/codex/create-public-terms-page-2026-04-27-12-13',
task: 'Stranded lane: create-public-terms-page-2026-04-27-12-13',
task_name: 'create-public-terms-page-2026-04-27-12-13',
agent: 'codex',
cli: 'codex',
source: 'managed-worktree',
activity: 'stalled',
routing_reason: 'stranded managed worktree',
});
expect(snapshot.sessions[0]?.activity_summary).toContain('Stranded managed worktree');
});
});
Loading