Skip to content

feat(devenv-tasks): support dependency pruning with execIf and execIfModified#2673

Open
allonhadaya-maven wants to merge 2 commits into
cachix:mainfrom
allonhadaya-maven:allon-skip-needless-tasks
Open

feat(devenv-tasks): support dependency pruning with execIf and execIfModified#2673
allonhadaya-maven wants to merge 2 commits into
cachix:mainfrom
allonhadaya-maven:allon-skip-needless-tasks

Conversation

@allonhadaya-maven
Copy link
Copy Markdown

This change extracts task status and exec-if-modified checks into a first pass that runs in reverse-topological order, from dependents to dependencies. Tasks whose dependents are all skipped are pruned from the DAG. Tasks whose dependents are all skipped short-circuiting evaluation of their checks.

Breaking change: Status checks no longer receive DEVENV_TASKS_OUTPUTS. This allows them to run before their task's dependencies. They still receive DEVENV_TASK_INPUT, shell env, and per-task env vars.

Motivating Example

{
  tasks = {
    "demo:expensive-task".exec = "sleep 10; echo 'Expensive Done'";
    "demo:conditional-task" = {
      exec = "echo 'Done'";
      status = "true";  # skip unconditionally for demo purposes
      after = ["demo:expensive-task"];
      before = ["devenv:enterShell"];
    };
  };
}

Before

$ devenv shell
✓ Configuring shell                                              32ms
  └ ✓ Evaluating shell cached                                    25ms
✓ Loading tasks                                                   2ms
  └ ✓ Evaluating devenv.config.task.config cached                 1ms
✓ Running tasks                                                 10.1s
  └ ✓ devenv:enterShell                                          50ms
    └ ✓ devenv:files:cleanup                                     24ms
    └ ✓ demo:conditional-task skipped                            34ms
      └ ✓ demo:expensive-task 1 lines → Expensive Done          10.0s
  └ ✓ devenv:enterTest skipped                                    0ms

After

devenv shell
✓ Configuring shell                                              42ms
  └ ✓ Evaluating shell cached                                    33ms
✓ Loading tasks                                                   3ms
  └ ✓ Evaluating devenv.config.task.config cached                 0ms
✓ Running tasks                                                  74ms
  └ ✓ devenv:enterShell                                          21ms
    └ ✓ devenv:files:cleanup                                     20ms
    └ ✓ demo:conditional-task skipped                             0ms
      └ ✓ demo:expensive-task skipped                             0ms
  └ ✓ devenv:enterTest skipped                                    0ms

Comment thread devenv-tasks/src/task_state.rs Outdated
Comment on lines +592 to +594
let env = self
.prepare_env(outputs, shell_env)
.wrap_err("Failed to prepare task environment")?;
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The initialization of env has been lifted out of the status_after block, and is being reused for task execution below. Is it sound to reuse the same instance of env instead of creating separate instances like we were previously?

@domenkozar
Copy link
Copy Markdown
Member

We should keep current behavior and add preflight = true to top-level task to enable this.

@allonhadaya-maven
Copy link
Copy Markdown
Author

allonhadaya-maven commented Mar 29, 2026

We should keep current behavior and add preflight = true to top-level task to enable this.

@domenkozar thanks for taking a look at the proposal, and providing the above guidance!

Please correct me if I'm wrong, but what I'm understanding is that maintaining API stability is your primary concern with the proposal? That sounds reasonable to me.

... add preflight = true to top-level task ...

I gave this a good deal of thought, and wanted to run some ideas by you:

So one little detail I wasn't clear on in your feedback was whether "top-level" task refers to the dependent task or the dependency task. To make this next part concrete, I'll reference the "Motivating Example" and discuss both possible interpretations:

Option A: "top-level task" means expensive-task?

In this option, we allow dependencies to declare preflight = true:

{
  tasks = {
    "demo:expensive-task" = {
      exec = "sleep 10; echo 'Expensive Done'";
      preflight = true;
    };
    "demo:conditional-task" = ...;
  };
}

Spooky action at a distance

One issue I see here is that the preflight attribute set on expensive-task has a 'spooky action at a distinace' on the way conditional-task's status check is evaluated. Dependency tasks may be defined in other files (even imported via inputs), so the addition of the preflight attribute may not even be visible to the user maintaining the dependent task (i.e. conditional-task). Similarly, adding after dependencies can spontaneously cause status checks to be evaluated differently.

Semantic ambiguity

Another issue I see here is that a task may depend both on tasks with preflight = true and preflight = false, leading to ambiguous semantics. Does the status check get coerced to one semantic or the other? Does the status check get executed twice (once preflight, once before exec)? We can answer this question, but I would argue there are less ambiguous options below...

Option B: "top-level task" means conditional-task?

In this option, we allow dependents to declare preflight = true:

{
  tasks = {
    "demo:expensive-task".exec = ...;
    "demo:conditional-task" = {
      preflight = true;
      exec = "echo 'Done'";
      status = "true";  # skip unconditionally for demo purposes
      after = ["demo:expensive-task"];
      before = ["devenv:enterShell"];
    };
  };
}

Code Complexity

In this option, the implementation of status checks gets spread across two distinct code paths: one for the preflight pass, and the other where it lives today inside TaskState. Maintaining one concept, 'status checks', that is technically two different things risks being a long-term maintenance burden.

API Rigidity

