@nanobpm/nano-workforce 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +60 -0
- package/.github/workflows/release.yml +58 -0
- package/.releaserc.json +17 -0
- package/AGENTS.md +168 -0
- package/CHANGELOG.md +231 -0
- package/LICENSE +202 -0
- package/README.md +303 -0
- package/SPEC.md +492 -0
- package/actions/abandon.test.ts +93 -0
- package/actions/abandon.ts +23 -0
- package/actions/blackboard.test.ts +195 -0
- package/actions/blackboard.ts +76 -0
- package/actions/cancel.ts +29 -0
- package/actions/feature-answer-hook.ts +44 -0
- package/actions/message.ts +49 -0
- package/actions/plan-hook.ts +19 -0
- package/actions/plan-start.ts +17 -0
- package/actions/start.ts +19 -0
- package/actions/status.ts +22 -0
- package/actions/webhook-submit.ts +21 -0
- package/app/abandon.test.ts +97 -0
- package/app/abandon.ts +105 -0
- package/app/baseGuard.test.ts +35 -0
- package/app/baseGuard.ts +62 -0
- package/app/blackboard.test.ts +295 -0
- package/app/blackboard.ts +301 -0
- package/app/github.test.ts +59 -0
- package/app/github.ts +647 -0
- package/app/mergeExclusion.test.ts +168 -0
- package/app/mergeExclusion.ts +211 -0
- package/app/mergeProtocol.test.ts +124 -0
- package/app/mergeProtocol.ts +193 -0
- package/app/mergeRebaseArm.test.ts +72 -0
- package/app/mergeTrain.test.ts +91 -0
- package/app/mergeTrain.ts +117 -0
- package/app/persist-escalation.test.ts +119 -0
- package/app/persist-round.test.ts +65 -0
- package/app/plan.test.ts +317 -0
- package/app/plan.ts +321 -0
- package/app/record-plan-review.test.ts +38 -0
- package/app/reviewWait.test.ts +70 -0
- package/app/reviewWait.ts +59 -0
- package/app/rounds.test.ts +74 -0
- package/app/rounds.ts +48 -0
- package/app/service.test.ts +101 -0
- package/app/service.ts +895 -0
- package/app/taskDelta.test.ts +144 -0
- package/app/taskDelta.ts +175 -0
- package/app/trialMerge.test.ts +15 -0
- package/app/trialMerge.ts +102 -0
- package/app/waves.test.ts +128 -0
- package/app/waves.ts +116 -0
- package/assets/icon.svg +13 -0
- package/components/review-round.json +69 -0
- package/db/migrations/001_init.sql +46 -0
- package/db/migrations/002_transcript.sql +7 -0
- package/db/migrations/003_open_escalation.sql +8 -0
- package/db/migrations/004_merge.sql +36 -0
- package/db/migrations/004_planning.sql +37 -0
- package/db/migrations/005_job_activation.sql +15 -0
- package/db/migrations/005_plan_deps.sql +20 -0
- package/db/migrations/006_plan_review.sql +22 -0
- package/db/migrations/006_task_escalation.sql +52 -0
- package/db/migrations/007_plan_review_job_key.sql +14 -0
- package/db/migrations/007_wave_gate.sql +16 -0
- package/db/migrations/008_review_nudge.sql +9 -0
- package/db/migrations/009_plan_blackboard.sql +46 -0
- package/db/migrations/010_plan_task_deltas.sql +27 -0
- package/db/migrations/011_plan_merge_exclusions.sql +26 -0
- package/db/migrations/012_merge_protocol_attempt.sql +4 -0
- package/db/migrations/013_merge_train_waiting_lane.sql +6 -0
- package/db/migrations/014_plan_trial_merges.sql +21 -0
- package/db/migrations/015_pr_abandon_token.sql +9 -0
- package/deno.json +24 -0
- package/deno.lock +1776 -0
- package/main.ts +71 -0
- package/nano-ide.ext.json +7 -0
- package/nano.app.json +138 -0
- package/nanobpm.project.json +20 -0
- package/package.json +56 -0
- package/pages/epic.page.json +195 -0
- package/pages/home.page.json +296 -0
- package/prompts/feature.md +132 -0
- package/prompts/fix-ci.md +65 -0
- package/prompts/plan-review.md +69 -0
- package/prompts/plan.md +183 -0
- package/prompts/rebase.md +82 -0
- package/prompts/review-round.md +171 -0
- package/prompts/trial-merge.md +43 -0
- package/renovate.json +21 -0
- package/resources/processes/convergence-loop.bpmn +399 -0
- package/resources/processes/merge-loop.bpmn +585 -0
- package/resources/processes/plan-fanout.bpmn +546 -0
- package/scripts/check-agent-prompts.test.ts +84 -0
- package/scripts/check-agent-prompts.ts +143 -0
- package/scripts/layout-bpmn.ts +99 -0
- package/scripts/purge-db.ts +57 -0
- package/scripts/upgrade-from-pack.ts +334 -0
- package/tsconfig.json +51 -0
- package/workers/arm-merge/worker.ts +18 -0
- package/workers/finalize/worker.ts +89 -0
- package/workers/mark-merged/worker.ts +21 -0
- package/workers/merge/worker.ts +119 -0
- package/workers/persist-escalation/worker.ts +107 -0
- package/workers/persist-round/worker.ts +52 -0
- package/workers/persist-task-escalation/worker.ts +112 -0
- package/workers/record-plan/worker.ts +135 -0
- package/workers/record-plan-review/worker.ts +92 -0
- package/workers/record-results/worker.ts +30 -0
- package/workers/record-trial-merge/worker.test.ts +104 -0
- package/workers/record-trial-merge/worker.ts +88 -0
- package/workers/record-wave/worker.test.ts +221 -0
- package/workers/record-wave/worker.ts +308 -0
- package/workers/select-wave/worker.test.ts +130 -0
- package/workers/select-wave/worker.ts +84 -0
package/app/service.ts
ADDED
|
@@ -0,0 +1,895 @@
|
|
|
1
|
+
// nano-workforce — the app's business logic over the Urban runtime seams (ADR 0055).
|
|
2
|
+
//
|
|
3
|
+
// The action handlers (`actions/*.ts`) and the review-ready poller (`main.ts`) both call
|
|
4
|
+
// these functions. Actions receive `app.data` (the typed datasource gateway) and
|
|
5
|
+
// `app.engine` (the transport-agnostic engine client) from the injected `AppApi`; the
|
|
6
|
+
// poller passes the same `DataLayer` + `EngineClient` obtained from `main.ts`.
|
|
7
|
+
//
|
|
8
|
+
// Data access goes through the record-oriented gateway (`data.table<T>(name, pk)` — the RAD
|
|
9
|
+
// `Table<T>` surface), not hand-written SQL. Row shapes are declared inline here.
|
|
10
|
+
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
11
|
+
import {
|
|
12
|
+
classifyMergeability,
|
|
13
|
+
ensureFreshHeadRun,
|
|
14
|
+
fetchPrMeta,
|
|
15
|
+
fetchPrReviews,
|
|
16
|
+
fetchPrState,
|
|
17
|
+
hasPendingCopilotReviewer,
|
|
18
|
+
type MergeMethod,
|
|
19
|
+
requestCopilotReview,
|
|
20
|
+
} from "./github.ts";
|
|
21
|
+
import { planTaskDeps, planTasks, plans } from "./plan.ts";
|
|
22
|
+
import { abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
|
|
23
|
+
import { freshHeadRunAction, loadMergeProtocol } from "./mergeProtocol.ts";
|
|
24
|
+
import { clampNudgeMinutes, reviewWaitTimeout } from "./reviewWait.ts";
|
|
25
|
+
import { waveMergeTargets } from "./waves.ts";
|
|
26
|
+
import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
|
|
27
|
+
import { planPrLane, type PrLaneDecision, taskDependencyDepths } from "./mergeTrain.ts";
|
|
28
|
+
|
|
29
|
+
/** The BPMN process that drives review convergence (`resources/processes/convergence-loop.bpmn`). */
|
|
30
|
+
export const PROCESS_ID = "convergence-loop";
|
|
31
|
+
/** The BPMN process that lands a converged PR (`resources/processes/merge-loop.bpmn`). */
|
|
32
|
+
export const MERGE_PROCESS_ID = "merge-loop";
|
|
33
|
+
/** Job type of the external review agent (the `review-round` service task's `zeebe:taskDefinition`
|
|
34
|
+
* in convergence-loop.bpmn). Deliberately NOT hosted here — an external harness services it; the
|
|
35
|
+
* activation poll keys off it to tell "agent working" from "queued". */
|
|
36
|
+
const REVIEW_JOB_TYPE = "senior:pr-review";
|
|
37
|
+
/** Default round cap before the loop escalates to a human. A per-submit override (submit form /
|
|
38
|
+
* webhook / start action) takes precedence; this env var sets the fleet-wide default. The cap
|
|
39
|
+
* coercion + ceiling live in the pure `./rounds.ts` module (re-exported for callers). */
|
|
40
|
+
export { clampCiFixBudget, clampRounds, MAX_CI_FIX_CEILING, MAX_ROUNDS_CEILING } from "./rounds.ts";
|
|
41
|
+
import { clampCiFixBudget, clampRounds } from "./rounds.ts";
|
|
42
|
+
export const MAX_ROUNDS = clampRounds(process.env.NANO_PR_MAX_ROUNDS, 20);
|
|
43
|
+
|
|
44
|
+
/** How many times the merge stage will dispatch a `senior:fix-ci` agent to make a blocked PR's
|
|
45
|
+
* failing required checks green before giving up and escalating to a human. Default 3; set
|
|
46
|
+
* `NANO_PR_MAX_CI_FIX_ROUNDS=0` to disable auto-fix (a blocked PR escalates immediately). Unlike
|
|
47
|
+
* the review-round cap this allows 0 (disable), so it parses directly rather than via clampRounds. */
|
|
48
|
+
export const MAX_CI_FIX_ROUNDS = clampCiFixBudget(process.env.NANO_PR_MAX_CI_FIX_ROUNDS, 3);
|
|
49
|
+
|
|
50
|
+
/** How many times the merge stage will dispatch a `senior:rebase` agent to bring a conflicting
|
|
51
|
+
* (moved-base) PR up to date with its base before giving up and escalating to a human. Default 3;
|
|
52
|
+
* set `NANO_PR_MAX_REBASE_ROUNDS=0` to disable auto-rebase (a conflicting PR escalates
|
|
53
|
+
* immediately). Reuses the CI-fix budget clamp (allows 0 = disable, ceiling-capped). */
|
|
54
|
+
export const MAX_REBASE_ROUNDS = clampCiFixBudget(process.env.NANO_PR_MAX_REBASE_ROUNDS, 3);
|
|
55
|
+
|
|
56
|
+
/** How long the convergence loop waits for a fresh review before escalating to a human. Seeded as
|
|
57
|
+
* the `reviewWaitTimeout` process variable at submit and evaluated by the process's
|
|
58
|
+
* `wait-review-timeout` timer catch (the timer arm of the event-based-gateway race against
|
|
59
|
+
* `review-ready`). ISO-8601 duration; a malformed `NANO_PR_REVIEW_WAIT_TIMEOUT` falls back to the
|
|
60
|
+
* default so an uninterpretable timer is never deployed. */
|
|
61
|
+
export const REVIEW_WAIT_TIMEOUT = reviewWaitTimeout(process.env.NANO_PR_REVIEW_WAIT_TIMEOUT);
|
|
62
|
+
|
|
63
|
+
/** Cooldown (ms) between the poller's automatic Copilot re-request nudges for a single waiting PR.
|
|
64
|
+
* Copilot dismisses re-requests, so the poller retries — but not on every tick; this throttles it
|
|
65
|
+
* to one attempt per window. Set via `NANO_PR_REVIEW_NUDGE_MINUTES` (minutes). */
|
|
66
|
+
export const REVIEW_NUDGE_MS = clampNudgeMinutes(process.env.NANO_PR_REVIEW_NUDGE_MINUTES) * 60_000;
|
|
67
|
+
|
|
68
|
+
/** Whether a converged PR is automatically driven to merge (the merge-loop). Default on; set
|
|
69
|
+
* `NANO_PR_AUTO_MERGE=0` to stop at `converged` (review-only mode). */
|
|
70
|
+
export const AUTO_MERGE = !["0", "false", "off", "no"].includes(
|
|
71
|
+
(process.env.NANO_PR_AUTO_MERGE ?? "1").trim().toLowerCase(),
|
|
72
|
+
);
|
|
73
|
+
/** Merge method passed to `gh pr merge` / the REST merge API. */
|
|
74
|
+
export const MERGE_METHOD: MergeMethod = (() => {
|
|
75
|
+
const m = (process.env.NANO_PR_MERGE_METHOD ?? "squash").trim().toLowerCase();
|
|
76
|
+
return m === "merge" || m === "rebase" ? m : "squash";
|
|
77
|
+
})();
|
|
78
|
+
/** Whether to pass `--admin` to `gh pr merge` (bypass branch policy where the operator is an
|
|
79
|
+
* admin — mirrors the manual `gh pr merge --squash --admin` fallback some repos require). */
|
|
80
|
+
export const MERGE_ADMIN = ["1", "true", "on", "yes"].includes(
|
|
81
|
+
(process.env.NANO_PR_MERGE_ADMIN ?? "0").trim().toLowerCase(),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// The `senior:pr-review` agent prompt is no longer read by the host: it is authored in the
|
|
85
|
+
// model as a `{{review-round}}` deploy-time template (see `models.templates` in nano.app.json)
|
|
86
|
+
// substituted into the task's `io.nanobpm.agentTask.task.prompt` header. The host only carries
|
|
87
|
+
// runtime PR identity + the round counter now.
|
|
88
|
+
|
|
89
|
+
const now = () => new Date().toISOString();
|
|
90
|
+
|
|
91
|
+
/** A PR is "done" in exactly these states; everything else (converging, waiting_review,
|
|
92
|
+
* escalated, and the merge-stage waiting_deps/waiting_merge/waiting_lane/queued) is in flight. `converged`
|
|
93
|
+
* is terminal only in review-only mode (AUTO_MERGE off); with auto-merge on, a converged PR
|
|
94
|
+
* transitions into the merge stage and lands as `merged`. The status endpoint and the cancel
|
|
95
|
+
* guard both key off this set. */
|
|
96
|
+
export const TERMINAL_STATUSES: readonly string[] = ["converged", "merged", "abandoned"];
|
|
97
|
+
|
|
98
|
+
interface PullRequest {
|
|
99
|
+
pr_key: string;
|
|
100
|
+
repo: string;
|
|
101
|
+
number: number;
|
|
102
|
+
url: string;
|
|
103
|
+
title: string | null;
|
|
104
|
+
status: string;
|
|
105
|
+
current_round: number;
|
|
106
|
+
process_key: string | null;
|
|
107
|
+
waiting_since: string | null;
|
|
108
|
+
last_review_id: number | null;
|
|
109
|
+
outcome: string | null;
|
|
110
|
+
created_at: string;
|
|
111
|
+
updated_at: string;
|
|
112
|
+
converged_at: string | null;
|
|
113
|
+
merged_at: string | null;
|
|
114
|
+
open_escalation_id: number | null;
|
|
115
|
+
open_escalation_question: string | null;
|
|
116
|
+
// Job-activation visibility (005_job_activation.sql), written by the poller's
|
|
117
|
+
// `pollJobActivation` pass. `active_worker` is the leasing worker's name while an
|
|
118
|
+
// agent is actively working the `senior:pr-review` round; NULL means the job is
|
|
119
|
+
// queued (created, not yet activated) or the process isn't at review-round.
|
|
120
|
+
active_worker: string | null;
|
|
121
|
+
lease_until: string | null;
|
|
122
|
+
// Review-wait liveness (008_review_nudge.sql): ISO ts the poller last re-requested a Copilot
|
|
123
|
+
// review for this PR, so the nudge is throttled to one attempt per REVIEW_NUDGE_MS window.
|
|
124
|
+
// NULL means never nudged.
|
|
125
|
+
last_nudge_at: string | null;
|
|
126
|
+
// Merge-protocol liveness (012_merge_protocol_attempt.sql): head commit last nudged by the
|
|
127
|
+
// frugal-CI fresh-head-run remedy. A rebase changes the head and therefore permits a new nudge.
|
|
128
|
+
fresh_head_run_head: string | null;
|
|
129
|
+
// Cooperative abandon check (015_pr_abandon_token.sql, issue #76): the per-PR capability token a
|
|
130
|
+
// running agent curls (GET /hooks/abandon?token=…) to learn whether this run was cancelled before
|
|
131
|
+
// it performs a side effect. Minted at submit, reused across the convergence + merge instances.
|
|
132
|
+
abandon_token: string | null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface PrDependency {
|
|
136
|
+
pr_key: string;
|
|
137
|
+
depends_on_key: string;
|
|
138
|
+
created_at: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface Escalation {
|
|
142
|
+
id: number;
|
|
143
|
+
pr_key: string;
|
|
144
|
+
round_no: number;
|
|
145
|
+
kind: string;
|
|
146
|
+
question: string;
|
|
147
|
+
answer: string | null;
|
|
148
|
+
status: string;
|
|
149
|
+
asked_at: string;
|
|
150
|
+
answered_at: string | null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const prs = (data: DataLayer) => data.table<PullRequest>("pull_requests", "pr_key");
|
|
154
|
+
const escs = (data: DataLayer) => data.table<Escalation>("escalations", "id");
|
|
155
|
+
const deps = (data: DataLayer) => data.table<PrDependency>("pr_dependencies", "pr_key");
|
|
156
|
+
|
|
157
|
+
export interface ParsedPr {
|
|
158
|
+
repo: string;
|
|
159
|
+
number: number;
|
|
160
|
+
url: string;
|
|
161
|
+
prKey: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Parse "owner/repo#123" or a canonical PR URL into its parts. */
|
|
165
|
+
export function parsePr(input: string): ParsedPr | null {
|
|
166
|
+
const s = input.trim();
|
|
167
|
+
let m = s.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/i);
|
|
168
|
+
if (m) {
|
|
169
|
+
const repo = `${m[1]}/${m[2]}`;
|
|
170
|
+
const number = Number(m[3]);
|
|
171
|
+
return { repo, number, url: `https://github.com/${repo}/pull/${number}`, prKey: `${repo}#${number}` };
|
|
172
|
+
}
|
|
173
|
+
m = s.match(/^([^/]+\/[^#]+)#(\d+)$/);
|
|
174
|
+
if (m) {
|
|
175
|
+
const repo = m[1];
|
|
176
|
+
const number = Number(m[2]);
|
|
177
|
+
return { repo, number, url: `https://github.com/${repo}/pull/${number}`, prKey: `${repo}#${number}` };
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Extract `Depends-on: owner/repo#N[, owner/repo#N …]` (or PR URLs) from a PR body. Multiple
|
|
183
|
+
* `Depends-on:` lines accumulate; each line may list several comma/space-separated refs. Returns
|
|
184
|
+
* the normalized `owner/repo#N` keys. Unparseable tokens are ignored. */
|
|
185
|
+
export function parseDependsOn(body: string): string[] {
|
|
186
|
+
const out = new Set<string>();
|
|
187
|
+
for (const line of (body ?? "").split(/\r?\n/)) {
|
|
188
|
+
const m = line.match(/^\s*depends[-\s]?on\s*:\s*(.+)$/i);
|
|
189
|
+
if (!m) continue;
|
|
190
|
+
for (const tok of m[1].split(/[,\s]+/)) {
|
|
191
|
+
const p = parsePr(tok);
|
|
192
|
+
if (p) out.add(p.prKey);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return [...out];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Replace a PR's dependency set (idempotent on resubmit). Self-references are dropped so a PR
|
|
199
|
+
* can never wait on itself. */
|
|
200
|
+
async function registerDependencies(data: DataLayer, prKey: string, depKeys: string[]) {
|
|
201
|
+
const table = deps(data);
|
|
202
|
+
// The gateway keys this table on `pr_key`, so a single delete clears the PR's whole dep set
|
|
203
|
+
// (DELETE ... WHERE pr_key = ?) — then we re-insert the current set.
|
|
204
|
+
await table.delete(prKey);
|
|
205
|
+
const ts = now();
|
|
206
|
+
for (const depKey of new Set(depKeys)) {
|
|
207
|
+
if (depKey === prKey) continue;
|
|
208
|
+
await table.insert({ pr_key: prKey, depends_on_key: depKey, created_at: ts });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Register a PR row (if new) and start the convergence process. Idempotent on prKey. Optional
|
|
213
|
+
* `dependsOn` (explicit refs) is unioned with any `Depends-on:` line parsed from the PR body and
|
|
214
|
+
* recorded as the PR's merge-stage dependency set. */
|
|
215
|
+
export async function submitPr(
|
|
216
|
+
data: DataLayer,
|
|
217
|
+
engine: EngineClient,
|
|
218
|
+
parsed: ParsedPr,
|
|
219
|
+
dependsOn: string[] = [],
|
|
220
|
+
maxRounds: number = MAX_ROUNDS,
|
|
221
|
+
) {
|
|
222
|
+
const table = prs(data);
|
|
223
|
+
const existing = await table.get(parsed.prKey);
|
|
224
|
+
if (existing && !TERMINAL_STATUSES.includes(existing.status)) {
|
|
225
|
+
return { prKey: parsed.prKey, alreadyRunning: true };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Best-effort GitHub read: the title labels the row and the body may carry `Depends-on:` refs.
|
|
229
|
+
// A transport failure (no gh/token) must not block submission — we just skip enrichment.
|
|
230
|
+
const token = process.env.GITHUB_TOKEN ?? "";
|
|
231
|
+
let title: string | null = null;
|
|
232
|
+
const depKeys = new Set(dependsOn.map((d) => parsePr(d)?.prKey).filter((k): k is string => !!k));
|
|
233
|
+
try {
|
|
234
|
+
const meta = await fetchPrMeta(parsed.repo, parsed.number, token);
|
|
235
|
+
if (meta) {
|
|
236
|
+
title = meta.title;
|
|
237
|
+
for (const k of parseDependsOn(meta.body)) depKeys.add(k);
|
|
238
|
+
}
|
|
239
|
+
} catch (err) {
|
|
240
|
+
console.warn(`[submit] ${parsed.prKey} meta fetch: ${err}`);
|
|
241
|
+
}
|
|
242
|
+
await registerDependencies(data, parsed.prKey, [...depKeys]);
|
|
243
|
+
|
|
244
|
+
const ts = now();
|
|
245
|
+
// Cooperative abandon check (#76): reuse the PR's existing capability token across re-runs (and
|
|
246
|
+
// the later merge instance), or mint one for a first submission.
|
|
247
|
+
const abandonToken = existing?.abandon_token ?? mintAbandonToken();
|
|
248
|
+
if (existing) {
|
|
249
|
+
// A prior run (cancelled, converged, or otherwise superseded) may have left an OPEN
|
|
250
|
+
// escalation row plus the denormalised pointer on the PR. A fresh convergence run must not
|
|
251
|
+
// inherit that stale answer form — the "(no question provided)" bleed-through on resubmit
|
|
252
|
+
// (Magikcraft/nano-bpm #597/#599). Mark any still-open escalations `stale` and clear the
|
|
253
|
+
// pointer below, mirroring the plan re-plan cleanup (issue #25 in plan.ts).
|
|
254
|
+
for (const e of await escs(data).find({ pr_key: parsed.prKey, status: "open" })) {
|
|
255
|
+
await escs(data).update(e.id, { status: "stale" });
|
|
256
|
+
}
|
|
257
|
+
// Re-open a previously converged/abandoned/merged PR for a fresh convergence run.
|
|
258
|
+
await table.update(parsed.prKey, {
|
|
259
|
+
status: "converging",
|
|
260
|
+
current_round: 1,
|
|
261
|
+
url: parsed.url,
|
|
262
|
+
title: title ?? existing.title,
|
|
263
|
+
waiting_since: null,
|
|
264
|
+
last_review_id: null,
|
|
265
|
+
last_nudge_at: null,
|
|
266
|
+
outcome: null,
|
|
267
|
+
converged_at: null,
|
|
268
|
+
merged_at: null,
|
|
269
|
+
// Drop any denormalised open-escalation pointer from the prior run so the answer form
|
|
270
|
+
// does not resurface a dead/stale question on the re-opened PR.
|
|
271
|
+
open_escalation_id: null,
|
|
272
|
+
open_escalation_question: null,
|
|
273
|
+
abandon_token: abandonToken,
|
|
274
|
+
updated_at: ts,
|
|
275
|
+
});
|
|
276
|
+
} else {
|
|
277
|
+
await table.insert({
|
|
278
|
+
pr_key: parsed.prKey,
|
|
279
|
+
repo: parsed.repo,
|
|
280
|
+
number: parsed.number,
|
|
281
|
+
url: parsed.url,
|
|
282
|
+
title,
|
|
283
|
+
status: "converging",
|
|
284
|
+
current_round: 1,
|
|
285
|
+
abandon_token: abandonToken,
|
|
286
|
+
created_at: ts,
|
|
287
|
+
updated_at: ts,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
const abUrl = abandonUrl(abandonToken);
|
|
291
|
+
const { processInstanceKey } = await engine.createInstance({
|
|
292
|
+
processDefinitionId: PROCESS_ID,
|
|
293
|
+
variables: {
|
|
294
|
+
repo: parsed.repo,
|
|
295
|
+
prNumber: parsed.number,
|
|
296
|
+
prUrl: parsed.url,
|
|
297
|
+
prKey: parsed.prKey,
|
|
298
|
+
round: 1,
|
|
299
|
+
maxRounds: clampRounds(maxRounds, MAX_ROUNDS),
|
|
300
|
+
reviewWaitTimeout: REVIEW_WAIT_TIMEOUT,
|
|
301
|
+
// Cooperative abandon check (#76): the capability URL + the abort brief appended to the
|
|
302
|
+
// review-round agent's prompt, so it can stop before pushing if the run is cancelled.
|
|
303
|
+
abandonUrl: abUrl,
|
|
304
|
+
abandonBrief: renderAbandonBrief(abUrl),
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
if (processInstanceKey != null) {
|
|
308
|
+
await table.update(parsed.prKey, { process_key: String(processInstanceKey) });
|
|
309
|
+
}
|
|
310
|
+
return { prKey: parsed.prKey, processKey: processInstanceKey };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Start the merge-loop for a converged PR (called by the `pr.finalize` worker when AUTO_MERGE
|
|
314
|
+
* is on). Carries the same PR identity + the converged round so the merge stage can escalate
|
|
315
|
+
* with a round number. Idempotent-ish: the caller only invokes this once per convergence. */
|
|
316
|
+
export async function startMerge(
|
|
317
|
+
data: DataLayer,
|
|
318
|
+
engine: EngineClient,
|
|
319
|
+
pr: { repo: string; number: number; url: string; prKey: string; round: number },
|
|
320
|
+
) {
|
|
321
|
+
// Cooperative abandon check (#76): reuse the token minted at submit so the merge agents
|
|
322
|
+
// (fix-ci, rebase) share the PR's abandon URL; mint one if an older row predates the column.
|
|
323
|
+
const existing = await prs(data).get(pr.prKey);
|
|
324
|
+
const abandonToken = existing?.abandon_token ?? mintAbandonToken();
|
|
325
|
+
if (!existing?.abandon_token) {
|
|
326
|
+
await prs(data).update(pr.prKey, { abandon_token: abandonToken, updated_at: now() });
|
|
327
|
+
}
|
|
328
|
+
const abUrl = abandonUrl(abandonToken);
|
|
329
|
+
const { processInstanceKey } = await engine.createInstance({
|
|
330
|
+
processDefinitionId: MERGE_PROCESS_ID,
|
|
331
|
+
variables: {
|
|
332
|
+
repo: pr.repo,
|
|
333
|
+
prNumber: pr.number,
|
|
334
|
+
prUrl: pr.url,
|
|
335
|
+
prKey: pr.prKey,
|
|
336
|
+
round: pr.round,
|
|
337
|
+
ciFixRound: 0,
|
|
338
|
+
ciFixMax: MAX_CI_FIX_ROUNDS,
|
|
339
|
+
rebaseRound: 0,
|
|
340
|
+
rebaseMax: MAX_REBASE_ROUNDS,
|
|
341
|
+
abandonUrl: abUrl,
|
|
342
|
+
abandonBrief: renderAbandonBrief(abUrl),
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
if (processInstanceKey != null) {
|
|
346
|
+
await prs(data).update(pr.prKey, { process_key: String(processInstanceKey), updated_at: now() });
|
|
347
|
+
}
|
|
348
|
+
return { prKey: pr.prKey, mergeProcessKey: processInstanceKey };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Answer an open escalation → record it and resume the process. */
|
|
352
|
+
export async function answerEscalation(
|
|
353
|
+
data: DataLayer,
|
|
354
|
+
engine: EngineClient,
|
|
355
|
+
prKey: string,
|
|
356
|
+
answer: string,
|
|
357
|
+
) {
|
|
358
|
+
const open = (await escs(data).find({ pr_key: prKey, status: "open" })).sort((a, b) => b.id - a.id)[0];
|
|
359
|
+
if (!open) return { ok: false, reason: "no open escalation" };
|
|
360
|
+
const ts = now();
|
|
361
|
+
await escs(data).update(open.id, { answer, status: "answered", answered_at: ts });
|
|
362
|
+
await prs(data).update(prKey, {
|
|
363
|
+
status: "converging",
|
|
364
|
+
updated_at: ts,
|
|
365
|
+
open_escalation_id: null,
|
|
366
|
+
open_escalation_question: null,
|
|
367
|
+
});
|
|
368
|
+
await engine.publishMessage({
|
|
369
|
+
name: "escalation-answered",
|
|
370
|
+
correlationKey: prKey,
|
|
371
|
+
variables: { answer, escalationId: open.id },
|
|
372
|
+
});
|
|
373
|
+
return { ok: true, escalationId: open.id };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** How a caller identifies the run to cancel: by its engine `processInstanceKey` or, more
|
|
377
|
+
* ergonomically, by the `prKey` the status endpoint reports. The cancel action rejects a
|
|
378
|
+
* request that supplies both, so exactly one selector reaches here. */
|
|
379
|
+
export interface CancelSelector {
|
|
380
|
+
processInstanceKey?: string;
|
|
381
|
+
prKey?: string;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Cancel a PR's running convergence instance and mark it abandoned. Terminating the engine
|
|
385
|
+
* instance emits no completion event (no worker runs), so the app-tier flips the PR's status
|
|
386
|
+
* here — the same place ADR 0040 puts app-owned rest state. Accepts either selector; a PR
|
|
387
|
+
* already in a terminal state is left untouched so a stale cancel can't overwrite a `converged`
|
|
388
|
+
* outcome with `abandoned`. */
|
|
389
|
+
export async function cancelRun(data: DataLayer, engine: EngineClient, selector: CancelSelector) {
|
|
390
|
+
const { processInstanceKey, prKey } = selector;
|
|
391
|
+
const table = prs(data);
|
|
392
|
+
const pr = prKey
|
|
393
|
+
? await table.get(prKey)
|
|
394
|
+
: processInstanceKey
|
|
395
|
+
? (await table.find({ process_key: processInstanceKey }))[0]
|
|
396
|
+
: undefined;
|
|
397
|
+
if (pr && TERMINAL_STATUSES.includes(pr.status)) {
|
|
398
|
+
return { ok: false, kind: "terminal", reason: `PR already ${pr.status}`, prKey: pr.pr_key };
|
|
399
|
+
}
|
|
400
|
+
const instanceKey = pr?.process_key ?? processInstanceKey ?? null;
|
|
401
|
+
if (instanceKey) {
|
|
402
|
+
try {
|
|
403
|
+
await engine.cancelInstance({ processInstanceKey: instanceKey });
|
|
404
|
+
} catch (err) {
|
|
405
|
+
// The instance may already be gone (converged/cancelled) — still reconcile the app row so
|
|
406
|
+
// a stale "converging" PR can't linger in the UI.
|
|
407
|
+
console.warn(`[cancel] engine cancel for ${instanceKey}: ${err}`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
if (pr) {
|
|
411
|
+
await table.update(pr.pr_key, {
|
|
412
|
+
status: "abandoned",
|
|
413
|
+
updated_at: now(),
|
|
414
|
+
open_escalation_id: null,
|
|
415
|
+
open_escalation_question: null,
|
|
416
|
+
});
|
|
417
|
+
return { ok: true, prKey: pr.pr_key };
|
|
418
|
+
}
|
|
419
|
+
return { ok: false, kind: "not_found", reason: "no PR for that selector" };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** A PR currently in flight, as reported by the status endpoint. */
|
|
423
|
+
export interface ActivePr {
|
|
424
|
+
prKey: string;
|
|
425
|
+
repo: string;
|
|
426
|
+
number: number;
|
|
427
|
+
url: string;
|
|
428
|
+
title: string | null;
|
|
429
|
+
status: string;
|
|
430
|
+
round: number;
|
|
431
|
+
processKey: string | null;
|
|
432
|
+
waitingSince: string | null;
|
|
433
|
+
openEscalation: string | null;
|
|
434
|
+
updatedAt: string;
|
|
435
|
+
/** Leasing worker while an agent is actively working the review round; null when queued
|
|
436
|
+
* (job created, not yet activated) or not at the review-round task. */
|
|
437
|
+
activeWorker: string | null;
|
|
438
|
+
/** ISO ts the current activation lease expires; null when not activated. */
|
|
439
|
+
leaseUntil: string | null;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Every tracked PR not in a terminal state (converged/abandoned), newest-updated first. Backs
|
|
443
|
+
* the GET status endpoint so an operator or an external harness can see what is in flight
|
|
444
|
+
* without reading the datasource directly. */
|
|
445
|
+
export async function activePrs(data: DataLayer): Promise<ActivePr[]> {
|
|
446
|
+
const all = await prs(data).all();
|
|
447
|
+
return all
|
|
448
|
+
.filter((p) => !TERMINAL_STATUSES.includes(p.status))
|
|
449
|
+
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : 0))
|
|
450
|
+
.map((p) => ({
|
|
451
|
+
prKey: p.pr_key,
|
|
452
|
+
repo: p.repo,
|
|
453
|
+
number: p.number,
|
|
454
|
+
url: p.url,
|
|
455
|
+
title: p.title ?? null,
|
|
456
|
+
status: p.status,
|
|
457
|
+
round: p.current_round,
|
|
458
|
+
processKey: p.process_key ?? null,
|
|
459
|
+
waitingSince: p.waiting_since ?? null,
|
|
460
|
+
openEscalation: p.open_escalation_question ?? null,
|
|
461
|
+
updatedAt: p.updated_at,
|
|
462
|
+
activeWorker: p.active_worker ?? null,
|
|
463
|
+
leaseUntil: p.lease_until ?? null,
|
|
464
|
+
}));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** One review-ready poll pass (SPEC §10): for every PR waiting on a review, fetch its GitHub
|
|
468
|
+
* reviews (via the host `gh` CLI or a token — see `app/github.ts`) and, on a fresh one,
|
|
469
|
+
* correlate a `review-ready` message to resume the loop. */
|
|
470
|
+
async function pollReviews(data: DataLayer, engine: EngineClient, token: string) {
|
|
471
|
+
const waiting = await prs(data).find({ status: "waiting_review" });
|
|
472
|
+
for (const pr of waiting) {
|
|
473
|
+
const { repo, number, pr_key: prKey } = pr;
|
|
474
|
+
const lastId = pr.last_review_id ?? 0;
|
|
475
|
+
try {
|
|
476
|
+
const reviews = await fetchPrReviews(repo, number, token);
|
|
477
|
+
if (reviews === null) return; // no usable transport (no gh, no token) → idle
|
|
478
|
+
const fresh = reviews
|
|
479
|
+
.filter((rv) =>
|
|
480
|
+
rv.id > lastId && rv.submitted_at && (!pr.waiting_since || rv.submitted_at >= pr.waiting_since)
|
|
481
|
+
)
|
|
482
|
+
.sort((a, b) => a.id - b.id)
|
|
483
|
+
.pop();
|
|
484
|
+
if (!fresh) {
|
|
485
|
+
// No fresh review yet. Copilot won't re-review a round with no new commit and dismisses
|
|
486
|
+
// re-requests, so actively (re-)solicit the next review — throttled to one attempt per
|
|
487
|
+
// REVIEW_NUDGE_MS window. The process's timer arm is the backstop if this never lands.
|
|
488
|
+
await maybeRerequestReview(data, pr, token);
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
await prs(data).update(prKey, { last_review_id: fresh.id, status: "converging", updated_at: now() });
|
|
492
|
+
await engine.publishMessage({
|
|
493
|
+
name: "review-ready",
|
|
494
|
+
correlationKey: prKey,
|
|
495
|
+
variables: { reviewId: fresh.id, reviewState: fresh.state, submittedAt: fresh.submitted_at },
|
|
496
|
+
});
|
|
497
|
+
console.log(`[poller] review ${fresh.id} (${fresh.state}) -> ${prKey}`);
|
|
498
|
+
} catch (err) {
|
|
499
|
+
console.error(`[poller] ${prKey}: ${err}`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Ensure a Copilot review is in flight for a PR still waiting, throttled to one attempt per
|
|
505
|
+
* REVIEW_NUDGE_MS window. Skips when Copilot is already a pending reviewer (a review is coming);
|
|
506
|
+
* otherwise re-requests one and records the nudge. This is the primary liveness mechanism — the
|
|
507
|
+
* process's `wait-review-timeout` timer arm only fires (escalating to a human) when even repeated
|
|
508
|
+
* nudges fail to produce a review. A transport failure logs-and-returns without burning the
|
|
509
|
+
* cooldown, so it retries next tick. */
|
|
510
|
+
async function maybeRerequestReview(data: DataLayer, pr: PullRequest, token: string) {
|
|
511
|
+
const since = pr.last_nudge_at ? Date.parse(pr.last_nudge_at) : 0;
|
|
512
|
+
if (Number.isFinite(since) && Date.now() - since < REVIEW_NUDGE_MS) return; // within cooldown
|
|
513
|
+
try {
|
|
514
|
+
const pending = await hasPendingCopilotReviewer(pr.repo, pr.number, token);
|
|
515
|
+
if (pending === null) return; // no usable transport → don't spend the cooldown
|
|
516
|
+
if (pending) {
|
|
517
|
+
// A review is already in flight — record the check so the cooldown holds and we don't
|
|
518
|
+
// re-poll reviewer state until the next window (bounds calls to one per window).
|
|
519
|
+
await prs(data).update(pr.pr_key, { last_nudge_at: now() });
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const res = await requestCopilotReview(pr.repo, pr.number, token);
|
|
523
|
+
// Burn the cooldown only once the request itself succeeded: a transient transport failure
|
|
524
|
+
// throws past this point, so `last_nudge_at` stays put and we retry on the next tick.
|
|
525
|
+
await prs(data).update(pr.pr_key, { last_nudge_at: now() });
|
|
526
|
+
if (res === "requested") console.log(`[poller] re-requested Copilot review -> ${pr.pr_key}`);
|
|
527
|
+
else if (res === "unavailable") {
|
|
528
|
+
console.warn(`[poller] Copilot not an assignable reviewer on ${pr.pr_key}; relying on timeout`);
|
|
529
|
+
}
|
|
530
|
+
} catch (err) {
|
|
531
|
+
console.error(`[poller] ${pr.pr_key} re-request: ${err}`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Is a dependency PR merged? Prefer our own tracked row (cheap, authoritative once we've
|
|
536
|
+
* merged it); otherwise ask GitHub whether that PR has merged (it may be an untracked PR, or
|
|
537
|
+
* one merged out-of-band). A transport failure surfaces as "not merged yet" (caller retries). */
|
|
538
|
+
async function isDepMerged(data: DataLayer, depKey: string, token: string): Promise<boolean> {
|
|
539
|
+
const tracked = await prs(data).get(depKey);
|
|
540
|
+
if (tracked && tracked.status === "merged") return true;
|
|
541
|
+
const parsed = parsePr(depKey);
|
|
542
|
+
if (!parsed) return true; // unparseable dep can't be checked on GitHub → treat as cleared so it never wedges the PR
|
|
543
|
+
const st = await fetchPrState(parsed.repo, parsed.number, token);
|
|
544
|
+
return st?.merged ?? false;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Flip a PR into the transient `merging` status and publish the correlating message, reverting
|
|
548
|
+
* to `prevStatus` if the publish fails. `merging` is deliberately a status no poll branch scans
|
|
549
|
+
* (so a slow pass can't double-signal), which means a publish failure *after* the flip would
|
|
550
|
+
* otherwise wedge the PR there forever — the next pass would never pick it back up. Reverting on
|
|
551
|
+
* failure keeps the PR on a pollable status so the next pass retries. Single source of truth for
|
|
552
|
+
* the flip-then-publish handoff shared by all merge-stage waits below. */
|
|
553
|
+
async function flipToMergingThenPublish(
|
|
554
|
+
data: DataLayer,
|
|
555
|
+
engine: EngineClient,
|
|
556
|
+
prKey: string,
|
|
557
|
+
prevStatus: string,
|
|
558
|
+
message: Parameters<EngineClient["publishMessage"]>[0],
|
|
559
|
+
) {
|
|
560
|
+
await prs(data).update(prKey, { status: "merging", updated_at: now() });
|
|
561
|
+
try {
|
|
562
|
+
await engine.publishMessage(message);
|
|
563
|
+
} catch (err) {
|
|
564
|
+
try {
|
|
565
|
+
await prs(data).update(prKey, { status: prevStatus, updated_at: now() });
|
|
566
|
+
} catch (revertErr) {
|
|
567
|
+
console.error(`[poller] revert ${prKey} -> ${prevStatus} failed: ${revertErr}`);
|
|
568
|
+
}
|
|
569
|
+
throw err;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function mergeLaneDecisionForPr(data: DataLayer, prKey: string): Promise<PrLaneDecision | null> {
|
|
574
|
+
const taskRows = await planTasks(data).find({ pr_key: prKey });
|
|
575
|
+
const task = taskRows[0];
|
|
576
|
+
if (!task) return null;
|
|
577
|
+
const planKey = task.plan_key;
|
|
578
|
+
const allTasks = await planTasks(data).find({ plan_key: planKey });
|
|
579
|
+
const taskToPr = new Map<string, string>();
|
|
580
|
+
const laneTasks: string[] = [];
|
|
581
|
+
for (const t of allTasks) {
|
|
582
|
+
laneTasks.push(t.task_id);
|
|
583
|
+
if (t.pr_key) taskToPr.set(t.task_id, t.pr_key);
|
|
584
|
+
}
|
|
585
|
+
const edges = await readExclusions(data, planKey);
|
|
586
|
+
if (edges.length === 0) return null;
|
|
587
|
+
const lanes = mergeLanes(edges, laneTasks);
|
|
588
|
+
const lanePrKeys = new Set([...taskToPr.values()]);
|
|
589
|
+
const completedPrKeys = new Set<string>();
|
|
590
|
+
for (const lanePrKey of lanePrKeys) {
|
|
591
|
+
const lanePr = await prs(data).get(lanePrKey);
|
|
592
|
+
if (lanePr && (lanePr.status === "merged" || lanePr.status === "abandoned")) {
|
|
593
|
+
completedPrKeys.add(lanePr.pr_key);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
const depths = taskDependencyDepths(await planTaskDeps(data).find({ plan_key: planKey }));
|
|
597
|
+
return planPrLane(lanes, taskToPr, completedPrKeys, prKey, depths);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async function mirrorTaskStatusForPr(data: DataLayer, prKey: string, status: "opened" | "waiting-for-lane") {
|
|
601
|
+
const ts = now();
|
|
602
|
+
for (const t of await planTasks(data).find({ pr_key: prKey })) {
|
|
603
|
+
await planTasks(data).update(t.id, { status, updated_at: ts });
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** Merge-stage poll pass (SPEC §11). Four durable waits, each keyed off the PR's `status`, are
|
|
608
|
+
* advanced by correlating a message — mirroring the review-ready pattern so the process owns
|
|
609
|
+
* the wait and this glue only signals when a GitHub condition is met:
|
|
610
|
+
* • waiting_deps → every declared dependency has merged → `deps-cleared`
|
|
611
|
+
* • waiting_merge → GitHub settled the PR as mergeable/blocked → `merge-ready` {mergeState}
|
|
612
|
+
* • waiting_lane → predecessor in same exclusion lane merged → re-arm `waiting_merge`
|
|
613
|
+
* • queued → the queued PR has landed on GitHub → `merge-landed`
|
|
614
|
+
* On publish we flip status to the transient `merging` (which no branch scans) so a slow pass
|
|
615
|
+
* can't double-signal, exactly as `pollReviews` flips to `converging`; `flipToMergingThenPublish`
|
|
616
|
+
* reverts the flip if the publish fails so a failed handoff can't wedge the PR. */
|
|
617
|
+
async function pollMerges(data: DataLayer, engine: EngineClient, token: string) {
|
|
618
|
+
// 1) Dependencies merged?
|
|
619
|
+
for (const pr of await prs(data).find({ status: "waiting_deps" })) {
|
|
620
|
+
const prKey = pr.pr_key;
|
|
621
|
+
try {
|
|
622
|
+
const depRows = await deps(data).find({ pr_key: prKey });
|
|
623
|
+
let allMerged = true;
|
|
624
|
+
for (const d of depRows) {
|
|
625
|
+
if (!(await isDepMerged(data, d.depends_on_key, token))) {
|
|
626
|
+
allMerged = false;
|
|
627
|
+
break;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
if (!allMerged) continue;
|
|
631
|
+
await flipToMergingThenPublish(data, engine, prKey, "waiting_deps", {
|
|
632
|
+
name: "deps-cleared",
|
|
633
|
+
correlationKey: prKey,
|
|
634
|
+
variables: {},
|
|
635
|
+
});
|
|
636
|
+
console.log(`[poller] deps cleared -> ${prKey}`);
|
|
637
|
+
} catch (err) {
|
|
638
|
+
console.error(`[poller] deps ${prKey}: ${err}`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// 2) Mergeable / blocked?
|
|
643
|
+
for (const pr of await prs(data).find({ status: "waiting_merge" })) {
|
|
644
|
+
const { repo, number, pr_key: prKey } = pr;
|
|
645
|
+
try {
|
|
646
|
+
const st = await fetchPrState(repo, number, token);
|
|
647
|
+
if (st === null) continue; // no transport → skip this PR (others may still advance)
|
|
648
|
+
if (st.merged) {
|
|
649
|
+
// Landed out-of-band (someone merged it) — skip straight to done.
|
|
650
|
+
await flipToMergingThenPublish(data, engine, prKey, "waiting_merge", {
|
|
651
|
+
name: "merge-landed",
|
|
652
|
+
correlationKey: prKey,
|
|
653
|
+
variables: {},
|
|
654
|
+
});
|
|
655
|
+
console.log(`[poller] already merged -> ${prKey}`);
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
const verdict = classifyMergeability(st);
|
|
659
|
+
if (verdict === "waiting") {
|
|
660
|
+
// Frugal-CI remedy (#43): when the repo publishes a merge protocol that wants a fresh
|
|
661
|
+
// head run and the PR has NO head run at all, review has converged but the last push
|
|
662
|
+
// produced no CI run — so branch protection's required checks read as "expected" forever
|
|
663
|
+
// and this PR would wait indefinitely. Produce a fresh `pull_request` run once per head
|
|
664
|
+
// (mark ready / close+reopen); rebases change `headRefOid`, so downstream merge-train PRs
|
|
665
|
+
// get a new nudge after every post-rebase landing attempt.
|
|
666
|
+
const protocol = await loadMergeProtocol(repo, token).catch(() => null);
|
|
667
|
+
if (protocol) {
|
|
668
|
+
const action = freshHeadRunAction(protocol, verdict, st.totalChecks, st.isDraft, {
|
|
669
|
+
headRefOid: st.headRefOid,
|
|
670
|
+
lastActionHeadRefOid: pr.fresh_head_run_head,
|
|
671
|
+
});
|
|
672
|
+
if (action) {
|
|
673
|
+
const ok = await ensureFreshHeadRun(repo, number, action).catch(() => false);
|
|
674
|
+
if (ok && st.headRefOid) {
|
|
675
|
+
await prs(data).update(prKey, { fresh_head_run_head: st.headRefOid, updated_at: now() });
|
|
676
|
+
}
|
|
677
|
+
console.log(`[poller] fresh head run (${action}) ${ok ? "requested" : "skipped"} -> ${prKey}`);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
continue; // GitHub still computing / checks pending
|
|
681
|
+
}
|
|
682
|
+
if (verdict === "ready") {
|
|
683
|
+
const lane = await mergeLaneDecisionForPr(data, prKey);
|
|
684
|
+
if (lane?.isHeld) {
|
|
685
|
+
await prs(data).update(prKey, { status: "waiting_lane", updated_at: now() });
|
|
686
|
+
await mirrorTaskStatusForPr(data, prKey, "waiting-for-lane");
|
|
687
|
+
console.log(`[poller] merge lane held by ${lane.laneHeadOf ?? "unknown"} -> ${prKey}`);
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
await flipToMergingThenPublish(data, engine, prKey, "waiting_merge", {
|
|
692
|
+
name: "merge-ready",
|
|
693
|
+
correlationKey: prKey,
|
|
694
|
+
variables: {
|
|
695
|
+
mergeState: verdict,
|
|
696
|
+
// Carried for the senior:fix-ci branch (verdict "blocked" = a failed required check).
|
|
697
|
+
// Joined to a scalar so it rides the message payload without a list projection; the
|
|
698
|
+
// fix-ci task appends it to the agent prompt so the agent knows which gates to green.
|
|
699
|
+
failingChecks: st.failingChecks,
|
|
700
|
+
failingChecksList: st.failingCheckNames.join("\n"),
|
|
701
|
+
},
|
|
702
|
+
});
|
|
703
|
+
console.log(`[poller] mergeable=${verdict} (${st.mergeStateStatus}) -> ${prKey}`);
|
|
704
|
+
} catch (err) {
|
|
705
|
+
console.error(`[poller] merge ${prKey}: ${err}`);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// 3) Lane-held PR released?
|
|
710
|
+
for (const pr of await prs(data).find({ status: "waiting_lane" })) {
|
|
711
|
+
const prKey = pr.pr_key;
|
|
712
|
+
try {
|
|
713
|
+
const lane = await mergeLaneDecisionForPr(data, prKey);
|
|
714
|
+
if (lane?.isHeld) continue;
|
|
715
|
+
await prs(data).update(prKey, { status: "waiting_merge", updated_at: now() });
|
|
716
|
+
await mirrorTaskStatusForPr(data, prKey, "opened");
|
|
717
|
+
console.log(`[poller] merge lane released -> ${prKey}`);
|
|
718
|
+
} catch (err) {
|
|
719
|
+
console.error(`[poller] lane ${prKey}: ${err}`);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// 4) Queued PR landed?
|
|
724
|
+
for (const pr of await prs(data).find({ status: "queued" })) {
|
|
725
|
+
const { repo, number, pr_key: prKey } = pr;
|
|
726
|
+
try {
|
|
727
|
+
const st = await fetchPrState(repo, number, token);
|
|
728
|
+
if (st === null) continue; // no transport → skip this PR (others may still advance)
|
|
729
|
+
if (!st.merged) continue; // still in the queue
|
|
730
|
+
await flipToMergingThenPublish(data, engine, prKey, "queued", {
|
|
731
|
+
name: "merge-landed",
|
|
732
|
+
correlationKey: prKey,
|
|
733
|
+
variables: {},
|
|
734
|
+
});
|
|
735
|
+
console.log(`[poller] queued PR landed -> ${prKey}`);
|
|
736
|
+
} catch (err) {
|
|
737
|
+
console.error(`[poller] queued ${prKey}: ${err}`);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** The subset of a Camunda-8 `/v2/jobs/search` result item this app reads. `worker` is the
|
|
743
|
+
* leasing worker's name (empty/absent until an agent activates the job); `deadline` is the
|
|
744
|
+
* activation lock's expiry (ISO ts). */
|
|
745
|
+
interface JobSearchItem {
|
|
746
|
+
worker?: string;
|
|
747
|
+
deadline?: string | null;
|
|
748
|
+
state?: string;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** One job-activation poll pass. The `converging` status means the process is parked at the
|
|
752
|
+
* `review-round` service task with a `senior:pr-review` job outstanding — but it does not say
|
|
753
|
+
* whether an external agent has *activated* (leased) that job yet. This pass reads that off the
|
|
754
|
+
* engine's Camunda-8 `/v2/jobs/search`: an activated job carries a leasing `worker` + a lock
|
|
755
|
+
* `deadline`; a merely-created (queued) one carries neither. (The wire `state` can't tell them
|
|
756
|
+
* apart — Camunda's JobStateEnum has no ACTIVATED value, so the engine projects Activated ->
|
|
757
|
+
* CREATED; the `worker`/`deadline` fields are the compatible activation signal.)
|
|
758
|
+
*
|
|
759
|
+
* It writes `active_worker` + `lease_until` onto the PR row so the pages surface can show
|
|
760
|
+
* "agent working" vs "queued (awaiting an agent)", updating (and bumping `updated_at`) only on
|
|
761
|
+
* an actual change so a steady state doesn't churn the grid. Best-effort: any transport failure
|
|
762
|
+
* leaves the last-known values untouched and the next pass retries. */
|
|
763
|
+
async function pollJobActivation(
|
|
764
|
+
data: DataLayer,
|
|
765
|
+
restAddress: string,
|
|
766
|
+
engineToken: string | undefined,
|
|
767
|
+
) {
|
|
768
|
+
const base = restAddress.replace(/\/+$/, "");
|
|
769
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
770
|
+
if (engineToken) headers.authorization = `Bearer ${engineToken}`;
|
|
771
|
+
|
|
772
|
+
const all = await prs(data).all();
|
|
773
|
+
for (const pr of all) {
|
|
774
|
+
// Only a `converging` PR has a live review-round job. Any other status with a stale worker
|
|
775
|
+
// set (e.g. it just moved to `waiting_review`) gets cleared so the grid can't show a
|
|
776
|
+
// phantom "agent working".
|
|
777
|
+
if (pr.status !== "converging") {
|
|
778
|
+
if (pr.active_worker || pr.lease_until) {
|
|
779
|
+
await prs(data).update(pr.pr_key, {
|
|
780
|
+
active_worker: null,
|
|
781
|
+
lease_until: null,
|
|
782
|
+
updated_at: now(),
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
// A `converging` PR without a `process_key` has no engine instance to query (creation
|
|
788
|
+
// failed or is mid-transition), so clear any stale activation lease before skipping —
|
|
789
|
+
// otherwise the grid could show a phantom "agent working".
|
|
790
|
+
if (!pr.process_key) {
|
|
791
|
+
if (pr.active_worker || pr.lease_until) {
|
|
792
|
+
await prs(data).update(pr.pr_key, {
|
|
793
|
+
active_worker: null,
|
|
794
|
+
lease_until: null,
|
|
795
|
+
updated_at: now(),
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
let worker: string | null = null;
|
|
802
|
+
let leaseUntil: string | null = null;
|
|
803
|
+
try {
|
|
804
|
+
const res = await fetch(`${base}/jobs/search`, {
|
|
805
|
+
method: "POST",
|
|
806
|
+
headers,
|
|
807
|
+
body: JSON.stringify({
|
|
808
|
+
filter: { type: REVIEW_JOB_TYPE, processInstanceKey: pr.process_key, state: "CREATED" },
|
|
809
|
+
page: { limit: 20 },
|
|
810
|
+
}),
|
|
811
|
+
});
|
|
812
|
+
if (!res.ok) continue; // engine unhappy → keep last-known, retry next pass
|
|
813
|
+
const body = (await res.json()) as { items?: JobSearchItem[] };
|
|
814
|
+
// An open job with a leasing worker means an agent has activated it. Prefer the one with
|
|
815
|
+
// the latest deadline if several are open (there is normally at most one).
|
|
816
|
+
const activated = (body.items ?? [])
|
|
817
|
+
.filter((j) => typeof j.worker === "string" && j.worker.length > 0)
|
|
818
|
+
.sort((a, b) => (a.deadline ?? "").localeCompare(b.deadline ?? ""))
|
|
819
|
+
.pop();
|
|
820
|
+
if (activated) {
|
|
821
|
+
worker = activated.worker ?? null;
|
|
822
|
+
leaseUntil = activated.deadline ?? null;
|
|
823
|
+
}
|
|
824
|
+
} catch (err) {
|
|
825
|
+
console.error(`[poller] job-activation ${pr.pr_key}: ${err}`);
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
if (worker !== (pr.active_worker ?? null) || leaseUntil !== (pr.lease_until ?? null)) {
|
|
830
|
+
await prs(data).update(pr.pr_key, {
|
|
831
|
+
active_worker: worker,
|
|
832
|
+
lease_until: leaseUntil,
|
|
833
|
+
updated_at: now(),
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/** Wave-merge barrier poll pass. After `record-wave` hands off a wave that has a successor, the
|
|
840
|
+
* plan-fanout instance parks at the `wait-wave-merged` catch event and `plans.gate_wave` records
|
|
841
|
+
* that wave's index. Here we check whether every OPENED PR in that wave has MERGED and, if so,
|
|
842
|
+
* publish `wave-merged` (correlated on the plan key) to release the next wave's implementation.
|
|
843
|
+
*
|
|
844
|
+
* `gate_wave` is cleared single-shot BEFORE publishing (and restored if the publish fails —
|
|
845
|
+
* mirroring `flipToMergingThenPublish`) so a slow pass can't double-signal and a later wave's
|
|
846
|
+
* barrier can't be tripped by a stale message reusing the same plan-key correlation. A wave whose
|
|
847
|
+
* tasks all ended `blocked`/`skipped` (no opened PR to wait on) clears vacuously — there is
|
|
848
|
+
* nothing to merge, and that failure has already cascaded to dependents in `select-wave`. */
|
|
849
|
+
async function pollWaveGates(data: DataLayer, engine: EngineClient, token: string) {
|
|
850
|
+
for (const plan of await plans(data).all()) {
|
|
851
|
+
const gateWave = plan.gate_wave;
|
|
852
|
+
if (gateWave == null) continue;
|
|
853
|
+
const planKey = plan.plan_key;
|
|
854
|
+
try {
|
|
855
|
+
let allMerged = true;
|
|
856
|
+
const tasks = await planTasks(data).find({ plan_key: planKey });
|
|
857
|
+
for (const prKey of waveMergeTargets(tasks, gateWave)) {
|
|
858
|
+
if (!(await isDepMerged(data, prKey, token))) {
|
|
859
|
+
allMerged = false;
|
|
860
|
+
break;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
if (!allMerged) continue;
|
|
864
|
+
await plans(data).update(planKey, { gate_wave: null, updated_at: now() });
|
|
865
|
+
try {
|
|
866
|
+
await engine.publishMessage({ name: "wave-merged", correlationKey: planKey, variables: {} });
|
|
867
|
+
} catch (err) {
|
|
868
|
+
try {
|
|
869
|
+
await plans(data).update(planKey, { gate_wave: gateWave, updated_at: now() });
|
|
870
|
+
} catch (revertErr) {
|
|
871
|
+
console.error(`[poller] revert wave-gate ${planKey} -> ${gateWave} failed: ${revertErr}`);
|
|
872
|
+
}
|
|
873
|
+
throw err;
|
|
874
|
+
}
|
|
875
|
+
console.log(`[poller] wave ${gateWave} merged -> ${planKey}`);
|
|
876
|
+
} catch (err) {
|
|
877
|
+
console.error(`[poller] wave-gate ${planKey}: ${err}`);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** One full poll pass: advance the review stage, the merge stage, and (when the engine REST
|
|
883
|
+
* endpoint is supplied) the job-activation visibility pass. Called on the self-scheduling loop
|
|
884
|
+
* in `main.ts`. */
|
|
885
|
+
export async function pollOnce(
|
|
886
|
+
data: DataLayer,
|
|
887
|
+
engine: EngineClient,
|
|
888
|
+
token: string,
|
|
889
|
+
engineRest?: { restAddress: string; token?: string },
|
|
890
|
+
) {
|
|
891
|
+
await pollReviews(data, engine, token);
|
|
892
|
+
await pollMerges(data, engine, token);
|
|
893
|
+
await pollWaveGates(data, engine, token);
|
|
894
|
+
if (engineRest) await pollJobActivation(data, engineRest.restAddress, engineRest.token);
|
|
895
|
+
}
|