agent-dealer 1.2.16 → 1.3.0

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");
@@ -2,8 +2,9 @@
2
2
  //
3
3
  // NOT-217 acceptance: strict direct execution bypasses queue order only. Execute now
4
4
  // admits an eligible issue immediately (queued or not) and refuses — without any
5
- // enqueue, move, or reorder — on capacity, repository, readiness, blocker, health, or
6
- // runtime-cap failure. Concurrent attempts cannot double-start or exceed capacity.
5
+ // enqueue, move, or reorder — on capacity, readiness, blocker, health, or runtime-cap
6
+ // failure (NOT-371: same-repository siblings admit while a global slot is free).
7
+ // Concurrent attempts cannot double-start or exceed capacity.
7
8
  import { test, before, beforeEach, after } from "node:test";
8
9
  import assert from "node:assert/strict";
9
10
  import fs from "node:fs";
@@ -180,28 +181,48 @@ test("execute now refuses on an unmet Linear blocker without queue movement", as
180
181
  }
181
182
  await app.close();
182
183
  });
183
- test("execute now refuses when the same repository already has an active issue", async () => {
184
+ test("NOT-371: execute now admits a same-repository issue when a global slot is free", async () => {
184
185
  const app = await buildApp();
185
186
  const { setMaxActiveIssues } = await import("../repository/admission-settings.js");
186
187
  try {
187
- // Persisted NOT-215 limit 2: global capacity stays free so the per-repository
188
- // exclusion is the reason under test.
188
+ // Persisted NOT-215 limit 2: the second global slot is free, so the
189
+ // same-repository sibling must admit — repository identity never refuses.
189
190
  setMaxActiveIssues(2);
190
191
  const active = await createIssueViaApi(app, "Active", { repo: "acme/same-repo" });
191
192
  assert.equal((await app.inject({ method: "POST", url: `/api/issues/${active}/execute` })).statusCode, 200);
192
- const clash = await createIssueViaApi(app, "Clash", { repo: "acme/same-repo", enqueue: false });
193
- const res = await app.inject({ method: "POST", url: `/api/issues/${clash}/execute` });
193
+ const sibling = await createIssueViaApi(app, "Sibling", { repo: "acme/same-repo", enqueue: false });
194
+ const res = await app.inject({ method: "POST", url: `/api/issues/${sibling}/execute` });
195
+ assert.equal(res.statusCode, 200, res.body);
196
+ assert.equal(res.json().state, "admitted");
197
+ assert.equal(getIssue(sibling).status, "developing");
198
+ assert.equal(getQueuedEntryForIssue(sibling), null, "success on an unqueued issue writes no queue row");
199
+ assert.equal(listWorkflowInstancesForIssue(sibling).length, 1);
200
+ }
201
+ finally {
202
+ setMaxActiveIssues(1);
203
+ }
204
+ await app.close();
205
+ });
206
+ test("NOT-371: execute now refuses a same-repository issue at full global capacity without queue mutation", async () => {
207
+ const app = await buildApp();
208
+ const { setMaxActiveIssues } = await import("../repository/admission-settings.js");
209
+ try {
210
+ setMaxActiveIssues(2);
211
+ const first = await createIssueViaApi(app, "First", { repo: "acme/same-repo" });
212
+ const second = await createIssueViaApi(app, "Second", { repo: "acme/same-repo", enqueue: false });
213
+ assert.equal((await app.inject({ method: "POST", url: `/api/issues/${first}/execute` })).statusCode, 200);
214
+ assert.equal((await app.inject({ method: "POST", url: `/api/issues/${second}/execute` })).statusCode, 200);
215
+ const q1 = await createIssueViaApi(app, "Q1", { repo: "acme/same-repo" });
216
+ const res = await app.inject({ method: "POST", url: `/api/issues/${q1}/execute` });
194
217
  assert.equal(res.statusCode, 409, res.body);
195
218
  const body = res.json();
196
219
  assert.equal(body.state, "refused");
197
- assert.match(body.reason, /repository slot/);
198
- assert.equal(body.queued, false);
199
- assert.equal(getQueuedEntryForIssue(clash), null, "a refused execute never enqueues");
200
- assert.equal(listWorkflowInstancesForIssue(clash).length, 0);
201
- // A different repository still admits under the same free global slot.
202
- const other = await createIssueViaApi(app, "Other repo", { repo: "acme/other-repo", enqueue: false });
203
- const otherRes = await app.inject({ method: "POST", url: `/api/issues/${other}/execute` });
204
- assert.equal(otherRes.statusCode, 200, otherRes.body);
220
+ assert.match(body.reason, /waiting for slot/);
221
+ assert.doesNotMatch(body.reason, /repository slot/);
222
+ assert.equal(body.queued, true);
223
+ assert.equal(body.position, 1);
224
+ assert.deepEqual(queuedIds(), [q1], "a refused execute never reorders");
225
+ assert.equal(listWorkflowInstancesForIssue(q1).length, 0);
205
226
  }
206
227
  finally {
207
228
  setMaxActiveIssues(1);
@@ -877,12 +877,12 @@ test("NOT-240: PATCH repository plus agents on an unqueued ready issue succeeds,
877
877
  });
878
878
  await app.close();
879
879
  });
880
- test("NOT-240: PATCH repository on a queued ready issue preserves position and recomputes the repository wait reason", async () => {
880
+ test("NOT-371: PATCH repository onto an active sibling's repo keeps position with no repository wait reason", async () => {
881
881
  const { setMaxActiveIssues } = await import("../repository/admission-settings.js");
882
882
  const app = await buildApp();
883
883
  try {
884
- // Two global slots: one admitted issue never blocks on capacity, so the
885
- // per-repository exclusion is the reason under test.
884
+ // Two global slots: one admitted issue never blocks on capacity, so the free
885
+ // slot proves repository identity alone never gates the queued sibling.
886
886
  setMaxActiveIssues(2);
887
887
  const mk = (title, repo) => app.inject({
888
888
  method: "POST",
@@ -901,7 +901,8 @@ test("NOT-240: PATCH repository on a queued ready issue preserves position and r
901
901
  const queuedId = (await mk("Queued mover", "acme/cold-repo")).json().id;
902
902
  assert.equal(queueStatusForIssue(queuedId)?.position, 1);
903
903
  assert.equal(queueStatusForIssue(queuedId)?.waitReason, null);
904
- // Move onto the occupied repository: same position, repository-slot wait reason.
904
+ // Move onto the active sibling's repository: same position, still "next up" —
905
+ // no repository-slot wait reason is ever synthesized.
905
906
  const clash = await app.inject({
906
907
  method: "PATCH",
907
908
  url: `/api/issues/${queuedId}`,
@@ -910,8 +911,8 @@ test("NOT-240: PATCH repository on a queued ready issue preserves position and r
910
911
  assert.equal(clash.statusCode, 200, clash.body);
911
912
  assert.equal(clash.json().repo, "github.com/acme/hot-repo");
912
913
  assert.equal(queueStatusForIssue(queuedId)?.position, 1, "a repository change must not reorder");
913
- assert.match(queueStatusForIssue(queuedId)?.waitReason ?? "", /repository slot/);
914
- // Move off to a free repository: stale wait text is cleared, position kept.
914
+ assert.equal(queueStatusForIssue(queuedId)?.waitReason, null);
915
+ // Move off to another repository: identical read state, position kept.
915
916
  const free = await app.inject({
916
917
  method: "PATCH",
917
918
  url: `/api/issues/${queuedId}`,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-dealer/server",
3
- "version": "1.2.16",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "files": [
@@ -23,7 +23,7 @@
23
23
  "test": "AGENT_DEALER_SKIP_GITHUB_HEALTH=1 AGENT_DEALER_SKIP_AGENT_HEALTH=1 npm run build -w @agent-dealer/shared && AGENT_DEALER_SKIP_GITHUB_HEALTH=1 AGENT_DEALER_SKIP_AGENT_HEALTH=1 node ../../scripts/run-workspace-tests.mjs"
24
24
  },
25
25
  "dependencies": {
26
- "@agent-dealer/shared": "1.2.16",
26
+ "@agent-dealer/shared": "1.3.0",
27
27
  "@fastify/cors": "^11.0.1",
28
28
  "@fastify/static": "^8.2.0",
29
29
  "@modelcontextprotocol/sdk": "^1.29.0",