By marrying these two distinct status behaviors via the preflight boolean option, it becomes very difficult to independently change either of these behaviors in future devenv versions.

Option C: Opt-in with execIf

Another option we could consider is to introduce a new task option that dependents can use to define preflight status checks, called execIf:

{
  tasks = {
    "demo:expensive-task".exec = ...;
    "demo:conditional-task" = {
      exec = "echo 'Done'";
      execIf = "false";  # skip unconditionally for demo purposes
      after = ["demo:expensive-task"];
      before = ["devenv:enterShell"];
    };
  };
}

Consistent with execIfModified

The option name is immediately sensible to users familiar with execIfModified. As an added benefit, both of those checks would be evaluated in the preflight pass, further cementing the underlying design change being proposed here.

API Flexibility

Going forward, devenv can start emphasizing this new option as a favorable alternative to status without any breaking changes. In the long run, either option can be deprecated if it does not effectively serve user needs.

API Expressiveness

While this is certainly an edge-case, tasks are free to define both status and execIf. There is no ambiguity about when each check runs - or what its effect on overall task execution is.

Inverted exit semantics from status

Note in the example that skipping a task is now achieved by returning a non-0 status code - the opposite semantics of status. This makes sense for the option name, execIf.

Fail-close: one of the qualities of the status exit semantics are that unexpected failures allow tasks to execute - i.e. the status check is fail-open. By inverting the exit semantics the execIf check becomes fail-close, meaning tasks are not executed during unexpected failure. This is less of a clear drawback to me, and more of a subtle trade-off. I see benefits to both designs. Let me know if you have a strong feeling on this point.

Migration friction: migrating from status to execIf requires inverting the return codes of the command. This adds a little friction and room for error in the migration path for existing users, even if the semantics are clear to new users.

Option D: Opt-in with execUnless

Building off of the last point, the last mechanism we should consider is a new task option that dependents can use to define preflight status checks called execUnless:

{
  tasks = {
    "demo:expensive-task".exec = ...;
    "demo:conditional-task" = {
      exec = "echo 'Done'";
      execUnless = "true";  # skip unconditionally for demo purposes
      after = ["demo:expensive-task"];
      before = ["devenv:enterShell"];
    };
  };
}

API Flexibility / Expressiveness

Same benefits as execIf.

Consistent exit semantics with status

Migrating existing tasks from status to execUnless becomes a simple find/replace operation - rather than also requiring inverting the exit status of the command as is the case with execIf.

Inconsistent semantics from execIfModified

This path loses the benefit of execIf described above in the section titled "Consistent with execIfModified".

Recommendation

I think option C makes the most sense for devenv in the long run. A close second would be option D, if eliminating migration friction from status is considered important. Third best, I think option B is acceptable. As far as I understand it, option A should not be pursued. All options maintain API stability.

Looking forward to hearing your thoughts! I should be able to complete the implementation when I get back to work on Monday along whichever lines you think are best.

@domenkozar
Copy link
Copy Markdown
Member

domenkozar commented Apr 8, 2026

Thanks for the thorough analysis of the options!

I think there is a simpler path that avoids the breaking change entirely: keep status exactly as it is today and add a new option for the preflight check.

The core feature here is skip propagation, which is valuable. But it does not require changing status semantics. Instead:

  1. Keep status unchanged: runs after deps, receives DEVENV_TASKS_OUTPUTS, same behavior as today.
  2. Add a new option (I am fine with preflight, open to naming suggestions) that:
    • Runs before dependencies
    • Does NOT receive DEVENV_TASKS_OUTPUTS
    • Enables skip propagation down the dependency tree
    • Can coexist with status (condition is checked first; if it skips, status is not run)
  3. Drop statusAfter entirely since status keeps its current semantics and there is nothing to migrate.

This way the feature is purely additive, no breaking change, no migration friction, and users opt in to the pruning benefit by using the new option for checks that do not need dependency outputs.

What do you think?

@allonhadaya-maven allonhadaya-maven force-pushed the allon-skip-needless-tasks branch from 322f643 to 0b6e44d Compare April 9, 2026 18:49
…Modified

This change defines a new task option `execIf` and extracts `execIfModified`
into a first pass that runs in reverse-topological order, from dependents to
dependencies.  Tasks whose dependents are all skipped are pruned;
short-circuiting evaluation of their checks.
@allonhadaya-maven allonhadaya-maven force-pushed the allon-skip-needless-tasks branch from 1d42d66 to aae3629 Compare April 10, 2026 14:27
@allonhadaya-maven allonhadaya-maven changed the title feat(devenv-tasks): skip tasks for whom all dependents are skipped feat(devenv-tasks): support dependency pruning with execIf and execIfModified Apr 10, 2026
@allonhadaya-maven
Copy link
Copy Markdown
Author

@domenkozar thanks for your followup guidance! PR is ready for review along the lines you suggested 🙏

@domenkozar
Copy link
Copy Markdown
Member

@domenkozar thanks for your followup guidance! PR is ready for review along the lines you suggested 🙏

I'll take a look at this soon.

@domenkozar domenkozar added this to the 2.3 milestone May 23, 2026
@allonhadaya-maven
Copy link
Copy Markdown
Author

Thanks @domenkozar! This branch is based on pretty old main so I'll aim to rebase it / resolve any conflicts today or tomorrow ahead of your review. I'll give you a mention when it's ready.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants