agent-dealer 1.2.16 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -72,19 +72,6 @@ export function countOccupyingIssues() {
72
72
  function listOccupyingIssues() {
73
73
  return listIssues([...occupyingStatuses]).map((i) => ({ id: i.id, status: i.status, repo: i.repo }));
74
74
  }
75
- /** Occupying repos with the active issue's id/title, for the per-repo exclusion reason. */
76
- function occupyingRepoSlots() {
77
- const slots = new Map();
78
- for (const issue of listIssues([...occupyingStatuses])) {
79
- if (issue.repo)
80
- slots.set(issue.repo, { issueId: issue.id, title: issue.title });
81
- }
82
- return slots;
83
- }
84
- /** NOT-215: concrete same-repository wait reason naming the active conflict. */
85
- export function repositorySlotReason(repo, activeTitle) {
86
- return `waiting for repository slot — ${repo} already active (${activeTitle})`;
87
- }
88
75
  /**
89
76
  * NOT-215 read model for the Admission queue header: truthful active / waiting /
90
77
  * limit counts, the persisted setting, the worker/spawn ceiling, the offerable
@@ -303,8 +290,6 @@ export async function admitNext() {
303
290
  blockersUnavailableReason: blockerUnavailableReason(),
304
291
  };
305
292
  const admitted = [];
306
- // Repos taken by occupying issues, plus issues admitted earlier in this tick.
307
- const repoSlots = occupyingRepoSlots();
308
293
  for (const entry of remaining) {
309
294
  // Slots filled mid-walk — stop. The read-time slot overlay
310
295
  // (listQueuedEntriesForRead) already reports the full state; persisting a slot
@@ -316,15 +301,6 @@ export async function admitNext() {
316
301
  markQueueEntryRemoved(entry.issueId);
317
302
  continue;
318
303
  }
319
- // Same-repository exclusion first (cheap, deterministic): never burn async
320
- // eligibility work on an entry that cannot take a slot anyway.
321
- if (issue.repo) {
322
- const slot = repoSlots.get(issue.repo);
323
- if (slot && slot.issueId !== issue.id) {
324
- setQueueWaitReason(entry.id, repositorySlotReason(issue.repo, slot.title));
325
- continue;
326
- }
327
- }
328
304
  const eligibility = await evaluateEligibility(issue, ctx);
329
305
  if (!eligibility.ok) {
330
306
  setQueueWaitReason(entry.id, eligibility.reason);
@@ -342,29 +318,10 @@ export async function admitNext() {
342
318
  if (capacityPolicy(listOccupyingIssues()) <= 0) {
343
319
  throw new StartPreconditionError(409, "no free admission slots");
344
320
  }
345
- // Re-check the repo slot inside the txn for the same race.
346
- if (issue.repo) {
347
- const fresh = occupyingRepoSlots();
348
- for (const a of admitted) {
349
- const admittedIssue = getIssue(a.issueId);
350
- if (admittedIssue?.repo) {
351
- fresh.set(admittedIssue.repo, {
352
- issueId: admittedIssue.id,
353
- title: admittedIssue.title,
354
- });
355
- }
356
- }
357
- const conflict = fresh.get(issue.repo);
358
- if (conflict && conflict.issueId !== issue.id) {
359
- throw new StartPreconditionError(409, repositorySlotReason(issue.repo, conflict.title));
360
- }
361
- }
362
321
  // Force-admit lives inside startWorkflowCore — single owner for start-path-queue-sync.
363
322
  return startWorkflowCore(entry.issueId);
364
323
  })();
365
324
  admitted.push({ issueId: entry.issueId, ...started });
366
- if (issue.repo)
367
- repoSlots.set(issue.repo, { issueId: issue.id, title: issue.title });
368
325
  }
369
326
  catch (err) {
370
327
  const message = err instanceof StartPreconditionError
@@ -395,6 +352,13 @@ export function slotWaitReason() {
395
352
  const titles = running.map((i) => i.title).join(", ");
396
353
  return titles ? `waiting for slot — running: ${titles}` : "waiting for slot";
397
354
  }
355
+ /**
356
+ * NOT-371: rows persisted under the removed NOT-215 per-repository exclusion still
357
+ * carry `waiting for repository slot` text until a tick or refresh overwrites them.
358
+ * The read model never emits that dead reason — it clears to "next up" (or the live
359
+ * capacity overlay) so operators never see a gate that no longer exists.
360
+ */
361
+ const staleRepositorySlotReason = /waiting for repository slot/;
398
362
  /**
399
363
  * The queue as operators read it: 1-based positions (rank, not the raw stored column, which
400
364
  * legacy rows may have left sparse) and the current blocker per entry. While capacity is
@@ -407,7 +371,10 @@ export function listQueuedEntriesForRead() {
407
371
  return listQueuedEntries().map((entry, index) => ({
408
372
  ...entry,
409
373
  position: index + 1,
410
- waitReason: slotReason ?? entry.waitReason,
374
+ waitReason: slotReason ??
375
+ (entry.waitReason && staleRepositorySlotReason.test(entry.waitReason)
376
+ ? null
377
+ : entry.waitReason),
411
378
  }));
412
379
  }
413
380
  export function queueStatusForIssue(issueId) {
@@ -466,8 +433,9 @@ export async function startIssueViaQueue(issueId) {
466
433
  /**
467
434
  * NOT-217 strict direct execution: attempt immediate admission for one issue without
468
435
  * touching the queue — no enqueue, no move-to-front, no reorder. It bypasses queue
469
- * *order* only: capacity (global + per-repository), readiness, Linear blockers, agent
470
- * health, runtime caps, and the active-workflow/frozen-snapshot guard all still refuse.
436
+ * *order* only: global capacity, readiness, Linear blockers, agent health, runtime
437
+ * caps, and the active-workflow/frozen-snapshot guard all still refuse (NOT-371:
438
+ * same-repository siblings never refuse — only a full global limit does).
471
439
  *
472
440
  * The eligibility + capacity checks are the same ones `admitNext` walks (same rules,
473
441
  * same policy, same persisted NOT-215 limit); the check, workflow start, and
@@ -489,18 +457,12 @@ export async function executeIssueNow(issueId) {
489
457
  if (!startableStatuses.has(issue.status)) {
490
458
  return { state: "error", code: 409, error: `Issue is ${issue.status} — not startable` };
491
459
  }
492
- // Fail-closed pre-checks, same order as the admitNext walk: global capacity, then the
493
- // per-repository exclusion, then the shared eligibility rules. Read-only — the queue
494
- // is never written on any path below except startWorkflowCore's own force-admit.
460
+ // Fail-closed pre-checks, same order as the admitNext walk: global capacity, then
461
+ // the shared eligibility rules. Read-only — the queue is never written on any path
462
+ // below except startWorkflowCore's own force-admit.
495
463
  if (capacityPolicy(listOccupyingIssues()) <= 0) {
496
464
  return { state: "refused", reason: slotWaitReason() ?? "waiting for slot" };
497
465
  }
498
- if (issue.repo) {
499
- const slot = occupyingRepoSlots().get(issue.repo);
500
- if (slot && slot.issueId !== issue.id) {
501
- return { state: "refused", reason: repositorySlotReason(issue.repo, slot.title) };
502
- }
503
- }
504
466
  const [deckOnline, blockers] = await Promise.all([
505
467
  checkAgentDeckHealth(),
506
468
  blockerSnapshotFor([issue]),
@@ -514,8 +476,8 @@ export async function executeIssueNow(issueId) {
514
476
  return { state: "refused", reason: eligibility.reason };
515
477
  // Atomic check + start + queue transition. Async eligibility above cannot run inside
516
478
  // the sync transaction, so re-verify everything a concurrent start could have changed
517
- // (occupancy, repo slot, active workflow, readiness via startWorkflowCore) before
518
- // writing. A refusal here rolls the transaction back — still no queue mutation.
479
+ // (occupancy, active workflow, readiness via startWorkflowCore) before writing.
480
+ // A refusal here rolls the transaction back — still no queue mutation.
519
481
  try {
520
482
  const started = getDb().transaction(() => {
521
483
  const fresh = getIssue(issueId);
@@ -527,12 +489,6 @@ export async function executeIssueNow(issueId) {
527
489
  if (capacityPolicy(listOccupyingIssues()) <= 0) {
528
490
  throw new StartPreconditionError(409, slotWaitReason() ?? "waiting for slot");
529
491
  }
530
- if (fresh.repo) {
531
- const slot = occupyingRepoSlots().get(fresh.repo);
532
- if (slot && slot.issueId !== fresh.id) {
533
- throw new StartPreconditionError(409, repositorySlotReason(fresh.repo, slot.title));
534
- }
535
- }
536
492
  return startWorkflowCore(issueId);
537
493
  })();
538
494
  return { state: "admitted", ...started };
@@ -551,8 +507,10 @@ export async function executeIssueNow(issueId) {
551
507
  /**
552
508
  * NOT-217: re-derive one queued entry's persisted wait reason after an operator edit
553
509
  * changed what admission sees (e.g. a new developer agent). Same rule order as the
554
- * admitNext walk — capacity, repository slot, shared eligibility — cleared (null) when
555
- * the issue is currently admittable so the row reads "next up". No reorder, no admit.
510
+ * admitNext walk — capacity, shared eligibility — cleared (null) when the issue is
511
+ * currently admittable so the row reads "next up". No reorder, no admit.
512
+ * NOT-371: a stale `waiting for repository slot` row is overwritten here with the
513
+ * current real reason (or cleared), since repository identity no longer gates.
556
514
  */
557
515
  export async function refreshQueueWaitReasonForIssue(issueId) {
558
516
  const entry = getQueuedEntryForIssue(issueId);
@@ -567,13 +525,6 @@ export async function refreshQueueWaitReasonForIssue(issueId) {
567
525
  setQueueWaitReason(entry.id, slotReason);
568
526
  return queueStatusForIssue(issueId);
569
527
  }
570
- if (issue.repo) {
571
- const slot = occupyingRepoSlots().get(issue.repo);
572
- if (slot && slot.issueId !== issue.id) {
573
- setQueueWaitReason(entry.id, repositorySlotReason(issue.repo, slot.title));
574
- return queueStatusForIssue(issueId);
575
- }
576
- }
577
528
  const [deckOnline, blockers] = await Promise.all([
578
529
  checkAgentDeckHealth(),
579
530
  blockerSnapshotFor([issue]),
@@ -15,9 +15,9 @@ const { createIssue, getIssue, updateIssue } = await import("../repository/issue
15
15
  const { listHumanActionsForIssue } = await import("../repository/human-actions.js");
16
16
  const { getActiveWorkflowInstance } = await import("../repository/workflow-events.js");
17
17
  const { recordRuntimeAvailability, clearAllRuntimeAvailability } = await import("../repository/runtime-availability.js");
18
- const { enqueueIssue, enqueueIssueWithOutcome, dequeueIssue, listQueuedEntries, getQueuedEntryForIssue } = await import("../repository/queue-entries.js");
18
+ const { enqueueIssue, enqueueIssueWithOutcome, dequeueIssue, listQueuedEntries, getQueuedEntryForIssue, setQueueWaitReason } = await import("../repository/queue-entries.js");
19
19
  const { startWorkflow } = await import("./commands.js");
20
- const { sequentialCapacityPolicy, occupyingStatuses, countOccupyingIssues, admitNext, checkRoleAgentHealthy, getAdmissionStatus, setAdmissionHealthCheckerForTests, setCapacityPolicyForTests, resetCapacityPolicyForTests, resetEligibilityRulesForTests } = await import("./admission.js");
20
+ const { sequentialCapacityPolicy, occupyingStatuses, countOccupyingIssues, admitNext, checkRoleAgentHealthy, getAdmissionStatus, listQueuedEntriesForRead, queueStatusForIssue, refreshQueueWaitReasonForIssue, setAdmissionHealthCheckerForTests, setCapacityPolicyForTests, resetCapacityPolicyForTests, resetEligibilityRulesForTests } = await import("./admission.js");
21
21
  const { getMaxActiveIssues, setMaxActiveIssues, getEffectiveMaxActiveIssues, admissionLimitOptions, workerSpawnCeiling, } = await import("../repository/admission-settings.js");
22
22
  const { setBlockersProviderForTests, resetDependenciesForTests } = await import("./dependencies.js");
23
23
  before(() => migrate());
@@ -771,6 +771,24 @@ function linearIssue(suffix, externalId, runtimes = { dev: "codex_local", rev: "
771
771
  externalLabel: `NOT-${suffix}`
772
772
  });
773
773
  }
774
+ /** NOT-371: a Linear-tracked issue pinned to a shared repository (blocker tests). */
775
+ function linearIssueInRepo(suffix, externalId, repo) {
776
+ const { dev, rev } = seedAgents(`linrepo-${suffix}`);
777
+ return createIssue({
778
+ title: `Linear ${suffix}`,
779
+ description: "d",
780
+ acceptanceCriteria: "It works",
781
+ repo,
782
+ baseBranch: "main",
783
+ developerAgentId: dev.id,
784
+ reviewerAgentId: rev.id,
785
+ maxReviewRounds: 2,
786
+ maxInfraAttempts: 2,
787
+ source: "linear",
788
+ externalId,
789
+ externalLabel: `NOT-${suffix}`
790
+ });
791
+ }
774
792
  function instanceCount(issueId) {
775
793
  return getDb().prepare("SELECT COUNT(*) AS n FROM workflow_instances WHERE issue_id = ?").get(issueId).n;
776
794
  }
@@ -820,28 +838,94 @@ test("NOT-215: fixed capacity 2 admits two eligible different-repository issues
820
838
  overCap: false,
821
839
  });
822
840
  });
823
- test("NOT-215: same-repository issues never run concurrently; the waiter names the conflict", async () => {
841
+ test("NOT-371: fixed capacity 2 admits two independent same-repository issues in one tick", async () => {
824
842
  setMaxActiveIssues(2);
825
843
  const repo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-admit-samerepo-"));
826
844
  const a = readyIssueInRepo("same-a", repo);
827
845
  const b = readyIssueInRepo("same-b", repo);
828
846
  enqueueIssue(a.id);
829
847
  enqueueIssue(b.id);
830
- assert.equal((await admitNext())?.issueId, a.id);
848
+ const first = await admitNext();
849
+ assert.equal(first?.issueId, a.id);
831
850
  assert.equal(getIssue(a.id).status, "developing");
832
- assert.equal(getIssue(b.id).status, "ready");
833
- assert.equal(getActiveWorkflowInstance(b.id), null);
834
- assert.equal(instanceCount(b.id), 0);
835
- const entry = getQueuedEntryForIssue(b.id);
851
+ assert.equal(getIssue(b.id).status, "developing");
852
+ assert.equal(listQueuedEntries().length, 0);
853
+ assert.equal(instanceCount(a.id), 1);
854
+ assert.equal(instanceCount(b.id), 1);
855
+ assert.notEqual(getActiveWorkflowInstance(a.id).id, getActiveWorkflowInstance(b.id).id, "each issue gets its own active workflow instance");
856
+ // No repository-slot wait reason anywhere — neither persisted nor on read.
857
+ for (const read of listQueuedEntriesForRead()) {
858
+ assert.doesNotMatch(read.waitReason ?? "", /repository slot/);
859
+ }
860
+ // Second tick admits nothing new and duplicates nothing.
861
+ assert.equal(await admitNext(), null);
862
+ assert.equal(instanceCount(a.id), 1);
863
+ assert.equal(instanceCount(b.id), 1);
864
+ });
865
+ test("NOT-371: an explicitly blocked same-repository issue waits while a later independent sibling skips ahead", async () => {
866
+ setMaxActiveIssues(2);
867
+ const repo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-admit-blockedrepo-"));
868
+ const blocked = linearIssueInRepo("blk", "lin-371-blk", repo);
869
+ const free = readyIssueInRepo("free", repo);
870
+ setBlockersProviderForTests(async (issues) => new Map(issues.map((i) => [
871
+ i.externalId,
872
+ i.externalId === "lin-371-blk"
873
+ ? [{ id: "", identifier: "NOT-1", stateName: "In Progress", stateType: "started" }]
874
+ : [],
875
+ ])));
876
+ enqueueIssue(blocked.id);
877
+ enqueueIssue(free.id);
878
+ // The free slot goes to the later independent sibling, not the blocked head.
879
+ assert.equal((await admitNext())?.issueId, free.id);
880
+ assert.equal(getIssue(free.id).status, "developing");
881
+ assert.equal(getIssue(blocked.id).status, "ready");
882
+ assert.equal(getActiveWorkflowInstance(blocked.id), null);
883
+ assert.equal(instanceCount(blocked.id), 0);
884
+ const entry = getQueuedEntryForIssue(blocked.id);
836
885
  assert.equal(entry.state, "queued");
837
- assert.match(entry.waitReason ?? "", /repository slot/);
838
- assert.ok((entry.waitReason ?? "").includes(repo), `reason names the conflicting repo: ${entry.waitReason}`);
839
- assert.ok((entry.waitReason ?? "").includes(a.title), `reason names the active issue: ${entry.waitReason}`);
840
- // The repo slot frees when the first issue leaves occupying states — then the waiter admits.
841
- releaseToNeedsHuman(a.id);
842
- assert.equal((await admitNext())?.issueId, b.id);
886
+ assert.match(entry.waitReason ?? "", /waiting on NOT-1/);
887
+ assert.doesNotMatch(entry.waitReason ?? "", /repository slot/);
888
+ // The declared blocker is satisfied and a slot frees — the next tick admits the waiter.
889
+ setBlockersProviderForTests(async (issues) => new Map(issues.map((i) => [i.externalId, []])));
890
+ releaseToNeedsHuman(free.id);
891
+ assert.equal((await admitNext())?.issueId, blocked.id);
892
+ assert.equal(getIssue(blocked.id).status, "developing");
893
+ });
894
+ test("NOT-371: a stale persisted repository-slot reason never surfaces and clears on refresh", async () => {
895
+ setMaxActiveIssues(2);
896
+ const repo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-admit-stalerepo-"));
897
+ const a = readyIssueInRepo("stale-a", repo);
898
+ const b = readyIssueInRepo("stale-b", repo);
899
+ enqueueIssue(a.id);
900
+ enqueueIssue(b.id);
901
+ // Simulate a row persisted under the removed NOT-215 exclusion.
902
+ const stale = `waiting for repository slot — ${repo} already active (Issue stale-a)`;
903
+ setQueueWaitReason(getQueuedEntryForIssue(b.id).id, stale);
904
+ // Read model: the dead reason clears to "next up" while a slot is free.
905
+ assert.equal(queueStatusForIssue(b.id)?.waitReason, null);
906
+ assert.equal(listQueuedEntriesForRead().find((e) => e.issueId === b.id)?.waitReason, null);
907
+ // Refresh: the persisted row is overwritten with the current real reason (none — admittable).
908
+ const refreshed = await refreshQueueWaitReasonForIssue(b.id);
909
+ assert.equal(refreshed?.waitReason, null);
910
+ assert.equal(refreshed?.position, 2);
911
+ assert.equal(getQueuedEntryForIssue(b.id)?.waitReason, null);
912
+ // And the issue itself admits alongside its same-repo sibling.
913
+ assert.equal((await admitNext())?.issueId, a.id);
843
914
  assert.equal(getIssue(b.id).status, "developing");
844
915
  });
916
+ test("NOT-371: a stale repository-slot row under a full system reads as the live capacity reason", async () => {
917
+ const a = readyIssue("stalefull-a");
918
+ const b = readyIssue("stalefull-b");
919
+ enqueueIssue(a.id);
920
+ enqueueIssue(b.id);
921
+ await admitNext();
922
+ assert.equal(getIssue(a.id).status, "developing");
923
+ // Default limit 1: the system is full; overwrite b's persisted reason with the dead text.
924
+ setQueueWaitReason(getQueuedEntryForIssue(b.id).id, "waiting for repository slot — acme/old already active (Issue stalefull-a)");
925
+ const read = queueStatusForIssue(b.id)?.waitReason ?? "";
926
+ assert.match(read, /waiting for slot/);
927
+ assert.doesNotMatch(read, /repository slot/);
928
+ });
845
929
  test("NOT-215: blocked/capped entries stay queued while later eligible different-repo entries fill both slots", async () => {
846
930
  setMaxActiveIssues(2);
847
931
  const blocked = linearIssue("blk", "lin-blk");
@@ -901,6 +985,121 @@ test("NOT-215: lowering 2 → 1 never preempts; admission pauses until occupancy
901
985
  assert.equal((await admitNext())?.issueId, c.id);
902
986
  assert.equal(getIssue(c.id).status, "developing");
903
987
  });
988
+ test("NOT-378: limit 5 fills all five slots in one tick across same and different repositories; a sixth waits on global capacity", async () => {
989
+ setMaxActiveIssues(5);
990
+ const repo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-admit-five-"));
991
+ const same1 = readyIssueInRepo("five-s1", repo);
992
+ const same2 = readyIssueInRepo("five-s2", repo);
993
+ const same3 = readyIssueInRepo("five-s3", repo);
994
+ const diff1 = readyIssue("five-d1");
995
+ const diff2 = readyIssue("five-d2");
996
+ const sixth = readyIssue("five-d3");
997
+ enqueueIssue(same1.id);
998
+ enqueueIssue(same2.id);
999
+ enqueueIssue(same3.id);
1000
+ enqueueIssue(diff1.id);
1001
+ enqueueIssue(diff2.id);
1002
+ enqueueIssue(sixth.id);
1003
+ const first = await admitNext();
1004
+ assert.equal(first?.issueId, same1.id);
1005
+ for (const issue of [same1, same2, same3, diff1, diff2]) {
1006
+ assert.equal(getIssue(issue.id).status, "developing");
1007
+ assert.equal(instanceCount(issue.id), 1);
1008
+ }
1009
+ const instanceIds = new Set([same1, same2, same3, diff1, diff2].map((issue) => getActiveWorkflowInstance(issue.id).id));
1010
+ assert.equal(instanceIds.size, 5, "each issue gets its own active workflow instance");
1011
+ assert.equal(getIssue(sixth.id).status, "ready");
1012
+ assert.equal(getActiveWorkflowInstance(sixth.id), null);
1013
+ assert.equal(instanceCount(sixth.id), 0);
1014
+ // Live global wait reason on read — never a repository-slot reason anywhere.
1015
+ const live = queueStatusForIssue(sixth.id)?.waitReason ?? "";
1016
+ assert.match(live, /waiting for slot/);
1017
+ assert.doesNotMatch(live, /repository slot/);
1018
+ for (const read of listQueuedEntriesForRead()) {
1019
+ assert.doesNotMatch(read.waitReason ?? "", /repository slot/);
1020
+ }
1021
+ // A second tick persists the slot reason and admits nothing new.
1022
+ assert.equal(await admitNext(), null);
1023
+ assert.match(getQueuedEntryForIssue(sixth.id)?.waitReason ?? "", /waiting for slot/);
1024
+ assert.equal(instanceCount(sixth.id), 0);
1025
+ });
1026
+ test("NOT-378: declared blockers skip ahead correctly with five slots; the waiter admits once its blocker clears", async () => {
1027
+ setMaxActiveIssues(5);
1028
+ const repo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-admit-fiveblk-"));
1029
+ const blocked = linearIssueInRepo("fblk", "lin-378-blk", repo);
1030
+ setBlockersProviderForTests(async (issues) => new Map(issues.map((i) => [
1031
+ i.externalId,
1032
+ i.externalId === "lin-378-blk"
1033
+ ? [{ id: "", identifier: "NOT-1", stateName: "In Progress", stateType: "started" }]
1034
+ : [],
1035
+ ])));
1036
+ const free = [
1037
+ readyIssueInRepo("fblk-f1", repo),
1038
+ readyIssueInRepo("fblk-f2", repo),
1039
+ readyIssue("fblk-f3"),
1040
+ readyIssue("fblk-f4"),
1041
+ readyIssue("fblk-f5"),
1042
+ ];
1043
+ enqueueIssue(blocked.id);
1044
+ for (const issue of free)
1045
+ enqueueIssue(issue.id);
1046
+ assert.equal((await admitNext())?.issueId, free[0].id);
1047
+ for (const issue of free) {
1048
+ assert.equal(getIssue(issue.id).status, "developing");
1049
+ assert.equal(instanceCount(issue.id), 1);
1050
+ }
1051
+ assert.equal(getIssue(blocked.id).status, "ready");
1052
+ assert.equal(getActiveWorkflowInstance(blocked.id), null);
1053
+ assert.equal(instanceCount(blocked.id), 0);
1054
+ const entry = getQueuedEntryForIssue(blocked.id);
1055
+ assert.equal(entry.state, "queued");
1056
+ assert.match(entry.waitReason ?? "", /waiting on NOT-1/);
1057
+ assert.doesNotMatch(entry.waitReason ?? "", /repository slot/);
1058
+ // The declared blocker is satisfied and a slot frees — the next tick admits the waiter.
1059
+ setBlockersProviderForTests(async (issues) => new Map(issues.map((i) => [i.externalId, []])));
1060
+ releaseToNeedsHuman(free[0].id);
1061
+ assert.equal((await admitNext())?.issueId, blocked.id);
1062
+ assert.equal(getIssue(blocked.id).status, "developing");
1063
+ });
1064
+ test("NOT-378: lowering 5 → 2 never preempts; admission pauses until occupancy drops below 2", async () => {
1065
+ setMaxActiveIssues(5);
1066
+ const active = [
1067
+ readyIssue("low5-a"),
1068
+ readyIssue("low5-b"),
1069
+ readyIssue("low5-c"),
1070
+ readyIssue("low5-d"),
1071
+ readyIssue("low5-e"),
1072
+ ];
1073
+ for (const issue of active)
1074
+ enqueueIssue(issue.id);
1075
+ await admitNext();
1076
+ for (const issue of active) {
1077
+ assert.equal(getIssue(issue.id).status, "developing");
1078
+ }
1079
+ setMaxActiveIssues(2);
1080
+ const waiter = readyIssue("low5-w");
1081
+ enqueueIssue(waiter.id);
1082
+ assert.equal(await admitNext(), null);
1083
+ for (const issue of active) {
1084
+ assert.equal(getIssue(issue.id).status, "developing", "lowering never stops running work");
1085
+ }
1086
+ assert.equal(getIssue(waiter.id).status, "ready");
1087
+ assert.match(getQueuedEntryForIssue(waiter.id)?.waitReason ?? "", /waiting for slot/);
1088
+ const over = getAdmissionStatus();
1089
+ assert.equal(over.active, 5);
1090
+ assert.equal(over.limit, 2);
1091
+ assert.equal(over.overCap, true);
1092
+ // Occupancy at the new limit is still not enough — it must fall *below* 2.
1093
+ releaseToNeedsHuman(active[0].id);
1094
+ releaseToNeedsHuman(active[1].id);
1095
+ releaseToNeedsHuman(active[2].id);
1096
+ assert.equal(await admitNext(), null);
1097
+ assert.equal(getIssue(waiter.id).status, "ready");
1098
+ assert.equal(getAdmissionStatus().overCap, false);
1099
+ releaseToNeedsHuman(active[3].id);
1100
+ assert.equal((await admitNext())?.issueId, waiter.id);
1101
+ assert.equal(getIssue(waiter.id).status, "developing");
1102
+ });
904
1103
  test("NOT-215: restart recovery observes the persisted setting without duplicate admission", async () => {
905
1104
  setMaxActiveIssues(2);
906
1105
  const a = readyIssue("rs-a");