Stacked on #2502 — review that first; this branch contains its three commits. Operator: two capacities **per project**. `globalMaxConcurrent` is a machine-wide *third* limiter kept in a separate authority (a central-DB singleton row) that every runtime had to subscribe to and periodically re-reconcile. It goes. This slice removes **enforcement and wiring only**. The setting key, central DB state, API route and Settings UI come out in part 3, so each half lands green and independently revertable. **Deleted:** the shared `AgentSemaphore` instance in `ProjectManager` and `ProjectEngineManager`; the per-project `ScopedAgentSemaphore` in `InProcessRuntime`; the `globalSemaphore` runtime-config field; both `concurrency:changed` subscriptions; ProjectManager’s 30s limit-refresh poll; the residual-slot return on project stop. The scheduler/triage semaphore gate is now simply **absent** — same shape as the worktrees-off gate in #2502. `semaphoreGate?` was already optional, so no gate object is constructed rather than one holding an infinite limit. Absence cannot start binding again by accident. --- ## Two findings that changed the shape of this slice **1. `AgentSemaphore` the class stays — my earlier estimate was wrong and I withdraw it.** I previously told the coordinator that ~75% of `concurrency.ts` (≈662 of 886 lines) was semaphore machinery that could go with this cap. That was line-range arithmetic, and it was wrong. `AgentSemaphore` is a general primitive with four consumers unrelated to the global cap: | Consumer | Governs | |---|---| | `verification-concurrency.ts` | `maxConcurrentVerifications` | | `research-orchestrator.ts` | research `maxConcurrentRuns` | | `experiment-executor.ts` | `maxConcurrentExperiments` — **a knob absent from my original inventory** | | `step-session-executor.ts` | parallel workflow steps | What goes is the global **instance** and its wiring, not the class. I will report the measured `concurrency.ts` delta after part 3 rather than repeat an estimate. **2. `acquireGlobalSlot` / `releaseGlobalSlot` had no production callers — only tests.** So the cross-project cap had *two* mechanisms: the in-memory semaphore (live) and a durable central-DB `currentlyActive` counter (dead — never incremented by real work). Both deleted, along with the tests that pinned the dead passthrough. ## The regression this almost introduced `runWithMergeAdmission` in `project-engine.ts` opened with: ```ts if (!semaphore) return await start(); ``` Unreachable while a global semaphore always existed. With the semaphore gone it would have fired on **every** merge and skipped `projectAdmissionCoordinator.admitOldest` entirely — silently stopping merges from counting against the **per-project** agent count. That is the opposite of the intent: a merge *is* an agent and still consumes one of the project’s slots; it just no longer consumes a machine-wide one. So the early return is **deleted rather than left to fire**. `admitOldest` already declares `semaphore` as optional and enforces `maxConcurrent` independently of it (`claimed() + reservations >= maxConcurrent`), so dropping the argument preserves per-project admission and oldest-first fairness exactly. Worth flagging as a pattern: this is the third time in this unit that a branch which was *unreachable* became *always-taken* once a limiter was removed. The type system caught the worktree one; this one was only visible by reading the branch, because the semaphore was reached through an `any` cast. ## Verification `pnpm lint` clean · engine `tsc` clean · `pnpm test:gate` green (309 + 10 + 71) · project-manager + hybrid-executor + merge-single-flight + scheduler 93/93. Nothing booted. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
190 lines
6.8 KiB
TypeScript
190 lines
6.8 KiB
TypeScript
import type { EventEmitter } from "node:events";
|
|
import type { TaskStore, Task, IsolationMode, ProjectSettings, GithubIssueAction, MigrationProgressEvent } from "@fusion/core";
|
|
import type { Scheduler } from "./scheduler.js";
|
|
|
|
/**
|
|
* Runtime status for a ProjectRuntime instance.
|
|
* Represents the lifecycle states of a project runtime.
|
|
*/
|
|
export type RuntimeStatus =
|
|
| "active" // Runtime is running and processing tasks
|
|
| "paused" // Runtime is temporarily suspended
|
|
| "errored" // Runtime encountered a fatal error
|
|
| "stopped" // Runtime is stopped (graceful shutdown complete)
|
|
| "starting" // Runtime is in the process of starting
|
|
| "stopping"; // Runtime is in the process of stopping
|
|
|
|
/**
|
|
* Metrics for a ProjectRuntime instance.
|
|
* Used for monitoring and health tracking.
|
|
*/
|
|
export interface RuntimeMetrics {
|
|
/** Number of tasks currently in-progress */
|
|
inFlightTasks: number;
|
|
/** Number of active agents currently running */
|
|
activeAgents: number;
|
|
/** ISO-8601 timestamp of the last activity */
|
|
lastActivityAt: string;
|
|
/** Memory usage in bytes (optional, may not be available in all modes) */
|
|
memoryBytes?: number;
|
|
}
|
|
|
|
/**
|
|
* Configuration for creating a ProjectRuntime instance.
|
|
*/
|
|
export interface ProjectRuntimeConfig {
|
|
/** Unique project ID (e.g., "proj_abc123") */
|
|
projectId: string;
|
|
/** Absolute path to the project working directory */
|
|
workingDirectory: string;
|
|
/** Execution isolation mode */
|
|
isolationMode: IsolationMode;
|
|
/** Maximum concurrent agents for this project */
|
|
maxConcurrent: number;
|
|
/** Maximum worktrees for this project */
|
|
maxWorktrees: number;
|
|
/** Optional project settings override */
|
|
settings?: ProjectSettings;
|
|
/*
|
|
FNXC:CapacityModel 2026-07-28-20:10 (drop the cross-project cap):
|
|
`globalSemaphore` is GONE. Capacity is two numbers PER PROJECT (total agents +
|
|
maxWorktrees); a machine-wide cap across projects was a third limiter and the
|
|
operator removed it. Runtimes no longer receive or consult one — the scheduler's
|
|
semaphore gate is simply absent rather than holding an infinite limit.
|
|
*/
|
|
/**
|
|
* An already-initialized TaskStore to use instead of creating a new one.
|
|
* When provided, the runtime will skip TaskStore construction and init().
|
|
* Useful when the caller (e.g. dashboard.ts) owns and watches the store.
|
|
*/
|
|
externalTaskStore?: TaskStore;
|
|
/**
|
|
* FNXC:MigrationHoldingPage 2026-07-19-12:00:
|
|
* A fixed-port daemon binds a temporary holding server before engine startup.
|
|
* Forward factory migration progress through the runtime so that server can
|
|
* report live cutover status until the real dashboard listener takes over.
|
|
*/
|
|
onMigrationProgress?: (event: MigrationProgressEvent) => void;
|
|
/**
|
|
* PR-entity node GitHub ops (U3): the injected `createPr`/`mergePr`/`respond`
|
|
* callbacks (+ source resolver + audit) for the `pr-create`/`pr-respond`/
|
|
* `pr-merge` workflow nodes. Threaded from the CLI layer; the runtime binds the
|
|
* engine-owned store and hands the assembled deps to the executor. Absent → the
|
|
* pr-* node kinds fail closed.
|
|
*/
|
|
prNodeGithubOps?: import("./pr-nodes.js").PrNodeGithubOps;
|
|
/**
|
|
* Absolute URL of the dashboard's CLI-agent hook ingestion endpoint that
|
|
* generated hook scripts POST to (e.g. `http://127.0.0.1:4040/api/cli-agent/hooks`).
|
|
* Threaded from the dashboard boot once the listening port is known. When
|
|
* absent, the runtime derives a localhost URL from `FUSION_DASHBOARD_PORT`
|
|
* (falling back to 4040).
|
|
*/
|
|
cliAgentHookEndpointUrl?: string;
|
|
}
|
|
|
|
/**
|
|
* Events emitted by a ProjectRuntime instance.
|
|
*/
|
|
export interface ProjectRuntimeEvents {
|
|
/** Emitted when a task is created in the project */
|
|
"task:created": [task: Task];
|
|
/** Emitted when a task is moved between columns */
|
|
"task:moved": [data: { task: Task; from: string; to: string }];
|
|
/** Emitted when a task is updated */
|
|
"task:updated": [task: Task];
|
|
/** Emitted when a task is deleted */
|
|
"task:deleted": [task: Task, meta?: { githubIssueAction?: GithubIssueAction }];
|
|
/** Emitted when a cross-node assignment event is observed */
|
|
"task:assigned": [data: { taskId: string; agentId: string; assignedAt: string; source?: string }];
|
|
/** Emitted when an error occurs in the runtime */
|
|
"error": [error: Error];
|
|
/** Emitted when the runtime health status changes */
|
|
"health-changed": [data: { status: RuntimeStatus; previous: RuntimeStatus }];
|
|
}
|
|
|
|
/**
|
|
* ProjectRuntime interface — core abstraction for multi-project support.
|
|
*
|
|
* Each project instance runs as a ProjectRuntime, either in-process (default)
|
|
* or in an isolated child process (opt-in). The ProjectManager orchestrates
|
|
* all runtimes and enforces global concurrency limits from CentralCore.
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const runtime = new InProcessRuntime(config, centralCore);
|
|
* await runtime.start();
|
|
*
|
|
* // Access project TaskStore
|
|
* const taskStore = runtime.getTaskStore();
|
|
*
|
|
* // Listen for events
|
|
* runtime.on("task:created", (task) => {
|
|
* console.log(`Task ${task.id} created`);
|
|
* });
|
|
*
|
|
* // Shutdown gracefully
|
|
* await runtime.stop();
|
|
* ```
|
|
*/
|
|
export interface ProjectRuntime extends EventEmitter<ProjectRuntimeEvents> {
|
|
/**
|
|
* Start the runtime and initialize all subsystems.
|
|
* This includes initializing the TaskStore, Scheduler, Executor, and WorktreePool.
|
|
*/
|
|
start(): Promise<void>;
|
|
|
|
/**
|
|
* Stop the runtime with graceful shutdown.
|
|
* Waits for active tasks to complete (with timeout), stops the scheduler,
|
|
* and cleans up resources.
|
|
*/
|
|
stop(): Promise<void>;
|
|
|
|
/**
|
|
* Get the current runtime status.
|
|
* @returns The current status of the runtime
|
|
*/
|
|
getStatus(): RuntimeStatus;
|
|
|
|
/**
|
|
* Get the project's TaskStore instance.
|
|
* @returns The TaskStore for this project
|
|
* @throws Error if called on a ChildProcessRuntime (not accessible in child mode)
|
|
*/
|
|
getTaskStore(): TaskStore;
|
|
|
|
/**
|
|
* Get the project's Scheduler instance.
|
|
* @returns The Scheduler for this project
|
|
* @throws Error if called on a ChildProcessRuntime (not accessible in child mode)
|
|
*/
|
|
getScheduler(): Scheduler;
|
|
|
|
/**
|
|
* Clear volatile executor pause-abort provenance for a task before a manual retry.
|
|
* Optional because isolated runtimes do not expose in-memory executor state.
|
|
*/
|
|
clearTaskPauseAbortState?(taskId: string): void;
|
|
|
|
/**
|
|
* Get current runtime metrics.
|
|
* @returns Metrics including in-flight tasks, active agents, and memory usage
|
|
*/
|
|
getMetrics(): RuntimeMetrics;
|
|
}
|
|
|
|
/**
|
|
* Global metrics aggregated across all project runtimes.
|
|
*/
|
|
export interface GlobalMetrics {
|
|
/** Total number of in-flight tasks across all runtimes */
|
|
totalInFlightTasks: number;
|
|
/** Total number of active agents across all runtimes */
|
|
totalActiveAgents: number;
|
|
/** Number of runtimes by status */
|
|
runtimeCountByStatus: Record<RuntimeStatus, number>;
|
|
/** Total number of registered runtimes */
|
|
totalRuntimes: number;
|
|
}
|