@juzser/blacksmith 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/{auditor.md → bs-auditor.md} +8 -5
- package/.claude/agents/{coder.md → bs-coder.md} +8 -7
- package/.claude/agents/{grader.md → bs-grader.md} +16 -9
- package/.claude/agents/{merger.md → bs-merger.md} +4 -4
- package/.claude/agents/{planner.md → bs-planner.md} +20 -6
- package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
- package/.claude/agents/{reviewer.md → bs-reviewer.md} +16 -5
- package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
- package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +9 -6
- package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +18 -10
- package/.claude/agents/{tester.md → bs-tester.md} +40 -12
- package/.claude/agents/bs-uiux.md +257 -0
- package/.claude/agents/{verifier.md → bs-verifier.md} +7 -4
- package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
- package/.claude/skills/bs/SKILL.md +99 -21
- package/.claude/skills/bs/audit.md +45 -31
- package/.claude/skills/bs/dispatch.md +133 -47
- package/.claude/skills/bs/lessons.md +28 -14
- package/.claude/skills/bs/mcp.md +4 -4
- package/.claude/skills/bs/new.md +3 -3
- package/.claude/skills/bs/plan.md +57 -16
- package/.claude/skills/bs/report.md +11 -11
- package/.claude/skills/bs/run.md +190 -52
- package/.claude/skills/bs/status.md +14 -7
- package/.claude/skills/bs/ui.md +21 -5
- package/.claude/skills/bs/waivers.md +78 -2
- package/.claude/skills/bs/wave.md +143 -50
- package/README.md +53 -32
- package/factory/orchestrator/dist/agentNames.js +43 -0
- package/factory/orchestrator/dist/agents-registry.js +11 -4
- package/factory/orchestrator/dist/agentsSync.js +70 -13
- package/factory/orchestrator/dist/artifacts.js +31 -0
- package/factory/orchestrator/dist/attribution.js +4 -0
- package/factory/orchestrator/dist/audit.js +245 -20
- package/factory/orchestrator/dist/autonomy.js +6 -2
- package/factory/orchestrator/dist/budgetAlarm.js +132 -37
- package/factory/orchestrator/dist/budgets.js +302 -84
- package/factory/orchestrator/dist/claims.js +9 -19
- package/factory/orchestrator/dist/cli.js +1087 -116
- package/factory/orchestrator/dist/cliName.js +27 -0
- package/factory/orchestrator/dist/crossFinding.js +20 -0
- package/factory/orchestrator/dist/crosscheck.js +136 -8
- package/factory/orchestrator/dist/daemon.js +352 -67
- package/factory/orchestrator/dist/db/projector.js +327 -54
- package/factory/orchestrator/dist/db/queries.js +1831 -94
- package/factory/orchestrator/dist/db/schema.js +57 -4
- package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
- package/factory/orchestrator/dist/delegation.js +86 -19
- package/factory/orchestrator/dist/diffstat.js +15 -8
- package/factory/orchestrator/dist/dispatchLint.js +108 -0
- package/factory/orchestrator/dist/effort.js +14 -2
- package/factory/orchestrator/dist/effortTiers.js +13 -0
- package/factory/orchestrator/dist/env.js +26 -0
- package/factory/orchestrator/dist/epic.js +494 -42
- package/factory/orchestrator/dist/epicBudget.js +93 -0
- package/factory/orchestrator/dist/epicWidth.js +22 -5
- package/factory/orchestrator/dist/errorIssues.js +182 -11
- package/factory/orchestrator/dist/escalation.js +2 -2
- package/factory/orchestrator/dist/events.js +164 -14
- package/factory/orchestrator/dist/feedback.js +169 -0
- package/factory/orchestrator/dist/findingContext.js +26 -0
- package/factory/orchestrator/dist/findings.js +108 -14
- package/factory/orchestrator/dist/gate.js +120 -10
- package/factory/orchestrator/dist/gh.js +19 -2
- package/factory/orchestrator/dist/git.js +20 -2
- package/factory/orchestrator/dist/goalCheck.js +2 -2
- package/factory/orchestrator/dist/harness.js +25 -17
- package/factory/orchestrator/dist/hookDecision.js +411 -39
- package/factory/orchestrator/dist/integration.js +20 -8
- package/factory/orchestrator/dist/issueReporter.js +81 -16
- package/factory/orchestrator/dist/judgePreflight.js +25 -4
- package/factory/orchestrator/dist/judgeRoles.js +47 -0
- package/factory/orchestrator/dist/judgeStopHook.js +239 -0
- package/factory/orchestrator/dist/judges.js +419 -21
- package/factory/orchestrator/dist/lessons.js +190 -11
- package/factory/orchestrator/dist/logCache.js +291 -0
- package/factory/orchestrator/dist/mcp.js +6 -6
- package/factory/orchestrator/dist/paths.js +20 -7
- package/factory/orchestrator/dist/plan.js +399 -33
- package/factory/orchestrator/dist/planQuorum.js +5 -2
- package/factory/orchestrator/dist/policy.js +1275 -30
- package/factory/orchestrator/dist/projectName.js +25 -0
- package/factory/orchestrator/dist/projects.js +12 -1
- package/factory/orchestrator/dist/promptCapture.js +306 -0
- package/factory/orchestrator/dist/promptHook.js +33 -0
- package/factory/orchestrator/dist/promptImport.js +281 -0
- package/factory/orchestrator/dist/prompts.js +13 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
- package/factory/orchestrator/dist/providers/types.js +1 -1
- package/factory/orchestrator/dist/queue.js +471 -30
- package/factory/orchestrator/dist/quorum.js +18 -5
- package/factory/orchestrator/dist/results.js +25 -7
- package/factory/orchestrator/dist/roadmap.js +10 -5
- package/factory/orchestrator/dist/run-cli.js +15 -10
- package/factory/orchestrator/dist/runner.js +2 -2
- package/factory/orchestrator/dist/scheduler.js +3 -3
- package/factory/orchestrator/dist/schemas.js +56 -8
- package/factory/orchestrator/dist/severity.js +8 -4
- package/factory/orchestrator/dist/spec.js +246 -12
- package/factory/orchestrator/dist/specChange.js +13 -9
- package/factory/orchestrator/dist/stack.js +6 -6
- package/factory/orchestrator/dist/storeRoot.js +90 -0
- package/factory/orchestrator/dist/symbols.js +261 -6
- package/factory/orchestrator/dist/taskEvents.js +192 -31
- package/factory/orchestrator/dist/taskStatus.js +14 -0
- package/factory/orchestrator/dist/taxonomy.js +11 -0
- package/factory/orchestrator/dist/testerAudit.js +31 -12
- package/factory/orchestrator/dist/testgate.js +20 -16
- package/factory/orchestrator/dist/uiuxGate.js +83 -0
- package/factory/orchestrator/dist/usage.js +113 -48
- package/factory/orchestrator/dist/waivers.js +99 -12
- package/factory/orchestrator/dist/waveBudget.js +2 -0
- package/factory/orchestrator/dist/waveConcurrency.js +143 -7
- package/factory/orchestrator/dist/waveSchedule.js +84 -0
- package/factory/orchestrator/dist/workroot.js +1 -1
- package/factory/orchestrator/dist/worktree.js +76 -21
- package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
- package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
- package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
- package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
- package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
- package/factory/policies/budgets.yml +123 -33
- package/factory/policies/crosscheck.yml +51 -13
- package/factory/policies/delegation.yml +3 -3
- package/factory/policies/effort.yml +18 -10
- package/factory/policies/guardrails.yml +8 -6
- package/factory/policies/harness.yml +5 -5
- package/factory/policies/lessons.md +53 -2
- package/factory/policies/scheduler.yml +4 -4
- package/factory/policies/sensitive-paths.yml +1 -1
- package/factory/policies/severity.yml +13 -1
- package/factory/policies/stack.yml +6 -6
- package/factory/policies/taxonomy.yml +9 -3
- package/factory/policies/worktree.yml +15 -9
- package/factory/specs/roadmap.md +27 -3
- package/factory/specs/schema/event.schema.json +8 -1
- package/factory/specs/schema/result.schema.json +21 -8
- package/factory/specs/schema/task-spec.schema.json +12 -0
- package/package.json +16 -12
- package/.claude/agents/uiux.md +0 -133
|
@@ -22,6 +22,19 @@ import { appendEvent } from './events.js';
|
|
|
22
22
|
*/
|
|
23
23
|
export class PromptError extends SmithError {
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* True when `text` is harness-injected rather than something the operator
|
|
27
|
+
* typed: blank, or a record opening with one of the CLI's own tags (reminders,
|
|
28
|
+
* local and shell command echoes, task notifications, memory input, and a
|
|
29
|
+
* subagent's hand-back, which is model output delivered as a user turn). The
|
|
30
|
+
* one list, shared by the dashboard's reader and any writer of operator
|
|
31
|
+
* prompts.
|
|
32
|
+
*/
|
|
33
|
+
export function isHarnessText(text) {
|
|
34
|
+
const trimmed = text.trim();
|
|
35
|
+
return (trimmed === '' ||
|
|
36
|
+
/^<(system-reminder|local-command|command-message|bash-input|bash-stdout|bash-stderr|task-notification|user-memory-input|agent-message)/.test(trimmed));
|
|
37
|
+
}
|
|
25
38
|
/**
|
|
26
39
|
* Append one `user_prompt`, returning the stored event.
|
|
27
40
|
*
|
|
@@ -93,7 +93,7 @@ export function extractBalancedJson(text) {
|
|
|
93
93
|
/**
|
|
94
94
|
* Schemas that describe ONE item a `kind: review` judge returns MANY of. Both
|
|
95
95
|
* of them: `finding` is the stored record (the native reviewer's contract,
|
|
96
|
-
* .claude/agents/reviewer.md), `finding-evidence` is the judge's half of it —
|
|
96
|
+
* .claude/agents/bs-reviewer.md), `finding-evidence` is the judge's half of it —
|
|
97
97
|
* the same evidence without the six identity fields the orchestrator owns.
|
|
98
98
|
* A set, not a comparison, because the second entry was added a year after the
|
|
99
99
|
* first and the `=== 'finding'` it replaced would have silently validated an
|
|
@@ -136,7 +136,7 @@ function describeShape(parsed) {
|
|
|
136
136
|
/**
|
|
137
137
|
* Extract + schema-validate a judge's raw text response. `finding.schema.json`
|
|
138
138
|
* describes ONE finding, but a `kind: review` judge's contract
|
|
139
|
-
* (.claude/agents/reviewer.md) returns an ARRAY of findings — so against an
|
|
139
|
+
* (.claude/agents/bs-reviewer.md) returns an ARRAY of findings — so against an
|
|
140
140
|
* ARRAY_VALUED_SCHEMAS name the top-level value must be that array, bare or
|
|
141
141
|
* wrapped in one single-key object (see `unwrapArray`), and validates
|
|
142
142
|
* element-wise; the value returned is the array itself, unwrapped. A bare
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Provider-agnostic judge contract (architecture §6): "any model that can
|
|
2
2
|
// honor the I/O contract can serve" — a JudgeRequest is a plain diff+spec+
|
|
3
3
|
// prior-findings prompt already assembled by the caller from
|
|
4
|
-
// .claude/agents/{reviewer,verifier}.md's own contract, plus a schema
|
|
4
|
+
// .claude/agents/bs-{reviewer,verifier}.md's own contract, plus a schema
|
|
5
5
|
// name the extracted response is validated against. Transports never see
|
|
6
6
|
// more than `prompt` — the trust boundary (architecture §6 "External
|
|
7
7
|
// providers judge; they never gain write access... their findings are
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import {
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { claimsOverlap, collectCommittedChanges, touchesSerializeAlways } from './claims.js';
|
|
3
5
|
import { certifyCommit, UNCOMMITTED_WORK_CODE } from './commit.js';
|
|
4
6
|
import { SmithError } from './errors.js';
|
|
5
|
-
import {
|
|
7
|
+
import { validateEventEnvelope } from './events.js';
|
|
8
|
+
import { runGit, runGitRaw, writeMergeTree } from './git.js';
|
|
6
9
|
import { topoSort } from './graph.js';
|
|
7
10
|
import { buildSymbolGraph, collectSources } from './symbols.js';
|
|
8
11
|
import { emitTaskBlocked, emitWaveMerged } from './taskEvents.js';
|
|
9
12
|
import { projectCommandEnv } from './testgate.js';
|
|
10
13
|
import { renderSelectedTestCmd, selectTests } from './testSelect.js';
|
|
11
|
-
import { integrationBranchName } from './worktree.js';
|
|
14
|
+
import { epicBranchPrefix, integrationBranchName } from './worktree.js';
|
|
12
15
|
export class QueueError extends SmithError {
|
|
13
16
|
}
|
|
14
17
|
/**
|
|
@@ -23,6 +26,67 @@ export function admit(tasks, edges = []) {
|
|
|
23
26
|
}
|
|
24
27
|
return result.order;
|
|
25
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Split an admitted order into maximal runs of consecutive tasks a batch
|
|
31
|
+
* step can stack into one candidate and test once (roadmap `merge-lanes`;
|
|
32
|
+
* the batching idea is Zuul's shared-queue gating and Mergify's batch/bisect,
|
|
33
|
+
* https://zuul-ci.org/docs/zuul/latest/gating.html,
|
|
34
|
+
* https://docs.mergify.com/merge-queue/batches/).
|
|
35
|
+
*
|
|
36
|
+
* Greedy, left to right, one open group at a time: a task joins the open
|
|
37
|
+
* group when its claims are disjoint from every member already in it and it
|
|
38
|
+
* does not depend_on one of them; otherwise the open group closes and the
|
|
39
|
+
* task starts the next one. Three conditions never even get a chance to
|
|
40
|
+
* join a group — they close whatever is open and are appended as a group of
|
|
41
|
+
* one, closed immediately, so a task after them starts fresh rather than
|
|
42
|
+
* silently inheriting their neighbours:
|
|
43
|
+
* - no claims at all (nothing to prove disjoint, so nothing to batch with);
|
|
44
|
+
* - a claim a serialize-always glob covers (worktree.yml already refuses to
|
|
45
|
+
* run these concurrently; batching would silently defeat that refusal);
|
|
46
|
+
* - depends_on a task that is anywhere in the walk before it — conservative
|
|
47
|
+
* on purpose: batching a dependency chain has not been proven safe here,
|
|
48
|
+
* even though the chain would still test together inside one group.
|
|
49
|
+
*/
|
|
50
|
+
export function groupForBatch(tasks, edges = [], serializeAlwaysGlobs = []) {
|
|
51
|
+
const dependsOn = new Map();
|
|
52
|
+
for (const edge of edges) {
|
|
53
|
+
if (!dependsOn.has(edge.task))
|
|
54
|
+
dependsOn.set(edge.task, new Set());
|
|
55
|
+
dependsOn.get(edge.task).add(edge.dependsOn);
|
|
56
|
+
}
|
|
57
|
+
const byId = new Map(tasks.map((t) => [t.taskId, t]));
|
|
58
|
+
const groups = [];
|
|
59
|
+
let current = [];
|
|
60
|
+
for (const task of tasks) {
|
|
61
|
+
const claims = task.claims ?? [];
|
|
62
|
+
const hasClaims = claims.length > 0;
|
|
63
|
+
const isSerializeAlways = hasClaims && touchesSerializeAlways({ claims }, serializeAlwaysGlobs).length > 0;
|
|
64
|
+
if (!hasClaims || isSerializeAlways) {
|
|
65
|
+
if (current.length > 0)
|
|
66
|
+
groups.push(current);
|
|
67
|
+
groups.push([task.taskId]);
|
|
68
|
+
current = [];
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const deps = dependsOn.get(task.taskId);
|
|
72
|
+
const dependsOnGroupMember = deps !== undefined && current.some((id) => deps.has(id));
|
|
73
|
+
const overlapsGroupMember = current.some((id) => {
|
|
74
|
+
const member = byId.get(id);
|
|
75
|
+
const memberClaims = member?.claims ?? [];
|
|
76
|
+
return (memberClaims.length > 0 && claimsOverlap({ claims }, { claims: memberClaims }).overlaps);
|
|
77
|
+
});
|
|
78
|
+
if (dependsOnGroupMember || overlapsGroupMember) {
|
|
79
|
+
groups.push(current);
|
|
80
|
+
current = [task.taskId];
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
current.push(task.taskId);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (current.length > 0)
|
|
87
|
+
groups.push(current);
|
|
88
|
+
return groups;
|
|
89
|
+
}
|
|
26
90
|
const OUTPUT_TAIL_LINES = 50;
|
|
27
91
|
function tailLines(text, n) {
|
|
28
92
|
const lines = text.split('\n');
|
|
@@ -36,34 +100,21 @@ function conflictingFiles(worktreeDir) {
|
|
|
36
100
|
.filter((f) => f.length > 0);
|
|
37
101
|
}
|
|
38
102
|
/**
|
|
39
|
-
*
|
|
40
|
-
* current
|
|
41
|
-
*
|
|
42
|
-
* and
|
|
43
|
-
*
|
|
103
|
+
* Certify there is a commit to merge (D-30/P9-8) and rebase the task's
|
|
104
|
+
* branch onto the current integration head. Returns the blocked outcome when
|
|
105
|
+
* either refuses, or null when the branch is ready to test — shared by
|
|
106
|
+
* `step` and the batch path (`runGroup`) so a task that cannot even be
|
|
107
|
+
* rebased fails exactly the same way whether it is running alone or as part
|
|
108
|
+
* of a group.
|
|
44
109
|
*/
|
|
45
|
-
|
|
46
|
-
const integrationBranch = integrationBranchName(opts.epic);
|
|
47
|
-
const events = opts.events;
|
|
48
|
-
/**
|
|
49
|
-
* Log the outcome the queue just observed — a refusal it has already decided
|
|
50
|
-
* on, or a failure it has already seen. Never a prediction about work still
|
|
51
|
-
* ahead of it: an event written before the merge that the merge then
|
|
52
|
-
* falsifies is exactly the phantom row D-46 is about.
|
|
53
|
-
*/
|
|
54
|
-
const logBlocked = async (error, detail) => {
|
|
55
|
-
if (!events)
|
|
56
|
-
return;
|
|
57
|
-
const { ctx, ...opt } = events;
|
|
58
|
-
await emitTaskBlocked(task.taskId, { error, severity: 'S2-major', detail }, ctx, opt);
|
|
59
|
-
};
|
|
110
|
+
async function certifyAndRebase(task, integrationBranch, logBlocked) {
|
|
60
111
|
// Before the rebase, because a rebase, a test run and a merge all "succeed"
|
|
61
112
|
// against a branch that carries nothing: certify that there is a commit to
|
|
62
113
|
// merge at all (D-30/P9-8). A task that merges nothing is a bug, not a pass.
|
|
63
114
|
const commit = certifyCommit(task.worktreeDir, { baseRef: integrationBranch });
|
|
64
115
|
if (!commit.certified) {
|
|
65
116
|
const reason = commit.reason;
|
|
66
|
-
await logBlocked(reason === 'not-a-git-worktree' ? 'execution.env-failure' : UNCOMMITTED_WORK_CODE, commit.dirty.length > 0
|
|
117
|
+
await logBlocked(task.taskId, reason === 'not-a-git-worktree' ? 'execution.env-failure' : UNCOMMITTED_WORK_CODE, commit.dirty.length > 0
|
|
67
118
|
? `${task.branch} has uncommitted work: ${commit.dirty.join(', ')}`
|
|
68
119
|
: `${task.branch} has nothing to merge into ${integrationBranch} (${reason}).`);
|
|
69
120
|
return { outcome: 'nothing-to-merge', taskId: task.taskId, reason, dirty: commit.dirty };
|
|
@@ -77,14 +128,50 @@ export async function step(task, opts) {
|
|
|
77
128
|
catch {
|
|
78
129
|
const files = conflictingFiles(task.worktreeDir);
|
|
79
130
|
execFileSync('git', ['rebase', '--abort'], { cwd: task.worktreeDir, stdio: 'pipe' });
|
|
80
|
-
await logBlocked('integration.merge-conflict-textual', `Rebase onto ${integrationBranch} conflicted in: ${files.join(', ')}`);
|
|
131
|
+
await logBlocked(task.taskId, 'integration.merge-conflict-textual', `Rebase onto ${integrationBranch} conflicted in: ${files.join(', ')}`);
|
|
81
132
|
return { outcome: 'rebase-conflict', taskId: task.taskId, conflictingFiles: files };
|
|
82
133
|
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Serial merge-queue admission step for one task: rebase its branch onto the
|
|
138
|
+
* current epic integration head, run the epic's test command, then
|
|
139
|
+
* merge --no-ff into the integration branch. Never auto-resolves a conflict
|
|
140
|
+
* and never touches main — the caller decides what happens next on a
|
|
141
|
+
* non-merged outcome.
|
|
142
|
+
*/
|
|
143
|
+
export async function step(task, opts) {
|
|
144
|
+
const integrationBranch = integrationBranchName(opts.epic, epicBranchPrefix(opts.projectDir, opts.epic));
|
|
145
|
+
const events = opts.events;
|
|
146
|
+
// Refused before certifyAndRebase's real `git rebase` or the merge below
|
|
147
|
+
// ever runs: both mutate git for real, and used to be discovered only by
|
|
148
|
+
// the append at the far end (emitWaveMerged/emitTaskBlocked). A merge that
|
|
149
|
+
// already landed by the time that append throws is a merge the log cannot
|
|
150
|
+
// see — a real incident, not a hypothetical one.
|
|
151
|
+
if (events) {
|
|
152
|
+
const { ctx, ...opt } = events;
|
|
153
|
+
await validateEventEnvelope({ session_id: ctx.sessionId, event_type: 'wave-merged', causal_parent: ctx.causalParent }, opt);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Log the outcome the queue just observed — a refusal it has already decided
|
|
157
|
+
* on, or a failure it has already seen. Never a prediction about work still
|
|
158
|
+
* ahead of it: an event written before the merge that the merge then
|
|
159
|
+
* falsifies is exactly the phantom row D-46 is about.
|
|
160
|
+
*/
|
|
161
|
+
const logBlocked = async (taskId, error, detail) => {
|
|
162
|
+
if (!events)
|
|
163
|
+
return;
|
|
164
|
+
const { ctx, ...opt } = events;
|
|
165
|
+
await emitTaskBlocked(taskId, { error, severity: 'S2-major', detail }, ctx, opt);
|
|
166
|
+
};
|
|
167
|
+
const early = await certifyAndRebase(task, integrationBranch, logBlocked);
|
|
168
|
+
if (early)
|
|
169
|
+
return early;
|
|
83
170
|
const plan = planTestRun(task.worktreeDir, opts);
|
|
84
171
|
const testOutcome = runTestCmd(plan.cmd, task.worktreeDir);
|
|
85
172
|
if (!testOutcome.passed) {
|
|
86
173
|
const outputTail = tailLines(testOutcome.output, OUTPUT_TAIL_LINES);
|
|
87
|
-
await logBlocked('execution.test-failure', outputTail);
|
|
174
|
+
await logBlocked(task.taskId, 'execution.test-failure', outputTail);
|
|
88
175
|
return {
|
|
89
176
|
outcome: 'tests-failed',
|
|
90
177
|
taskId: task.taskId,
|
|
@@ -92,14 +179,368 @@ export async function step(task, opts) {
|
|
|
92
179
|
...(plan.report ? { tests: plan.report } : {}),
|
|
93
180
|
};
|
|
94
181
|
}
|
|
95
|
-
|
|
96
|
-
|
|
182
|
+
// Merge where the integration branch already lives, or with no working tree
|
|
183
|
+
// at all — never by `git checkout` in the project directory, which moved
|
|
184
|
+
// the operator's own clone off the branch they were using.
|
|
185
|
+
const message = `Merge ${task.taskId} into ${integrationBranch}`;
|
|
186
|
+
const holder = worktreeHolding(opts.projectDir, integrationBranch);
|
|
187
|
+
if (holder === null) {
|
|
188
|
+
mergeWithoutWorktree(opts.projectDir, integrationBranch, task.branch, message);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
// A merge into a worktree with edits in it either stops half-way or folds
|
|
192
|
+
// someone's unfinished work into the merge's checkout; neither is ours.
|
|
193
|
+
const dirty = trackedChanges(holder);
|
|
194
|
+
if (dirty.length > 0) {
|
|
195
|
+
await logBlocked(task.taskId, 'execution.env-failure', `${integrationBranch} is checked out in ${holder}, which has uncommitted changes: ${dirty.join(', ')}`);
|
|
196
|
+
return {
|
|
197
|
+
outcome: 'integration-dirty',
|
|
198
|
+
taskId: task.taskId,
|
|
199
|
+
worktree: holder,
|
|
200
|
+
dirty,
|
|
201
|
+
...(plan.report ? { tests: plan.report } : {}),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
execFileSync('git', ['merge', '--no-ff', task.branch, '-m', message], {
|
|
205
|
+
cwd: holder,
|
|
206
|
+
stdio: 'pipe',
|
|
207
|
+
});
|
|
208
|
+
}
|
|
97
209
|
if (events) {
|
|
98
210
|
const { ctx, ...opt } = events;
|
|
99
|
-
await emitWaveMerged(task.taskId, ctx, opt, mergedFiles(opts.projectDir));
|
|
211
|
+
await emitWaveMerged(task.taskId, ctx, opt, mergedFiles(opts.projectDir, integrationBranch));
|
|
100
212
|
}
|
|
101
213
|
return { outcome: 'merged', taskId: task.taskId, ...(plan.report ? { tests: plan.report } : {}) };
|
|
102
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* The worktree (the main one or any linked one) that has `branch` checked
|
|
217
|
+
* out, or null when none does. git refuses to check a branch out in two
|
|
218
|
+
* worktrees at once, so there is at most one. An entry git marks `prunable`
|
|
219
|
+
* — its directory is gone — holds nothing: there is no checkout to merge in,
|
|
220
|
+
* and moving the branch under it harms nothing. It is not pruned here either;
|
|
221
|
+
* the operator's worktree registrations are theirs (architecture §18 rule 10).
|
|
222
|
+
*/
|
|
223
|
+
function worktreeHolding(projectDir, branch) {
|
|
224
|
+
const ref = `branch refs/heads/${branch}`;
|
|
225
|
+
const records = runGitRaw(projectDir, ['worktree', 'list', '--porcelain', '-z']).split('\0\0');
|
|
226
|
+
for (const record of records) {
|
|
227
|
+
const lines = record.split('\0');
|
|
228
|
+
const dir = lines.find((l) => l.startsWith('worktree '));
|
|
229
|
+
if (dir === undefined || !lines.includes(ref))
|
|
230
|
+
continue;
|
|
231
|
+
if (lines.some((l) => l === 'prunable' || l.startsWith('prunable ')))
|
|
232
|
+
continue;
|
|
233
|
+
return dir.slice('worktree '.length);
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
/** Tracked paths with staged or unstaged changes. Untracked files never block a merge. */
|
|
238
|
+
function trackedChanges(worktreeDir) {
|
|
239
|
+
const fields = runGitRaw(worktreeDir, [
|
|
240
|
+
'status',
|
|
241
|
+
'--porcelain=v1',
|
|
242
|
+
'-z',
|
|
243
|
+
'--untracked-files=no',
|
|
244
|
+
]).split('\0');
|
|
245
|
+
const paths = [];
|
|
246
|
+
for (let i = 0; i < fields.length; i++) {
|
|
247
|
+
const field = fields[i];
|
|
248
|
+
if (field.length < 4)
|
|
249
|
+
continue;
|
|
250
|
+
paths.push(field.slice(3));
|
|
251
|
+
// A rename or copy carries its source path as the next NUL field.
|
|
252
|
+
if (field[0] === 'R' || field[0] === 'C')
|
|
253
|
+
i++;
|
|
254
|
+
}
|
|
255
|
+
return paths;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Merge `source` into `branch` with plumbing alone, for when no worktree has
|
|
259
|
+
* `branch` checked out: `merge-tree --write-tree` builds the merged tree,
|
|
260
|
+
* `commit-tree` makes the two-parent merge commit (author and committer from
|
|
261
|
+
* git config, as `git merge` would), and `update-ref` moves `branch` only if
|
|
262
|
+
* it still points where the merge started.
|
|
263
|
+
*
|
|
264
|
+
* No working tree means no commit hook runs — deliberately: the queue is
|
|
265
|
+
* plumbing, and the gate runs the suite. It also means nothing is added to,
|
|
266
|
+
* removed from or pruned out of the repository's worktree list.
|
|
267
|
+
*
|
|
268
|
+
* A conflict throws, and so does a `branch` that moved during the merge;
|
|
269
|
+
* either way `branch` is left where it was.
|
|
270
|
+
*/
|
|
271
|
+
/** `merge-tree --write-tree` + `commit-tree`, the two-parent merge commit
|
|
272
|
+
* alone — no ref touched. Shared by `mergeWithoutWorktree` (one task, lands
|
|
273
|
+
* immediately) and `attemptCandidate` (N tasks, chained, lands only once at
|
|
274
|
+
* the end). */
|
|
275
|
+
function buildMergeCommit(projectDir, base, head, message) {
|
|
276
|
+
const tree = writeMergeTree(projectDir, base, head);
|
|
277
|
+
return runGit(projectDir, ['commit-tree', tree, '-p', base, '-p', head, '-m', message]);
|
|
278
|
+
}
|
|
279
|
+
function mergeWithoutWorktree(projectDir, branch, source, message) {
|
|
280
|
+
const base = runGit(projectDir, ['rev-parse', '--verify', `refs/heads/${branch}^{commit}`]);
|
|
281
|
+
const head = runGit(projectDir, ['rev-parse', '--verify', `${source}^{commit}`]);
|
|
282
|
+
const commit = buildMergeCommit(projectDir, base, head, message);
|
|
283
|
+
runGit(projectDir, ['update-ref', '-m', message, `refs/heads/${branch}`, commit, base]);
|
|
284
|
+
}
|
|
285
|
+
function landCandidate(projectDir, integrationBranch, base, candidate, message) {
|
|
286
|
+
const holder = worktreeHolding(projectDir, integrationBranch);
|
|
287
|
+
if (holder === null) {
|
|
288
|
+
try {
|
|
289
|
+
runGit(projectDir, [
|
|
290
|
+
'update-ref',
|
|
291
|
+
'-m',
|
|
292
|
+
message,
|
|
293
|
+
`refs/heads/${integrationBranch}`,
|
|
294
|
+
candidate,
|
|
295
|
+
base,
|
|
296
|
+
]);
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
return { outcome: 'integration-moved' };
|
|
300
|
+
}
|
|
301
|
+
return { outcome: 'landed' };
|
|
302
|
+
}
|
|
303
|
+
const dirty = trackedChanges(holder);
|
|
304
|
+
if (dirty.length > 0) {
|
|
305
|
+
return { outcome: 'integration-dirty', worktree: holder, dirty };
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
execFileSync('git', ['merge', '--ff-only', candidate], { cwd: holder, stdio: 'pipe' });
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return { outcome: 'integration-moved' };
|
|
312
|
+
}
|
|
313
|
+
return { outcome: 'landed' };
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Report `tasks` as blocked by an `integration-dirty` refusal already
|
|
317
|
+
* observed elsewhere — the dirty holder's own outcome (`attemptCandidate`'s
|
|
318
|
+
* landing) or a sibling half's (`bisectGroup`, once one half has found
|
|
319
|
+
* nothing can land). Shared so every task the batch touches gets the same
|
|
320
|
+
* outcome shape and log line regardless of which call site saw the dirty
|
|
321
|
+
* worktree first.
|
|
322
|
+
*/
|
|
323
|
+
async function dirtyOutcomes(tasks, landing, integrationBranch, logBlocked) {
|
|
324
|
+
const outcomes = [];
|
|
325
|
+
for (const task of tasks) {
|
|
326
|
+
await logBlocked(task.taskId, 'execution.env-failure', `${integrationBranch} is checked out in ${landing.worktree}, which has uncommitted changes: ${landing.dirty.join(', ')}`);
|
|
327
|
+
outcomes.push({
|
|
328
|
+
outcome: 'integration-dirty',
|
|
329
|
+
taskId: task.taskId,
|
|
330
|
+
worktree: landing.worktree,
|
|
331
|
+
dirty: landing.dirty,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
return outcomes;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* A scratch worktree for testing a batch candidate before anything lands —
|
|
338
|
+
* a sibling of the project, the same convention `taskWorktreeDir` uses, but
|
|
339
|
+
* named so it can never collide with a real task id or the reserved
|
|
340
|
+
* `integration` one: nothing outside this function should ever address it.
|
|
341
|
+
*/
|
|
342
|
+
function batchCandidateWorktreeDir(projectDir) {
|
|
343
|
+
const project = path.resolve(projectDir);
|
|
344
|
+
return path.join(path.dirname(project), '.wt', path.basename(project), `batch-${randomUUID()}`);
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Fold `readyTasks` onto the current integration head with git plumbing
|
|
348
|
+
* alone (no worktree, no branch touched until the very end), test the result
|
|
349
|
+
* once in a throwaway detached worktree, and either land it with one
|
|
350
|
+
* compare-and-swap or bisect it. `readyTasks` have already been certified and
|
|
351
|
+
* rebased by the caller (`runGroup`) — this only builds, tests and lands.
|
|
352
|
+
*/
|
|
353
|
+
async function attemptCandidate(readyTasks, opts, integrationBranch, logBlocked,
|
|
354
|
+
/**
|
|
355
|
+
* Set by the caller when it already knows this exact set is guilty of a
|
|
356
|
+
* red parent — the sibling half of a bisection whose other half landed
|
|
357
|
+
* clean. Skips the group's own suite run for 2+ tasks (there is nothing
|
|
358
|
+
* left to learn from testing a candidate already known red) and goes
|
|
359
|
+
* straight to splitting it further. A single task still runs, because its
|
|
360
|
+
* own failure output tail is the thing the caller reports.
|
|
361
|
+
*/
|
|
362
|
+
knownRed = false) {
|
|
363
|
+
if (knownRed && readyTasks.length >= 2) {
|
|
364
|
+
return bisectGroup(readyTasks, opts, integrationBranch, logBlocked);
|
|
365
|
+
}
|
|
366
|
+
const projectDir = opts.projectDir;
|
|
367
|
+
const base = runGit(projectDir, [
|
|
368
|
+
'rev-parse',
|
|
369
|
+
'--verify',
|
|
370
|
+
`refs/heads/${integrationBranch}^{commit}`,
|
|
371
|
+
]);
|
|
372
|
+
// Chain each task's merge onto the last, exactly like `mergeWithoutWorktree`
|
|
373
|
+
// does for one task — `merge-tree` computes its own merge-base from the two
|
|
374
|
+
// commits it is given, and since every task was rebased onto `base` (not
|
|
375
|
+
// onto each other), that merge-base is `base` at every step. Claim-disjoint
|
|
376
|
+
// tasks (groupForBatch's contract) fold without conflict; a fold that does
|
|
377
|
+
// conflict anyway throws, same as a single-task merge conflict would.
|
|
378
|
+
let head = base;
|
|
379
|
+
const foldCommits = new Map();
|
|
380
|
+
for (const task of readyTasks) {
|
|
381
|
+
const source = runGit(projectDir, ['rev-parse', '--verify', `${task.branch}^{commit}`]);
|
|
382
|
+
const message = `Merge ${task.taskId} into ${integrationBranch}`;
|
|
383
|
+
head = buildMergeCommit(projectDir, head, source, message);
|
|
384
|
+
foldCommits.set(task.taskId, head);
|
|
385
|
+
}
|
|
386
|
+
const candidate = head;
|
|
387
|
+
const worktreeDir = batchCandidateWorktreeDir(projectDir);
|
|
388
|
+
runGit(projectDir, ['worktree', 'add', '--detach', worktreeDir, candidate]);
|
|
389
|
+
let testOutcome;
|
|
390
|
+
try {
|
|
391
|
+
testOutcome = runTestCmd(opts.testCmd, worktreeDir);
|
|
392
|
+
}
|
|
393
|
+
finally {
|
|
394
|
+
// Always removed, including on error — this worktree exists only to run
|
|
395
|
+
// one suite once and has no meaning once that suite has an answer.
|
|
396
|
+
runGit(projectDir, ['worktree', 'remove', '--force', worktreeDir]);
|
|
397
|
+
}
|
|
398
|
+
if (testOutcome.passed) {
|
|
399
|
+
const message = `Batch-merge ${readyTasks.map((t) => t.taskId).join(', ')} into ${integrationBranch}`;
|
|
400
|
+
const landing = landCandidate(projectDir, integrationBranch, base, candidate, message);
|
|
401
|
+
if (landing.outcome === 'integration-dirty') {
|
|
402
|
+
// Mirrors `step`'s own refusal: a worktree holds the integration
|
|
403
|
+
// branch and carries uncommitted tracked changes, so merging into it
|
|
404
|
+
// either stops half-way or folds someone's unfinished work in. The
|
|
405
|
+
// tests already ran and passed; nothing landed.
|
|
406
|
+
const outcomes = await dirtyOutcomes(readyTasks, landing, integrationBranch, logBlocked);
|
|
407
|
+
return { outcomes, suiteRuns: 1 };
|
|
408
|
+
}
|
|
409
|
+
if (landing.outcome === 'integration-moved') {
|
|
410
|
+
// The ref moved under the batch between the candidate's build and its
|
|
411
|
+
// land — D-46's compare-and-swap race, the same one a single task's
|
|
412
|
+
// `mergeWithoutWorktree` throws on, just reachable here with a whole
|
|
413
|
+
// group under test at once. Nothing landed, so every ready task
|
|
414
|
+
// reports it rather than one throw swallowing the rest of the group.
|
|
415
|
+
const outcomes = [];
|
|
416
|
+
for (const task of readyTasks) {
|
|
417
|
+
await logBlocked(task.taskId, 'execution.env-failure', `${integrationBranch} moved to a different commit while this batch was testing; nothing landed.`);
|
|
418
|
+
outcomes.push({ outcome: 'integration-moved', taskId: task.taskId });
|
|
419
|
+
}
|
|
420
|
+
return { outcomes, suiteRuns: 1 };
|
|
421
|
+
}
|
|
422
|
+
const outcomes = [];
|
|
423
|
+
for (const task of readyTasks) {
|
|
424
|
+
const commit = foldCommits.get(task.taskId);
|
|
425
|
+
if (opts.events) {
|
|
426
|
+
const { ctx, ...opt } = opts.events;
|
|
427
|
+
// This task's own merge commit against its own first parent — not
|
|
428
|
+
// the integration tip, which by now carries every task in the batch.
|
|
429
|
+
await emitWaveMerged(task.taskId, ctx, opt, mergedFiles(projectDir, commit));
|
|
430
|
+
}
|
|
431
|
+
outcomes.push({ outcome: 'merged', taskId: task.taskId });
|
|
432
|
+
}
|
|
433
|
+
return { outcomes, suiteRuns: 1 };
|
|
434
|
+
}
|
|
435
|
+
// Red. A lone task's failure is a genuine test failure, reported exactly as
|
|
436
|
+
// `step` reports one. Two or more: bisect — Zuul's shared-queue gating and
|
|
437
|
+
// Mergify's batch/bisect (cited at groupForBatch) — rather than fail every
|
|
438
|
+
// task in the group for one task's sake: split, recurse on the left half,
|
|
439
|
+
// then recurse on the right half. `runGroup` re-certifies and re-rebases
|
|
440
|
+
// each half against whatever the integration head is when its turn comes,
|
|
441
|
+
// which is what makes "land the innocent half, then retest the guilty one
|
|
442
|
+
// rebuilt on the new head" and "the left half was itself red, so it
|
|
443
|
+
// recursed, and only then did the right half get its own turn" the same
|
|
444
|
+
// code path: the right half's `runGroup` call sees the left half's result
|
|
445
|
+
// simply because it starts after the left half's `await` returns.
|
|
446
|
+
if (readyTasks.length === 1) {
|
|
447
|
+
const task = readyTasks[0];
|
|
448
|
+
const outputTail = tailLines(testOutcome.output, OUTPUT_TAIL_LINES);
|
|
449
|
+
await logBlocked(task.taskId, 'execution.test-failure', outputTail);
|
|
450
|
+
return {
|
|
451
|
+
outcomes: [{ outcome: 'tests-failed', taskId: task.taskId, outputTail }],
|
|
452
|
+
suiteRuns: 1,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
const bisected = await bisectGroup(readyTasks, opts, integrationBranch, logBlocked);
|
|
456
|
+
return { outcomes: bisected.outcomes, suiteRuns: 1 + bisected.suiteRuns };
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Split a red (or known-red) group in half and recurse on each — the shared
|
|
460
|
+
* tail of `attemptCandidate`'s red path, factored out so a known-red group
|
|
461
|
+
* can jump straight here without spending a suite run to relearn what the
|
|
462
|
+
* caller already knows. `left` runs with no assumption of its own; when it
|
|
463
|
+
* comes back fully merged, the failure cannot be in it, so `right` is
|
|
464
|
+
* dispatched already known red (`runGroup`, ready.length >= 2 skips its own
|
|
465
|
+
* test the same way).
|
|
466
|
+
*/
|
|
467
|
+
async function bisectGroup(readyTasks, opts, integrationBranch, logBlocked) {
|
|
468
|
+
const mid = Math.ceil(readyTasks.length / 2);
|
|
469
|
+
const left = await runGroup(readyTasks.slice(0, mid), opts, integrationBranch, logBlocked);
|
|
470
|
+
// Once anything has hit integration-dirty, nothing can land regardless of
|
|
471
|
+
// what the right half's suite says — the checkout stays dirty until an
|
|
472
|
+
// operator clears it — so report the right half the same way instead of
|
|
473
|
+
// spending a suite run (or more bisection) to learn that.
|
|
474
|
+
const dirty = left.outcomes.find((o) => o.outcome === 'integration-dirty');
|
|
475
|
+
if (dirty) {
|
|
476
|
+
const rightOutcomes = await dirtyOutcomes(readyTasks.slice(mid), dirty, integrationBranch, logBlocked);
|
|
477
|
+
return { outcomes: [...left.outcomes, ...rightOutcomes], suiteRuns: left.suiteRuns };
|
|
478
|
+
}
|
|
479
|
+
const rightKnownRed = left.outcomes.every((o) => o.outcome === 'merged');
|
|
480
|
+
const right = await runGroup(readyTasks.slice(mid), opts, integrationBranch, logBlocked, rightKnownRed);
|
|
481
|
+
return {
|
|
482
|
+
outcomes: [...left.outcomes, ...right.outcomes],
|
|
483
|
+
suiteRuns: left.suiteRuns + right.suiteRuns,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Certify and rebase every task in the group, then fold whatever survives
|
|
488
|
+
* into one candidate via `attemptCandidate` — recursing through
|
|
489
|
+
* `attemptCandidate` on a red candidate. Order-preserving: a task's outcome
|
|
490
|
+
* lands at its own position in the returned array no matter which half of a
|
|
491
|
+
* bisection (or neither — a certify/rebase refusal) produced it, so the
|
|
492
|
+
* caller can print outcomes in admitted order the same way `step` does.
|
|
493
|
+
*/
|
|
494
|
+
async function runGroup(tasks, opts, integrationBranch, logBlocked, knownRed = false) {
|
|
495
|
+
if (tasks.length === 0)
|
|
496
|
+
return { outcomes: [], suiteRuns: 0 };
|
|
497
|
+
const byTaskId = new Map();
|
|
498
|
+
const ready = [];
|
|
499
|
+
for (const task of tasks) {
|
|
500
|
+
const early = await certifyAndRebase(task, integrationBranch, logBlocked);
|
|
501
|
+
if (early)
|
|
502
|
+
byTaskId.set(task.taskId, early);
|
|
503
|
+
else
|
|
504
|
+
ready.push(task);
|
|
505
|
+
}
|
|
506
|
+
let suiteRuns = 0;
|
|
507
|
+
if (ready.length > 0) {
|
|
508
|
+
const result = await attemptCandidate(ready, opts, integrationBranch, logBlocked, knownRed);
|
|
509
|
+
suiteRuns = result.suiteRuns;
|
|
510
|
+
for (const outcome of result.outcomes)
|
|
511
|
+
byTaskId.set(outcome.taskId, outcome);
|
|
512
|
+
}
|
|
513
|
+
return { outcomes: tasks.map((t) => byTaskId.get(t.taskId)), suiteRuns };
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* The batch equivalent of `step`, for a group of two or more claim-disjoint
|
|
517
|
+
* tasks (`groupForBatch` decides the grouping; a group of one should just
|
|
518
|
+
* call `step`). Rebases each task in its own worktree exactly as `step`
|
|
519
|
+
* does, stacks the survivors into one candidate with git plumbing alone (no
|
|
520
|
+
* worktree touches the project checkout, no branch moves until the end), and
|
|
521
|
+
* runs the test command once against it. Green lands the whole candidate
|
|
522
|
+
* with one compare-and-swap; red bisects (see `attemptCandidate`) instead of
|
|
523
|
+
* failing every task in the group for one task's sake.
|
|
524
|
+
*/
|
|
525
|
+
export async function batchStep(tasks, opts) {
|
|
526
|
+
const integrationBranch = integrationBranchName(opts.epic, epicBranchPrefix(opts.projectDir, opts.epic));
|
|
527
|
+
const events = opts.events;
|
|
528
|
+
// Same envelope check as step(), and for the same reason: runGroup below
|
|
529
|
+
// rebases and merges real git state for every task in the group before its
|
|
530
|
+
// first append. The causal_parent is one value for the whole batch call, so
|
|
531
|
+
// one check here, before runGroup starts, covers it.
|
|
532
|
+
if (events) {
|
|
533
|
+
const { ctx, ...opt } = events;
|
|
534
|
+
await validateEventEnvelope({ session_id: ctx.sessionId, event_type: 'wave-merged', causal_parent: ctx.causalParent }, opt);
|
|
535
|
+
}
|
|
536
|
+
const logBlocked = async (taskId, error, detail) => {
|
|
537
|
+
if (!events)
|
|
538
|
+
return;
|
|
539
|
+
const { ctx, ...opt } = events;
|
|
540
|
+
await emitTaskBlocked(taskId, { error, severity: 'S2-major', detail }, ctx, opt);
|
|
541
|
+
};
|
|
542
|
+
return runGroup(tasks, opts, integrationBranch, logBlocked);
|
|
543
|
+
}
|
|
103
544
|
/** The commit `ref` names, or null when it names nothing (or nothing committish). */
|
|
104
545
|
function resolveCommit(projectDir, ref) {
|
|
105
546
|
try {
|
|
@@ -141,7 +582,7 @@ function isAncestor(projectDir, ancestor, descendant) {
|
|
|
141
582
|
*/
|
|
142
583
|
export async function adopt(task, opts) {
|
|
143
584
|
const { projectDir } = opts;
|
|
144
|
-
const integrationBranch = integrationBranchName(opts.epic);
|
|
585
|
+
const integrationBranch = integrationBranchName(opts.epic, epicBranchPrefix(projectDir, opts.epic));
|
|
145
586
|
for (const ref of [integrationBranch, task.branch]) {
|
|
146
587
|
if (!resolveCommit(projectDir, ref)) {
|
|
147
588
|
throw new QueueError('queue.adopt-unknown-branch', `Branch "${ref}" does not exist in ${projectDir} — there is nothing to adopt against.`, { branch: ref, taskId: task.taskId });
|