@wardby/cli 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/.env.example +8 -1
  2. package/README.md +3 -1
  3. package/deploy/local/docker-compose.quickstart-coding.yml +27 -0
  4. package/dist/claude-coding-worker/driver.d.ts +2 -0
  5. package/dist/claude-coding-worker/driver.js +19 -3
  6. package/dist/claude-coding-worker/sdk.js +1 -1
  7. package/dist/cli.js +41 -22
  8. package/dist/coding/continuation-wording.d.ts +10 -0
  9. package/dist/coding/continuation-wording.js +13 -0
  10. package/dist/coding/docker-preflight.d.ts +12 -0
  11. package/dist/coding/docker-preflight.js +40 -0
  12. package/dist/coding/local-git.d.ts +22 -0
  13. package/dist/coding/local-git.js +102 -0
  14. package/dist/coding/local-repo-status.d.ts +7 -0
  15. package/dist/coding/local-repo-status.js +56 -0
  16. package/dist/coding/local-repo.d.ts +17 -0
  17. package/dist/coding/local-repo.js +65 -0
  18. package/dist/coding/observability.d.ts +1 -1
  19. package/dist/coding/observability.js +1 -0
  20. package/dist/coding/profile.d.ts +6 -0
  21. package/dist/coding/profile.js +13 -4
  22. package/dist/coding/protocol.d.ts +32 -7
  23. package/dist/coding/protocol.js +66 -4
  24. package/dist/coding-worker/artifact.d.ts +1 -0
  25. package/dist/coding-worker/errors.js +2 -0
  26. package/dist/config/providers.d.ts +13 -0
  27. package/dist/config/providers.js +21 -9
  28. package/dist/core/attribution.d.ts +13 -0
  29. package/dist/core/attribution.js +30 -0
  30. package/dist/core/budget-groups.d.ts +3 -1
  31. package/dist/core/budget-groups.js +53 -5
  32. package/dist/core/ci-context.d.ts +8 -0
  33. package/dist/core/ci-context.js +59 -0
  34. package/dist/core/delegation-siblings.d.ts +22 -0
  35. package/dist/core/delegation-siblings.js +40 -0
  36. package/dist/core/dispatch.d.ts +28 -4
  37. package/dist/core/dispatch.js +57 -15
  38. package/dist/core/engine-native.d.ts +16 -0
  39. package/dist/core/engine-native.js +49 -2
  40. package/dist/core/host-events.d.ts +25 -2
  41. package/dist/core/host-events.js +361 -19
  42. package/dist/core/host-status.d.ts +8 -3
  43. package/dist/core/host-status.js +38 -7
  44. package/dist/core/issue-events.d.ts +2 -6
  45. package/dist/core/issue-events.js +13 -19
  46. package/dist/core/pull-request-state-sync.d.ts +17 -0
  47. package/dist/core/pull-request-state-sync.js +53 -0
  48. package/dist/core/reconciler.d.ts +10 -1
  49. package/dist/core/reconciler.js +15 -2
  50. package/dist/core/related-pull-requests.d.ts +95 -0
  51. package/dist/core/related-pull-requests.js +338 -0
  52. package/dist/core/repo-access.d.ts +5 -1
  53. package/dist/core/repo-access.js +17 -1
  54. package/dist/core/review-fix-ledger.d.ts +20 -0
  55. package/dist/core/review-fix-ledger.js +41 -0
  56. package/dist/core/review-fix.d.ts +60 -0
  57. package/dist/core/review-fix.js +155 -0
  58. package/dist/core/review-host-tools.d.ts +13 -2
  59. package/dist/core/review-host-tools.js +72 -11
  60. package/dist/core/runner.d.ts +2 -2
  61. package/dist/core/runner.js +379 -184
  62. package/dist/core/serial-gate.d.ts +11 -0
  63. package/dist/core/serial-gate.js +19 -0
  64. package/dist/core/timing.d.ts +7 -0
  65. package/dist/core/timing.js +7 -0
  66. package/dist/generated/prisma/browser.d.ts +23 -0
  67. package/dist/generated/prisma/client.d.ts +23 -0
  68. package/dist/generated/prisma/commonInputTypes.d.ts +22 -0
  69. package/dist/generated/prisma/internal/class.d.ts +33 -0
  70. package/dist/generated/prisma/internal/class.js +4 -4
  71. package/dist/generated/prisma/internal/prismaNamespace.d.ts +274 -1
  72. package/dist/generated/prisma/internal/prismaNamespace.js +47 -2
  73. package/dist/generated/prisma/internal/prismaNamespaceBrowser.d.ts +48 -0
  74. package/dist/generated/prisma/internal/prismaNamespaceBrowser.js +47 -2
  75. package/dist/generated/prisma/models/Agent.d.ts +422 -1
  76. package/dist/generated/prisma/models/AgentRepository.d.ts +120 -2
  77. package/dist/generated/prisma/models/CodingAgentProfile.d.ts +42 -1
  78. package/dist/generated/prisma/models/CodingRun.d.ts +205 -1
  79. package/dist/generated/prisma/models/DeferredReview.d.ts +1336 -0
  80. package/dist/generated/prisma/models/DeferredReview.js +1 -0
  81. package/dist/generated/prisma/models/LocalPullRequest.d.ts +1384 -0
  82. package/dist/generated/prisma/models/LocalPullRequest.js +1 -0
  83. package/dist/generated/prisma/models/LocalReview.d.ts +1315 -0
  84. package/dist/generated/prisma/models/LocalReview.js +1 -0
  85. package/dist/generated/prisma/models/Run.d.ts +223 -0
  86. package/dist/generated/prisma/models/RunHostCheck.d.ts +148 -1
  87. package/dist/generated/prisma/models.d.ts +3 -0
  88. package/dist/help-index.json +476 -37
  89. package/dist/import/neutral-schema.d.ts +2 -2
  90. package/dist/mcp/auth/access.d.ts +3 -1
  91. package/dist/mcp/auth/host-account-cli.js +2 -2
  92. package/dist/mcp/auth/repo-authorization.d.ts +6 -7
  93. package/dist/mcp/auth/repo-authorization.js +21 -0
  94. package/dist/mcp/index.js +4 -1
  95. package/dist/mcp/tools/agents.js +46 -4
  96. package/dist/mcp/tools/host-accounts.js +2 -2
  97. package/dist/mcp/tools/repositories.js +71 -12
  98. package/dist/mcp/tools/runs.js +23 -0
  99. package/dist/mcp/tools/trigger.js +153 -24
  100. package/dist/providers/engine/types.d.ts +11 -0
  101. package/dist/providers/executor/composition.js +12 -7
  102. package/dist/providers/executor/container.d.ts +40 -3
  103. package/dist/providers/executor/container.js +140 -9
  104. package/dist/providers/executor/types.d.ts +1 -1
  105. package/dist/providers/jobs/fake-kubernetes-api.d.ts +3 -2
  106. package/dist/providers/jobs/fake-kubernetes-api.js +3 -0
  107. package/dist/providers/jobs/kubernetes-api.d.ts +3 -1
  108. package/dist/providers/jobs/kubernetes-client.d.ts +2 -1
  109. package/dist/providers/jobs/kubernetes-client.js +3 -0
  110. package/dist/providers/jobs/kubernetes-isolation.d.ts +7 -0
  111. package/dist/providers/jobs/kubernetes-isolation.js +8 -3
  112. package/dist/providers/jobs/kubernetes-preflight.d.ts +8 -0
  113. package/dist/providers/jobs/kubernetes-preflight.js +7 -0
  114. package/dist/providers/jobs/kubernetes-quota.d.ts +31 -0
  115. package/dist/providers/jobs/kubernetes-quota.js +110 -0
  116. package/dist/providers/jobs/kubernetes.d.ts +9 -0
  117. package/dist/providers/jobs/kubernetes.js +50 -0
  118. package/dist/providers/jobs/types.d.ts +7 -0
  119. package/dist/providers/review-host/ci.d.ts +7 -0
  120. package/dist/providers/review-host/ci.js +20 -0
  121. package/dist/providers/review-host/github-events.d.ts +7 -0
  122. package/dist/providers/review-host/github-events.js +31 -7
  123. package/dist/providers/review-host/github.d.ts +19 -2
  124. package/dist/providers/review-host/github.js +145 -2
  125. package/dist/providers/review-host/index.d.ts +8 -2
  126. package/dist/providers/review-host/index.js +14 -3
  127. package/dist/providers/review-host/local.d.ts +72 -0
  128. package/dist/providers/review-host/local.js +434 -0
  129. package/dist/providers/review-host/types.d.ts +65 -3
  130. package/dist/providers/review-host/types.js +5 -3
  131. package/dist/providers/vcs/git.d.ts +10 -20
  132. package/dist/providers/vcs/git.js +71 -127
  133. package/dist/providers/vcs/github-remote.d.ts +67 -0
  134. package/dist/providers/vcs/github-remote.js +201 -0
  135. package/dist/providers/vcs/github.d.ts +81 -0
  136. package/dist/providers/vcs/github.js +203 -6
  137. package/dist/providers/vcs/index.d.ts +16 -1
  138. package/dist/providers/vcs/index.js +43 -15
  139. package/dist/providers/vcs/local-remote.d.ts +44 -0
  140. package/dist/providers/vcs/local-remote.js +108 -0
  141. package/dist/providers/vcs/remote.d.ts +58 -0
  142. package/dist/providers/vcs/remote.js +1 -0
  143. package/dist/providers/vcs/routing.d.ts +33 -0
  144. package/dist/providers/vcs/routing.js +52 -0
  145. package/dist/providers/vcs/types.d.ts +12 -1
  146. package/dist/quickstart/coding-db.d.ts +11 -0
  147. package/dist/quickstart/coding-db.js +75 -0
  148. package/dist/quickstart/coding-doctor.d.ts +13 -0
  149. package/dist/quickstart/coding-doctor.js +88 -0
  150. package/dist/quickstart/coding-images.d.ts +15 -0
  151. package/dist/quickstart/coding-images.js +79 -0
  152. package/dist/quickstart/coding-seed.d.ts +63 -0
  153. package/dist/quickstart/coding-seed.js +85 -0
  154. package/dist/quickstart/coding.d.ts +66 -0
  155. package/dist/quickstart/coding.js +430 -0
  156. package/dist/quickstart/images.d.ts +19 -0
  157. package/dist/quickstart/images.js +63 -0
  158. package/dist/quickstart/index.js +61 -12
  159. package/dist/quickstart/starter-services.d.ts +45 -0
  160. package/dist/quickstart/starter-services.js +186 -0
  161. package/dist/quickstart-images.json +1 -0
  162. package/dist/serve.js +14 -1
  163. package/dist/viewer/api-schema.d.ts +176 -0
  164. package/dist/viewer/api-schema.js +27 -2
  165. package/dist/viewer/graph.d.ts +7 -2
  166. package/dist/viewer/graph.js +25 -11
  167. package/dist/viewer/http.d.ts +7 -1
  168. package/dist/viewer/http.js +8 -3
  169. package/dist/viewer/infra.d.ts +2 -0
  170. package/dist/viewer/infra.js +23 -0
  171. package/dist/viewer/run-detail.d.ts +2 -1
  172. package/dist/viewer/run-detail.js +2 -2
  173. package/docs/agent-recipes.md +123 -2
  174. package/docs/code-review-agents.md +293 -38
  175. package/docs/coding-agent-setup.md +141 -2
  176. package/docs/coding-packages.md +21 -0
  177. package/docs/coding-services.md +3 -0
  178. package/docs/coding-worker-isolation.md +56 -20
  179. package/docs/getting-started-gke.md +24 -0
  180. package/docs/getting-started.md +82 -8
  181. package/docs/jira-agents.md +26 -5
  182. package/docs/security-deployment.md +9 -0
  183. package/docs/viewer-api.md +21 -2
  184. package/help/admin-viewer.md +11 -1
  185. package/help/agent-recipes.md +25 -1
  186. package/help/builder-agent.md +4 -0
  187. package/help/code-review-agents.md +82 -2
  188. package/help/coding-packages.md +12 -1
  189. package/help/coding-services.md +4 -0
  190. package/help/cost-attribution.md +3 -1
  191. package/help/creating-agents.md +3 -0
  192. package/help/deploy-gke.md +2 -1
  193. package/help/errors/coding-provider-not-configured.md +60 -0
  194. package/help/errors/coding-turn-limit.md +25 -0
  195. package/help/errors/continuation-closed.md +75 -0
  196. package/help/errors/local-branch-conflict.md +37 -0
  197. package/help/errors/local-path-invalid.md +30 -0
  198. package/help/errors/local-ref-invalid.md +36 -0
  199. package/help/errors/local-ref-not-found.md +41 -0
  200. package/help/errors/local-repo-not-allowed.md +46 -0
  201. package/help/errors/local-repo-not-found.md +39 -0
  202. package/help/errors/model-unavailable.md +7 -6
  203. package/help/errors/vcs-github-not-configured.md +34 -0
  204. package/help/getting-started.md +4 -0
  205. package/help/github.md +22 -5
  206. package/help/jira.md +9 -2
  207. package/help/local-repositories.md +182 -0
  208. package/help/related-pull-requests.md +89 -0
  209. package/help/review-fix-rounds.md +69 -0
  210. package/help/troubleshooting/budgets.md +11 -0
  211. package/help/troubleshooting/coding-workers.md +22 -0
  212. package/help/troubleshooting/repository-access.md +5 -0
  213. package/package.json +3 -2
  214. package/prisma/migrations/20261004100000_delegations_and_coding_turns/migration.sql +12 -0
  215. package/prisma/migrations/20261005000000_review_fix_rounds/migration.sql +5 -0
  216. package/prisma/migrations/20261006000000_parallel_delegations/migration.sql +6 -0
  217. package/prisma/migrations/20261007000000_ci_rereview/migration.sql +4 -0
  218. package/prisma/migrations/20261008000000_review_after_ci/migration.sql +33 -0
  219. package/prisma/migrations/20261009000001_coding_run_local_branch/migration.sql +7 -0
  220. package/prisma/migrations/20261009000002_local_review/migration.sql +50 -0
  221. package/prisma/schema.prisma +98 -1
