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.
- package/bundle/server/dist/adapters/agent-health.js +21 -0
- package/bundle/server/dist/coordinator/admission.js +23 -72
- package/bundle/server/dist/coordinator/admission.test.js +98 -14
- package/bundle/server/dist/coordinator/auto-merge.js +8 -1
- package/bundle/server/dist/coordinator/commands.js +153 -13
- package/bundle/server/dist/coordinator/human-resolution.js +4 -0
- package/bundle/server/dist/coordinator/human-resolution.test.js +292 -2
- package/bundle/server/dist/coordinator/projection.js +26 -3
- package/bundle/server/dist/coordinator/projection.test.js +8 -1
- package/bundle/server/dist/coordinator/routing.js +96 -0
- package/bundle/server/dist/coordinator/routing.test.js +163 -0
- package/bundle/server/dist/coordinator/runtime-auth-park.js +97 -0
- package/bundle/server/dist/coordinator/worker-loop.js +15 -0
- package/bundle/server/dist/docs-host-awake.test.js +16 -0
- package/bundle/server/dist/index.js +14 -1
- package/bundle/server/dist/power/host-awake-lifecycle.js +30 -0
- package/bundle/server/dist/power/host-awake-lifecycle.test.js +181 -0
- package/bundle/server/dist/power/host-awake.js +162 -0
- package/bundle/server/dist/power/host-awake.test.js +192 -0
- package/bundle/server/dist/power/sleep-timer.js +86 -0
- package/bundle/server/dist/power/sleep-timer.test.js +79 -0
- package/bundle/server/dist/power/status.js +31 -0
- package/bundle/server/dist/power/status.test.js +60 -0
- package/bundle/server/dist/routes/execute-now.integration.test.js +36 -15
- package/bundle/server/dist/routes/index.js +3 -0
- package/bundle/server/dist/routes/issues.test.js +7 -6
- package/bundle/server/package.json +4 -3
- package/bundle/server/static-ui/assets/{index-DVyqblie.js → index-Bm74Vq_X.js} +13 -13
- package/bundle/server/static-ui/assets/index-DL8mEXzN.css +1 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/package.json +1 -1
- package/dist/bin.js +3 -0
- package/dist/node-hardening.d.ts +9 -0
- package/dist/node-hardening.js +38 -0
- package/dist/ports.js +54 -14
- package/dist/stop-einval.test.d.ts +1 -0
- package/dist/stop-einval.test.js +215 -0
- package/dist/stop.d.ts +6 -1
- package/dist/stop.js +18 -7
- package/dist/version-output.test.js +23 -16
- package/package.json +1 -1
- 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 ??
|
|
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");
|
|
@@ -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
|
-
|
|
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
|
}
|