Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
290 changes: 274 additions & 16 deletions crucible/src/plan/starlark.rs

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions crucible/src/plan/starlark/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,14 @@ pub enum CompileError {
WhenNotAnAnswer,
#[error("argument \"answers\" has no meaning without \"when\"")]
AnswersWithoutWhen,
#[error("argument \"answers\" lists no labels")]
EmptyAnswers,
#[error("argument \"otherwise\" has no meaning without \"when\"")]
OtherwiseWithoutWhen,
#[error("a task takes \"answers\" or \"otherwise\", not both")]
OtherwiseWithAnswers,
#[error("unreachable otherwise on {task:?}: every answer of {asked} is listed or dropped")]
UnreachableOtherwise { task: String, asked: String },
#[error(
"{asked} cannot answer {label:?}{} (it answers: {declared})",
diag::hint(.suggestion.as_deref())
Expand Down
7 changes: 6 additions & 1 deletion crucible/src/plan/starlark/idents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,12 @@ pub(crate) fn narrow(error: CompileError, idents: &Idents) -> CompileError {
(None, "session")
}
CompileError::WhenNotAnAnswer => (None, "when"),
CompileError::UnknownAnswer { .. } | CompileError::AnswersWithoutWhen => (None, "answers"),
CompileError::UnknownAnswer { .. }
| CompileError::AnswersWithoutWhen
| CompileError::EmptyAnswers => (None, "answers"),
CompileError::OtherwiseWithoutWhen
| CompileError::OtherwiseWithAnswers
| CompileError::UnreachableOtherwise { .. } => (None, "otherwise"),
CompileError::RouteDecider { .. } => (Some("route"), "name"),
_ => {
return CompileError::At { at, inner };
Expand Down
14 changes: 14 additions & 0 deletions crucible/src/plan/starlark/reference.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ fn task_knobs() -> Vec<Kwarg> {
),
when_kwarg(),
answers_kwarg(),
otherwise_kwarg(),
]
}

Expand All @@ -150,6 +151,18 @@ fn when_kwarg() -> Kwarg {
)
}

fn otherwise_kwarg() -> Kwarg {
Kwarg::new(
"otherwise",
"bool",
format!(
"In place of `answers`: every answer no other task lists and the question does not \
`drop`, `\"{UNCERTAIN}\"` included. Expanded at compile time. An unreachable \
`otherwise` is an error."
),
)
}