@@ -2,6 +2,7 @@ import { RESPONSE_PATH_SNAPSHOT_BUDGET, resolveWorkItem } from "./attribution.js
2
2
  import { dispatchRun } from "./dispatch.js";
3
3
  import { issueStatusRow, postIssueWorkingStatus } from "./issue-status.js";
4
4
  import { logger } from "./logger.js";
5
+ import { openSiblingsForIssue, SIBLING_GUIDANCE } from "./related-pull-requests.js";
5
6
  import { composeTaskOverride } from "./untrusted-content.js";
6
7
  const log = logger.child({ module: "issue-events" });
7
8
  const MAX_TASK_BODY = 8000;
@@ -42,11 +43,15 @@ export function issueTaskText(event, matched, issueUrl, link, openPrs = []) {
42
43
  "Use jira_get_issue to read the issue. Your final answer is posted on this issue for you when you finish, so " +
43
44
  "do not also post it with jira_comment; use jira_comment only for other issues or progress notes on long work.",
44
45
  ];
45
- // Control-plane data from stored rows (validated at write time), never issue text.
46
- for (const pr of openPrs.filter((p) => RUN_ID_RE.test(p.openedByRunId))) {
47
- sections.push(`This issue already has an open pull request wardby opened: ${pr.repository}#${pr.number} (${pr.url}). ` +
46
+ // Control-plane data from stored rows (validated here), never issue text.
47
+ const hinted = openPrs.filter((p) => RUN_ID_RE.test(p.openedByRunId));
48
+ for (const pr of hinted) {
49
+ sections.push(`This issue already has an open pull request wardby opened: ${pr.repository}#${pr.number} ` +
50
+ `(https://github.com/${pr.repository}/pull/${pr.number}). ` +
48
51
  `To revise it, delegate with continuePriorRun set to exactly "${pr.openedByRunId}".`);
49
52
  }
53
+ if (hinted.length > 0)
54
+ sections.push(SIBLING_GUIDANCE);
50
55
  if (matched.includes("mention") && event.comment) {
51
56
  sections.push(`Request comment:\n${event.comment.body.slice(0, MAX_TASK_BODY)}`);
52
57
  }
@@ -80,20 +85,6 @@ function matchedKinds(event, link, bot) {
80
85
  }
81
86
  });
