sequant 2.1.0 → 2.1.2

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.
@@ -0,0 +1,482 @@
1
+ /**
2
+ * RunOrchestrator — CLI-free execution engine for sequant workflows.
3
+ *
4
+ * Owns the full lifecycle: config → issue discovery → dispatch → results.
5
+ * Importable and usable without Commander.js or CLI context.
6
+ *
7
+ * @module
8
+ */
9
+ import chalk from "chalk";
10
+ import { spawnSync } from "child_process";
11
+ import pLimit from "p-limit";
12
+ import { detectDefaultBranch, ensureWorktrees, ensureWorktreesChain, getWorktreeDiffStats, } from "./worktree-manager.js";
13
+ import { LogWriter } from "./log-writer.js";
14
+ import { StateManager } from "./state-manager.js";
15
+ import { ShutdownManager } from "../shutdown.js";
16
+ import { getIssueInfo, sortByDependencies, parseBatches, runIssueWithLogging, } from "./batch-executor.js";
17
+ import { reconcileStateAtStartup } from "./state-utils.js";
18
+ import { getCommitHash } from "./git-diff-utils.js";
19
+ import { MetricsWriter } from "./metrics-writer.js";
20
+ import { determineOutcome } from "./metrics-schema.js";
21
+ import { getTokenUsageForRun } from "./token-utils.js";
22
+ import { resolveRunOptions, buildExecutionConfig } from "./config-resolver.js";
23
+ // ── Orchestrator ────────────────────────────────────────────────────────────
24
+ /**
25
+ * CLI-free workflow execution engine.
26
+ *
27
+ * Two usage modes:
28
+ * 1. Full lifecycle: `RunOrchestrator.run(init, issueNumbers)` — handles
29
+ * services, worktrees, state guard, execution, and metrics.
30
+ * 2. Low-level: `new RunOrchestrator(config).execute(issueNumbers)` — caller
31
+ * manages setup/teardown.
32
+ */
33
+ export class RunOrchestrator {
34
+ cfg;
35
+ constructor(config) {
36
+ this.validate(config);
37
+ this.cfg = config;
38
+ }
39
+ /**
40
+ * Full lifecycle execution — the primary entry point for programmatic use.
41
+ *
42
+ * Handles: config resolution → services setup → state guard →
43
+ * issue discovery → worktree creation → execution → metrics → cleanup.
44
+ */
45
+ static async run(init, issueArgs, batches) {
46
+ const { options, settings, manifest, onProgress } = init;
47
+ // ── Config resolution ──────────────────────────────────────────────
48
+ const mergedOptions = resolveRunOptions(options, settings);
49
+ const baseBranch = init.baseBranch ??
50
+ options.base ??
51
+ settings.run.defaultBase ??
52
+ detectDefaultBranch(mergedOptions.verbose ?? false);
53
+ // ── Parse issues ───────────────────────────────────────────────────
54
+ let issueNumbers;
55
+ let resolvedBatches = batches ?? null;
56
+ if (mergedOptions.batch &&
57
+ mergedOptions.batch.length > 0 &&
58
+ !resolvedBatches) {
59
+ resolvedBatches = parseBatches(mergedOptions.batch);
60
+ issueNumbers = resolvedBatches.flat();
61
+ }
62
+ else if (resolvedBatches) {
63
+ issueNumbers = resolvedBatches.flat();
64
+ }
65
+ else {
66
+ issueNumbers = issueArgs
67
+ .map((i) => parseInt(i, 10))
68
+ .filter((n) => !isNaN(n));
69
+ }
70
+ if (issueNumbers.length === 0) {
71
+ return {
72
+ results: [],
73
+ logPath: null,
74
+ exitCode: 0,
75
+ worktreeMap: new Map(),
76
+ issueInfoMap: new Map(),
77
+ config: buildExecutionConfig(mergedOptions, settings, 0),
78
+ mergedOptions,
79
+ logWriter: null,
80
+ };
81
+ }
82
+ // Sort by dependencies
83
+ if (issueNumbers.length > 1 && !resolvedBatches) {
84
+ issueNumbers = sortByDependencies(issueNumbers);
85
+ }
86
+ // ── Build execution config ─────────────────────────────────────────
87
+ const config = buildExecutionConfig(mergedOptions, settings, issueNumbers.length);
88
+ // ── Services setup ─────────────────────────────────────────────────
89
+ let logWriter = null;
90
+ const shouldLog = !mergedOptions.noLog &&
91
+ !config.dryRun &&
92
+ (mergedOptions.logJson ?? settings.run.logJson);
93
+ if (shouldLog) {
94
+ const runConfig = {
95
+ phases: config.phases,
96
+ sequential: config.sequential,
97
+ qualityLoop: config.qualityLoop,
98
+ maxIterations: config.maxIterations,
99
+ chain: mergedOptions.chain,
100
+ qaGate: mergedOptions.qaGate,
101
+ };
102
+ try {
103
+ logWriter = new LogWriter({
104
+ logPath: mergedOptions.logPath ?? settings.run.logPath,
105
+ verbose: config.verbose,
106
+ startCommit: getCommitHash(process.cwd()),
107
+ });
108
+ await logWriter.initialize(runConfig);
109
+ }
110
+ catch (err) {
111
+ const msg = err instanceof Error ? err.message : String(err);
112
+ console.log(chalk.yellow(` ! Log initialization failed, continuing without logging: ${msg}`));
113
+ logWriter = null;
114
+ }
115
+ }
116
+ let stateManager = null;
117
+ if (!config.dryRun) {
118
+ stateManager = new StateManager({ verbose: config.verbose });
119
+ }
120
+ const shutdown = new ShutdownManager();
121
+ if (logWriter) {
122
+ const writer = logWriter;
123
+ shutdown.registerCleanup("Finalize run logs", async () => {
124
+ await writer.finalize();
125
+ });
126
+ }
127
+ // ── Pre-flight state guard ─────────────────────────────────────────
128
+ if (stateManager && !config.dryRun) {
129
+ try {
130
+ const reconcileResult = await reconcileStateAtStartup({
131
+ verbose: config.verbose,
132
+ });
133
+ if (reconcileResult.success && reconcileResult.advanced.length > 0) {
134
+ console.log(chalk.gray(` State reconciled: ${reconcileResult.advanced.map((n) => `#${n}`).join(", ")} → merged`));
135
+ }
136
+ }
137
+ catch (error) {
138
+ logNonFatalWarning(" ! State reconciliation failed, continuing...", error, config.verbose);
139
+ }
140
+ }
141
+ if (stateManager && !config.dryRun && !mergedOptions.force) {
142
+ const activeIssues = [];
143
+ for (const issueNumber of issueNumbers) {
144
+ try {
145
+ const issueState = await stateManager.getIssueState(issueNumber);
146
+ if (issueState &&
147
+ (issueState.status === "ready_for_merge" ||
148
+ issueState.status === "merged")) {
149
+ console.log(chalk.yellow(` ! #${issueNumber}: already ${issueState.status} — skipping (use --force to re-run)`));
150
+ }
151
+ else {
152
+ activeIssues.push(issueNumber);
153
+ }
154
+ }
155
+ catch (error) {
156
+ logNonFatalWarning(` ! State lookup failed for #${issueNumber}, including anyway...`, error, config.verbose);
157
+ activeIssues.push(issueNumber);
158
+ }
159
+ }
160
+ if (activeIssues.length < issueNumbers.length) {
161
+ issueNumbers = activeIssues;
162
+ if (issueNumbers.length === 0) {
163
+ console.log(chalk.yellow(`\n All issues already completed. Use --force to re-run.`));
164
+ shutdown.dispose();
165
+ return {
166
+ results: [],
167
+ logPath: null,
168
+ exitCode: 0,
169
+ worktreeMap: new Map(),
170
+ issueInfoMap: new Map(),
171
+ config,
172
+ mergedOptions,
173
+ logWriter: null,
174
+ };
175
+ }
176
+ }
177
+ }
178
+ // ── Issue info + worktree setup ────────────────────────────────────
179
+ const issueInfoMap = new Map();
180
+ for (const issueNumber of issueNumbers) {
181
+ issueInfoMap.set(issueNumber, await getIssueInfo(issueNumber));
182
+ }
183
+ const useWorktreeIsolation = mergedOptions.worktreeIsolation !== false && issueNumbers.length > 0;
184
+ let worktreeMap = new Map();
185
+ if (useWorktreeIsolation && !config.dryRun) {
186
+ const issueData = issueNumbers.map((num) => ({
187
+ number: num,
188
+ title: issueInfoMap.get(num)?.title || `Issue #${num}`,
189
+ }));
190
+ if (mergedOptions.chain) {
191
+ worktreeMap = await ensureWorktreesChain(issueData, config.verbose, manifest.packageManager, baseBranch);
192
+ }
193
+ else {
194
+ worktreeMap = await ensureWorktrees(issueData, config.verbose, manifest.packageManager, baseBranch);
195
+ }
196
+ for (const [issueNum, worktree] of worktreeMap.entries()) {
197
+ if (!worktree.existed) {
198
+ shutdown.registerCleanup(`Cleanup worktree for #${issueNum}`, async () => {
199
+ spawnSync("git", ["worktree", "remove", "--force", worktree.path], {
200
+ stdio: "pipe",
201
+ });
202
+ });
203
+ }
204
+ }
205
+ }
206
+ // ── Execute ────────────────────────────────────────────────────────
207
+ let results = [];
208
+ try {
209
+ const orchestrator = new RunOrchestrator({
210
+ config,
211
+ options: mergedOptions,
212
+ issueInfoMap,
213
+ worktreeMap,
214
+ services: { logWriter, stateManager, shutdownManager: shutdown },
215
+ packageManager: manifest.packageManager,
216
+ baseBranch,
217
+ onProgress,
218
+ });
219
+ if (resolvedBatches) {
220
+ for (let batchIdx = 0; batchIdx < resolvedBatches.length; batchIdx++) {
221
+ const batch = resolvedBatches[batchIdx];
222
+ console.log(chalk.blue(`\n Batch ${batchIdx + 1}/${resolvedBatches.length}: Issues ${batch.map((n) => `#${n}`).join(", ")}`));
223
+ const batchResults = await orchestrator.execute(batch);
224
+ results.push(...batchResults);
225
+ const batchFailed = batchResults.some((r) => !r.success);
226
+ if (batchFailed && config.sequential) {
227
+ console.log(chalk.yellow(`\n ! Batch ${batchIdx + 1} failed, stopping batch execution`));
228
+ break;
229
+ }
230
+ }
231
+ }
232
+ else {
233
+ results = await orchestrator.execute(issueNumbers);
234
+ }
235
+ // ── Finalize logs ──────────────────────────────────────────────
236
+ let logPath = null;
237
+ if (logWriter) {
238
+ logPath = await logWriter.finalize({
239
+ endCommit: getCommitHash(process.cwd()),
240
+ });
241
+ }
242
+ // ── Record metrics ─────────────────────────────────────────────
243
+ if (!config.dryRun && results.length > 0) {
244
+ try {
245
+ await RunOrchestrator.recordMetrics(config, mergedOptions, results, worktreeMap, issueNumbers);
246
+ }
247
+ catch (metricsError) {
248
+ logNonFatalWarning(" ! Metrics recording failed, continuing...", metricsError, config.verbose);
249
+ }
250
+ }
251
+ return {
252
+ results,
253
+ logPath,
254
+ exitCode: results.some((r) => !r.success) && !config.dryRun ? 1 : 0,
255
+ worktreeMap,
256
+ issueInfoMap,
257
+ config,
258
+ mergedOptions,
259
+ logWriter,
260
+ };
261
+ }
262
+ finally {
263
+ shutdown.dispose();
264
+ }
265
+ }
266
+ /**
267
+ * Execute workflow for the given issue numbers.
268
+ * Returns one IssueResult per issue.
269
+ */
270
+ async execute(issueNumbers) {
271
+ if (issueNumbers.length === 0) {
272
+ return [];
273
+ }
274
+ const batchCtx = this.buildBatchContext();
275
+ const { config } = this.cfg;
276
+ const options = this.cfg.options;
277
+ // Chain mode implies sequential
278
+ if (options.chain) {
279
+ config.sequential = true;
280
+ }
281
+ if (config.sequential) {
282
+ return this.executeSequential(issueNumbers, batchCtx, options);
283
+ }
284
+ return this.executeParallel(issueNumbers, batchCtx);
285
+ }
286
+ // ── Private helpers ─────────────────────────────────────────────────────
287
+ validate(config) {
288
+ if (!config.config) {
289
+ throw new Error("OrchestratorConfig.config is required");
290
+ }
291
+ if (!config.config.phases ||
292
+ !Array.isArray(config.config.phases) ||
293
+ config.config.phases.length === 0) {
294
+ throw new Error("OrchestratorConfig.config.phases must be a non-empty array");
295
+ }
296
+ }
297
+ buildBatchContext() {
298
+ const { config, options, issueInfoMap, worktreeMap, services } = this.cfg;
299
+ return {
300
+ config,
301
+ options,
302
+ issueInfoMap,
303
+ worktreeMap,
304
+ logWriter: services.logWriter ?? null,
305
+ stateManager: services.stateManager ?? null,
306
+ shutdownManager: services.shutdownManager,
307
+ packageManager: this.cfg.packageManager,
308
+ baseBranch: this.cfg.baseBranch,
309
+ onProgress: this.cfg.onProgress,
310
+ };
311
+ }
312
+ async executeSequential(issueNumbers, batchCtx, options) {
313
+ const results = [];
314
+ const shutdown = this.cfg.services.shutdownManager;
315
+ for (let i = 0; i < issueNumbers.length; i++) {
316
+ const issueNumber = issueNumbers[i];
317
+ if (shutdown?.shuttingDown) {
318
+ break;
319
+ }
320
+ const result = await this.executeOneIssue({
321
+ issueNumber,
322
+ batchCtx,
323
+ chain: options.chain
324
+ ? { enabled: true, isLast: i === issueNumbers.length - 1 }
325
+ : undefined,
326
+ });
327
+ results.push(result);
328
+ if (!result.success) {
329
+ if (options.qaGate && options.chain) {
330
+ const qaFailed = result.phaseResults.some((p) => p.phase === "qa" && !p.success);
331
+ if (qaFailed)
332
+ break;
333
+ }
334
+ break;
335
+ }
336
+ }
337
+ return results;
338
+ }
339
+ async executeParallel(issueNumbers, batchCtx) {
340
+ const limit = pLimit(this.cfg.config.concurrency);
341
+ const shutdown = this.cfg.services.shutdownManager;
342
+ const settledResults = await Promise.allSettled(issueNumbers.map((issueNumber) => limit(async () => {
343
+ if (shutdown?.shuttingDown) {
344
+ return {
345
+ issueNumber,
346
+ success: false,
347
+ phaseResults: [],
348
+ durationSeconds: 0,
349
+ loopTriggered: false,
350
+ };
351
+ }
352
+ return this.executeOneIssue({
353
+ issueNumber,
354
+ batchCtx: { ...batchCtx, onProgress: this.cfg.onProgress },
355
+ parallelIssueNumber: issueNumber,
356
+ });
357
+ })));
358
+ return settledResults.map((settled, i) => {
359
+ if (settled.status === "fulfilled") {
360
+ return settled.value;
361
+ }
362
+ return {
363
+ issueNumber: issueNumbers[i],
364
+ success: false,
365
+ phaseResults: [],
366
+ durationSeconds: 0,
367
+ loopTriggered: false,
368
+ };
369
+ });
370
+ }
371
+ async executeOneIssue(args) {
372
+ const { issueNumber, batchCtx, chain, parallelIssueNumber } = args;
373
+ const { config, options, issueInfoMap, worktreeMap, logWriter, stateManager, shutdownManager, packageManager, baseBranch, onProgress, } = batchCtx;
374
+ const issueInfo = issueInfoMap.get(issueNumber) ?? {
375
+ title: `Issue #${issueNumber}`,
376
+ labels: [],
377
+ };
378
+ const worktreeInfo = worktreeMap.get(issueNumber);
379
+ if (logWriter) {
380
+ logWriter.startIssue(issueNumber, issueInfo.title, issueInfo.labels);
381
+ }
382
+ const ctx = {
383
+ issueNumber,
384
+ title: issueInfo.title,
385
+ labels: issueInfo.labels,
386
+ config,
387
+ options,
388
+ services: { logWriter, stateManager, shutdownManager },
389
+ worktree: worktreeInfo
390
+ ? { path: worktreeInfo.path, branch: worktreeInfo.branch }
391
+ : undefined,
392
+ chain,
393
+ packageManager,
394
+ baseBranch,
395
+ onProgress,
396
+ };
397
+ const result = await runIssueWithLogging(ctx);
398
+ if (logWriter && result.prNumber && result.prUrl) {
399
+ logWriter.setPRInfo(result.prNumber, result.prUrl, parallelIssueNumber);
400
+ }
401
+ if (logWriter) {
402
+ logWriter.completeIssue(parallelIssueNumber);
403
+ }
404
+ return result;
405
+ }
406
+ static async recordMetrics(config, mergedOptions, results, worktreeMap, issueNumbers) {
407
+ const metricsWriter = new MetricsWriter({ verbose: config.verbose });
408
+ const totalDuration = results.reduce((sum, r) => sum + (r.durationSeconds ?? 0), 0);
409
+ const allPhases = new Set();
410
+ for (const result of results) {
411
+ for (const pr of result.phaseResults) {
412
+ const phase = pr.phase;
413
+ if ([
414
+ "spec",
415
+ "security-review",
416
+ "testgen",
417
+ "exec",
418
+ "test",
419
+ "qa",
420
+ "loop",
421
+ ].includes(phase)) {
422
+ allPhases.add(phase);
423
+ }
424
+ }
425
+ }
426
+ let totalFilesChanged = 0;
427
+ let totalLinesAdded = 0;
428
+ let totalQaIterations = 0;
429
+ for (const result of results) {
430
+ const wt = worktreeMap.get(result.issueNumber);
431
+ if (wt?.path) {
432
+ const s = getWorktreeDiffStats(wt.path);
433
+ totalFilesChanged += s.filesChanged;
434
+ totalLinesAdded += s.linesAdded;
435
+ }
436
+ if (result.loopTriggered) {
437
+ totalQaIterations += result.phaseResults.filter((p) => p.phase === "loop").length;
438
+ }
439
+ }
440
+ const cliFlags = [];
441
+ if (mergedOptions.sequential)
442
+ cliFlags.push("--sequential");
443
+ if (mergedOptions.chain)
444
+ cliFlags.push("--chain");
445
+ if (mergedOptions.qaGate)
446
+ cliFlags.push("--qa-gate");
447
+ if (mergedOptions.qualityLoop)
448
+ cliFlags.push("--quality-loop");
449
+ if (mergedOptions.testgen)
450
+ cliFlags.push("--testgen");
451
+ const tokenUsage = getTokenUsageForRun(undefined, true);
452
+ const passed = results.filter((r) => r.success).length;
453
+ await metricsWriter.recordRun({
454
+ issues: issueNumbers,
455
+ phases: Array.from(allPhases),
456
+ outcome: determineOutcome(passed, results.length),
457
+ duration: totalDuration,
458
+ model: process.env.ANTHROPIC_MODEL ?? "opus",
459
+ flags: cliFlags,
460
+ metrics: {
461
+ tokensUsed: tokenUsage.tokensUsed,
462
+ filesChanged: totalFilesChanged,
463
+ linesAdded: totalLinesAdded,
464
+ acceptanceCriteria: 0,
465
+ qaIterations: totalQaIterations,
466
+ inputTokens: tokenUsage.inputTokens || undefined,
467
+ outputTokens: tokenUsage.outputTokens || undefined,
468
+ cacheTokens: tokenUsage.cacheTokens || undefined,
469
+ },
470
+ });
471
+ if (config.verbose) {
472
+ console.log(chalk.gray(" Metrics recorded to .sequant/metrics.json"));
473
+ }
474
+ }
475
+ }
476
+ /** Log a non-fatal warning: one-line summary always, detail in verbose. */
477
+ export function logNonFatalWarning(message, error, verbose) {
478
+ console.log(chalk.yellow(message));
479
+ if (verbose) {
480
+ console.log(chalk.gray(` ${error}`));
481
+ }
482
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sequant",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
4
4
  "description": "Quantize your development workflow - Sequential AI phases with quality gates",
5
5
  "type": "module",
6
6
  "bin": {