sequant 2.2.0 → 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 +94 -9
- package/dist/src/commands/doctor.d.ts +25 -0
- package/dist/src/commands/doctor.js +36 -1
- 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 +11 -2
- package/dist/src/commands/run-display.js +62 -28
- 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 +80 -18
- package/dist/src/commands/stats.d.ts +2 -0
- package/dist/src/commands/stats.js +94 -8
- package/dist/src/commands/status.js +12 -0
- 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/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 +248 -175
- 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 +62 -8
- package/dist/src/lib/workflow/phase-executor.js +157 -16
- 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 +39 -0
- package/dist/src/lib/workflow/run-orchestrator.js +340 -15
- 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 +8 -1
- package/dist/src/lib/workflow/worktree-manager.js +15 -6
- 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 +103 -49
- 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 -8
- package/templates/skills/fullsolve/SKILL.md +79 -5
- package/templates/skills/loop/SKILL.md +28 -0
- package/templates/skills/merger/SKILL.md +621 -0
- package/templates/skills/qa/SKILL.md +727 -8
- package/templates/skills/setup/SKILL.md +6 -0
- package/templates/skills/spec/SKILL.md +52 -0
- package/templates/skills/spec/references/parallel-groups.md +7 -0
- package/templates/skills/spec/references/recommended-workflow.md +4 -2
- package/templates/skills/testgen/SKILL.md +24 -17
|
@@ -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,9 +63,126 @@ 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();
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Point-in-time view of the entire run.
|
|
76
|
+
*
|
|
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.
|
|
81
|
+
*/
|
|
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
|
+
}
|
|
38
186
|
}
|
|
39
187
|
/**
|
|
40
188
|
* Pure config resolution — no side effects.
|
|
@@ -134,6 +282,9 @@ export class RunOrchestrator {
|
|
|
134
282
|
startCommit: getCommitHash(process.cwd()),
|
|
135
283
|
});
|
|
136
284
|
await logWriter.initialize(runConfig);
|
|
285
|
+
const runId = logWriter.getRunId();
|
|
286
|
+
if (runId)
|
|
287
|
+
emitRunIdLine(runId);
|
|
137
288
|
}
|
|
138
289
|
catch (err) {
|
|
139
290
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -203,6 +354,63 @@ export class RunOrchestrator {
|
|
|
203
354
|
}
|
|
204
355
|
}
|
|
205
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
|
+
}
|
|
206
414
|
// ── Issue info + worktree setup ────────────────────────────────────
|
|
207
415
|
const issueInfoMap = new Map();
|
|
208
416
|
for (const issueNumber of issueNumbers) {
|
|
@@ -233,17 +441,18 @@ export class RunOrchestrator {
|
|
|
233
441
|
}
|
|
234
442
|
// ── Execute ────────────────────────────────────────────────────────
|
|
235
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);
|
|
236
455
|
try {
|
|
237
|
-
const orchestrator = new RunOrchestrator({
|
|
238
|
-
config,
|
|
239
|
-
options: mergedOptions,
|
|
240
|
-
issueInfoMap,
|
|
241
|
-
worktreeMap,
|
|
242
|
-
services: { logWriter, stateManager, shutdownManager: shutdown },
|
|
243
|
-
packageManager: manifest.packageManager,
|
|
244
|
-
baseBranch,
|
|
245
|
-
onProgress,
|
|
246
|
-
});
|
|
247
456
|
if (resolvedBatches) {
|
|
248
457
|
for (let batchIdx = 0; batchIdx < resolvedBatches.length; batchIdx++) {
|
|
249
458
|
const batch = resolvedBatches[batchIdx];
|
|
@@ -276,10 +485,11 @@ export class RunOrchestrator {
|
|
|
276
485
|
logNonFatalWarning(" ! Metrics recording failed, continuing...", metricsError, config.verbose);
|
|
277
486
|
}
|
|
278
487
|
}
|
|
488
|
+
const allResults = [...lockedResults, ...results];
|
|
279
489
|
return {
|
|
280
|
-
results,
|
|
490
|
+
results: allResults,
|
|
281
491
|
logPath,
|
|
282
|
-
exitCode:
|
|
492
|
+
exitCode: allResults.some((r) => !r.success) && !config.dryRun ? 1 : 0,
|
|
283
493
|
worktreeMap,
|
|
284
494
|
issueInfoMap,
|
|
285
495
|
config,
|
|
@@ -288,6 +498,7 @@ export class RunOrchestrator {
|
|
|
288
498
|
};
|
|
289
499
|
}
|
|
290
500
|
finally {
|
|
501
|
+
orchestrator.markDone();
|
|
291
502
|
shutdown.dispose();
|
|
292
503
|
}
|
|
293
504
|
}
|
|
@@ -345,11 +556,34 @@ export class RunOrchestrator {
|
|
|
345
556
|
if (shutdown?.shuttingDown) {
|
|
346
557
|
break;
|
|
347
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
|
+
}
|
|
348
577
|
const result = await this.executeOneIssue({
|
|
349
578
|
issueNumber,
|
|
350
579
|
batchCtx,
|
|
351
580
|
chain: options.chain
|
|
352
|
-
? {
|
|
581
|
+
? {
|
|
582
|
+
enabled: true,
|
|
583
|
+
isLast: i === issueNumbers.length - 1,
|
|
584
|
+
predecessorBranch,
|
|
585
|
+
stackManifest,
|
|
586
|
+
}
|
|
353
587
|
: undefined,
|
|
354
588
|
});
|
|
355
589
|
results.push(result);
|
|
@@ -501,6 +735,97 @@ export class RunOrchestrator {
|
|
|
501
735
|
}
|
|
502
736
|
}
|
|
503
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
|
+
}
|
|
504
829
|
/** Log a non-fatal warning: one-line summary always, detail in verbose. */
|
|
505
830
|
export function logNonFatalWarning(message, error, verbose) {
|
|
506
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
|
|
@@ -188,10 +188,18 @@ export async function cleanupStaleEntries(options = {}) {
|
|
|
188
188
|
/**
|
|
189
189
|
* Lightweight state reconciliation at run start
|
|
190
190
|
*
|
|
191
|
-
* Checks issues in `ready_for_merge`
|
|
192
|
-
* if their PRs are merged or their branches
|
|
191
|
+
* Checks issues in `ready_for_merge`, `in_progress`, or `waiting_for_qa_gate`
|
|
192
|
+
* state and advances them to `merged` if their PRs are merged or their branches
|
|
193
|
+
* are in main.
|
|
193
194
|
*
|
|
194
|
-
*
|
|
195
|
+
* Including `in_progress` covers the case where a PR was merged outside
|
|
196
|
+
* this sequant session (separate process, `gh pr merge`, web UI) — without
|
|
197
|
+
* it, the run command would re-execute already-merged issues. See #592.
|
|
198
|
+
*
|
|
199
|
+
* Including `waiting_for_qa_gate` covers the symmetric case where a PR
|
|
200
|
+
* awaiting human QA-gate approval is merged externally before the next
|
|
201
|
+
* sequant session — without it, `sequant run <N>` re-executes the QA phase
|
|
202
|
+
* against already-merged work. See #606.
|
|
195
203
|
*
|
|
196
204
|
* @param options - Reconciliation options
|
|
197
205
|
* @returns Result with lists of advanced and still-pending issues
|
|
@@ -213,9 +221,13 @@ export async function reconcileStateAtStartup(options = {}) {
|
|
|
213
221
|
const state = await manager.getState();
|
|
214
222
|
const advanced = [];
|
|
215
223
|
const stillPending = [];
|
|
216
|
-
// Find issues in ready_for_merge state
|
|
224
|
+
// Find issues in ready_for_merge, in_progress, or waiting_for_qa_gate state.
|
|
225
|
+
// in_progress covers PRs merged outside this session (#592).
|
|
226
|
+
// waiting_for_qa_gate covers PRs merged before the next QA-gate run (#606).
|
|
217
227
|
for (const [issueNumStr, issueState] of Object.entries(state.issues)) {
|
|
218
|
-
if (issueState.status !== "ready_for_merge"
|
|
228
|
+
if (issueState.status !== "ready_for_merge" &&
|
|
229
|
+
issueState.status !== "in_progress" &&
|
|
230
|
+
issueState.status !== "waiting_for_qa_gate") {
|
|
219
231
|
continue;
|
|
220
232
|
}
|
|
221
233
|
const issueNum = parseInt(issueNumStr, 10);
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* const state = await manager.getIssueState(42);
|
|
21
21
|
* ```
|
|
22
22
|
*/
|
|
23
|
-
import { type WorkflowState, type IssueState, type Phase, type PhaseStatus, type IssueStatus, type PRInfo, type AcceptanceCriteria, type ACStatus } from "./state-schema.js";
|
|
23
|
+
import { type WorkflowState, type IssueState, type Phase, type PhaseStatus, type IssueStatus, type PRInfo, type AcceptanceCriteria, type ACStatus, type RelayState } from "./state-schema.js";
|
|
24
24
|
import type { ScopeAssessment } from "../scope/types.js";
|
|
25
25
|
export interface StateManagerOptions {
|
|
26
26
|
/** Path to state file (default: .sequant/state.json) */
|
|
@@ -133,6 +133,17 @@ export declare class StateManager {
|
|
|
133
133
|
* Update session ID for an issue (for resume)
|
|
134
134
|
*/
|
|
135
135
|
updateSessionId(issueNumber: number, sessionId: string): Promise<void>;
|
|
136
|
+
/**
|
|
137
|
+
* Set or clear the relay state for an issue (#383).
|
|
138
|
+
*
|
|
139
|
+
* Pass `null` to remove the relay field entirely (deactivation). Pass an
|
|
140
|
+
* object to overwrite the current relay state (activation or refresh).
|
|
141
|
+
*/
|
|
142
|
+
setRelayState(issueNumber: number, relay: RelayState | null): Promise<void>;
|
|
143
|
+
/**
|
|
144
|
+
* Increment the relay message counter. No-op when relay isn't active.
|
|
145
|
+
*/
|
|
146
|
+
incrementRelayMessageCount(issueNumber: number, delta?: number): Promise<void>;
|
|
136
147
|
/**
|
|
137
148
|
* Update loop iteration for an issue
|
|
138
149
|
*/
|
|
@@ -392,6 +392,43 @@ export class StateManager {
|
|
|
392
392
|
await this.saveState(state);
|
|
393
393
|
});
|
|
394
394
|
}
|
|
395
|
+
/**
|
|
396
|
+
* Set or clear the relay state for an issue (#383).
|
|
397
|
+
*
|
|
398
|
+
* Pass `null` to remove the relay field entirely (deactivation). Pass an
|
|
399
|
+
* object to overwrite the current relay state (activation or refresh).
|
|
400
|
+
*/
|
|
401
|
+
async setRelayState(issueNumber, relay) {
|
|
402
|
+
await this.withLock(async () => {
|
|
403
|
+
const state = await this.getState();
|
|
404
|
+
const issueState = state.issues[String(issueNumber)];
|
|
405
|
+
if (!issueState) {
|
|
406
|
+
throw new Error(`Issue #${issueNumber} not found in state`);
|
|
407
|
+
}
|
|
408
|
+
if (relay === null) {
|
|
409
|
+
delete issueState.relay;
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
issueState.relay = relay;
|
|
413
|
+
}
|
|
414
|
+
issueState.lastActivity = new Date().toISOString();
|
|
415
|
+
await this.saveState(state);
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Increment the relay message counter. No-op when relay isn't active.
|
|
420
|
+
*/
|
|
421
|
+
async incrementRelayMessageCount(issueNumber, delta = 1) {
|
|
422
|
+
await this.withLock(async () => {
|
|
423
|
+
const state = await this.getState();
|
|
424
|
+
const issueState = state.issues[String(issueNumber)];
|
|
425
|
+
if (!issueState || !issueState.relay)
|
|
426
|
+
return;
|
|
427
|
+
issueState.relay.messageCount += delta;
|
|
428
|
+
issueState.lastActivity = new Date().toISOString();
|
|
429
|
+
await this.saveState(state);
|
|
430
|
+
});
|
|
431
|
+
}
|
|
395
432
|
/**
|
|
396
433
|
* Update loop iteration for an issue
|
|
397
434
|
*/
|