collapse the plan-admission-throttle payload to one gate (+ AGENTS.md) (#2562)
The cross-project semaphore is deleted, so `task:plan-admission-throttled` was describing a gate that no longer exists. Nothing wires `options.semaphore` any more, which left three things dead-but-visible: - `semaphoreAvailable` was permanently `Infinity`, so `Math.min(projectRoom, …)` was a no-op keeping a deleted limiter in the arithmetic - `blockedBy` was a **discriminator** between `"running-agent cap"` and `"global semaphore"`; only the first can occur - four `semaphore*` metadata fields were always `undefined`, and two more terms in the dedupe signature were constant ## `blockedBy` is kept, not dropped Even though it is now a constant. The event exists (FN-8600) to answer *“why did this card sit queued to plan?”* after the fact — a named reason answers that even when there is one gate, whereas a payload with **no** reason field reads as “unknown”. It costs nothing and preserves the shape if a second gate is ever added. The dedupe signature drops the two semaphore terms and keeps the eligible task IDs — that term is what stops a **new** card’s stall being swallowed when the counts land on an unchanged tuple, which is the property the event depends on. ## AGENTS.md It documented the removed field names verbatim, so it is updated in the same commit. Leaving docs describing a payload the code cannot emit is exactly the readable-but-wrong artifact this program keeps deleting. ## Verification `pnpm lint` clean · engine `tsc` clean · `pnpm test:gate` green · triage suites **234/234**. --- **Correction I owe on `concurrency.ts`, measured rather than estimated.** I earlier told the coordinator ~75% of its 886 lines could go with the cross-project cap. That was line-range arithmetic and it was wrong. With the cap now fully removed, `concurrency.ts` is **still 886 lines**, because `AgentSemaphore` has four consumers unrelated to it — `verification-concurrency` (maxConcurrentVerifications), `research-orchestrator` (research runs), `experiment-executor` (maxConcurrentExperiments), `step-session-executor` (parallel steps) — plus `ProjectAdmissionCoordinator`, which is FN-8453 oldest-first **ordering**, not a limiter. The real remaining win there is the pre-held-slot bookkeeping and the idle-semaphore leak recovery, which existed to service the global instance; I will measure that as its own slice rather than quote a fraction. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Updated plan admission throttling to consistently use the project’s running-agent capacity. * Improved throttle audit events by reporting stable capacity details and removing obsolete semaphore information. * Preserved accurate deduplication for repeated throttling events, including changes in stalled tasks. * **Documentation** * Updated run-audit guidance to match the revised throttling event format. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -293,7 +293,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme
|
||||
- FN-8004: `agent:heartbeat-move-skipped-soft-delete` records a heartbeat move that races a soft-deleted task without parking the durable agent. Metadata remains ids/timestamps/source only (`agentId`, optional `taskId`/`deletedAt`, `moveAttemptedAt`, optional `source`); it never stores error prose.
|
||||
- FN-8141: the executor's `fn_task_done(outcome="blocked", reason=..., blockedBy?=[...])` honest-blocked exit emits `task:execution-blocked-parked` when an executor parks a genuinely-impossible task `failed` (`error = "BLOCKED: <reason>"`) instead of laundering it to `done` by skipping steps. It bypasses the completion/verdict/bulk-completion gates (blocked is not a completion claim), leaves steps in their true statuses, preserves worktree/branch, records `blockedBy` as real `task.dependencies` edges so the task requeues behind the blocker, and does NOT hand off to review — the parked row is honored by the executor's `status === "failed"` post-loop branch and is not auto-recovered into in-review by `recoverStrandedCompletedTodoTasks` (steps are not all done/skipped and `task.error` is set). Metadata stays ids/outcomes-only (`taskId`, `blockedBy` ids, `hasReason` boolean — never the reason prose).
|
||||
- FN-8305: durable symbol-lock operations emit `symbol-lock:acquired`, `symbol-lock:acquire-conflict`, `symbol-lock:renewed`, `symbol-lock:released`, `symbol-lock:reconcile-stale`, and deduplicated `symbol-lock:reconcile-stale-no-action`. Metadata is ids/counts/outcomes-only; normalized opaque symbol keys are permitted IDs, while raw symbol prose is not.
|
||||
- FN-8600: triage emits `task:plan-admission-throttled` when planning admission is withheld while eligible cards are waiting, recording the binding gate (`blockedBy`: `"running-agent cap"` or `"global semaphore"`) plus `maxConcurrent`, `claimed`, `projectRoom`, `eligibleCount`, up to five `eligibleTaskIds`, `processingCount`, up to five `processingTaskIds`, and the semaphore `activeCount`/`limit`/`availableCount`/`waitingCount`. Metadata is ids/counts-only. Deduped on the gate signature INCLUDING the eligible task IDs, so a sustained stall collapses to one row while a new card's stall is never swallowed; the marker is set only after the write lands, so a failed write retries on the next poll. Purpose: before this event the binding gate existed only in a `planLog` line that is persisted nowhere, so "why did this card sit queued to plan?" was unanswerable after the fact. Reachable today by direct DB query only — the sole run-audit read route resolves through a durable agent's heartbeat run and this event uses a synthetic run id under `agentId:"triage"`.
|
||||
- FN-8600: triage emits `task:plan-admission-throttled` when planning admission is withheld while eligible cards are waiting, recording the binding gate (`blockedBy`, now always `"running-agent cap"` — the cross-project semaphore that was the other value is deleted, and the four `semaphore*` fields went with it) plus `maxConcurrent`, `claimed`, `projectRoom`, `eligibleCount`, up to five `eligibleTaskIds`, `processingCount`, and up to five `processingTaskIds`. Metadata is ids/counts-only. Deduped on the gate signature INCLUDING the eligible task IDs, so a sustained stall collapses to one row while a new card's stall is never swallowed; the marker is set only after the write lands, so a failed write retries on the next poll. Purpose: before this event the binding gate existed only in a `planLog` line that is persisted nowhere, so "why did this card sit queued to plan?" was unanswerable after the fact. Reachable today by direct DB query only — the sole run-audit read route resolves through a durable agent's heartbeat run and this event uses a synthetic run id under `agentId:"triage"`.
|
||||
- FN-8592: startup and periodic self-healing emit `task:reconcile-stranded-hold-continuation` when an idle hold-column card with a real spec is re-seeded at its pre-release Plan Review, and deduped `task:reconcile-stranded-hold-continuation-no-action` for a candidate guard or race loss. Metadata stays ids/counts/outcomes-only (`taskId`, `column`, node/workflow identifiers, staleness or reason); healthy non-candidates are silent. The repair is insert-only and uses the shared per-task advisory transaction lock; global/engine pause and `autoMerge:false` defer to the operator.
|
||||
- FN-8492: the self-healing sweep `reconcile-orphaned-pending-step-results` (startup, right after legacy adoption, plus periodic maintenance) emits `task:reconcile-orphaned-pending-step-results` when it REWRITES `pending` workflow-step results with no live session behind them to `failed` (canonical liveness triple: `activeSessionRegistry` path, `executingTaskLock`, `isTaskActive`). It must never DELETE an orphaned entry — the merge gate blocks on pending/failed results, not on an enabled step with no result, so deletion silently satisfies the gate and the task merges with its review skipped; the `failed` rewrite keeps the gate closed and hands re-run/bypass to the failed-pre-merge-steps recovery and FN-7720 operator-bypass paths. `in-progress` rows are always skipped (executor-owned; resume is deferred at startup), the row is re-read immediately before the write, and user pauses are never disturbed. Metadata is ids/counts-only (`taskId`, `column`, `orphanedCount`, `resultCount`).
|
||||
- FN-8356: self-healing emits `task:reconcile-stale-duplicate-decision` when it clears a triage-marker duplicate-decision pause against a missing, deleted, done, or archived canonical. Metadata is ids/outcomes-only (`taskId`, `canonicalId`, `canonicalColumn`, `canonicalDeleted`, `priorPausedReason`); active canonical decisions and user pauses remain untouched.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { Settings, Task, TaskStore } from "@fusion/core";
|
||||
import { AgentSemaphore } from "../concurrency.js";
|
||||
import { TriageProcessor } from "../triage.js";
|
||||
|
||||
/*
|
||||
@@ -54,7 +53,29 @@ function eligibleTodoTask(id: string): Task {
|
||||
|
||||
interface RecordedEvent { type: string; target: string; metadata?: Record<string, unknown> }
|
||||
|
||||
/*
|
||||
FNXC:CapacityModel 2026-07-29-18:40 (PR #2562 review):
|
||||
The card that CONSUMES the project's single agent slot. Previously an exhausted host
|
||||
semaphore forced the withhold; that gate is deleted, so the binding gate is now the
|
||||
per-project agent count and something has to be holding it.
|
||||
*/
|
||||
function runningTask(id: string): Task {
|
||||
return {
|
||||
id,
|
||||
description: "already running",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-07-26T15:00:00.000Z",
|
||||
updatedAt: "2026-07-26T15:00:00.000Z",
|
||||
} as Task;
|
||||
}
|
||||
|
||||
function createStore(tasks: Task[], recorded: RecordedEvent[], settings: Partial<Settings> = {}): TaskStore {
|
||||
// The running claimant is added here so every case exhausts the one project slot.
|
||||
tasks = [runningTask("FN-RUNNING"), ...tasks];
|
||||
return {
|
||||
getTask: vi.fn().mockImplementation(async (id: string) => {
|
||||
const task = tasks.find((candidate) => candidate.id === id);
|
||||
@@ -62,7 +83,9 @@ function createStore(tasks: Task[], recorded: RecordedEvent[], settings: Partial
|
||||
}),
|
||||
listTasks: vi.fn().mockResolvedValue(tasks),
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 12,
|
||||
// FNXC:CapacityModel 2026-07-29-18:40: one slot, consumed by the in-progress
|
||||
// row below, so planning admission is withheld by the PROJECT agent count.
|
||||
maxConcurrent: 1,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 600_000,
|
||||
groupOverlappingFiles: false,
|
||||
@@ -92,9 +115,23 @@ function createStore(tasks: Task[], recorded: RecordedEvent[], settings: Partial
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
/** Runs one real poll pass against an exhausted host semaphore. */
|
||||
async function pollWithExhaustedSemaphore(store: TaskStore, semaphore: AgentSemaphore): Promise<TriageProcessor> {
|
||||
const processor = new TriageProcessor(store, "/tmp/fn-8600-throttle-root", { semaphore });
|
||||
/*
|
||||
FNXC:CapacityModel 2026-07-29-18:40 (PR #2562 review — greptile P1):
|
||||
Drive the throttle through the PROJECT AGENT COUNT, not an exhausted host semaphore.
|
||||
|
||||
These cases previously spent a 1-slot `AgentSemaphore` to force the withhold. That
|
||||
gate is deleted with the cross-project cap, so the poll no longer consults it and no
|
||||
throttle event was emitted — the suite exercised a limiter that no longer exists and
|
||||
would have gone quietly non-firing.
|
||||
|
||||
The REQUIREMENT is unchanged and is what these cases still pin: when planning
|
||||
admission is withheld while eligible cards wait, the binding gate must be recorded
|
||||
durably (FN-8600 — an operator asked why a card sat "Queued to plan" for seven
|
||||
minutes and it was unanswerable after the fact). Only the gate that can bind has
|
||||
changed, so the setup exhausts `maxConcurrent` instead.
|
||||
*/
|
||||
async function pollWithExhaustedProjectCapacity(store: TaskStore): Promise<TriageProcessor> {
|
||||
const processor = new TriageProcessor(store, "/tmp/fn-8600-throttle-root", {});
|
||||
// poll() is a no-op unless the processor is running; these tests drive one pass directly rather
|
||||
// than starting the interval timer, which would make them time-dependent.
|
||||
(processor as unknown as { running: boolean }).running = true;
|
||||
@@ -113,32 +150,25 @@ describe("plan admission throttle run-audit (FN-8600)", () => {
|
||||
});
|
||||
|
||||
it("records the binding gate when planning is withheld with eligible work", async () => {
|
||||
const semaphore = new AgentSemaphore(1);
|
||||
expect(semaphore.tryAcquire()).toBe(true); // host capacity fully spent
|
||||
|
||||
|
||||
const store = createStore([eligibleTodoTask("FN-8600")], recorded);
|
||||
await pollWithExhaustedSemaphore(store, semaphore);
|
||||
await pollWithExhaustedProjectCapacity(store);
|
||||
|
||||
const throttle = recorded.filter((event) => event.type === "task:plan-admission-throttled");
|
||||
expect(throttle).toHaveLength(1);
|
||||
// The gate the operator could not previously determine.
|
||||
expect(throttle[0].metadata).toMatchObject({
|
||||
blockedBy: "global semaphore",
|
||||
maxConcurrent: 12,
|
||||
blockedBy: "running-agent cap",
|
||||
maxConcurrent: 1,
|
||||
eligibleCount: 1,
|
||||
eligibleTaskIds: ["FN-8600"],
|
||||
semaphoreLimit: 1,
|
||||
semaphoreAvailableCount: 0,
|
||||
});
|
||||
semaphore.release();
|
||||
});
|
||||
|
||||
it("emits one row for a sustained stall instead of one per poll", async () => {
|
||||
const semaphore = new AgentSemaphore(1);
|
||||
expect(semaphore.tryAcquire()).toBe(true);
|
||||
|
||||
|
||||
const store = createStore([eligibleTodoTask("FN-8600")], recorded);
|
||||
const processor = new TriageProcessor(store, "/tmp/fn-8600-throttle-root", { semaphore });
|
||||
const processor = new TriageProcessor(store, "/tmp/fn-8600-throttle-root", {});
|
||||
(processor as unknown as { running: boolean }).running = true;
|
||||
const poll = (processor as unknown as { poll: () => Promise<void> }).poll.bind(processor);
|
||||
await poll();
|
||||
@@ -147,7 +177,6 @@ describe("plan admission throttle run-audit (FN-8600)", () => {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(recorded.filter((event) => event.type === "task:plan-admission-throttled")).toHaveLength(1);
|
||||
semaphore.release();
|
||||
});
|
||||
|
||||
/*
|
||||
@@ -157,25 +186,27 @@ describe("plan admission throttle run-audit (FN-8600)", () => {
|
||||
whole job is answering "why is THIS card queued".
|
||||
*/
|
||||
it("emits again when a different card is the one stalling, even with identical counts", async () => {
|
||||
const semaphore = new AgentSemaphore(1);
|
||||
expect(semaphore.tryAcquire()).toBe(true);
|
||||
|
||||
|
||||
const first = eligibleTodoTask("FN-8600");
|
||||
const store = createStore([first], recorded);
|
||||
const processor = new TriageProcessor(store, "/tmp/fn-8600-throttle-root", { semaphore });
|
||||
const processor = new TriageProcessor(store, "/tmp/fn-8600-throttle-root", {});
|
||||
(processor as unknown as { running: boolean }).running = true;
|
||||
const poll = (processor as unknown as { poll: () => Promise<void> }).poll.bind(processor);
|
||||
await poll();
|
||||
|
||||
// Same counts, different card: A leaves the queue as C enters.
|
||||
/*
|
||||
Same counts, different card: A leaves the queue as C enters. The running
|
||||
claimant must be re-supplied — this mock REPLACES the list, and without it the
|
||||
project slot frees up, the gate stops binding and no second row is emitted
|
||||
(which is a correct outcome for a different scenario, not this one).
|
||||
*/
|
||||
(store.listTasks as unknown as { mockResolvedValue: (v: Task[]) => void })
|
||||
.mockResolvedValue([eligibleTodoTask("FN-8601")]);
|
||||
.mockResolvedValue([runningTask("FN-RUNNING"), eligibleTodoTask("FN-8601")]);
|
||||
await poll();
|
||||
|
||||
const throttle = recorded.filter((event) => event.type === "task:plan-admission-throttled");
|
||||
expect(throttle).toHaveLength(2);
|
||||
expect(throttle[1].metadata).toMatchObject({ eligibleTaskIds: ["FN-8601"] });
|
||||
semaphore.release();
|
||||
});
|
||||
|
||||
/*
|
||||
@@ -185,9 +216,7 @@ describe("plan admission throttle run-audit (FN-8600)", () => {
|
||||
original unanswerable-stall problem.
|
||||
*/
|
||||
it("retries the audit write on the next poll when the first write fails", async () => {
|
||||
const semaphore = new AgentSemaphore(1);
|
||||
expect(semaphore.tryAcquire()).toBe(true);
|
||||
|
||||
|
||||
const store = createStore([eligibleTodoTask("FN-8600")], recorded);
|
||||
let attempts = 0;
|
||||
(store as unknown as { recordRunAuditEvent: unknown }).recordRunAuditEvent = vi.fn()
|
||||
@@ -197,7 +226,7 @@ describe("plan admission throttle run-audit (FN-8600)", () => {
|
||||
recorded.push({ type: event.mutationType, target: event.target, metadata: event.metadata });
|
||||
});
|
||||
|
||||
const processor = new TriageProcessor(store, "/tmp/fn-8600-throttle-root", { semaphore });
|
||||
const processor = new TriageProcessor(store, "/tmp/fn-8600-throttle-root", {});
|
||||
(processor as unknown as { running: boolean }).running = true;
|
||||
const poll = (processor as unknown as { poll: () => Promise<void> }).poll.bind(processor);
|
||||
|
||||
@@ -209,28 +238,42 @@ describe("plan admission throttle run-audit (FN-8600)", () => {
|
||||
await poll();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(recorded.filter((event) => event.type === "task:plan-admission-throttled")).toHaveLength(1);
|
||||
semaphore.release();
|
||||
});
|
||||
|
||||
it("stays silent when planning has capacity", async () => {
|
||||
const semaphore = new AgentSemaphore(4);
|
||||
const store = createStore([], recorded);
|
||||
await pollWithExhaustedSemaphore(store, semaphore);
|
||||
/*
|
||||
FNXC:PlanAdmissionThrottle 2026-07-31-11:25 (PR #2562 review — coderabbit):
|
||||
THIS TEST PROVED THE WRONG SILENCE. It used `createStore([])`, which seeds only
|
||||
the running claimant and NO eligible card — so it asserted that an EMPTY QUEUE
|
||||
emits no throttle, which is true of any implementation, including one that emits
|
||||
a throttle on every poll where a card is waiting. The name says "has capacity";
|
||||
the setup had no candidate for capacity to matter to.
|
||||
|
||||
Now the real shape: an eligible card AND room to start it. `maxConcurrent: 3`
|
||||
against one running claimant leaves projectRoom > 0, so admission proceeds and
|
||||
the throttle must stay silent for the reason the name claims.
|
||||
|
||||
`specifyTask` is stubbed because admission now actually dispatches — without it
|
||||
the test would drive a real planning session.
|
||||
*/
|
||||
const store = createStore([eligibleTodoTask("FN-8600-ROOM")], recorded, { maxConcurrent: 3 });
|
||||
const processor = new TriageProcessor(store, "/tmp/fn-8600-throttle-root", {});
|
||||
vi.spyOn(processor, "specifyTask").mockResolvedValue(undefined);
|
||||
(processor as unknown as { running: boolean }).running = true;
|
||||
await (processor as unknown as { poll: () => Promise<void> }).poll();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(recorded.filter((event) => event.type === "task:plan-admission-throttled")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("carries no prompt, title, or reason prose — ids and counts only", async () => {
|
||||
const semaphore = new AgentSemaphore(1);
|
||||
expect(semaphore.tryAcquire()).toBe(true);
|
||||
|
||||
|
||||
const store = createStore([eligibleTodoTask("FN-8600")], recorded);
|
||||
await pollWithExhaustedSemaphore(store, semaphore);
|
||||
await pollWithExhaustedProjectCapacity(store);
|
||||
|
||||
const throttle = recorded.find((event) => event.type === "task:plan-admission-throttled");
|
||||
expect(throttle).toBeDefined();
|
||||
const serialized = JSON.stringify(throttle!.metadata ?? {});
|
||||
expect(serialized).not.toContain("favorite projects");
|
||||
semaphore.release();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1858,9 +1858,6 @@ export class TriageProcessor {
|
||||
exceed its operator-facing top-level capacity in a different lane.
|
||||
*/
|
||||
const maxConcurrent = settings.maxConcurrent ?? 2;
|
||||
const semaphoreAvailable = this.options.semaphore
|
||||
? Math.max(0, this.options.semaphore.availableCount)
|
||||
: Infinity;
|
||||
// processing entries that have not yet written status:"planning" still claim a future slot.
|
||||
let pendingSpecifyCount = 0;
|
||||
for (const id of this.processing) {
|
||||
@@ -1872,23 +1869,40 @@ export class TriageProcessor {
|
||||
tasks: allTasks,
|
||||
pendingSpecifyCount,
|
||||
});
|
||||
// `claimed` is project-local. The scoped/global host semaphore remains a
|
||||
// distinct process-wide availability gate, so project A cannot spend B's cap.
|
||||
/*
|
||||
FNXC:CapacityModel 2026-07-31-11:10 (PR #2562 review — coderabbit; CORRECTED):
|
||||
Capacity is the PROJECT's agent count, full stop. The second term was the
|
||||
cross-project host semaphore's AVAILABILITY, which no longer constrains
|
||||
admission: permanently Infinity here, so `Math.min` was a no-op keeping a dead
|
||||
limiter visible in the arithmetic.
|
||||
|
||||
REMOVED FROM THROTTLE ACCOUNTING, NOT DELETED. An earlier version of this note
|
||||
said "nothing wires `options.semaphore` any more", which is false and dangerous
|
||||
in a specific way: it reads as permission to delete live coordination code.
|
||||
`options.semaphore` is still wired at five call sites — pre-held slot
|
||||
registration (`reserve`), the release on drop, the leak-recovery path, and the
|
||||
two admission-reservation hand-offs. Only its use as an ADMISSION LIMIT is gone.
|
||||
*/
|
||||
const projectRoom = Math.max(0, maxConcurrent - claimed);
|
||||
const maxToStart = Math.min(projectRoom, semaphoreAvailable);
|
||||
const maxToStart = projectRoom;
|
||||
|
||||
if (maxToStart <= 0 && triageTasks.length > 0) {
|
||||
const semaphoreSnapshot = this.options.semaphore?.snapshot();
|
||||
const semaphoreDetail = semaphoreSnapshot
|
||||
? `, semaphore active=${semaphoreSnapshot.activeCount}/${semaphoreSnapshot.limit}, available=${semaphoreSnapshot.availableCount}, waiting=${semaphoreSnapshot.waitingCount}`
|
||||
: ", semaphore unavailable";
|
||||
const processingIds = [...this.processing].slice(0, 5);
|
||||
const eligibleIds = triageTasks.slice(0, 5).map((t) => t.id);
|
||||
const blockedBy = projectRoom <= 0 ? "running-agent cap" : "global semaphore";
|
||||
/*
|
||||
FNXC:CapacityModel 2026-07-29-10:20 (drop the cross-project cap — throttle payload):
|
||||
`blockedBy` was a DISCRIMINATOR between two gates: "running-agent cap" and
|
||||
"global semaphore". With the machine-wide cap deleted there is only one gate
|
||||
left, so the field collapses to a constant. It is KEPT rather than dropped:
|
||||
the event's whole purpose (FN-8600) is answering "why did this card sit
|
||||
queued?", and a named reason answers it even when there is only one — while
|
||||
a payload with no reason field at all would read as "unknown".
|
||||
*/
|
||||
const blockedBy = "running-agent cap";
|
||||
planLog.log(
|
||||
`Plan throttled by ${blockedBy}: eligible=${triageTasks.length} [${eligibleIds.join(", ")}], ` +
|
||||
`maxConcurrent=${maxConcurrent}, claimed=${claimed}, processing=${this.processing.size}` +
|
||||
`${processingIds.length > 0 ? ` [${processingIds.join(", ")}]` : ""}${semaphoreDetail}`,
|
||||
`${processingIds.length > 0 ? ` [${processingIds.join(", ")}]` : ""}`,
|
||||
);
|
||||
/*
|
||||
FNXC:ConcurrencyAdmission 2026-07-26-09:30:
|
||||
@@ -1915,14 +1929,19 @@ export class TriageProcessor {
|
||||
Live counts still jitter as unrelated lanes cycle, so this bounds write volume rather than
|
||||
guaranteeing exactly one row.
|
||||
*/
|
||||
/*
|
||||
FNXC:CapacityModel 2026-07-29-10:20: the two semaphore terms are dropped from
|
||||
the dedupe signature with the gate they described. `blockedBy` is now
|
||||
constant and contributes nothing, but stays for readability of the key; the
|
||||
eligible task IDs remain the term that keeps a NEW card's stall from being
|
||||
swallowed by an unchanged count tuple.
|
||||
*/
|
||||
const throttleSignature = [
|
||||
blockedBy,
|
||||
maxConcurrent,
|
||||
claimed,
|
||||
triageTasks.length,
|
||||
this.processing.size,
|
||||
semaphoreSnapshot?.activeCount ?? -1,
|
||||
semaphoreSnapshot?.limit ?? -1,
|
||||
eligibleIds.join(","),
|
||||
].join("|");
|
||||
if (this.lastPlanThrottleSignature !== throttleSignature) {
|
||||
@@ -1950,10 +1969,6 @@ export class TriageProcessor {
|
||||
eligibleTaskIds: eligibleIds,
|
||||
processingCount: this.processing.size,
|
||||
processingTaskIds: processingIds,
|
||||
semaphoreActiveCount: semaphoreSnapshot?.activeCount,
|
||||
semaphoreLimit: semaphoreSnapshot?.limit,
|
||||
semaphoreAvailableCount: semaphoreSnapshot?.availableCount,
|
||||
semaphoreWaitingCount: semaphoreSnapshot?.waitingCount,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
|
||||
Reference in New Issue
Block a user