Skip to content

feat: multi-agent orchestration with locking and streaming output - #25

Open
dukex wants to merge 3 commits into
mainfrom
feat/multi-agent-orchestration
Open

feat: multi-agent orchestration with locking and streaming output#25
dukex wants to merge 3 commits into
mainfrom
feat/multi-agent-orchestration

Conversation

@dukex

@dukex dukex commented Apr 4, 2026

Copy link
Copy Markdown
Member

Add support for running multiple concurrent agents with file-based locking
to prevent duplicate issue processing. Each agent's output is prefixed with
[issueKey] for real-time visibility into what each agent is doing.

  • File-based lock mechanism in .crewbit.locks/ with acquire/release/isLocked
  • resolveNextActions() returns up to N issues respecting locks
  • daemon.maxConcurrent config option (default 1 for backward compat)
  • Session pool with graceful shutdown on SIGINT/SIGTERM
  • Streaming output multiplexed with [issueKey] prefix per line

Copilot AI review requested due to automatic review settings April 4, 2026 10:39
Comment thread src/lock.ts
import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";

export const LOCK_DIR = ".crewbit.locks";

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Suggested change
export const LOCK_DIR = ".crewbit.locks";
export const LOCK_DIR = ".crewbit/locks";

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds multi-agent orchestration to the crewbit daemon by introducing file-based issue locking, allowing multiple concurrent sessions, and adding a multi-issue resolver to fill available concurrency slots.

Changes:

  • Introduces .crewbit.locks/ file locks and helper APIs to prevent duplicate issue processing.
  • Adds resolveNextActions() to fetch up to N runnable issues while skipping locked ones.
  • Updates daemon.ts to run multiple sessions concurrently with polling/backoff and signal-based graceful shutdown.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
src/workflow.ts Refactors action building and adds resolveNextActions() for multi-issue selection.
src/workflow.test.ts Adds unit tests covering multi-issue selection, limits, locks, and prompt formatting.
src/types.ts Extends WorkflowConfig.daemon with maxConcurrent.
src/lock.ts Adds lock acquire/release/isLocked/list helpers using .crewbit.locks.
src/lock.test.ts Adds tests for lock lifecycle and lock listing.
daemon.ts Adds session pool orchestration, lock integration, and shutdown handling.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/lock.ts
Comment on lines +8 to +15
if (existsSync(lockPath)) return false;
if (!existsSync(lockDir)) mkdirSync(lockDir, { recursive: true });
const metadata = JSON.stringify({
pid: process.pid,
timestamp: new Date().toISOString(),
});
writeFileSync(lockPath, metadata);
return true;

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

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

acquireLock is not atomic: checking existsSync() then writeFileSync() can race across concurrent processes, allowing two daemons to both “acquire” the same lock. Use an atomic create (e.g., writeFileSync with flag 'wx' and handle EEXIST, or open with 'wx') so only one process can create the lock file.

Suggested change
if (existsSync(lockPath)) return false;
if (!existsSync(lockDir)) mkdirSync(lockDir, { recursive: true });
const metadata = JSON.stringify({
pid: process.pid,
timestamp: new Date().toISOString(),
});
writeFileSync(lockPath, metadata);
return true;
if (!existsSync(lockDir)) mkdirSync(lockDir, { recursive: true });
const metadata = JSON.stringify({
pid: process.pid,
timestamp: new Date().toISOString(),
});
try {
writeFileSync(lockPath, metadata, { flag: "wx" });
return true;
} catch (error: unknown) {
if (
typeof error === "object" &&
error !== null &&
"code" in error &&
error.code === "EEXIST"
) {
return false;
}
throw error;
}

