@revisium/orchestrator 0.1.0-alpha.9 → 0.1.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/README.md +143 -157
- package/control-plane/default-playbook/catalog/pipelines.json +217 -8
- package/control-plane/default-playbook/package.json +1 -1
- package/control-plane/default-playbook/prompts/developer.md +1 -0
- package/control-plane/default-playbook/prompts/triager.md +3 -0
- package/dist/api/graphql-api/graphql-ws/subscription-mappers.js +3 -0
- package/dist/api/graphql-api/graphql-ws/subscription-mappers.js.map +1 -1
- package/dist/api/graphql-api/pr/inputs/pr-readiness.input.js +6 -0
- package/dist/api/graphql-api/pr/inputs/pr-readiness.input.js.map +1 -1
- package/dist/api/graphql-api/pr/model/pr-readiness.model.js +30 -0
- package/dist/api/graphql-api/pr/model/pr-readiness.model.js.map +1 -1
- package/dist/api/graphql-api/runs/inputs/create-run.input.js +6 -0
- package/dist/api/graphql-api/runs/inputs/create-run.input.js.map +1 -1
- package/dist/api/graphql-api/runs/model/run.model.js +6 -0
- package/dist/api/graphql-api/runs/model/run.model.js.map +1 -1
- package/dist/api/graphql-api/share/model/issue-ref.model.js +53 -0
- package/dist/api/graphql-api/share/model/issue-ref.model.js.map +1 -0
- package/dist/control-plane/bootstrap.js +14 -1
- package/dist/control-plane/bootstrap.js.map +1 -1
- package/dist/control-plane/client-transport.js +1 -1
- package/dist/control-plane/client-transport.js.map +1 -1
- package/dist/control-plane/default-playbook-policy.js +593 -0
- package/dist/control-plane/default-playbook-policy.js.map +1 -0
- package/dist/control-plane/seed-default-playbook.js +77 -8
- package/dist/control-plane/seed-default-playbook.js.map +1 -1
- package/dist/e2e/kit/agents.js +15 -21
- package/dist/e2e/kit/agents.js.map +1 -1
- package/dist/e2e/kit/assertions.js +12 -8
- package/dist/e2e/kit/assertions.js.map +1 -1
- package/dist/e2e/kit/fake-integrator.js +11 -2
- package/dist/e2e/kit/fake-integrator.js.map +1 -1
- package/dist/e2e/kit/git-target-repo.js +1 -1
- package/dist/engine/dbos.service.js +3 -0
- package/dist/engine/dbos.service.js.map +1 -1
- package/dist/features/pr/queries/impl/get-pr-readiness.query.js.map +1 -1
- package/dist/features/runs/commands/impl/create-run.command.js.map +1 -1
- package/dist/features/runs/queries/handlers/runs-query.handlers.js +1 -0
- package/dist/features/runs/queries/handlers/runs-query.handlers.js.map +1 -1
- package/dist/mcp/mcp-capabilities.js +14 -1
- package/dist/mcp/mcp-capabilities.js.map +1 -1
- package/dist/mcp/mcp-facade.service.js +306 -11
- package/dist/mcp/mcp-facade.service.js.map +1 -1
- package/dist/mcp/mcp-tools.js +41 -14
- package/dist/mcp/mcp-tools.js.map +1 -1
- package/dist/observability/activity-signal.js +107 -0
- package/dist/observability/activity-signal.js.map +1 -0
- package/dist/observability/index.js +1 -0
- package/dist/observability/index.js.map +1 -1
- package/dist/pipeline/data-driven-task.workflow.js +482 -40
- package/dist/pipeline/data-driven-task.workflow.js.map +1 -1
- package/dist/pipeline/pipeline.service.js +195 -101
- package/dist/pipeline/pipeline.service.js.map +1 -1
- package/dist/pipeline/route-contract.js +4 -3
- package/dist/pipeline/route-contract.js.map +1 -1
- package/dist/pipeline-core/kit/fixtures.js +72 -21
- package/dist/pipeline-core/kit/fixtures.js.map +1 -1
- package/dist/playbook/import-mapper.js +8 -1
- package/dist/playbook/import-mapper.js.map +1 -1
- package/dist/poller/pr-readiness-core.js +299 -28
- package/dist/poller/pr-readiness-core.js.map +1 -1
- package/dist/revisium/playbooks.service.js +17 -2
- package/dist/revisium/playbooks.service.js.map +1 -1
- package/dist/revisium/run.service.js +1 -0
- package/dist/revisium/run.service.js.map +1 -1
- package/dist/run/append-event.js +8 -5
- package/dist/run/append-event.js.map +1 -1
- package/dist/run/create-run.js +5 -1
- package/dist/run/create-run.js.map +1 -1
- package/dist/run/inspect-run.js +10 -3
- package/dist/run/inspect-run.js.map +1 -1
- package/dist/run/issue-ref.js +88 -0
- package/dist/run/issue-ref.js.map +1 -0
- package/dist/runners/claude-code.service.js +2 -2
- package/dist/runners/claude-code.service.js.map +1 -1
- package/dist/runners/codex.service.js +2 -2
- package/dist/runners/codex.service.js.map +1 -1
- package/dist/runners/integrator-branch-naming.js +13 -8
- package/dist/runners/integrator-branch-naming.js.map +1 -1
- package/dist/runners/integrator.js +254 -89
- package/dist/runners/integrator.js.map +1 -1
- package/dist/runners/worktree.service.js +2 -2
- package/dist/runners/worktree.service.js.map +1 -1
- package/dist/task-control-plane/pr-readiness.service.js +15 -13
- package/dist/task-control-plane/pr-readiness.service.js.map +1 -1
- package/dist/task-control-plane/run-watch.service.js +234 -3
- package/dist/task-control-plane/run-watch.service.js.map +1 -1
- package/dist/task-control-plane/task-control-plane-api.service.js +272 -25
- package/dist/task-control-plane/task-control-plane-api.service.js.map +1 -1
- package/dist/worker/artifact-store.js +21 -3
- package/dist/worker/artifact-store.js.map +1 -1
- package/dist/worker/build-context.js +19 -3
- package/dist/worker/build-context.js.map +1 -1
- package/dist/worker/claude-code-runner.js +76 -15
- package/dist/worker/claude-code-runner.js.map +1 -1
- package/dist/worker/codex-runner.js +40 -13
- package/dist/worker/codex-runner.js.map +1 -1
- package/dist/worker/process-executor.js +149 -20
- package/dist/worker/process-executor.js.map +1 -1
- package/dist/worker/runner-common.js +29 -0
- package/dist/worker/runner-common.js.map +1 -1
- package/dist/worker/runner.js +16 -1
- package/dist/worker/runner.js.map +1 -1
- package/package.json +1 -1
|
@@ -18,7 +18,7 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
|
18
18
|
* Exposes:
|
|
19
19
|
* - integrate(input, deps) — REAL integrator (live only); git/gh side effects; resumable.
|
|
20
20
|
* - stubIntegrate(input) — STUB (script only); ZERO external effects; pure + deterministic.
|
|
21
|
-
* - preflightLive(taskId, base, deps) — LIVE PREFLIGHT; clean check + base
|
|
21
|
+
* - preflightLive(taskId, base, deps) — LIVE PREFLIGHT; clean check + base availability; one-shot.
|
|
22
22
|
* - IntegratorService — @Injectable wrapper with bound arrow properties.
|
|
23
23
|
*
|
|
24
24
|
* The pure primitives (resolveExecutable, branchName, parseOwnerRepo) and shared types live in
|
|
@@ -27,8 +27,9 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
|
27
27
|
*/
|
|
28
28
|
import { Inject, Injectable } from '@nestjs/common';
|
|
29
29
|
import { execFileSync } from 'node:child_process';
|
|
30
|
-
import { collectPrReadiness } from '../poller/pr-readiness-core.js';
|
|
30
|
+
import { collectPrReadiness, fetchRequiredCheckNames, } from '../poller/pr-readiness-core.js';
|
|
31
31
|
import { RunService } from '../revisium/run.service.js';
|
|
32
|
+
import { issueRefTag } from '../run/issue-ref.js';
|
|
32
33
|
import { resolveGhAccount, resolvePinnedGh } from './gh-identity.js';
|
|
33
34
|
import { gitAbsPath, branchExists, countAhead } from './integrator-git.js';
|
|
34
35
|
import { resolveOwnerRepo } from './integrator-remote.js';
|
|
@@ -38,7 +39,23 @@ import { branchName } from './integrator-branch-naming.js';
|
|
|
38
39
|
export { resolveExecutable } from './integrator-git.js';
|
|
39
40
|
export { branchName };
|
|
40
41
|
export { parseOwnerRepo } from './integrator-remote.js';
|
|
41
|
-
function
|
|
42
|
+
function issueBoundTitle(title, issueRef, ownerRepo) {
|
|
43
|
+
const tag = issueRefTag(issueRef, ownerRepo);
|
|
44
|
+
return tag ? `${tag} ${title}` : title;
|
|
45
|
+
}
|
|
46
|
+
function commitMessage(title, issueRef, ownerRepo) {
|
|
47
|
+
const tag = issueRefTag(issueRef, ownerRepo);
|
|
48
|
+
return tag ? `feat: ${tag} ${title}` : `feat: ${title}`;
|
|
49
|
+
}
|
|
50
|
+
function parsePrList(raw) {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(raw);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new Error(`gh pr list returned non-JSON: ${raw.slice(0, 200)}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function matchingOpenPr(ownerRepo, branch, base, execGh, jsonFields) {
|
|
42
59
|
const raw = execGh([
|
|
43
60
|
'pr',
|
|
44
61
|
'list',
|
|
@@ -49,21 +66,14 @@ function findOrCreatePr(ownerRepo, branch, base, title, execGh) {
|
|
|
49
66
|
'--state',
|
|
50
67
|
'open',
|
|
51
68
|
'--json',
|
|
52
|
-
|
|
69
|
+
jsonFields,
|
|
53
70
|
]);
|
|
54
|
-
|
|
55
|
-
try {
|
|
56
|
-
entries = JSON.parse(raw);
|
|
57
|
-
}
|
|
58
|
-
catch {
|
|
59
|
-
throw new Error(`gh pr list returned non-JSON: ${raw.slice(0, 200)}`);
|
|
60
|
-
}
|
|
61
|
-
const matching = entries.filter((p) => p.baseRefName === base);
|
|
71
|
+
const matching = parsePrList(raw).filter((p) => p.baseRefName === base);
|
|
62
72
|
if (matching.length === 1) {
|
|
63
73
|
const pr = matching[0];
|
|
64
74
|
if (!pr)
|
|
65
75
|
throw new Error('unexpected empty match');
|
|
66
|
-
return { prUrl: pr.url, prNumber: pr.number };
|
|
76
|
+
return { prUrl: pr.url, prNumber: pr.number, ...(pr.headRefOid ? { headSha: pr.headRefOid } : {}) };
|
|
67
77
|
}
|
|
68
78
|
if (matching.length > 1) {
|
|
69
79
|
const candidates = matching.map((p) => `#${p.number}`).join(', ');
|
|
@@ -72,7 +82,9 @@ function findOrCreatePr(ownerRepo, branch, base, title, execGh) {
|
|
|
72
82
|
lesson: `Ambiguous: ${matching.length} open PRs for branch "${branch}" targeting "${base}" in ${ownerRepo} — candidates ${candidates} — manual review needed`,
|
|
73
83
|
};
|
|
74
84
|
}
|
|
75
|
-
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
function createPr(ownerRepo, branch, base, title, issueRef, execGh) {
|
|
76
88
|
const createOut = execGh([
|
|
77
89
|
'pr',
|
|
78
90
|
'create',
|
|
@@ -84,7 +96,7 @@ function findOrCreatePr(ownerRepo, branch, base, title, execGh) {
|
|
|
84
96
|
'--head',
|
|
85
97
|
branch,
|
|
86
98
|
'--title',
|
|
87
|
-
title,
|
|
99
|
+
issueBoundTitle(title, issueRef, ownerRepo),
|
|
88
100
|
'--body',
|
|
89
101
|
'',
|
|
90
102
|
]);
|
|
@@ -115,17 +127,29 @@ function findOrCreatePr(ownerRepo, branch, base, title, execGh) {
|
|
|
115
127
|
}
|
|
116
128
|
return { prUrl: viewData.url, prNumber: viewData.number };
|
|
117
129
|
}
|
|
130
|
+
function findOrCreatePr(ownerRepo, branch, base, title, issueRef, execGh) {
|
|
131
|
+
const existing = matchingOpenPr(ownerRepo, branch, base, execGh, 'number,url,baseRefName');
|
|
132
|
+
if (existing && !('needsHuman' in existing)) {
|
|
133
|
+
return { prUrl: existing.prUrl, prNumber: existing.prNumber };
|
|
134
|
+
}
|
|
135
|
+
if (existing)
|
|
136
|
+
return existing;
|
|
137
|
+
return createPr(ownerRepo, branch, base, title, issueRef, execGh);
|
|
138
|
+
}
|
|
139
|
+
function findExistingPrWithHead(ownerRepo, branch, base, execGh) {
|
|
140
|
+
return matchingOpenPr(ownerRepo, branch, base, execGh, 'number,url,baseRefName,headRefOid');
|
|
141
|
+
}
|
|
118
142
|
// ─── Preflight (B5+B7) ────────────────────────────────────────────────────────
|
|
119
143
|
/**
|
|
120
|
-
* preflightLive — clean check + base
|
|
144
|
+
* preflightLive — clean check + base availability, evaluated ONCE as a memoized DBOS step.
|
|
121
145
|
* Only called on live runs; script/stub runs skip this entirely.
|
|
122
146
|
*
|
|
123
147
|
* 1. git fetch origin <base> (idempotent — the only mutation)
|
|
124
148
|
* 2. git status --porcelain → non-empty → block (repo not clean)
|
|
125
|
-
* 3. Verify base
|
|
126
|
-
*
|
|
149
|
+
* 3. Verify origin/<base> resolves. Non-base caller branches are allowed because the run worktree
|
|
150
|
+
* is cut from origin/<base>; the caller checkout is never switched or rebased.
|
|
127
151
|
*
|
|
128
|
-
* Returns { ok: true } when the repo is clean and
|
|
152
|
+
* Returns { ok: true } when the caller repo is clean and origin/<base> is available.
|
|
129
153
|
* Returns IntegratorBlocked on ANY failure — no throw, so DBOS does NOT retry.
|
|
130
154
|
*/
|
|
131
155
|
export async function preflightLive(taskId, base, deps) {
|
|
@@ -150,9 +174,9 @@ export async function preflightLive(taskId, base, deps) {
|
|
|
150
174
|
lesson: `target repo ${cwd} is not clean (${lineCount} uncommitted change${lineCount === 1 ? '' : 's'}); commit/stash and retry --live`,
|
|
151
175
|
};
|
|
152
176
|
}
|
|
153
|
-
// 3. Base
|
|
154
|
-
//
|
|
155
|
-
//
|
|
177
|
+
// 3. Base availability. The isolated run worktree is created later from origin/<base>, so feature
|
|
178
|
+
// branches in the caller checkout do not need to be based on the fresh base. The only caller-branch
|
|
179
|
+
// policy we preserve here is fail-loud protection for local-only commits on the base branch itself.
|
|
156
180
|
let headBranch;
|
|
157
181
|
let headSha;
|
|
158
182
|
let originSha;
|
|
@@ -164,16 +188,10 @@ export async function preflightLive(taskId, base, deps) {
|
|
|
164
188
|
catch (err) {
|
|
165
189
|
return {
|
|
166
190
|
needsHuman: true,
|
|
167
|
-
lesson: `live preflight: cannot
|
|
191
|
+
lesson: `live preflight: cannot resolve origin/${base} after fetch — ${String(err)}`,
|
|
168
192
|
};
|
|
169
193
|
}
|
|
170
194
|
if (headBranch === base && headSha !== originSha) {
|
|
171
|
-
// The clean base branch isn't at origin/<base>. SELF-HEAL instead of dead-ending: a sibling run
|
|
172
|
-
// merging advances origin/<base>, and the old hard block forced a manual `git pull` + re-run for
|
|
173
|
-
// every other in-flight run (slice 142 / dogfood). The tree is already clean (step 2), so if the
|
|
174
|
-
// base is simply BEHIND (HEAD is an ancestor of origin/<base>) we fast-forward it — exactly what
|
|
175
|
-
// `git pull --ff-only` does, and harmless for worktree-isolated runs (they re-cut from origin anyway).
|
|
176
|
-
// Only a genuinely DIVERGED base (local commits absent from the remote) still blocks for a human.
|
|
177
195
|
let behind = false;
|
|
178
196
|
try {
|
|
179
197
|
execGit(['merge-base', '--is-ancestor', 'HEAD', `origin/${base}`], cwd);
|
|
@@ -185,33 +203,11 @@ export async function preflightLive(taskId, base, deps) {
|
|
|
185
203
|
if (!behind) {
|
|
186
204
|
return {
|
|
187
205
|
needsHuman: true,
|
|
188
|
-
lesson: `target repo base branch ${base} has
|
|
206
|
+
lesson: `target repo base branch ${base} has local-only or diverged commits relative to origin/${base} ` +
|
|
189
207
|
`(HEAD=${headSha.slice(0, 8)} has local commits absent from origin/${base}@${originSha.slice(0, 8)}); ` +
|
|
190
208
|
`reconcile manually, then retry --live`,
|
|
191
209
|
};
|
|
192
210
|
}
|
|
193
|
-
try {
|
|
194
|
-
execGit(['merge', '--ff-only', `origin/${base}`], cwd); // clean + ancestor → cannot conflict
|
|
195
|
-
}
|
|
196
|
-
catch (err) {
|
|
197
|
-
return {
|
|
198
|
-
needsHuman: true,
|
|
199
|
-
lesson: `live preflight: fast-forward of ${base} to origin/${base} failed — ${String(err)}; pull ${base} manually and retry --live`,
|
|
200
|
-
};
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
if (headBranch !== base) {
|
|
204
|
-
try {
|
|
205
|
-
execGit(['merge-base', '--is-ancestor', `origin/${base}`, 'HEAD'], cwd);
|
|
206
|
-
}
|
|
207
|
-
catch {
|
|
208
|
-
return {
|
|
209
|
-
needsHuman: true,
|
|
210
|
-
lesson: `target repo branch is not based on fresh origin/${base} ` +
|
|
211
|
-
`(HEAD=${headBranch}@${headSha.slice(0, 8)}, expected origin/${base}@${originSha.slice(0, 8)} as ancestor); ` +
|
|
212
|
-
`rebase or merge origin/${base}, then retry --live`,
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
211
|
}
|
|
216
212
|
return { ok: true };
|
|
217
213
|
}
|
|
@@ -225,8 +221,43 @@ export function stubIntegrate(input) {
|
|
|
225
221
|
prUrl: 'stub://pr/placeholder',
|
|
226
222
|
branch: `feat/${input.taskId}-stub`,
|
|
227
223
|
prNumber: 0,
|
|
224
|
+
...(input.issueRef ? { issueRef: input.issueRef } : {}),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
export async function captureProducedChange(input, deps) {
|
|
228
|
+
const { execGit: git, resolveRunCwd } = deps;
|
|
229
|
+
const cwd = await resolveRunCwd(input.runId, input.taskId);
|
|
230
|
+
const branch = branchName(input.taskId, input.title, input.issueRef);
|
|
231
|
+
const ownerRepoResult = resolveOwnerRepo(git, cwd);
|
|
232
|
+
const ownerRepo = 'needsHuman' in ownerRepoResult ? undefined : ownerRepoResult.ownerRepo;
|
|
233
|
+
if (branchExists(git, cwd, branch)) {
|
|
234
|
+
git(['switch', branch], cwd);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
git(['switch', '-c', branch], cwd);
|
|
238
|
+
}
|
|
239
|
+
git(['add', '-A'], cwd);
|
|
240
|
+
if (stagedDiffPresent(git, cwd)) {
|
|
241
|
+
git(['commit', '-m', commitMessage(input.title, input.issueRef, ownerRepo)], cwd);
|
|
242
|
+
}
|
|
243
|
+
const headSha = git(['rev-parse', 'HEAD'], cwd).trim();
|
|
244
|
+
return {
|
|
245
|
+
branch,
|
|
246
|
+
headSha,
|
|
247
|
+
...(input.issueRef ? { issueRef: input.issueRef } : {}),
|
|
248
|
+
worktreePath: cwd,
|
|
249
|
+
...(input.artifactRef ? { artifactRef: input.artifactRef } : {}),
|
|
228
250
|
};
|
|
229
251
|
}
|
|
252
|
+
function stagedDiffPresent(git, cwd) {
|
|
253
|
+
try {
|
|
254
|
+
git(['diff', '--cached', '--quiet'], cwd);
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
230
261
|
// ─── REAL integrator (live only) ─────────────────────────────────────────────
|
|
231
262
|
/**
|
|
232
263
|
* integrate — REAL integrator (live only).
|
|
@@ -234,12 +265,14 @@ export function stubIntegrate(input) {
|
|
|
234
265
|
* push + find-or-create PR even if commit already happened (ahead guard).
|
|
235
266
|
*/
|
|
236
267
|
export async function integrate(input, deps) {
|
|
268
|
+
if (input.change)
|
|
269
|
+
return integrateProducedChange(input, deps, input.change);
|
|
237
270
|
const { execGit: git, execGh: gh, resolveRunCwd } = deps;
|
|
238
271
|
// Resolve the run's ISOLATED worktree (plan 0017) — it is already checked out on `branch`, so the
|
|
239
272
|
// branchExists→switch path below is a no-op and the dirty-tree `switch -c origin/<base>` (which
|
|
240
273
|
// failed when the base checkout carried prior-run changes) is never taken.
|
|
241
274
|
const cwd = await resolveRunCwd(input.runId, input.taskId);
|
|
242
|
-
const branch = branchName(input.taskId, input.title);
|
|
275
|
+
const branch = branchName(input.taskId, input.title, input.issueRef);
|
|
243
276
|
// 1. Derive owner/repo
|
|
244
277
|
const ownerRepoResult = resolveOwnerRepo(git, cwd);
|
|
245
278
|
if ('needsHuman' in ownerRepoResult)
|
|
@@ -257,38 +290,97 @@ export async function integrate(input, deps) {
|
|
|
257
290
|
// 4. Stage all changes (safe: clean-repo precondition from preflight)
|
|
258
291
|
git(['add', '-A'], cwd);
|
|
259
292
|
// 5. Commit decision (B4 replay safety)
|
|
260
|
-
|
|
261
|
-
try {
|
|
262
|
-
git(['diff', '--cached', '--quiet'], cwd);
|
|
263
|
-
hasStagedDiff = false; // exit 0 → nothing staged
|
|
264
|
-
}
|
|
265
|
-
catch {
|
|
266
|
-
hasStagedDiff = true; // exit 1 → staged diff present
|
|
267
|
-
}
|
|
268
|
-
if (hasStagedDiff) {
|
|
293
|
+
if (stagedDiffPresent(git, cwd)) {
|
|
269
294
|
// Commit — NO Co-Authored-By, NO summary footer (MEMORY)
|
|
270
|
-
const commitMsg =
|
|
295
|
+
const commitMsg = commitMessage(input.title, input.issueRef, ownerRepo);
|
|
271
296
|
git(['commit', '-m', commitMsg], cwd);
|
|
272
297
|
}
|
|
273
298
|
else {
|
|
274
299
|
// No staged diff — check if branch is ahead of origin/<base>
|
|
275
300
|
const ahead = countAhead(git, cwd, branch, input.base);
|
|
276
301
|
if (ahead === 0) {
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
302
|
+
// Probe the BASE checkout for uncommitted changes: if it is dirty the developer wrote OUTSIDE
|
|
303
|
+
// the run's worktree (the slice-143 / #130 symptom). Only changes the diagnostic lesson; no
|
|
304
|
+
// destructive action, so a rare mis-attribution is still a useful signal.
|
|
305
|
+
let lesson = 'nothing to integrate — no staged changes and branch is not ahead of origin/' + input.base;
|
|
306
|
+
try {
|
|
307
|
+
const baseCwd = await deps.resolveTaskCwd(input.taskId);
|
|
308
|
+
const basePorcelain = git(['status', '--porcelain'], baseCwd).trim();
|
|
309
|
+
if (basePorcelain !== '') {
|
|
310
|
+
lesson =
|
|
311
|
+
`developer produced changes but the run's worktree is empty — they appear to have been ` +
|
|
312
|
+
`written OUTSIDE the worktree (the base checkout ${baseCwd} is dirty); see slice 143. ` +
|
|
313
|
+
`Re-run; the agent must write under its cwd / $REVO_WORKTREE_PATH.`;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
// git error on the base — keep the generic lesson
|
|
318
|
+
}
|
|
319
|
+
return { needsHuman: true, lesson };
|
|
282
320
|
}
|
|
283
321
|
// Ahead but no staged diff → commit happened on a prior attempt; fall through to push
|
|
284
322
|
}
|
|
285
323
|
// 6. Push (idempotent — no force)
|
|
286
324
|
git(['push', '-u', 'origin', branch], cwd);
|
|
287
325
|
// 7. Find-or-create PR
|
|
288
|
-
const prResult = findOrCreatePr(ownerRepo, branch, input.base, input.title, gh);
|
|
326
|
+
const prResult = findOrCreatePr(ownerRepo, branch, input.base, input.title, input.issueRef, gh);
|
|
289
327
|
if ('needsHuman' in prResult)
|
|
290
328
|
return prResult;
|
|
291
|
-
return { prUrl: prResult.prUrl, branch, prNumber: prResult.prNumber };
|
|
329
|
+
return { prUrl: prResult.prUrl, branch, prNumber: prResult.prNumber, ...(input.issueRef ? { issueRef: input.issueRef } : {}) };
|
|
330
|
+
}
|
|
331
|
+
async function integrateProducedChange(input, deps, change) {
|
|
332
|
+
const { execGit: git, execGh: gh } = deps;
|
|
333
|
+
const cwd = change.worktreePath ?? await deps.resolveRunCwd(input.runId, input.taskId);
|
|
334
|
+
const branch = change.branch;
|
|
335
|
+
const issueRef = change.issueRef ?? input.issueRef;
|
|
336
|
+
const ownerRepoResult = resolveOwnerRepo(git, cwd);
|
|
337
|
+
if ('needsHuman' in ownerRepoResult)
|
|
338
|
+
return ownerRepoResult;
|
|
339
|
+
const { ownerRepo } = ownerRepoResult;
|
|
340
|
+
git(['fetch', 'origin', input.base], cwd);
|
|
341
|
+
const existing = findExistingPrWithHead(ownerRepo, branch, input.base, gh);
|
|
342
|
+
if (existing && 'needsHuman' in existing)
|
|
343
|
+
return existing;
|
|
344
|
+
if (existing?.headSha === change.headSha) {
|
|
345
|
+
return {
|
|
346
|
+
prUrl: existing.prUrl,
|
|
347
|
+
branch,
|
|
348
|
+
prNumber: existing.prNumber,
|
|
349
|
+
...(issueRef ? { issueRef } : {}),
|
|
350
|
+
headSha: change.headSha,
|
|
351
|
+
status: 'noop',
|
|
352
|
+
message: 'nothing to integrate — produced head already pushed and equals PR head',
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
if (!existing && countAhead(git, cwd, change.headSha, input.base) === 0) {
|
|
356
|
+
return {
|
|
357
|
+
needsHuman: true,
|
|
358
|
+
lesson: `nothing to integrate — produced head ${change.headSha.slice(0, 8)} is not ahead of ` +
|
|
359
|
+
`origin/${input.base} and no open PR exists`,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
git(['push', 'origin', `${change.headSha}:refs/heads/${branch}`], cwd);
|
|
363
|
+
if (existing) {
|
|
364
|
+
return {
|
|
365
|
+
prUrl: existing.prUrl,
|
|
366
|
+
branch,
|
|
367
|
+
prNumber: existing.prNumber,
|
|
368
|
+
...(issueRef ? { issueRef } : {}),
|
|
369
|
+
headSha: change.headSha,
|
|
370
|
+
status: 'pushed',
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
const created = createPr(ownerRepo, branch, input.base, input.title, issueRef, gh);
|
|
374
|
+
if ('needsHuman' in created)
|
|
375
|
+
return created;
|
|
376
|
+
return {
|
|
377
|
+
prUrl: created.prUrl,
|
|
378
|
+
branch,
|
|
379
|
+
prNumber: created.prNumber,
|
|
380
|
+
...(issueRef ? { issueRef } : {}),
|
|
381
|
+
headSha: change.headSha,
|
|
382
|
+
status: 'pushed',
|
|
383
|
+
};
|
|
292
384
|
}
|
|
293
385
|
/**
|
|
294
386
|
* confirmMerge — REAL (live only). Ensures the run's PR is actually merged before the run reaches its
|
|
@@ -296,8 +388,8 @@ export async function integrate(input, deps) {
|
|
|
296
388
|
* base — truly disposable). Idempotent + replay-safe:
|
|
297
389
|
*
|
|
298
390
|
* 1. `gh pr view` the run's branch. Already `merged` (a human merged it) → succeed.
|
|
299
|
-
* 2. Not merged + OPEN + `mergeStateStatus === CLEAN` (CI green, no conflicts)
|
|
300
|
-
* --delete-branch
|
|
391
|
+
* 2. Not merged + OPEN + `mergeStateStatus === CLEAN` (CI green, no conflicts) + a fresh readiness head
|
|
392
|
+
* → `gh pr merge --squash --delete-branch --match-head-commit <sha>`, then re-view to CONFIRM merged.
|
|
301
393
|
* 3. Not mergeable (not OPEN, or not CLEAN — red CI / conflicts / blocked) → block (needsHuman), which
|
|
302
394
|
* routes to a `blocked` terminal and KEEPS the worktree for rework.
|
|
303
395
|
*
|
|
@@ -307,7 +399,7 @@ export async function integrate(input, deps) {
|
|
|
307
399
|
export async function confirmMerge(input, deps) {
|
|
308
400
|
const { execGit: git, execGh: gh, resolveRunCwd } = deps;
|
|
309
401
|
const cwd = await resolveRunCwd(input.runId, input.taskId);
|
|
310
|
-
const branch = branchName(input.taskId, input.title);
|
|
402
|
+
const branch = branchName(input.taskId, input.title, input.issueRef);
|
|
311
403
|
const ownerRepoResult = resolveOwnerRepo(git, cwd);
|
|
312
404
|
if ('needsHuman' in ownerRepoResult)
|
|
313
405
|
return ownerRepoResult;
|
|
@@ -323,7 +415,7 @@ export async function confirmMerge(input, deps) {
|
|
|
323
415
|
};
|
|
324
416
|
const pr = view();
|
|
325
417
|
if (pr.state === 'MERGED')
|
|
326
|
-
return { merged: true, prNumber: pr.number, prUrl: pr.url };
|
|
418
|
+
return { merged: true, prNumber: pr.number, prUrl: pr.url, ...(input.issueRef ? { issueRef: input.issueRef } : {}) };
|
|
327
419
|
if (pr.state !== 'OPEN') {
|
|
328
420
|
return { needsHuman: true, lesson: `PR #${pr.number} is ${pr.state} (not OPEN) and not merged — resolve manually` };
|
|
329
421
|
}
|
|
@@ -336,16 +428,33 @@ export async function confirmMerge(input, deps) {
|
|
|
336
428
|
`conflicts, or required reviews pending; merge it manually (or fix + re-run) then cleanup`,
|
|
337
429
|
};
|
|
338
430
|
}
|
|
431
|
+
const expectedHeadSha = input.mergeReadiness?.headSha.trim();
|
|
432
|
+
if (!expectedHeadSha) {
|
|
433
|
+
return {
|
|
434
|
+
needsHuman: true,
|
|
435
|
+
lesson: `PR #${pr.number} merge requires a fresh merge readiness headSha guard — re-run readiness before approving merge`,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
339
438
|
// The integrator opens the PR as a DRAFT (human review at the merge gate); `gh pr merge` refuses a
|
|
340
439
|
// draft, so mark it ready first (the approved merge gate IS that review). Idempotent: a no-op once ready.
|
|
341
440
|
if (pr.isDraft) {
|
|
342
441
|
gh(['pr', 'ready', branch, '--repo', ownerRepo]);
|
|
343
442
|
}
|
|
344
443
|
// Merge (squash) — idempotent: a replay re-views first (step 1) and short-circuits on state MERGED.
|
|
345
|
-
|
|
444
|
+
try {
|
|
445
|
+
gh(['pr', 'merge', branch, '--repo', ownerRepo, '--squash', '--delete-branch', '--match-head-commit', expectedHeadSha]);
|
|
446
|
+
}
|
|
447
|
+
catch (err) {
|
|
448
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
449
|
+
return {
|
|
450
|
+
needsHuman: true,
|
|
451
|
+
lesson: `PR #${pr.number} merge was blocked by the GitHub head guard ` +
|
|
452
|
+
`(expected ${expectedHeadSha}): ${message || 'merge command failed'}`,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
346
455
|
const after = view();
|
|
347
456
|
if (after.state === 'MERGED')
|
|
348
|
-
return { merged: true, prNumber: after.number, prUrl: after.url };
|
|
457
|
+
return { merged: true, prNumber: after.number, prUrl: after.url, ...(input.issueRef ? { issueRef: input.issueRef } : {}) };
|
|
349
458
|
return { needsHuman: true, lesson: `PR #${after.number} merge did not take effect (state=${after.state}) — verify manually` };
|
|
350
459
|
}
|
|
351
460
|
/** Default polling bounds — pr-readiness parity (20 polls × 30s), overridable via env so the e2e suite
|
|
@@ -354,18 +463,39 @@ function envInt(name, fallback) {
|
|
|
354
463
|
const n = Number.parseInt(process.env[name] ?? '', 10);
|
|
355
464
|
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
356
465
|
}
|
|
357
|
-
function defaultCollect(repo, branch, base, execGh) {
|
|
466
|
+
function defaultCollect(repo, branch, base, execGh, issueRef) {
|
|
358
467
|
// pollPr classifies by CI checks + unresolved review THREADS only — it does not read the PR comment
|
|
359
468
|
// feed, so suppress the `api repos/.../{reviews,comments}` calls (`includeComments: false`). Those
|
|
360
469
|
// extra calls are pure overhead here and the review-thread query already carries the actionable
|
|
361
470
|
// feedback the loop acts on.
|
|
362
|
-
return collectPrReadiness({ repo, headBranch: branch, baseBranch: base, includeReviewThreads: true, includeComments: false }, execGh).then((r) => ({
|
|
471
|
+
return collectPrReadiness({ repo, headBranch: branch, baseBranch: base, issueRef, includeReviewThreads: true, includeComments: false }, execGh).then((r) => ({
|
|
363
472
|
pr: { number: r.pr.number, headSha: r.pr.headSha },
|
|
364
473
|
checks: { pending: r.checks.pending, fail: r.checks.fail, list: r.checks.list },
|
|
365
474
|
reviewThreads: { items: r.reviewThreads.items },
|
|
475
|
+
readinessVerdict: r.verdict,
|
|
476
|
+
nextAction: r.nextAction,
|
|
477
|
+
evidence: r.evidence,
|
|
366
478
|
}));
|
|
367
479
|
}
|
|
368
480
|
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
481
|
+
function ciFailuresFrom(readiness) {
|
|
482
|
+
return readiness.checks.list
|
|
483
|
+
.filter((c) => readiness.checks.fail.includes(c.name))
|
|
484
|
+
.map((c) => ({ name: c.name, conclusion: c.result }));
|
|
485
|
+
}
|
|
486
|
+
function readinessRequiresReview(readiness) {
|
|
487
|
+
return readiness.readinessVerdict === 'needs_human'
|
|
488
|
+
|| readiness.readinessVerdict === 'needs_work'
|
|
489
|
+
|| readiness.nextAction === 'human_decision'
|
|
490
|
+
|| readiness.nextAction === 'reviewer_triage'
|
|
491
|
+
|| readiness.nextAction === 'developer_fix';
|
|
492
|
+
}
|
|
493
|
+
function readinessEvidence(readiness) {
|
|
494
|
+
return [
|
|
495
|
+
readiness.readinessVerdict ? `readiness verdict=${readiness.readinessVerdict}` : undefined,
|
|
496
|
+
readiness.nextAction ? `readiness nextAction=${readiness.nextAction}` : undefined,
|
|
497
|
+
].filter((item) => item !== undefined);
|
|
498
|
+
}
|
|
369
499
|
/**
|
|
370
500
|
* pollPr — REAL (live only). Polls `collectPrReadiness` until CI is terminal (no pending checks) or a
|
|
371
501
|
* timeout, then classifies the feedback by TYPE (plan 0018): unresolved review threads → review_changes;
|
|
@@ -376,19 +506,20 @@ export async function pollPr(input, deps) {
|
|
|
376
506
|
const { execGit: git, execGh: gh, resolveRunCwd } = deps;
|
|
377
507
|
const collect = deps.collect ?? defaultCollect;
|
|
378
508
|
const sleep = deps.sleep ?? defaultSleep;
|
|
509
|
+
const requiredChecks = deps.requiredChecks ?? fetchRequiredCheckNames;
|
|
379
510
|
// Read env at CALL time (not module load) so the e2e harness can shrink the poll budget regardless
|
|
380
511
|
// of import order; prod default stays 20 × 30s.
|
|
381
512
|
const maxPolls = deps.maxPolls ?? envInt('REVO_POLL_PR_MAX_POLLS', 20);
|
|
382
513
|
const intervalMs = deps.pollIntervalMs ?? envInt('REVO_POLL_PR_INTERVAL_MS', 30_000);
|
|
383
514
|
const cwd = await resolveRunCwd(input.runId, input.taskId);
|
|
384
|
-
const branch = branchName(input.taskId, input.title);
|
|
515
|
+
const branch = branchName(input.taskId, input.title, input.issueRef);
|
|
385
516
|
const ownerRepoResult = resolveOwnerRepo(git, cwd);
|
|
386
517
|
if ('needsHuman' in ownerRepoResult)
|
|
387
518
|
return ownerRepoResult;
|
|
388
519
|
const { ownerRepo } = ownerRepoResult;
|
|
389
520
|
let readiness;
|
|
390
521
|
for (let i = 0; i < maxPolls; i++) {
|
|
391
|
-
readiness = await collect(ownerRepo, branch, input.base, gh);
|
|
522
|
+
readiness = await collect(ownerRepo, branch, input.base, gh, input.issueRef);
|
|
392
523
|
// Terminal once nothing is pending and at least one check has registered (matches pr-readiness).
|
|
393
524
|
if (readiness.checks.pending.length === 0 && readiness.checks.list.length > 0)
|
|
394
525
|
break;
|
|
@@ -404,10 +535,7 @@ export async function pollPr(input, deps) {
|
|
|
404
535
|
}
|
|
405
536
|
// `settled` is the CI-terminal readiness (defined past the guard); the review-grace loop may refresh it.
|
|
406
537
|
let settled = readiness;
|
|
407
|
-
|
|
408
|
-
const ciFailures = settled.checks.list
|
|
409
|
-
.filter((c) => settled.checks.fail.includes(c.name))
|
|
410
|
-
.map((c) => ({ name: c.name, conclusion: c.result }));
|
|
538
|
+
const initialCiFailures = ciFailuresFrom(settled);
|
|
411
539
|
// CI green → flip the PR draft→ready so CodeRabbit / human reviewers actually engage. They SKIP
|
|
412
540
|
// draft PRs, which silently bypassed the entire review loop (the PR was readied only at confirmMerge,
|
|
413
541
|
// i.e. after the merge gate) — slice 142. Then wait a BOUNDED grace for review threads to surface
|
|
@@ -415,7 +543,7 @@ export async function pollPr(input, deps) {
|
|
|
415
543
|
// BLOCK on a review arriving: the human merge gate is the backstop, so an absent / rate-limited
|
|
416
544
|
// reviewer falls through to it rather than deadlocking the run. CI-red stays draft (don't request
|
|
417
545
|
// review of broken code) and routes to ci_changes for a fix first.
|
|
418
|
-
if (
|
|
546
|
+
if (initialCiFailures.length === 0) {
|
|
419
547
|
try {
|
|
420
548
|
gh(['pr', 'ready', branch, '--repo', ownerRepo]);
|
|
421
549
|
}
|
|
@@ -425,9 +553,19 @@ export async function pollPr(input, deps) {
|
|
|
425
553
|
const reviewGracePolls = deps.reviewGracePolls ?? envInt('REVO_POLL_PR_REVIEW_GRACE_POLLS', 4);
|
|
426
554
|
for (let i = 0; i < reviewGracePolls && settled.reviewThreads.items.length === 0; i++) {
|
|
427
555
|
await sleep(intervalMs);
|
|
428
|
-
settled = await collect(ownerRepo, branch, input.base, gh);
|
|
556
|
+
settled = await collect(ownerRepo, branch, input.base, gh, input.issueRef);
|
|
429
557
|
}
|
|
430
558
|
}
|
|
559
|
+
if (settled.checks.pending.length > 0 || settled.checks.list.length === 0) {
|
|
560
|
+
const detail = settled.checks.pending.length > 0
|
|
561
|
+
? `pending checks: ${settled.checks.pending.join(', ')}`
|
|
562
|
+
: 'no checks registered';
|
|
563
|
+
const evidence = settled.evidence.length > 0 ? ` Evidence: ${settled.evidence.join(' | ')}` : '';
|
|
564
|
+
return {
|
|
565
|
+
needsHuman: true,
|
|
566
|
+
lesson: `pollPr found unsettled readiness after readying ${branch} - ${detail}.${evidence}`,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
431
569
|
const reviewThreads = settled.reviewThreads.items.map((t) => ({
|
|
432
570
|
threadId: t.id,
|
|
433
571
|
path: t.path,
|
|
@@ -435,11 +573,34 @@ export async function pollPr(input, deps) {
|
|
|
435
573
|
author: t.author,
|
|
436
574
|
body: t.body,
|
|
437
575
|
}));
|
|
438
|
-
|
|
439
|
-
|
|
576
|
+
const ciFailures = ciFailuresFrom(settled);
|
|
577
|
+
// Only REQUIRED-check failures drive the `ci_changes` verdict (PR #135 fix): on this repo only
|
|
578
|
+
// "Verify"/"E2E"/"Required checks" gate the merge — Sonar/CodeRabbit are advisory, and a failing
|
|
579
|
+
// advisory check used to wrongly trigger a pointless ciRework loop. `gh pr view` does not carry
|
|
580
|
+
// isRequired, so we ask GitHub directly. ciFailures keeps the FULL failing list for downstream
|
|
581
|
+
// context; the verdict keys off the required-only subset.
|
|
582
|
+
// FAIL-SAFE: if the required set can't be determined (gh error or empty), fall back to counting
|
|
583
|
+
// ALL failures (current behavior) — never silently treat an unknown required-set as clean.
|
|
584
|
+
let ciVerdictFailures = ciFailures;
|
|
585
|
+
if (ciFailures.length > 0 && settled.pr.number !== null) {
|
|
586
|
+
try {
|
|
587
|
+
const required = requiredChecks(ownerRepo, settled.pr.number, gh);
|
|
588
|
+
if (required.size > 0)
|
|
589
|
+
ciVerdictFailures = ciFailures.filter((f) => required.has(f.name));
|
|
590
|
+
}
|
|
591
|
+
catch {
|
|
592
|
+
// gh failed → keep the full failing list (fail-safe: unknown required-set ≠ clean).
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
// Decision order (plan 0018): review threads first, then CI (required-only), else clean.
|
|
596
|
+
const verdict = reviewThreads.length > 0 || readinessRequiresReview(settled)
|
|
597
|
+
? 'review_changes'
|
|
598
|
+
: ciVerdictFailures.length > 0 ? 'ci_changes' : 'clean';
|
|
440
599
|
return {
|
|
441
600
|
prNumber: settled.pr.number ?? null,
|
|
442
601
|
headSha: settled.pr.headSha,
|
|
602
|
+
evidence: [...settled.evidence, ...readinessEvidence(settled), `PR headSha=${settled.pr.headSha}`, `pollPr verdict=${verdict}`],
|
|
603
|
+
...(input.issueRef ? { issueRef: input.issueRef } : {}),
|
|
443
604
|
verdict,
|
|
444
605
|
ciFailures,
|
|
445
606
|
reviewThreads,
|
|
@@ -555,6 +716,10 @@ let IntegratorService = class IntegratorService {
|
|
|
555
716
|
runPreflight = (taskId, base) => {
|
|
556
717
|
return preflightLive(taskId, base, this.deps);
|
|
557
718
|
};
|
|
719
|
+
/** Capture a change-producing developer step as a git head artifact before reviewer/integrator handoff. */
|
|
720
|
+
runCaptureProducedChange = (input) => {
|
|
721
|
+
return captureProducedChange(input, this.deps);
|
|
722
|
+
};
|
|
558
723
|
/**
|
|
559
724
|
* Real pollPr — live path. Same fail-loud pinned-gh handling as runIntegrate (the review/CI poll reads
|
|
560
725
|
* the PR via the pinned account, never the ambient one). Blocks if the pinned identity is unresolved.
|
|
@@ -569,7 +734,7 @@ let IntegratorService = class IntegratorService {
|
|
|
569
734
|
};
|
|
570
735
|
/** Stub pollPr — script path; zero external effects (reports a clean PR so the loop converges to merge). */
|
|
571
736
|
runPollStub = (_input) => {
|
|
572
|
-
return { prNumber: null, headSha: 'stub', verdict: 'clean', ciFailures: [], reviewThreads: [] };
|
|
737
|
+
return { prNumber: null, headSha: 'stub', evidence: ['stub pollPr readiness: clean'], verdict: 'clean', ciFailures: [], reviewThreads: [] };
|
|
573
738
|
};
|
|
574
739
|
/**
|
|
575
740
|
* Real respondThreads — live path. Same fail-loud pinned-gh handling: refuse the ambient account.
|