sequant 2.1.2 → 2.3.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-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +73 -0
- package/dist/bin/cli.js +95 -9
- package/dist/src/commands/doctor.d.ts +25 -0
- package/dist/src/commands/doctor.js +36 -1
- package/dist/src/commands/init.d.ts +1 -0
- package/dist/src/commands/init.js +118 -0
- package/dist/src/commands/locks.d.ts +67 -0
- package/dist/src/commands/locks.js +290 -0
- package/dist/src/commands/merge.js +11 -0
- package/dist/src/commands/prompt.d.ts +39 -0
- package/dist/src/commands/prompt.js +179 -0
- package/dist/src/commands/run-display.d.ts +26 -0
- package/dist/src/commands/run-display.js +150 -0
- package/dist/src/commands/run-progress.d.ts +32 -0
- package/dist/src/commands/run-progress.js +76 -0
- package/dist/src/commands/run.js +83 -73
- package/dist/src/commands/stats.d.ts +2 -0
- package/dist/src/commands/stats.js +94 -8
- package/dist/src/commands/status.js +27 -1
- package/dist/src/commands/watch.d.ts +16 -0
- package/dist/src/commands/watch.js +147 -0
- package/dist/src/lib/ac-linter.d.ts +1 -1
- package/dist/src/lib/ac-linter.js +81 -0
- package/dist/src/lib/assess-collision-detect.d.ts +91 -0
- package/dist/src/lib/assess-collision-detect.js +217 -0
- package/dist/src/lib/assess-comment-parser.d.ts +59 -1
- package/dist/src/lib/assess-comment-parser.js +124 -2
- package/dist/src/lib/cli-ui/format.d.ts +19 -0
- package/dist/src/lib/cli-ui/format.js +34 -0
- package/dist/src/lib/cli-ui/run-renderer-types.d.ts +181 -0
- package/dist/src/lib/cli-ui/run-renderer-types.js +7 -0
- package/dist/src/lib/cli-ui/run-renderer.d.ts +239 -0
- package/dist/src/lib/cli-ui/run-renderer.js +1173 -0
- package/dist/src/lib/heuristics/behavior-rule-detector.d.ts +94 -0
- package/dist/src/lib/heuristics/behavior-rule-detector.js +467 -0
- package/dist/src/lib/locks/index.d.ts +7 -0
- package/dist/src/lib/locks/index.js +5 -0
- package/dist/src/lib/locks/lock-manager.d.ts +168 -0
- package/dist/src/lib/locks/lock-manager.js +433 -0
- package/dist/src/lib/locks/types.d.ts +59 -0
- package/dist/src/lib/locks/types.js +31 -0
- package/dist/src/lib/qa/markdown-only-ci.d.ts +46 -0
- package/dist/src/lib/qa/markdown-only-ci.js +74 -0
- package/dist/src/lib/relay/activation.d.ts +60 -0
- package/dist/src/lib/relay/activation.js +122 -0
- package/dist/src/lib/relay/archive.d.ts +34 -0
- package/dist/src/lib/relay/archive.js +106 -0
- package/dist/src/lib/relay/frame.d.ts +20 -0
- package/dist/src/lib/relay/frame.js +76 -0
- package/dist/src/lib/relay/index.d.ts +13 -0
- package/dist/src/lib/relay/index.js +13 -0
- package/dist/src/lib/relay/paths.d.ts +43 -0
- package/dist/src/lib/relay/paths.js +59 -0
- package/dist/src/lib/relay/pid.d.ts +34 -0
- package/dist/src/lib/relay/pid.js +72 -0
- package/dist/src/lib/relay/reader.d.ts +35 -0
- package/dist/src/lib/relay/reader.js +115 -0
- package/dist/src/lib/relay/types.d.ts +68 -0
- package/dist/src/lib/relay/types.js +76 -0
- package/dist/src/lib/relay/writer.d.ts +48 -0
- package/dist/src/lib/relay/writer.js +113 -0
- package/dist/src/lib/settings.d.ts +31 -1
- package/dist/src/lib/settings.js +18 -3
- package/dist/src/lib/skill-version.d.ts +19 -0
- package/dist/src/lib/skill-version.js +68 -0
- package/dist/src/lib/templates.d.ts +1 -0
- package/dist/src/lib/templates.js +1 -1
- package/dist/src/lib/version-check.d.ts +60 -5
- package/dist/src/lib/version-check.js +97 -9
- package/dist/src/lib/workflow/batch-executor.d.ts +20 -1
- package/dist/src/lib/workflow/batch-executor.js +249 -176
- package/dist/src/lib/workflow/config-resolver.js +4 -0
- package/dist/src/lib/workflow/heartbeat.d.ts +71 -0
- package/dist/src/lib/workflow/heartbeat.js +194 -0
- package/dist/src/lib/workflow/phase-executor.d.ts +88 -3
- package/dist/src/lib/workflow/phase-executor.js +276 -52
- package/dist/src/lib/workflow/phase-mapper.d.ts +3 -2
- package/dist/src/lib/workflow/phase-mapper.js +17 -20
- package/dist/src/lib/workflow/platforms/github.d.ts +1 -1
- package/dist/src/lib/workflow/platforms/github.js +20 -3
- package/dist/src/lib/workflow/pr-status.d.ts +18 -2
- package/dist/src/lib/workflow/pr-status.js +41 -9
- package/dist/src/lib/workflow/qa-stagnation.d.ts +117 -0
- package/dist/src/lib/workflow/qa-stagnation.js +179 -0
- package/dist/src/lib/workflow/run-orchestrator.d.ts +76 -0
- package/dist/src/lib/workflow/run-orchestrator.js +382 -29
- package/dist/src/lib/workflow/run-reflect.js +1 -1
- package/dist/src/lib/workflow/run-state.d.ts +71 -0
- package/dist/src/lib/workflow/run-state.js +14 -0
- package/dist/src/lib/workflow/state-cleanup.d.ts +13 -5
- package/dist/src/lib/workflow/state-cleanup.js +17 -5
- package/dist/src/lib/workflow/state-manager.d.ts +12 -1
- package/dist/src/lib/workflow/state-manager.js +37 -0
- package/dist/src/lib/workflow/state-schema.d.ts +62 -0
- package/dist/src/lib/workflow/state-schema.js +35 -1
- package/dist/src/lib/workflow/types.d.ts +74 -1
- package/dist/src/lib/workflow/worktree-manager.d.ts +12 -4
- package/dist/src/lib/workflow/worktree-manager.js +76 -17
- package/dist/src/mcp/tools/run.d.ts +44 -0
- package/dist/src/mcp/tools/run.js +104 -13
- package/dist/src/ui/tui/App.d.ts +14 -0
- package/dist/src/ui/tui/App.js +41 -0
- package/dist/src/ui/tui/ElapsedTimer.d.ts +10 -0
- package/dist/src/ui/tui/ElapsedTimer.js +31 -0
- package/dist/src/ui/tui/Header.d.ts +6 -0
- package/dist/src/ui/tui/Header.js +15 -0
- package/dist/src/ui/tui/IssueBox.d.ts +16 -0
- package/dist/src/ui/tui/IssueBox.js +68 -0
- package/dist/src/ui/tui/Spinner.d.ts +9 -0
- package/dist/src/ui/tui/Spinner.js +18 -0
- package/dist/src/ui/tui/index.d.ts +15 -0
- package/dist/src/ui/tui/index.js +29 -0
- package/dist/src/ui/tui/theme.d.ts +29 -0
- package/dist/src/ui/tui/theme.js +52 -0
- package/dist/src/ui/tui/truncate.d.ts +11 -0
- package/dist/src/ui/tui/truncate.js +31 -0
- package/package.json +10 -3
- package/templates/agents/sequant-explorer.md +1 -0
- package/templates/agents/sequant-qa-checker.md +2 -1
- package/templates/agents/sequant-testgen.md +1 -0
- package/templates/hooks/post-tool.sh +11 -0
- package/templates/hooks/pre-tool.sh +18 -9
- package/templates/hooks/relay-check.sh +107 -0
- package/templates/relay/frame.txt +11 -0
- package/templates/scripts/cleanup-worktree.sh +25 -3
- package/templates/scripts/new-feature.sh +6 -0
- package/templates/skills/_shared/references/behavior-rule-detection.md +205 -0
- package/templates/skills/_shared/references/subagent-types.md +21 -8
- package/templates/skills/assess/SKILL.md +261 -94
- package/templates/skills/assess/references/predicted-collision-detection.md +109 -0
- package/templates/skills/docs/SKILL.md +141 -22
- package/templates/skills/exec/SKILL.md +10 -49
- package/templates/skills/fullsolve/SKILL.md +80 -32
- package/templates/skills/loop/SKILL.md +28 -0
- package/templates/skills/merger/SKILL.md +621 -0
- package/templates/skills/qa/SKILL.md +746 -8
- package/templates/skills/qa/scripts/quality-checks.sh +47 -1
- package/templates/skills/setup/SKILL.md +6 -0
- package/templates/skills/spec/SKILL.md +217 -964
- package/templates/skills/spec/references/parallel-groups.md +7 -0
- package/templates/skills/spec/references/quality-checklist.md +75 -0
- package/templates/skills/spec/references/recommended-workflow.md +4 -2
- package/templates/skills/test/SKILL.md +0 -27
- package/templates/skills/testgen/SKILL.md +24 -44
|
@@ -9,17 +9,48 @@
|
|
|
9
9
|
import chalk from "chalk";
|
|
10
10
|
import { spawnSync } from "child_process";
|
|
11
11
|
import pLimit from "p-limit";
|
|
12
|
+
import { formatCoarseNowLine } from "./run-state.js";
|
|
12
13
|
import { detectDefaultBranch, ensureWorktrees, ensureWorktreesChain, getWorktreeDiffStats, } from "./worktree-manager.js";
|
|
13
14
|
import { LogWriter } from "./log-writer.js";
|
|
14
15
|
import { StateManager } from "./state-manager.js";
|
|
15
16
|
import { ShutdownManager } from "../shutdown.js";
|
|
16
|
-
import {
|
|
17
|
+
import { LockManager, formatLockedMessage } from "../locks/index.js";
|
|
18
|
+
/** Human-readable line for the run-orchestrator's `--signal-other` log (#637). */
|
|
19
|
+
function formatSignalLine(issue, pid, result) {
|
|
20
|
+
switch (result.reason) {
|
|
21
|
+
case "sent":
|
|
22
|
+
return ` Signaled PID ${pid} (SIGTERM) for #${issue}`;
|
|
23
|
+
case "cross-host":
|
|
24
|
+
return ` Could not signal PID ${pid} for #${issue} (cross-host holder)`;
|
|
25
|
+
case "self-or-parent":
|
|
26
|
+
return ` Refused to signal PID ${pid} for #${issue} (matches this process or its parent)`;
|
|
27
|
+
case "pid-dead":
|
|
28
|
+
return ` Could not signal PID ${pid} for #${issue} (already exited)`;
|
|
29
|
+
case "kill-failed":
|
|
30
|
+
return ` Could not signal PID ${pid} for #${issue} (kill syscall failed)`;
|
|
31
|
+
case "orchestrator":
|
|
32
|
+
return ` Skipped signal for #${issue} (orchestrator mode)`;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
import { getIssueInfo, sortByDependencies, parseBatches, runIssueWithLogging, emitRunIdLine, } from "./batch-executor.js";
|
|
17
36
|
import { reconcileStateAtStartup } from "./state-utils.js";
|
|
18
37
|
import { getCommitHash } from "./git-diff-utils.js";
|
|
19
38
|
import { MetricsWriter } from "./metrics-writer.js";
|
|
20
39
|
import { determineOutcome } from "./metrics-schema.js";
|
|
21
40
|
import { getTokenUsageForRun } from "./token-utils.js";
|
|
22
41
|
import { resolveRunOptions, buildExecutionConfig } from "./config-resolver.js";
|
|
42
|
+
/**
|
|
43
|
+
* Build the stack-manifest line emitted into PR bodies under --stacked.
|
|
44
|
+
*
|
|
45
|
+
* Example for issues `[100, 101, 102]` at `currentIndex=1`:
|
|
46
|
+
* `Part of stack: #100 → #101 (this) → #102`
|
|
47
|
+
*
|
|
48
|
+
* @internal Exported for testing.
|
|
49
|
+
*/
|
|
50
|
+
export function buildStackManifest(issueNumbers, currentIndex) {
|
|
51
|
+
const parts = issueNumbers.map((n, i) => i === currentIndex ? `#${n} (this)` : `#${n}`);
|
|
52
|
+
return `Part of stack: ${parts.join(" → ")}`;
|
|
53
|
+
}
|
|
23
54
|
// ── Orchestrator ────────────────────────────────────────────────────────────
|
|
24
55
|
/**
|
|
25
56
|
* CLI-free workflow execution engine.
|
|
@@ -32,25 +63,143 @@ import { resolveRunOptions, buildExecutionConfig } from "./config-resolver.js";
|
|
|
32
63
|
*/
|
|
33
64
|
export class RunOrchestrator {
|
|
34
65
|
cfg;
|
|
66
|
+
issueStates = new Map();
|
|
67
|
+
phaseStartTimes = new Map();
|
|
68
|
+
done = false;
|
|
35
69
|
constructor(config) {
|
|
36
70
|
this.validate(config);
|
|
37
|
-
this.cfg = config;
|
|
71
|
+
this.cfg = { ...config, onProgress: this.wrapProgress(config.onProgress) };
|
|
72
|
+
this.initIssueStates();
|
|
38
73
|
}
|
|
39
74
|
/**
|
|
40
|
-
*
|
|
75
|
+
* Point-in-time view of the entire run.
|
|
41
76
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
77
|
+
* Safe under concurrent reads: the returned object contains only freshly
|
|
78
|
+
* allocated arrays and plain records; no internal Map or mutable state
|
|
79
|
+
* reference is leaked. Callers may hold snapshots across awaits without
|
|
80
|
+
* observing torn writes.
|
|
44
81
|
*/
|
|
45
|
-
|
|
46
|
-
const {
|
|
47
|
-
|
|
82
|
+
getSnapshot() {
|
|
83
|
+
const { config } = this.cfg;
|
|
84
|
+
const snapshotConfig = {
|
|
85
|
+
concurrency: config.concurrency,
|
|
86
|
+
baseBranch: this.cfg.baseBranch ?? "main",
|
|
87
|
+
qualityLoop: config.qualityLoop,
|
|
88
|
+
};
|
|
89
|
+
const issues = [];
|
|
90
|
+
for (const state of this.issueStates.values()) {
|
|
91
|
+
issues.push(cloneIssueState(state));
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
config: snapshotConfig,
|
|
95
|
+
issues,
|
|
96
|
+
done: this.done,
|
|
97
|
+
capturedAt: new Date(),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** Mark the run as completed so the dashboard can unmount. */
|
|
101
|
+
markDone() {
|
|
102
|
+
this.done = true;
|
|
103
|
+
}
|
|
104
|
+
initIssueStates() {
|
|
105
|
+
const { issueInfoMap, worktreeMap, config } = this.cfg;
|
|
106
|
+
for (const [num, info] of issueInfoMap.entries()) {
|
|
107
|
+
const branch = worktreeMap.get(num)?.branch ?? `#${num}`;
|
|
108
|
+
const phases = config.phases.map((name) => ({
|
|
109
|
+
name,
|
|
110
|
+
status: "pending",
|
|
111
|
+
}));
|
|
112
|
+
this.issueStates.set(num, {
|
|
113
|
+
number: num,
|
|
114
|
+
title: info.title,
|
|
115
|
+
branch,
|
|
116
|
+
status: "queued",
|
|
117
|
+
phases,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
wrapProgress(external) {
|
|
122
|
+
return (issue, phase, event, extra) => {
|
|
123
|
+
this.applyProgressEvent(issue, phase, event, extra);
|
|
124
|
+
external?.(issue, phase, event, extra);
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
applyProgressEvent(issue, phase, event, extra) {
|
|
128
|
+
const state = this.issueStates.get(issue);
|
|
129
|
+
if (!state)
|
|
130
|
+
return;
|
|
131
|
+
if (event === "start") {
|
|
132
|
+
if (!state.startedAt)
|
|
133
|
+
state.startedAt = new Date();
|
|
134
|
+
state.status = "running";
|
|
135
|
+
const now = new Date();
|
|
136
|
+
this.phaseStartTimes.set(`${issue}:${phase}`, now.getTime());
|
|
137
|
+
state.currentPhase = {
|
|
138
|
+
name: phase,
|
|
139
|
+
startedAt: now,
|
|
140
|
+
lastActivityAt: now,
|
|
141
|
+
nowLine: formatCoarseNowLine(phase),
|
|
142
|
+
};
|
|
143
|
+
const p = findOrAppendPhase(state, phase);
|
|
144
|
+
p.status = "running";
|
|
145
|
+
p.startedAt = now;
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (event === "activity") {
|
|
149
|
+
// Ignore activity for stale phases (race between completion and a
|
|
150
|
+
// final flushed output chunk).
|
|
151
|
+
if (!state.currentPhase || state.currentPhase.name !== phase)
|
|
152
|
+
return;
|
|
153
|
+
const line = extractActivityLine(extra?.text);
|
|
154
|
+
if (!line)
|
|
155
|
+
return;
|
|
156
|
+
state.currentPhase.nowLine = line;
|
|
157
|
+
state.currentPhase.lastActivityAt = new Date();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
// complete / failed
|
|
161
|
+
const key = `${issue}:${phase}`;
|
|
162
|
+
const startMs = this.phaseStartTimes.get(key);
|
|
163
|
+
this.phaseStartTimes.delete(key);
|
|
164
|
+
const elapsedMs = extra?.durationSeconds != null
|
|
165
|
+
? extra.durationSeconds * 1000
|
|
166
|
+
: startMs != null
|
|
167
|
+
? Date.now() - startMs
|
|
168
|
+
: undefined;
|
|
169
|
+
const p = findOrAppendPhase(state, phase);
|
|
170
|
+
p.status = event === "complete" ? "done" : "failed";
|
|
171
|
+
p.elapsedMs = elapsedMs;
|
|
172
|
+
state.currentPhase = undefined;
|
|
173
|
+
if (event === "failed") {
|
|
174
|
+
state.status = "failed";
|
|
175
|
+
state.completedAt = new Date();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
// Completed phase: if it's the last phase in the plan, mark issue passed.
|
|
179
|
+
const allDone = state.phases.every((ph) => ph.status === "done" || ph.status === "failed");
|
|
180
|
+
if (allDone) {
|
|
181
|
+
state.status = state.phases.some((ph) => ph.status === "failed")
|
|
182
|
+
? "failed"
|
|
183
|
+
: "passed";
|
|
184
|
+
state.completedAt = new Date();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Pure config resolution — no side effects.
|
|
189
|
+
*
|
|
190
|
+
* Produces a `ResolvedRun` containing merged options, execution config,
|
|
191
|
+
* parsed/sorted issue numbers, base branch, and display-only flags. Safe
|
|
192
|
+
* to call for preview purposes (e.g. CLI config display before run).
|
|
193
|
+
*
|
|
194
|
+
* `run()` uses this internally to avoid duplicating resolution logic.
|
|
195
|
+
*/
|
|
196
|
+
static resolveConfig(init, issueArgs, batches) {
|
|
197
|
+
const { options, settings, manifest } = init;
|
|
48
198
|
const mergedOptions = resolveRunOptions(options, settings);
|
|
49
199
|
const baseBranch = init.baseBranch ??
|
|
50
200
|
options.base ??
|
|
51
201
|
settings.run.defaultBase ??
|
|
52
202
|
detectDefaultBranch(mergedOptions.verbose ?? false);
|
|
53
|
-
// ── Parse issues ───────────────────────────────────────────────────
|
|
54
203
|
let issueNumbers;
|
|
55
204
|
let resolvedBatches = batches ?? null;
|
|
56
205
|
if (mergedOptions.batch &&
|
|
@@ -67,6 +216,39 @@ export class RunOrchestrator {
|
|
|
67
216
|
.map((i) => parseInt(i, 10))
|
|
68
217
|
.filter((n) => !isNaN(n));
|
|
69
218
|
}
|
|
219
|
+
if (issueNumbers.length > 1 && !resolvedBatches) {
|
|
220
|
+
issueNumbers = sortByDependencies(issueNumbers);
|
|
221
|
+
}
|
|
222
|
+
const config = buildExecutionConfig(mergedOptions, settings, issueNumbers.length);
|
|
223
|
+
const logEnabled = !mergedOptions.noLog &&
|
|
224
|
+
!config.dryRun &&
|
|
225
|
+
(mergedOptions.logJson ?? settings.run.logJson ?? false);
|
|
226
|
+
return {
|
|
227
|
+
mergedOptions,
|
|
228
|
+
config,
|
|
229
|
+
issueNumbers,
|
|
230
|
+
batches: resolvedBatches,
|
|
231
|
+
baseBranch,
|
|
232
|
+
stack: manifest.stack,
|
|
233
|
+
autoDetectPhases: mergedOptions.autoDetectPhases ?? false,
|
|
234
|
+
worktreeIsolationEnabled: mergedOptions.worktreeIsolation !== false && issueNumbers.length > 0,
|
|
235
|
+
logEnabled,
|
|
236
|
+
stateEnabled: !config.dryRun,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Full lifecycle execution — the primary entry point for programmatic use.
|
|
241
|
+
*
|
|
242
|
+
* Handles: config resolution → services setup → state guard →
|
|
243
|
+
* issue discovery → worktree creation → execution → metrics → cleanup.
|
|
244
|
+
*/
|
|
245
|
+
static async run(init, issueArgs, batches) {
|
|
246
|
+
const { manifest, onProgress, settings } = init;
|
|
247
|
+
// ── Config resolution ──────────────────────────────────────────────
|
|
248
|
+
const resolved = RunOrchestrator.resolveConfig(init, issueArgs, batches);
|
|
249
|
+
const { mergedOptions, config, baseBranch } = resolved;
|
|
250
|
+
let { issueNumbers } = resolved;
|
|
251
|
+
const resolvedBatches = resolved.batches;
|
|
70
252
|
if (issueNumbers.length === 0) {
|
|
71
253
|
return {
|
|
72
254
|
results: [],
|
|
@@ -74,17 +256,11 @@ export class RunOrchestrator {
|
|
|
74
256
|
exitCode: 0,
|
|
75
257
|
worktreeMap: new Map(),
|
|
76
258
|
issueInfoMap: new Map(),
|
|
77
|
-
config
|
|
259
|
+
config,
|
|
78
260
|
mergedOptions,
|
|
79
261
|
logWriter: null,
|
|
80
262
|
};
|
|
81
263
|
}
|
|
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
264
|
// ── Services setup ─────────────────────────────────────────────────
|
|
89
265
|
let logWriter = null;
|
|
90
266
|
const shouldLog = !mergedOptions.noLog &&
|
|
@@ -106,6 +282,9 @@ export class RunOrchestrator {
|
|
|
106
282
|
startCommit: getCommitHash(process.cwd()),
|
|
107
283
|
});
|
|
108
284
|
await logWriter.initialize(runConfig);
|
|
285
|
+
const runId = logWriter.getRunId();
|
|
286
|
+
if (runId)
|
|
287
|
+
emitRunIdLine(runId);
|
|
109
288
|
}
|
|
110
289
|
catch (err) {
|
|
111
290
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -175,6 +354,63 @@ export class RunOrchestrator {
|
|
|
175
354
|
}
|
|
176
355
|
}
|
|
177
356
|
}
|
|
357
|
+
// ── Concurrency lock (#625) ────────────────────────────────────────
|
|
358
|
+
// Acquired here — after the state guard, before worktree creation — so
|
|
359
|
+
// that issues already filtered as ready_for_merge don't claim locks they
|
|
360
|
+
// wouldn't release. Locked issues are skipped from the run with a
|
|
361
|
+
// synthetic IssueResult so the batch continues.
|
|
362
|
+
const lockManager = new LockManager();
|
|
363
|
+
const lockedResults = [];
|
|
364
|
+
if (!lockManager.isNoop && !config.dryRun) {
|
|
365
|
+
const commandLabel = `npx sequant run ${issueNumbers.join(" ")}`;
|
|
366
|
+
const claimed = [];
|
|
367
|
+
for (const issueNumber of issueNumbers) {
|
|
368
|
+
const claim = mergedOptions.force
|
|
369
|
+
? (() => {
|
|
370
|
+
const { previous } = lockManager.forceAcquire(issueNumber, commandLabel);
|
|
371
|
+
if (previous && mergedOptions.signalOther) {
|
|
372
|
+
const result = lockManager.signalOther(previous);
|
|
373
|
+
console.log(chalk.gray(formatSignalLine(issueNumber, previous.pid, result)));
|
|
374
|
+
}
|
|
375
|
+
return { acquired: true };
|
|
376
|
+
})()
|
|
377
|
+
: lockManager.acquire(issueNumber, commandLabel);
|
|
378
|
+
if (claim.acquired) {
|
|
379
|
+
claimed.push(issueNumber);
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
lockedResults.push(buildLockedResult(issueNumber, claim.holder));
|
|
383
|
+
console.log(chalk.yellow(` ! ${formatLockedMessage(issueNumber, claim.holder)}`));
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
issueNumbers = claimed;
|
|
387
|
+
if (claimed.length > 0) {
|
|
388
|
+
shutdown.registerCleanup("Release issue locks", async () => {
|
|
389
|
+
lockManager.releaseAll();
|
|
390
|
+
});
|
|
391
|
+
// Sync cleanup for SIGKILL / uncaughtException paths. process.on('exit')
|
|
392
|
+
// only fires sync handlers; this is the best-effort safety net for
|
|
393
|
+
// events ShutdownManager doesn't catch.
|
|
394
|
+
const exitHandler = () => lockManager.releaseAll();
|
|
395
|
+
process.on("exit", exitHandler);
|
|
396
|
+
shutdown.registerCleanup("Detach exit-handler", async () => {
|
|
397
|
+
process.off("exit", exitHandler);
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
if (issueNumbers.length === 0) {
|
|
401
|
+
shutdown.dispose();
|
|
402
|
+
return {
|
|
403
|
+
results: lockedResults,
|
|
404
|
+
logPath: null,
|
|
405
|
+
exitCode: lockedResults.length > 0 ? 1 : 0,
|
|
406
|
+
worktreeMap: new Map(),
|
|
407
|
+
issueInfoMap: new Map(),
|
|
408
|
+
config,
|
|
409
|
+
mergedOptions,
|
|
410
|
+
logWriter: null,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
}
|
|
178
414
|
// ── Issue info + worktree setup ────────────────────────────────────
|
|
179
415
|
const issueInfoMap = new Map();
|
|
180
416
|
for (const issueNumber of issueNumbers) {
|
|
@@ -205,17 +441,18 @@ export class RunOrchestrator {
|
|
|
205
441
|
}
|
|
206
442
|
// ── Execute ────────────────────────────────────────────────────────
|
|
207
443
|
let results = [];
|
|
444
|
+
const orchestrator = new RunOrchestrator({
|
|
445
|
+
config,
|
|
446
|
+
options: mergedOptions,
|
|
447
|
+
issueInfoMap,
|
|
448
|
+
worktreeMap,
|
|
449
|
+
services: { logWriter, stateManager, shutdownManager: shutdown },
|
|
450
|
+
packageManager: manifest.packageManager,
|
|
451
|
+
baseBranch,
|
|
452
|
+
onProgress,
|
|
453
|
+
});
|
|
454
|
+
init.onOrchestratorReady?.(orchestrator);
|
|
208
455
|
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
456
|
if (resolvedBatches) {
|
|
220
457
|
for (let batchIdx = 0; batchIdx < resolvedBatches.length; batchIdx++) {
|
|
221
458
|
const batch = resolvedBatches[batchIdx];
|
|
@@ -248,10 +485,11 @@ export class RunOrchestrator {
|
|
|
248
485
|
logNonFatalWarning(" ! Metrics recording failed, continuing...", metricsError, config.verbose);
|
|
249
486
|
}
|
|
250
487
|
}
|
|
488
|
+
const allResults = [...lockedResults, ...results];
|
|
251
489
|
return {
|
|
252
|
-
results,
|
|
490
|
+
results: allResults,
|
|
253
491
|
logPath,
|
|
254
|
-
exitCode:
|
|
492
|
+
exitCode: allResults.some((r) => !r.success) && !config.dryRun ? 1 : 0,
|
|
255
493
|
worktreeMap,
|
|
256
494
|
issueInfoMap,
|
|
257
495
|
config,
|
|
@@ -260,6 +498,7 @@ export class RunOrchestrator {
|
|
|
260
498
|
};
|
|
261
499
|
}
|
|
262
500
|
finally {
|
|
501
|
+
orchestrator.markDone();
|
|
263
502
|
shutdown.dispose();
|
|
264
503
|
}
|
|
265
504
|
}
|
|
@@ -317,11 +556,34 @@ export class RunOrchestrator {
|
|
|
317
556
|
if (shutdown?.shuttingDown) {
|
|
318
557
|
break;
|
|
319
558
|
}
|
|
559
|
+
// #605: under --stacked, non-first PRs target the predecessor branch.
|
|
560
|
+
// The final PR still targets `main` (AC-3 open-question default) so the
|
|
561
|
+
// stack can land partially. Manifest renders for every PR in the stack.
|
|
562
|
+
let predecessorBranch;
|
|
563
|
+
let stackManifest;
|
|
564
|
+
if (options.chain && options.stacked) {
|
|
565
|
+
if (i > 0 && i < issueNumbers.length - 1) {
|
|
566
|
+
// Invariant: chain breaks on prior failure (see `break` below), so the
|
|
567
|
+
// predecessor's worktree is always in worktreeMap when we reach this
|
|
568
|
+
// branch. The optional-chained fallback to undefined is unreachable.
|
|
569
|
+
predecessorBranch = this.cfg.worktreeMap.get(issueNumbers[i - 1])?.branch;
|
|
570
|
+
}
|
|
571
|
+
else if (i > 0) {
|
|
572
|
+
// Last PR: still emit manifest, but base stays main (no predecessor).
|
|
573
|
+
// intentionally undefined predecessorBranch
|
|
574
|
+
}
|
|
575
|
+
stackManifest = buildStackManifest(issueNumbers, i);
|
|
576
|
+
}
|
|
320
577
|
const result = await this.executeOneIssue({
|
|
321
578
|
issueNumber,
|
|
322
579
|
batchCtx,
|
|
323
580
|
chain: options.chain
|
|
324
|
-
? {
|
|
581
|
+
? {
|
|
582
|
+
enabled: true,
|
|
583
|
+
isLast: i === issueNumbers.length - 1,
|
|
584
|
+
predecessorBranch,
|
|
585
|
+
stackManifest,
|
|
586
|
+
}
|
|
325
587
|
: undefined,
|
|
326
588
|
});
|
|
327
589
|
results.push(result);
|
|
@@ -473,6 +735,97 @@ export class RunOrchestrator {
|
|
|
473
735
|
}
|
|
474
736
|
}
|
|
475
737
|
}
|
|
738
|
+
function findOrAppendPhase(state, name) {
|
|
739
|
+
let p = state.phases.find((ph) => ph.name === name);
|
|
740
|
+
if (!p) {
|
|
741
|
+
p = { name, status: "pending" };
|
|
742
|
+
state.phases.push(p);
|
|
743
|
+
}
|
|
744
|
+
return p;
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Activity is considered stale (and `nowLine` falls back to the coarse
|
|
748
|
+
* `running <phase>` form) once it goes this long without an update (#543).
|
|
749
|
+
*/
|
|
750
|
+
const ACTIVITY_STALE_MS = 5_000;
|
|
751
|
+
function cloneIssueState(s) {
|
|
752
|
+
return {
|
|
753
|
+
number: s.number,
|
|
754
|
+
title: s.title,
|
|
755
|
+
branch: s.branch,
|
|
756
|
+
status: s.status,
|
|
757
|
+
startedAt: s.startedAt,
|
|
758
|
+
completedAt: s.completedAt,
|
|
759
|
+
phases: s.phases.map((p) => ({
|
|
760
|
+
name: p.name,
|
|
761
|
+
status: p.status,
|
|
762
|
+
startedAt: p.startedAt,
|
|
763
|
+
elapsedMs: p.elapsedMs,
|
|
764
|
+
})),
|
|
765
|
+
currentPhase: s.currentPhase
|
|
766
|
+
? {
|
|
767
|
+
name: s.currentPhase.name,
|
|
768
|
+
startedAt: s.currentPhase.startedAt,
|
|
769
|
+
lastActivityAt: s.currentPhase.lastActivityAt,
|
|
770
|
+
nowLine: nowLineWithStaleFallback(s.currentPhase),
|
|
771
|
+
logPath: s.currentPhase.logPath,
|
|
772
|
+
}
|
|
773
|
+
: undefined,
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
function nowLineWithStaleFallback(current) {
|
|
777
|
+
const ageMs = Date.now() - current.lastActivityAt.getTime();
|
|
778
|
+
if (ageMs >= ACTIVITY_STALE_MS) {
|
|
779
|
+
return formatCoarseNowLine(current.name);
|
|
780
|
+
}
|
|
781
|
+
return current.nowLine;
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Reduce a chunk of streamed agent output to a single line suitable for the
|
|
785
|
+
* activity row. Strips ANSI sequences and trailing whitespace; returns the
|
|
786
|
+
* last non-empty line, truncated to keep the cell render cheap. Returns
|
|
787
|
+
* `undefined` when the chunk contains no usable content.
|
|
788
|
+
*/
|
|
789
|
+
function extractActivityLine(raw) {
|
|
790
|
+
if (!raw)
|
|
791
|
+
return undefined;
|
|
792
|
+
// Strip ANSI CSI escapes — covers SGR (colour/bold, `…m`), cursor-movement
|
|
793
|
+
// and line-clear codes (`\x1b[2K`, `\x1b[G`), and DEC private-mode toggles
|
|
794
|
+
// (`\x1b[?25l`), any of which can leak through chalk/ink in agent output.
|
|
795
|
+
// eslint-disable-next-line no-control-regex
|
|
796
|
+
const cleaned = raw.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "");
|
|
797
|
+
const lines = cleaned.split(/\r?\n/);
|
|
798
|
+
let last = "";
|
|
799
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
800
|
+
const trimmed = lines[i].trim();
|
|
801
|
+
if (trimmed.length > 0) {
|
|
802
|
+
last = trimmed;
|
|
803
|
+
break;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
if (!last)
|
|
807
|
+
return undefined;
|
|
808
|
+
// Bound at 200 chars; the TUI truncates further per row width.
|
|
809
|
+
return last.length > 200 ? last.slice(0, 200) : last;
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Build the synthetic `IssueResult` returned for an issue that was skipped
|
|
813
|
+
* because another sequant session holds its lock (#625).
|
|
814
|
+
*/
|
|
815
|
+
export function buildLockedResult(issueNumber, holder) {
|
|
816
|
+
return {
|
|
817
|
+
issueNumber,
|
|
818
|
+
success: false,
|
|
819
|
+
phaseResults: [],
|
|
820
|
+
abortReason: `locked by PID ${holder.pid}`,
|
|
821
|
+
locked: {
|
|
822
|
+
pid: holder.pid,
|
|
823
|
+
hostname: holder.hostname,
|
|
824
|
+
startedAt: holder.startedAt,
|
|
825
|
+
command: holder.command,
|
|
826
|
+
},
|
|
827
|
+
};
|
|
828
|
+
}
|
|
476
829
|
/** Log a non-fatal warning: one-line summary always, detail in verbose. */
|
|
477
830
|
export function logNonFatalWarning(message, error, verbose) {
|
|
478
831
|
console.log(chalk.yellow(message));
|
|
@@ -33,7 +33,7 @@ function analyzeTimingPatterns(input, observations, suggestions) {
|
|
|
33
33
|
// If spec times are similar (within 30%) despite different issues, flag it
|
|
34
34
|
if (max > 0 && min / max > 0.7 && max - min < 120) {
|
|
35
35
|
observations.push(`Spec times similar across issues (${formatSec(min)}–${formatSec(max)}) despite varying complexity`);
|
|
36
|
-
suggestions.push("Consider `--phases exec,qa` for
|
|
36
|
+
suggestions.push("Consider `--phases exec,qa` for issues where spec is not adding value (the default includes spec, #533)");
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
// Flag individual phases that took unusually long
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime state snapshots for multi-issue dashboard rendering.
|
|
3
|
+
*
|
|
4
|
+
* Decouples the TUI from the orchestrator: `getSnapshot()` returns an
|
|
5
|
+
* immutable, plain-object view of current run state that can be safely
|
|
6
|
+
* read from a render loop without holding locks.
|
|
7
|
+
*/
|
|
8
|
+
import type { Phase } from "./types.js";
|
|
9
|
+
/** Top-level lifecycle status of a single issue. */
|
|
10
|
+
export type IssueStatus = "queued" | "running" | "passed" | "failed";
|
|
11
|
+
/** Per-phase status within an issue. */
|
|
12
|
+
export type PhaseStatus = "pending" | "running" | "done" | "failed";
|
|
13
|
+
/**
|
|
14
|
+
* One phase's runtime state.
|
|
15
|
+
* `elapsedMs` is populated once a phase reaches `done` or `failed`.
|
|
16
|
+
*/
|
|
17
|
+
export interface PhaseRuntimeState {
|
|
18
|
+
name: string;
|
|
19
|
+
status: PhaseStatus;
|
|
20
|
+
startedAt?: Date;
|
|
21
|
+
elapsedMs?: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* State of the currently running phase for an issue.
|
|
25
|
+
* Populated only while a phase is active. Dashboard consumers render
|
|
26
|
+
* `nowLine` as the activity row and tick `lastActivityAt` for the stamp.
|
|
27
|
+
*/
|
|
28
|
+
export interface CurrentPhaseState {
|
|
29
|
+
name: string;
|
|
30
|
+
startedAt: Date;
|
|
31
|
+
lastActivityAt: Date;
|
|
32
|
+
nowLine: string;
|
|
33
|
+
logPath?: string;
|
|
34
|
+
}
|
|
35
|
+
/** Complete runtime state for a single issue. */
|
|
36
|
+
export interface IssueRuntimeState {
|
|
37
|
+
number: number;
|
|
38
|
+
title: string;
|
|
39
|
+
branch: string;
|
|
40
|
+
status: IssueStatus;
|
|
41
|
+
phases: PhaseRuntimeState[];
|
|
42
|
+
currentPhase?: CurrentPhaseState;
|
|
43
|
+
startedAt?: Date;
|
|
44
|
+
completedAt?: Date;
|
|
45
|
+
}
|
|
46
|
+
/** Run-level configuration captured at start for the header. */
|
|
47
|
+
export interface RunSnapshotConfig {
|
|
48
|
+
concurrency: number;
|
|
49
|
+
baseBranch: string;
|
|
50
|
+
baseSha?: string;
|
|
51
|
+
baseFetchedAt?: Date;
|
|
52
|
+
qualityLoop: boolean;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* A consistent point-in-time view of the entire run.
|
|
56
|
+
*
|
|
57
|
+
* Returned as a freshly-allocated plain object by `getSnapshot()`; callers
|
|
58
|
+
* may read fields concurrently without further synchronization because no
|
|
59
|
+
* internal mutable references are leaked.
|
|
60
|
+
*/
|
|
61
|
+
export interface RunSnapshot {
|
|
62
|
+
config: RunSnapshotConfig;
|
|
63
|
+
issues: IssueRuntimeState[];
|
|
64
|
+
done: boolean;
|
|
65
|
+
capturedAt: Date;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Format a coarse "now" line for a phase transition.
|
|
69
|
+
* Used as the M1 default when no finer activity signal exists.
|
|
70
|
+
*/
|
|
71
|
+
export declare function formatCoarseNowLine(phase: Phase | string): string;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime state snapshots for multi-issue dashboard rendering.
|
|
3
|
+
*
|
|
4
|
+
* Decouples the TUI from the orchestrator: `getSnapshot()` returns an
|
|
5
|
+
* immutable, plain-object view of current run state that can be safely
|
|
6
|
+
* read from a render loop without holding locks.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Format a coarse "now" line for a phase transition.
|
|
10
|
+
* Used as the M1 default when no finer activity signal exists.
|
|
11
|
+
*/
|
|
12
|
+
export function formatCoarseNowLine(phase) {
|
|
13
|
+
return `running ${phase}`;
|
|
14
|
+
}
|
|
@@ -58,9 +58,9 @@ export interface ReconcileOptions {
|
|
|
58
58
|
export interface ReconcileResult {
|
|
59
59
|
/** Whether reconciliation was successful */
|
|
60
60
|
success: boolean;
|
|
61
|
-
/** Issues
|
|
61
|
+
/** Issues advanced to `merged` (from `ready_for_merge`, `in_progress`, or `waiting_for_qa_gate`) */
|
|
62
62
|
advanced: number[];
|
|
63
|
-
/** Issues checked but
|
|
63
|
+
/** Issues checked but not yet merged (status unchanged) */
|
|
64
64
|
stillPending: number[];
|
|
65
65
|
/** Error message if failed */
|
|
66
66
|
error?: string;
|
|
@@ -68,10 +68,18 @@ export interface ReconcileResult {
|
|
|
68
68
|
/**
|
|
69
69
|
* Lightweight state reconciliation at run start
|
|
70
70
|
*
|
|
71
|
-
* Checks issues in `ready_for_merge`
|
|
72
|
-
* if their PRs are merged or their branches
|
|
71
|
+
* Checks issues in `ready_for_merge`, `in_progress`, or `waiting_for_qa_gate`
|
|
72
|
+
* state and advances them to `merged` if their PRs are merged or their branches
|
|
73
|
+
* are in main.
|
|
73
74
|
*
|
|
74
|
-
*
|
|
75
|
+
* Including `in_progress` covers the case where a PR was merged outside
|
|
76
|
+
* this sequant session (separate process, `gh pr merge`, web UI) — without
|
|
77
|
+
* it, the run command would re-execute already-merged issues. See #592.
|
|
78
|
+
*
|
|
79
|
+
* Including `waiting_for_qa_gate` covers the symmetric case where a PR
|
|
80
|
+
* awaiting human QA-gate approval is merged externally before the next
|
|
81
|
+
* sequant session — without it, `sequant run <N>` re-executes the QA phase
|
|
82
|
+
* against already-merged work. See #606.
|
|
75
83
|
*
|
|
76
84
|
* @param options - Reconciliation options
|
|
77
85
|
* @returns Result with lists of advanced and still-pending issues
|