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.
- package/bundle/server/dist/adapters/agent-health.js +78 -33
- package/bundle/server/dist/adapters/agent-health.test.js +155 -1
- package/bundle/server/dist/adapters/linear-inbox.js +11 -2
- package/bundle/server/dist/adapters/linear-inbox.test.js +49 -1
- package/bundle/server/dist/coordinator/commands.js +63 -8
- package/bundle/server/dist/coordinator/commands.test.js +132 -2
- package/bundle/server/dist/coordinator/developer-effect.js +23 -0
- package/bundle/server/dist/coordinator/developer-effect.test.js +75 -0
- package/bundle/server/dist/coordinator/resume-live-head.js +54 -0
- package/bundle/server/dist/coordinator/resume-live-head.test.js +42 -0
- package/bundle/server/dist/coordinator/reviewer-effect.js +11 -2
- package/bundle/server/dist/coordinator/reviewer-effect.test.js +60 -0
- package/bundle/server/dist/routes/intake-linear.test.js +91 -0
- package/bundle/server/dist/runners/spawn-cli.js +16 -1
- package/bundle/server/dist/runners/spawn-cli.test.js +30 -1
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-BrFYuzDI.js +60 -0
- package/bundle/server/static-ui/assets/index-D2O482vN.css +1 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/agents.d.ts +15 -15
- package/bundle/shared/dist/agents.js +7 -0
- package/bundle/shared/dist/index.d.ts +47 -9
- package/bundle/shared/dist/index.js +7 -0
- package/bundle/shared/dist/linear-intake.d.ts +50 -0
- package/bundle/shared/dist/linear-intake.js +67 -0
- package/bundle/shared/dist/linear-intake.test.d.ts +1 -0
- package/bundle/shared/dist/linear-intake.test.js +85 -0
- package/bundle/shared/package.json +1 -1
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-Ct2CCoYn.js +0 -60
- 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
|
-
|
|
303
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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, {
|
|
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
|
-
?
|
|
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
|
|
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:
|
|
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, {
|
|
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);
|