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.
- package/bundle/server/dist/coordinator/admission.js +23 -72
- package/bundle/server/dist/coordinator/admission.test.js +213 -14
- package/bundle/server/dist/coordinator/reviewer-browser-probe.js +1004 -0
- package/bundle/server/dist/coordinator/reviewer-browser-probe.test.js +680 -0
- package/bundle/server/dist/coordinator/worker-loop.js +1 -1
- package/bundle/server/dist/repository/admission-settings.js +5 -5
- package/bundle/server/dist/repository/admission-settings.test.js +71 -15
- package/bundle/server/dist/routes/execute-now.integration.test.js +36 -15
- package/bundle/server/dist/routes/issues.test.js +7 -6
- package/bundle/server/dist/routes/queue.js +1 -1
- package/bundle/server/dist/routes/queue.test.js +55 -20
- package/bundle/server/dist/runners/process-registry.js +1 -1
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/{index-FCHzrnLr.js → index-Bz_c-UbO.js} +13 -13
- package/bundle/server/static-ui/index.html +1 -1
- package/bundle/shared/dist/queue-entries.d.ts +5 -4
- package/bundle/shared/dist/queue-entries.js +5 -4
- package/bundle/shared/package.json +1 -1
- package/package.json +1 -1
- package/templates/prod.env.example +5 -2
|
@@ -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 ??
|
|
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:
|
|
470
|
-
*
|
|
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
|
|
493
|
-
//
|
|
494
|
-
//
|
|
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,
|
|
518
|
-
//
|
|
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,
|
|
555
|
-
*
|
|
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-
|
|
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
|
-
|
|
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, "
|
|
833
|
-
assert.equal(
|
|
834
|
-
assert.equal(instanceCount(
|
|
835
|
-
|
|
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 ?? "", /
|
|
838
|
-
assert.
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
releaseToNeedsHuman(
|
|
842
|
-
assert.equal((await admitNext())?.issueId,
|
|
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");
|