@yaawn/workflow 0.0.0-stage → 0.4.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 +172 -2
- package/agents/instructions/output-styles/yaawn-eli5.instructions.md +45 -0
- package/agents/instructions/yaawn-accessibility.css.instructions.md +58 -0
- package/agents/instructions/yaawn-accessibility.html.instructions.md +90 -0
- package/agents/instructions/yaawn-accessibility.javascript.instructions.md +50 -0
- package/agents/instructions/yaawn-clean-code.typescript.instructions.md +74 -0
- package/agents/instructions/yaawn-function-coupling.typescript.instructions.md +100 -0
- package/agents/instructions/yaawn-html-css-js.html.instructions.md +32 -0
- package/agents/instructions/yaawn-open-source-first.instructions.md +38 -0
- package/agents/instructions/yaawn-personas.instructions.md +18 -0
- package/agents/instructions/yaawn-semantic-html.html.instructions.md +40 -0
- package/agents/instructions/yaawn-vanilla-css.css.instructions.md +50 -0
- package/agents/output-styles/yaawn-eli5.instructions.md +45 -0
- package/agents/personas/yaawn-developer.md +16 -0
- package/agents/personas/yaawn-product-owner.md +18 -0
- package/agents/personas/yaawn-reviewer.md +16 -0
- package/agents/skills/yaawn-clean-code/SKILL.md +61 -0
- package/agents/skills/yaawn-clean-code/references/classes.md +114 -0
- package/agents/skills/yaawn-clean-code/references/comments.md +164 -0
- package/agents/skills/yaawn-clean-code/references/error-handling.md +151 -0
- package/agents/skills/yaawn-clean-code/references/formatting.md +146 -0
- package/agents/skills/yaawn-clean-code/references/functions.md +261 -0
- package/agents/skills/yaawn-clean-code/references/meaningful-names.md +234 -0
- package/agents/skills/yaawn-clean-code/references/objects-and-data-structures.md +130 -0
- package/agents/skills/yaawn-clean-code/references/principles.md +76 -0
- package/agents/skills/yaawn-clean-code/references/simple-design-and-code-smells.md +130 -0
- package/agents/skills/yaawn-clean-code/references/unit-tests.md +114 -0
- package/agents/skills/yaawn-create-plan/SKILL.md +234 -0
- package/agents/skills/yaawn-grill-bug-report/SKILL.md +31 -0
- package/agents/skills/yaawn-grill-bug-report/references/bug-report-checklist.md +27 -0
- package/agents/skills/yaawn-grill-design/SKILL.md +68 -0
- package/agents/skills/yaawn-grill-design/references/design-checklist.md +36 -0
- package/agents/skills/yaawn-grill-other/SKILL.md +31 -0
- package/agents/skills/yaawn-grill-other/references/other-checklist.md +19 -0
- package/agents/skills/yaawn-grill-refactor/SKILL.md +31 -0
- package/agents/skills/yaawn-grill-refactor/references/refactor-checklist.md +26 -0
- package/agents/skills/yaawn-grill-spike/SKILL.md +31 -0
- package/agents/skills/yaawn-grill-spike/references/spike-checklist.md +25 -0
- package/agents/skills/yaawn-grill-story/SKILL.md +34 -0
- package/agents/skills/yaawn-grill-story/references/concept-challenge.md +77 -0
- package/agents/skills/yaawn-grill-story/references/story-checklist.md +44 -0
- package/agents/skills/yaawn-implement-plan/SKILL.md +196 -0
- package/agents/skills/yaawn-implement-plan/assets/fix-prompt.md +66 -0
- package/agents/skills/yaawn-implement-plan/assets/review-prompt.md +89 -0
- package/agents/skills/yaawn-implement-plan/assets/session-prompt.md +80 -0
- package/agents/skills/yaawn-implement-plan/scripts/agent.ts +218 -0
- package/agents/skills/yaawn-implement-plan/scripts/caffeinate.ts +56 -0
- package/agents/skills/yaawn-implement-plan/scripts/groups.ts +233 -0
- package/agents/skills/yaawn-implement-plan/scripts/overnight.ts +772 -0
- package/agents/skills/yaawn-implement-plan/scripts/parallel.ts +1176 -0
- package/agents/skills/yaawn-implement-plan/scripts/processes.ts +53 -0
- package/agents/skills/yaawn-implement-plan/scripts/stages.ts +65 -0
- package/agents/skills/yaawn-implement-plan/scripts/targets.ts +381 -0
- package/agents/skills/yaawn-implement-plan/scripts/worktrees.ts +431 -0
- package/agents/skills/yaawn-owasp-security/SKILL.md +106 -0
- package/agents/skills/yaawn-owasp-security/references/a01-broken-access-control.md +58 -0
- package/agents/skills/yaawn-owasp-security/references/a02-security-misconfiguration.md +56 -0
- package/agents/skills/yaawn-owasp-security/references/a03-software-supply-chain-failures.md +56 -0
- package/agents/skills/yaawn-owasp-security/references/a04-cryptographic-failures.md +67 -0
- package/agents/skills/yaawn-owasp-security/references/a05-injection.md +53 -0
- package/agents/skills/yaawn-owasp-security/references/a06-insecure-design.md +48 -0
- package/agents/skills/yaawn-owasp-security/references/a07-authentication-failures.md +68 -0
- package/agents/skills/yaawn-owasp-security/references/a08-software-data-integrity-failures.md +45 -0
- package/agents/skills/yaawn-owasp-security/references/a09-security-logging-alerting-failures.md +59 -0
- package/agents/skills/yaawn-owasp-security/references/a10-mishandling-exceptional-conditions.md +63 -0
- package/agents/skills/yaawn-prepare-plan/SKILL.md +110 -0
- package/agents/skills/yaawn-save-plan/SKILL.md +84 -0
- package/agents/skills/yaawn-save-plan/scripts/plan-body.ts +397 -0
- package/agents/skills/yaawn-save-plan/scripts/save-plan.ts +539 -0
- package/agents/skills/yaawn-split-story/SKILL.md +189 -0
- package/agents/skills/yaawn-split-story/references/spider.md +64 -0
- package/agents/skills/yaawn-split-story/scripts/create-issue.ts +162 -0
- package/agents/skills/yaawn-verify-executed-plan/SKILL.md +154 -0
- package/agents/skills/yaawn-verify-executed-plan/scripts/overnight.ts +362 -0
- package/agents/skills/yaawn-verify-executed-plan/scripts/pull-request.ts +131 -0
- package/agents/skills/yaawn-write-bug-report/SKILL.md +87 -0
- package/agents/skills/yaawn-write-other/SKILL.md +90 -0
- package/agents/skills/yaawn-write-refactor/SKILL.md +94 -0
- package/agents/skills/yaawn-write-spike/SKILL.md +94 -0
- package/agents/skills/yaawn-write-user-story/SKILL.md +187 -0
- package/agents/skills/yaawn-write-user-story/references/acceptance-criteria.md +186 -0
- package/agents/skills/yaawn-write-user-story/references/five-types-of-value.md +82 -0
- package/agents/skills/yaawn-write-user-story/references/user-story-fundamentals.md +75 -0
- package/agents/yaawn-glossary.md +146 -0
- package/agents/yaawn-grilling.md +75 -0
- package/agents/yaawn-worktrees.md +69 -0
- package/config/install-tasks.json +35 -0
- package/config/settings.json +23 -0
- package/config/wizard.json +128 -0
- package/github/ISSUE_TEMPLATE/yaawn-bug-report.md +59 -0
- package/github/ISSUE_TEMPLATE/yaawn-other.md +38 -0
- package/github/ISSUE_TEMPLATE/yaawn-refactor.md +38 -0
- package/github/ISSUE_TEMPLATE/yaawn-spike.md +16 -0
- package/github/ISSUE_TEMPLATE/yaawn-story.md +56 -0
- package/package.json +33 -4
- package/scripts/install.js +73 -0
- package/yaawn/gitignore +1 -0
- package/yaawn/yaawn-definition-of-done.md +5 -0
- package/yaawn/yaawn-project-glossary.md +7 -0
- package/yaawn/yaawn-user-roles.md +3 -0
- package/yaawn/yaawn-verification-commands.md +7 -0
|
@@ -0,0 +1,1176 @@
|
|
|
1
|
+
import { type ChildProcess, execFile, spawn } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
constants,
|
|
5
|
+
existsSync,
|
|
6
|
+
fstatSync,
|
|
7
|
+
openSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { createInterface } from "node:readline";
|
|
14
|
+
import { createInterface as createPrompt } from "node:readline/promises";
|
|
15
|
+
import type { Readable } from "node:stream";
|
|
16
|
+
import { promisify } from "node:util";
|
|
17
|
+
import {
|
|
18
|
+
type AgentChoice,
|
|
19
|
+
type AgentName,
|
|
20
|
+
readAgent,
|
|
21
|
+
} from "./agent.js";
|
|
22
|
+
import { keepMacAwakeWhileRunning, printWalkAwayBanner } from "./caffeinate.js";
|
|
23
|
+
import {
|
|
24
|
+
type ImplementationGroup,
|
|
25
|
+
type LabelAction,
|
|
26
|
+
type Lane,
|
|
27
|
+
allCompatibleGroups,
|
|
28
|
+
assignGroups,
|
|
29
|
+
conflictGraph,
|
|
30
|
+
filesToChange,
|
|
31
|
+
filesToChangeHeading,
|
|
32
|
+
groupLabel,
|
|
33
|
+
groupLabelRemovals,
|
|
34
|
+
groupsOf,
|
|
35
|
+
labelActions,
|
|
36
|
+
laneFiles,
|
|
37
|
+
laneIssueNumbers,
|
|
38
|
+
laneOwners,
|
|
39
|
+
lanePairs,
|
|
40
|
+
} from "./groups.js";
|
|
41
|
+
import { drainOutput } from "./processes.js";
|
|
42
|
+
import {
|
|
43
|
+
type LaneOutcomeState,
|
|
44
|
+
type StageLabel,
|
|
45
|
+
laneStartedLabel,
|
|
46
|
+
parseStageMarker,
|
|
47
|
+
runResultLine as outcomeResultLine,
|
|
48
|
+
runningResult,
|
|
49
|
+
stoppedResult,
|
|
50
|
+
} from "./stages.js";
|
|
51
|
+
import {
|
|
52
|
+
type Issue,
|
|
53
|
+
addLabel,
|
|
54
|
+
ensureGroupLabel,
|
|
55
|
+
executionOrder,
|
|
56
|
+
gh,
|
|
57
|
+
ghAsync,
|
|
58
|
+
issueFailuresAsync,
|
|
59
|
+
issueRefs,
|
|
60
|
+
plansDir,
|
|
61
|
+
removeGroupLabels,
|
|
62
|
+
removeLabel,
|
|
63
|
+
resolveTargets,
|
|
64
|
+
resolveTargetsAsync,
|
|
65
|
+
resultPath,
|
|
66
|
+
succeeds,
|
|
67
|
+
succeedsAsync,
|
|
68
|
+
uncommittedChangesAsync,
|
|
69
|
+
} from "./targets.js";
|
|
70
|
+
import {
|
|
71
|
+
type MainCopyCheck,
|
|
72
|
+
blockedUpdateReason,
|
|
73
|
+
branchName,
|
|
74
|
+
describeMainCopyState,
|
|
75
|
+
fetchMainCopy,
|
|
76
|
+
git,
|
|
77
|
+
isBareLayout,
|
|
78
|
+
laneWorktreePath,
|
|
79
|
+
registeredWorktrees,
|
|
80
|
+
syncMainCopy,
|
|
81
|
+
} from "./worktrees.js";
|
|
82
|
+
|
|
83
|
+
// Every launch: one lane per issue, each in its own git worktree, each
|
|
84
|
+
// running `overnight.ts lane <issue> --agent <name>` there.
|
|
85
|
+
|
|
86
|
+
export interface LaunchRequest {
|
|
87
|
+
issues: number[];
|
|
88
|
+
ready: boolean;
|
|
89
|
+
maxParallel: number | null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type LaneState = LaneOutcomeState;
|
|
93
|
+
|
|
94
|
+
// The lane issue's title names a new worktree directory; an existing checkout
|
|
95
|
+
// of the lane branch is reused instead.
|
|
96
|
+
interface TitledLane extends Lane {
|
|
97
|
+
title: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// What a pre-flight lookup found, or why it could not look.
|
|
101
|
+
interface Lookup<T> {
|
|
102
|
+
value: T;
|
|
103
|
+
failures: string[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The lanes that passed every pre-flight check, and the main copy's
|
|
107
|
+
// uncommitted changes (without the plans directory).
|
|
108
|
+
interface CheckedLaunch {
|
|
109
|
+
lanes: TitledLane[];
|
|
110
|
+
changes: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface ActiveRun {
|
|
114
|
+
pid: number;
|
|
115
|
+
tokens: string[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface LaneRun {
|
|
119
|
+
lane: TitledLane;
|
|
120
|
+
agent: AgentName;
|
|
121
|
+
worktree: string;
|
|
122
|
+
state: LaneState;
|
|
123
|
+
stage: StageLabel;
|
|
124
|
+
tail: string[];
|
|
125
|
+
result: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const overnightScript =
|
|
129
|
+
".agents/skills/yaawn-implement-plan/scripts/overnight.ts";
|
|
130
|
+
const readyLabel = "Yaawn: Ready To Implement";
|
|
131
|
+
const issueListLimit = "1000";
|
|
132
|
+
const numberPattern = /^\d+$/;
|
|
133
|
+
const exitCodeTerminated = 143;
|
|
134
|
+
const pgrepNoMatch = 1;
|
|
135
|
+
const maxResultBytes = 1024 * 1024;
|
|
136
|
+
const failureTailLines = 20;
|
|
137
|
+
const execFileAsync = promisify(execFile);
|
|
138
|
+
|
|
139
|
+
// --- arguments
|
|
140
|
+
|
|
141
|
+
function positiveInteger(token: string | undefined, label: string): number {
|
|
142
|
+
const value = Number(token);
|
|
143
|
+
if (
|
|
144
|
+
!numberPattern.test(token ?? "") ||
|
|
145
|
+
!Number.isSafeInteger(value) ||
|
|
146
|
+
value < 1
|
|
147
|
+
) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`${label} must be a positive integer, got ${JSON.stringify(token ?? "")}`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return value;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function parseLaunchRequest(args: string[]): LaunchRequest {
|
|
156
|
+
const issues = new Set<number>();
|
|
157
|
+
let ready = false;
|
|
158
|
+
let maxParallel: number | null = null;
|
|
159
|
+
for (let index = 0; index < args.length; index++) {
|
|
160
|
+
const arg = args[index];
|
|
161
|
+
if (arg === "--ready") ready = true;
|
|
162
|
+
else if (arg === "--max-parallel" && maxParallel === null) {
|
|
163
|
+
index += 1;
|
|
164
|
+
maxParallel = positiveInteger(args[index], "--max-parallel");
|
|
165
|
+
} else if (arg.startsWith("-"))
|
|
166
|
+
throw new Error(`unexpected flag ${JSON.stringify(arg)}`);
|
|
167
|
+
else issues.add(positiveInteger(arg, "issue"));
|
|
168
|
+
}
|
|
169
|
+
if (ready && issues.size > 0)
|
|
170
|
+
throw new Error("--ready takes no issue numbers");
|
|
171
|
+
if (!ready && issues.size === 0) throw new Error("no issue given");
|
|
172
|
+
return { issues: [...issues], ready, maxParallel };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// --- lanes
|
|
176
|
+
|
|
177
|
+
function toIssueNumber(text: string): number | null {
|
|
178
|
+
const value = Number(text.trim());
|
|
179
|
+
return numberPattern.test(text.trim()) &&
|
|
180
|
+
Number.isSafeInteger(value) &&
|
|
181
|
+
value > 0
|
|
182
|
+
? value
|
|
183
|
+
: null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const readyIssuesArgs = [
|
|
187
|
+
"issue",
|
|
188
|
+
"list",
|
|
189
|
+
"--label",
|
|
190
|
+
readyLabel,
|
|
191
|
+
"--state",
|
|
192
|
+
"open",
|
|
193
|
+
"--json",
|
|
194
|
+
"number",
|
|
195
|
+
"--limit",
|
|
196
|
+
issueListLimit,
|
|
197
|
+
"--jq",
|
|
198
|
+
".[].number",
|
|
199
|
+
];
|
|
200
|
+
|
|
201
|
+
function laneIssueNumbersFrom(output: string): number[] {
|
|
202
|
+
const issues = output
|
|
203
|
+
.split("\n")
|
|
204
|
+
.map(toIssueNumber)
|
|
205
|
+
.filter((issue): issue is number => issue !== null);
|
|
206
|
+
return [...new Set(issues)].sort((a, b) => a - b);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function readyLaneIssues(): number[] {
|
|
210
|
+
return laneIssueNumbersFrom(gh(readyIssuesArgs));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function readyLaneIssuesAsync(): Promise<number[]> {
|
|
214
|
+
return laneIssueNumbersFrom(await ghAsync(readyIssuesArgs));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function resolvedLane(issue: number): Lane {
|
|
218
|
+
return { issue, targets: executionOrder(resolveTargets(issue)) };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function titledLane(lane: Lane): TitledLane {
|
|
222
|
+
return { ...lane, title: lane.targets[0].title };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function lookedUpLane(issue: number): Promise<Lookup<TitledLane[]>> {
|
|
226
|
+
return lookup(
|
|
227
|
+
resolveTargetsAsync(issue).then((targets) => [
|
|
228
|
+
titledLane({ issue, targets: executionOrder(targets) }),
|
|
229
|
+
]),
|
|
230
|
+
[],
|
|
231
|
+
`lane #${issue}: could not resolve`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Every lane is fetched in parallel and every lookup runs to completion, so
|
|
236
|
+
// all failures show at once; the lanes keep the order of their issues.
|
|
237
|
+
async function resolveLanes(
|
|
238
|
+
request: LaunchRequest,
|
|
239
|
+
): Promise<Lookup<TitledLane[]>> {
|
|
240
|
+
const laneIssues = request.ready
|
|
241
|
+
? await lookup(
|
|
242
|
+
readyLaneIssuesAsync(),
|
|
243
|
+
[],
|
|
244
|
+
`could not list "${readyLabel}" issues`,
|
|
245
|
+
)
|
|
246
|
+
: { value: request.issues, failures: [] };
|
|
247
|
+
const lanes = await Promise.all(laneIssues.value.map(lookedUpLane));
|
|
248
|
+
return {
|
|
249
|
+
value: lanes.flatMap(({ value }) => value),
|
|
250
|
+
failures: [
|
|
251
|
+
...laneIssues.failures,
|
|
252
|
+
...lanes.flatMap(({ failures }) => failures),
|
|
253
|
+
],
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// --- worktrees
|
|
258
|
+
|
|
259
|
+
function branchExists(branch: string): boolean {
|
|
260
|
+
return succeeds("git", [
|
|
261
|
+
"show-ref",
|
|
262
|
+
"--verify",
|
|
263
|
+
"--quiet",
|
|
264
|
+
`refs/heads/${branch}`,
|
|
265
|
+
]);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Reused on relaunch: an existing worktree keeps the work of an earlier run.
|
|
269
|
+
function ensureWorktree(worktree: string, issue: number): void {
|
|
270
|
+
if (registeredWorktrees().has(worktree) && existsSync(worktree)) return;
|
|
271
|
+
const branch = branchName(issue);
|
|
272
|
+
const args = branchExists(branch)
|
|
273
|
+
? ["worktree", "add", worktree, branch]
|
|
274
|
+
: ["worktree", "add", "-b", branch, worktree, "HEAD"];
|
|
275
|
+
git(args);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// --- pre-flight
|
|
279
|
+
|
|
280
|
+
// Turns a failed lookup into a FAIL line, so parallel lookups never reject.
|
|
281
|
+
async function lookup<T>(
|
|
282
|
+
work: Promise<T>,
|
|
283
|
+
fallback: T,
|
|
284
|
+
failure: string,
|
|
285
|
+
): Promise<Lookup<T>> {
|
|
286
|
+
try {
|
|
287
|
+
return { value: await work, failures: [] };
|
|
288
|
+
} catch (error) {
|
|
289
|
+
return {
|
|
290
|
+
value: fallback,
|
|
291
|
+
failures: [`${failure}: ${firstLine(errorMessage(error))}`],
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function output(command: string, args: string[]): Promise<string> {
|
|
297
|
+
const { stdout } = await execFileAsync(command, args, { encoding: "utf-8" });
|
|
298
|
+
return stdout;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function toolFailure(
|
|
302
|
+
failure: string,
|
|
303
|
+
command: string,
|
|
304
|
+
args: string[],
|
|
305
|
+
): Promise<string | null> {
|
|
306
|
+
return (await succeedsAsync(command, args)) ? null : failure;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Async counterpart of agent.ts's agentToolCheck; choice.agent comes from its allow-list.
|
|
310
|
+
async function agentToolFailure(choice: AgentChoice): Promise<string | null> {
|
|
311
|
+
if (!choice.ok) return choice.failure;
|
|
312
|
+
return toolFailure(`${choice.agent} not installed`, "which", [choice.agent]);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function toolFailures(choice: AgentChoice): Promise<string[]> {
|
|
316
|
+
const failures = await Promise.all([
|
|
317
|
+
toolFailure("gh not authenticated", "gh", ["auth", "status"]),
|
|
318
|
+
agentToolFailure(choice),
|
|
319
|
+
toolFailure("caffeinate not installed", "which", ["caffeinate"]),
|
|
320
|
+
toolFailure("git worktree not available", "git", ["worktree", "list"]),
|
|
321
|
+
]);
|
|
322
|
+
return failures.filter((failure): failure is string => failure !== null);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function parentPid(pid: number): Promise<number> {
|
|
326
|
+
try {
|
|
327
|
+
const parent = Number(
|
|
328
|
+
(await output("ps", ["-o", "ppid=", "-p", String(pid)])).trim(),
|
|
329
|
+
);
|
|
330
|
+
return Number.isSafeInteger(parent) ? parent : 0;
|
|
331
|
+
} catch {
|
|
332
|
+
return 0;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// npx and tsx start this orchestrator through parent processes whose command
|
|
337
|
+
// lines match the run pattern too; none of them is another run.
|
|
338
|
+
async function ownProcessChain(): Promise<Set<number>> {
|
|
339
|
+
const chain = new Set<number>();
|
|
340
|
+
for (
|
|
341
|
+
let pid = process.pid;
|
|
342
|
+
pid > 1 && !chain.has(pid);
|
|
343
|
+
pid = await parentPid(pid)
|
|
344
|
+
) {
|
|
345
|
+
chain.add(pid);
|
|
346
|
+
}
|
|
347
|
+
return chain;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function matchingPids(pattern: string): Promise<number[]> {
|
|
351
|
+
try {
|
|
352
|
+
return (await output("pgrep", ["-f", pattern]))
|
|
353
|
+
.split("\n")
|
|
354
|
+
.filter(Boolean)
|
|
355
|
+
.map(Number);
|
|
356
|
+
} catch (error) {
|
|
357
|
+
if ((error as { code?: unknown }).code === pgrepNoMatch) return [];
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const runCommandMarker = "yaawn-implement-plan/scripts/overnight.ts run";
|
|
363
|
+
|
|
364
|
+
async function commandLine(pid: number): Promise<string> {
|
|
365
|
+
try {
|
|
366
|
+
return (await output("ps", ["-o", "args=", "-p", String(pid)])).trim();
|
|
367
|
+
} catch {
|
|
368
|
+
return "";
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function runArguments(args: string): string[] {
|
|
373
|
+
const markerIndex = args.indexOf(runCommandMarker);
|
|
374
|
+
if (markerIndex === -1) return [];
|
|
375
|
+
return args
|
|
376
|
+
.slice(markerIndex + runCommandMarker.length)
|
|
377
|
+
.split(/\s+/)
|
|
378
|
+
.filter(Boolean);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// The values after --max-parallel and --agent are not issues.
|
|
382
|
+
function runIssueArguments(tokens: string[]): number[] {
|
|
383
|
+
return tokens
|
|
384
|
+
.filter(
|
|
385
|
+
(token, index) =>
|
|
386
|
+
!["--max-parallel", "--agent"].includes(tokens[index - 1]),
|
|
387
|
+
)
|
|
388
|
+
.map(toIssueNumber)
|
|
389
|
+
.filter((value): value is number => value !== null);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Read before the lanes are known, so it can run alongside the other checks.
|
|
393
|
+
async function otherActiveRuns(): Promise<ActiveRun[]> {
|
|
394
|
+
const [ownChain, pids] = await Promise.all([
|
|
395
|
+
ownProcessChain(),
|
|
396
|
+
matchingPids(runCommandMarker),
|
|
397
|
+
]);
|
|
398
|
+
return Promise.all(
|
|
399
|
+
pids
|
|
400
|
+
.filter((pid) => !ownChain.has(pid))
|
|
401
|
+
.map(async (pid) => ({
|
|
402
|
+
pid,
|
|
403
|
+
tokens: runArguments(await commandLine(pid)),
|
|
404
|
+
})),
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// A --ready run's lanes are not on its command line, so it conflicts with every lane.
|
|
409
|
+
function otherRunFailures(
|
|
410
|
+
{ pid, tokens }: ActiveRun,
|
|
411
|
+
laneIssues: number[],
|
|
412
|
+
): string[] {
|
|
413
|
+
if (tokens.includes("--ready"))
|
|
414
|
+
return [`overnight --ready run already active (pid ${pid})`];
|
|
415
|
+
const activeIssues = runIssueArguments(tokens);
|
|
416
|
+
return laneIssues
|
|
417
|
+
.filter((issue) => activeIssues.includes(issue))
|
|
418
|
+
.map((issue) => `overnight run already active for #${issue}`);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function activeRunFailures(lanes: Lane[], activeRuns: ActiveRun[]): string[] {
|
|
422
|
+
const laneIssues = [...new Set(lanes.flatMap(laneIssueNumbers))];
|
|
423
|
+
const failures = activeRuns.flatMap((run) =>
|
|
424
|
+
otherRunFailures(run, laneIssues),
|
|
425
|
+
);
|
|
426
|
+
return [...new Set(failures)];
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function filesFailures(issue: Issue): string[] {
|
|
430
|
+
const { files, invalid } = filesToChange(issue);
|
|
431
|
+
const failures: string[] = [];
|
|
432
|
+
if (files.length === 0) {
|
|
433
|
+
failures.push(
|
|
434
|
+
`#${issue.number} lists no files under ### ${filesToChangeHeading} (run yaawn-create-plan)`,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
if (invalid.length > 0) {
|
|
438
|
+
failures.push(
|
|
439
|
+
`#${issue.number} ### ${filesToChangeHeading} entries that are not repo-relative paths: ${invalid.join(", ")}`,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
return failures;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Worktrees live inside the project only in the bare-repo layout, so a repo
|
|
446
|
+
// that has not adopted it fails before any worktree is created.
|
|
447
|
+
async function bareLayoutFailures(): Promise<string[]> {
|
|
448
|
+
const layout = await lookup(
|
|
449
|
+
output("git", ["worktree", "list", "--porcelain"]).then(isBareLayout),
|
|
450
|
+
false,
|
|
451
|
+
"could not read the git worktree layout",
|
|
452
|
+
);
|
|
453
|
+
if (layout.failures.length > 0) return layout.failures;
|
|
454
|
+
return layout.value
|
|
455
|
+
? []
|
|
456
|
+
: [
|
|
457
|
+
"this repository does not use the bare-repo layout (project root = bare .git, main copy in <project>/main) — set it up as described in .agents/yaawn-worktrees.md, or stay on the previous @yaawn/workflow version",
|
|
458
|
+
];
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Reporting only: pre-flight fetches to count commits but never merges.
|
|
462
|
+
function mainCopyFailures(check: MainCopyCheck, changes: string): string[] {
|
|
463
|
+
if (!check.ok) return [check.reason];
|
|
464
|
+
const { state } = check;
|
|
465
|
+
if (state.kind === "diverged") return [describeMainCopyState(state)];
|
|
466
|
+
return state.kind === "behind" && changes.trim()
|
|
467
|
+
? [blockedUpdateReason(state.behind)]
|
|
468
|
+
: [];
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function printMainCopyState(check: MainCopyCheck): void {
|
|
472
|
+
if (check.ok && check.state.kind !== "diverged") {
|
|
473
|
+
console.log(describeMainCopyState(check.state));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function worktreeFailures(lane: TitledLane): string[] {
|
|
478
|
+
const worktree = laneWorktreePath(lane.issue, lane.title);
|
|
479
|
+
return existsSync(worktree) && !registeredWorktrees().has(worktree)
|
|
480
|
+
? [
|
|
481
|
+
`lane #${lane.issue}: ${worktree} exists but is not a git worktree of this repository`,
|
|
482
|
+
]
|
|
483
|
+
: [];
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function targetFailures(target: Issue): Promise<string[]> {
|
|
487
|
+
return [...(await issueFailuresAsync(target)), ...filesFailures(target)];
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async function laneFailures(lane: TitledLane): Promise<string[]> {
|
|
491
|
+
const targets = await Promise.all(lane.targets.map(targetFailures));
|
|
492
|
+
return [...targets.flat(), ...worktreeFailures(lane)];
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function fileConflicts(lanes: Lane[]): string[] {
|
|
496
|
+
const files = new Map(lanes.map((lane) => [lane.issue, laneFiles(lane)]));
|
|
497
|
+
return lanePairs(lanes).flatMap(([first, second]) => {
|
|
498
|
+
const shared = [...(files.get(first.issue) ?? [])].filter((file) =>
|
|
499
|
+
files.get(second.issue)?.has(file),
|
|
500
|
+
);
|
|
501
|
+
return shared.length === 0
|
|
502
|
+
? []
|
|
503
|
+
: [
|
|
504
|
+
`lanes #${first.issue} and #${second.issue} both change: ${shared.join(", ")}`,
|
|
505
|
+
];
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function sharedTargetConflicts(owners: Map<number, number[]>): string[] {
|
|
510
|
+
return [...owners]
|
|
511
|
+
.filter(([, laneIssues]) => laneIssues.length > 1)
|
|
512
|
+
.map(
|
|
513
|
+
([issue, laneIssues]) =>
|
|
514
|
+
`#${issue} is part of lanes ${issueRefs(laneIssues)}`,
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function dependencyConflicts(
|
|
519
|
+
lanes: Lane[],
|
|
520
|
+
owners: Map<number, number[]>,
|
|
521
|
+
): string[] {
|
|
522
|
+
return lanes.flatMap((lane) =>
|
|
523
|
+
lane.targets.flatMap((target) =>
|
|
524
|
+
target.externalDependsOn.flatMap((dependency) =>
|
|
525
|
+
(owners.get(dependency) ?? [])
|
|
526
|
+
.filter((owner) => owner !== lane.issue)
|
|
527
|
+
.map(
|
|
528
|
+
(owner) =>
|
|
529
|
+
`#${target.number} (lane #${lane.issue}) depends on #${dependency} (lane #${owner}) — lanes run independently`,
|
|
530
|
+
),
|
|
531
|
+
),
|
|
532
|
+
),
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function crossLaneConflicts(lanes: Lane[]): string[] {
|
|
537
|
+
const owners = laneOwners(lanes);
|
|
538
|
+
return [
|
|
539
|
+
...sharedTargetConflicts(owners),
|
|
540
|
+
...fileConflicts(lanes),
|
|
541
|
+
...dependencyConflicts(lanes, owners),
|
|
542
|
+
];
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function printCompatibleSuggestion(lanes: Lane[], conflicts: string[]): void {
|
|
546
|
+
if (conflicts.length === 0) return;
|
|
547
|
+
const graph = conflictGraph(lanes);
|
|
548
|
+
const allGroups = allCompatibleGroups(
|
|
549
|
+
lanes.map((lane) => lane.issue),
|
|
550
|
+
graph,
|
|
551
|
+
);
|
|
552
|
+
// Groups of 2+ are the useful "run together" suggestions; singletons are
|
|
553
|
+
// just issues that can run alone (always possible, not informative here).
|
|
554
|
+
const groups = allGroups.filter((group) => group.length >= 2);
|
|
555
|
+
console.log("");
|
|
556
|
+
if (groups.length === 0) {
|
|
557
|
+
console.log(
|
|
558
|
+
"No two issues can be implemented together (all conflict pairwise).",
|
|
559
|
+
);
|
|
560
|
+
console.log("Run them one at a time:");
|
|
561
|
+
lanes.forEach((lane) =>
|
|
562
|
+
console.log(` /yaawn-implement-plan ${lane.issue}`),
|
|
563
|
+
);
|
|
564
|
+
} else {
|
|
565
|
+
console.log(
|
|
566
|
+
"These groups can be implemented together (no shared files within a group):",
|
|
567
|
+
);
|
|
568
|
+
groups.forEach((group) =>
|
|
569
|
+
console.log(` /yaawn-implement-plan ${group.join(", ")}`),
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// --- implementation groups
|
|
575
|
+
|
|
576
|
+
interface OpenIssue {
|
|
577
|
+
number: number;
|
|
578
|
+
labels: { name: string }[];
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const isSingleIssueRun = (request: LaunchRequest): boolean =>
|
|
582
|
+
!request.ready && request.issues.length === 1;
|
|
583
|
+
|
|
584
|
+
const isIssueNumber = (value: unknown): value is number =>
|
|
585
|
+
Number.isSafeInteger(value) && (value as number) > 0;
|
|
586
|
+
|
|
587
|
+
function openIssues(): OpenIssue[] {
|
|
588
|
+
const raw = JSON.parse(
|
|
589
|
+
gh([
|
|
590
|
+
"issue",
|
|
591
|
+
"list",
|
|
592
|
+
"--state",
|
|
593
|
+
"open",
|
|
594
|
+
"--json",
|
|
595
|
+
"number,labels",
|
|
596
|
+
"--limit",
|
|
597
|
+
issueListLimit,
|
|
598
|
+
]),
|
|
599
|
+
) as unknown;
|
|
600
|
+
return Array.isArray(raw) ? (raw as OpenIssue[]) : [];
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function hasGroupLabel({ number, labels }: OpenIssue): boolean {
|
|
604
|
+
const names = Array.isArray(labels) ? labels.map(({ name }) => name) : [];
|
|
605
|
+
return groupLabelRemovals(number, names).length > 0;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Closed issues keep their labels: they record the group they ran in.
|
|
609
|
+
function wipeGroupLabels(): void {
|
|
610
|
+
openIssues()
|
|
611
|
+
.filter(({ number }) => isIssueNumber(number))
|
|
612
|
+
.filter(hasGroupLabel)
|
|
613
|
+
.forEach(({ number }) => removeGroupLabels(number));
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Groups span every ready lane issue, not only the lanes of this run.
|
|
617
|
+
function readyLanes(request: LaunchRequest, runLanes: Lane[]): Lane[] {
|
|
618
|
+
if (request.ready) return runLanes;
|
|
619
|
+
const known = new Map(runLanes.map((lane) => [lane.issue, lane]));
|
|
620
|
+
return readyLaneIssues().map(
|
|
621
|
+
(issue) => known.get(issue) ?? resolvedLane(issue),
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// A failed action only warns.
|
|
626
|
+
function applyLabelActions(actions: LabelAction[]): void {
|
|
627
|
+
actions.forEach(({ issue, label, kind }) => {
|
|
628
|
+
try {
|
|
629
|
+
if (kind === "add") addLabel(issue, label);
|
|
630
|
+
else removeLabel(issue, label);
|
|
631
|
+
console.log(
|
|
632
|
+
`${kind === "add" ? "added" : "removed"} label "${label}" on #${issue}`,
|
|
633
|
+
);
|
|
634
|
+
} catch (error) {
|
|
635
|
+
console.log(
|
|
636
|
+
`WARN could not ${kind} label "${label}" on #${issue}: ${errorMessage(error)}`,
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function ensuredGroupLabels(groups: ImplementationGroup[]): Set<string> {
|
|
643
|
+
return new Set(
|
|
644
|
+
groups
|
|
645
|
+
.map(({ number }) => groupLabel(number))
|
|
646
|
+
.filter((label) => {
|
|
647
|
+
try {
|
|
648
|
+
ensureGroupLabel(label);
|
|
649
|
+
return true;
|
|
650
|
+
} catch (error) {
|
|
651
|
+
console.log(
|
|
652
|
+
`WARN could not create label "${label}": ${errorMessage(error)}`,
|
|
653
|
+
);
|
|
654
|
+
return false;
|
|
655
|
+
}
|
|
656
|
+
}),
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Every group label was just wiped, so the lanes start without any.
|
|
661
|
+
function labelGroups(lanes: Lane[]): ImplementationGroup[] {
|
|
662
|
+
const assignment = assignGroups(lanes);
|
|
663
|
+
const groups = groupsOf(assignment);
|
|
664
|
+
const ensured = ensuredGroupLabels(groups);
|
|
665
|
+
applyLabelActions(
|
|
666
|
+
labelActions(assignment, new Map()).filter(({ label }) =>
|
|
667
|
+
ensured.has(label),
|
|
668
|
+
),
|
|
669
|
+
);
|
|
670
|
+
return groups;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// At every --ready or multi-issue run start, all group labels are wiped and
|
|
674
|
+
// recalculated from scratch; single-issue runs leave them untouched. Labeling
|
|
675
|
+
// never blocks a launch: on any failure it warns and returns null, as it does
|
|
676
|
+
// for runs without ready lane issues.
|
|
677
|
+
function implementationGroups(
|
|
678
|
+
request: LaunchRequest,
|
|
679
|
+
lanes: Lane[],
|
|
680
|
+
): ImplementationGroup[] | null {
|
|
681
|
+
if (isSingleIssueRun(request)) return null;
|
|
682
|
+
try {
|
|
683
|
+
wipeGroupLabels();
|
|
684
|
+
const ready = readyLanes(request, lanes);
|
|
685
|
+
return ready.length === 0 ? null : labelGroups(ready);
|
|
686
|
+
} catch (error) {
|
|
687
|
+
console.log(
|
|
688
|
+
`WARN implementation groups not labeled: ${errorMessage(error)}`,
|
|
689
|
+
);
|
|
690
|
+
return null;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function printImplementationGroups(groups: ImplementationGroup[]): void {
|
|
695
|
+
console.log("");
|
|
696
|
+
console.log(
|
|
697
|
+
"Implementation groups (the issues of a group can be implemented together):",
|
|
698
|
+
);
|
|
699
|
+
groups.forEach(({ number, issues }) =>
|
|
700
|
+
console.log(` ${number}. /yaawn-implement-plan ${issues.join(" ")}`),
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function describeLane(lane: TitledLane): string[] {
|
|
705
|
+
const header = ` #${lane.issue} — worktree ${laneWorktreePath(lane.issue, lane.title)}, branch ${branchName(lane.issue)}`;
|
|
706
|
+
const targets = lane.targets.map((target, index) => {
|
|
707
|
+
const after =
|
|
708
|
+
target.dependsOn.length > 0
|
|
709
|
+
? ` — after ${issueRefs(target.dependsOn)}`
|
|
710
|
+
: "";
|
|
711
|
+
return ` ${index + 1}. #${target.number} ${target.title}${after}`;
|
|
712
|
+
});
|
|
713
|
+
return [header, ...targets];
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function printLanes(lanes: TitledLane[], maxParallel: number | null): void {
|
|
717
|
+
const limit =
|
|
718
|
+
maxParallel === null ? "all at once" : `at most ${maxParallel} at once`;
|
|
719
|
+
console.log(`mode: parallel lanes (${limit}, one worktree each)`);
|
|
720
|
+
console.log("lanes (targets of a lane run one at a time):");
|
|
721
|
+
lanes.flatMap(describeLane).forEach((line) => console.log(line));
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function printFailures(failures: string[]): void {
|
|
725
|
+
failures.forEach((failure) => console.log(`FAIL ${failure}`));
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function printUncommittedChanges(changes: string): void {
|
|
729
|
+
console.log(
|
|
730
|
+
`--- uncommitted changes in the main copy (without ${plansDir}/) — they stay in the main copy and never enter the worktrees; they block the update when the main copy is behind`,
|
|
731
|
+
);
|
|
732
|
+
console.log(changes);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Prints the pre-flight and returns the lanes only when every check passes.
|
|
736
|
+
// Independent checks run in parallel; results print in a fixed order.
|
|
737
|
+
async function checkedLanes(
|
|
738
|
+
request: LaunchRequest,
|
|
739
|
+
choice: AgentChoice,
|
|
740
|
+
): Promise<CheckedLaunch | null> {
|
|
741
|
+
const [tools, layout, mainCopy, activeRuns, changes] = await Promise.all([
|
|
742
|
+
toolFailures(choice),
|
|
743
|
+
bareLayoutFailures(),
|
|
744
|
+
fetchMainCopy(),
|
|
745
|
+
lookup(otherActiveRuns(), [], "could not check for active overnight runs"),
|
|
746
|
+
lookup(uncommittedChangesAsync(), "", "could not list uncommitted changes"),
|
|
747
|
+
]);
|
|
748
|
+
if (tools.length > 0) {
|
|
749
|
+
printFailures(tools);
|
|
750
|
+
return null;
|
|
751
|
+
}
|
|
752
|
+
if (layout.length > 0) {
|
|
753
|
+
printFailures(layout);
|
|
754
|
+
return null;
|
|
755
|
+
}
|
|
756
|
+
const resolved = await resolveLanes(request);
|
|
757
|
+
if (resolved.failures.length > 0) {
|
|
758
|
+
printFailures(resolved.failures);
|
|
759
|
+
return null;
|
|
760
|
+
}
|
|
761
|
+
const lanes = resolved.value;
|
|
762
|
+
if (lanes.length === 0) {
|
|
763
|
+
printFailures([
|
|
764
|
+
`nothing to implement: no open issue is labelled "${readyLabel}"`,
|
|
765
|
+
]);
|
|
766
|
+
return null;
|
|
767
|
+
}
|
|
768
|
+
const conflicts = crossLaneConflicts(lanes);
|
|
769
|
+
const failures = [
|
|
770
|
+
...activeRuns.failures,
|
|
771
|
+
...activeRunFailures(lanes, activeRuns.value),
|
|
772
|
+
...(await Promise.all(lanes.map(laneFailures))).flat(),
|
|
773
|
+
...changes.failures,
|
|
774
|
+
...mainCopyFailures(mainCopy, changes.value),
|
|
775
|
+
...conflicts,
|
|
776
|
+
];
|
|
777
|
+
printLanes(lanes, request.maxParallel);
|
|
778
|
+
printFailures(failures);
|
|
779
|
+
printMainCopyState(mainCopy);
|
|
780
|
+
printUncommittedChanges(changes.value);
|
|
781
|
+
const groups = implementationGroups(request, lanes);
|
|
782
|
+
if (groups) printImplementationGroups(groups);
|
|
783
|
+
if (failures.length > 0) {
|
|
784
|
+
if (!groups) printCompatibleSuggestion(lanes, conflicts);
|
|
785
|
+
return null;
|
|
786
|
+
}
|
|
787
|
+
return { lanes, changes: changes.value };
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Without a terminal nobody can answer: fail closed so preflight never
|
|
791
|
+
// passes without a confirmation, and the caller asks the human instead.
|
|
792
|
+
async function confirmedUncommittedChanges(): Promise<boolean> {
|
|
793
|
+
if (!process.stdin.isTTY) {
|
|
794
|
+
console.log(
|
|
795
|
+
"uncommitted changes in the main copy — no terminal to confirm; not continuing. Confirm with the human (or commit the changes) before launching",
|
|
796
|
+
);
|
|
797
|
+
return false;
|
|
798
|
+
}
|
|
799
|
+
const prompt = createPrompt({ input: process.stdin, output: process.stdout });
|
|
800
|
+
try {
|
|
801
|
+
const answer = await prompt.question("continue anyway? [y/N] ");
|
|
802
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
803
|
+
} finally {
|
|
804
|
+
prompt.close();
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// The agent is read in the main copy: a lane worktree branches from HEAD and
|
|
809
|
+
// may lack an uncommitted settings file, so each lane gets it as --agent.
|
|
810
|
+
export async function preflightMany(
|
|
811
|
+
request: LaunchRequest,
|
|
812
|
+
agentOption: string | null,
|
|
813
|
+
): Promise<boolean> {
|
|
814
|
+
const checked = await checkedLanes(request, readAgent(agentOption));
|
|
815
|
+
if (checked === null) return false;
|
|
816
|
+
return checked.changes.trim() ? confirmedUncommittedChanges() : true;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// --- process output
|
|
820
|
+
|
|
821
|
+
function keepTail(stream: Readable | null, tail: string[]): void {
|
|
822
|
+
if (!stream) return;
|
|
823
|
+
createInterface({ input: stream }).on("line", (line) => {
|
|
824
|
+
tail.push(line);
|
|
825
|
+
if (tail.length > failureTailLines) tail.shift();
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function errorMessage(error: unknown): string {
|
|
830
|
+
return error instanceof Error ? error.message : String(error);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const firstLine = (text: string): string => text.split("\n")[0];
|
|
834
|
+
|
|
835
|
+
function codeBlock(lines: string[]): string {
|
|
836
|
+
const longestBacktickRun = Math.max(
|
|
837
|
+
0,
|
|
838
|
+
...lines
|
|
839
|
+
.flatMap((line) => line.match(/`+/g) ?? [])
|
|
840
|
+
.map((run) => run.length),
|
|
841
|
+
);
|
|
842
|
+
const fence = "`".repeat(Math.max(3, longestBacktickRun + 1));
|
|
843
|
+
return [fence, ...lines, fence].join("\n");
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function failureLines(tail: string[]): string {
|
|
847
|
+
return tail.length === 0
|
|
848
|
+
? "no output captured"
|
|
849
|
+
: `last ${tail.length} output lines:\n${codeBlock(tail)}`;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// --- run
|
|
853
|
+
|
|
854
|
+
function logState(run: LaneRun, label: LaneState | StageLabel): void {
|
|
855
|
+
console.log(`${new Date().toISOString()} #${run.lane.issue} ${label}`);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function setState(run: LaneRun, state: LaneState): void {
|
|
859
|
+
run.state = state;
|
|
860
|
+
logState(run, state);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Only the exact marker of the lane's own issue moves its stage; every other
|
|
864
|
+
// line of lane output is never interpreted.
|
|
865
|
+
function watchStages(run: LaneRun, stream: Readable | null): void {
|
|
866
|
+
if (!stream) return;
|
|
867
|
+
createInterface({ input: stream }).on("line", (line) => {
|
|
868
|
+
const stage = parseStageMarker(line, run.lane.issue);
|
|
869
|
+
if (stage === undefined || run.state !== "started") return;
|
|
870
|
+
run.stage = stage;
|
|
871
|
+
logState(run, stage);
|
|
872
|
+
saveRunningResult(run);
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// The pipe stays open for the lane's life: the lane stops itself when its
|
|
877
|
+
// stdin ends, so it never outlives this process.
|
|
878
|
+
function keepStdinOpen(child: ChildProcess, run: LaneRun): void {
|
|
879
|
+
child.stdin?.on("error", (error) => {
|
|
880
|
+
run.tail.push(`lane stdin closed: ${error.message}`);
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function startLaneProcess(run: LaneRun): ChildProcess {
|
|
885
|
+
const child = spawn(
|
|
886
|
+
"npx",
|
|
887
|
+
[
|
|
888
|
+
"tsx",
|
|
889
|
+
overnightScript,
|
|
890
|
+
"lane",
|
|
891
|
+
String(run.lane.issue),
|
|
892
|
+
"--agent",
|
|
893
|
+
run.agent,
|
|
894
|
+
],
|
|
895
|
+
{
|
|
896
|
+
cwd: run.worktree,
|
|
897
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
898
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
899
|
+
},
|
|
900
|
+
);
|
|
901
|
+
keepStdinOpen(child, run);
|
|
902
|
+
watchStages(run, child.stdout);
|
|
903
|
+
keepTail(child.stdout, run.tail);
|
|
904
|
+
keepTail(child.stderr, run.tail);
|
|
905
|
+
return child;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function runLaneProcess(
|
|
909
|
+
run: LaneRun,
|
|
910
|
+
children: Set<ChildProcess>,
|
|
911
|
+
): Promise<number | null> {
|
|
912
|
+
return new Promise((resolve) => {
|
|
913
|
+
const child = startLaneProcess(run);
|
|
914
|
+
children.add(child);
|
|
915
|
+
let settled = false;
|
|
916
|
+
const settle = (code: number | null): void => {
|
|
917
|
+
if (settled) return;
|
|
918
|
+
settled = true;
|
|
919
|
+
children.delete(child);
|
|
920
|
+
resolve(code);
|
|
921
|
+
};
|
|
922
|
+
child.on("error", (error) => {
|
|
923
|
+
run.tail.push(`failed to start: ${error.message}`);
|
|
924
|
+
settle(null);
|
|
925
|
+
});
|
|
926
|
+
child.on("exit", (code) => {
|
|
927
|
+
void drainOutput(child).then(() => settle(code));
|
|
928
|
+
});
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function terminateChild(child: ChildProcess): void {
|
|
933
|
+
try {
|
|
934
|
+
child.kill("SIGTERM");
|
|
935
|
+
} catch (error) {
|
|
936
|
+
console.error(`could not stop lane process: ${errorMessage(error)}`);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const isUnfinished = (run: LaneRun): boolean =>
|
|
941
|
+
run.state !== "done" && run.state !== "failed";
|
|
942
|
+
|
|
943
|
+
function stopLane(run: LaneRun): void {
|
|
944
|
+
run.state = "stopped";
|
|
945
|
+
run.result = laneReport(
|
|
946
|
+
run,
|
|
947
|
+
`${stoppedResult(run.lane.issue, run.stage)}\n${failureLines(run.tail)}`,
|
|
948
|
+
);
|
|
949
|
+
writeLiveResult(run);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function printSummary(runs: LaneRun[]): void {
|
|
953
|
+
try {
|
|
954
|
+
console.log(`\n${combinedResult(runs)}`);
|
|
955
|
+
} catch (error) {
|
|
956
|
+
console.error(`could not print the summary: ${errorMessage(error)}`);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// Writes synchronously and exits without waiting for the lanes: one lane's
|
|
961
|
+
// failed write never keeps the others, the summary, or the exit from happening.
|
|
962
|
+
function stopLanesOnSignal(
|
|
963
|
+
runs: LaneRun[],
|
|
964
|
+
children: Set<ChildProcess>,
|
|
965
|
+
): void {
|
|
966
|
+
let stopping = false;
|
|
967
|
+
const stop = (): void => {
|
|
968
|
+
if (stopping) return;
|
|
969
|
+
stopping = true;
|
|
970
|
+
children.forEach(terminateChild);
|
|
971
|
+
runs.filter(isUnfinished).forEach(stopLane);
|
|
972
|
+
printSummary(runs);
|
|
973
|
+
process.exit(exitCodeTerminated);
|
|
974
|
+
};
|
|
975
|
+
process.on("SIGTERM", stop);
|
|
976
|
+
process.on("SIGINT", stop);
|
|
977
|
+
process.on("SIGHUP", stop);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// O_NOFOLLOW: the lane's sessions run with all tools allowed, so the result
|
|
981
|
+
// file could be a symlink to a secret — never follow it into the main copy.
|
|
982
|
+
function readBoundedFile(file: string): string {
|
|
983
|
+
const descriptor = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
984
|
+
try {
|
|
985
|
+
const { size } = fstatSync(descriptor);
|
|
986
|
+
if (size > maxResultBytes)
|
|
987
|
+
throw new Error(`${file} is ${size} bytes (limit ${maxResultBytes})`);
|
|
988
|
+
return readFileSync(descriptor, "utf-8");
|
|
989
|
+
} finally {
|
|
990
|
+
closeSync(descriptor);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function readWorktreeResult(run: LaneRun): string | null {
|
|
995
|
+
const file = path.join(run.worktree, resultPath(run.lane.issue));
|
|
996
|
+
try {
|
|
997
|
+
return readBoundedFile(file).trim();
|
|
998
|
+
} catch (error) {
|
|
999
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
1000
|
+
run.tail.push(`could not read ${file}: ${errorMessage(error)}`);
|
|
1001
|
+
}
|
|
1002
|
+
return null;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Mirrors the single-issue run: exit code 0 only when every target is done,
|
|
1007
|
+
// and its result ends with "run result: all N targets done".
|
|
1008
|
+
function isLaneDone(code: number | null, result: string | null): boolean {
|
|
1009
|
+
return (
|
|
1010
|
+
code === 0 &&
|
|
1011
|
+
result !== null &&
|
|
1012
|
+
/^run result: all \d+ targets done$/m.test(result)
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function recordExitCode(code: number | null, tail: string[]): void {
|
|
1017
|
+
if (code === 0) return;
|
|
1018
|
+
tail.push(
|
|
1019
|
+
code === null
|
|
1020
|
+
? "lane run did not exit normally"
|
|
1021
|
+
: `lane run exited with code ${code}`,
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function laneReport(run: LaneRun, body: string): string {
|
|
1026
|
+
return [
|
|
1027
|
+
body,
|
|
1028
|
+
`worktree: ${run.worktree}`,
|
|
1029
|
+
`branch: ${branchName(run.lane.issue)}`,
|
|
1030
|
+
].join("\n\n");
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function laneResult(run: LaneRun, worktreeResult: string | null): string {
|
|
1034
|
+
return laneReport(
|
|
1035
|
+
run,
|
|
1036
|
+
worktreeResult ??
|
|
1037
|
+
`#${run.lane.issue} ${run.state}\n${failureLines(run.tail)}`,
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function writeResultFile(run: LaneRun): void {
|
|
1042
|
+
writeFileSync(resultPath(run.lane.issue), `${run.result}\n`);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// A live result that cannot be written leaves the lane's state untouched.
|
|
1046
|
+
function writeLiveResult(run: LaneRun): void {
|
|
1047
|
+
try {
|
|
1048
|
+
writeResultFile(run);
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
console.error(
|
|
1051
|
+
`could not write ${resultPath(run.lane.issue)}: ${errorMessage(error)}`,
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function saveRunningResult(run: LaneRun): void {
|
|
1057
|
+
run.result = laneReport(run, runningResult(run.lane.issue, run.stage));
|
|
1058
|
+
writeLiveResult(run);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// Fails the lane when its main result file cannot be written.
|
|
1062
|
+
function saveLaneResult(run: LaneRun): void {
|
|
1063
|
+
try {
|
|
1064
|
+
writeResultFile(run);
|
|
1065
|
+
} catch (error) {
|
|
1066
|
+
const failure = `could not write ${resultPath(run.lane.issue)}: ${errorMessage(error)}`;
|
|
1067
|
+
console.error(failure);
|
|
1068
|
+
run.state = "failed";
|
|
1069
|
+
run.tail.push(failure);
|
|
1070
|
+
run.result = `${run.result}\n\n${failure}`;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function finishLane(run: LaneRun, code: number | null): void {
|
|
1075
|
+
recordExitCode(code, run.tail);
|
|
1076
|
+
const worktreeResult = readWorktreeResult(run);
|
|
1077
|
+
run.state = isLaneDone(code, worktreeResult) ? "done" : "failed";
|
|
1078
|
+
run.result = laneResult(run, worktreeResult);
|
|
1079
|
+
saveLaneResult(run);
|
|
1080
|
+
setState(run, run.state);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// A lane that cannot get its worktree fails alone; the other lanes go on.
|
|
1084
|
+
async function runLane(
|
|
1085
|
+
run: LaneRun,
|
|
1086
|
+
children: Set<ChildProcess>,
|
|
1087
|
+
): Promise<void> {
|
|
1088
|
+
setState(run, "started");
|
|
1089
|
+
saveRunningResult(run);
|
|
1090
|
+
try {
|
|
1091
|
+
ensureWorktree(run.worktree, run.lane.issue);
|
|
1092
|
+
} catch (error) {
|
|
1093
|
+
run.tail.push(
|
|
1094
|
+
`could not create worktree ${run.worktree}: ${errorMessage(error)}`,
|
|
1095
|
+
);
|
|
1096
|
+
finishLane(run, null);
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
finishLane(run, await runLaneProcess(run, children));
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// A waiting lane starts as soon as a running one ends.
|
|
1103
|
+
async function runPool(
|
|
1104
|
+
runs: LaneRun[],
|
|
1105
|
+
maxParallel: number,
|
|
1106
|
+
runOne: (run: LaneRun) => Promise<void>,
|
|
1107
|
+
): Promise<void> {
|
|
1108
|
+
const waiting = [...runs];
|
|
1109
|
+
const worker = async (): Promise<void> => {
|
|
1110
|
+
for (let run = waiting.shift(); run !== undefined; run = waiting.shift()) {
|
|
1111
|
+
await runOne(run);
|
|
1112
|
+
}
|
|
1113
|
+
};
|
|
1114
|
+
await Promise.all(
|
|
1115
|
+
Array.from({ length: Math.min(maxParallel, runs.length) }, worker),
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
function runResultLine(runs: LaneRun[]): string {
|
|
1120
|
+
return outcomeResultLine(
|
|
1121
|
+
runs.map((run) => ({
|
|
1122
|
+
issue: run.lane.issue,
|
|
1123
|
+
state: run.state,
|
|
1124
|
+
stage: run.stage,
|
|
1125
|
+
})),
|
|
1126
|
+
);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function combinedResult(runs: LaneRun[]): string {
|
|
1130
|
+
return [
|
|
1131
|
+
"--- summary",
|
|
1132
|
+
...runs.map((run) => run.result),
|
|
1133
|
+
runResultLine(runs),
|
|
1134
|
+
].join("\n\n");
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function newLaneRun(lane: TitledLane, agent: AgentName): LaneRun {
|
|
1138
|
+
return {
|
|
1139
|
+
lane,
|
|
1140
|
+
agent,
|
|
1141
|
+
worktree: laneWorktreePath(lane.issue, lane.title),
|
|
1142
|
+
state: "started",
|
|
1143
|
+
stage: laneStartedLabel,
|
|
1144
|
+
tail: [],
|
|
1145
|
+
result: "",
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
export async function runMany(
|
|
1150
|
+
request: LaunchRequest,
|
|
1151
|
+
agentOption: string | null,
|
|
1152
|
+
): Promise<boolean> {
|
|
1153
|
+
const choice = readAgent(agentOption);
|
|
1154
|
+
const checked = await checkedLanes(request, choice);
|
|
1155
|
+
if (checked === null || !choice.ok) return false;
|
|
1156
|
+
// Once, before any lane's worktree exists, so every lane starts from it.
|
|
1157
|
+
const sync = await syncMainCopy();
|
|
1158
|
+
if (!sync.ok) {
|
|
1159
|
+
printFailures([sync.reason]);
|
|
1160
|
+
return false;
|
|
1161
|
+
}
|
|
1162
|
+
if (sync.updated) console.log("main copy brought up to date");
|
|
1163
|
+
const { lanes } = checked;
|
|
1164
|
+
lanes.forEach((lane) => rmSync(resultPath(lane.issue), { force: true }));
|
|
1165
|
+
const { agent } = choice;
|
|
1166
|
+
const runs = lanes.map((lane) => newLaneRun(lane, agent));
|
|
1167
|
+
await keepMacAwakeWhileRunning();
|
|
1168
|
+
printWalkAwayBanner(agent);
|
|
1169
|
+
const children = new Set<ChildProcess>();
|
|
1170
|
+
stopLanesOnSignal(runs, children);
|
|
1171
|
+
await runPool(runs, request.maxParallel ?? runs.length, (run) =>
|
|
1172
|
+
runLane(run, children),
|
|
1173
|
+
);
|
|
1174
|
+
console.log(`\n${combinedResult(runs)}`);
|
|
1175
|
+
return runs.every((run) => run.state === "done");
|
|
1176
|
+
}
|