@vzn/vx 0.0.638 → 0.0.640

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.
Files changed (134) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +152 -95
  3. package/docs/cli.md +225 -372
  4. package/docs/execution.md +22 -47
  5. package/docs/features.md +14 -16
  6. package/docs/patterns.md +6 -8
  7. package/docs/schema.md +268 -215
  8. package/docs/security.md +1 -1
  9. package/package.json +5 -5
  10. package/schemas/last.json +7 -4
  11. package/schemas/show.json +14 -14
  12. package/skills/vx/SKILL.md +10 -7
  13. package/src/cache/cache.ts +44 -11
  14. package/src/cache/file-hashes.ts +7 -12
  15. package/src/cache/fs-clock.ts +62 -0
  16. package/src/cache/git-inputs.ts +8 -2
  17. package/src/cache/index.ts +5 -2
  18. package/src/cache/inputs.ts +154 -83
  19. package/src/cache/key-fold.ts +23 -3
  20. package/src/cache/layer.ts +33 -18
  21. package/src/cache/layered-cache.ts +108 -315
  22. package/src/cache/linked-inputs.ts +87 -0
  23. package/src/cache/plugin-cache.ts +2 -10
  24. package/src/cache/remote-lane.ts +383 -0
  25. package/src/cache/run-history.ts +24 -8
  26. package/src/cache/run-store.ts +4 -4
  27. package/src/cache/schema.ts +6 -4
  28. package/src/cache/scoped-cache.ts +7 -0
  29. package/src/cli/completions.ts +2 -2
  30. package/src/cli/foreign-flags.ts +11 -23
  31. package/src/cli/help.ts +6 -31
  32. package/src/cli/info.ts +2 -1
  33. package/src/cli/init.ts +23 -11
  34. package/src/cli/last.ts +30 -4
  35. package/src/cli/plan-format.ts +5 -58
  36. package/src/cli/plugin-commands.ts +2 -2
  37. package/src/cli/plugin-templates.ts +2 -2
  38. package/src/cli/run.ts +23 -185
  39. package/src/cli/select.ts +16 -6
  40. package/src/cli/show.ts +15 -6
  41. package/src/cli/upgrade.ts +215 -55
  42. package/src/cli/watch-cycle.ts +2 -1
  43. package/src/cli/watch-filter.ts +22 -6
  44. package/src/cli/watch-judge.ts +49 -5
  45. package/src/cli/watch-set.ts +18 -8
  46. package/src/cli/watch.ts +103 -16
  47. package/src/cli/why.ts +9 -7
  48. package/src/config.ts +19 -21
  49. package/src/exec/executor.ts +5 -5
  50. package/src/exec/sandbox-runtime.ts +1 -1
  51. package/src/exec/sandbox-violations.ts +3 -1
  52. package/src/graph/dependency-spec.ts +2 -2
  53. package/src/graph/folds.ts +236 -0
  54. package/src/graph/index.ts +17 -2
  55. package/src/graph/priorities.ts +1 -1
  56. package/src/graph/scheduler.ts +45 -31
  57. package/src/graph/task-graph.ts +190 -58
  58. package/src/graph/task-status.ts +95 -0
  59. package/src/{orchestrator → history}/failure-mode.ts +1 -1
  60. package/src/{orchestrator → history}/history.ts +1 -1
  61. package/src/history/index.ts +48 -0
  62. package/src/{orchestrator → history}/metrics.ts +7 -20
  63. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  64. package/src/index.ts +11 -29
  65. package/src/orchestrator/affected-tasks.ts +20 -19
  66. package/src/orchestrator/doctor.ts +1 -1
  67. package/src/orchestrator/download-policy.ts +16 -22
  68. package/src/orchestrator/excluded-keys.ts +4 -5
  69. package/src/orchestrator/execute-args.ts +80 -0
  70. package/src/orchestrator/execute-task.ts +50 -411
  71. package/src/orchestrator/fingerprint-watch.ts +2 -2
  72. package/src/orchestrator/index.ts +2 -73
  73. package/src/orchestrator/keyed-projects.ts +12 -51
  74. package/src/orchestrator/local-shortcircuit.ts +1 -1
  75. package/src/orchestrator/miss-save.ts +33 -3
  76. package/src/orchestrator/options.ts +1 -36
  77. package/src/orchestrator/output-host.ts +4 -3
  78. package/src/orchestrator/persistent-task.ts +321 -0
  79. package/src/orchestrator/persistent.ts +130 -3
  80. package/src/orchestrator/placement.ts +5 -5
  81. package/src/orchestrator/plan.ts +19 -6
  82. package/src/orchestrator/plugin-host.ts +129 -69
  83. package/src/orchestrator/plugin.ts +18 -8
  84. package/src/orchestrator/prepare.ts +40 -34
  85. package/src/orchestrator/projects.ts +7 -10
  86. package/src/orchestrator/remote-prefetch.ts +14 -1
  87. package/src/orchestrator/run-artifacts.ts +5 -177
  88. package/src/orchestrator/run-context.ts +55 -0
  89. package/src/orchestrator/run-failures.ts +15 -3
  90. package/src/orchestrator/run-records.ts +35 -3
  91. package/src/orchestrator/run-report.ts +151 -171
  92. package/src/orchestrator/run-settings.ts +1 -16
  93. package/src/orchestrator/run-steps.ts +57 -0
  94. package/src/orchestrator/run-stop.ts +153 -0
  95. package/src/orchestrator/run.ts +293 -604
  96. package/src/orchestrator/sandbox-fix.ts +1 -17
  97. package/src/orchestrator/sandbox-request.ts +2 -2
  98. package/src/orchestrator/signals.ts +14 -6
  99. package/src/orchestrator/stable-keys.ts +164 -27
  100. package/src/orchestrator/steps.ts +70 -0
  101. package/src/orchestrator/task-hash.ts +34 -25
  102. package/src/orchestrator/task-log-buffer.ts +1 -1
  103. package/src/orchestrator/task-process.ts +94 -0
  104. package/src/orchestrator/telemetry-host.ts +4 -4
  105. package/src/orchestrator/telemetry.ts +14 -86
  106. package/src/orchestrator/upstream.ts +4 -181
  107. package/src/{orchestrator → report}/events.ts +41 -8
  108. package/src/{orchestrator → report}/forecast.ts +35 -2
  109. package/src/{orchestrator → report}/framed-output.ts +40 -34
  110. package/src/report/index.ts +36 -0
  111. package/src/{orchestrator → report}/logger.ts +158 -20
  112. package/src/{orchestrator → report}/status-line.ts +88 -7
  113. package/src/{orchestrator → report}/summary.ts +126 -35
  114. package/src/report/tally.ts +83 -0
  115. package/src/settings.ts +12 -39
  116. package/src/util/colors.ts +44 -4
  117. package/src/util/git-ignored.ts +143 -0
  118. package/src/util/index.ts +19 -4
  119. package/src/util/paths.ts +1 -1
  120. package/src/util/report.ts +13 -5
  121. package/src/util/settle.ts +14 -39
  122. package/src/util/tail.ts +1 -1
  123. package/src/util/ui.ts +17 -6
  124. package/src/workspace/affected.ts +81 -2
  125. package/src/workspace/config-imports.ts +1 -1
  126. package/src/workspace/config-schema.ts +142 -143
  127. package/src/workspace/lockfile.ts +1 -1
  128. package/src/workspace/migration.ts +19 -3
  129. package/schemas/summary.json +0 -133
  130. package/src/cache/chained-cache.ts +0 -280
  131. package/src/orchestrator/tally.ts +0 -112
  132. /package/src/{orchestrator → report}/path-links.ts +0 -0
  133. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  134. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
