agent-dealer 1.1.8 → 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 (31) 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/commands.js +63 -8
  6. package/bundle/server/dist/coordinator/commands.test.js +132 -2
  7. package/bundle/server/dist/coordinator/developer-effect.js +23 -0
  8. package/bundle/server/dist/coordinator/developer-effect.test.js +75 -0
  9. package/bundle/server/dist/coordinator/resume-live-head.js +54 -0
  10. package/bundle/server/dist/coordinator/resume-live-head.test.js +42 -0
  11. package/bundle/server/dist/coordinator/reviewer-effect.js +11 -2
  12. package/bundle/server/dist/coordinator/reviewer-effect.test.js +60 -0
  13. package/bundle/server/dist/routes/intake-linear.test.js +91 -0
  14. package/bundle/server/dist/runners/spawn-cli.js +16 -1
  15. package/bundle/server/dist/runners/spawn-cli.test.js +30 -1
  16. package/bundle/server/package.json +2 -2
  17. package/bundle/server/static-ui/assets/index-BrFYuzDI.js +60 -0
  18. package/bundle/server/static-ui/assets/index-D2O482vN.css +1 -0
  19. package/bundle/server/static-ui/index.html +2 -2
  20. package/bundle/shared/dist/agents.d.ts +15 -15
  21. package/bundle/shared/dist/agents.js +7 -0
  22. package/bundle/shared/dist/index.d.ts +47 -9
  23. package/bundle/shared/dist/index.js +7 -0
  24. package/bundle/shared/dist/linear-intake.d.ts +50 -0
  25. package/bundle/shared/dist/linear-intake.js +67 -0
  26. package/bundle/shared/dist/linear-intake.test.d.ts +1 -0
  27. package/bundle/shared/dist/linear-intake.test.js +85 -0
  28. package/bundle/shared/package.json +1 -1
  29. package/package.json +1 -1
  30. package/bundle/server/static-ui/assets/index-Ct2CCoYn.js +0 -60
  31. package/bundle/server/static-ui/assets/index-DlKwApOA.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"],
@@ -34,6 +34,7 @@ import { externalMergeStateForIssue } from "./external-merge.js";
34
34
  import { detectNonConvergence, formatNonConvergenceReason, reviewHistoryFromEvents, } from "./non-convergence.js";
35
35
  import { capEscalationEvents, deferLeasedWorkItemForBaseFetch, deferLeasedWorkItemForDeckOutage, deferLeasedWorkItemForUsageCap, formatCapEscalationReason, usageCapDeferralStartedAt, } from "./usage-cap-defer.js";
36
36
  import { dequeueIssue, getQueuedEntryForIssue, markQueueEntryAdmitted, } from "../repository/queue-entries.js";
37
+ import { fetchResumeLiveHeadSha } from "./resume-live-head.js";
37
38
  import { getWorkflow } from "./workflows/registry.js";
38
39
  import { DEV_REVIEWER_V1_VERSION } from "./workflows/dev-reviewer-v1.js";
39
40
  /** Default start template — resolved through the workflow registry (NOT-70). */
@@ -1111,12 +1112,20 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1111
1112
  // A reviewer-origin infra escalation (session_failed/publish_failed exhausted) tagged
1112
1113
  // its continuation with where to resume: nothing was wrong with the code/PR, only the
1113
1114
  // reviewer session/publish attempt kept failing, so "resume" must re-queue a reviewer
1114
- // at the still-valid pinned head — not default to an unrelated developer round.
1115
+ // at the pinned head — not default to an unrelated developer round.
1115
1116
  const continuation = parseContinuationPreview(action.continuationPreviewJson);
1116
1117
  const resumeAsReviewer = (action.actionType === "policy_escalation" || action.actionType === "deck_interaction_required") &&
1117
1118
  resolution.choice === "resume" &&
1118
1119
  continuation?.resumeRole === "reviewer" &&
1119
1120
  !!continuation.resumeHeadSha;
