Files
fusion/packages/engine/src/project-runtime.ts
gsxdsm 3bbb6ffc6b capacity, part 2: delete the cross-project concurrency cap (enforcement half) (#2509)
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>
2026-07-28 18:52:39 -07:00

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;
}