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
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"plugins": [
{
"name": "genie",
"version": "4.260403.2",
"version": "4.260403.3",
"source": "./plugins/genie",
"description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, wish them into plans, make with parallel agents, ship as one team. A coding genie that grows with your project."
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@automagik/genie",
"version": "4.260403.2",
"version": "4.260403.3",
"description": "Collaborative terminal toolkit for human + AI workflows",
"type": "module",
"bin": {
Expand Down
2 changes: 1 addition & 1 deletion plugins/genie/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "genie",
"version": "4.260403.2",
"version": "4.260403.3",
"description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, turn them into wishes, execute with /work, validate with /review, and ship as one team.",
"author": {
"name": "Namastex Labs"
Expand Down
2 changes: 1 addition & 1 deletion plugins/genie/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "genie-plugin",
"version": "4.260403.2",
"version": "4.260403.3",
"private": true,
"description": "Runtime dependencies for genie bundled CLIs",
"type": "module",
Expand Down
9 changes: 9 additions & 0 deletions src/genie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,15 @@ if (isTuiPane) {

// Default command: genie (no args) → thin TUI client (attach to serve).
if (args.length === 0) {
// Guard against nested tmux cascade — running `genie` inside the TUI right pane
if (process.env.TMUX?.includes('genie-tui')) {
console.log('Already inside the genie TUI. Use Ctrl-b d to detach, or run genie commands directly.');
process.exit(0);
}
if (process.env.TMUX) {
console.warn('Note: switching to genie TUI from within another tmux session.');
}

const { findWorkspace, scanAgents } = await import('./lib/workspace.js');
const ws = findWorkspace();

Expand Down
177 changes: 177 additions & 0 deletions src/lib/__tests__/zombie-spawns.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/**
* Regression tests for zombie spawning agents.
*
* Bug 1: reconcileStaleSpawns ignored agents with a non-null pane_id,
* leaving them stuck in 'spawning' forever when the pane died.
* Bug 2: Concurrency cap counted 'spawning' agents as active, blocking
* all auto-resume when zombie spawning agents accumulated.
*/

import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { Agent } from '../agent-registry.js';
import {
type LogEntry,
type SchedulerConfig,
type SchedulerDeps,
type WorkerInfo,
attemptAgentResume,
} from '../scheduler-daemon.js';

// ---------------------------------------------------------------------------
// Helpers (mirrored from resume.test.ts)
// ---------------------------------------------------------------------------

const defaultConfig: SchedulerConfig = {
maxConcurrent: 5,
pollIntervalMs: 30_000,
maxJitterMs: 30_000,
jitterThreshold: 3,
heartbeatIntervalMs: 60_000,
orphanCheckIntervalMs: 300_000,
deadHeartbeatThreshold: 2,
leaseRecoveryIntervalMs: 60_000,
};

function makeWorker(overrides: Partial<WorkerInfo> = {}): WorkerInfo {
return {
id: 'test-agent',
paneId: '%42',
state: 'error',
claudeSessionId: 'session-abc',
autoResume: true,
resumeAttempts: 0,
maxResumeAttempts: 3,
...overrides,
};
}

function createMockSql() {
const sql: any = (_strings: TemplateStringsArray, ..._values: unknown[]) => [];
sql.begin = async (fn: (tx: typeof sql) => Promise<unknown>) => fn(sql);
sql.listen = async () => {};
sql.end = async () => {};
return sql;
}

function createMockDeps(overrides: Partial<SchedulerDeps> = {}) {
const logs: LogEntry[] = [];
const agentUpdates: { id: string; updates: Partial<Agent> }[] = [];
let idCounter = 0;

const deps: SchedulerDeps = {
getConnection: async () => createMockSql(),
spawnCommand: async () => ({ pid: 12345 }),
log: (entry) => logs.push(entry),
generateId: () => `test-id-${++idCounter}`,
now: () => new Date('2026-03-20T12:00:00Z'),
sleep: async () => {},
jitter: (maxMs) => Math.floor(maxMs / 2),
isPaneAlive: async () => false,
listWorkers: async () => [],
countTmuxSessions: async () => 0,
publishEvent: async () => {},
resumeAgent: async () => true,
updateAgent: async (id, u) => {
agentUpdates.push({ id, updates: u });
},
...overrides,
};

return { deps, logs, agentUpdates };
}

// ---------------------------------------------------------------------------
// Bug 1: reconcileStaleSpawns second pass for dead panes (code review)
// ---------------------------------------------------------------------------

describe('reconcileStaleSpawns dead-pane pass', () => {
test('source includes dead-pane reconciliation logic', () => {
const source = readFileSync(join(__dirname, '..', 'agent-registry.ts'), 'utf-8');

// Second pass selects agents with non-empty pane_id still in spawning state
expect(source).toContain("AND pane_id IS NOT NULL AND pane_id != ''");

// Calls isPaneAlive to verify pane is actually dead
expect(source).toContain('isPaneAlive(row.pane_id)');

// Uses distinct audit reason for dead-pane reconciliation
expect(source).toContain('stale_spawn_dead_pane');
});

test('dead-pane pass gracefully handles tmux errors (code review)', () => {
const source = readFileSync(join(__dirname, '..', 'agent-registry.ts'), 'utf-8');

// The catch block around isPaneAlive ensures TmuxUnreachableError
// does not incorrectly mark agents as dead
expect(source).toContain('// TmuxUnreachableError or other');
});
});

// ---------------------------------------------------------------------------
// Bug 2: spawning agents should not count toward concurrency cap
// ---------------------------------------------------------------------------

describe('concurrency cap excludes spawning agents', () => {
test('spawning agents do not block resume of other agents', async () => {
// 5 zombie 'spawning' agents that previously blocked all resumes
const zombieSpawning: WorkerInfo[] = Array.from({ length: 5 }, (_, i) =>
makeWorker({ id: `zombie-${i}`, state: 'spawning' as any }),
);
Comment on lines +119 to +121

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Unnecessary as any cast.

'spawning' is already a valid AgentState value (per agent-registry.ts line 25). The type assertion is redundant.

♻️ Remove unnecessary cast
-    const zombieSpawning: WorkerInfo[] = Array.from({ length: 5 }, (_, i) =>
-      makeWorker({ id: `zombie-${i}`, state: 'spawning' as any }),
+    const zombieSpawning: WorkerInfo[] = Array.from({ length: 5 }, (_, i) =>
+      makeWorker({ id: `zombie-${i}`, state: 'spawning' }),
     );

Same applies to line 156.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/__tests__/zombie-spawns.test.ts` around lines 119 - 121, The test
uses an unnecessary type assertion: remove the "as any" from the string literal
'spawning' in the zombieSpawning array creation so it relies on the existing
AgentState type; update both occurrences (the one creating zombieSpawning and
the similar instance around line 156) where makeWorker({ id: ..., state:
'spawning' as any }) is used to simply pass state: 'spawning' to makeWorker
(WorkerInfo).

const { deps, logs } = createMockDeps({
listWorkers: async () => zombieSpawning,
resumeAgent: async () => true,
});

const agent = makeWorker({ id: 'real-agent', state: 'error' });
const result = await attemptAgentResume(deps, defaultConfig, agent);

// Before fix: result was 'skipped' with reason 'concurrency_cap'
// After fix: spawning is excluded, so activeCount = 0 and resume proceeds
expect(result).toBe('resumed');
expect(logs.some((l) => l.reason === 'concurrency_cap')).toBe(false);
});

test('actually-working agents still enforce the concurrency cap', async () => {
// 5 genuinely working agents should still block resume
const working: WorkerInfo[] = Array.from({ length: 5 }, (_, i) =>
makeWorker({ id: `worker-${i}`, state: 'working' }),
);
const { deps, logs } = createMockDeps({
listWorkers: async () => working,
});

const agent = makeWorker({ id: 'overflow-agent', state: 'error' });
const result = await attemptAgentResume(deps, defaultConfig, agent);

expect(result).toBe('skipped');
const skip = logs.find((l) => l.event === 'agent_resume_skipped');
expect(skip?.reason).toBe('concurrency_cap');
});

test('mix of spawning and working agents only counts working toward cap', async () => {
const mixed: WorkerInfo[] = [
// 3 zombie spawning (should NOT count)
...Array.from({ length: 3 }, (_, i) => makeWorker({ id: `zombie-${i}`, state: 'spawning' as any })),
// 4 actually working (should count)
...Array.from({ length: 4 }, (_, i) => makeWorker({ id: `worker-${i}`, state: 'working' })),
];
const { deps } = createMockDeps({
listWorkers: async () => mixed,
resumeAgent: async () => true,
});

const agent = makeWorker({ id: 'another-agent', state: 'error' });
const result = await attemptAgentResume(deps, defaultConfig, agent);

// activeCount = 4 (only working), which is < maxConcurrent(5), so resume proceeds
expect(result).toBe('resumed');
});

test('source code confirms spawning is in the exclusion list', () => {
const source = readFileSync(join(__dirname, '..', 'scheduler-daemon.ts'), 'utf-8');
// The filter that computes activeCount must exclude 'spawning'
expect(source).toContain("'done', 'error', 'suspended', 'spawning'");
});
});
21 changes: 18 additions & 3 deletions src/lib/agent-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,15 +272,16 @@ describe.skipIf(!DB_AVAILABLE)('pg', () => {
expect(a2!.state).toBe('error');
});

test('does not touch spawning agents with a pane', async () => {
test('resets spawning agents with a dead pane', async () => {
const oldStart = new Date(Date.now() - 5_000).toISOString();
// pane %42 does not exist in test env, so isPaneAlive returns false
await register(makeAgent({ id: 'has-pane', paneId: '%42', state: 'spawning', startedAt: oldStart }));

const reset = await reconcileStaleSpawns(2);
expect(reset).toEqual([]);
expect(reset).toEqual(['has-pane']);

const a = await get('has-pane');
expect(a!.state).toBe('spawning');
expect(a!.state).toBe('error');
});

test('does not touch recently spawning agents', async () => {
Expand Down Expand Up @@ -309,6 +310,20 @@ describe.skipIf(!DB_AVAILABLE)('pg', () => {
const reset = await reconcileStaleSpawns(2);
expect(reset).toEqual([]);
});

test('does not touch identity records with current_executor_id', async () => {
// Identity record created by findOrCreateAgent — has executor linked
const agent = await findOrCreateAgent('id-eng', 'id-team', 'engineer');
const exec = await createExecutor(agent.id, 'claude', 'tmux', { repoPath: '/tmp' });
await setCurrentExecutor(agent.id, exec.id);

// Backdate started_at so it would match the threshold
const sql = await getConnection();
await sql`UPDATE agents SET started_at = now() - interval '10 seconds' WHERE id = ${agent.id}`;

const reset = await reconcileStaleSpawns(2);
expect(reset).not.toContain(agent.id);
});
});

describe('templates', () => {
Expand Down
45 changes: 41 additions & 4 deletions src/lib/agent-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { recordAuditEvent } from './audit.js';
import { type Sql, getConnection } from './db.js';
import type { AgentIdentity, ExecutorState } from './executor-types.js';
import type { ProviderName } from './provider-adapters.js';
import { isPaneAlive } from './tmux.js';

export type AgentState = 'spawning' | 'working' | 'idle' | 'permission' | 'question' | 'done' | 'error' | 'suspended';
export type TransportType = 'tmux' | 'inline';
Expand Down Expand Up @@ -250,6 +251,7 @@ export async function reconcileStaleSpawns(thresholdSeconds = 60): Promise<strin
SET state = 'error', last_state_change = now()
WHERE state = 'spawning'
AND (pane_id IS NULL OR pane_id = '')
AND current_executor_id IS NULL
AND started_at < now() - interval '1 second' * ${thresholdSeconds}
RETURNING id
`;
Expand All @@ -260,7 +262,39 @@ export async function reconcileStaleSpawns(thresholdSeconds = 60): Promise<strin
reason: 'stale_spawn',
}).catch(() => {});
}
return rows.map((r: { id: string }) => r.id);
const resetIds = rows.map((r: { id: string }) => r.id);

// Second pass: agents stuck in 'spawning' with a pane_id that is actually dead.
// These were missed by the first pass because they have a non-empty pane_id.
const staleWithPane = await sql<{ id: string; pane_id: string }[]>`
SELECT id, pane_id FROM agents
WHERE state = 'spawning'
AND pane_id IS NOT NULL AND pane_id != ''
AND started_at < now() - interval '1 second' * ${thresholdSeconds}
`;
for (const row of staleWithPane) {
try {
const alive = await isPaneAlive(row.pane_id);
if (!alive) {
await sql`
UPDATE agents
SET state = 'error', last_state_change = now()
WHERE id = ${row.id} AND state = 'spawning'
`;
console.error(`[reconcile] Reset stuck agent ${row.id} (dead pane ${row.pane_id}) from spawning → error`);
recordAuditEvent('worker', row.id, 'state_changed', 'reconciler', {
state: 'error',
reason: 'stale_spawn_dead_pane',
}).catch(() => {});
resetIds.push(row.id);
}
} catch {
// TmuxUnreachableError or other — skip this agent, don't mark as dead
// when we can't verify pane status
}
}

return resetIds;
} catch {
return []; // Best-effort — don't block startup if DB is unavailable
}
Expand Down Expand Up @@ -502,12 +536,15 @@ export async function findOrCreateAgent(name: string, team: string, role?: strin
`;
if (existing.length > 0) return rowToAgentIdentity(existing[0]);

// Create new agent with identity columns only
// Create new agent with identity columns only.
// state = NULL: identity records track state through their current executor,
// not the legacy state column. NULL prevents reconcileStaleSpawns() from
// falsely marking identity records as 'error'.
const id = randomUUID();
const now = new Date().toISOString();
const rows = await sql<AgentIdentityRow[]>`
INSERT INTO agents (id, custom_name, team, role, started_at, created_at, updated_at)
VALUES (${id}, ${name}, ${team}, ${role ?? null}, ${now}, ${now}, ${now})
INSERT INTO agents (id, custom_name, team, role, started_at, state, created_at, updated_at)
VALUES (${id}, ${name}, ${team}, ${role ?? null}, ${now}, ${null}, ${now}, ${now})
ON CONFLICT (custom_name, team) WHERE custom_name IS NOT NULL AND team IS NOT NULL
DO UPDATE SET updated_at = now()
RETURNING id, started_at, role, custom_name, team, native_agent_id, native_color,
Expand Down
Loading
Loading