skills/debug-task/references/config-mistakes.md
This reference covers the task configuration errors that cause the most confusion. Each section describes the mistake, why it happens, how to detect it, and how to fix it.
command vs scriptaffectedFiles misconfigurationextends not resolvingrunInCI variantsallowFailure hiding errorsmutex contentiontimeout and retryCountos platform filteringoutputStyle and missing output#tag targets — v2.3+cacheStrategy — v2.3+taskOptions — v2.4+command vs scriptThis is the single most common configuration mistake.
command accepts a single binary name with optional arguments — also known as a
simple command in shell
terminology. It supports task inheritance merge strategies.
tasks:
lint:
command: 'eslint'
args:
- '--ext'
- '.ts,.tsx'
- 'src/'
script accepts
pipelines, compound commands,
and full shell syntax — pipes, redirects, &&, ||, subshells. It does not support inheritance
merging.
tasks:
lint:
script: 'eslint --ext .ts,.tsx src/ && prettier --check src/'
# WRONG: shell syntax in command
tasks:
lint:
command: 'eslint . && prettier --check .'
In v2 this is a parse error — moon rejects the configuration at runtime with an error.
moon task <project>:<task> --json
If the command field contains pipes, redirects, expressions, etc., it should be script instead.
Move the value to script. If you need inheritance merging for args, split into separate tasks and
use deps to chain them:
tasks:
lint-eslint:
command: 'eslint'
args: ['--ext', '.ts,.tsx', 'src/']
lint-prettier:
command: 'prettier'
args: ['--check', 'src/']
lint:
# Run both linters
deps:
- '~:lint-eslint'
- '~:lint-prettier'
moon's inheritance system lets you define tasks once in .moon/tasks/**/* and have them inherited
by matching projects. When inheritance goes wrong, the task either doesn't appear or appears with
unexpected config.
Check the inheritedBy conditions in the global task file:
# .moon/tasks/node-lint.yml
inheritedBy:
toolchain: 'node'
stack: 'frontend'
Every defined field must match (an implicit AND across fields). If the project has
toolchain: 'node' but stack: 'backend', it won't inherit this task. The canonical field names
are plural (languages, layers, stacks, tags, toolchains — the singular forms above are
aliases), and files (workspace-relative file existence) and order conditions also exist. A list
of values within languages/layers/stacks matches with OR semantics; explicit and/or/not
clause objects are only supported by tags and toolchains.
# See the project's metadata
moon project <project> --json
Compare toolchains, stack, layer, language, and tags against the inheritedBy conditions.
Check for explicit exclusion:
# moon.{json,jsonc,hcl,pkl,toml,yaml,yml} (project level)
workspace:
inheritedTasks:
exclude: ['lint'] # This project opted out
Check for rename:
workspace:
inheritedTasks:
rename:
buildPackage: 'build' # Task exists but under a different name
When a project overrides an inherited task, moon merges the configs using strategies. The defaults are:
| Field | Default merge strategy |
|---|---|
args | append |
checks <sup>v2.4+</sup> | append |
deps | append |
env | append (object merge) |
inputs | append |
outputs | append |
tags <sup>v2.3+</sup> | append |
toolchains | append |
The corresponding merge-strategy options are mergeArgs, mergeChecks <sup>v2.4+</sup>,
mergeDeps, mergeEnv, mergeInputs, mergeOutputs, mergeTags <sup>v2.3+</sup>, and
mergeToolchains — plus an umbrella merge option that sets all eight at once (the specific
options override it).
# Global: args = ['--check']
# Project: args = ['--fix']
# Result with append: ['--check', '--fix']
# Result with replace: ['--fix']
# Result with prepend: ['--fix', '--check']
If the merged result isn't what you expect, explicitly set the merge strategy:
tasks:
lint:
args: ['--fix']
options:
mergeArgs: 'replace' # Don't append to inherited args
Beyond tasks, .moon/tasks/**/* files can define a top-level env that is inherited by every
matching project and merged into the project's own env — with project-level variables winning on
conflicting keys by default.
# .moon/tasks/node.yml — every matching project inherits this
env:
NODE_ENV: 'production'
The project controls how this merge happens (for both env and fileGroups) via
workspace.mergeStrategies in its moon.* config, using the same strategies as task merging:
# moon.yml
workspace:
mergeStrategies:
env: 'replace' # project env fully replaces inherited env
fileGroups: 'preserve' # first (most global) definition of a group wins
Debugging implications:
env. Inspect the resolved values with moon task <target> --json, then search
.moon/tasks/**/* for the variable.mergeStrategies.env applies to the entire map: preserve keeps the first-defined map
(typically the most global) and ignores the rest, while replace keeps only the last. Both can
make an entire block of variables silently vanish.mergeStrategies.fileGroups applies per group name — a project redefining a group with
replace (or an empty list) drops the inherited inputs, which changes any task inputs that
reference the group via tokens (@files(...), @globs(...)), and therefore the task's hash.# See which config files contributed to the task
cat .moon/cache/states/<project>/snapshot.json
The snapshot's inherited.layers maps each task ID to the ordered list of global config files that
contributed to it, and inherited.configs maps each config file path to its parsed contents.
moon has two built-in presets that set multiple options at once:
server presettasks:
dev:
command: 'vite dev'
preset: 'server'
This sets:
cache -> offoutputStyle -> streampersistent -> onpriority -> 'low'runInCI -> offutility presettasks:
setup:
command: 'setup-script'
preset: 'utility'
This sets:
cache -> offinteractive -> onoutputStyle -> streampersistent -> offrunInCI -> 'skip'Tasks named dev, start, or serve are automatically marked with the server preset. This
means they're persistent, non-cacheable, and won't run in CI — even if you didn't explicitly set a
preset.
This is the most surprising automatic behavior in moon. If your task is named dev and you're
wondering why it doesn't cache or run in CI, this is why.
moon task <project>:<task> --json
Check the preset, options.persistent, options.cache, and options.runInCI fields.
You can override individual options even when a preset is applied:
tasks:
dev:
command: 'vite dev'
preset: 'server'
options:
runInCI: 'always' # Override the preset's runInCI: false
A persistent task (options.persistent: true or preset: 'server') is one that runs continuously —
a dev server, a file watcher, a background process. moon handles persistent tasks specially: they
run last and in parallel, after all non-persistent dependencies complete.
If a non-persistent task lists a persistent task in deps, moon produces a hard error. moon
validates dep chains and rejects this configuration before execution starts.
# ERROR: integration-test depends on dev-server, which is persistent
tasks:
dev-server:
command: 'vite dev'
preset: 'server'
integration-test:
command: 'cypress run'
deps:
- '~:dev-server' # error
# Visualize the dependency graph
moon action-graph <project>:<task>
# Look for a persistent task node with edges pointing to it from other tasks
Option 1: Remove the dependency. Run the server and tests separately:
# In one terminal
moon run app:dev-server
# In another terminal
moon run app:integration-test
Option 2: Use a script that manages both. Create a script that starts the server, waits for it to be ready, runs tests, then kills the server:
tasks:
integration-test:
script: 'start-server-and-test "vite dev" http://localhost:3000 "cypress run"'
Option 3: Restructure so persistent tasks are leaf nodes. Persistent tasks should not have dependents. They should be the last thing that runs.
affectedFiles misconfigurationThe affectedFiles option passes affected file paths to the task's command as arguments (and/or as
the MOON_AFFECTED_FILES env var). This only works when --affected is passed to moon run or
moon exec.
tasks:
lint:
command: 'eslint'
args: ['.'] # Already passing '.' as an argument
options:
affectedFiles: true # Also tries to pass file paths as args
Now eslint receives both . and the affected file paths, which may cause it to lint
everything (.) regardless.
The affectedFiles setting supports an object form with additional options:
tasks:
lint:
command: 'eslint'
options:
affectedFiles:
pass: 'args' # 'args', 'env', or true (both)
filter:
- '**/*.ts'
- '**/*.tsx'
passInputsWhenNoMatch and passDotWhenNoResultsControls what happens when there are no affected files. These options are nested inside the
affectedFiles object:
tasks:
lint:
command: 'eslint'
options:
affectedFiles:
pass: 'args'
passInputsWhenNoMatch: true # Pass task inputs instead of '.'
passDotWhenNoResults: true # Pass '.' when no results at all
ignoreProjectBoundary: false # Ignore project boundary for file matching
By default, when no files are affected, . (current directory) is passed as the argument. Set
passInputsWhenNoMatch: true to pass the task's inputs list instead.
Note: The v1 option
affectedPassInputswas removed in v2. UseaffectedFiles.passInputsWhenNoMatchinstead.
affectedFiles does nothing unless --affected is passed on the command line. If you set it in
config but always run moon run <target> without --affected, the setting has no effect.
extends not resolvingTasks can extend other tasks using the extends field:
tasks:
build:
command: 'vite build'
inputs:
- 'src/**/*'
build-prod:
extends: 'build'
env:
NODE_ENV: 'production'
Base task doesn't exist: The task being extended must exist in the same project (either defined locally or inherited). If it's not found, it will error.
Circular extension: Task A extends B, B extends A. moon does not detect this — the chain is
resolved recursively with no cycle guard, so a circular (or self-referencing) extends hangs or
crashes graph building rather than producing a diagnostic. If moon dies while building tasks,
inspect extends chains by hand.
moon task <project>:<extended-task> --json
The resolved config should show the merged result of the base task plus the overrides from the extending task.
moon treats tasks with command noop, nop, or no-op as intentional no-ops. These tasks execute
successfully but do nothing. They're sometimes used as aggregation points — a task that only exists
to declare deps on other tasks.
tasks:
all-checks:
command: 'noop'
deps:
- '~:lint'
- '~:test'
- '~:typecheck'
If a user reports "my task runs but produces no output," check if the command is one of the no-op values.
moon task <project>:<task> --json
# Look at the "command" field
runInCI variantsThe runInCI option controls whether a task runs in CI environments. It accepts more values than
most people realize:
| Value | Local | CI (affected) | CI (not affected) |
|---|---|---|---|
true / 'affected' (default) | Runs | Runs | Skipped |
false | Runs | Skipped | Skipped |
'always' | Runs | Runs | Runs |
'only' | Skipped | Runs | Skipped |
'skip' | Runs | Skipped | Skipped |
'only' — the task is CI-only. Running moon run app:deploy locally does nothing. This trips
people up when they try to test a CI task locally.
'skip' — the task is skipped in CI but task relationships (deps) remain valid. Unlike false,
downstream tasks that depend on a 'skip' task won't break in CI.
'always' — the task always runs in CI regardless of affected status. Useful for tasks like
deploy that should run on every merge to main, even if no inputs changed.
moon task <project>:<task> --json | grep -i runci
# Also check state.setRunInCi — true means runInCI was set explicitly OR by a
# preset; the key is omitted entirely when it defaulted from the task type
# (build/test → run in CI)
allowFailure hiding errorsWhen options.allowFailure is true, a failing command no longer fails the pipeline: the failure
is still recorded and displayed for the task itself, but downstream work continues and moon exits
successfully, so the failure is easy to overlook.
tasks:
advisory-lint:
command: 'eslint src/'
options:
allowFailure: true # Lint failures are warnings, not blockers
This is intentional for advisory tasks. But if it's inherited from a global task and the user doesn't realize it's set, real errors go unnoticed.
Gotcha with deps: A task cannot depend on a task with allowFailure: true — the task
builder rejects the configuration with a hard AllowFailureDepRequirement error, because a failing
dependency would let the dependent task run with incorrect results. If a task suddenly errors at
graph-build time after someone added allowFailure to an upstream task, this is why.
moon task <project>:<task> --json
# Check options.allowFailure
mutex contentionThe mutex option ensures only one task with that mutex name runs at a time, even across different
projects. This prevents concurrent access to shared resources (like a database or a shared port).
tasks:
integration-test:
command: 'vitest --run'
options:
mutex: 'database' # Only one test suite hits the DB at a time
Unexpected serialization: If multiple tasks share a mutex, they run one at a time instead of in parallel. This can make the pipeline much slower than expected.
Combined with deps: If task A (mutex: "x") depends on task B (mutex: "x"), and both need to run, B acquires the mutex, completes, then A acquires it. This is fine. But if you have a cycle in deps + shared mutex, the pipeline can deadlock.
moon task <project>:<task> --json
# Check options.mutex — see if multiple tasks share the same value
timeout and retryCountThe timeout option (in seconds) kills the task if it exceeds the time limit.
tasks:
e2e:
command: 'playwright test'
options:
timeout: 300 # 5 minutes
If a task is timing out, check whether the timeout is too aggressive for the workload. On CI with
slower machines, you may need a longer timeout. The same timeout also applies to each checks
script individually — with surprising outcomes: a timed-out requirement counts as passing, a
timed-out condition counts as not-passed (the task runs), and a timed-out fingerprint
contributes nothing to the hash. None of them error.
The retryCount option re-runs a failed task up to N times. This is useful for flaky tests but can
mask real failures.
tasks:
flaky-test:
command: 'vitest --run'
options:
retryCount: 2 # Retry up to 2 times on failure
If a task "sometimes passes," check if retryCount is set — the task might be flaky but passing on
retries. Note retryCount: 2 means up to 3 total attempts.
os platform filteringThe os option restricts a task to specific operating systems. On a non-matching platform the task
isn't removed — its command is rewritten to noop at build time (args, script, and outputs
cleared), so it runs as a passing no-op. moon task <target> --json showing command: noop when
you configured something else is the giveaway.
tasks:
build-macos:
command: 'xcodebuild'
options:
os: 'macos' # Only runs on macOS
Supported values: linux, macos, windows.
If a task "doesn't run" on one platform but works on another, check the os option. This is
especially common in cross-platform CI pipelines.
outputStyle and missing outputThe outputStyle option controls how task output is displayed in the terminal:
| Value | Behavior |
|---|---|
'buffer' | Capture output and display after task completes |
'buffer-only-failure' | Only show output if the task fails |
'hash' | Display the generated hash |
'none' | Suppress all output |
'stream' | Stream output in real-time |
If the user reports "my task runs but I see no output," check outputStyle. A value of 'none' or
'buffer-only-failure' (with a passing task) suppresses output entirely.
The server and utility presets both set outputStyle: 'stream'.
cacheLifetimeControls how long cached outputs are considered valid. After this duration, the cached entry becomes stale and will no longer be hydrated — even if the hash matches, the task will re-execute.
tasks:
build:
command: 'vite build'
options:
cacheLifetime: '7 days'
At runtime, moon checks staleness in two places:
.tar.gz archive in .moon/cache/outputs/ is older than the lifetime,
hydration is rejected and the task re-executes.Additionally, moon clean --lifetime uses this value to remove stale archives from disk.
cacheKeyAn additional arbitrary string added to the hash computation. Changing this value invalidates all existing caches for the task, even if nothing else changed.
tasks:
build:
command: 'vite build'
options:
cacheKey: 'v2' # Bump this to force cache invalidation
Useful for: breaking the cache after a toolchain upgrade, config change outside moon's tracking, or any "just bust the cache" scenario.
#tag targetsAvailable in v2.3+.
Tasks can declare tags for categorization. Targets can then reference tasks by tag using #:
tasks:
lint:
command: 'eslint'
tags: ['quality', 'ci']
# Run every task with the `quality` tag, in every project
moon run ':#quality'
# Run quality-tagged tasks in a specific project
moon run 'app:#quality'
# Run quality-tagged tasks in projects tagged `frontend`
moon run '#frontend:#quality'
The
#is a shell comment marker, so#tagtargets must be quoted (or escaped with\#) on the command line. A bare#tagwith no colon is rejected — the task scope must always be present.
Dependency-relative scopes (^:#tag, ~:#tag) parse, but error with "no deps in run context" / "no
self in run context" when run from the command line — they're only valid inside a task's deps
list.
The #tag target matches nothing
moon task <project>:<task> --json
# Inspect the `tags` field
If tags is missing or doesn't contain the tag you used in the target, the task won't match.
Tags lost during inheritance
By default tags merges with append, so global tasks contribute their tags and projects can add
more. If options.mergeTags: 'replace' is set, the project's tags replace the global ones — which
can silently drop tags you expected to inherit. Check options.mergeTags in
moon task <target> --json.
Tag vs project tag confusion <sup>MQL</sup>
MQL has two tag fields: projectTag (with tag as a legacy alias) matches project tags, while
taskTag <sup>v2.3+</sup> matches task tags. On task queries, taskTag matches the task's own
tags, and projectTag/tag match the task's parent project's tags — so
moon query tasks --query "tag=quality" returns tasks whose project is tagged quality, not
tasks tagged quality.
moon query tasks --tags quality # task tags (regex flag)
moon query tasks --query "taskTag=quality" # task tags (MQL)
moon query tasks --query "tag=quality" # parent project tag (alias of projectTag)
moon query projects --query "taskTag=quality" # projects containing a task tagged quality
In v2.3–v2.4, no tag field worked on task queries — the task matcher silently dropped them all, matching nothing (this also broke task tag glob targets like
:#tag-*). Fixed in v2.5. On older versions, use the--tagsflag.
cacheStrategyAvailable in v2.3+.
Each entry in deps can declare a cacheStrategy that controls whether the dep contributes to the
current task's hash. The full breakdown is in cache-issues.md —
Dependency cache strategies. The summary:
| Strategy | This task's cache invalidates when… |
|---|---|
'hash' | …the dep's hash changes (inputs, command, args, env). |
'ignored' | …never. Dep is a sequencing edge only. |
'outputs' | …the dep's output files change. |
When cacheStrategy is omitted, the default is now chosen based on whether the dep declares
outputs:
'hash' (same as before).'ignored' (was 'hash' before).If you upgraded from v2.2 and downstream tasks stop invalidating when an upstream lint / test /
typecheck (no outputs) changes, this is why. Set cacheStrategy: 'hash' explicitly to restore the
old behavior:
tasks:
build:
deps:
- target: '~:lint'
cacheStrategy: 'hash'
moon task <project>:<task> --json
# Each `deps` entry shows its resolved cacheStrategy
Available in v2.4+.
A task's checks field is a list of shell scripts that run before the task. Each check has a
type that determines what happens based on the script's exit code. A check defined as a plain string
is a requirement by default.
tasks:
deploy:
command: './deploy.sh'
checks:
# requirement (string shorthand): must pass or the task fails
- 'command -v aws'
# condition: if all conditions pass, the task is SKIPPED
- check: 'condition'
script: './scripts/already-deployed.sh'
# fingerprint: script output is folded into the task hash
- check: 'fingerprint'
script: 'aws --version'
hash: 'stdout' # true (all output) | false (run, hash nothing) | 'exit-code' | 'stdout' | 'stderr'
| Type | Script passes (exit 0) | Script fails (non-zero) |
|---|---|---|
requirement | Task continues | Task fails — RequirementCheckFailed, does not run |
condition | Counts toward skipping (see below) | Task runs as normal |
fingerprint | Output mixed into hash | Task fails — FingerprintCheckFailed during hashing |
A script that fails to spawn is also fatal for requirements and fingerprints, but the process
error propagates as-is rather than as the variants above. A script that hits the task's
options.timeout never errors: a timed-out requirement counts as passing, a timed-out
condition as not-passed, and a timed-out fingerprint contributes nothing to the hash.
Conditions skip, they don't gate. The task is skipped only when all condition checks
pass. If any condition fails, the task runs as normal. This is the inverse of a requirement, and a
common source of "my task never runs" confusion.
When checks actually run:
fingerprint checks run during hash generation, which happens on every run — even when the
result is a cache hit, and even when the task's cache is disabled.requirement and condition checks run just before task execution — so they do not run
on a cache hit. A missing tool won't trip a requirement check while the task hydrates from
cache; it only surfaces on the next cache miss.options.timeout also applies to each check script individually."My task fails with a requirement/fingerprint check error"
Task app:deploy is unable to run as the requirement check `command -v aws` failed.
The named script exited non-zero (or, for fingerprints, crashed). The diagnostic codes are
task_runner::requirement_check_failed and task_runner::hash_check_failed. Run the script
manually to see why it fails.
"My task is skipped even though inputs changed and it's not a cache hit"
All condition checks passed, so moon skipped the task on purpose. The target ends in a
SkippedConditional state (plain Skipped is a different state, used when a dependency failed).
Confirm with debug logs:
moon run <project>:<task> --log debug --force 2>&1 | grep -i "condition\|check"
# "Skipping task as all conditional checks have passed" → condition skip
# "Will continue to run the task as not all conditional checks have passed" → ran normally
"My task re-runs every time after adding a check"
A fingerprint check hashes its script output. If that output is volatile (a timestamp, PID, or
changing version), the hash changes on every run. Narrow the hashed portion with the hash field
(e.g. hash: 'exit-code'), or remove the fingerprint. See
cache-issues.md.
Checks disappeared or duplicated after inheritance
Checks merge with append by default. Set options.mergeChecks (replace, prepend, preserve)
to control how inherited checks combine with project-level ones.
moon task <project>:<task> --json
# Inspect the `checks` array — each entry shows its type and script
taskOptionsAvailable in v2.4+.
A project's moon.* config can now define a top-level taskOptions block that applies default
task options to every task in that project,
which each task can still override.
# moon.yml
taskOptions:
cache: false
retryCount: 2
tasks:
build:
command: 'vite build'
# Inherits cache: false and retryCount: 2
lint:
command: 'eslint .'
options:
cache: true # Overrides the project default
This is a new inheritance layer. When a task option isn't what you expect, and it isn't set on
the task itself or in a global .moon/tasks/* file, check the project's taskOptions. The
inheritance order is:
.moon/tasks/* taskOptions (workspace-wide defaults, since v1.20).moon.* taskOptions <sup>v2.4+</sup> (project-wide defaults).options (most specific, wins).# See the fully resolved options after all layers merge
moon task <project>:<task> --json
# See which config files/layers contributed
cat .moon/cache/states/<project>/snapshot.json
If a task unexpectedly stopped caching, retries, or picked up a mutex/timeout, a project-level
taskOptions is a likely culprit that's easy to overlook because it lives outside the tasks: map.
moon's task builder validates configuration at build time and produces specific errors. If you see one of these, here's what it means:
PersistentDepRequirement — a non-persistent task depends on a persistent task. This is always
a configuration error because the persistent task never finishes. Fix: remove the dependency or
restructure the task graph.
AllowFailureDepRequirement — a task depends on a task with allowFailure: true. This is a
hard error: moon rejects the configuration, because a failing dependency would still let the
dependent task run, producing incorrect results.
RunInCiDepRequirement — a task that runs in CI depends on a task that doesn't run in CI
(runInCI: false). The dependency won't execute in CI, so the dependent task may fail or produce
incorrect results.
InvalidCommandSyntax / UnsupportedCommandSyntax — the command field contains shell syntax
(pipes, redirects, &&) that should use script instead.
UnknownExtendsSource — the extends field references a task that doesn't exist in the current
project or global scope.
UnknownDepTarget — a deps entry references a target that doesn't exist. Check for typos in
the project or task name.
"a shell script is required for a task check" <sup>v2.4+</sup> — a checks entry has an empty
or whitespace-only script. Every check must define a non-empty shell script.