feat(cli): dispatch the three engines concurrently and merge their findings - #94
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces a new orchestration layer for check that dispatches the sg (ast-grep), Vale, and runtime engines concurrently, merges their findings, and derives the exit code from both findings severity and engine failures (while treating “unavailable” engines as advisory).
Changes:
- Add
rules/dispatch.tsto run all engines viaPromise.allSettled, merging results and surfacing engine failures without discarding other engines’ findings. - Wire
checkto use the new dispatcher and update engine layout metadata so Vale is executed viavale-runner. - Add orchestration tests and update the OpenSpec tasks checklist for section 2.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/cli/test/vale-orchestration.test.ts | Adds orchestration/exit-code tests for concurrent engine dispatch, Vale availability, and failure handling. |
| packages/cli/test/engine-dispatch.test.ts | Updates expectations/comments to reflect Vale now having an executor (vale-runner). |
| packages/cli/src/rules/engines.ts | Adds vale-runner executor and updates Vale engine layout to be executable. |
| packages/cli/src/rules/dispatch.ts | New shared dispatcher: concurrent engine runs, merged results, notices vs failures, exit code derivation. |
| packages/cli/src/commands/check.ts | Moves orchestration logic to runEngines() and uses deriveExitCode() for process exit + JSON success. |
| openspec/changes/add-vale-rule-engine/tasks.md | Marks orchestration tasks 2.1–2.3 as complete. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
d3d84fd to
bf38e0f
Compare
|
@claude /review |
|
Claude finished @thecodedrift's task in 3m 53s —— View job Review: dispatch the three engines concurrently and merge their findings
Overall assessmentThe I found one bug I'd consider blocking, plus a few smaller issues, all posted inline: 1. if (astGrepSources.length === 0 && runtimeRules.length === 0) { ... return; }It never asks whether Vale has rules. A project with only 2. 3. Nothing else stood out — the |
…required Two gaps in the "One changeset, at the bottom of the stack" guidance, both found on the #71/#93/#94/#95 merging-down stack: - The section said where the changeset lives but never when to create it. Document the trigger: write it on the base branch before cutting the children, so every child inherits a file it can append to. - "Grow it incrementally" is merge-forward reasoning. Under merging down nothing lands until everything does, so a whole-change note is accurate at the only moment it is read. Scope the rule to forward, and state the constraint that holds in both shapes: the file must be in the bottom PR's own diff. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
bf38e0f to
c666973
Compare
You were right to call finding 1 blocking, and it is the most valuable thing any review turned up on this stack. Verified at Fixed in Findings 2 and 3 are fixed in
One follow-up deliberately left out of scope, flagged so it is visible rather than lost: the new Also note — AI Coding Agent |
c666973 to
2ce9194
Compare
thecodedrift
left a comment
There was a problem hiding this comment.
just a small refactoring suggestion
7f2ead8 to
78c1be3
Compare
…ndings Unit 3, tasks 2.1-2.3. `check` sequenced ast-grep then runtime inline and had no Vale at all. That block moves to rules/dispatch.ts, gains Vale, and runs all three concurrently. Vale's layout entry gains `executor: "vale-runner"`, replacing the `null` that recorded it as scaffolded but inert, and engine-dispatch.test.ts is updated to assert the new routing rather than the placeholder. allSettled, not all. `all` rejects on the first rejection and abandons the rest, so one engine throwing would discard findings the others had already produced — which is precisely the "an unavailable engine must not abort the others" requirement. Using allSettled makes that true by construction rather than by every future caller remembering to catch. A rejected engine becomes a reported failure rather than being swallowed: the engines report expected trouble as an outcome, so a throw is something unforeseen, and treating it as "no findings" is the silent-disable failure again. Exit code now has two independent causes. An error-severity finding is the ordinary one. An engine failure is the one that would be missed: a Vale that timed out or rejected its config produces no findings, so without it a broken engine exits 0 and reads exactly like a clean run. An unavailable engine stays advisory — an unsupported arch must not fail a check the other engines completed. Vale is not invoked when `.taskless/vale/rules/` is empty, per the spec. A scaffolded-but-empty engine directory is the state every `taskless init` leaves, and spawning a subprocess per check to confirm it found nothing is pure cost. Tests cover the mixed sg+vale corpus merging into one set, Vale absent while ast-grep still reports, an engine throwing without taking the others' results with it, and each exit-code cause on its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
The "no rules configured" gate asked ast-grep and the runtime harness and returned before `runEngines`, so a project with only `.taskless/vale/rules/` reported itself unconfigured and never dispatched the engine this stack just gave an executor. Ask Vale too, short-circuited so the ordinary project pays nothing extra.
A blanket catch answered `false` for any readdir failure, so an unreadable `.taskless/vale/rules/` skipped Vale with no notice and no failure — the silent-disable the failure/notice split exists to prevent. Only ENOENT and ENOTDIR mean absence now; anything else propagates and `runEngines` reports it as an engine failure. Also makes the allSettled isolation test portable: it asserted a Vale result over a real run, so it only passed on a machine that happened to have the optional binary. Vale is mocked to a deterministic outcome instead, keeping the behavior under test exercised everywhere.
…lper `isValeFailure(outcome)` was a free function a caller had to remember to call; `ValeRunOutcome` now carries `blocking` as a literal-typed field per variant, so dispatch reads the engine's own account of how bad its trouble is. The mistake the helper invited -- writing the natural-looking `outcome.status !== "ok"` and failing `check` on every host missing the Vale binary -- is now a type error rather than a silent behaviour change. That is the point of the shape, beyond this one call site: every engine runs a binary and returns a self-describing outcome, so the next lint engine answers "is this fatal?" the same way and no dispatcher grows a per-engine special case. The migration had to land here rather than with the field: `dispatch.ts` does not exist on the branch that defines `ValeRunOutcome`, so the field only became reachable once the rebase brought it up. Caught by the literal typing on the way through: the mocked `ok` outcome in `vale-orchestration.test.ts` predated the field and failed to compile, which is the check working as intended. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
`DispatchOptions.astGrepSources` carried a full `AstGrepRuleSource` beside
each resolved `configPath`, and nothing in dispatch ever read it —
`runAstGrepEngine` destructures `configPath` and discards the rest. Narrow
the field to the `string[]` of config paths dispatch uses.
The caller loses a hop (mapping sources to `{ source, configPath }` pairs
becomes resolving the paths), and the tests stop constructing a six-field
rule source whose only load-bearing member was one string literal.
Also collect notices and failures with `flatMap(... ?? [])` rather than
`map().filter()` with a hand-written type predicate, matching the `results`
line directly above them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
`deriveExitCode(dispatched)` was a pure function every caller had to remember to call, computing a value that is fixed the moment the engines settle and never changes afterwards. Nothing about it varied per caller, so it was derivation for a thing that only needs computing once. `runEngines` now computes it and `DispatchResult` carries `exitCode`. Beyond removing the call, this removes the possibility of two callers disagreeing about what counts as failure -- the rule that an engine failure fails the check even with zero findings now lives with the data rather than with whoever remembers to consult it. The tradeoff is that four unit tests exercised the helper directly on a hand-built `DispatchResult`. They are replaced by tests through `runEngines`, which is the path the exit code actually takes: warning findings exit 0, an error-severity finding exits 1, a clean run exits 0, an unavailable engine exits 0, and a thrown engine exits 1. The last two already ran through `runEngines` and only needed the field. To cover the error case the sg fixture severity became a parameter. Net one test fewer, covering the same five paths against real dispatch rather than a constructed struct. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
78c1be3 to
5d8d080
Compare
|
@claude /review |
|
Claude finished @thecodedrift's task in 2m 15s —— View job Review: dispatch the three engines concurrently and merge their findings
Overall assessmentThe three findings from the prior review round ( Went a level deeper on this pass and found five new items, all posted inline. None are blocking; the first is the most worth a look: 1. 2. 3. 4. 5. Confirmed for the record, not new: the Nothing else stood out. Import conventions, exported-type reachability, and the |
| const results = dispatched.results; | ||
|
|
||
| for (const notice of dispatched.notices) warn(`Notice: ${notice}`); | ||
| for (const failure of dispatched.failures) warn(`Error: ${failure}`); |
There was a problem hiding this comment.
dispatched.failures has no way to reach --json output. warn() is a no-op whenever args.json is true (line 286-288), and checkOutputSchema (packages/cli/src/schemas/check.ts:34-41) only defines success/results/skipped — no field for failures or notices.
So when an engine failure (not a finding) is why the check fails — e.g. Vale times out or its config crashes, while ast-grep/runtime produce nothing error-severity — --json output is just {"success":false,"results":[]}. A script consuming that JSON has no way to distinguish "a rule fired" from "an engine crashed," even though dispatched.failures has the message right there.
Given skipped already exists on outputSchema as an advisory array for one engine's (runtime's) edge case, a failures/notices field following the same pattern would give --json consumers parity with what the text path already prints.
| * way. | ||
| */ | ||
| async function runValeEngine(options: DispatchOptions): Promise<EngineOutcome> { | ||
| if (!(await hasValeRules(options.cwd))) { |
There was a problem hiding this comment.
This duplicates the hasValeRules call check.ts's noRuleFiles gate already made (check.ts:358) to decide whether to short-circuit — every check run against a project with Vale rules pays two readdirs of the same directory for the same answer.
It's also an asymmetry with the other two engines: astGrepConfigPaths and runtimeRules are both pre-resolved upstream and threaded through DispatchOptions (empty means skip), but Vale re-derives "do I have work" itself from cwd down here. Threading the gate's already-computed answer through DispatchOptions (or just not re-checking here) would remove both the duplicate I/O and the asymmetry.
| options: DispatchOptions | ||
| ): Promise<EngineOutcome> { | ||
| const results: CheckResult[] = []; | ||
| for (const configPath of options.astGrepConfigPaths) { |
There was a problem hiding this comment.
This loops astGrepConfigPaths with a sequential await per config, spawning each ast-grep subprocess one after another. A project mid-migration (sg/rules/ and the legacy rules/ both populated) pays the sum of both scans in serial — inside the PR whose stated point is running scans concurrently instead of paying the sum of their latencies. The two configs are independent, so Promise.all(options.astGrepConfigPaths.map((configPath) => runAstGrepScan(...))) would cut static-scan latency for that case, same as the three-engine dispatch does one level up.
| import { runVale } from "./vale/run"; | ||
|
|
||
| /** Errno values that mean "the directory is not there", and nothing worse. */ | ||
| const ABSENT_DIRECTORY_CODES = new Set(["ENOENT", "ENOTDIR"]); |
There was a problem hiding this comment.
ABSENT_DIRECTORY_CODES/this ENOENT-or-ENOTDIR check re-implements isMissingDirectory in packages/cli/src/rules/vale/verify.ts:60-66 — same decision ("is this readdir failure just 'directory doesn't exist'"), independently written twice (a Set + inline compare here, a typed helper there). Worth sharing one implementation so the two call sites' definition of "absent" can't quietly drift apart.
| const noRuleFiles = | ||
| astGrepSources.length === 0 && | ||
| runtimeRules.length === 0 && | ||
| !(await hasValeRules(cwd)); |
There was a problem hiding this comment.
Confirming this for the record since it lines up with the follow-up you already flagged in the PR thread: this hasValeRules(cwd) call sits inside the outer try (opened at line 295) but before the inner try/catch (375/434) that builds the SCAN_FAILED envelope. hasValeRules deliberately rethrows anything that isn't ENOENT/ENOTDIR, so an EACCES on .taskless/vale/rules/ here isn't caught by this function — it propagates past the finally to the top-level handler in index.ts, which prints the raw error and ignores --json entirely. Not raising this as new (you already scoped it out), just confirming it's accurate in case the follow-up picks it up.
Stack (root → tip):
Unit 3 of
add-vale-rule-engine. Stacked on #93, merging down. Tasks 2.1–2.3.checksequenced ast-grep then runtime inline and had no Vale at all. That block moves torules/dispatch.ts, gains Vale, and runs all three concurrently.allSettled, notallallrejects on the first rejection and abandons the rest — so one engine throwing would discard findings the others had already produced. That is precisely the "an unavailable engine must not abort the others" requirement, andallSettledmakes it true by construction rather than by every future caller remembering to catch.A rejected engine becomes a reported failure rather than being swallowed. The engines report expected trouble as an outcome, so a throw is something unforeseen — and treating it as "no findings" is the silent-disable failure again.
There's a test for exactly this: ast-grep is stubbed to reject, and Vale's findings still come back while the rejection surfaces as a failure.
Exit code now has two independent causes
The second is the one that would have been missed: a Vale that timed out produces no findings, so without it a broken engine exits 0 and reads exactly like a clean run. The third is deliberately not a failure — an unsupported arch must not fail a check the other engines completed.
Other changes
executor: "vale-runner", replacing thenullthat recorded it as scaffolded-but-inert.engine-dispatch.test.tsis updated to assert the new routing rather than the placeholder — repointing the reader in the same unit that changes the behaviour..taskless/vale/rules/is empty, per the spec. That's the state everytaskless initleaves, and spawning a subprocess per check to confirm it found nothing is pure cost.Verification
pnpm --filter @taskless/cli test→ 524 passed (10 new); lint, typecheck, prettier,openspec validate --strictclean.Section 2 is complete. Remaining: unit 4 — the engine-selection topic, its
TOPICSentry, and the archive.Refs OSS-21