1121
+ // NOT-226: the pinned head may be stale — an operator can push a fix to the PR branch
1122
+ // while the issue is parked. Re-check the PR's live head (same `gh pr view` head the
1123
+ // reviewer uses for stale detection) and re-pin to it when it moved. Only when the
1124
+ // issue has a PR; an unknown live head (lookup failure/timeout) keeps today's behavior.
1125
+ const pinnedResumeHeadSha = resumeAsReviewer ? continuation.resumeHeadSha : null;
1126
+ const liveResumeHeadSha = resumeAsReviewer && issue.prNumber != null && opts?.resumeLiveHeadSha ? opts.resumeLiveHeadSha : null;
1127
+ const rePinnedResumeHeadSha = liveResumeHeadSha && liveResumeHeadSha !== pinnedResumeHeadSha ? liveResumeHeadSha : null;
1128
+ const effectiveResumeHeadSha = rePinnedResumeHeadSha ?? pinnedResumeHeadSha;
1120
1129
  // "review"/"review_grant" advance current_round before the next session is queued;
1121
1130
  // "infra"/"none" queue at the round current_round is already at. Computed up front so
1122
1131
  // currentIntent's round number matches the round the queued work item actually carries
@@ -1155,7 +1164,14 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1155
1164
  }
1156
1165
  : {};
1157
1166
  return getDb().transaction(() => {
1158
- resolveHumanAction(actionId, resolvedBy, { choice });
1167
+ resolveHumanAction(actionId, resolvedBy, {
1168
+ choice,
1169
+ // NOT-226: record the re-pin so the resolved action itself shows which head the
1170
+ // reviewer was re-queued at when the branch had moved while parked.
1171
+ ...(rePinnedResumeHeadSha
1172
+ ? { resumedFromHeadSha: pinnedResumeHeadSha, resumeLiveHeadSha: rePinnedResumeHeadSha }
1173
+ : {}),
1174
+ });
1159
1175
  // stage must be the status this resolution actually lands on (finalStatus), not the
1160
1176
  // generic developer-resume outcome.issueStatus — otherwise a reviewer resume's own
1161
1177
  // human_action.resolved/repair.started events would be recorded under "developing"
@@ -1175,8 +1191,13 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1175
1191
  : finalStatus === "closed"
1176
1192
  ? "Closed"
1177
1193
  : resumeAsReviewer
1178
- ? `Reviewer re-evaluating at ${continuation.resumeHeadSha.slice(0, 8)}`
1194
+ ? rePinnedResumeHeadSha
1195
+ ? `Reviewer re-evaluating at ${rePinnedResumeHeadSha.slice(0, 8)} (was ${pinnedResumeHeadSha.slice(0, 8)})`
1196
+ : `Reviewer re-evaluating at ${pinnedResumeHeadSha.slice(0, 8)}`
1179
1197
  : `Developer implementing round ${nextRound}`,
1198
+ // NOT-226: the reviewer now evaluates the live head — the issue must point there
1199
+ // too, or the next stale check / resume would keep using the old commit.
1200
+ ...(rePinnedResumeHeadSha ? { headSha: rePinnedResumeHeadSha } : {}),
1180
1201
  });
1181
1202
  if (outcome.workflowOutcome) {
1182
1203
  const finalOutcome = externallyMerged ? "done" : outcome.workflowOutcome;
@@ -1216,8 +1237,8 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1216
1237
  };
1217
1238
  }
1218
1239
  // Another round: spend the budget this resolution's roundKind names, then queue the
1219
- // resumed effect — a reviewer at the pinned head for a reviewer-origin infra
1220
- // escalation, a fresh developer round otherwise.
1240
+ // resumed effect — a reviewer at the pinned head (or the live head it was re-pinned
1241
+ // to, NOT-226) for a reviewer-origin infra escalation, a fresh developer round otherwise.
1221
1242
  switch (outcome.roundKind) {
1222
1243
  case "review_grant":
1223
1244
  grantReviewRetry(issue.id);
@@ -1256,7 +1277,7 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1256
1277
  workflowInstanceId: instance.id,
1257
1278
  kind: "reviewer",
1258
1279
  round: issueNow.currentRound,
1259
- payload: { inputSha: continuation.resumeHeadSha, profileSnapshot: queuedProfileSnapshot(issue, "reviewer") },
1280
+ payload: { inputSha: effectiveResumeHeadSha, profileSnapshot: queuedProfileSnapshot(issue, "reviewer") },
1260
1281
  idempotencyKey: `${instance.id}:reviewer:resume:${action.id}`,
1261
1282
  })