82
87
  }
83
- async function openPullRequests(db, issueKey, agentId) {
84
- try {
85
- return await db.issuePullRequest.findMany({
86
- where: { issueProvider: "jira", issueKey, agentId, state: "open" },
87
- orderBy: { createdAt: "desc" },
88
- take: 3,
89
- select: { repository: true, number: true, url: true, openedByRunId: true },
90
- });
91
- }
92
- catch (err) {
93
- log.warn({ err, issueKey, agentId }, "open pull requests could not be looked up; continuing without a hint");
94
- return [];
95
- }
96
- }
97
88
  const JQL_FILTER_BUDGET = { timeoutMs: 5000, retryOn429: false };
98
89
  export async function routeIssueEvent(event, deps) {
99
90
  const result = { runIds: [], followUps: [] };
@@ -108,6 +99,10 @@ export async function routeIssueEvent(event, deps) {
108
99
  // One snapshot per event, taken lazily on the first dispatch so an event no link matches costs nothing.
109
100
  let item;
110
101
  const workItem = () => (item ??= resolveWorkItem(deps.db, deps.trackers, event.provider, event.issueKey, RESPONSE_PATH_SNAPSHOT_BUDGET));
102
+ // Likewise the card's open siblings: one lookup per event (every matched
103
+ // link gets the same whole-card hints), not one per link.
104
+ let siblings;
105
+ const openPrs = () => (siblings ??= openSiblingsForIssue(deps.db, { provider: event.provider, key: event.issueKey }));
111
106
  for (const link of links) {
112
107
  if (link.access !== "write" || !link.agent.ownerId || link.agent.kind !== "native")
113
108
  continue;
@@ -126,7 +121,6 @@ export async function routeIssueEvent(event, deps) {
126
121
  if (!ok)
127
122
  continue;
128
123
  }
129
- const openPrs = await openPullRequests(deps.db, event.issueKey, link.agentId);
130
124
  try {
131
125
  const dispatched = await dispatchRun({
132
126
  db: deps.db,
@@ -135,7 +129,7 @@ export async function routeIssueEvent(event, deps) {
135
129
  agentId: link.agentId,
136
130
  trigger: "host_event",
137
131
  attribution: { source: "issue_event", item: await workItem() },
138
- taskOverride: issueTaskText(event, matched, tracker.issueUrl(event.issueKey), link, openPrs),
132
+ taskOverride: issueTaskText(event, matched, tracker.issueUrl(event.issueKey), link, await openPrs()),
139
133
  afterPersist: async (tx, run) => {
140
134
  await tx.runIssueStatus.create({ data: issueStatusRow(event, run.id, link.commentVisibilityRole) });
141
135
  },
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Keeps IssuePullRequest.state current when a pr_closed webhook was missed
3
+ * (an outage, a redelivery that never came). Each reconciler pass reads a
4
+ * small batch of rows still marked open — least recently checked first —
5
+ * from the code host and settles merged or closed ones through
6
+ * handlePullRequestClosed, the webhook's own path. Sibling continuation hints
7
+ * and the Related pull requests section read this state. Best effort: never throws.
8
+ */
9
+ import type { PrismaClient } from "#prisma";
10
+ import type { IssueTrackerRegistry } from "../providers/issue-tracker/types.js";
11
+ import type { ReviewHostRegistry } from "../providers/review-host/types.js";
12
+ /** Rows checked per pass (each is one token mint + one GET + one revoke). */
13
+ export declare const PR_STATE_SYNC_BATCH = 10;
14
+ /** A row is re-checked at most this often. */
15
+ export declare const PR_STATE_SYNC_MIN_AGE_MS: number;
16
+ export type PullRequestStateSyncDb = Pick<PrismaClient, "issuePullRequest" | "agentIssueProject">;
17
+ export declare function syncOpenPullRequestStates(db: PullRequestStateSyncDb, hosts: ReviewHostRegistry | undefined, trackers: IssueTrackerRegistry | undefined, now?: Date): Promise<number>;
@@ -0,0 +1,53 @@
1
+ import { handlePullRequestClosed } from "./issue-bridge.js";
2
+ import { logger } from "./logger.js";
3
+ const log = logger.child({ module: "pull-request-state-sync" });
4
+ /** Rows checked per pass (each is one token mint + one GET + one revoke). */
5
+ export const PR_STATE_SYNC_BATCH = 10;
6
+ /** A row is re-checked at most this often. */
7
+ export const PR_STATE_SYNC_MIN_AGE_MS = 10 * 60_000;
8
+ export async function syncOpenPullRequestStates(db, hosts, trackers, now = new Date()) {
9
+ // handlePullRequestClosed needs trackers; without them no IssuePullRequest rows are written anyway.
10
+ if (!hosts || !trackers)
11
+ return 0;
12
+ let settled = 0;
13
+ try {
14
+ const rows = await db.issuePullRequest.findMany({
15
+ where: { state: "open", updatedAt: { lt: new Date(now.getTime() - PR_STATE_SYNC_MIN_AGE_MS) } },
16
+ orderBy: { updatedAt: "asc" },
17
+ take: PR_STATE_SYNC_BATCH,
18
+ select: { id: true, codeProvider: true, repository: true, number: true },
19
+ });
20
+ const touch = (id) => db.issuePullRequest
21
+ .updateMany({ where: { id, state: "open" }, data: { updatedAt: now } })
22
+ .catch((err) => log.warn({ err, id }, "could not mark a pull request as checked"));
23
+ for (const row of rows) {
24
+ const host = hosts[row.codeProvider];
25
+ if (!host?.pullRequestOrigin)
26
+ continue;
27
+ try {
28
+ const origin = await host.pullRequestOrigin(row.repository, row.number);
29
+ if (origin.state === "open" && origin.merged !== true) {
30
+ await touch(row.id);
31
+ continue;
32
+ }
33
+ const merged = origin.merged === true;
34
+ await handlePullRequestClosed(db, trackers, {
35
+ codeProvider: row.codeProvider,
36
+ repository: row.repository,
37
+ number: row.number,
38
+ merged,
39
+ });
40
+ settled += 1;
41
+ log.info({ repository: row.repository, number: row.number, merged }, "settled a pull request whose close was missed");
42
+ }
43
+ catch (err) {
44
+ log.warn({ err, repository: row.repository, number: row.number }, "could not sync a pull request's state");
45
+ await touch(row.id);
46
+ }
47
+ }
48
+ }
49
+ catch (err) {
50
+ log.warn({ err }, "pull request state sync failed");
51
+ }
52
+ return settled;
53
+ }
@@ -34,11 +34,16 @@
34
34
  * of those paths instead of patching each. Mention status comments, and the
35
35
  * status comments of runs started by Jira issue events, get the same sweep:
36
36
  * it also catches a run that ended before its comment was posted.
37
+ *
38
+ * Given host-event deps, each pass also starts reviews deferred until CI
39
+ * finished (waitForCi links) whose CI has not finished in time, and drops
40
+ * deferred reviews that are too old (host-events.ts startDeferredReviews).
37
41
  */
38
42
  import type { PrismaClient } from "#prisma";
39
43
  import type { Executor } from "../providers/executor/types.js";
40
44
  import type { ReviewHostRegistry } from "../providers/review-host/types.js";
41
45
  import type { IssueTrackerRegistry } from "../providers/issue-tracker/types.js";
46
+ import { type ReviewStartDeps } from "./host-events.js";
42
47
  export type ReconcilerDb = Pick<PrismaClient, "run" | "runHostCheck" | "runHostStatus" | "runIssueStatus" | "agentIssueProject" | "issuePullRequest" | "codingRun" | "agent" | "$transaction" | "$queryRaw">;
43
48
  /**
44
49
  * How long after a run finishes before its still-open check counts as
@@ -68,7 +73,9 @@ export declare function closeOrphanedHostChecks(db: Pick<PrismaClient, "runHostC
68
73
  */
69
74
  export declare function closeOrphanedHostStatuses(db: Pick<PrismaClient, "runHostStatus" | "run">, hosts: ReviewHostRegistry | undefined, now?: Date): Promise<void>;
70
75
  /** Runs one reconciliation pass. Returns the number of runs marked `lost`. */
71
- export declare function reconcileOnce(db: ReconcilerDb, now?: Date, heartbeatTimeoutMs?: number, executor?: Executor, reviewHosts?: ReviewHostRegistry, issueTrackers?: IssueTrackerRegistry): Promise<number>;
76
+ export declare function reconcileOnce(db: ReconcilerDb, now?: Date, heartbeatTimeoutMs?: number, executor?: Executor, reviewHosts?: ReviewHostRegistry, issueTrackers?: IssueTrackerRegistry,
77
+ /** What starting a deferred review needs; absent = no deferred-review sweep. */
78
+ deferredReviews?: ReviewStartDeps): Promise<number>;
72
79
  export interface ReconcilerOptions {
73
80
  db?: ReconcilerDb;
74
81
  intervalMs?: number;
@@ -78,6 +85,8 @@ export interface ReconcilerOptions {
78
85
  reviewHosts?: ReviewHostRegistry;
79
86
  /** Trackers used to complete issue status comments orphaned the same way; none configured = no sweep. */
80
87
  issueTrackers?: IssueTrackerRegistry;
88
+ /** Host-event deps for starting deferred reviews (waitForCi) whose CI never finished; absent = no sweep. */
89
+ deferredReviews?: ReviewStartDeps;
81
90
  }
82
91
  export interface ReconcilerHandle {
83
92
  stop(): void;
@@ -34,6 +34,10 @@
34
34
  * of those paths instead of patching each. Mention status comments, and the
35
35
  * status comments of runs started by Jira issue events, get the same sweep:
36
36
  * it also catches a run that ended before its comment was posted.
37
+ *
38
+ * Given host-event deps, each pass also starts reviews deferred until CI
39
+ * finished (waitForCi links) whose CI has not finished in time, and drops
40
+ * deferred reviews that are too old (host-events.ts startDeferredReviews).
37
41
  */
38
42
  import { HEARTBEAT_TIMEOUT_MS, RECONCILE_INTERVAL_MS } from "./timing.js";
39
43
  import { prisma as defaultDb } from "./db.js";
@@ -42,6 +46,8 @@ import { closeOpenHostCheck } from "./review-host-checks.js";
42
46
  import { completeHostStatus } from "./host-status.js";
43
47
  import { closeOrphanedIssueStatuses } from "./issue-status.js";
44
48
  import { fileSelfDefect } from "./self-defects.js";
49
+ import { syncOpenPullRequestStates } from "./pull-request-state-sync.js";
50
+ import { startDeferredReviews } from "./host-events.js";
45
51
  const reconcilerLog = logger.child({ module: "reconciler" });
46
52
  /** A pass waits this long per self-defect, then moves on; the filing finishes (or logs) in the background. */
47
53
  const SELF_DEFECT_WAIT = { waitMs: 2_000 };
@@ -120,7 +126,9 @@ export async function closeOrphanedHostStatuses(db, hosts, now = new Date()) {
120
126
  await completeHostStatus(db, run, hosts, { postIfMissing: true });
121
127
  }
122
128
  /** Runs one reconciliation pass. Returns the number of runs marked `lost`. */
123
- export async function reconcileOnce(db, now = new Date(), heartbeatTimeoutMs = HEARTBEAT_TIMEOUT_MS, executor, reviewHosts, issueTrackers) {
129
+ export async function reconcileOnce(db, now = new Date(), heartbeatTimeoutMs = HEARTBEAT_TIMEOUT_MS, executor, reviewHosts, issueTrackers,
130
+ /** What starting a deferred review needs; absent = no deferred-review sweep. */
131
+ deferredReviews) {
124
132
  const cutoff = new Date(now.getTime() - heartbeatTimeoutMs);
125
133
  const stale = {
126
134
  executionManaged: true,
@@ -228,6 +236,11 @@ export async function reconcileOnce(db, now = new Date(), heartbeatTimeoutMs = H
228
236
  await closeOrphanedHostChecks(db, reviewHosts, now);
229
237
  await closeOrphanedHostStatuses(db, reviewHosts, now);
230
238
  await closeOrphanedIssueStatuses(db, issueTrackers, now);
239
+ // Settle stored pull requests whose merge/close webhook was missed; bounded per pass, never throws.
240
+ await syncOpenPullRequestStates(db, reviewHosts, issueTrackers, now);
241
+ // Start reviews whose head's CI never finished (waitForCi links); bounded per pass, never throws.
242
+ if (deferredReviews)
243
+ await startDeferredReviews(deferredReviews, now);
231
244
  return lost;
232
245
  }
233
246
  export function startReconciler(options = {}) {
@@ -235,7 +248,7 @@ export function startReconciler(options = {}) {
235
248
  const intervalMs = options.intervalMs ?? RECONCILE_INTERVAL_MS;
236
249
  const heartbeatTimeoutMs = options.heartbeatTimeoutMs ?? HEARTBEAT_TIMEOUT_MS;
237
250
  const timer = setInterval(() => {
238
- reconcileOnce(db, new Date(), heartbeatTimeoutMs, options.executor, options.reviewHosts, options.issueTrackers).catch((err) => {
251
+ reconcileOnce(db, new Date(), heartbeatTimeoutMs, options.executor, options.reviewHosts, options.issueTrackers, options.deferredReviews).catch((err) => {
239
252
  reconcilerLog.error({ err }, "reconcile pass failed");
240
253
  });
241
254
  }, intervalMs);
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The pull requests that came from the same request as a run: every pull
3
+ * request opened by a coding run in the same top-level run tree (a lead that
4
+ * fanned out to several repositories), plus any recorded for the same tracker
5
+ * issue (IssuePullRequest). Control-plane rows only.
6
+ * Ordered by when each pull request's coding run was dispatched: the lead's
7
+ * delegation order (for a lead with parallelDelegations, the order its
8
+ * concurrent delegations were admitted, ties broken by repository#number).
9
+ * A PR opened while siblings were still running lists only the PRs opened
10
+ * before it; updateRelatedPullRequests rewrites every open PR of the set
11
+ * with the full list when the lead run ends. Continuations join
12
+ * the set in both directions: a follow-up that continued one of the set's
13
+ * pull requests brings the original request's tree, and the original request
14
+ * brings every later follow-up tree that continued one of its pull requests.
15
+ */
16
+ import { type PrismaClient, type Run } from "#prisma";
17
+ import { type IssueTrackerRegistry } from "../providers/issue-tracker/types.js";
18
+ import type { ReviewHostRegistry } from "../providers/review-host/types.js";
19
+ /** Most pull requests a group holds; the description shows fewer (MAX_RELATED_PULL_REQUESTS). */
20
+ export declare const MAX_RELATED_GROUP = 50;
21
+ export type RelatedPullRequestsDb = Pick<PrismaClient, "run" | "codingRun" | "issuePullRequest" | "$queryRaw">;
22
+ export type StoredPullRequestState = "open" | "merged" | "closed";
23
+ export interface RelatedPullRequest {
24
+ repository: string;
25
+ number: number;
26
+ openedAt: Date;
27
+ /** The coding run that opened it: the continuePriorRun target for a follow-up. */
28
+ openedByRunId: string;
29
+ /** Only when stored (IssuePullRequest.state, kept current by pr_closed); otherwise unknown. */
30
+ state?: StoredPullRequestState;
31
+ }
32
+ export interface RelatedPullRequestGroup {
33
+ pullRequests: RelatedPullRequest[];
34
+ /** The first well-formed issue seen on the group's coding runs. */
35
+ issue?: {
36
+ provider: string;
37
+ key: string;
38
+ };
39
+ }
40
+ export declare function collectRelatedPullRequests(db: RelatedPullRequestsDb, runId: string,
41
+ /**
42
+ * Scopes the issue join (and any tree row explicitly tagged for a
43
+ * different issue) to exactly this issue, instead of every issue key the
44
+ * tree's coding runs happen to carry. Used by openSiblingsForIssue so a
45
+ * tree that touches two cards never leaks the other card's pull requests
46
+ * into this one's continuation hints; omitted by every other caller, which
47
+ * keeps today's "every issue the tree touched" behavior (Decision 2).
48
+ */
49
+ onlyIssueKey?: {
50
+ provider: string;
51
+ key: string;
52
+ }): Promise<RelatedPullRequestGroup>;
53
+ /**
54
+ * After a native run ends, rewrites the Related pull requests section on
55
+ * every open pull request of its request (control-plane rows only). Best
56
+ * effort: never throws, and never removes a section (a set of one writes
57
+ * nothing). Only PRs the App authored, whose marker names a coding run this
58
+ * deployment recorded in that repository, are edited.
59
+ */
60
+ export declare function updateRelatedPullRequests(db: RelatedPullRequestsDb, run: Pick<Run, "id">, hosts: ReviewHostRegistry | undefined, trackers: IssueTrackerRegistry | undefined): Promise<void>;
61
+ /** Most open siblings hinted in one task. */
62
+ export declare const MAX_SIBLING_HINTS = 10;
63
+ /** Closes every sibling-hint block (mention and Jira alike). */
64
+ export declare const SIBLING_GUIDANCE: string;
65
+ export interface OpenSibling {
66
+ repository: string;
67
+ number: number;
68
+ openedByRunId: string;
69
+ }
70
+ /**
71
+ * The group's pull requests a follow-up may continue: stored state "open", or
72
+ * no stored state yet (a same-tree sibling not recorded for an issue), minus
73
+ * `exclude` and malformed run ids. Stored state only: no host call. A hint for
74
+ * a PR that has closed since is refused by the continuation itself (git.ts).
75
+ */
76
+ export declare function openSiblingsOf(group: RelatedPullRequestGroup, exclude?: {
77
+ repository: string;
78
+ number: number;
79
+ }): OpenSibling[];
80
+ /** Open siblings of the request `seedRunId` belongs to. Never throws. */
81
+ export declare function openSiblings(db: RelatedPullRequestsDb, seedRunId: string, exclude: {
82
+ repository: string;
83
+ number: number;
84
+ }): Promise<OpenSibling[]>;
85
+ /**
86
+ * Open pull requests of a whole card: seeded from the newest pull request
87
+ * recorded for the issue (any agent), then the collector is scoped to this
88
+ * issue alone (never "every issue the tree touched") so a tree that happens
89
+ * to also carry another card's coding runs can't leak that card's pull
90
+ * requests into this one's hints. Never throws.
91
+ */
92
+ export declare function openSiblingsForIssue(db: RelatedPullRequestsDb, issue: {
93
+ provider: string;
94
+ key: string;
95
+ }): Promise<OpenSibling[]>;
@@ -0,0 +1,338 @@
1
+ /**
2
+ * The pull requests that came from the same request as a run: every pull
3
+ * request opened by a coding run in the same top-level run tree (a lead that
4
+ * fanned out to several repositories), plus any recorded for the same tracker
5
+ * issue (IssuePullRequest). Control-plane rows only.
6
+ * Ordered by when each pull request's coding run was dispatched: the lead's
7
+ * delegation order (for a lead with parallelDelegations, the order its
8
+ * concurrent delegations were admitted, ties broken by repository#number).
9
+ * A PR opened while siblings were still running lists only the PRs opened
10
+ * before it; updateRelatedPullRequests rewrites every open PR of the set
11
+ * with the full list when the lead run ends. Continuations join
12
+ * the set in both directions: a follow-up that continued one of the set's
13
+ * pull requests brings the original request's tree, and the original request
14
+ * brings every later follow-up tree that continued one of its pull requests.
15
+ */
16
+ import { Prisma } from "#prisma";
17
+ import { CODING_CODE_PROVIDER, normalizeGitHubRepository } from "../coding/protocol.js";
18
+ import { ISSUE_KEY, ISSUE_TRACKER_NAMES, } from "../providers/issue-tracker/types.js";
19
+ import { renderRelatedSection } from "../providers/vcs/github.js";
20
+ import { pullRequestOutcome } from "./host-status.js";
21
+ import { logger } from "./logger.js";
22
+ const log = logger.child({ module: "related-pull-requests" });
23
+ /** Most pull requests a group holds; the description shows fewer (MAX_RELATED_PULL_REQUESTS). */
24
+ export const MAX_RELATED_GROUP = 50;
25
+ /** Most coding runs read per tree walk (and per continuation lookup). */
26
+ const MAX_TREE_CODING_RUNS = 200;
27
+ /** Most forward/reverse expansion passes after the seed's own tree walk. */
28
+ const MAX_EXPANSION_PASSES = 4;
29
+ const STORED_STATES = new Set(["open", "merged", "closed"]);
30
+ /** Every coding run in the top-level trees containing `seeds` (walks up parentRunId, then down). */
31
+ async function treeCodingRuns(db, seeds) {
32
+ return db.$queryRaw `
33
+ WITH RECURSIVE up AS (
34
+ SELECT r."id", r."parentRunId" FROM "Run" r WHERE r."id" IN (${Prisma.join(seeds)})
35
+ -- UNION, not UNION ALL: a parentRunId cycle (never written, but not a constraint) can't recurse forever.
36
+ UNION
37
+ SELECT p."id", p."parentRunId" FROM "Run" p JOIN up u ON p."id" = u."parentRunId"
38
+ ), down AS (
39
+ SELECT u."id" FROM up u WHERE u."parentRunId" IS NULL
40
+ UNION
41
+ SELECT c."id" FROM "Run" c JOIN down d ON c."parentRunId" = d."id"
42
+ )
43
+ SELECT cr."runId", cr."result", cr."rootCodingRunId", cr."issueProvider", cr."issueKey", r."startedAt"
44
+ FROM "CodingRun" cr
45
+ JOIN down d ON cr."runId" = d."id"
46
+ JOIN "Run" r ON r."id" = cr."runId"
47
+ ORDER BY r."startedAt" ASC, cr."runId" ASC
48
+ LIMIT ${MAX_TREE_CODING_RUNS}`;
49
+ }
50
+ function safeRepository(value) {
51
+ try {
52
+ return normalizeGitHubRepository(value);
53
+ }
54
+ catch {
55
+ return undefined;
56
+ }
57
+ }
58
+ export async function collectRelatedPullRequests(db, runId,
59
+ /**
60
+ * Scopes the issue join (and any tree row explicitly tagged for a
61
+ * different issue) to exactly this issue, instead of every issue key the
62
+ * tree's coding runs happen to carry. Used by openSiblingsForIssue so a
63
+ * tree that touches two cards never leaks the other card's pull requests
64
+ * into this one's continuation hints; omitted by every other caller, which
65
+ * keeps today's "every issue the tree touched" behavior (Decision 2).
66
+ */
67
+ onlyIssueKey) {
68
+ const rows = await treeCodingRuns(db, [runId]);
69
+ const seen = new Set(rows.map((r) => r.runId));
70
+ // A continuation's pull request belongs to the request that opened it, so
71
+ // the set is closed in both directions: forward (a continuation row walks
72
+ // to its root's tree) and reverse (an opener's later continuations, found
73
+ // through the indexed rootCodingRunId, walk their own trees, which may
74
+ // have opened new pull requests). Repeated until nothing new turns up, at
75
+ // most MAX_EXPANSION_PASSES times, and never past a full group.
76
+ const expandedOpeners = new Set();
77
+ for (let pass = 0; pass < MAX_EXPANSION_PASSES; pass++) {
78
+ const roots = [];
79
+ const openers = [];
80
+ let opened = 0;
81
+ for (const r of rows) {
82
+ const outcome = pullRequestOutcome(r.result)?.outcome;
83
+ if (outcome === "pull_request_updated" && r.rootCodingRunId && !seen.has(r.rootCodingRunId)) {
84
+ roots.push(r.rootCodingRunId);
85
+ }
86
+ else if (outcome === "pull_request_opened") {
87
+ opened++;
88
+ if (!expandedOpeners.has(r.runId))
89
+ openers.push(r.runId);
90
+ }
91
+ }
92
+ if (opened >= MAX_RELATED_GROUP)
93
+ break;
94
+ for (const opener of openers)
95
+ expandedOpeners.add(opener);
96
+ const continuations = openers.length > 0
97
+ ? await db.codingRun.findMany({
98
+ where: { rootCodingRunId: { in: openers } },
99
+ select: { runId: true },
100
+ orderBy: { runId: "asc" },
101
+ take: MAX_TREE_CODING_RUNS,
102
+ })
103
+ : [];
104
+ const seeds = [...new Set([...roots, ...continuations.map((c) => c.runId)])].filter((id) => !seen.has(id));
105
+ if (seeds.length === 0)
106
+ break;
107
+ let added = 0;
108
+ for (const extra of await treeCodingRuns(db, seeds)) {
109
+ if (!seen.has(extra.runId)) {
110
+ seen.add(extra.runId);
111
+ rows.push(extra);
112
+ added++;
113
+ }
114
+ }
115
+ if (added === 0)
116
+ break;
117
+ }
118
+ const byKey = new Map();
119
+ const keyOf = (repository, number) => `${repository}#${number}`;
120
+ let issue;
121
+ const issues = new Map();
122
+ for (const r of rows) {
123
+ if (r.issueProvider && r.issueKey && ISSUE_KEY.test(r.issueKey)) {
124
+ const found = { provider: r.issueProvider, key: r.issueKey };
125
+ issue ??= found;
126
+ issues.set(`${found.provider}\0${found.key}`, found);
127
+ }
128
+ const pr = pullRequestOutcome(r.result);
129
+ if (pr?.outcome !== "pull_request_opened")
130
+ continue;
131
+ // A tree row explicitly tagged for a different card never joins this one's set.
132
+ if (onlyIssueKey && r.issueKey && (r.issueProvider !== onlyIssueKey.provider || r.issueKey !== onlyIssueKey.key)) {
133
+ continue;
134
+ }
135
+ const repository = safeRepository(pr.repository);
136
+ if (repository && !byKey.has(keyOf(repository, pr.pullRequestNumber))) {
137
+ byKey.set(keyOf(repository, pr.pullRequestNumber), {
138
+ repository,
139
+ number: pr.pullRequestNumber,
140
+ openedAt: new Date(r.startedAt),
141
+ openedByRunId: r.runId,
142
+ });
143
+ }
144
+ }
145
+ const issueFilter = onlyIssueKey
146
+ ? new Map([[`${onlyIssueKey.provider}\0${onlyIssueKey.key}`, onlyIssueKey]])
147
+ : issues;
148
+ if (issueFilter.size > 0) {
149
+ const recorded = await db.issuePullRequest.findMany({
150
+ where: {
151
+ codeProvider: CODING_CODE_PROVIDER,
152
+ OR: [...issueFilter.values()].map((i) => ({ issueProvider: i.provider, issueKey: i.key })),
153
+ },
154
+ select: { repository: true, number: true, createdAt: true, openedByRunId: true, state: true },
155
+ orderBy: { createdAt: "asc" },
156
+ take: MAX_RELATED_GROUP,
157
+ });
158
+ // Every pull request recorded for the issue, merged and closed included (Decision 2).
159
+ for (const r of recorded) {
160
+ const repository = safeRepository(r.repository);
161
+ if (!repository || !Number.isSafeInteger(r.number) || r.number <= 0)
162
+ continue;
163
+ const state = STORED_STATES.has(r.state) ? r.state : undefined;
164
+ const existing = byKey.get(keyOf(repository, r.number));
165
+ if (existing) {
166
+ // The tree's own opener and dispatch time stay; the stored state is added.
167
+ if (state)
168
+ existing.state = state;
169
+ continue;
170
+ }
171
+ byKey.set(keyOf(repository, r.number), {
172
+ repository,
173
+ number: r.number,
174
+ openedAt: r.createdAt,
175
+ openedByRunId: r.openedByRunId,
176
+ ...(state ? { state } : {}),
177
+ });
178
+ }
179
+ }
180
+ const pullRequests = [...byKey.values()]
181
+ .sort((a, b) => a.openedAt.getTime() - b.openedAt.getTime() ||
182
+ `${a.repository}#${a.number}`.localeCompare(`${b.repository}#${b.number}`))
183
+ .slice(0, MAX_RELATED_GROUP);
184
+ return { pullRequests, ...(issue ? { issue } : {}) };
185
+ }
186
+ function issueFor(group, trackers) {
187
+ if (!group.issue)
188
+ return undefined;
189
+ const provider = group.issue.provider;
190
+ let url;
191
+ try {
192
+ url = trackers?.[provider]?.issueUrl(group.issue.key);
193
+ }
194
+ catch {
195
+ url = undefined;
196
+ }
197
+ const trackerName = Object.hasOwn(ISSUE_TRACKER_NAMES, provider) ? ISSUE_TRACKER_NAMES[provider] : undefined;
198
+ return { key: group.issue.key, ...(url ? { url } : {}), ...(trackerName ? { trackerName } : {}) };
199
+ }
200
+ /**
201
+ * After a native run ends, rewrites the Related pull requests section on
202
+ * every open pull request of its request (control-plane rows only). Best
203
+ * effort: never throws, and never removes a section (a set of one writes
204
+ * nothing). Only PRs the App authored, whose marker names a coding run this
205
+ * deployment recorded in that repository, are edited.
206
+ */
207
+ export async function updateRelatedPullRequests(db, run, hosts, trackers) {
208
+ const host = hosts?.[CODING_CODE_PROVIDER];
209
+ if (!host?.replaceRelatedSection || !host.pullRequestOrigin)
210
+ return;
211
+ try {
212
+ // A run that delegated nothing has no request set of its own.
213
+ if (!(await db.run.findFirst({ where: { parentRunId: run.id }, select: { id: true } })))
214
+ return;
215
+ const group = await collectRelatedPullRequests(db, run.id);
216
+ if (group.pullRequests.length < 2)
217
+ return;
218
+ const live = [];
219
+ for (const pr of group.pullRequests) {
220
+ // A stored merged/closed state is final and such a PR is never edited: no host read.
221
+ if (pr.state === "merged" || pr.state === "closed") {
222
+ live.push({ repository: pr.repository, number: pr.number, state: pr.state });
223
+ continue;
224
+ }
225
+ try {
226
+ const origin = await host.pullRequestOrigin(pr.repository, pr.number);
227
+ const state = origin.merged
228
+ ? "merged"
229
+ : origin.state !== "open"
230
+ ? "closed"
231
+ : origin.draft
232
+ ? "draft"
233
+ : "open";
234
+ let markerRunId;
235
+ if (origin.markerRunId) {
236
+ const opener = await db.codingRun.findUnique({
237
+ where: { runId: origin.markerRunId },
238
+ select: { repository: true },
239
+ });
240
+ if (opener && opener.repository.toLowerCase() === pr.repository)
241
+ markerRunId = origin.markerRunId;
242
+ }
243
+ live.push({ repository: pr.repository, number: pr.number, state, ...(markerRunId ? { markerRunId } : {}) });
244
+ }
245
+ catch (err) {
246
+ log.warn({ err, runId: run.id, repository: pr.repository, number: pr.number }, "could not read a related pull request");
247
+ // The stored state (issue-recorded PRs) is better than none; never edited without a live read.
248
+ live.push({ repository: pr.repository, number: pr.number, ...(pr.state ? { state: pr.state } : {}) });
249
+ }
250
+ }
251
+ const issue = issueFor(group, trackers);
252
+ for (const target of live) {
253
+ if (!target.markerRunId || (target.state !== "open" && target.state !== "draft"))
254
+ continue;
255
+ const block = renderRelatedSection({
256
+ entries: live.map((pr) => ({
257
+ repository: pr.repository,
258
+ number: pr.number,
259
+ ...(pr.state ? { state: pr.state } : {}),
260
+ ...(pr === target ? { self: true } : {}),
261
+ })),
262
+ ...(issue ? { issue } : {}),
263
+ });
264
+ if (!block)
265
+ continue;
266
+ try {
267
+ const outcome = await host.replaceRelatedSection(target.repository, target.number, {
268
+ expectedMarkerRunId: target.markerRunId,
269
+ block,
270
+ });
271
+ log.info({ runId: run.id, repository: target.repository, number: target.number, outcome }, "related pull requests section");
272
+ }
273
+ catch (err) {
274
+ log.warn({ err, runId: run.id, repository: target.repository, number: target.number }, "could not update a related pull requests section");
275
+ }
276
+ }
277
+ }
278
+ catch (err) {
279
+ log.warn({ err, runId: run.id }, "could not update related pull requests");
280
+ }
281
+ }
282
+ /** Most open siblings hinted in one task. */
283
+ export const MAX_SIBLING_HINTS = 10;
284
+ const SAFE_RUN_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
285
+ /** Closes every sibling-hint block (mention and Jira alike). */
286
+ export const SIBLING_GUIDANCE = "Continue the pull request you were asked about, and a listed open one only when the change requires it. " +
287
+ "Never open a new pull request in a repository that already has an open pull request listed here. Merged or " +
288
+ "closed pull requests are not listed: a change there needs a new pull request.";
289
+ /**
290
+ * The group's pull requests a follow-up may continue: stored state "open", or
291
+ * no stored state yet (a same-tree sibling not recorded for an issue), minus
292
+ * `exclude` and malformed run ids. Stored state only: no host call. A hint for
293
+ * a PR that has closed since is refused by the continuation itself (git.ts).
294
+ */
295
+ export function openSiblingsOf(group, exclude) {
296
+ const excluded = exclude ? `${safeRepository(exclude.repository) ?? ""}#${exclude.number}` : "";
297
+ return group.pullRequests
298
+ .filter((pr) => (pr.state === undefined || pr.state === "open") &&
299
+ `${pr.repository}#${pr.number}` !== excluded &&
300
+ SAFE_RUN_ID.test(pr.openedByRunId))
301
+ .slice(0, MAX_SIBLING_HINTS)
302
+ .map((pr) => ({ repository: pr.repository, number: pr.number, openedByRunId: pr.openedByRunId }));
303
+ }
304
+ /** Open siblings of the request `seedRunId` belongs to. Never throws. */
305
+ export async function openSiblings(db, seedRunId, exclude) {
306
+ try {
307
+ return openSiblingsOf(await collectRelatedPullRequests(db, seedRunId), exclude);
308
+ }
309
+ catch (err) {
310
+ log.warn({ err, seedRunId }, "could not list open sibling pull requests; continuing without hints");
311
+ return [];
312
+ }
313
+ }
314
+ /**
315
+ * Open pull requests of a whole card: seeded from the newest pull request
316
+ * recorded for the issue (any agent), then the collector is scoped to this
317
+ * issue alone (never "every issue the tree touched") so a tree that happens
318
+ * to also carry another card's coding runs can't leak that card's pull
319
+ * requests into this one's hints. Never throws.
320
+ */
321
+ export async function openSiblingsForIssue(db, issue) {
322
+ try {
323
+ const newest = await db.issuePullRequest.findMany({
324
+ where: { issueProvider: issue.provider, issueKey: issue.key, codeProvider: CODING_CODE_PROVIDER },
325
+ orderBy: { createdAt: "desc" },
326
+ take: 1,
327
+ select: { openedByRunId: true },
328
+ });
329
+ const seed = newest[0]?.openedByRunId;
330
+ if (!seed || !SAFE_RUN_ID.test(seed))
331
+ return [];
332
+ return openSiblingsOf(await collectRelatedPullRequests(db, seed, issue));
333
+ }
334
+ catch (err) {
335
+ log.warn({ err, issueKey: issue.key }, "could not list the issue's open pull requests; continuing without hints");
336
+ return [];
337
+ }
338
+ }