@@ -0,0 +1,94 @@
1
+ // What a task's process runs with (its env, its timeout) and what an
2
+ // undeclared write costs the run's memos. Shared by the one-shot path
3
+ // (execute-task.ts) and the server path (persistent-task.ts).
4
+
5
+ import path from 'node:path'
6
+ import type { ExecConfig } from '../config.js'
7
+ import type { GitFilesCache } from '../cache/index.js'
8
+ import {
9
+ buildIsolatedEnv,
10
+ packageManagerPath,
11
+ PM_EXEC_ENV,
12
+ VX_RUN_TASK_ENV,
13
+ VX_RUN_WORKSPACE_ENV,
14
+ } from '../exec/index.js'
15
+ import type { TaskNode } from '../graph/index.js'
16
+ import { parseDuration } from '../util/index.js'
17
+
18
+ /**
19
+ * Drop the run's facts about files a task may have written without
20
+ * declaring them (`undeclaredWriteReach`): the project's git snapshot and
21
+ * its index OIDs. The next reader
22
+ * re-enumerates the project (one `git ls-files`) and hashes its files by
23
+ * content; the per-declaration file-list memos are keyed on the snapshot
24
+ * array, so they miss with it (item 743).
25
+ */
26
+ export function forgetUndeclaredWrites(
27
+ args: { node: TaskNode; gitFilesCache?: GitFilesCache | undefined },
28
+ reach: 'none' | 'project' | 'workspace',
29
+ ): void {
30
+ if (reach === 'none') return
31
+ if (reach === 'workspace') {
32
+ args.gitFilesCache?.clear()
33
+ return
34
+ }
35
+ args.gitFilesCache?.delete(args.node.projectDir)
36
+ args.gitFilesCache?.invalidateWorkspacePartition()
37
+ }
38
+
39
+ /** The two bin directories a task's PATH starts with: its own, then the root's (once when they coincide). */
40
+ export function taskBinDirs(node: TaskNode, workspaceRoot: string): string[] {
41
+ const bins = [path.join(node.projectDir, 'node_modules', '.bin')]
42
+ const rootBin = path.join(workspaceRoot, 'node_modules', '.bin')
43
+ // Identical when the root is itself a project — dedupe rather than list it
44
+ // twice, so PATH reads the same either way.
45
+ if (rootBin !== bins[0]) bins.push(rootBin)
46
+ return bins
47
+ }
48
+
49
+ /**
50
+ * Build the child-process env for one task. Same arguments at every call site
51
+ * (persistent + cached). Two `node_modules/.bin` directories are prepended to
52
+ * PATH: the project's own, then the WORKSPACE ROOT's. Never a sibling
53
+ * project's, and never an arbitrary ancestor — that is the project-isolation
54
+ * rule, and the root is not a sibling.
55
+ *
56
+ * The root entry is where a monorepo's shared tooling actually lives: declare
57
+ * `oxlint` once as a root devDependency and every member's `.bin` is empty of
58
+ * it. Without the root on PATH such a task exits 127, which is what this
59
+ * repo's own gate did the moment core stopped BEING the root and the two
60
+ * paths stopped coinciding. It also removes a divergence that mattered more:
61
+ * the REAPI executor already rebuilds both entries in the action's command,
62
+ * so a task that resolved on a worker failed on the machine that submitted
63
+ * it. npm/pnpm/yarn all put the ancestor chain on PATH for the same reason.
64
+ */
65
+ export function taskEnv(
66
+ node: TaskNode,
67
+ step: ExecConfig,
68
+ workspaceRoot: string,
69
+ ): NodeJS.ProcessEnv {
70
+ const bins = taskBinDirs(node, workspaceRoot)
71
+ const env = buildIsolatedEnv({
72
+ passThrough: step.env?.passThrough ?? [],
73
+ define: step.env?.define ?? {},
74
+ source: process.env,
75
+ binPaths: bins,
76
+ })
77
+ env[VX_RUN_WORKSPACE_ENV] = workspaceRoot
78
+ env[VX_RUN_TASK_ENV] = node.id
79
+ // A task's own passThrough or define wins.
80
+ if (env[PM_EXEC_ENV] === undefined) {
81
+ const manager = packageManagerPath(workspaceRoot)
82
+ if (manager !== null) env[PM_EXEC_ENV] = manager
83
+ }
84
+ return env
85
+ }
86
+
87
+ /** A task's own `exec.timeout` in ms; the schema has checked its form. */
88
+ export function taskTimeoutMs(step: ExecConfig): number | undefined {
89
+ return step.timeout === undefined ? undefined : parseDuration(step.timeout)!
90
+ }
91
+
92
+ export function isAborted(signal: AbortSignal | undefined): boolean {
93
+ return signal?.aborted === true
94
+ }
@@ -11,7 +11,7 @@
11
11
  //