fn answers_kwarg() -> Kwarg {
Kwarg::new(
"answers",
Expand Down Expand Up @@ -473,6 +486,7 @@ pub fn functions() -> Vec<Function> {
Kwarg::new("stage", "\"iteration\" | \"epilogue\"", "As on any task."),
when_kwarg(),
answers_kwarg(),
otherwise_kwarg(),
],
},
Function {
Expand Down
5 changes: 5 additions & 0 deletions docs/dsl-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ An agent turn driven by a prompt.
| `stage` | `"iteration" \| "epilogue"` | `epilogue` runs once after the loop concludes, and only if the run kept a candidate. |
| `when` | `route.question` | Run only on a listed answer to one question of a `route()` this task depends on. Otherwise the task settles `not_taken`: no dispatch, no spend, no effect on validity, and every `all`-join dependent is not taken with it. Rejoin branches with `join = "passed"` or `join = "settled"`. Playbook and custom workflows only. |
| `answers` | `str \| list[str]` | The answers `when` accepts: labels the question declares, or `"uncertain"`. Defaults to `"yes"` for a noul and is required for a choice. |
| `otherwise` | `bool` | In place of `answers`: every answer no other task lists and the question does not `drop`, `"uncertain"` included. Expanded at compile time. An unreachable `otherwise` is an error. |

### `skill()`

Expand Down Expand Up @@ -66,6 +67,7 @@ An agent turn whose prompt is a skill's instructions plus its arguments.
| `stage` | `"iteration" \| "epilogue"` | `epilogue` runs once after the loop concludes, and only if the run kept a candidate. |
| `when` | `route.question` | Run only on a listed answer to one question of a `route()` this task depends on. Otherwise the task settles `not_taken`: no dispatch, no spend, no effect on validity, and every `all`-join dependent is not taken with it. Rejoin branches with `join = "passed"` or `join = "settled"`. Playbook and custom workflows only. |
| `answers` | `str \| list[str]` | The answers `when` accepts: labels the question declares, or `"uncertain"`. Defaults to `"yes"` for a noul and is required for a choice. |
| `otherwise` | `bool` | In place of `answers`: every answer no other task lists and the question does not `drop`, `"uncertain"` included. Expanded at compile time. An unreachable `otherwise` is an error. |

### `command()`

Expand All @@ -89,6 +91,7 @@ A deterministic shell task in the candidate workspace.
| `stage` | `"iteration" \| "epilogue"` | `epilogue` runs once after the loop concludes, and only if the run kept a candidate. |
| `when` | `route.question` | Run only on a listed answer to one question of a `route()` this task depends on. Otherwise the task settles `not_taken`: no dispatch, no spend, no effect on validity, and every `all`-join dependent is not taken with it. Rejoin branches with `join = "passed"` or `join = "settled"`. Playbook and custom workflows only. |
| `answers` | `str \| list[str]` | The answers `when` accepts: labels the question declares, or `"uncertain"`. Defaults to `"yes"` for a noul and is required for a choice. |
| `otherwise` | `bool` | In place of `answers`: every answer no other task lists and the question does not `drop`, `"uncertain"` included. Expanded at compile time. An unreachable `otherwise` is an error. |

### `evaluate()`

Expand All @@ -114,6 +117,7 @@ A measurement command. Its last non-empty stdout line is a JSON object; `pass =
| `stage` | `"iteration" \| "epilogue"` | `epilogue` runs once after the loop concludes, and only if the run kept a candidate. |
| `when` | `route.question` | Run only on a listed answer to one question of a `route()` this task depends on. Otherwise the task settles `not_taken`: no dispatch, no spend, no effect on validity, and every `all`-join dependent is not taken with it. Rejoin branches with `join = "passed"` or `join = "settled"`. Playbook and custom workflows only. |
| `answers` | `str \| list[str]` | The answers `when` accepts: labels the question declares, or `"uncertain"`. Defaults to `"yes"` for a noul and is required for a choice. |
| `otherwise` | `bool` | In place of `answers`: every answer no other task lists and the question does not `drop`, `"uncertain"` included. Expanded at compile time. An unreachable `otherwise` is an error. |

### `report()`

Expand Down Expand Up @@ -254,6 +258,7 @@ Engine-owned decision: answers typed questions about its dependencies' outputs a
| `stage` | `"iteration" \| "epilogue"` | As on any task. |
| `when` | `route.question` | Run only on a listed answer to one question of a `route()` this task depends on. Otherwise the task settles `not_taken`: no dispatch, no spend, no effect on validity, and every `all`-join dependent is not taken with it. Rejoin branches with `join = "passed"` or `join = "settled"`. Playbook and custom workflows only. |
| `answers` | `str \| list[str]` | The answers `when` accepts: labels the question declares, or `"uncertain"`. Defaults to `"yes"` for a noul and is required for a choice. |
| `otherwise` | `bool` | In place of `answers`: every answer no other task lists and the question does not `drop`, `"uncertain"` included. Expanded at compile time. An unreachable `otherwise` is an error. |

### `noul()`

Expand Down
5 changes: 4 additions & 1 deletion docs/rfc/RFC-0001.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!-- GENERATED: do not edit. Source: RFC-0001 -->
<!-- SIGNATURE: sha256:abd9863b8a7ebbfc0cc149f8f9d09534fc3b7878bf1b4843e69b0cec89d9a5c7 -->
<!-- SIGNATURE: sha256:00243abaccb40eb17bd2e29baa525f7d475208bb95cf7f88ce54d293742767d5 -->

# RFC-0001: Crucible implementation contract

Expand Down Expand Up @@ -296,6 +296,8 @@ The engine must reach the decision model through the broker, at an endpoint and

Any task may declare when, a predicate over one question of one route task it depends on, satisfied when that question's resolved label is among the labels the predicate lists. Validation must reject a when that names a task that is not a route, a task outside the declaring task's dependencies, an unknown question, an empty label list, or a label that is neither declared by the question nor "uncertain", each with a diagnostic at the argument and a did-you-mean over the declared names. Every label of a routed question, including "uncertain", must either be listed by at least one when or be named in that question's drop list, so an answer that leads nowhere is one the author wrote down rather than one they forgot. A drop list must name only that question's labels or "uncertain". A question no when refers to is exempt.

A task may declare otherwise in place of answers. The compiler must expand it to the question's labels, "uncertain" included, that no other task's when lists and the drop list does not name, and must write that list to the generated TOML. Tasks declaring otherwise on the same question get the same list. An otherwise that expands to nothing, otherwise with answers, otherwise without when, and an empty answers list must each be a compile error at that argument.

A task that revises another under [RFC-0002:C-REVISE-LOOP](../rfc/RFC-0002.md#rfc-0002c-revise-loop) must not declare when: a reviewer runs whenever what it revises does, so the condition belongs on the revised task, and validation must reject the reviewer's. When a revised task settles not taken no round starts, and its reviewer settles not taken with it.

A task whose when is not satisfied must settle as not taken, without dispatch and without spend. A when over a route that itself settled not taken is not satisfied. A when over a route that settled any other way without passing is not satisfied either, except that a task joining "all" on that route is blocked by the failure as any dependent is. Not taken is a terminal status distinct from skipped. It must not count against validity, must not short-circuit the run, and must not be reported as a failure. A task joining "all" on a dependency that settled not taken must itself settle not taken, so an untaken branch is untaken to its leaves. A task joining "passed" or "settled" is dispatched as those joins already define, and is how branches rejoin. A required task is required when taken: if its when is satisfied it gates validity as any required task does. The workflow's result task must not be conditional and must not be able to settle not taken through "all" joins alone.
Expand Down Expand Up @@ -333,6 +335,7 @@ Admit the playbook lane
- route, noul, choice constructors and the when predicate; not-taken terminal status (C-ROUTE)
- a route decides from a model or, deterministically, from a dependency's output
- injected inference bindings, one typed document for every model the engine or its sandboxes reach (C-INFERENCE)
- otherwise, resolved by the compiler to the answers no other task lists

#### Changed

Expand Down
3 changes: 1 addition & 2 deletions examples/route/workflow.star
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,12 @@ oncall = command(name = "oncall", run = "./act.sh oncall", depends_on = [gate],

finance = command(name = "finance", run = "./act.sh finance", depends_on = [gate], when = gate.bucket, answers = "billing")

# A low-confidence read lands with a human, the same as a feature request.
backlog = command(
name = "backlog",
run = "./act.sh backlog",
depends_on = [gate],
when = gate.bucket,
answers = ["feature", "uncertain"],
otherwise = True,
)

filed = command(
Expand Down
2 changes: 2 additions & 0 deletions gov/rfc/RFC-0001/clauses/C-ROUTE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ The engine must reach the decision model through the broker, at an endpoint and

Any task may declare when, a predicate over one question of one route task it depends on, satisfied when that question's resolved label is among the labels the predicate lists. Validation must reject a when that names a task that is not a route, a task outside the declaring task's dependencies, an unknown question, an empty label list, or a label that is neither declared by the question nor "uncertain", each with a diagnostic at the argument and a did-you-mean over the declared names. Every label of a routed question, including "uncertain", must either be listed by at least one when or be named in that question's drop list, so an answer that leads nowhere is one the author wrote down rather than one they forgot. A drop list must name only that question's labels or "uncertain". A question no when refers to is exempt.

A task may declare otherwise in place of answers. The compiler must expand it to the question's labels, "uncertain" included, that no other task's when lists and the drop list does not name, and must write that list to the generated TOML. Tasks declaring otherwise on the same question get the same list. An otherwise that expands to nothing, otherwise with answers, otherwise without when, and an empty answers list must each be a compile error at that argument.

A task that revises another under [[RFC-0002:C-REVISE-LOOP]] must not declare when: a reviewer runs whenever what it revises does, so the condition belongs on the revised task, and validation must reject the reviewer's. When a revised task settles not taken no round starts, and its reviewer settles not taken with it.

A task whose when is not satisfied must settle as not taken, without dispatch and without spend. A when over a route that itself settled not taken is not satisfied. A when over a route that settled any other way without passing is not satisfied either, except that a task joining "all" on that route is blocked by the failure as any dependent is. Not taken is a terminal status distinct from skipped. It must not count against validity, must not short-circuit the run, and must not be reported as a failure. A task joining "all" on a dependency that settled not taken must itself settle not taken, so an untaken branch is untaken to its leaves. A task joining "passed" or "settled" is dispatched as those joins already define, and is how branches rejoin. A required task is required when taken: if its when is satisfied it gates validity as any required task does. The workflow's result task must not be conditional and must not be able to settle not taken through "all" joins alone.
Expand Down
1 change: 1 addition & 0 deletions gov/rfc/RFC-0001/rfc.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ added = [
"route, noul, choice constructors and the when predicate; not-taken terminal status (C-ROUTE)",
"a route decides from a model or, deterministically, from a dependency's output",
"injected inference bindings, one typed document for every model the engine or its sandboxes reach (C-INFERENCE)",
"otherwise, resolved by the compiler to the answers no other task lists",
]
changed = [
"DSL grammar admits dictionaries, conditionals, comprehensions, iteration, user-defined functions, load() and the pure standard library",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#:schema ../schema/work.schema.json

[govctl]
id = "WI-2026-09-21-002"
title = "Let a task take whatever answers no other task lists"
status = "active"
created = "2026-09-21"
started = "2026-09-21"
refs = [
"RFC-0001:C-ROUTE",
"RFC-0001:C-WORKFLOW",
]

[content]
description = "`otherwise = True` on a task's `when` expands at compile time to the question's labels that no other task lists and the question does not drop. The generated TOML carries the expanded list."

[[content.acceptance_criteria]]
text = "otherwise = True expands to the unlisted, undropped labels, uncertain included; the generated TOML has the list and no otherwise"
status = "pending"
category = "added"

[[content.acceptance_criteria]]
text = "compile errors at the argument for an unreachable otherwise, otherwise with answers, otherwise without when, and answers = []"
status = "pending"
category = "added"

[[content.acceptance_criteria]]
text = "DSL reference entry and the route example"
status = "pending"
category = "chore"
Loading