feat: multi-agent orchestration with locking and streaming output - #25
feat: multi-agent orchestration with locking and streaming output#25dukex wants to merge 3 commits into
Conversation
| import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs"; | ||
| import { join } from "node:path"; | ||
|
|
||
| export const LOCK_DIR = ".crewbit.locks"; |
There was a problem hiding this comment.
| export const LOCK_DIR = ".crewbit.locks"; | |
| export const LOCK_DIR = ".crewbit/locks"; |
There was a problem hiding this comment.
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.tsto 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.
| 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; |
There was a problem hiding this comment.
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.
| 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; | |
| } |
| 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 }); |
There was a problem hiding this comment.
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().
| const metadata = JSON.stringify({ | ||
| pid: process.pid, | ||
| timestamp: new Date().toISOString(), | ||
| }); | ||
| writeFileSync(lockPath, metadata); | ||
| return true; |
There was a problem hiding this comment.
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.
| 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); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
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.
| 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); |
There was a problem hiding this comment.
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).
| let emptyBackoffExp = 1; | ||
| let failureBackoffExp = 1; | ||
| const lockDir = join(REPO_ROOT, LOCK_DIR); | ||
|
|
There was a problem hiding this comment.
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.
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>
e10ce20 to
c3afb0f
Compare
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>
|



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.