12
12
  // See docs/design/observability-architecture-2026-06.md §1, §3.
13
13
 
14
- import type { EventBus } from './events.js'
14
+ import type { EventBus } from '../report/index.js'
15
15
  import type { TaskNode } from '../graph/index.js'
16
16
  import type {
17
17
  RunContextRecord,
@@ -27,8 +27,8 @@ import type { VxPlugin } from './plugin.js'
27
27
  export interface TelemetryHandle {
28
28
  /** Emit the per-run summary to every sink (crash-isolated). */
29
29
  emitSummary(summary: RunSummaryRecord): void
30
- /** Await every sink's flush (crash-isolated, time-bounded by the sink). */
31
- flush(): Promise<void>
30
+ /** Await every sink's flush (crash-isolated, time-bounded by the sink), each as a `<plugin>:telemetry` step. */
31
+ flush(step?: <T>(id: string, work: () => Promise<T>) => Promise<T>): Promise<void>
32
32
  /** Remove the bus subscription. Idempotent. */
33
33
  dispose(): void
34
34
  /** Sample a running task's process tree; see `TelemetrySource.track`. */
@@ -125,7 +125,7 @@ export async function subscribeTelemetry(
125
125
  const dispose = bus.subscribe(source.subscriber)
126
126
  return {
127
127
  emitSummary: (summary) => source.emitSummary(summary),
128
- flush: () => source.flush(),
128
+ flush: (step) => source.flush(step),
129
129
  dispose,
130
130
  ...(source.track !== undefined ? { track: source.track } : {}),
131
131
  }
@@ -18,6 +18,9 @@
18
18
 
19
19
  import { sampleTrees } from '../exec/index.js'
20
20
  import {
21
+ type CacheSource,
22
+ deriveCacheSource,
23
+ isCacheHit,
21
24
  isGroupTask,
22
25
  type InputChanges,
23
26
  type TaskNode,
@@ -25,88 +28,10 @@ import {
25
28
  type TaskStatus,
26
29
  } from '../graph/index.js'
27
30
  import { maskedCommand, settleWithin, teardownTimeoutMs } from '../util/index.js'
28
- import type { RunEvent, RunEventSubscriber } from './events.js'
31
+ import type { RunEvent, RunEventSubscriber } from '../report/index.js'
29
32
 
30
33
  /** Bumped when the record shape changes. Readers MUST check `v`. */
31
- export const TELEMETRY_SCHEMA_VERSION = 3
32
-
33
- /** Where a task's result came from, derived ONCE in core from the status. */
34
- export type CacheSource = 'miss' | 'local' | 'remote' | 'none'
35
-
36
- /**
37
- * Map a task outcome status to its cache source. `success`/`failed` ran
38
- * (a miss as far as the cache read is concerned); the two cache-hit
39
- * statuses restored from local/remote; `skipped`/`aborted` never engaged
40
- * the cache. Pure function — the single place this mapping is decided;
41
- * `isCacheHit` below is derived from it rather than re-listing the two
42
- * hit statuses, so there is one decision, not two that can disagree.
43
- */
44
- export function deriveCacheSource(status: TaskStatus): CacheSource {
45
- switch (status) {
46
- case 'cache-hit':
47
- return 'local'
48
- case 'cache-hit-remote':
49
- return 'remote'
50
- case 'success':
51
- case 'failed':
52
- return 'miss'
53
- case 'skipped':
54
- case 'aborted':
55
- return 'none'
56
- }
57
- }
58
-
59
- /**
60
- * Whether each status counts as a PASS. Written as a `Record<TaskStatus, …>`
61
- * rather than a `new Set([...])` on purpose: a Record must name every member,
62
- * so adding a status to the union is a COMPILE error HERE and the omission
63
- * cannot ship. A Set of string literals has no such tripwire — it silently
64
- * answers `false` for the new member, which is how ten hand-rolled copies of
65
- * this vocabulary accumulated across core, cloud and the dashboard.
66
- */
67
- const PASSES: Record<TaskStatus, boolean> = {
68
- success: true,
69
- 'cache-hit': true,
70
- 'cache-hit-remote': true,
71
- failed: false,
72
- skipped: false,
73
- aborted: false,
74
- }
75
-
76
- /**
77
- * Every `TaskStatus`, at runtime. Read off `PASSES`'s keys, which the
78
- * `Record<TaskStatus, …>` type guarantees is exactly the union — so this is
79
- * derived, not a second list that can fall behind. Exported so a consumer that
80
- * cannot import the type (a test asserting a copy of this vocabulary in
81
- * another package agrees) can still enumerate the real set.
82
- */
83
- export const TASK_STATUSES: readonly TaskStatus[] = Object.keys(PASSES) as TaskStatus[]
84
-
85
- /**
86
- * Did the task pass? A cache hit counts — it produced the same result without
87
- * spending the time, which is the whole point. `skipped` and `aborted` do NOT:
88
- * neither finished on its own terms, so neither can vouch for anything.
89
- *
90
- * Takes `string`, not `TaskStatus`, because most callers hold a status that
91
- * arrived over a wire or out of a database column. An unrecognised string
92
- * reads as NOT passing — the safe direction, since the alternative is calling
93
- * a run green on a status this build has never heard of.
94
- */
95
- export function isPassStatus(status: string): boolean {
96
- return PASSES[status as TaskStatus] === true
97
- }
98
-
99
- /**
100
- * Did the task's result come out of the cache (either layer)? Derived from
101
- * `deriveCacheSource` rather than re-listing the two hit statuses, so the two
102
- * cannot disagree about what a hit is. Unknown strings read as not-a-hit.
103
- */
104
- export function isCacheHit(status: string): boolean {
105
- // No known-status guard: an unknown string falls through the switch to
106
- // undefined, which is neither hit source (the guard survived item 654).
107
- const source = deriveCacheSource(status as TaskStatus)
108
- return source === 'local' || source === 'remote'
109
- }
34
+ export const TELEMETRY_SCHEMA_VERSION = 4
110
35
 
111
36
  /** Identifies which run a record belongs to + its captured context. Maps
112
37
  * cleanly onto OTel CI/CD + VCS resource attributes. */
@@ -162,8 +87,6 @@ export interface RunContextRecord {
162
87
  host: string | null
163
88
  os: string
164
89
  arch: string
165
- /** `--tag k=v` pairs. */
166
- tags: Readonly<Record<string, string>>
167
90
  }
168
91
 
169
92
  /** One attempt of a retried task that failed and was run again. */
@@ -468,8 +391,11 @@ export interface TelemetrySource {
468
391
  readonly subscriber: RunEventSubscriber
469
392
  /** Fan the per-run summary to every sink's `onRunSummary` (crash-isolated). */
470
393
  emitSummary(summary: RunSummaryRecord): void
471
- /** Await every sink's `flush()` (each crash-isolated, all time-bounded). */
472
- flush(): Promise<void>
394
+ /**
395
+ * Await every sink's `flush()` (each crash-isolated, all time-bounded),
396
+ * each as the step `<plugin>:telemetry` through `step`.
397
+ */
398
+ flush(step?: <T>(id: string, work: () => Promise<T>) => Promise<T>): Promise<void>
473
399
  /**
474
400
  * Sample this task's process tree every `SAMPLE_MS` until the returned
475
401
  * function is called, the root exits, or the run ends. Present only when
@@ -702,7 +628,7 @@ export function createTelemetrySource(args: {
702
628
  }
703
629
  }
704
630
  },
705
- async flush(): Promise<void> {
631
+ async flush(step = <T>(_id: string, work: () => Promise<T>): Promise<T> => work()) {
706
632
  const ms = teardownTimeoutMs()
707
633
  // Bounded, for the same reason plugin teardown is bounded in
708
634
  // plugin-host.ts: run() awaits this BEFORE closeCache() and before it
@@ -718,7 +644,9 @@ export function createTelemetrySource(args: {
718
644
  sinks.map(async (sink) => {
719
645
  if (disabled.has(sink) || sink.flush === undefined) return
720
646
  try {
721
- await sink.flush(deadline.signal)
647
+ await step(`${args.owners?.get(sink) ?? label(sink)}:telemetry`, () =>
648
+ sink.flush!(deadline.signal),
649
+ )
722
650
  } catch (err) {
723
651
  // A flush failure can never break the run — but it still costs
724
652
  // this sink its export, so say so rather than dropping it.
@@ -1,14 +1,9 @@
1
1
  import {
2
- DependencySpecError,
3
- compileTaskPattern,
4
- isGroupTask,
5
- isTaskPattern,
6
- parseDependencySpec,
7
- type DependencySpec,
2
+ selectFoldedDeps,
3
+ type FoldCandidate,
8
4
  type TaskNode,
9
5
  type TaskOutcome,
10
6
  } from '../graph/index.js'
11
- import { UserError } from '../util/index.js'
12
7
 
13
8
  /**
14
9
  * A task's upstream as its KEY reads it: the live outcomes of its scheduled
@@ -36,15 +31,9 @@ export function keyedOutcome(node: TaskNode, hash: string | undefined): TaskOutc
36
31
  return o
37
32
  }
38
33
 
39
- /** `node.deps` less its ordering-only edges: the dependencies a key may fold. */
40
- export function keyedDeps(node: TaskNode): readonly string[] {
41
- const order = node.orderOnly
42
- return order === undefined ? node.deps : node.deps.filter((d) => !order.includes(d))
43
- }
44
-
45
34
  /**
46
35
  * Pick which upstream task hashes participate in the current task's
47
- * cache key, filtered by `cache.inputs.tasks`. The folded value is the
36
+ * cache key, filtered by `inputs.tasks`. The folded value is the
48
37
  * upstream's own cache key (its input-based task hash) — pure-input
49
38
  * transitive hashing, like Turbo/Nx. An upstream change propagates
50
39
  * through its key into every dependent's key. There is deliberately NO
@@ -77,135 +66,6 @@ export function filterUpstreamHashes(
77
66
  ])
78
67
  }
79
68
 
80
- /**
81
- * A filter selects among the task's own dependencies, never through a
82
- * group: `dependsOn: ['build']` over a group `build` of `build.a` and
83
- * `build.b` offers the filter `build`, not its members. A pattern that
84
- * matches only a member (`build.*`) therefore selected nothing, and the key stopped moving
85
- * with that member's inputs: a stale hit under a green run. Such a spec
86
- * is refused, once, over the graph every key site reads. One that matches
87
- * nothing anywhere stays legal (a preset's pattern needn't match in every
88
- * project). An exact name is refused earlier, at load, unless `dependsOn`
89
- * names it (`assertFilterNamesDeclaredDeps`).
90
- */
91
- export function refuseFiltersThroughGroups(nodes: ReadonlyMap<string, TaskNode>): void {
92
- for (const node of nodes.values()) {
93
- const filter = node.config.cache?.inputs?.tasks
94
- if (filter === undefined || filter.length === 0) continue
95
- const deps = keyedDeps(node).flatMap((id) => nodes.get(id) ?? [])
96
- const groups = deps.filter(isGroupTask)
97
- if (groups.length === 0) continue
98
- for (const raw of filter) {
99
- const spec = parseSpec(raw, node.id)
100
- if (spec.negated) continue
101
- const matches = specMatcher(spec)
102
- const hit = (n: TaskNode): boolean => matches(n, n.projectName === node.projectName)
103
- if (deps.some(hit)) continue
104
- for (const group of groups) {
105
- const member = groupMembers(group, nodes).find(hit)
106
- if (member === undefined) continue
107
- throw new UserError(
108
- `${node.id}: cache.inputs.tasks: '${raw}' matches ${member.id} only through the group ${group.id}, so it selects nothing; name '${group.taskName}' or depend on ${member.id} directly`,
109
- )
110
- }
111
- }
112
- }
113
- }
114
-
115
- function parseSpec(raw: string, selfTaskId: string): DependencySpec {
116
- try {
117
- return parseDependencySpec(raw)
118
- } catch (err) {
119
- if (err instanceof DependencySpecError) {
120
- throw new UserError(`${selfTaskId}: cache.inputs.tasks: ${err.message}`)
121
- }
122
- throw err
123
- }
124
- }
125
-
126
- /** Every task a group stands for, through nested groups, each once. */
127
- function groupMembers(group: TaskNode, nodes: ReadonlyMap<string, TaskNode>): TaskNode[] {
128
- const out: TaskNode[] = []
129
- const seen = new Set<string>([group.id])
130
- const stack = group.deps.toReversed()
131
- while (stack.length > 0) {
132
- const id = stack.pop()!
133
- if (seen.has(id)) continue
134
- seen.add(id)
135
- const n = nodes.get(id)
136
- if (n === undefined) continue
137
- out.push(n)
138
- if (isGroupTask(n)) stack.push(...n.deps.toReversed())
139
- }
140
- return out
141
- }
142
-
143
- /**
144
- * One dependency as the key fold sees it: the task, and the value the
145
- * fold dedups by — its hash on the hash path, a structural stand-in for
146
- * the hash on the graph (`foldUnit` in keyed-projects.ts).
147
- */
148
- export interface FoldCandidate {
149
- node: TaskNode
150
- unit: string
151
- }
152
-
153
- /**
154
- * The dependencies a task's key folds, per its `cache.inputs.tasks`.
155
- *
156
- * Patterns (Turbo/Nx micro-syntax + filter extensions):
157
- * '*' all same-project upstream
158
- * '^*' all dep-workspace upstream
159
- * 'name' same-project task `name`
160
- * '^name' `name` task in every dep workspace
161
- * 'pkg#name' specific package's `name` task
162
- * 'name.*' patterns — EITHER half of any form above may contain `*`
163
- * (same glob as dependsOn patterns), including the project
164
- * half of `pkg#name`. A filter that matched literally here
165
- * would silently select ZERO upstream hashes and decouple the
166
- * task from its dependencies — a stale-hit trap, so every
167
- * name is matched through the shared glob.
168
- * '!<form>' exclude — any of the above with a leading `!`
169
- *
170
- * Patterns are applied in order; last write wins, so
171
- * `['*', '^*', '!^noisy']` reads as "all minus deps' noisy".
172
- *
173
- * Defaults:
174
- * - `filter === undefined` → every candidate, as given.
175
- * - `filter === []` → none (fully decoupled).
176
- *
177
- * Otherwise deduped by `unit`, first candidate kept. The unit is the
178
- * fold's own identity, not the task's: two groups over the same members
179
- * share one hash, so excluding either excludes both, and a selection by
180
- * task id would keep the other while the key folded neither.
181
- */
182
- export function selectFoldedDeps(
183
- deps: readonly FoldCandidate[],
184
- filter: readonly string[] | undefined,
185
- selfProjectName: string,
186
- selfTaskId: string,
187
- ): FoldCandidate[] {
188
- if (filter === undefined) return [...deps]
189
-
190
- const specs: DependencySpec[] = filter.map((raw) => parseSpec(raw, selfTaskId))
191
-
192
- // Per-spec predicate, compiled once (exact compares + patterns).
193
- const matchers = specs.map((spec) => specMatcher(spec))
194
-
195
- const selected = new Map<string, FoldCandidate>()
196
- for (let i = 0; i < specs.length; i++) {
197
- const spec = specs[i]!
198
- const matches = matchers[i]!
199
- for (const d of deps) {
200
- const isSelf = d.node.projectName === selfProjectName
201
- if (!matches(d.node, isSelf)) continue
202
- if (spec.negated) selected.delete(d.unit)
203
- else if (!selected.has(d.unit)) selected.set(d.unit, d)
204
- }
205
- }
206
- return [...selected.values()]
207
- }
208
-
209
69
  /**
210
70
  * Expand any GROUP among these outcomes into the real tasks it stands for,
211
71
  * transitively, preserving order and de-duplicating by task id.
@@ -223,7 +83,7 @@ export function selectFoldedDeps(
223
83
  * along two paths (a group `build` whose `^build` meets a package diamond)
224
84
  * is expanded once, or the walk doubles per layer. Only what the FILTER
225
85
  * already selected is passed in, so a group excluded from a task's
226
- * `cache.inputs.tasks` brings no members with it.
86
+ * `inputs.tasks` brings no members with it.
227
87
  */
228
88
  export function expandGroupUpstream(upstream: readonly TaskOutcome[]): TaskOutcome[] {
229
89
  const out: TaskOutcome[] = []
@@ -245,40 +105,3 @@ export function expandGroupUpstream(upstream: readonly TaskOutcome[]): TaskOutco
245
105
  }
246
106
  return out
247
107
  }
248
-
249
- /** Exact-name compare, or the shared `*`-glob when the name is a pattern. */
250
- function nameMatcher(name: string): (candidate: string) => boolean {
251
- if (isTaskPattern(name)) {
252
- const re = compileTaskPattern(name)
253
- return (candidate) => re.test(candidate)
254
- }
255
- return (candidate) => candidate === name
256
- }
257
-
258
- /**
259
- * Compile one spec into an upstream predicate. BOTH halves of a `pkg#task`
260
- * form glob: a filter only ever selects from upstreams that already exist,
261
- * so a package pattern is unambiguous here — unlike dependsOn, which must
262
- * materialize concrete edges and therefore rejects it.
263
- */
264
- function specMatcher(spec: DependencySpec): (n: TaskNode, isSelf: boolean) => boolean {
265
- switch (spec.kind) {
266
- case 'wildcardSelf':
267
- return (_n, isSelf) => isSelf
268
- case 'wildcardDeps':
269
- return (_n, isSelf) => !isSelf
270
- case 'self': {
271
- const task = nameMatcher(spec.task)
272
- return (n, isSelf) => isSelf && task(n.taskName)
273
- }
274
- case 'deps': {
275
- const task = nameMatcher(spec.task)
276
- return (n, isSelf) => !isSelf && task(n.taskName)
277
- }
278
- case 'cross': {
279
- const project = nameMatcher(spec.project)
280
- const task = nameMatcher(spec.task)
281
- return (n) => project(n.projectName) && task(n.taskName)
282
- }
283
- }
284
- }
@@ -16,7 +16,7 @@ import type { RunContext } from './summary.js'
16
16
  /**
17
17
  * A task no cache answered for: it declares no `cache`, or the run's policy
18
18
  * read and wrote nothing. Its run is `no-cache`, never a miss, on every
19
- * surface — `--dry`'s plan, the frame, the footer, `--report`, `--summarize`.
19
+ * surface — `--dry`'s plan, the frame, the footer, the run history.
20
20
  */
21
21
  export function ranNoCache(o: { node: TaskNode; cacheOff?: boolean }): boolean {
22
22
  return o.node.config.cache === undefined || o.cacheOff === true
@@ -50,7 +50,12 @@ export type RunEvent =
50
50
  | { kind: 'task:stderr'; node: TaskNode; chunk: string }
51
51
  | { kind: 'task:complete'; node: TaskNode; outcome: TaskOutcome }
52
52
  | { kind: 'run:status'; line: string }
53
+ /** A warning, which the terminal prints on stderr. */
54
+ | { kind: 'run:status'; line: string; warn: true }
55
+ | { kind: 'step:start'; id: string }
56
+ | { kind: 'step:end'; id: string; durationMs: number; ok: boolean }
53
57
  | { kind: 'run:end' }
58
+ | { kind: 'run:done' }
54
59
 
55
60
  export type RunEventSubscriber = (event: RunEvent) => void
56
61
 
@@ -112,11 +117,13 @@ export function busLogger(bus: EventBus): Logger {
112
117
  // run() reaches runEnd on its success path, in its finally and from a
113
118
  // signal; a plugin's onRunEnd hears the first only.
114
119
  let ended = false
120
+ let done = false
115
121
  // A status line carries plugin warnings, and a remote layer's warning
116
122
  // quotes the server's reply, which may echo what it was sent (L-40).
117
123
  const secrets = secretMask([process.env])
118
124
  return {
119
125
  status: (line) => bus.emit({ kind: 'run:status', line: secrets?.mask(line) ?? line }),
126
+ warn: (line) => bus.emit({ kind: 'run:status', line: secrets?.mask(line) ?? line, warn: true }),
120
127
  taskStdout: (node, chunk) => bus.emit({ kind: 'task:stdout', node, chunk }),
121
128
  taskStderr: (node, chunk) => bus.emit({ kind: 'task:stderr', node, chunk }),
122
129
  taskComplete: (node, outcome) => bus.emit({ kind: 'task:complete', node, outcome }),
@@ -127,6 +134,13 @@ export function busLogger(bus: EventBus): Logger {
127
134
  ended = true
128
135
  bus.emit({ kind: 'run:end' })
129
136
  },
137
+ stepStart: (id) => bus.emit({ kind: 'step:start', id }),
138
+ stepEnd: (id, info) => bus.emit({ kind: 'step:end', id, ...info }),
139
+ runDone: () => {
140
+ if (done) return
141
+ done = true
142
+ bus.emit({ kind: 'run:done' })
143
+ },
130
144
  }
131
145
  }
132
146
 
@@ -156,11 +170,21 @@ export function terminalSubscriber(sink: Logger): RunEventSubscriber {
156
170
  sink.taskComplete(event.node, event.outcome)
157
171
  return
158
172
  case 'run:status':
159
- sink.status(event.line)
173
+ if ('warn' in event && sink.warn !== undefined) sink.warn(event.line)
174
+ else sink.status(event.line)
175
+ return
176
+ case 'step:start':
177
+ sink.stepStart?.(event.id)
178
+ return
179
+ case 'step:end':
180
+ sink.stepEnd?.(event.id, { durationMs: event.durationMs, ok: event.ok })
160
181
  return
161
182
  case 'run:end':
162
183
  sink.runEnd?.()
163
184
  return
185
+ case 'run:done':
186
+ sink.runDone?.()
187
+ return
164
188
  }
165
189
  }
166
190
  }
@@ -234,9 +258,8 @@ export interface OutcomeView {
234
258
  durationMs: number
235
259
  /**
236
260
  * Group node (no `exec`) — organizational, not work. `TaskView` carries
237
- * this too, but a consumer that only holds outcomes (the `--report`
238
- * writer) has no node to ask, and counting groups as executed tasks is
239
- * exactly how it drifted from the terminal and `--summarize`.
261
+ * this too, but a consumer that only holds outcomes has no node to ask,
262
+ * and counting groups as executed tasks drifts from the terminal.
240
263
  */
241
264
  isGroup?: boolean
242
265
  /** Executed with no `cache` block: never consulted the cache, not a miss. */
@@ -329,7 +352,7 @@ export function skippedReason(blockedBy: string | undefined): string {
329
352
  }
330
353
 
331
354
  /** `skipped (blocked by lib#build)`, or a bare `skipped` for fail-fast's. */
332
- export function skippedLabel(blockedBy: string | undefined): string {
355
+ function skippedLabel(blockedBy: string | undefined): string {
333
356
  const why = skippedReason(blockedBy)
334
357
  return why === '' ? 'skipped' : `skipped (${why})`
335
358
  }
@@ -439,7 +462,12 @@ export type WireEvent =
439
462
  | { kind: 'task:stderr'; taskId: string; chunk: string }
440
463
  | { kind: 'task:complete'; outcome: OutcomeView }
441
464
  | { kind: 'run:status'; line: string }
465
+ /** A warning, which the terminal prints on stderr. */
466
+ | { kind: 'run:status'; line: string; warn: true }
467
+ | { kind: 'step:start'; id: string }
468
+ | { kind: 'step:end'; id: string; durationMs: number; ok: boolean }
442
469
  | { kind: 'run:end' }
470
+ | { kind: 'run:done' }
443
471
 
444
472
  /** Map an in-process event to its serializable wire form. */
445
473
  export function toWireEvent(event: RunEvent): WireEvent {
@@ -455,8 +483,13 @@ export function toWireEvent(event: RunEvent): WireEvent {
455
483
  case 'task:complete':
456
484
  return { kind: 'task:complete', outcome: projectOutcome(event.outcome) }
457
485
  case 'run:status':
458
- return { kind: 'run:status', line: event.line }
486
+ return 'warn' in event
487
+ ? { kind: 'run:status', line: event.line, warn: true }
488
+ : { kind: 'run:status', line: event.line }
489
+ case 'step:start':
490
+ case 'step:end':
459
491
  case 'run:end':
460
- return { kind: 'run:end' }
492
+ case 'run:done':
493
+ return event
461
494
  }
462
495
  }
@@ -2,7 +2,7 @@
2
2
  // p50 in local history. A display aid for the live region only: it never
3
3
  // steers the scheduler and never reaches the final summary.
4
4
 
5
- import { isGroupTask, type TaskNode } from '../graph/index.js'
5
+ import { computeReverseDepCount, isGroupTask, type TaskNode } from '../graph/index.js'
6
6
 
7
7
  /** What the live region says about the rest of a run. */
8
8
  export interface LiveForecast {
@@ -14,6 +14,18 @@ export interface LiveForecast {
14
14
  blocks: ReadonlyMap<string, number>
15
15
  /** Each running task's p50, where history knows it: the row's expected duration. */
16
16
  expected?: ReadonlyMap<string, number>
17
+ /**
18
+ * Tasks not started because a dependency has not finished, in the order
19
+ * the scheduler would start them, at most one per worker: what an idle
20
+ * worker row holds (M, 2026-10-15).
21
+ */
22
+ blocked?: readonly BlockedTask[]
23
+ }
24
+
25
+ /** A task an unfinished dependency holds back, and what holds it: the running task its longest chain waits on, else that dependency. */
26
+ export interface BlockedTask {
27
+ id: string
28
+ by: string
17
29
  }
18
30
 
19
31
  export type Forecast = (
@@ -68,6 +80,14 @@ export function createForecast(
68
80
  if (p !== undefined) cost.set(n.id, p)
69
81
  }
70
82
  const workers = Math.max(1, concurrency)
83
+ // The scheduler's own baseline ranking, most dependents first, ties in
84
+ // graph order: once per run, as the scheduler computes it.
85
+ const rank = computeReverseDepCount(nodes)
86
+ const position = new Map(order.map((n, i) => [n.id, i]))
87
+ const before = (a: string, b: string): boolean => {
88
+ const d = (rank.get(a) ?? 0) - (rank.get(b) ?? 0)
89
+ return d !== 0 ? d > 0 : position.get(a)! < position.get(b)!
90
+ }
71
91
 
72
92
  const dependents = new Map<string, string[]>()
73
93
  for (const n of order)
@@ -106,6 +126,8 @@ export function createForecast(
106
126
  let work = 0
107
127
  let known = false
108
128
  let overrun = false
129
+ // The best `workers` blocked tasks, kept sorted as the pass meets them.
130
+ const held: BlockedTask[] = []
109
131
  for (const n of order) {
110
132
  if (finished.has(n.id)) continue
111
133
  let start = 0
@@ -120,6 +142,16 @@ export function createForecast(
120
142
  const since = running.get(n.id)
121
143
  if (since !== undefined) head.set(n.id, n.id)
122
144
  else if (via !== undefined && head.has(via)) head.set(n.id, head.get(via)!)
145
+ const by =
146
+ since === undefined ? (head.get(n.id) ?? n.deps.find((d) => end.has(d))) : undefined
147
+ if (by !== undefined && !isGroupTask(n)) {
148
+ let at = held.length
149
+ while (at > 0 && before(n.id, held[at - 1]!.id)) at--
150
+ if (at < workers) {
151
+ held.splice(at, 0, { id: n.id, by })
152
+ if (held.length > workers) held.pop()
153
+ }
154
+ }
123
155
  const p = cost.get(n.id)
124
156
  let own = 0
125
157
  if (p !== undefined) {
@@ -135,13 +167,14 @@ export function createForecast(
135
167
  }
136
168
  work += own
137
169
  }
138
- if (!known || overrun) return { blocks, expected }
170
+ if (!known || overrun) return { blocks, expected, blocked: held }
139
171
  const critical = chainEnd === undefined ? undefined : head.get(chainEnd)
140
172
  return {
141
173
  etaMs: Math.max(chain, work / workers),
142
174
  ...(critical !== undefined ? { critical } : {}),
143
175
  blocks,
144
176
  expected,
177
+ blocked: held,
145
178
  }
146
179
  }
147
180
  }