Copilot uses AI. Check for mistakes.
Comment thread src/lock.ts
Comment on lines +6 to +9
export function acquireLock(lockDir: string, issueKey: string): boolean {
const lockPath = join(lockDir, `${issueKey}.lock`);
if (existsSync(lockPath)) return false;
if (!existsSync(lockDir)) mkdirSync(lockDir, { recursive: true });

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

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

Lock filenames are derived directly from issueKey. Some providers use keys containing '/' (e.g. "owner/repo#123"), which will create subpaths and can throw ENOENT (and also enables path traversal if issueKey is ever untrusted). Encode/sanitize issueKey (e.g., replace non-file-safe chars or base64url) before using it in the lock filename, and decode in getLockedKeys().

Copilot uses AI. Check for mistakes.
Comment thread src/lock.ts
Comment on lines +10 to +15
const metadata = JSON.stringify({
pid: process.pid,
timestamp: new Date().toISOString(),
});
writeFileSync(lockPath, metadata);
return true;

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

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

Lock metadata (pid, timestamp) is written but never used. If the daemon crashes, stale lock files will block issues indefinitely. Consider validating/removing stale locks (e.g., check whether pid is still alive and/or enforce a TTL) when acquiring/reading locks.

Copilot uses AI. Check for mistakes.
Comment thread src/lock.test.ts Outdated
Comment thread src/lock.test.ts Outdated
Comment thread src/types.ts Outdated
Comment thread src/commands/daemon.ts Outdated
Comment on lines 88 to 103
const sessions = new Map<string, ActiveSession>();

process.on("SIGINT", () => {
log("Stopped.");
process.exit(0);
log("Stopping... waiting for in-flight sessions to finish.");
Promise.allSettled([...sessions.values()].map((session) => session.promise)).then(() => {
log("Stopped.");
process.exit(0);
});
});
process.on("SIGTERM", () => {
log("Stopped.");
process.exit(0);
log("Stopping... waiting for in-flight sessions to finish.");
Promise.allSettled([...sessions.values()].map((session) => session.promise)).then(() => {
log("Stopped.");
process.exit(0);
});
});

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

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

SIGINT/SIGTERM handlers wait for the current sessions promises, but the main loop keeps running and can start new sessions after shutdown begins. Also, sessions started after the handler captures sessions.values() won’t be awaited, and process.exit(0) can terminate them abruptly (leaving locks behind). Add a shutdown flag to stop scheduling new work, and ensure the awaited set includes all in-flight sessions until exit.

Copilot uses AI. Check for mistakes.
Comment thread src/commands/daemon.ts Outdated
Comment on lines +60 to +66
const logPrefix = `[${action.issueKey}]`;
const prefixedLog = (message: string) => {
log(`${logPrefix} ${message}`);
};
const runner = createRunner(config, REPO_ROOT, prefixedLog);
try {
return await runner.run(action, config, dryRun);

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

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

The daemon prefixes messages sent through log(), but ClaudeCodeRunner streams child stdout/stderr directly to process.stdout/process.stderr (unprefixed). With multiple concurrent sessions, output will interleave without the promised per-line [issueKey] prefix. To deliver the stated behavior, route runner stdout/stderr through a callback that prefixes each line (or have the daemon own stream multiplexing).

Copilot uses AI. Check for mistakes.
Comment thread src/commands/daemon.ts
Comment on lines +81 to 84
let emptyBackoffExp = 1;
let failureBackoffExp = 1;
const lockDir = join(REPO_ROOT, LOCK_DIR);

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

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

The lock directory is created under the repo root (.crewbit.locks). Without a corresponding .gitignore entry, these lock files will appear as untracked changes and can be accidentally committed. Consider ignoring the lock dir (and any test lock dirs) in .gitignore.

Copilot uses AI. Check for mistakes.
Add support for running multiple concurrent agents with file-based locking
to prevent duplicate issue processing. Each agent's output is prefixed with
[issueKey] for real-time visibility into what each agent is doing.

- File-based lock mechanism in .crewbit.locks/ with acquire/release/isLocked
- resolveNextActions() returns up to N issues respecting locks
- daemon.maxConcurrent config option (default 1 for backward compat)
- Session pool with graceful shutdown on SIGINT/SIGTERM
- Streaming output multiplexed with [issueKey] prefix per line

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@dukex
dukex force-pushed the feat/multi-agent-orchestration branch from e10ce20 to c3afb0f Compare April 10, 2026 00:56
dukex and others added 2 commits April 9, 2026 21:59
Extract reapFinishedSessions() and startNewSessions() to bring
the main loop cognitive complexity below the allowed threshold.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants