agent-dealer 1.1.7 → 1.1.9

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 (35) hide show
  1. package/bundle/server/dist/adapters/agent-health.js +78 -33
  2. package/bundle/server/dist/adapters/agent-health.test.js +155 -1
  3. package/bundle/server/dist/adapters/linear-inbox.js +11 -2
  4. package/bundle/server/dist/adapters/linear-inbox.test.js +49 -1
  5. package/bundle/server/dist/coordinator/admission.test.js +35 -0
  6. package/bundle/server/dist/coordinator/commands.js +152 -12
  7. package/bundle/server/dist/coordinator/commands.test.js +132 -2
  8. package/bundle/server/dist/coordinator/developer-effect.js +23 -0
  9. package/bundle/server/dist/coordinator/developer-effect.test.js +75 -0
  10. package/bundle/server/dist/coordinator/resume-live-head.js +54 -0
  11. package/bundle/server/dist/coordinator/resume-live-head.test.js +42 -0
  12. package/bundle/server/dist/coordinator/reviewer-effect.js +11 -2
  13. package/bundle/server/dist/coordinator/reviewer-effect.test.js +60 -0
  14. package/bundle/server/dist/routes/intake-linear.test.js +91 -0
  15. package/bundle/server/dist/routes/issues.js +37 -7
  16. package/bundle/server/dist/routes/issues.test.js +333 -1
  17. package/bundle/server/dist/runners/spawn-cli.js +16 -1
  18. package/bundle/server/dist/runners/spawn-cli.test.js +30 -1
  19. package/bundle/server/package.json +2 -2
  20. package/bundle/server/static-ui/assets/index-BrFYuzDI.js +60 -0
  21. package/bundle/server/static-ui/assets/index-D2O482vN.css +1 -0
  22. package/bundle/server/static-ui/index.html +2 -2
  23. package/bundle/shared/dist/agents.d.ts +15 -15
  24. package/bundle/shared/dist/agents.js +7 -0
  25. package/bundle/shared/dist/index.d.ts +47 -9
  26. package/bundle/shared/dist/index.js +7 -0
  27. package/bundle/shared/dist/linear-intake.d.ts +50 -0
  28. package/bundle/shared/dist/linear-intake.js +67 -0
  29. package/bundle/shared/dist/linear-intake.test.d.ts +1 -0
  30. package/bundle/shared/dist/linear-intake.test.js +85 -0
  31. package/bundle/shared/dist/workflow.js +3 -2
  32. package/bundle/shared/package.json +1 -1
  33. package/package.json +1 -1
  34. package/bundle/server/static-ui/assets/index-BFNVs6dA.js +0 -60
  35. package/bundle/server/static-ui/assets/index-BrC-jEt_.css +0 -1
@@ -299,25 +299,83 @@ function claudeUsesThirdPartyProvider() {
299
299
  */
300
300
  export async function githubIssuesUncached() {
301
301
  const status = await runCommand("gh", ["auth", "status"]);
302
- const out = status.output.toLowerCase();
303
- if (out.includes("enoent") ||
302
+ return classifyGithubAuthStatus({
303
+ ok: status.ok,
304
+ output: status.output,
305
+ timedOut: status.timedOut,
306
+ });
307
+ }
308
+ const GITHUB_CLI_MISSING_MESSAGE = "gh CLI not found — install GitHub CLI (`gh`)";
309
+ const GITHUB_AUTH_MESSAGE = "Run `gh auth login` — GitHub CLI auth required to open PRs";
310
+ const GITHUB_UNREACHABLE_MESSAGE = "Can't reach GitHub (network or keyring timeout) — check your VPN; Dealer will retry";
311
+ /**
312
+ * Output fragments naming a timeout, TLS handshake, DNS or connection failure.
313
+ * Matched against lowercased `gh` output. `timedout` (no space) covers ETIMEDOUT;
314
+ * `timeout` covers gh's own "Timeout trying to log in … (keyring)" and the
315
+ * "timeout while trying to … secret in/from keyring" variants.
316
+ */
317
+ const GITHUB_UNREACHABLE_PATTERNS = [
318
+ "timed out",
319
+ "timeout",
320
+ "timedout",
321
+ "tls",
322
+ "ssl",
323
+ "handshake",
324
+ "certificate",
325
+ "dns",
326
+ "could not resolve",
327
+ "no such host",
328
+ "name resolution",
329
+ "eai_again",
330
+ "enotfound",
331
+ "getaddrinfo",
332
+ "connection refused",
333
+ "connection reset",
334
+ "failed to connect",
335
+ "could not connect",
336
+ "network is unreachable",
337
+ "network unreachable",
338
+ "econnrefused",
339
+ "econnreset",
340
+ "ehostunreach",
341
+ "enetunreach",
342
+ "econnaborted",
343
+ "socket hang up",
344
+ ];
345
+ /**
346
+ * NOT-195: one `gh auth status` classifier shared by the async (`runCommand`) and
347
+ * sync (`spawnSync`) paths. Four cases, checked in order:
348
+ *
349
+ * 1. CLI missing — `ENOENT` (or the pre-existing not-found texts) only. A spawn
350
+ * timeout also sets the sync `error`, so any-error-means-missing misread a slow
351
+ * network as an absent binary.
352
+ * 2. Unreachable — spawn timeout, `ETIMEDOUT`, or output naming a timeout / TLS /
353
+ * DNS / connection failure. Checked before auth because gh maps some
354
+ * connectivity failures onto login/token wording (and the keyring-login timeout
355
+ * names neither). Transient: callers defer and retry.
356
+ * 3. Logged out / invalid token — the pre-existing texts, or any other non-zero exit.
357
+ * 4. Healthy.
358
+ */
359
+ export function classifyGithubAuthStatus(probe) {
360
+ const out = probe.output.toLowerCase();
361
+ if (probe.spawnErrorCode === "ENOENT" ||
362
+ out.includes("enoent") ||
304
363
  out.includes("not found") ||
305
- out.includes("no such file") ||
306
- (out.includes("spawn") && out.includes("gh"))) {
307
- return [{ code: "github_cli_missing", message: "gh CLI not found — install GitHub CLI (`gh`)" }];
364
+ out.includes("no such file")) {
365
+ return [{ code: "github_cli_missing", message: GITHUB_CLI_MISSING_MESSAGE }];
308
366
  }
309
- if (!status.ok ||
367
+ if (probe.timedOut ||
368
+ probe.spawnErrorCode === "ETIMEDOUT" ||
369
+ GITHUB_UNREACHABLE_PATTERNS.some((p) => out.includes(p))) {
370
+ return [{ code: "github_unreachable", message: GITHUB_UNREACHABLE_MESSAGE }];
371
+ }
372
+ if (!probe.ok ||
310
373
  out.includes("not logged in") ||
311
374
  out.includes("failed to log in") ||
312
375
  out.includes("token in keyring is invalid") ||
313
376
  out.includes("re-authenticate") ||
314
377
  out.includes("to re-authenticate")) {
315
- return [
316
- {
317
- code: "github_auth",
318
- message: "Run `gh auth login` — GitHub CLI auth required to open PRs",
319
- },
320
- ];
378
+ return [{ code: "github_auth", message: GITHUB_AUTH_MESSAGE }];
321
379
  }
322
380
  return [];
323
381
  }
@@ -343,27 +401,14 @@ export function githubIssuesSync() {
343
401
  env: process.env,
344
402
  });
345
403
  const output = `${result.stdout ?? ""}${result.stderr ?? ""}${result.error?.message ?? ""}`;
346
- const out = output.toLowerCase();
347
- let issues = [];
348
- if (result.error ||
349
- out.includes("enoent") ||
350
- out.includes("not found") ||
351
- out.includes("no such file")) {
352
- issues = [{ code: "github_cli_missing", message: "gh CLI not found — install GitHub CLI (`gh`)" }];
353
- }
354
- else if (result.status !== 0 ||
355
- out.includes("not logged in") ||
356
- out.includes("failed to log in") ||
357
- out.includes("token in keyring is invalid") ||
358
- out.includes("re-authenticate") ||
359
- out.includes("to re-authenticate")) {
360
- issues = [
361
- {
362
- code: "github_auth",
363
- message: "Run `gh auth login` — GitHub CLI auth required to open PRs",
364
- },
365
- ];
366
- }
404
+ // NOT-195: same four-case classifier as the async path — a spawnSync timeout sets
405
+ // `error` (ETIMEDOUT), which must not read as a missing CLI.
406
+ const issues = classifyGithubAuthStatus({
407
+ ok: result.status === 0,
408
+ output,
409
+ timedOut: result.error?.code === "ETIMEDOUT",
410
+ spawnErrorCode: result.error?.code,
411
+ });
367
412
  githubIssueCache = { at: Date.now(), issues };
368
413
  return issues;
369
414
  }
@@ -15,7 +15,7 @@ import { fileURLToPath } from "node:url";
15
15
  process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-agenthealth-"));
16
16
  const { migrate, getDb } = await import("../db/index.js");
17
17
  const { createAgent, getAgent } = await import("../repository/agents.js");
18
- const { healthForAgent, runtimeIssuesUncached, githubIssuesUncached, clearAgentHealthCaches, setCursorProbeTimingForTests, setRunCommandForTests, } = await import("./agent-health.js");
18
+ const { healthForAgent, runtimeIssuesUncached, githubIssuesUncached, githubIssuesSync, classifyGithubAuthStatus, clearAgentHealthCaches, setCursorProbeTimingForTests, setRunCommandForTests, } = await import("./agent-health.js");
19
19
  migrate();
20
20
  const FAILURE = { ok: false, code: "DECK_UNAVAILABLE", message: "Agent Deck API error: 502" };
21
21
  /** Tests inject an empty github list so host `gh auth` does not pollute assertions. */
@@ -461,4 +461,158 @@ The keychain item is stuck. Delete it and sign in again:
461
461
  assert.match(issue.message, /expected 127\.0\.0\.1:1110/);
462
462
  assert.equal(issue.message.includes("Run agent-deck setup"), false);
463
463
  });
464
+ // ---------------------------------------------------------------------------
465
+ // NOT-195: a `gh` timeout is a transient network problem (`github_unreachable`),
466
+ // not "gh CLI not found" or "Run `gh auth login`". Fixtures in
467
+ // packages/shared/src/fixtures/runtime-auth/gh-auth-status-*.txt.
468
+ // ---------------------------------------------------------------------------
469
+ const GH_FIXTURE = (name) => fs.readFileSync(path.join(FIXTURES, name), "utf8");
470
+ async function uncachedWithGhOutput(output, ok) {
471
+ setRunCommandForTests(async () => ({ ok, output, timedOut: false }));
472
+ clearAgentHealthCaches();
473
+ try {
474
+ return await githubIssuesUncached();
475
+ }
476
+ finally {
477
+ setRunCommandForTests(null);
478
+ clearAgentHealthCaches();
479
+ }
480
+ }
481
+ test("NOT-195: keyring-timeout capture reports github_unreachable, not github_auth", async () => {
482
+ const issues = await uncachedWithGhOutput(GH_FIXTURE("gh-auth-status-keyring-timeout.txt"), false);
483
+ assert.deepEqual(issues.map((i) => i.code), ["github_unreachable"]);
484
+ assert.match(issues[0].message, /Can't reach GitHub/);
485
+ assert.match(issues[0].message, /VPN/);
486
+ assert.doesNotMatch(issues[0].message, /gh auth login/);
487
+ });
488
+ test("NOT-195: logged-out capture still reports github_auth with the gh auth login message", async () => {
489
+ const issues = await uncachedWithGhOutput(GH_FIXTURE("gh-auth-status-logged-out.txt"), false);
490
+ assert.deepEqual(issues.map((i) => i.code), ["github_auth"]);
491
+ assert.match(issues[0].message, /gh auth login/);
492
+ });
493
+ test("NOT-195: invalid-token capture still reports github_auth with the gh auth login message", async () => {
494
+ const issues = await uncachedWithGhOutput(GH_FIXTURE("gh-auth-status-invalid-token.txt"), false);
495
+ assert.deepEqual(issues.map((i) => i.code), ["github_auth"]);
496
+ assert.match(issues[0].message, /gh auth login/);
497
+ });
498
+ test("NOT-195: logged-in capture leaves github health empty", async () => {
499
+ const issues = await uncachedWithGhOutput(GH_FIXTURE("gh-auth-status-logged-in.txt"), true);
500
+ assert.deepEqual(issues, []);
501
+ });
502
+ test("NOT-195: an async probe timeout reports github_unreachable, not github_cli_missing", async () => {
503
+ setRunCommandForTests(async () => ({ ok: false, output: "timeout", timedOut: true }));
504
+ clearAgentHealthCaches();
505
+ try {
506
+ const issues = await githubIssuesUncached();
507
+ assert.deepEqual(issues.map((i) => i.code), ["github_unreachable"]);
508
+ }
509
+ finally {
510
+ setRunCommandForTests(null);
511
+ clearAgentHealthCaches();
512
+ }
513
+ });
514
+ test("NOT-195: ETIMEDOUT reports github_unreachable; only ENOENT reports github_cli_missing", async () => {
515
+ // Sync spawnSync shapes: a timeout sets error.code ETIMEDOUT, a missing binary ENOENT.
516
+ const timedOut = classifyGithubAuthStatus({
517
+ ok: false,
518
+ output: "spawnSync gh ETIMEDOUT",
519
+ timedOut: true,
520
+ spawnErrorCode: "ETIMEDOUT",
521
+ });
522
+ assert.deepEqual(timedOut.map((i) => i.code), ["github_unreachable"]);
523
+ const etimedoutOutput = classifyGithubAuthStatus({
524
+ ok: false,
525
+ output: "spawnSync gh ETIMEDOUT",
526
+ timedOut: false,
527
+ spawnErrorCode: "ETIMEDOUT",
528
+ });
529
+ assert.deepEqual(etimedoutOutput.map((i) => i.code), ["github_unreachable"]);
530
+ const missing = classifyGithubAuthStatus({
531
+ ok: false,
532
+ output: "spawnSync gh ENOENT",
533
+ timedOut: false,
534
+ spawnErrorCode: "ENOENT",
535
+ });
536
+ assert.deepEqual(missing.map((i) => i.code), ["github_cli_missing"]);
537
+ // A non-ENOENT spawn failure must not read as a missing CLI (fail-closed as auth).
538
+ const otherSpawnError = classifyGithubAuthStatus({
539
+ ok: false,
540
+ output: "spawnSync gh EACCES",
541
+ timedOut: false,
542
+ spawnErrorCode: "EACCES",
543
+ });
544
+ assert.deepEqual(otherSpawnError.map((i) => i.code), ["github_auth"]);
545
+ // gh's sibling token-path timeout variant (format verified in the gh 2.78.0 binary:
546
+ // "%s Timeout trying to log in to %s using token (%s)") is unreachable too.
547
+ const tokenTimeout = classifyGithubAuthStatus({
548
+ ok: false,
549
+ output: "ghe.example.com\n X Timeout trying to log in to ghe.example.com using token (GH_TOKEN)",
550
+ timedOut: false,
551
+ });
552
+ assert.deepEqual(tokenTimeout.map((i) => i.code), ["github_unreachable"]);
553
+ });
554
+ /** A `gh` stub on PATH that prints a captured fixture verbatim and exits with the captured code. */
555
+ function stubGhOnPath(fixture, exitCode) {
556
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-gh-stub-"));
557
+ const bin = path.join(dir, "gh");
558
+ fs.writeFileSync(bin, `#!/bin/sh\ncat ${JSON.stringify(path.join(FIXTURES, fixture))}\nexit ${exitCode}\n`);
559
+ fs.chmodSync(bin, 0o755);
560
+ const prevPath = process.env.PATH ?? "";
561
+ process.env.PATH = `${dir}${path.delimiter}${prevPath}`;
562
+ return prevPath;
563
+ }
564
+ /**
565
+ * The sync entry point, through the real spawnSync plumbing. `test:unit` sets
566
+ * AGENT_DEALER_SKIP_GITHUB_HEALTH=1, which would short-circuit to [] — clear it so
567
+ * the classifier actually runs.
568
+ */
569
+ function syncWithStubGh(fixture, exitCode) {
570
+ const prevPath = stubGhOnPath(fixture, exitCode);
571
+ const prevSkip = process.env.AGENT_DEALER_SKIP_GITHUB_HEALTH;
572
+ delete process.env.AGENT_DEALER_SKIP_GITHUB_HEALTH;
573
+ clearAgentHealthCaches();
574
+ try {
575
+ return githubIssuesSync();
576
+ }
577
+ finally {
578
+ process.env.PATH = prevPath;
579
+ if (prevSkip === undefined)
580
+ delete process.env.AGENT_DEALER_SKIP_GITHUB_HEALTH;
581
+ else
582
+ process.env.AGENT_DEALER_SKIP_GITHUB_HEALTH = prevSkip;
583
+ clearAgentHealthCaches();
584
+ }
585
+ }
586
+ test("NOT-195: githubIssuesSync follows the same classification over the three fixtures", async () => {
587
+ const unreachable = syncWithStubGh("gh-auth-status-keyring-timeout.txt", 1);
588
+ assert.deepEqual(unreachable.map((i) => i.code), ["github_unreachable"]);
589
+ assert.match(unreachable[0].message, /Can't reach GitHub/);
590
+ const loggedOut = syncWithStubGh("gh-auth-status-logged-out.txt", 1);
591
+ assert.deepEqual(loggedOut.map((i) => i.code), ["github_auth"]);
592
+ assert.match(loggedOut[0].message, /gh auth login/);
593
+ const invalidToken = syncWithStubGh("gh-auth-status-invalid-token.txt", 1);
594
+ assert.deepEqual(invalidToken.map((i) => i.code), ["github_auth"]);
595
+ const healthy = syncWithStubGh("gh-auth-status-logged-in.txt", 0);
596
+ assert.deepEqual(healthy, []);
597
+ });
598
+ test("NOT-195: github_unreachable marks the agent unhealthy so admission/review defers without spending an attempt", async () => {
599
+ // Any nonCap health issue fails checkRoleAgentHealthy, and the worker loop routes
600
+ // that through the existing deferLeasedWorkItemForAgentUnhealthy infra-defer path
601
+ // (agent_unhealthy outcome — no infra attempt, no review round spent), exactly as
602
+ // github_auth already behaves. No new wiring: the code IS the defer path.
603
+ const agent = createAgent({
604
+ name: "gh-down",
605
+ runtime: "claude_code",
606
+ deckId: "00000000-0000-4000-a000-000000000099",
607
+ });
608
+ const gh = classifyGithubAuthStatus({
609
+ ok: false,
610
+ output: GH_FIXTURE("gh-auth-status-keyring-timeout.txt"),
611
+ timedOut: false,
612
+ });
613
+ assert.deepEqual(gh.map((i) => i.code), ["github_unreachable"]);
614
+ const result = await healthForAgent(agent, true, new Map(), true, null, gh);
615
+ assert.equal(result.healthy, false);
616
+ assert.equal(result.issues.some((i) => i.code === "github_unreachable"), true);
617
+ });
464
618
  }); // describe agent-health (serial)
@@ -1,3 +1,4 @@
1
+ import { resolveLinearRepoLabels } from "@agent-dealer/shared";
1
2
  import { DEFAULT_LINEAR_STATE_FILTER, getLinearIntakeConfig } from "../repository/intake-settings.js";
2
3
  import { linearGraphqlRequest } from "./linear-graphql.js";
3
4
  export { DEFAULT_LINEAR_STATE_FILTER };
@@ -13,7 +14,14 @@ async function linearQuery(operation, query, variables, opts) {
13
14
  timeoutMs: opts?.timeoutMs,
14
15
  });
15
16
  }
16
- function nodeToCandidate(n) {
17
+ /**
18
+ * NOT-242: every candidate carries a server-resolved repository hint read from
19
+ * explicit `repo:` labels. Raw labels stay on the candidate; resolution is
20
+ * deterministic (one valid label resolves, zero is unresolved, several
21
+ * conflict, a bad value is invalid) and never guesses from anything else.
22
+ */
23
+ export function nodeToCandidate(n) {
24
+ const labels = n.labels?.nodes.map((l) => l.name) ?? [];
17
25
  return {
18
26
  id: n.id,
19
27
  identifier: n.identifier,
@@ -22,7 +30,8 @@ function nodeToCandidate(n) {
22
30
  url: n.url,
23
31
  state: n.state?.name,
24
32
  teamId: n.team?.id,
25
- labels: n.labels?.nodes.map((l) => l.name) ?? [],
33
+ labels,
34
+ repoResolution: resolveLinearRepoLabels(labels),
26
35
  };
27
36
  }
28
37
  const ISSUE_FIELDS = `
@@ -1,6 +1,6 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { buildIssueFilter, parseLinearIssueRef } from "./linear-inbox.js";
3
+ import { buildIssueFilter, nodeToCandidate, parseLinearIssueRef } from "./linear-inbox.js";
4
4
  test("parseLinearIssueRef accepts identifier, URL, and UUID", () => {
5
5
  assert.equal(parseLinearIssueRef("NOT-103"), "NOT-103");
6
6
  assert.equal(parseLinearIssueRef(" not-90 "), "NOT-90");
@@ -21,6 +21,54 @@ test("buildIssueFilter omits assignee when assigneeMe is false", () => {
21
21
  team: { id: { eq: "team-1" } },
22
22
  });
23
23
  });
24
+ test("nodeToCandidate resolves exactly one repo: label to the canonical identity", () => {
25
+ const c = nodeToCandidate({
26
+ id: "uuid-1",
27
+ identifier: "NOT-242",
28
+ title: "t",
29
+ url: "https://linear.app/not-so-fat/issue/NOT-242/t",
30
+ labels: { nodes: [{ name: "agent-dealer" }, { name: "repo:github.com/not-so-fat/agent-dealer" }] },
31
+ });
32
+ // Raw labels stay available alongside the resolved hint.
33
+ assert.deepEqual(c.labels, ["agent-dealer", "repo:github.com/not-so-fat/agent-dealer"]);
34
+ assert.equal(c.repoResolution?.status, "resolved");
35
+ assert.equal(c.repoResolution?.repository, "github.com/not-so-fat/agent-dealer");
36
+ assert.equal(c.repoResolution?.sourceLabel, "repo:github.com/not-so-fat/agent-dealer");
37
+ });
38
+ test("nodeToCandidate leaves zero repo: labels unresolved and conflicts several", () => {
39
+ const none = nodeToCandidate({
40
+ id: "uuid-1",
41
+ identifier: "NOT-1",
42
+ title: "t",
43
+ url: "https://linear.app/x/issue/NOT-1/t",
44
+ labels: { nodes: [{ name: "agent-dealer" }] },
45
+ });
46
+ assert.equal(none.repoResolution?.status, "unresolved");
47
+ assert.equal(none.repoResolution?.repository, undefined);
48
+ const conflict = nodeToCandidate({
49
+ id: "uuid-2",
50
+ identifier: "NOT-2",
51
+ title: "t",
52
+ url: "https://linear.app/x/issue/NOT-2/t",
53
+ labels: { nodes: [{ name: "repo:github.com/a/one" }, { name: "repo:github.com/b/two" }] },
54
+ });
55
+ assert.equal(conflict.repoResolution?.status, "conflict");
56
+ assert.equal(conflict.repoResolution?.repository, undefined);
57
+ assert.deepEqual(conflict.repoResolution?.labels, ["repo:github.com/a/one", "repo:github.com/b/two"]);
58
+ });
59
+ test("nodeToCandidate marks a non-GitHub repo: value invalid", () => {
60
+ const c = nodeToCandidate({
61
+ id: "uuid-3",
62
+ identifier: "NOT-3",
63
+ title: "t",
64
+ url: "https://linear.app/x/issue/NOT-3/t",
65
+ labels: { nodes: [{ name: "repo:https://gitlab.com/acme/app" }] },
66
+ });
67
+ assert.equal(c.repoResolution?.status, "invalid");
68
+ assert.equal(c.repoResolution?.repository, undefined);
69
+ assert.deepEqual(c.repoResolution?.labels, ["repo:https://gitlab.com/acme/app"]);
70
+ assert.ok(c.repoResolution?.error);
71
+ });
24
72
  test("buildIssueFilter includes assignee when assigneeMe is true", () => {
25
73
  const filter = buildIssueFilter({
26
74
  stateFilter: ["Todo"],
@@ -716,3 +716,38 @@ test("NOT-157: soft Cursor probe timeout waits with probe-timeout reason, not lo
716
716
  setAdmissionHealthCheckerForTests(async () => ({ ok: true }));
717
717
  }
718
718
  });
719
+ // ---------------------------------------------------------------------------
720
+ // NOT-239: Close issue vs the admission tick — exactly one terminal outcome.
721
+ // ---------------------------------------------------------------------------
722
+ test("NOT-239: closing the queue head lets the next tick admit the following issue", async () => {
723
+ const { closeReadyIssue } = await import("./commands.js");
724
+ const head = readyIssue("close-head");
725
+ const next = readyIssue("close-next");
726
+ enqueueIssue(head.id);
727
+ enqueueIssue(next.id);
728
+ // Close wins the race: the head retires without ever starting.
729
+ assert.deepEqual(closeReadyIssue(head.id, "web"), {
730
+ ok: true,
731
+ issueStatus: "closed",
732
+ alreadyClosed: false,
733
+ });
734
+ const admitted = await admitNext();
735
+ assert.equal(admitted?.issueId, next.id, "the tick fills the freed slot with the next issue");
736
+ assert.equal(getIssue(next.id).status, "developing");
737
+ assert.equal(getIssue(head.id).status, "closed");
738
+ assert.equal(instanceCount(head.id), 0, "close created no workflow instance");
739
+ assert.equal(instanceCount(next.id), 1);
740
+ });
741
+ test("NOT-239: a tick that admitted first makes a late close refuse", async () => {
742
+ const { closeReadyIssue } = await import("./commands.js");
743
+ const issue = readyIssue("close-late");
744
+ enqueueIssue(issue.id);
745
+ const admitted = await admitNext();
746
+ assert.equal(admitted?.issueId, issue.id);
747
+ const late = closeReadyIssue(issue.id, "web");
748
+ assert.equal(late.ok, false);
749
+ if (late.ok)
750
+ throw new Error("expected the late close to refuse");
751
+ assert.equal(late.code, 409);
752
+ assert.equal(getIssue(issue.id).status, "developing");
753
+ });