From f2a550fe102a924ebfce0fc4f076635649de8c67 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Mon, 27 Apr 2026 12:44:11 +0200 Subject: [PATCH] Expose abandoned agent worktrees for takeover Managed agent worktrees could exist without an active-session file, AGENT.lock telemetry, or GX file locks after a CLI stopped during quota or approval exhaustion. Hivemind now emits those bare worktrees as stranded managed-worktree lanes so MCP context marks them needs_attention and another agent can adopt the branch instead of missing it. Constraint: Some quota-stop paths fail before codex-guard can enqueue takeover metadata. Rejected: Rely only on stale active-session files | fully stopped sessions can leave only a managed worktree behind. Confidence: high Scope-risk: narrow Directive: Keep active sessions, AGENT.lock telemetry, and GX file locks higher precedence than the stranded fallback. Tested: pnpm --filter @colony/core test -- hivemind.test.ts Tested: pnpm --filter @colony/mcp-server test -- server.test.ts Tested: pnpm --filter @colony/core typecheck Not-tested: pnpm --filter @colony/mcp-server typecheck has existing @colony/spec strict/type declaration failures. Co-authored-by: OmX --- apps/mcp-server/src/tools/shared.ts | 1 + apps/mcp-server/test/server.test.ts | 41 ++++++++++++++++ packages/core/src/hivemind.ts | 75 ++++++++++++++++++++++++++++- packages/core/test/hivemind.test.ts | 36 ++++++++++++++ 4 files changed, 151 insertions(+), 2 deletions(-) diff --git a/apps/mcp-server/src/tools/shared.ts b/apps/mcp-server/src/tools/shared.ts index 688483d..5cee32b 100644 --- a/apps/mcp-server/src/tools/shared.ts +++ b/apps/mcp-server/src/tools/shared.ts @@ -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'; diff --git a/apps/mcp-server/test/server.test.ts b/apps/mcp-server/test/server.test.ts index b00302a..a045120 100644 --- a/apps/mcp-server/test/server.test.ts +++ b/apps/mcp-server/test/server.test.ts @@ -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; + lanes: Array>; + }; + + 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' } }); diff --git a/packages/core/src/hivemind.ts b/packages/core/src/hivemind.ts index 7f13d13..ad38a47 100644 --- a/packages/core/src/hivemind.ts +++ b/packages/core/src/hivemind.ts @@ -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; @@ -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[] { @@ -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, @@ -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 { diff --git a/packages/core/test/hivemind.test.ts b/packages/core/test/hivemind.test.ts index f55ded7..1244189 100644 --- a/packages/core/test/hivemind.test.ts +++ b/packages/core/test/hivemind.test.ts @@ -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'); + }); });