agent-dealer 1.2.15 → 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.
Files changed (42) hide show
  1. package/bundle/server/dist/adapters/agent-health.js +21 -0
  2. package/bundle/server/dist/coordinator/admission.js +23 -72
  3. package/bundle/server/dist/coordinator/admission.test.js +98 -14
  4. package/bundle/server/dist/coordinator/auto-merge.js +8 -1
  5. package/bundle/server/dist/coordinator/commands.js +153 -13
  6. package/bundle/server/dist/coordinator/human-resolution.js +4 -0
  7. package/bundle/server/dist/coordinator/human-resolution.test.js +292 -2
  8. package/bundle/server/dist/coordinator/projection.js +26 -3
  9. package/bundle/server/dist/coordinator/projection.test.js +8 -1
  10. package/bundle/server/dist/coordinator/routing.js +96 -0
  11. package/bundle/server/dist/coordinator/routing.test.js +163 -0
  12. package/bundle/server/dist/coordinator/runtime-auth-park.js +97 -0
  13. package/bundle/server/dist/coordinator/worker-loop.js +15 -0
  14. package/bundle/server/dist/docs-host-awake.test.js +16 -0
  15. package/bundle/server/dist/index.js +14 -1
  16. package/bundle/server/dist/power/host-awake-lifecycle.js +30 -0
  17. package/bundle/server/dist/power/host-awake-lifecycle.test.js +181 -0
  18. package/bundle/server/dist/power/host-awake.js +162 -0
  19. package/bundle/server/dist/power/host-awake.test.js +192 -0
  20. package/bundle/server/dist/power/sleep-timer.js +86 -0
  21. package/bundle/server/dist/power/sleep-timer.test.js +79 -0
  22. package/bundle/server/dist/power/status.js +31 -0
  23. package/bundle/server/dist/power/status.test.js +60 -0
  24. package/bundle/server/dist/routes/execute-now.integration.test.js +36 -15
  25. package/bundle/server/dist/routes/index.js +3 -0
  26. package/bundle/server/dist/routes/issues.test.js +7 -6
  27. package/bundle/server/package.json +4 -3
  28. package/bundle/server/static-ui/assets/{index-DVyqblie.js → index-Bm74Vq_X.js} +13 -13
  29. package/bundle/server/static-ui/assets/index-DL8mEXzN.css +1 -0
  30. package/bundle/server/static-ui/index.html +2 -2
  31. package/bundle/shared/package.json +1 -1
  32. package/dist/bin.js +3 -0
  33. package/dist/node-hardening.d.ts +9 -0
  34. package/dist/node-hardening.js +38 -0
  35. package/dist/ports.js +54 -14
  36. package/dist/stop-einval.test.d.ts +1 -0
  37. package/dist/stop-einval.test.js +215 -0
  38. package/dist/stop.d.ts +6 -1
  39. package/dist/stop.js +18 -7
  40. package/dist/version-output.test.js +23 -16
  41. package/package.json +1 -1
  42. package/bundle/server/static-ui/assets/index-COYJxD1y.css +0 -1
@@ -104,6 +104,14 @@ let runCommandImpl = defaultRunCommand;
104
104
  export function setRunCommandForTests(fn) {
105
105
  runCommandImpl = fn ?? defaultRunCommand;
106
106
  }
107
+ let runtimeIssuesUncachedForTests = null;
108
+ /**
109
+ * Replace the live auth probe in coordinator tests. Pass `null` to restore the real probe.
110
+ * Cleared by {@link clearAgentHealthCaches}.
111
+ */
112
+ export function setRuntimeIssuesUncachedForTests(fn) {
113
+ runtimeIssuesUncachedForTests = fn;
114
+ }
107
115
  function runCommand(cmd, args, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS, env) {
108
116
  return runCommandImpl(cmd, args, timeoutMs, env);
109
117
  }
