loreli 0.0.0 → 2.0.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/LICENSE +1 -1
- package/README.md +710 -97
- package/bin/loreli.js +89 -0
- package/package.json +77 -14
- package/packages/README.md +101 -0
- package/packages/action/README.md +98 -0
- package/packages/action/prompts/action.md +172 -0
- package/packages/action/src/index.js +684 -0
- package/packages/agent/README.md +606 -0
- package/packages/agent/src/backends/claude.js +387 -0
- package/packages/agent/src/backends/codex.js +351 -0
- package/packages/agent/src/backends/cursor.js +371 -0
- package/packages/agent/src/backends/index.js +486 -0
- package/packages/agent/src/base.js +138 -0
- package/packages/agent/src/cli.js +275 -0
- package/packages/agent/src/discover.js +396 -0
- package/packages/agent/src/factory.js +124 -0
- package/packages/agent/src/index.js +12 -0
- package/packages/agent/src/models.js +159 -0
- package/packages/agent/src/output.js +62 -0
- package/packages/agent/src/session.js +162 -0
- package/packages/agent/src/trace.js +186 -0
- package/packages/classify/README.md +136 -0
- package/packages/classify/prompts/blocker.md +12 -0
- package/packages/classify/prompts/feedback.md +14 -0
- package/packages/classify/prompts/pane-state.md +20 -0
- package/packages/classify/src/index.js +81 -0
- package/packages/config/README.md +898 -0
- package/packages/config/src/defaults.js +145 -0
- package/packages/config/src/index.js +223 -0
- package/packages/config/src/schema.js +291 -0
- package/packages/config/src/validate.js +160 -0
- package/packages/context/README.md +165 -0
- package/packages/context/src/index.js +198 -0
- package/packages/hub/README.md +338 -0
- package/packages/hub/src/base.js +154 -0
- package/packages/hub/src/github.js +1597 -0
- package/packages/hub/src/index.js +79 -0
- package/packages/hub/src/labels.js +48 -0
- package/packages/identity/README.md +288 -0
- package/packages/identity/src/index.js +620 -0
- package/packages/identity/src/themes/avatar.js +217 -0
- package/packages/identity/src/themes/digimon.js +217 -0
- package/packages/identity/src/themes/dragonball.js +217 -0
- package/packages/identity/src/themes/lotr.js +217 -0
- package/packages/identity/src/themes/marvel.js +217 -0
- package/packages/identity/src/themes/pokemon.js +217 -0
- package/packages/identity/src/themes/starwars.js +217 -0
- package/packages/identity/src/themes/transformers.js +217 -0
- package/packages/identity/src/themes/zelda.js +217 -0
- package/packages/knowledge/README.md +217 -0
- package/packages/knowledge/src/index.js +243 -0
- package/packages/log/README.md +93 -0
- package/packages/log/src/index.js +252 -0
- package/packages/marker/README.md +200 -0
- package/packages/marker/src/index.js +184 -0
- package/packages/mcp/README.md +323 -0
- package/packages/mcp/instructions.md +126 -0
- package/packages/mcp/scaffolding/.agents/skills/loreli-context/SKILL.md +89 -0
- package/packages/mcp/scaffolding/ISSUE_TEMPLATE/config.yml +2 -0
- package/packages/mcp/scaffolding/ISSUE_TEMPLATE/loreli.yml +83 -0
- package/packages/mcp/scaffolding/loreli.yml +491 -0
- package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +4 -0
- package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +14 -0
- package/packages/mcp/scaffolding/mcp-configs/.mcp.json +14 -0
- package/packages/mcp/scaffolding/pull-request.md +23 -0
- package/packages/mcp/src/index.js +600 -0
- package/packages/mcp/src/tools/agent-context.js +44 -0
- package/packages/mcp/src/tools/agents.js +450 -0
- package/packages/mcp/src/tools/context.js +200 -0
- package/packages/mcp/src/tools/github.js +1163 -0
- package/packages/mcp/src/tools/hitl.js +162 -0
- package/packages/mcp/src/tools/index.js +18 -0
- package/packages/mcp/src/tools/refactor.js +227 -0
- package/packages/mcp/src/tools/repo.js +44 -0
- package/packages/mcp/src/tools/start.js +904 -0
- package/packages/mcp/src/tools/status.js +149 -0
- package/packages/mcp/src/tools/work.js +134 -0
- package/packages/orchestrator/README.md +192 -0
- package/packages/orchestrator/src/index.js +1492 -0
- package/packages/planner/README.md +251 -0
- package/packages/planner/prompts/plan-reviewer.md +109 -0
- package/packages/planner/prompts/planner.md +191 -0
- package/packages/planner/prompts/tiebreaker-reviewer.md +71 -0
- package/packages/planner/src/index.js +1381 -0
- package/packages/review/README.md +129 -0
- package/packages/review/prompts/reviewer.md +158 -0
- package/packages/review/src/index.js +1403 -0
- package/packages/risk/README.md +178 -0
- package/packages/risk/prompts/risk.md +272 -0
- package/packages/risk/src/index.js +439 -0
- package/packages/session/README.md +165 -0
- package/packages/session/src/index.js +215 -0
- package/packages/test-utils/README.md +96 -0
- package/packages/test-utils/src/index.js +354 -0
- package/packages/tmux/README.md +261 -0
- package/packages/tmux/src/index.js +501 -0
- package/packages/workflow/README.md +317 -0
- package/packages/workflow/prompts/preamble.md +14 -0
- package/packages/workflow/src/index.js +660 -0
- package/packages/workflow/src/proof-of-life.js +74 -0
- package/packages/workspace/README.md +143 -0
- package/packages/workspace/src/index.js +1127 -0
- package/index.js +0 -8
|
@@ -0,0 +1,1403 @@
|
|
|
1
|
+
import { join, dirname } from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { Workflow } from 'loreli/workflow';
|
|
4
|
+
import { output } from 'loreli/agent';
|
|
5
|
+
import { checkout, pathFor } from 'loreli/workspace';
|
|
6
|
+
import { side, capability } from 'loreli/identity';
|
|
7
|
+
import { logger } from 'loreli/log';
|
|
8
|
+
import { mark, has, parse, excise } from 'loreli/marker';
|
|
9
|
+
import { classify } from 'loreli/knowledge';
|
|
10
|
+
|
|
11
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const log = logger('review');
|
|
13
|
+
const CLOSE_RE = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/gi;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build workspace context used to regenerate MCP scaffolding after checkout.
|
|
17
|
+
*
|
|
18
|
+
* @param {object} orchestrator - Orchestrator instance.
|
|
19
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
20
|
+
* @param {string} agent - Agent identity name.
|
|
21
|
+
* @returns {object}
|
|
22
|
+
*/
|
|
23
|
+
function workspaceContext(orchestrator, repo, agent) {
|
|
24
|
+
const context = {
|
|
25
|
+
session: orchestrator.sessionId,
|
|
26
|
+
agent,
|
|
27
|
+
repo,
|
|
28
|
+
home: orchestrator.storage?.home,
|
|
29
|
+
denied: orchestrator.cfg?.get?.('agents.disallowedTools') ?? []
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
if (process.env.GITHUB_TOKEN) context.token = process.env.GITHUB_TOKEN;
|
|
33
|
+
|
|
34
|
+
return context;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build checkout options that preserve workspace scaffolding.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} orchestrator - Orchestrator instance.
|
|
41
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
42
|
+
* @param {string} agent - Agent identity name.
|
|
43
|
+
* @returns {{context: object, descriptors: object[]}}
|
|
44
|
+
*/
|
|
45
|
+
function workspaceOptions(orchestrator, repo, agent) {
|
|
46
|
+
const context = workspaceContext(orchestrator, repo, agent);
|
|
47
|
+
const descriptors = orchestrator.backendRegistry?.scaffoldAll?.(context) ?? [];
|
|
48
|
+
return { context, descriptors };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Path to the action prompt template. Used for cross-role rendering
|
|
53
|
+
* when forwarding review feedback to the action agent.
|
|
54
|
+
*
|
|
55
|
+
* @type {string}
|
|
56
|
+
*/
|
|
57
|
+
const ACTION_TEMPLATE = join(__dirname, '..', '..', 'action', 'prompts', 'action.md');
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Review workflow for Loreli's orchestration pipeline.
|
|
61
|
+
*
|
|
62
|
+
* Manages reviewer agents — PR scanning, review dispatch, feedback
|
|
63
|
+
* forwarding, merge landing, Human In The Loop (HITL), stalemate escalation, and
|
|
64
|
+
* the signoff protocol.
|
|
65
|
+
*
|
|
66
|
+
* @extends Workflow
|
|
67
|
+
*/
|
|
68
|
+
export class ReviewWorkflow extends Workflow {
|
|
69
|
+
/** @type {string} Agent role this workflow manages. */
|
|
70
|
+
static role = 'reviewer';
|
|
71
|
+
|
|
72
|
+
/** @type {string} Mustache template path for reviewer prompts. */
|
|
73
|
+
static template = join(__dirname, '..', 'prompts', 'reviewer.md');
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Tracked PRs undergoing review.
|
|
77
|
+
* Keys are PR numbers, values are { agent, reviewer, rounds }.
|
|
78
|
+
*
|
|
79
|
+
* @type {Map<number, {agent: string, reviewer: string, rounds: number}>}
|
|
80
|
+
*/
|
|
81
|
+
_watched = new Map();
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* PRs that have been fully processed (merged, failed, or escalated).
|
|
85
|
+
* Prevents scan() from re-adding PRs after land() removes them.
|
|
86
|
+
* Entries are `{ addedAt: number }` for TTL-based pruning.
|
|
87
|
+
*
|
|
88
|
+
* @type {Map<number, number>}
|
|
89
|
+
*/
|
|
90
|
+
_completed = new Map();
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Preserved round counts for evicted PRs. When a reviewer is evicted
|
|
94
|
+
* (stalled or killed) and scan() re-adds the PR with a new reviewer,
|
|
95
|
+
* the round count must carry over so the escalation threshold
|
|
96
|
+
* (maxRounds) is eventually reached. Without this, evict → scan
|
|
97
|
+
* cycles reset rounds to 0 indefinitely.
|
|
98
|
+
* Entries are `{ rounds, addedAt }` for TTL-based pruning.
|
|
99
|
+
*
|
|
100
|
+
* @type {Map<number, {rounds: number, addedAt: number}>}
|
|
101
|
+
*/
|
|
102
|
+
_evictedRounds = new Map();
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* TTL for completed/evicted entries (1 hour). Entries older than
|
|
106
|
+
* this are pruned during hydrate() to prevent unbounded growth.
|
|
107
|
+
* @type {number}
|
|
108
|
+
*/
|
|
109
|
+
static PRUNE_TTL = 3_600_000;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Extract linked issue numbers from a PR body using GitHub closing
|
|
113
|
+
* keywords (`Closes #N`, `Fixes #N`, `Resolves #N`).
|
|
114
|
+
*
|
|
115
|
+
* @param {string} body - Pull request body text.
|
|
116
|
+
* @returns {number[]} Unique linked issue numbers.
|
|
117
|
+
*/
|
|
118
|
+
links(body) {
|
|
119
|
+
if (!body) return [];
|
|
120
|
+
const found = new Set();
|
|
121
|
+
CLOSE_RE.lastIndex = 0;
|
|
122
|
+
|
|
123
|
+
let match;
|
|
124
|
+
while ((match = CLOSE_RE.exec(body)) !== null) {
|
|
125
|
+
const number = Number.parseInt(match[1], 10);
|
|
126
|
+
if (Number.isInteger(number)) found.add(number);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return [...found];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Reconcile linked issue closure for non-default merge targets.
|
|
134
|
+
*
|
|
135
|
+
* GitHub only auto-closes linked issues for merges to the default
|
|
136
|
+
* branch. When Loreli targets a non-default merge base, merged PRs
|
|
137
|
+
* can leave linked issues open and trigger duplicate work cycles.
|
|
138
|
+
* This closes still-open linked issues after merge, but only when
|
|
139
|
+
* base != default branch.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
142
|
+
* @param {number} prNum - Pull request number.
|
|
143
|
+
* @returns {Promise<void>}
|
|
144
|
+
*/
|
|
145
|
+
async reconcile(repo, prNum) {
|
|
146
|
+
if (!this.hub?.pull || !this.hub?.repo || !this.hub?.issue || !this.hub?.update) return;
|
|
147
|
+
|
|
148
|
+
let pr;
|
|
149
|
+
try {
|
|
150
|
+
pr = await this.hub.pull(repo, prNum);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
log.warn(`reconcile: failed to read PR #${prNum}: ${err.message}`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let info;
|
|
157
|
+
try {
|
|
158
|
+
info = await this.hub.repo(repo);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
log.warn(`reconcile: failed to read repo metadata for ${repo}: ${err.message}`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const base = pr?.base;
|
|
165
|
+
const main = info?.default_branch ?? 'main';
|
|
166
|
+
if (!base || base === main) return;
|
|
167
|
+
|
|
168
|
+
const linked = this.links(pr?.body);
|
|
169
|
+
for (const number of linked) {
|
|
170
|
+
try {
|
|
171
|
+
const issue = await this.hub.issue(repo, number);
|
|
172
|
+
if (issue?.state !== 'open') continue;
|
|
173
|
+
|
|
174
|
+
await this.hub.update(repo, number, { state: 'closed' });
|
|
175
|
+
log.info(`reconcile: closed linked issue #${number} after merge of PR #${prNum} (base=${base}, default=${main})`);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
log.warn(`reconcile: failed to close linked issue #${number} for PR #${prNum}: ${err.message}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Evaluate a foreign reviewer assignment through the proof-of-life protocol.
|
|
184
|
+
*
|
|
185
|
+
* Same logic as ActionWorkflow._checkForeignClaim but operates on PRs.
|
|
186
|
+
*
|
|
187
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
188
|
+
* @param {number} prNum - PR number.
|
|
189
|
+
* @param {string} reviewer - Reviewer agent name being released.
|
|
190
|
+
* @param {string} reason - Human-readable eviction reason.
|
|
191
|
+
* @returns {Promise<void>}
|
|
192
|
+
*/
|
|
193
|
+
async _releaseReviewer(repo, prNum, reviewer, reason) {
|
|
194
|
+
const identity = this.orchestrator.clientIdentity;
|
|
195
|
+
if (!identity || !this.hub) return;
|
|
196
|
+
try {
|
|
197
|
+
const body = mark('review-release', { agent: reviewer });
|
|
198
|
+
await this.hub.as(identity, 'orchestrator')
|
|
199
|
+
.comment(repo, prNum, `${reason}\n\n${body}`);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
log.warn(`scan: release comment failed for PR #${prNum}: ${err.message}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Check whether a foreign reviewer (not in this orchestrator's agent
|
|
207
|
+
* map) is still alive via the proof-of-life comment protocol.
|
|
208
|
+
*
|
|
209
|
+
* Delegates to the base Workflow `check()` method which implements
|
|
210
|
+
* the unified, status-aware PoL gate.
|
|
211
|
+
*
|
|
212
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
213
|
+
* @param {number} prNum - PR number.
|
|
214
|
+
* @param {object} tracking - Watched PR tracking object.
|
|
215
|
+
* @returns {Promise<string>} 'active'|'requested'|'pending'|'release'.
|
|
216
|
+
*/
|
|
217
|
+
async _checkForeignReviewer(repo, prNum, tracking) {
|
|
218
|
+
return this.check(repo, prNum, tracking.reviewer);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Report reviewer demand: how many PRs need a reviewer vs how many
|
|
223
|
+
* reviewers are active. Uses hydrated state so no extra API calls.
|
|
224
|
+
*
|
|
225
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
226
|
+
* @returns {Promise<{workload: number, supply: number, deficit: number}>}
|
|
227
|
+
*/
|
|
228
|
+
async demand(repo) {
|
|
229
|
+
const prs = await this.hub.pulls(repo, { state: 'open' });
|
|
230
|
+
const skipRisk = this.orchestrator.cfg?.get?.('workflows.risk.skip') ?? false;
|
|
231
|
+
let allowSameSide = false;
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
await this.orchestrator.backendRegistry?.discover?.();
|
|
235
|
+
const providers = this.orchestrator.backendRegistry?.providers?.() ?? [];
|
|
236
|
+
allowSameSide = capability(providers).mode === 'single';
|
|
237
|
+
} catch { /* non-fatal: keep strict pairing */ }
|
|
238
|
+
|
|
239
|
+
let workload = 0;
|
|
240
|
+
const actionProviders = new Set();
|
|
241
|
+
for (const pr of prs) {
|
|
242
|
+
if (this._watched.has(pr.number)) continue;
|
|
243
|
+
if (this._completed.has(pr.number)) continue;
|
|
244
|
+
|
|
245
|
+
// Same filter as scan() — only count PRs that pass the label gate
|
|
246
|
+
if (!skipRisk) {
|
|
247
|
+
const hasRiskLabel = pr.labels?.some(function isRisk(l) {
|
|
248
|
+
const name = l.name ?? l;
|
|
249
|
+
return (name.endsWith('-risk') || name === 'loreli:risk-unassessed') && name.startsWith('loreli:');
|
|
250
|
+
});
|
|
251
|
+
if (!hasRiskLabel) continue;
|
|
252
|
+
|
|
253
|
+
const isCritical = pr.labels?.some(function isCrit(l) {
|
|
254
|
+
return (l.name ?? l) === 'loreli:critical-risk';
|
|
255
|
+
});
|
|
256
|
+
if (isCritical) continue;
|
|
257
|
+
|
|
258
|
+
const isUnassessed = pr.labels?.some(function isUn(l) {
|
|
259
|
+
return (l.name ?? l) === 'loreli:risk-unassessed';
|
|
260
|
+
});
|
|
261
|
+
if (isUnassessed) continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Collect action providers from PR labels so scale() can spawn
|
|
265
|
+
// the correct opposing-side reviewer even when no live action
|
|
266
|
+
// agent exists.
|
|
267
|
+
const providerLabel = pr.labels?.find(function isProvider(l) {
|
|
268
|
+
const name = l.name ?? l;
|
|
269
|
+
return name === 'loreli:anthropic' || name === 'loreli:openai' ||
|
|
270
|
+
name === 'loreli:cursor-anthropic' || name === 'loreli:cursor-openai';
|
|
271
|
+
});
|
|
272
|
+
if (providerLabel) actionProviders.add((providerLabel.name ?? providerLabel).replace('loreli:', ''));
|
|
273
|
+
|
|
274
|
+
workload++;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Only count reviewers that can actually be paired with an action
|
|
278
|
+
// agent. A same-side reviewer (e.g. openai reviewer for an openai
|
|
279
|
+
// action agent) passes the role filter but pair() will reject it,
|
|
280
|
+
// creating a false "fully staffed" signal that suppresses scaling.
|
|
281
|
+
//
|
|
282
|
+
// When no live action agent exists (dead/foreign), fall back to
|
|
283
|
+
// PR label metadata to determine which side the action was on.
|
|
284
|
+
const actions = [...this.orchestrator.agents.values()]
|
|
285
|
+
.filter(function isAction(a) { return a.role === 'action'; });
|
|
286
|
+
|
|
287
|
+
const actionSides = actions.length
|
|
288
|
+
? new Set(actions.map(function toSide(a) { return side(a.identity?.provider); }))
|
|
289
|
+
: new Set([...actionProviders].map(function toSide(p) { return side(p); }));
|
|
290
|
+
|
|
291
|
+
const assigned = new Set(
|
|
292
|
+
[...this._watched.values()].map(function name(t) { return t.reviewer; })
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const supply = [...this.orchestrator.agents.values()]
|
|
296
|
+
.filter(function isPairable(a) {
|
|
297
|
+
if (a.role !== 'reviewer' || a.state === 'dormant') return false;
|
|
298
|
+
if (assigned.has(a.identity?.name)) return false;
|
|
299
|
+
if (allowSameSide) return true;
|
|
300
|
+
const reviewerSide = side(a.identity?.provider);
|
|
301
|
+
return actionSides.size === 0 || [...actionSides].some(function opposes(s) { return s !== reviewerSide; });
|
|
302
|
+
}).length;
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
workload,
|
|
306
|
+
supply,
|
|
307
|
+
deficit: Math.max(0, workload - supply),
|
|
308
|
+
actionProviders: [...actionProviders]
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Register reactor handlers for the orchestrator's tick loop.
|
|
314
|
+
* Hydrate → Scan → Forward → Land runs sequentially on each tick.
|
|
315
|
+
*
|
|
316
|
+
* @returns {Record<string, function>} Handler map.
|
|
317
|
+
*/
|
|
318
|
+
reactor() {
|
|
319
|
+
const self = this;
|
|
320
|
+
return {
|
|
321
|
+
async 'review-hydrate'(repo) { await self.hydrate(repo); },
|
|
322
|
+
async scan(repo) { await self.scan(repo); },
|
|
323
|
+
async forward(repo) { await self.forward(repo); },
|
|
324
|
+
async land(repo) { await self.land(repo); },
|
|
325
|
+
async 'review-reap'(repo) { await self.reap(repo); }
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Rehydrate in-memory review state from GitHub artifacts.
|
|
331
|
+
*
|
|
332
|
+
* Called at the start of each reactor tick so that PRs dispatched
|
|
333
|
+
* by other participants (or lost to a process restart) are visible
|
|
334
|
+
* to forward() and land(). Without this, only the process that
|
|
335
|
+
* originally called scan() would track the PR.
|
|
336
|
+
*
|
|
337
|
+
* Derives state from:
|
|
338
|
+
* - `review-claim` marker comments → PR is being reviewed
|
|
339
|
+
* - PR branch prefix → action agent name
|
|
340
|
+
* - CHANGES_REQUESTED reviews → round count
|
|
341
|
+
* - Terminal labels (needs-attention, escalated, merge-failed) → completed
|
|
342
|
+
* - Closed/merged PR state → completed
|
|
343
|
+
*
|
|
344
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
345
|
+
* @returns {Promise<void>}
|
|
346
|
+
*/
|
|
347
|
+
async hydrate(repo) {
|
|
348
|
+
if (!this.hub) return;
|
|
349
|
+
|
|
350
|
+
const prs = await this.hub.pulls(repo, { state: 'open' });
|
|
351
|
+
|
|
352
|
+
for (const pr of prs) {
|
|
353
|
+
if (this._watched.has(pr.number) || this._completed.has(pr.number)) continue;
|
|
354
|
+
|
|
355
|
+
// Terminal labels → mark completed
|
|
356
|
+
const isTerminal = pr.labels?.some(function isEnd(l) {
|
|
357
|
+
const name = l.name ?? l;
|
|
358
|
+
return name === 'loreli:needs-attention' ||
|
|
359
|
+
name === 'loreli:escalated' ||
|
|
360
|
+
name === 'loreli:merge-failed';
|
|
361
|
+
});
|
|
362
|
+
if (isTerminal) {
|
|
363
|
+
this._completed.set(pr.number, Date.now());
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const comments = await this.hub.comments(repo, pr.number);
|
|
368
|
+
const claim = comments.find(function isClaim(c) { return has(c.body, 'review-claim'); });
|
|
369
|
+
if (!claim) continue;
|
|
370
|
+
|
|
371
|
+
const reviewerName = parse(claim.body, 'review-claim')?.agent;
|
|
372
|
+
if (!reviewerName) continue;
|
|
373
|
+
|
|
374
|
+
const released = comments.some(function isRelease(c) {
|
|
375
|
+
if (!has(c.body, 'review-release')) return false;
|
|
376
|
+
const data = parse(c.body, 'review-release');
|
|
377
|
+
return data?.agent === reviewerName;
|
|
378
|
+
});
|
|
379
|
+
if (released) continue;
|
|
380
|
+
|
|
381
|
+
// Agent name from branch prefix
|
|
382
|
+
const slash = pr.head?.indexOf('/');
|
|
383
|
+
const agentName = slash > 0 ? pr.head.slice(0, slash) : null;
|
|
384
|
+
|
|
385
|
+
// Round count from CHANGES_REQUESTED reviews
|
|
386
|
+
const reviews = await this.hub.reviews(repo, pr.number);
|
|
387
|
+
const crReviews = reviews.filter(function isCR(r) {
|
|
388
|
+
return r.state === 'CHANGES_REQUESTED';
|
|
389
|
+
});
|
|
390
|
+
const rounds = crReviews.length;
|
|
391
|
+
|
|
392
|
+
// Restore lastReviewId by finding the latest feedback marker
|
|
393
|
+
// and matching it to the CHANGES_REQUESTED review that triggered it.
|
|
394
|
+
let lastReviewId = null;
|
|
395
|
+
const feedbackMarker = [...comments].reverse().find(function isFeedback(c) {
|
|
396
|
+
return has(c.body, 'feedback');
|
|
397
|
+
});
|
|
398
|
+
if (feedbackMarker && crReviews.length) {
|
|
399
|
+
const markerTime = new Date(feedbackMarker.created).getTime();
|
|
400
|
+
const processed = crReviews.filter(function before(r) {
|
|
401
|
+
return new Date(r.submitted).getTime() <= markerTime;
|
|
402
|
+
}).at(-1);
|
|
403
|
+
lastReviewId = processed?.id ?? null;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
this._watched.set(pr.number, {
|
|
407
|
+
agent: agentName,
|
|
408
|
+
reviewer: reviewerName,
|
|
409
|
+
rounds,
|
|
410
|
+
lastReviewId,
|
|
411
|
+
dispatchedAt: new Date(claim.created ?? claim.created_at).getTime()
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Move PRs that closed/merged between ticks from _watched to _completed.
|
|
416
|
+
// Only open PRs appear in the pulls() result above, so any _watched
|
|
417
|
+
// PR not present was resolved since the last tick.
|
|
418
|
+
for (const prNum of this._watched.keys()) {
|
|
419
|
+
if (!prs.some(function match(p) { return p.number === prNum; })) {
|
|
420
|
+
this._watched.delete(prNum);
|
|
421
|
+
this._completed.set(prNum, Date.now());
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Prune stale entries to prevent unbounded growth in long-running sessions
|
|
426
|
+
const ttl = ReviewWorkflow.PRUNE_TTL;
|
|
427
|
+
const now = Date.now();
|
|
428
|
+
for (const [prNum, addedAt] of this._completed) {
|
|
429
|
+
if (now - addedAt > ttl) this._completed.delete(prNum);
|
|
430
|
+
}
|
|
431
|
+
for (const [prNum, entry] of this._evictedRounds) {
|
|
432
|
+
if (now - entry.addedAt > ttl) this._evictedRounds.delete(prNum);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ── Signoff Protocol ──────────────────────────────────
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Post an approval comment using the hub's scoped identity.
|
|
440
|
+
* Embeds a machine-readable signoff marker so downstream tools can
|
|
441
|
+
* detect approvals without parsing visible text.
|
|
442
|
+
*
|
|
443
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
444
|
+
* @param {number} number - Issue or PR number.
|
|
445
|
+
* @param {object} identity - Agent identity with name.
|
|
446
|
+
* @param {string} role - Agent role for hub scoping.
|
|
447
|
+
* @returns {Promise<void>}
|
|
448
|
+
*/
|
|
449
|
+
async signoff(repo, number, identity, role) {
|
|
450
|
+
const scoped = this.hub.as(identity, role);
|
|
451
|
+
const marker = mark('signoff', { agent: identity.name });
|
|
452
|
+
const visible = identity.approval?.() ?? `Approved by **${identity.name}**`;
|
|
453
|
+
await scoped.comment(repo, number, `${marker}\n${visible}`);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ── Scan ──────────────────────────────────────────────
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Scan for new PRs created by action agents and dispatch
|
|
460
|
+
* opposing-provider reviewers.
|
|
461
|
+
*
|
|
462
|
+
* Uses branch naming convention (`{agentName}/issue-{number}`)
|
|
463
|
+
* to match PRs to agents. Auto-enlists opposing reviewer when
|
|
464
|
+
* none is available.
|
|
465
|
+
*
|
|
466
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
467
|
+
* @returns {Promise<Array<{pr: number, reviewer: string}>>}
|
|
468
|
+
*/
|
|
469
|
+
async scan(repo) {
|
|
470
|
+
// Evict tracked PRs whose reviewer is no longer useful.
|
|
471
|
+
//
|
|
472
|
+
// Two cases:
|
|
473
|
+
// 1. Reviewer killed — stall detection (tier 3) removed it from the
|
|
474
|
+
// agents map. Without eviction the PR stays in _watched forever.
|
|
475
|
+
// 2. Reviewer stalled — the reviewer is alive but idle beyond the
|
|
476
|
+
// stall timeout. Waiting for tier 3 kill (3x) would exceed
|
|
477
|
+
// typical review timeouts. Proactive eviction lets scan()
|
|
478
|
+
// assign a replacement sooner.
|
|
479
|
+
//
|
|
480
|
+
// In both cases forward() dedup skips the stale review and land()
|
|
481
|
+
// finds no APPROVE — a deadlock unless we evict here.
|
|
482
|
+
const stallTimeout = this.orchestrator.stallTimeout;
|
|
483
|
+
const now = Date.now();
|
|
484
|
+
for (const [prNum, tracking] of this._watched) {
|
|
485
|
+
const reviewer = this.orchestrator.agents.get(tracking.reviewer);
|
|
486
|
+
if (!reviewer) {
|
|
487
|
+
// Locally removed agents (killed by our stall detection) can
|
|
488
|
+
// be evicted immediately — no proof-of-life needed.
|
|
489
|
+
if (this.orchestrator._removed?.has(tracking.reviewer)) {
|
|
490
|
+
log.info(`scan: evicting PR #${prNum} — reviewer ${tracking.reviewer} was locally removed`);
|
|
491
|
+
this._evictedRounds.set(prNum, { rounds: tracking.rounds, addedAt: Date.now() });
|
|
492
|
+
this._watched.delete(prNum);
|
|
493
|
+
await this._releaseReviewer(repo, prNum, tracking.reviewer,
|
|
494
|
+
`Review claim released — reviewer \`${tracking.reviewer}\` is no longer active.`);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Foreign reviewer — use proof-of-life protocol before evicting
|
|
499
|
+
const verdict = await this._checkForeignReviewer(repo, prNum, tracking);
|
|
500
|
+
if (verdict !== 'release') {
|
|
501
|
+
log.debug(`scan: foreign reviewer ${tracking.reviewer} on PR #${prNum} — ${verdict}`);
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
log.info(`scan: evicting PR #${prNum} — reviewer ${tracking.reviewer} proof-of-life expired`);
|
|
505
|
+
this._evictedRounds.set(prNum, { rounds: tracking.rounds, addedAt: Date.now() });
|
|
506
|
+
this._watched.delete(prNum);
|
|
507
|
+
await this._releaseReviewer(repo, prNum, tracking.reviewer,
|
|
508
|
+
`Review claim released — no proof of life from \`${tracking.reviewer}\`.`);
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Fast eviction for dormant reviewers — crash recovery when
|
|
513
|
+
// the orchestrator's stall detection may not catch it for
|
|
514
|
+
// minutes. Checking state here on every tick gives sub-second
|
|
515
|
+
// recovery.
|
|
516
|
+
if (reviewer.state === 'dormant') {
|
|
517
|
+
log.info(`scan: evicting PR #${prNum} — reviewer ${tracking.reviewer} is dormant`);
|
|
518
|
+
this._evictedRounds.set(prNum, { rounds: tracking.rounds, addedAt: Date.now() });
|
|
519
|
+
this._watched.delete(prNum);
|
|
520
|
+
await this._releaseReviewer(repo, prNum, tracking.reviewer,
|
|
521
|
+
`Review claim released — reviewer \`${tracking.reviewer}\` is dormant.`);
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Evict reviewers whose dispatch exceeded the stall timeout
|
|
526
|
+
// without submitting any review. This catches reviewers that
|
|
527
|
+
// appear active (MCP calls) but are stuck (corrupted workspace,
|
|
528
|
+
// missing task file, confused agent loop).
|
|
529
|
+
if (tracking.dispatchedAt) {
|
|
530
|
+
const elapsed = now - tracking.dispatchedAt;
|
|
531
|
+
if (elapsed > stallTimeout) {
|
|
532
|
+
const reviews = await this.hub.reviews(repo, prNum);
|
|
533
|
+
// All agents share a single GitHub token — check for ANY
|
|
534
|
+
// review submitted after the dispatch timestamp, not by
|
|
535
|
+
// specific user/agent name.
|
|
536
|
+
const submitted = reviews.some(function afterDispatch(r) {
|
|
537
|
+
return new Date(r.submitted).getTime() > tracking.dispatchedAt;
|
|
538
|
+
});
|
|
539
|
+
if (!submitted) {
|
|
540
|
+
log.warn(`scan: evicting PR #${prNum} — reviewer ${tracking.reviewer} dispatched ${Math.round(elapsed / 1000)}s ago with no review`);
|
|
541
|
+
this._evictedRounds.set(prNum, { rounds: tracking.rounds, addedAt: Date.now() });
|
|
542
|
+
this._watched.delete(prNum);
|
|
543
|
+
await this._releaseReviewer(repo, prNum, tracking.reviewer,
|
|
544
|
+
`Review claim released — reviewer \`${tracking.reviewer}\` stalled (${Math.round(elapsed / 1000)}s, no review submitted).`);
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const last = this.orchestrator._lastActivity.get(tracking.reviewer);
|
|
551
|
+
const idle = last ? now - new Date(last).getTime() : 0;
|
|
552
|
+
if (idle > stallTimeout) {
|
|
553
|
+
const verdict = await this.check(repo, prNum, tracking.reviewer);
|
|
554
|
+
if (verdict !== 'release') {
|
|
555
|
+
log.debug(`scan: reviewer ${tracking.reviewer} on PR #${prNum} idle but PoL: ${verdict}`);
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
log.info(`scan: evicting PR #${prNum} — reviewer ${tracking.reviewer} confirmed stalled via PoL`);
|
|
559
|
+
this._evictedRounds.set(prNum, { rounds: tracking.rounds, addedAt: Date.now() });
|
|
560
|
+
this._watched.delete(prNum);
|
|
561
|
+
await this._releaseReviewer(repo, prNum, tracking.reviewer,
|
|
562
|
+
`Review claim released — reviewer \`${tracking.reviewer}\` confirmed stalled.`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const dispatched = [];
|
|
567
|
+
const prs = await this.hub.pulls(repo, { state: 'open' });
|
|
568
|
+
|
|
569
|
+
// Piggyback: record agent names discovered from PR branch prefixes
|
|
570
|
+
// in the orchestrator's taken set. Zero API cost — pulls() was
|
|
571
|
+
// already needed for review scanning.
|
|
572
|
+
for (const pr of prs) {
|
|
573
|
+
const slash = pr.head?.indexOf('/');
|
|
574
|
+
if (slash > 0) this.orchestrator.takenNames?.add(pr.head.slice(0, slash));
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const actions = [...this.orchestrator.agents.values()]
|
|
578
|
+
.filter(function isAction(a) { return a.role === 'action'; });
|
|
579
|
+
|
|
580
|
+
const skipRisk = this.orchestrator.cfg?.get?.('workflows.risk.skip') ?? false;
|
|
581
|
+
let allowSameSide = false;
|
|
582
|
+
|
|
583
|
+
try {
|
|
584
|
+
await this.orchestrator.backendRegistry?.discover?.();
|
|
585
|
+
const providers = this.orchestrator.backendRegistry?.providers?.() ?? [];
|
|
586
|
+
allowSameSide = capability(providers).mode === 'single';
|
|
587
|
+
} catch { /* non-fatal: keep strict pairing */ }
|
|
588
|
+
|
|
589
|
+
for (const pr of prs) {
|
|
590
|
+
if (this._watched.has(pr.number)) continue;
|
|
591
|
+
if (this._completed.has(pr.number)) continue;
|
|
592
|
+
|
|
593
|
+
// Derive author name from branch prefix convention: {agent}/issue-{N}
|
|
594
|
+
const slash = pr.head?.indexOf('/');
|
|
595
|
+
const authorName = slash > 0 ? pr.head.slice(0, slash) : null;
|
|
596
|
+
if (!authorName) continue;
|
|
597
|
+
|
|
598
|
+
// Check for existing review-claim marker — another participant
|
|
599
|
+
// may have already claimed this PR for review. Skip this check
|
|
600
|
+
// for PRs we just evicted (reviewer dead or stalled) — we need
|
|
601
|
+
// to allow re-claim so a replacement reviewer can be dispatched.
|
|
602
|
+
//
|
|
603
|
+
// Also allow re-claim when the claim has a matching review-release
|
|
604
|
+
// marker — the previous reviewer was evicted (possibly by a
|
|
605
|
+
// different orchestrator session) and the PR is available.
|
|
606
|
+
// `wasReleased` tracks this so claimFirst is also skipped — the
|
|
607
|
+
// old claim comment still exists and would win the race otherwise.
|
|
608
|
+
const wasEvicted = this._evictedRounds.has(pr.number);
|
|
609
|
+
let wasReleased = false;
|
|
610
|
+
if (!wasEvicted) {
|
|
611
|
+
const prComments = await this.hub.comments(repo, pr.number);
|
|
612
|
+
const claim = prComments.find(function isClaimed(c) { return has(c.body, 'review-claim'); });
|
|
613
|
+
if (claim) {
|
|
614
|
+
const claimedAgent = parse(claim.body, 'review-claim')?.agent;
|
|
615
|
+
const released = claimedAgent && prComments.some(function isRelease(c) {
|
|
616
|
+
if (!has(c.body, 'review-release')) return false;
|
|
617
|
+
return parse(c.body, 'review-release')?.agent === claimedAgent;
|
|
618
|
+
});
|
|
619
|
+
if (!released) {
|
|
620
|
+
log.debug(`scan: PR #${pr.number} already claimed by ${claimedAgent} — skipping`);
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
log.info(`scan: PR #${pr.number} claim by ${claimedAgent} was released — allowing re-claim`);
|
|
624
|
+
wasReleased = true;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Prefer live action agent for full identity context; fall back
|
|
629
|
+
// to PR-derived metadata when the original agent was reaped,
|
|
630
|
+
// belongs to another orchestrator, or is from a previous session.
|
|
631
|
+
const agent = actions.find(function owns(a) {
|
|
632
|
+
return pr.head.startsWith(a.identity.name + '/');
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
// Derive provider from PR labels when no live agent exists.
|
|
636
|
+
// PRs from dead/reaped agents still carry loreli:anthropic or
|
|
637
|
+
// loreli:openai labels applied at claim time.
|
|
638
|
+
const providerLabel = pr.labels?.find(function isProvider(l) {
|
|
639
|
+
const name = l.name ?? l;
|
|
640
|
+
return name === 'loreli:anthropic' || name === 'loreli:openai' ||
|
|
641
|
+
name === 'loreli:cursor-anthropic' || name === 'loreli:cursor-openai';
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
// Require either a live agent match or recognizable loreli labels.
|
|
645
|
+
// Prevents dispatching reviewers for non-Loreli PRs.
|
|
646
|
+
if (!agent && !providerLabel) continue;
|
|
647
|
+
|
|
648
|
+
const authorProvider = agent?.identity?.provider
|
|
649
|
+
?? (providerLabel?.name ?? providerLabel)?.replace('loreli:', '')
|
|
650
|
+
?? 'unknown';
|
|
651
|
+
|
|
652
|
+
// Label gate: only dispatch reviewer for PRs that have been
|
|
653
|
+
// risk-assessed (carry a loreli:*-risk label) or flagged as
|
|
654
|
+
// unassessed, or when risk assessment is disabled. RiskWorkflow
|
|
655
|
+
// runs first in the reactor chain and applies labels before
|
|
656
|
+
// this handler fires.
|
|
657
|
+
if (!skipRisk) {
|
|
658
|
+
const hasRiskLabel = pr.labels?.some(function isRisk(l) {
|
|
659
|
+
const name = l.name ?? l;
|
|
660
|
+
return (name.endsWith('-risk') || name === 'loreli:risk-unassessed') && name.startsWith('loreli:');
|
|
661
|
+
});
|
|
662
|
+
if (!hasRiskLabel) continue;
|
|
663
|
+
|
|
664
|
+
// CRITICAL PRs must be escalated to HITL — never assign a reviewer.
|
|
665
|
+
// The label was applied by RiskWorkflow; scan() performs the escalation.
|
|
666
|
+
const isCritical = pr.labels?.some(function isCrit(l) {
|
|
667
|
+
return (l.name ?? l) === 'loreli:critical-risk';
|
|
668
|
+
});
|
|
669
|
+
if (isCritical) {
|
|
670
|
+
try {
|
|
671
|
+
await this.hitl(repo, pr.number);
|
|
672
|
+
} catch (err) {
|
|
673
|
+
log.warn(`scan: HITL failed for CRITICAL PR #${pr.number}: ${err.message}`);
|
|
674
|
+
}
|
|
675
|
+
this._completed.set(pr.number, Date.now());
|
|
676
|
+
log.info(`scan: PR #${pr.number} escalated to HITL — risk verdict CRITICAL`);
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Unassessed PRs (risk agent crashed, timed out, or failed to
|
|
681
|
+
// dispatch) carry risk-unassessed and must escalate to HITL.
|
|
682
|
+
// Fail-safe: never let unassessed changes through to automated review.
|
|
683
|
+
const isUnassessed = pr.labels?.some(function isUn(l) {
|
|
684
|
+
return (l.name ?? l) === 'loreli:risk-unassessed';
|
|
685
|
+
});
|
|
686
|
+
if (isUnassessed) {
|
|
687
|
+
try {
|
|
688
|
+
await this.hitl(repo, pr.number);
|
|
689
|
+
} catch (err) {
|
|
690
|
+
log.warn(`scan: HITL failed for unassessed PR #${pr.number}: ${err.message}`);
|
|
691
|
+
}
|
|
692
|
+
this._completed.set(pr.number, Date.now());
|
|
693
|
+
log.info(`scan: PR #${pr.number} escalated to HITL — risk assessment failed`);
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Attach risk context for MEDIUM PRs so the reviewer sees the warning
|
|
699
|
+
let riskContext = null;
|
|
700
|
+
const isMedium = pr.labels?.some(function isMed(l) {
|
|
701
|
+
return (l.name ?? l) === 'loreli:medium-risk';
|
|
702
|
+
});
|
|
703
|
+
if (isMedium) {
|
|
704
|
+
const comments = await this.hub.comments(repo, pr.number);
|
|
705
|
+
const riskComment = comments.find(function hasRisk(c) { return has(c.body, 'risk'); });
|
|
706
|
+
if (riskComment) riskContext = { assessment: riskComment.body };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const assigned = new Set(
|
|
710
|
+
[...this._watched.values()].map(function name(t) { return t.reviewer; })
|
|
711
|
+
);
|
|
712
|
+
const reviewers = [...this.orchestrator.agents.values()]
|
|
713
|
+
.filter(function isAvailable(a) {
|
|
714
|
+
if (a.role !== 'reviewer') return false;
|
|
715
|
+
return !assigned.has(a.identity?.name);
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
// Verify a reviewer is available BEFORE claiming. Claiming
|
|
719
|
+
// without an available reviewer creates a deadlock: the next tick
|
|
720
|
+
// sees the review-claim, skips the PR, but no reviewer was
|
|
721
|
+
// dispatched so the PR is stuck forever.
|
|
722
|
+
const pairIdentity = agent?.identity ?? { provider: authorProvider };
|
|
723
|
+
let reviewer = this.pair(pairIdentity, reviewers);
|
|
724
|
+
if (!reviewer && allowSameSide && reviewers.length) {
|
|
725
|
+
reviewer = reviewers[0];
|
|
726
|
+
}
|
|
727
|
+
if (!reviewer) {
|
|
728
|
+
log.debug(`scan: no reviewer available for PR #${pr.number} — waiting for scale()`);
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Optimistic claim: post a review-claim marker on the PR,
|
|
733
|
+
// then verify this agent was first. Prevents two reactors
|
|
734
|
+
// from both dispatching reviewers for the same PR.
|
|
735
|
+
// Skip for evicted PRs and released claims — the old claim
|
|
736
|
+
// comment still exists on GitHub and would win the first-
|
|
737
|
+
// comment race. We already verified the claim is dead above.
|
|
738
|
+
const claimIdentity = reviewer.identity ?? this.orchestrator.clientIdentity;
|
|
739
|
+
if (claimIdentity && !wasEvicted && !wasReleased) {
|
|
740
|
+
const visible = reviewer.identity?.claim?.() ?? `Claimed by **${claimIdentity.name}**`;
|
|
741
|
+
const won = await this.claimFirst(repo, pr.number, 'review-claim', claimIdentity, 'reviewer', visible);
|
|
742
|
+
if (!won) {
|
|
743
|
+
log.info(`scan: lost review-claim race for PR #${pr.number} — skipping`);
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const home = this.orchestrator.storage?.home;
|
|
749
|
+
const wsRoot = home ? `${home}/workspaces` : undefined;
|
|
750
|
+
const reviewerCwd = reviewer.cwd ?? pathFor(reviewer.identity.name, wsRoot);
|
|
751
|
+
const base = this.orchestrator.cfg?.get?.('merge.base') ?? 'main';
|
|
752
|
+
const options = workspaceOptions(this.orchestrator, repo, reviewer.identity.name);
|
|
753
|
+
try {
|
|
754
|
+
await checkout(reviewerCwd, pr.head, base, options);
|
|
755
|
+
log.info(`scan: checked out ${pr.head} in ${reviewer.identity.name}'s workspace`);
|
|
756
|
+
} catch (err) {
|
|
757
|
+
log.warn(`scan: branch checkout failed for ${reviewer.identity.name}: ${err.message} — skipping PR`);
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const files = await this.hub.files(repo, pr.number);
|
|
762
|
+
|
|
763
|
+
await this.saveTask(reviewer.identity.name, {
|
|
764
|
+
type: 'review_pr',
|
|
765
|
+
pr: pr.number
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
const templateVars = {
|
|
769
|
+
name: reviewer.identity.name,
|
|
770
|
+
repo,
|
|
771
|
+
faction: reviewer.identity.faction,
|
|
772
|
+
provider: reviewer.identity.provider,
|
|
773
|
+
model: reviewer.identity.model,
|
|
774
|
+
labels: reviewer.identity.labels?.('reviewer'),
|
|
775
|
+
pullRequest: {
|
|
776
|
+
number: pr.number,
|
|
777
|
+
title: pr.title,
|
|
778
|
+
head: pr.head,
|
|
779
|
+
base: pr.base,
|
|
780
|
+
issue: excise(pr.body, 'trace') ?? pr.body,
|
|
781
|
+
author: agent?.identity?.name ?? authorName,
|
|
782
|
+
authorProvider: agent?.identity?.provider ?? authorProvider,
|
|
783
|
+
files
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
if (riskContext) templateVars.riskContext = riskContext;
|
|
787
|
+
|
|
788
|
+
await this.dispatch(reviewer, templateVars);
|
|
789
|
+
this.orchestrator.activity(reviewer.identity.name);
|
|
790
|
+
|
|
791
|
+
const restored = this._evictedRounds.get(pr.number)?.rounds ?? 0;
|
|
792
|
+
this._evictedRounds.delete(pr.number);
|
|
793
|
+
|
|
794
|
+
this._watched.set(pr.number, {
|
|
795
|
+
agent: agent?.identity?.name ?? authorName,
|
|
796
|
+
reviewer: reviewer.identity.name,
|
|
797
|
+
rounds: restored,
|
|
798
|
+
dispatchedAt: Date.now()
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
dispatched.push({ pr: pr.number, reviewer: reviewer.identity.name });
|
|
802
|
+
log.info(`scan: dispatched ${reviewer.identity.name} to review PR #${pr.number}`);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
return dispatched;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// ── Restart ─────────────────────────────────────────
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Close a stale PR and release its linked issue when the action
|
|
812
|
+
* agent is dead. Posts a themed comment on the PR, closes it via
|
|
813
|
+
* hub.closePull(), posts a `restart` marker on the linked issue
|
|
814
|
+
* (resets the circuit breaker counter), removes blocking labels,
|
|
815
|
+
* and kills the assigned reviewer.
|
|
816
|
+
*
|
|
817
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
818
|
+
* @param {number} prNum - Pull request number.
|
|
819
|
+
* @param {object} tracking - Tracked PR state from _watched.
|
|
820
|
+
* @returns {Promise<void>}
|
|
821
|
+
*/
|
|
822
|
+
async _restartAction(repo, prNum, tracking) {
|
|
823
|
+
const identity = this.orchestrator.clientIdentity;
|
|
824
|
+
|
|
825
|
+
// Derive the linked issue number from the PR branch name.
|
|
826
|
+
// Convention: {agent}/issue-{N}
|
|
827
|
+
let issueNum = null;
|
|
828
|
+
try {
|
|
829
|
+
const pr = await this.hub.pull(repo, prNum);
|
|
830
|
+
const match = pr.head?.match(/\/issue-(\d+)$/);
|
|
831
|
+
if (match) issueNum = Number(match[1]);
|
|
832
|
+
} catch (err) {
|
|
833
|
+
log.warn(`_restartAction: failed to fetch PR #${prNum}: ${err.message}`);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// Post a themed comment on the PR before closing.
|
|
837
|
+
if (identity) {
|
|
838
|
+
try {
|
|
839
|
+
await this.hub.as(identity, 'orchestrator')
|
|
840
|
+
.comment(repo, prNum,
|
|
841
|
+
`**Restarting** — action agent \`${tracking.agent}\` is no longer available. ` +
|
|
842
|
+
'Closing this PR and releasing the linked issue for re-dispatch.');
|
|
843
|
+
} catch (err) {
|
|
844
|
+
log.warn(`_restartAction: comment failed on PR #${prNum}: ${err.message}`);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
try {
|
|
849
|
+
await this.hub.closePull(repo, prNum);
|
|
850
|
+
} catch (err) {
|
|
851
|
+
log.warn(`_restartAction: closePull failed for PR #${prNum}: ${err.message}`);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Post restart marker on the linked issue — claimant() treats
|
|
855
|
+
// this as a release, and the circuit breaker uses it as a high-
|
|
856
|
+
// water mark (releases before this point are ignored).
|
|
857
|
+
if (issueNum && identity) {
|
|
858
|
+
try {
|
|
859
|
+
const restartMarker = mark('restart', { agent: tracking.agent });
|
|
860
|
+
await this.hub.as(identity, 'orchestrator')
|
|
861
|
+
.comment(repo, issueNum, `${restartMarker}\nAction restarted — previous agent \`${tracking.agent}\` was unavailable.`);
|
|
862
|
+
} catch (err) {
|
|
863
|
+
log.warn(`_restartAction: restart marker failed on issue #${issueNum}: ${err.message}`);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Remove blocking labels so dispatch can pick up the issue.
|
|
867
|
+
try {
|
|
868
|
+
await this.hub.unlabel(repo, issueNum, 'loreli:blocked');
|
|
869
|
+
await this.hub.unlabel(repo, issueNum, 'loreli:needs-attention');
|
|
870
|
+
} catch (err) {
|
|
871
|
+
log.warn(`_restartAction: unlabel failed on issue #${issueNum}: ${err.message}`);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
this._watched.delete(prNum);
|
|
876
|
+
this._completed.set(prNum, Date.now());
|
|
877
|
+
|
|
878
|
+
// Kill the reviewer — the PR is closed, no review needed.
|
|
879
|
+
if (tracking.reviewer && this.orchestrator.agents.has(tracking.reviewer)) {
|
|
880
|
+
try {
|
|
881
|
+
await this.orchestrator.kill(tracking.reviewer);
|
|
882
|
+
} catch (err) {
|
|
883
|
+
log.warn(`_restartAction: kill reviewer ${tracking.reviewer} failed: ${err.message}`);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
log.info(`forward: restarted PR #${prNum} — action agent ${tracking.agent} is dead` +
|
|
888
|
+
(issueNum ? `, issue #${issueNum} released for re-dispatch` : ''));
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// ── Forward ───────────────────────────────────────────
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Poll for new reviews on tracked PRs. If REQUEST_CHANGES, relay
|
|
895
|
+
* feedback to the action agent. Enforces maxRounds escalation.
|
|
896
|
+
*
|
|
897
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
898
|
+
* @returns {Promise<Array<{pr: number, action: string}>>}
|
|
899
|
+
*/
|
|
900
|
+
async forward(repo) {
|
|
901
|
+
if (!this._watched.size) return [];
|
|
902
|
+
|
|
903
|
+
const maxRounds = this.orchestrator.cfg?.get?.('watch.maxRounds') ?? 7;
|
|
904
|
+
const forwarded = [];
|
|
905
|
+
|
|
906
|
+
for (const [prNum, tracking] of this._watched) {
|
|
907
|
+
const reviews = await this.hub.reviews(repo, prNum);
|
|
908
|
+
|
|
909
|
+
const latest = reviews
|
|
910
|
+
.filter(function fromReviewer(r) { return r.author === tracking.reviewer || r.state === 'CHANGES_REQUESTED'; })
|
|
911
|
+
.at(-1);
|
|
912
|
+
|
|
913
|
+
if (!latest || latest.state === 'APPROVED') continue;
|
|
914
|
+
if (latest.state !== 'CHANGES_REQUESTED') continue;
|
|
915
|
+
|
|
916
|
+
// Deduplicate: skip if this exact review was already relayed.
|
|
917
|
+
if (tracking.lastReviewId === latest.id) {
|
|
918
|
+
// Check if the action agent has pushed new commits since the
|
|
919
|
+
// review. If so, re-dispatch the reviewer.
|
|
920
|
+
const agent = this.orchestrator.agents.get(tracking.agent);
|
|
921
|
+
if (agent && latest.commitId) {
|
|
922
|
+
const pr = await this.hub.pull(repo, prNum);
|
|
923
|
+
const headSha = pr?.headSha ?? pr?.head;
|
|
924
|
+
if (headSha && headSha !== latest.commitId && !tracking.reviewerDispatched) {
|
|
925
|
+
await this._redispatch(repo, prNum, tracking, agent);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
tracking.rounds++;
|
|
932
|
+
if (tracking.rounds >= maxRounds) {
|
|
933
|
+
log.warn(`forward: PR #${prNum} exceeded ${maxRounds} review rounds — escalating`);
|
|
934
|
+
await this._escalate(repo, prNum, tracking);
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const agent = this.orchestrator.agents.get(tracking.agent);
|
|
939
|
+
if (!agent) {
|
|
940
|
+
await this._restartAction(repo, prNum, tracking);
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const prompt = await this.renderFrom(ACTION_TEMPLATE, {
|
|
945
|
+
name: agent.identity.name,
|
|
946
|
+
repo,
|
|
947
|
+
faction: agent.identity.faction,
|
|
948
|
+
provider: agent.identity.provider,
|
|
949
|
+
model: agent.identity.model,
|
|
950
|
+
labels: agent.identity.labels?.('action'),
|
|
951
|
+
reviewFeedback: {
|
|
952
|
+
number: prNum,
|
|
953
|
+
reviewer: tracking.reviewer,
|
|
954
|
+
reviewerProvider: this.orchestrator.agents.get(tracking.reviewer)?.identity?.provider ?? 'unknown',
|
|
955
|
+
comments: [{ body: latest.body }]
|
|
956
|
+
}
|
|
957
|
+
}, { role: 'action' });
|
|
958
|
+
|
|
959
|
+
// Respawn the agent if it crashed or was evicted since the last tick.
|
|
960
|
+
if (agent.state === 'dormant') await agent.spawn();
|
|
961
|
+
|
|
962
|
+
await agent.send(prompt);
|
|
963
|
+
this.orchestrator.activity(agent.identity.name);
|
|
964
|
+
tracking.lastReviewId = latest.id;
|
|
965
|
+
|
|
966
|
+
const feedbackEnabled = this.orchestrator.cfg?.get?.('feedback.enabled') ?? true;
|
|
967
|
+
if (feedbackEnabled && latest.body) {
|
|
968
|
+
try {
|
|
969
|
+
const { category, confidence } = await classify(latest.body, {
|
|
970
|
+
backends: this.orchestrator.backendRegistry,
|
|
971
|
+
config: this.orchestrator.cfg
|
|
972
|
+
});
|
|
973
|
+
if (confidence > 0) {
|
|
974
|
+
const identity = this.orchestrator.clientIdentity;
|
|
975
|
+
const provider = this.orchestrator.agents.get(tracking.reviewer)?.identity?.provider ?? 'unknown';
|
|
976
|
+
const marker = mark('feedback', { category, confidence: confidence.toFixed(2), provider });
|
|
977
|
+
const visible = `Feedback routing note: classified latest review as **${category}** (${confidence.toFixed(2)} confidence).`;
|
|
978
|
+
try {
|
|
979
|
+
if (identity) await this.hub.as(identity, 'orchestrator').comment(repo, prNum, `${marker}\n${visible}`);
|
|
980
|
+
log.debug(`forward: classified feedback on PR #${prNum} as ${category} (${confidence.toFixed(2)})`);
|
|
981
|
+
} catch (err) { log.debug(`forward: feedback marker failed: ${err.message}`); }
|
|
982
|
+
}
|
|
983
|
+
} catch (err) { log.debug(`forward: feedback classification unavailable, skipping marker: ${err.message}`); }
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Reset so _redispatch can fire again when the action agent
|
|
987
|
+
// pushes another round of changes.
|
|
988
|
+
tracking.reviewerDispatched = false;
|
|
989
|
+
|
|
990
|
+
forwarded.push({ pr: prNum, action: tracking.agent });
|
|
991
|
+
|
|
992
|
+
log.info(`forward: relayed feedback on PR #${prNum} to ${tracking.agent} (round ${tracking.rounds})`);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// ── Conflict nudge ──────────────────────────────────
|
|
996
|
+
// Check watched PRs for merge conflicts and nudge the action agent
|
|
997
|
+
// to rebase. GitHub computes mergeable asynchronously on single-PR
|
|
998
|
+
// fetches — null means pending, skip until next tick.
|
|
999
|
+
for (const [prNum, tracking] of this._watched) {
|
|
1000
|
+
const agent = this.orchestrator.agents.get(tracking.agent);
|
|
1001
|
+
if (!agent) continue;
|
|
1002
|
+
|
|
1003
|
+
let pr;
|
|
1004
|
+
try {
|
|
1005
|
+
pr = await this.hub.pull(repo, prNum);
|
|
1006
|
+
} catch (err) {
|
|
1007
|
+
log.debug(`forward: conflict check skipped for PR #${prNum}: ${err.message}`);
|
|
1008
|
+
continue;
|
|
1009
|
+
}
|
|
1010
|
+
if (pr.mergeable == null) continue;
|
|
1011
|
+
|
|
1012
|
+
if (pr.mergeable) {
|
|
1013
|
+
if (tracking.conflictNotifiedSha) {
|
|
1014
|
+
log.info(`forward: PR #${prNum} conflict resolved`);
|
|
1015
|
+
tracking.conflictNotifiedSha = null;
|
|
1016
|
+
}
|
|
1017
|
+
continue;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
if (tracking.conflictNotifiedSha === pr.headSha) continue;
|
|
1021
|
+
|
|
1022
|
+
if (agent.state === 'dormant') await agent.spawn();
|
|
1023
|
+
|
|
1024
|
+
const base = pr.base || 'main';
|
|
1025
|
+
await agent.send(
|
|
1026
|
+
`Your pull request #${prNum} has merge conflicts with \`${base}\`. ` +
|
|
1027
|
+
`Rebase your branch onto \`origin/${base}\`, resolve the conflicts, ` +
|
|
1028
|
+
`and force-push to update the PR.`
|
|
1029
|
+
);
|
|
1030
|
+
this.orchestrator.activity(agent.identity.name);
|
|
1031
|
+
tracking.conflictNotifiedSha = pr.headSha;
|
|
1032
|
+
|
|
1033
|
+
log.info(`forward: nudged ${tracking.agent} to resolve conflict on PR #${prNum}`);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
return forwarded;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// ── Land ──────────────────────────────────────────────
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Detect approved reviews on tracked PRs. Requires cross-provider
|
|
1043
|
+
* approval. Auto-merges or triggers Human In The Loop (HITL).
|
|
1044
|
+
*
|
|
1045
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
1046
|
+
* @returns {Promise<Array<{pr: number, merged: boolean, gated: boolean, agent: string}>>}
|
|
1047
|
+
*/
|
|
1048
|
+
async land(repo) {
|
|
1049
|
+
if (!this._watched.size) return [];
|
|
1050
|
+
|
|
1051
|
+
const method = this.orchestrator.cfg?.get?.('merge.method') ?? 'squash';
|
|
1052
|
+
const globalHitl = this.orchestrator.cfg?.get?.('merge.hitl') ?? false;
|
|
1053
|
+
const feedbackHitl = this.orchestrator.cfg?.get?.('feedback.hitl') ?? false;
|
|
1054
|
+
const humanReviewers = this.orchestrator.cfg?.get?.('reviewers') ?? [];
|
|
1055
|
+
let dualSide = true;
|
|
1056
|
+
|
|
1057
|
+
try {
|
|
1058
|
+
await this.orchestrator.backendRegistry?.discover?.();
|
|
1059
|
+
const providers = this.orchestrator.backendRegistry?.providers?.() ?? [];
|
|
1060
|
+
dualSide = capability(providers).hasDual;
|
|
1061
|
+
} catch { /* non-fatal: keep strict gate */ }
|
|
1062
|
+
|
|
1063
|
+
const landed = [];
|
|
1064
|
+
|
|
1065
|
+
for (const [prNum, tracking] of this._watched) {
|
|
1066
|
+
const reviews = await this.hub.reviews(repo, prNum);
|
|
1067
|
+
const approval = reviews.find(function approved(r) {
|
|
1068
|
+
return r.state === 'APPROVED';
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
if (!approval) continue;
|
|
1072
|
+
|
|
1073
|
+
// Verify cross-provider
|
|
1074
|
+
const agent = this.orchestrator.agents.get(tracking.agent);
|
|
1075
|
+
const reviewer = this.orchestrator.agents.get(tracking.reviewer);
|
|
1076
|
+
if (tracking.agent === tracking.reviewer) {
|
|
1077
|
+
log.warn(`land: PR #${prNum} approved by same identity — not sufficient`);
|
|
1078
|
+
continue;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
if (dualSide && agent && reviewer && side(agent.identity.provider) === side(reviewer.identity.provider)) {
|
|
1082
|
+
log.warn(`land: PR #${prNum} approved by same-side reviewer in dual-side mode — not sufficient`);
|
|
1083
|
+
continue;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// Post audit trail comment before merging or gating — mandatory
|
|
1087
|
+
// so every merged PR has a Loreli-signed approval record.
|
|
1088
|
+
if (reviewer) {
|
|
1089
|
+
await this.signoff(repo, prNum, reviewer.identity, reviewer.role ?? 'reviewer');
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
let needsHitl = globalHitl;
|
|
1093
|
+
|
|
1094
|
+
if (!needsHitl && feedbackHitl !== false) {
|
|
1095
|
+
const prData = await this.hub.read(repo, prNum);
|
|
1096
|
+
const closesMatch = prData.body?.match(/Closes #(\d+)/);
|
|
1097
|
+
if (closesMatch) {
|
|
1098
|
+
try {
|
|
1099
|
+
const issueData = await this.hub.read(repo, Number(closesMatch[1]));
|
|
1100
|
+
const fbLabel = (issueData.labels ?? []).find(function isFeedback(l) {
|
|
1101
|
+
const name = typeof l === 'string' ? l : l.name;
|
|
1102
|
+
return name?.startsWith('loreli:feedback:');
|
|
1103
|
+
});
|
|
1104
|
+
if (fbLabel) {
|
|
1105
|
+
const category = (typeof fbLabel === 'string' ? fbLabel : fbLabel.name)
|
|
1106
|
+
.replace('loreli:feedback:', '');
|
|
1107
|
+
needsHitl = feedbackHitl === true || feedbackHitl.includes(category);
|
|
1108
|
+
}
|
|
1109
|
+
} catch (err) {
|
|
1110
|
+
log.warn(`land: feedback HITL check failed for PR #${prNum}: ${err.message}`);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
if (needsHitl && humanReviewers.length) {
|
|
1116
|
+
await this.hitl(repo, prNum);
|
|
1117
|
+
this._watched.delete(prNum);
|
|
1118
|
+
this._completed.set(prNum, Date.now());
|
|
1119
|
+
landed.push({ pr: prNum, merged: false, gated: true, agent: tracking.agent });
|
|
1120
|
+
log.info(`land: PR #${prNum} escalated to HITL`);
|
|
1121
|
+
} else {
|
|
1122
|
+
try {
|
|
1123
|
+
await this.hub.merge(repo, prNum, { method });
|
|
1124
|
+
await this.reconcile(repo, prNum);
|
|
1125
|
+
this._watched.delete(prNum);
|
|
1126
|
+
this._completed.set(prNum, Date.now());
|
|
1127
|
+
landed.push({ pr: prNum, merged: true, gated: false, agent: tracking.agent });
|
|
1128
|
+
log.info(`land: PR #${prNum} merged via ${method}`);
|
|
1129
|
+
} catch (err) {
|
|
1130
|
+
// Merge failure escalation: alert humans instead of silently
|
|
1131
|
+
// abandoning. 405 = not mergeable, 409 = merge conflict.
|
|
1132
|
+
this._watched.delete(prNum);
|
|
1133
|
+
this._completed.set(prNum, Date.now());
|
|
1134
|
+
landed.push({ pr: prNum, merged: false, gated: false, agent: tracking.agent });
|
|
1135
|
+
|
|
1136
|
+
try {
|
|
1137
|
+
const signer = this.agents()[0]?.identity ?? this.orchestrator.clientIdentity;
|
|
1138
|
+
const body = `${mark('hitl')}\n**Merge failed — needs human attention**\n\n` +
|
|
1139
|
+
`Auto-merge failed: \`${err.message}\`\n\n` +
|
|
1140
|
+
`Please resolve the issue and merge manually.`;
|
|
1141
|
+
if (signer) {
|
|
1142
|
+
await this.hub.as(signer, 'reviewer').comment(repo, prNum, body);
|
|
1143
|
+
} else {
|
|
1144
|
+
await this.hub.comment(repo, prNum, body);
|
|
1145
|
+
}
|
|
1146
|
+
await this.hub.label(repo, prNum, ['loreli:needs-attention', 'loreli:merge-failed']);
|
|
1147
|
+
} catch (labelErr) {
|
|
1148
|
+
log.warn(`land: failed to label merge-failure on PR #${prNum}: ${labelErr.message}`);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
log.warn(`land: merge failed for PR #${prNum}: ${err.message}`);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// Kill the reviewer immediately — the PR is done (merged, gated,
|
|
1156
|
+
// or failed). Waiting for the reap sweep would leave interactive
|
|
1157
|
+
// reviewers stuck in 'working' state for up to a full tick cycle.
|
|
1158
|
+
if (tracking.reviewer && this.orchestrator.agents.has(tracking.reviewer)) {
|
|
1159
|
+
try {
|
|
1160
|
+
await this.orchestrator.kill(tracking.reviewer);
|
|
1161
|
+
log.info(`land: stopped reviewer ${tracking.reviewer} — PR #${prNum} finalized`);
|
|
1162
|
+
} catch (err) {
|
|
1163
|
+
log.warn(`land: failed to stop reviewer ${tracking.reviewer}: ${err.message}`);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// Kill action agents whose PRs were finalized (merged, gated, or
|
|
1169
|
+
// failed). Only targets agents that owned a landed PR — unrelated
|
|
1170
|
+
// action agents working on other issues are left untouched.
|
|
1171
|
+
const landedAgents = new Set(landed.map(function agent(l) { return l.agent; }));
|
|
1172
|
+
for (const name of landedAgents) {
|
|
1173
|
+
if (!name || !this.orchestrator.agents.has(name)) continue;
|
|
1174
|
+
try {
|
|
1175
|
+
await this.orchestrator.kill(name);
|
|
1176
|
+
log.info(`land: stopped action agent ${name} — PR finalized`);
|
|
1177
|
+
} catch (err) {
|
|
1178
|
+
log.warn(`land: failed to stop action agent ${name}: ${err.message}`);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
return landed;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// ── Reap (HITL Timeout) ───────────────────────────────
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* Scan for stale HITL items that have timed out without human response.
|
|
1189
|
+
* Applies loreli:stale label and re-pings configured reviewers.
|
|
1190
|
+
* Idempotent — items already labeled loreli:stale are skipped.
|
|
1191
|
+
*
|
|
1192
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
1193
|
+
* @returns {Promise<void>}
|
|
1194
|
+
*/
|
|
1195
|
+
async reap(repo) {
|
|
1196
|
+
const timeout = this.orchestrator.cfg?.get?.('hitl.timeout');
|
|
1197
|
+
if (timeout == null) return;
|
|
1198
|
+
|
|
1199
|
+
const items = await this.hub.pulls(repo, { state: 'open' });
|
|
1200
|
+
const needsAttention = [];
|
|
1201
|
+
for (const pr of items) {
|
|
1202
|
+
if (!pr.labels?.some(function isNeedsAttention(l) { return (l.name ?? l) === 'loreli:needs-attention'; })) continue;
|
|
1203
|
+
if (pr.labels?.some(function isStale(l) { return (l.name ?? l) === 'loreli:stale'; })) continue;
|
|
1204
|
+
needsAttention.push(pr);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
if (!needsAttention.length) return;
|
|
1208
|
+
|
|
1209
|
+
const now = Date.now();
|
|
1210
|
+
const reviewers = this.orchestrator.cfg?.get?.('reviewers') ?? [];
|
|
1211
|
+
|
|
1212
|
+
for (const pr of needsAttention) {
|
|
1213
|
+
const comments = await this.hub.comments(repo, pr.number);
|
|
1214
|
+
const hitlComment = comments.find(function isHitl(c) { return has(c.body, 'hitl'); });
|
|
1215
|
+
if (!hitlComment) continue;
|
|
1216
|
+
|
|
1217
|
+
const elapsed = now - new Date(hitlComment.created ?? hitlComment.created_at).getTime();
|
|
1218
|
+
if (elapsed < timeout) continue;
|
|
1219
|
+
|
|
1220
|
+
const mentions = reviewers.length
|
|
1221
|
+
? reviewers.map(function mention(r) { return `@${r}`; }).join(', ')
|
|
1222
|
+
: '';
|
|
1223
|
+
|
|
1224
|
+
const signer = this.agents()[0]?.identity ?? this.orchestrator.clientIdentity;
|
|
1225
|
+
if (signer) {
|
|
1226
|
+
try {
|
|
1227
|
+
const scoped = this.hub.as(signer, 'reviewer');
|
|
1228
|
+
const msg = mentions
|
|
1229
|
+
? `**HITL reminder** — ${mentions}\n\nThis PR has been awaiting human attention for over ${Math.round(elapsed / 3600000)} hours.`
|
|
1230
|
+
: `**HITL reminder**\n\nThis PR has been awaiting human attention for over ${Math.round(elapsed / 3600000)} hours.`;
|
|
1231
|
+
await scoped.comment(repo, pr.number, msg);
|
|
1232
|
+
} catch (err) {
|
|
1233
|
+
log.warn(`reap: failed to post reminder on PR #${pr.number}: ${err.message}`);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
try {
|
|
1238
|
+
await this.hub.label(repo, pr.number, ['loreli:stale']);
|
|
1239
|
+
} catch (err) {
|
|
1240
|
+
log.warn(`reap: failed to label PR #${pr.number} as stale: ${err.message}`);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
log.warn(`reap: PR #${pr.number} marked stale — HITL timeout exceeded (${Math.round(elapsed / 3600000)}h)`);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// ── HITL ──────────────────────────────────────────────
|
|
1248
|
+
|
|
1249
|
+
/**
|
|
1250
|
+
* Activate Human In The Loop (HITL) for a PR.
|
|
1251
|
+
*
|
|
1252
|
+
* Requests review from configured human reviewers, posts a summary
|
|
1253
|
+
* comment, and kills active agents to conserve resources.
|
|
1254
|
+
*
|
|
1255
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
1256
|
+
* @param {number} pr - Pull request number.
|
|
1257
|
+
* @returns {Promise<{hitlAt: string, reviewers: string[]}>}
|
|
1258
|
+
* @throws {Error} When no human reviewers are configured.
|
|
1259
|
+
*/
|
|
1260
|
+
async hitl(repo, pr) {
|
|
1261
|
+
log.info(`hitl PR #${pr} on ${repo}`);
|
|
1262
|
+
const reviewers = this.orchestrator.cfg?.get?.('reviewers') ?? [];
|
|
1263
|
+
if (!reviewers.length) throw new Error('No reviewers configured for HITL');
|
|
1264
|
+
|
|
1265
|
+
await this.hub.request(repo, pr, reviewers);
|
|
1266
|
+
await this.hub.assign(repo, pr, reviewers);
|
|
1267
|
+
|
|
1268
|
+
// Post summary comment tagging reviewers with themed HITL message
|
|
1269
|
+
const mentions = reviewers.map(function mention(r) { return `@${r}`; }).join(', ');
|
|
1270
|
+
|
|
1271
|
+
// Use first reviewer agent's identity for the signature,
|
|
1272
|
+
// fall back to orchestrator's client identity
|
|
1273
|
+
const reviewerAgent = this.agents()[0];
|
|
1274
|
+
const signer = reviewerAgent?.identity ?? this.orchestrator.clientIdentity;
|
|
1275
|
+
if (!signer) throw new Error('hitl() requires at least one reviewer agent or a client identity');
|
|
1276
|
+
|
|
1277
|
+
const hitlMarker = mark('hitl');
|
|
1278
|
+
const visible = signer.hitl?.(mentions) ?? `**Human review required**\n\nAgent review is complete. ${mentions} — please review and merge when satisfied.`;
|
|
1279
|
+
const body = `${hitlMarker}\n${visible}`;
|
|
1280
|
+
|
|
1281
|
+
const scoped = this.hub.as(signer, reviewerAgent?.role ?? 'reviewer');
|
|
1282
|
+
await scoped.comment(repo, pr, body);
|
|
1283
|
+
|
|
1284
|
+
// Kill all active agents for this session
|
|
1285
|
+
const names = [...this.orchestrator.agents.keys()];
|
|
1286
|
+
for (const name of names) {
|
|
1287
|
+
await this.orchestrator.kill(name);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
const hitlAt = new Date().toISOString();
|
|
1291
|
+
return { hitlAt, reviewers };
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// ── Re-dispatch ─────────────────────────────────────────
|
|
1295
|
+
|
|
1296
|
+
/**
|
|
1297
|
+
* Re-dispatch the reviewer for re-review after an action agent has
|
|
1298
|
+
* pushed new commits.
|
|
1299
|
+
*
|
|
1300
|
+
* Also removes the stale `loreli:changes-requested` label — the
|
|
1301
|
+
* action agent addressed the feedback, so the label no longer
|
|
1302
|
+
* reflects the current PR state.
|
|
1303
|
+
*
|
|
1304
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
1305
|
+
* @param {number} prNum - Pull request number.
|
|
1306
|
+
* @param {object} tracking - Tracked PR state from _watched.
|
|
1307
|
+
* @param {object} agent - The action agent.
|
|
1308
|
+
* @returns {Promise<void>}
|
|
1309
|
+
*/
|
|
1310
|
+
async _redispatch(repo, prNum, tracking, agent) {
|
|
1311
|
+
// Remove stale changes-requested label — the action agent has
|
|
1312
|
+
// pushed new commits addressing the feedback.
|
|
1313
|
+
try {
|
|
1314
|
+
await this.hub.unlabel(repo, prNum, 'loreli:changes-requested');
|
|
1315
|
+
} catch (err) { log.debug(`_redispatch: unlabel failed: ${err.message}`); }
|
|
1316
|
+
|
|
1317
|
+
const reviewer = this.orchestrator.agents.get(tracking.reviewer);
|
|
1318
|
+
if (!reviewer) {
|
|
1319
|
+
log.warn(`_redispatch: reviewer ${tracking.reviewer} not found — cannot re-dispatch`);
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
try {
|
|
1324
|
+
const [files, prDetails] = await Promise.all([
|
|
1325
|
+
this.hub.files(repo, prNum),
|
|
1326
|
+
this.hub.pull(repo, prNum)
|
|
1327
|
+
]);
|
|
1328
|
+
const prompt = await this.render({
|
|
1329
|
+
name: reviewer.identity.name,
|
|
1330
|
+
repo,
|
|
1331
|
+
faction: reviewer.identity.faction,
|
|
1332
|
+
provider: reviewer.identity.provider,
|
|
1333
|
+
model: reviewer.identity.model,
|
|
1334
|
+
labels: reviewer.identity.labels?.('reviewer'),
|
|
1335
|
+
pullRequest: {
|
|
1336
|
+
number: prNum,
|
|
1337
|
+
title: prDetails?.title ?? `Re-review PR #${prNum}`,
|
|
1338
|
+
head: prDetails?.head ?? `${agent.identity.name}/work`,
|
|
1339
|
+
base: prDetails?.base ?? this.orchestrator.cfg?.get?.('merge.base') ?? 'main',
|
|
1340
|
+
issue: 'Re-review after feedback was addressed.',
|
|
1341
|
+
author: agent.identity.name,
|
|
1342
|
+
authorProvider: agent.identity.provider,
|
|
1343
|
+
files
|
|
1344
|
+
}
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1347
|
+
await reviewer.send(prompt);
|
|
1348
|
+
this.orchestrator.activity(reviewer.identity.name);
|
|
1349
|
+
tracking.reviewerDispatched = true;
|
|
1350
|
+
log.info(`_redispatch: re-dispatched ${reviewer.identity.name} for re-review of PR #${prNum}`);
|
|
1351
|
+
} catch (err) {
|
|
1352
|
+
log.warn(`_redispatch: re-dispatch failed for PR #${prNum}: ${err.message}`);
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// ── Escalation ────────────────────────────────────────
|
|
1357
|
+
|
|
1358
|
+
/**
|
|
1359
|
+
* Escalate a stalemate when maxRounds is exceeded.
|
|
1360
|
+
*
|
|
1361
|
+
* If human reviewers are configured, triggers HITL.
|
|
1362
|
+
* Otherwise, creates a discussion in the Loreli category describing
|
|
1363
|
+
* the stalemate and labels the PR for attention.
|
|
1364
|
+
*
|
|
1365
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
1366
|
+
* @param {number} prNum - Pull request number.
|
|
1367
|
+
* @param {object} tracking - Tracked PR state.
|
|
1368
|
+
* @returns {Promise<void>}
|
|
1369
|
+
*/
|
|
1370
|
+
async _escalate(repo, prNum, tracking) {
|
|
1371
|
+
const humanReviewers = this.orchestrator.cfg?.get?.('reviewers') ?? [];
|
|
1372
|
+
|
|
1373
|
+
if (humanReviewers.length) {
|
|
1374
|
+
await this.hitl(repo, prNum);
|
|
1375
|
+
this._watched.delete(prNum);
|
|
1376
|
+
this._completed.set(prNum, Date.now());
|
|
1377
|
+
log.info(`escalate: PR #${prNum} handed to human reviewers after ${tracking.rounds} rounds`);
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// No human reviewers — escalation is terminal and blocking.
|
|
1382
|
+
// The PR stays open with loreli:needs-attention + loreli:escalated
|
|
1383
|
+
// until a human intervenes. Auto-approval is never performed.
|
|
1384
|
+
const signer = this.orchestrator.agents.get(tracking.reviewer)?.identity
|
|
1385
|
+
?? this.orchestrator.clientIdentity;
|
|
1386
|
+
|
|
1387
|
+
if (signer) {
|
|
1388
|
+
try {
|
|
1389
|
+
const scoped = this.hub.as(signer, 'reviewer');
|
|
1390
|
+
await scoped.comment(repo, prNum,
|
|
1391
|
+
`${mark('hitl')}\n**Escalation — needs human attention**\n\n` +
|
|
1392
|
+
`This PR exceeded ${tracking.rounds} review rounds without convergence. ` +
|
|
1393
|
+
'A human must review and decide whether to merge, close, or provide direction.');
|
|
1394
|
+
} catch (err) {
|
|
1395
|
+
log.warn(`escalate: comment failed for PR #${prNum}: ${err.message}`);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
try {
|
|
1400
|
+
await this.hub.label(repo, prNum, ['loreli:needs-attention', 'loreli:escalated']);
|
|
1401
|
+
} catch (err) { log.warn(`escalate: labeling failed for PR #${prNum}: ${err.message}`); }
|
|
1402
|
+
}
|
|
1403
|
+
}
|