1262
1283
  : enqueueWorkItem({
@@ -1281,18 +1302,52 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1281
1302
  * HTTP/CLI entry: same as `resolveHumanActionAndAdvance`, but when `final_review:complete`
1282
1303
  * parks for merge, awaits undraft+merge (`finalizeAutoMerge`) before returning.
1283
1304
  */
1284
- export async function resolveHumanActionAndAdvanceAsync(actionId, resolvedBy, choice) {
1305
+ export async function resolveHumanActionAndAdvanceAsync(actionId, resolvedBy, choice, opts) {
1285
1306
  // NOT-221: the lease push runs before anything resolves — a failed lease must leave
1286
1307
  // the action open with the fresh tip, which the sync core below cannot do.
1287
1308
  if (choice === "push_with_lease") {
1288
1309
  return resolvePushWithLeaseAsync(actionId, resolvedBy);
1289
1310
  }
1311
+ // NOT-226: for a reviewer-role resume on an issue with a PR, read the PR's live head
1312
+ // BEFORE the resolving transaction (network must never hold the SQLite write lock).
1313
+ // An explicit opt wins (tests); otherwise look it up, bounded — any failure yields
1314
+ // null and the sync core below keeps today's pinned-head behavior.
1315
+ let resumeLiveHeadSha = opts?.resumeLiveHeadSha;
1316
+ if (resumeLiveHeadSha === undefined) {
1317
+ try {
1318
+ const pendingAction = getHumanAction(actionId);
1319
+ const pendingIssue = pendingAction?.issueId ? getIssue(pendingAction.issueId) : null;
1320
+ const pendingResolution = pendingAction ? parseHumanResolution(pendingAction.actionType, choice) : null;
1321
+ const pendingContinuation = pendingAction
1322
+ ? parseContinuationPreview(pendingAction.continuationPreviewJson)
1323
+ : null;
1324
+ const isReviewerResumeCandidate = !!pendingAction &&
1325
+ pendingAction.status === "open" &&
1326
+ !!pendingIssue &&
1327
+ !!pendingResolution &&
1328
+ (pendingAction.actionType === "policy_escalation" ||
1329
+ pendingAction.actionType === "deck_interaction_required") &&
1330
+ pendingResolution.choice === "resume" &&
1331
+ pendingContinuation?.resumeRole === "reviewer" &&
1332
+ !!pendingContinuation.resumeHeadSha &&
1333
+ pendingIssue.prNumber != null;
1334
+ resumeLiveHeadSha = isReviewerResumeCandidate
1335
+ ? await fetchResumeLiveHeadSha({ prNumber: pendingIssue.prNumber, repo: pendingIssue.repo })
1336
+ : null;
1337
+ }
1338
+ catch {
1339
+ resumeLiveHeadSha = null;
1340
+ }
1341
+ }
1290
1342
  // NOT-196: the `gh` PR-state read runs here, outside any DB transaction — the sync
1291
1343
  // core below only consumes the pre-read state. Only close choices on issues with a
1292
1344
  // PR number pay for the call; anything unreadable resolves to "unknown" (or
1293
1345
  // undefined when there is nothing to check), both of which keep today's `closed`.
1294
1346
  const externalMergeState = await preReadExternalMergeState(actionId, choice);
1295
- const result = resolveHumanActionAndAdvance(actionId, resolvedBy, choice, { externalMergeState });
1347
+ const result = resolveHumanActionAndAdvance(actionId, resolvedBy, choice, {
1348
+ resumeLiveHeadSha,
1349
+ externalMergeState,
1350
+ });
1296
1351
  if (!result.ok || !result.pendingMerge)
1297
1352
  return result;
1298
1353
  const action = getHumanAction(actionId);