@dev-loops/core 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/bin/capture-deep-persona-signals.mjs +143 -0
- package/bin/ensure-phase-files.mjs +7 -0
- package/bin/log-bash-exit-1.mjs +7 -0
- package/bin/parse-review-threads.mjs +7 -0
- package/package.json +78 -0
- package/src/analysis/change-classifier.mjs +146 -0
- package/src/analysis/diff-analyzer.mjs +285 -0
- package/src/bash-exit-one.mjs +130 -0
- package/src/cli/helpers.mjs +22 -0
- package/src/cli/primitives.mjs +70 -0
- package/src/cli/retry-wrapper.mjs +169 -0
- package/src/cli/subcommand-runner.mjs +246 -0
- package/src/config/config.mjs +965 -0
- package/src/debt/cluster.mjs +240 -0
- package/src/debt/debt-finding.mjs +68 -0
- package/src/debt/debt-signal.mjs +46 -0
- package/src/debt/deep-persona-signals.mjs +266 -0
- package/src/debt/remediation-to-issue.mjs +121 -0
- package/src/debt/score.mjs +127 -0
- package/src/debt/shape.mjs +214 -0
- package/src/github/copilot-helpers.mjs +343 -0
- package/src/github/repo-slug.mjs +105 -0
- package/src/github/review-threads.mjs +343 -0
- package/src/harness/adapter.mjs +57 -0
- package/src/harness/index.mjs +3 -0
- package/src/harness/noop-adapter.mjs +22 -0
- package/src/harness/pi-adapter.mjs +47 -0
- package/src/loop/async-start-contract.mjs +170 -0
- package/src/loop/conductor-routing.mjs +817 -0
- package/src/loop/copilot-ci-status.mjs +255 -0
- package/src/loop/copilot-loop-iterations.mjs +161 -0
- package/src/loop/copilot-loop-state.mjs +510 -0
- package/src/loop/handoff-envelope.mjs +800 -0
- package/src/loop/issue-refinement-artifact.mjs +268 -0
- package/src/loop/lifecycle-state.mjs +342 -0
- package/src/loop/phase-files.mjs +187 -0
- package/src/loop/policy-constants.mjs +17 -0
- package/src/loop/pr-gate-coordination.mjs +1278 -0
- package/src/loop/public-dev-loop-routing-contract.mjs +277 -0
- package/src/loop/public-dev-loop-routing.mjs +1746 -0
- package/src/loop/queue-board-ordering.mjs +38 -0
- package/src/loop/queue-board-sync.mjs +223 -0
- package/src/loop/queue-driver.mjs +164 -0
- package/src/loop/queue-parallel.mjs +190 -0
- package/src/loop/queue-state.mjs +230 -0
- package/src/loop/retrospective-checkpoint.mjs +178 -0
- package/src/loop/reviewer-loop-state.mjs +456 -0
- package/src/loop/run-inspection.mjs +604 -0
- package/src/loop/steering.mjs +793 -0
- package/src/loop/timeout-policy.mjs +73 -0
- package/src/loop/tracker-first-loop-state.mjs +87 -0
- package/src/loop/tracker-pr-state.mjs +301 -0
- package/src/loop/worktree-guard.mjs +141 -0
- package/src/refinement/ac-dod-matrix.mjs +95 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { loadBoardConfig, resolveProjectNumber } from "./queue-board-sync.mjs";
|
|
2
|
+
import { main as listQueueItemsMain } from "../../../../scripts/projects/list-queue-items.mjs";
|
|
3
|
+
|
|
4
|
+
export async function resolveNextUpOrder(
|
|
5
|
+
repo,
|
|
6
|
+
repoRoot,
|
|
7
|
+
env = process.env,
|
|
8
|
+
dependencies = {},
|
|
9
|
+
) {
|
|
10
|
+
const config = loadBoardConfig(repoRoot);
|
|
11
|
+
if (!config.enabled) {
|
|
12
|
+
return { ok: true, order: [], reason: config.reason ?? "board not configured" };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let projectNumber;
|
|
16
|
+
try {
|
|
17
|
+
projectNumber = await resolveProjectNumber(repo, config, env, dependencies.runChild);
|
|
18
|
+
} catch (err) {
|
|
19
|
+
return { ok: true, order: [], reason: err.message ?? "board lookup failed" };
|
|
20
|
+
}
|
|
21
|
+
if (!projectNumber) {
|
|
22
|
+
return { ok: true, order: [], reason: "could not resolve board project" };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const listItems = dependencies.listQueueItems ?? listQueueItemsMain;
|
|
26
|
+
try {
|
|
27
|
+
const result = await listItems(
|
|
28
|
+
{ repo, project: projectNumber, column: "Next Up" },
|
|
29
|
+
{ env, runChild: dependencies.runChild },
|
|
30
|
+
);
|
|
31
|
+
const order = (result?.items ?? [])
|
|
32
|
+
.map((it) => it.issueNumber ?? it.prNumber)
|
|
33
|
+
.filter((n) => typeof n === "number");
|
|
34
|
+
return { ok: true, order, reason: null };
|
|
35
|
+
} catch (err) {
|
|
36
|
+
return { ok: true, order: [], reason: err.message ?? "Next Up query failed" };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
import { runChild as coreRunChild } from "../cli/primitives.mjs";
|
|
5
|
+
import { main as moveQueueItemMain } from "../../../../scripts/projects/move-queue-item.mjs";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_NON_SUCCESS_COLUMN = "Backlog";
|
|
8
|
+
|
|
9
|
+
// ── Local config loader ─────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
function readDevloopsSettings(repoRoot) {
|
|
12
|
+
const base = path.join(repoRoot, ".devloops");
|
|
13
|
+
const extensions = ["", ".yaml", ".yml", ".json"];
|
|
14
|
+
let foundError = null;
|
|
15
|
+
for (const ext of extensions) {
|
|
16
|
+
try {
|
|
17
|
+
const raw = readFileSync(base + ext, "utf8");
|
|
18
|
+
const settings = ext === ".json" ? JSON.parse(raw) : parseYaml(raw);
|
|
19
|
+
return { settings: settings?.queue ?? null };
|
|
20
|
+
} catch (err) {
|
|
21
|
+
if (err?.code === "ENOENT") {
|
|
22
|
+
// try next extension
|
|
23
|
+
} else if (!foundError) {
|
|
24
|
+
foundError = err;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (foundError) {
|
|
29
|
+
return { error: foundError.message };
|
|
30
|
+
}
|
|
31
|
+
return { settings: null };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function loadBoardConfig(repoRoot) {
|
|
35
|
+
const { settings: queue, error } = readDevloopsSettings(repoRoot);
|
|
36
|
+
if (error) {
|
|
37
|
+
return { enabled: false, reason: `config read/parse error: ${error}` };
|
|
38
|
+
}
|
|
39
|
+
if (!queue) return { enabled: false };
|
|
40
|
+
if (typeof queue.projectNumber === "number" && queue.projectNumber > 0) {
|
|
41
|
+
return { enabled: true, projectNumber: queue.projectNumber };
|
|
42
|
+
}
|
|
43
|
+
if (typeof queue.boardTitle === "string" && queue.boardTitle.trim().length > 0) {
|
|
44
|
+
return { enabled: true, boardTitle: queue.boardTitle.trim() };
|
|
45
|
+
}
|
|
46
|
+
return { enabled: false };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Minimal project lookup (read-only, no create/repair) ────────────────
|
|
50
|
+
|
|
51
|
+
const GET_USER_ID = [
|
|
52
|
+
"query($login:String!) {",
|
|
53
|
+
" user(login:$login) { id }",
|
|
54
|
+
"}"
|
|
55
|
+
].join("\n");
|
|
56
|
+
|
|
57
|
+
const GET_ORG_ID = [
|
|
58
|
+
"query($login:String!) {",
|
|
59
|
+
" organization(login:$login) { id }",
|
|
60
|
+
"}"
|
|
61
|
+
].join("\n");
|
|
62
|
+
|
|
63
|
+
const LIST_USER_PROJECTS = [
|
|
64
|
+
"query($login:String!, $after:String) {",
|
|
65
|
+
" user(login:$login) {",
|
|
66
|
+
" projectsV2(first:50, after:$after) {",
|
|
67
|
+
" pageInfo { hasNextPage endCursor }",
|
|
68
|
+
" nodes { id number title url }",
|
|
69
|
+
" }",
|
|
70
|
+
" }",
|
|
71
|
+
"}"
|
|
72
|
+
].join("\n");
|
|
73
|
+
|
|
74
|
+
const LIST_ORG_PROJECTS = [
|
|
75
|
+
"query($login:String!, $after:String) {",
|
|
76
|
+
" organization(login:$login) {",
|
|
77
|
+
" projectsV2(first:50, after:$after) {",
|
|
78
|
+
" pageInfo { hasNextPage endCursor }",
|
|
79
|
+
" nodes { id number title url }",
|
|
80
|
+
" }",
|
|
81
|
+
" }",
|
|
82
|
+
"}"
|
|
83
|
+
].join("\n");
|
|
84
|
+
|
|
85
|
+
async function ghGraphql(query, vars, env, runChild) {
|
|
86
|
+
const child = runChild ?? coreRunChild;
|
|
87
|
+
const fieldArgs = [];
|
|
88
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
89
|
+
fieldArgs.push("--field", `${key}=${value}`);
|
|
90
|
+
}
|
|
91
|
+
const result = await child(
|
|
92
|
+
"gh",
|
|
93
|
+
["api", "graphql", "--field", `query=${query}`, ...fieldArgs],
|
|
94
|
+
env,
|
|
95
|
+
);
|
|
96
|
+
if (result.code !== 0) {
|
|
97
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
98
|
+
throw Object.assign(new Error(`gh api graphql failed: ${detail}`), { code: "GH_API_ERROR" });
|
|
99
|
+
}
|
|
100
|
+
const payload = JSON.parse(result.stdout);
|
|
101
|
+
if (payload.errors && payload.errors.length > 0) {
|
|
102
|
+
throw Object.assign(
|
|
103
|
+
new Error(`GraphQL errors: ${payload.errors.map((e) => e.message).join("; ")}`),
|
|
104
|
+
{ code: "GRAPHQL_ERROR" },
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return payload;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function resolveOwner(login, env, runChild) {
|
|
111
|
+
const userPayload = await ghGraphql(GET_USER_ID, { login }, env, runChild);
|
|
112
|
+
if (userPayload?.data?.user?.id) {
|
|
113
|
+
return { id: userPayload.data.user.id, kind: "user" };
|
|
114
|
+
}
|
|
115
|
+
const orgPayload = await ghGraphql(GET_ORG_ID, { login }, env, runChild);
|
|
116
|
+
if (orgPayload?.data?.organization?.id) {
|
|
117
|
+
return { id: orgPayload.data.organization.id, kind: "org" };
|
|
118
|
+
}
|
|
119
|
+
throw Object.assign(
|
|
120
|
+
new Error(`Could not resolve owner ID for "${login}"`),
|
|
121
|
+
{ code: "NO_USER_ID" },
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function listAllProjects(login, kind, env, runChild) {
|
|
126
|
+
const query = kind === "org" ? LIST_ORG_PROJECTS : LIST_USER_PROJECTS;
|
|
127
|
+
const projects = [];
|
|
128
|
+
let after = null;
|
|
129
|
+
while (true) {
|
|
130
|
+
const vars = { login };
|
|
131
|
+
if (after) vars.after = after;
|
|
132
|
+
const payload = await ghGraphql(query, vars, env, runChild);
|
|
133
|
+
const connection = kind === "org"
|
|
134
|
+
? payload?.data?.organization?.projectsV2
|
|
135
|
+
: payload?.data?.user?.projectsV2;
|
|
136
|
+
const nodes = connection?.nodes ?? [];
|
|
137
|
+
projects.push(...nodes.filter((n) => n != null));
|
|
138
|
+
const pageInfo = connection?.pageInfo ?? {};
|
|
139
|
+
if (!pageInfo.hasNextPage) break;
|
|
140
|
+
if (!pageInfo.endCursor) {
|
|
141
|
+
throw Object.assign(
|
|
142
|
+
new Error("Invalid projects list payload: hasNextPage is true but endCursor is missing"),
|
|
143
|
+
{ code: "GH_API_ERROR" },
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
after = pageInfo.endCursor;
|
|
147
|
+
}
|
|
148
|
+
return projects;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const projectNumberCache = new Map();
|
|
152
|
+
|
|
153
|
+
function projectCacheKey(repo, boardTitle) {
|
|
154
|
+
return `${repo}::${boardTitle}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function resolveProjectNumber(repo, config, env, runChild) {
|
|
158
|
+
if (config.projectNumber) return config.projectNumber;
|
|
159
|
+
if (config.boardTitle) {
|
|
160
|
+
const key = projectCacheKey(repo, config.boardTitle);
|
|
161
|
+
const cached = projectNumberCache.get(key);
|
|
162
|
+
if (cached) return cached;
|
|
163
|
+
|
|
164
|
+
const [owner] = repo.split("/");
|
|
165
|
+
const { kind } = await resolveOwner(owner, env, runChild);
|
|
166
|
+
const projects = await listAllProjects(owner, kind, env, runChild);
|
|
167
|
+
const match = projects.find((p) => p.title === config.boardTitle);
|
|
168
|
+
if (!match) {
|
|
169
|
+
throw Object.assign(
|
|
170
|
+
new Error(`Board title "${config.boardTitle}" not found under "${owner}"`),
|
|
171
|
+
{ code: "BOARD_NOT_FOUND" },
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
projectNumberCache.set(key, match.number);
|
|
175
|
+
return match.number;
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── Public API ──────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
export async function syncBoardStatus(
|
|
183
|
+
repo,
|
|
184
|
+
repoRoot,
|
|
185
|
+
itemNumber,
|
|
186
|
+
targetColumn,
|
|
187
|
+
env = process.env,
|
|
188
|
+
dependencies = {},
|
|
189
|
+
) {
|
|
190
|
+
const config = loadBoardConfig(repoRoot);
|
|
191
|
+
if (!config.enabled) {
|
|
192
|
+
return { ok: true, skipped: true, reason: config.reason ?? "board not configured" };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let projectNumber;
|
|
196
|
+
try {
|
|
197
|
+
projectNumber = await resolveProjectNumber(repo, config, env, dependencies.runChild);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
return { ok: true, skipped: true, reason: err.message ?? "board lookup failed" };
|
|
200
|
+
}
|
|
201
|
+
if (!projectNumber) {
|
|
202
|
+
return { ok: true, skipped: true, reason: "could not resolve board project" };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const moveItem = dependencies.moveQueueItem ?? moveQueueItemMain;
|
|
206
|
+
try {
|
|
207
|
+
const result = await moveItem(
|
|
208
|
+
{ repo, project: projectNumber, item: itemNumber, toColumn: targetColumn },
|
|
209
|
+
{ env, runChild: dependencies.runChild },
|
|
210
|
+
);
|
|
211
|
+
return { ok: true, skipped: false, result };
|
|
212
|
+
} catch (err) {
|
|
213
|
+
return { ok: true, skipped: true, reason: err.message ?? "board sync failed" };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function nonSuccessBoardColumn(repoRoot, fallback = DEFAULT_NON_SUCCESS_COLUMN) {
|
|
218
|
+
const { settings: queue } = readDevloopsSettings(repoRoot);
|
|
219
|
+
const configured = queue?.nonSuccessStatus;
|
|
220
|
+
return typeof configured === "string" && configured.trim().length > 0
|
|
221
|
+
? configured.trim()
|
|
222
|
+
: fallback;
|
|
223
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sequential queue driver — iterates entries, calls startup resolver per entry,
|
|
3
|
+
* routes through existing dev-loop strategies.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
readQueue,
|
|
8
|
+
writeQueue,
|
|
9
|
+
transitionEntry,
|
|
10
|
+
snapshotEntry,
|
|
11
|
+
nextReadyEntry,
|
|
12
|
+
allDone,
|
|
13
|
+
RECOVERABLE_FAILURES,
|
|
14
|
+
appendBugIssue,
|
|
15
|
+
} from "./queue-state.mjs";
|
|
16
|
+
import { syncBoardStatus, nonSuccessBoardColumn } from "./queue-board-sync.mjs";
|
|
17
|
+
import { resolveNextUpOrder } from "./queue-board-ordering.mjs";
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_QUEUE_DRIVER_OPTIONS = {
|
|
20
|
+
mergeAuthorized: false,
|
|
21
|
+
reDispatchMaxRetries: 1,
|
|
22
|
+
maxAutoFiledIssues: 10,
|
|
23
|
+
env: process.env,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function classifyFailure(error) {
|
|
27
|
+
if (!error) return "unknown";
|
|
28
|
+
const msg = typeof error === "string" ? error : error.message ?? "";
|
|
29
|
+
if (/parse|acceptance.report|unexpected token|JSON|malformed/i.test(msg)) return "acceptance_report_parse_failure";
|
|
30
|
+
if (/round.cap|max.*round|review.*limit/i.test(msg)) return "round_cap_reached";
|
|
31
|
+
if (/timeout|timed.out|watch.*expired/i.test(msg)) return "timeout";
|
|
32
|
+
if (/blocked|human.*comment|needs.*decision|needs.*user/i.test(msg)) return "blocked_needs_user_decision";
|
|
33
|
+
if (/ci.*fail|check.*fail|build.*fail|test.*fail/i.test(msg)) return "ci_failure";
|
|
34
|
+
return "unknown";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isRecoverable(failureKind) {
|
|
38
|
+
return RECOVERABLE_FAILURES.has(failureKind);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function doTransition(entry, to, queue, repoRoot, opts, metadata) {
|
|
42
|
+
transitionEntry(entry, to, metadata);
|
|
43
|
+
await writeQueue(repoRoot, queue);
|
|
44
|
+
if (opts.onTransition) opts.onTransition(to, entry, queue);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Run the queue sequentially. Returns { ok, results, queue }.
|
|
49
|
+
* ok is true only when every entry succeeded; blocked entries count as failure.
|
|
50
|
+
*/
|
|
51
|
+
export async function runQueue(repoRoot, repo, options = {}) {
|
|
52
|
+
const opts = { ...DEFAULT_QUEUE_DRIVER_OPTIONS, ...options };
|
|
53
|
+
const queue = await readQueue(repoRoot);
|
|
54
|
+
|
|
55
|
+
// Optional board-aware ordering: fetch Next Up order before processing.
|
|
56
|
+
// Fail-open: if the board is unreachable, orderHint stays empty and the
|
|
57
|
+
// driver falls back to the existing queue order.
|
|
58
|
+
const ordering = opts.useBoardOrdering !== false && !allDone(queue)
|
|
59
|
+
? await resolveNextUpOrder(repo, repoRoot, opts.env ?? process.env, opts.queueBoardSyncDependencies ?? {})
|
|
60
|
+
: { ok: true, order: [], reason: "board ordering disabled or queue idle" };
|
|
61
|
+
const orderHint = ordering.ok ? ordering.order : [];
|
|
62
|
+
|
|
63
|
+
let autoFiledCount = 0;
|
|
64
|
+
const results = [];
|
|
65
|
+
let incomplete = false;
|
|
66
|
+
|
|
67
|
+
while (!allDone(queue)) {
|
|
68
|
+
const entry = nextReadyEntry(queue, opts.reDispatchMaxRetries, orderHint);
|
|
69
|
+
if (!entry) {
|
|
70
|
+
const remaining = queue.entries.filter(
|
|
71
|
+
(e) => e.status !== "done" && e.status !== "blocked" && e.status !== "failed"
|
|
72
|
+
);
|
|
73
|
+
if (remaining.length > 0) {
|
|
74
|
+
incomplete = true;
|
|
75
|
+
results.push({
|
|
76
|
+
target: null, ok: false,
|
|
77
|
+
error: `Queue incomplete: ${remaining.length} entries blocked by unmet dependencies`,
|
|
78
|
+
pendingTargets: remaining.map((e) => e.target),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const wasFailed = entry.status === "failed";
|
|
85
|
+
if (wasFailed) {
|
|
86
|
+
entry.retryCount = (entry.retryCount ?? 0) + 1;
|
|
87
|
+
await doTransition(entry, "queued", queue, repoRoot, opts);
|
|
88
|
+
}
|
|
89
|
+
await doTransition(entry, "running", queue, repoRoot, opts);
|
|
90
|
+
|
|
91
|
+
const boardSync = [];
|
|
92
|
+
const boardSyncDeps = opts.queueBoardSyncDependencies ?? {};
|
|
93
|
+
const recordBoardSync = async (promise) => {
|
|
94
|
+
const r = await promise;
|
|
95
|
+
boardSync.push(r);
|
|
96
|
+
return r;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
await recordBoardSync(syncBoardStatus(
|
|
100
|
+
repo,
|
|
101
|
+
repoRoot,
|
|
102
|
+
entry.target,
|
|
103
|
+
"In Progress",
|
|
104
|
+
opts.env ?? process.env,
|
|
105
|
+
boardSyncDeps,
|
|
106
|
+
));
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const entryResult = opts.runEntry
|
|
110
|
+
? await opts.runEntry(entry, repo, opts)
|
|
111
|
+
: { ok: true, pr: null };
|
|
112
|
+
|
|
113
|
+
if (entryResult.ok) {
|
|
114
|
+
if (entryResult.pr) {
|
|
115
|
+
await doTransition(entry, "waiting_review", queue, repoRoot, opts, { pr: entryResult.pr });
|
|
116
|
+
await doTransition(entry, "gates_passing", queue, repoRoot, opts);
|
|
117
|
+
if (opts.mergeAuthorized) {
|
|
118
|
+
await doTransition(entry, "merging", queue, repoRoot, opts);
|
|
119
|
+
await doTransition(entry, "done", queue, repoRoot, opts, { retrospectiveWritten: true });
|
|
120
|
+
await recordBoardSync(syncBoardStatus(repo, repoRoot, entry.target, "Done", opts.env ?? process.env, boardSyncDeps));
|
|
121
|
+
}
|
|
122
|
+
// else: stays at gates_passing for future merge run
|
|
123
|
+
} else {
|
|
124
|
+
await doTransition(entry, "done", queue, repoRoot, opts);
|
|
125
|
+
await recordBoardSync(syncBoardStatus(repo, repoRoot, entry.target, "Done", opts.env ?? process.env, boardSyncDeps));
|
|
126
|
+
}
|
|
127
|
+
results.push({ target: entry.target, ok: true, entry: snapshotEntry(entry), boardSync });
|
|
128
|
+
} else {
|
|
129
|
+
throw new Error(entryResult.error || "Entry failed");
|
|
130
|
+
}
|
|
131
|
+
} catch (err) {
|
|
132
|
+
const fallbackColumn = nonSuccessBoardColumn(repoRoot, "Backlog");
|
|
133
|
+
await recordBoardSync(syncBoardStatus(repo, repoRoot, entry.target, fallbackColumn, opts.env ?? process.env, boardSyncDeps));
|
|
134
|
+
|
|
135
|
+
const failureKind = classifyFailure(err);
|
|
136
|
+
const recoverable = isRecoverable(failureKind);
|
|
137
|
+
|
|
138
|
+
if (recoverable && (entry.retryCount ?? 0) < opts.reDispatchMaxRetries) {
|
|
139
|
+
await doTransition(entry, "failed", queue, repoRoot, opts, {
|
|
140
|
+
failureReason: err.message, failureKind,
|
|
141
|
+
});
|
|
142
|
+
results.push({
|
|
143
|
+
target: entry.target, ok: false, recoverable: true,
|
|
144
|
+
failureKind, entry: snapshotEntry(entry), boardSync,
|
|
145
|
+
});
|
|
146
|
+
} else {
|
|
147
|
+
await doTransition(entry, "blocked", queue, repoRoot, opts, {
|
|
148
|
+
failureReason: err.message, failureKind,
|
|
149
|
+
});
|
|
150
|
+
if (autoFiledCount < opts.maxAutoFiledIssues && failureKind !== "blocked_needs_user_decision") {
|
|
151
|
+
appendBugIssue(queue, entry.target + 1000, entry.target);
|
|
152
|
+
autoFiledCount++;
|
|
153
|
+
}
|
|
154
|
+
results.push({
|
|
155
|
+
target: entry.target, ok: false, recoverable: false,
|
|
156
|
+
failureKind, entry: snapshotEntry(entry), boardSync,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const allOk = results.every((r) => r.ok !== false) && !incomplete;
|
|
163
|
+
return { ok: allOk, results, queue, ordering };
|
|
164
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parallel execution helper for queue mode.
|
|
3
|
+
*
|
|
4
|
+
* When --parallel is set:
|
|
5
|
+
* 1. Compute file-touch overlap matrix for all queued items
|
|
6
|
+
* 2. Items with no overlap → dispatch in parallel (up to concurrency cap)
|
|
7
|
+
* 3. Items with overlapping files → serialize within overlap groups
|
|
8
|
+
* 4. Dependency chains (--after) → always serialize within chain
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { topologicalOrder } from "./queue-state.mjs";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Compute overlap groups from a set of entries and their file lists.
|
|
15
|
+
*
|
|
16
|
+
* @param {Array<{target: number, files: string[]}>} entryFiles
|
|
17
|
+
* @returns {Array<Array<number>>} Array of groups (each group is serialized internally)
|
|
18
|
+
*/
|
|
19
|
+
export function computeOverlapGroups(entryFiles) {
|
|
20
|
+
const n = entryFiles.length;
|
|
21
|
+
if (n === 0) return [];
|
|
22
|
+
|
|
23
|
+
// Build overlap matrix
|
|
24
|
+
const overlap = Array.from({ length: n }, () => Array(n).fill(false));
|
|
25
|
+
for (let i = 0; i < n; i++) {
|
|
26
|
+
for (let j = i + 1; j < n; j++) {
|
|
27
|
+
const filesI = new Set(entryFiles[i].files);
|
|
28
|
+
const filesJ = entryFiles[j].files;
|
|
29
|
+
const hasOverlap = filesJ.some((f) => filesI.has(f));
|
|
30
|
+
overlap[i][j] = hasOverlap;
|
|
31
|
+
overlap[j][i] = hasOverlap;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Union-find to group overlapping entries
|
|
36
|
+
const parent = Array.from({ length: n }, (_, i) => i);
|
|
37
|
+
function find(x) {
|
|
38
|
+
while (parent[x] !== x) {
|
|
39
|
+
parent[x] = parent[parent[x]];
|
|
40
|
+
x = parent[x];
|
|
41
|
+
}
|
|
42
|
+
return x;
|
|
43
|
+
}
|
|
44
|
+
function union(a, b) {
|
|
45
|
+
const ra = find(a), rb = find(b);
|
|
46
|
+
if (ra !== rb) parent[rb] = ra;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < n; i++) {
|
|
50
|
+
for (let j = i + 1; j < n; j++) {
|
|
51
|
+
if (overlap[i][j]) union(i, j);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Collect groups preserving original order
|
|
56
|
+
const groups = new Map();
|
|
57
|
+
const groupOrder = [];
|
|
58
|
+
for (let i = 0; i < n; i++) {
|
|
59
|
+
const root = find(i);
|
|
60
|
+
if (!groups.has(root)) {
|
|
61
|
+
groups.set(root, []);
|
|
62
|
+
groupOrder.push(root);
|
|
63
|
+
}
|
|
64
|
+
groups.get(root).push(entryFiles[i].target);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return groupOrder.map((root) => groups.get(root));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Given overlap groups, compute which groups can run in parallel.
|
|
72
|
+
* Returns an array of "waves" where each wave contains groups that can run concurrently.
|
|
73
|
+
*
|
|
74
|
+
* @param {Array<Array<number>>} groups
|
|
75
|
+
* @param {number} maxParallel - Concurrency cap
|
|
76
|
+
* @returns {Array<Array<Array<number>>>} Array of waves
|
|
77
|
+
*/
|
|
78
|
+
export function scheduleParallelWaves(groups, maxParallel = 3) {
|
|
79
|
+
if (groups.length === 0) return [];
|
|
80
|
+
|
|
81
|
+
const waves = [];
|
|
82
|
+
let i = 0;
|
|
83
|
+
|
|
84
|
+
while (i < groups.length) {
|
|
85
|
+
const wave = [];
|
|
86
|
+
while (wave.length < maxParallel && i < groups.length) {
|
|
87
|
+
wave.push(groups[i]);
|
|
88
|
+
i++;
|
|
89
|
+
}
|
|
90
|
+
waves.push(wave);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return waves;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Compute the full parallel schedule for a queue.
|
|
98
|
+
*
|
|
99
|
+
* @param {Array<{target: number, files: string[], dependsOn: number[]}>} entries
|
|
100
|
+
* @param {number} maxParallel
|
|
101
|
+
* @returns {{waves: Array<Array<Array<number>>>, groups: Array<Array<number>>}}
|
|
102
|
+
*/
|
|
103
|
+
export function computeParallelSchedule(entries, maxParallel = 3) {
|
|
104
|
+
// First, topological sort for dependency ordering
|
|
105
|
+
const ordered = topologicalOrder(
|
|
106
|
+
entries.map((e) => ({
|
|
107
|
+
target: e.target,
|
|
108
|
+
dependsOn: e.dependsOn,
|
|
109
|
+
}))
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// Map back to full entries in topological order
|
|
113
|
+
const orderedEntries = ordered.map((o) =>
|
|
114
|
+
entries.find((e) => e.target === o.target)
|
|
115
|
+
).filter(Boolean);
|
|
116
|
+
|
|
117
|
+
// Build depChains: for each entry, collect ALL transitive ancestors
|
|
118
|
+
const allAncestors = new Map();
|
|
119
|
+
function getAncestors(target, visited = new Set()) {
|
|
120
|
+
if (allAncestors.has(target)) return allAncestors.get(target);
|
|
121
|
+
if (visited.has(target)) return new Set();
|
|
122
|
+
visited.add(target);
|
|
123
|
+
const entry = orderedEntries.find((e) => e.target === target);
|
|
124
|
+
const ancestors = new Set();
|
|
125
|
+
for (const dep of entry?.dependsOn || []) {
|
|
126
|
+
ancestors.add(dep);
|
|
127
|
+
for (const ancestor of getAncestors(dep, new Set(visited))) {
|
|
128
|
+
ancestors.add(ancestor);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
allAncestors.set(target, ancestors);
|
|
132
|
+
return ancestors;
|
|
133
|
+
}
|
|
134
|
+
for (const entry of orderedEntries) {
|
|
135
|
+
getAncestors(entry.target);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Compute file overlap groups
|
|
139
|
+
const entryFiles = orderedEntries.map((e) => ({
|
|
140
|
+
target: e.target,
|
|
141
|
+
files: e.files || [],
|
|
142
|
+
dependsOn: e.dependsOn || [],
|
|
143
|
+
}));
|
|
144
|
+
|
|
145
|
+
const groups = computeOverlapGroups(entryFiles);
|
|
146
|
+
|
|
147
|
+
// Merge dependency chains into overlap groups: for each group, add all
|
|
148
|
+
// ancestors of every member so dependency chains always serialize together
|
|
149
|
+
const expandedGroups = groups.map((group) => {
|
|
150
|
+
const expanded = new Set(group);
|
|
151
|
+
for (const target of group) {
|
|
152
|
+
const ancestors = allAncestors.get(target);
|
|
153
|
+
if (ancestors) {
|
|
154
|
+
for (const ancestor of ancestors) {
|
|
155
|
+
expanded.add(ancestor);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return [...expanded];
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Deduplicate: if a target appears in multiple groups, merge those groups
|
|
163
|
+
const merged = [];
|
|
164
|
+
for (const group of expandedGroups) {
|
|
165
|
+
// Check if any member is already in a previously emitted group
|
|
166
|
+
let mergedWith = -1;
|
|
167
|
+
for (let i = 0; i < merged.length; i++) {
|
|
168
|
+
for (const target of group) {
|
|
169
|
+
if (merged[i].has(target)) {
|
|
170
|
+
mergedWith = i;
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (mergedWith >= 0) break;
|
|
175
|
+
}
|
|
176
|
+
if (mergedWith >= 0) {
|
|
177
|
+
for (const target of group) {
|
|
178
|
+
merged[mergedWith].add(target);
|
|
179
|
+
}
|
|
180
|
+
} else {
|
|
181
|
+
merged.push(new Set(group));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const dedupedGroups = merged.map((s) => [...s]);
|
|
186
|
+
|
|
187
|
+
const waves = scheduleParallelWaves(dedupedGroups, maxParallel);
|
|
188
|
+
|
|
189
|
+
return { waves, groups: dedupedGroups };
|
|
190
|
+
}
|