@@ -122,6 +130,7 @@ export function clearAgentHealthCaches() {
122
130
  githubIssueCache = null;
123
131
  cursorSoftFailStreak = 0;
124
132
  cursorLastHealthyAt = null;
133
+ runtimeIssuesUncachedForTests = null;
125
134
  resetMuseCapabilityStateForTests();
126
135
  }
127
136
  function isSoftCursorProbeIssue(issue) {
@@ -256,6 +265,9 @@ async function museRuntimeIssues() {
256
265
  }
257
266
  /** Exported for direct testing — bypasses the 60s cache in runtimeIssues(). */
258
267
  export async function runtimeIssuesUncached(runtime) {
268
+ if (runtimeIssuesUncachedForTests) {
269
+ return await runtimeIssuesUncachedForTests(runtime);
270
+ }
259
271
  const issues = [];
260
272
  if (runtime === "claude_code") {
261
273
  if (!claudeBinExists()) {
@@ -542,6 +554,15 @@ export async function healthForAgent(agent, agentDeckOnline, runtimeIssuesByRunt
542
554
  };
543
555
  }
544
556
  export async function listAgentsWithHealth(agents) {
557
+ // NOT-369: ensure the one-time AC sleep-timer notice is computed for the health surface
558
+ // (informational only — never attached as an AgentHealthIssue / never blocks admission).
559
+ try {
560
+ const { ensureSleepTimerCheckedAtStartup } = await import("../power/sleep-timer.js");
561
+ ensureSleepTimerCheckedAtStartup();
562
+ }
563
+ catch {
564
+ // Best-effort: health listing must not fail over power checks.
565
+ }
545
566
  const agentDeckOnline = await checkAgentDeckHealth();
546
567
  const mcpRegistration = checkAgentDeckMcpRegistration();
547
568
  const needsDeckAccess = agents.some((a) => a.deckId);
@@ -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");
@@ -32,6 +32,7 @@ import { MERGE_FAILURE_EVIDENCE_KEY, MERGE_FAILURE_RESPONSE_OPTIONS } from "./hu
32
32
  import { isMergeConflictFailure, runMergeConflictSync } from "./merge-conflict-sync.js";
33
33
  import { triggerLinearPostMerge } from "./linear-merge-verify.js";
34
34
  import { startBaseAdvancedScan } from "./base-advanced-scan.js";
35
+ import { runHeldWork } from "../power/host-awake-lifecycle.js";
35
36
  import { OPERATOR_VERIFICATION_RESPONSE_OPTIONS, formatOperatorCriteria, getOperatorCriteriaForIssue, hasOperatorVerificationForHead, operatorVerificationRequestId, } from "./operator-criteria.js";
36
37
  const run = promisify(execFile);
37
38
  /** Bound each `gh` shell-out so a hang cannot freeze the coordinator process. */
@@ -173,6 +174,11 @@ export function isAutoMergeInFlight(issueId) {
173
174
  export function clearFinalizeInflightForTests() {
174
175
  finalizeInflight.clear();
175
176
  }
177
+ /** Test seam: replace finalizeAutoMergeOnce inside the host-awake hold (lifecycle tests). */
178
+ let finalizeOnceForTests = null;
179
+ export function setFinalizeAutoMergeOnceForTests(fn) {
180
+ finalizeOnceForTests = fn;
181
+ }
176
182
  /**
177
183
  * Completes an auto-merge parked in `final_review` / system ownership after reviewer
178
184
  * approve. Success → done + reflect; failure → needs_human + policy_escalation.
@@ -183,7 +189,8 @@ export function finalizeAutoMerge(issueId) {
183
189
  const existing = finalizeInflight.get(issueId);
184
190
  if (existing)
185
191
  return existing;
186
- const promise = finalizeAutoMergeOnce(issueId).finally(() => {
192
+ // NOT-369: hold idle-sleep for the merge/publish step; release when it settles.
193
+ const promise = runHeldWork(() => (finalizeOnceForTests ?? finalizeAutoMergeOnce)(issueId)).finally(() => {
187
194
  if (finalizeInflight.get(issueId) === promise) {
188
195
  finalizeInflight.delete(issueId);
189
196
  }