Skip to content

Add collection task outputs - #10

Open
kriben wants to merge 10 commits into
mainfrom
multiple-tasks-fan-in
Open

kriben wants to merge 10 commits into
mainfrom
multiple-tasks-fan-in

Conversation

@kriben

@kriben kriben commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #4.
Fixes #8.
Fixes #9.

@kriben
kriben force-pushed the multiple-tasks-fan-in branch 2 times, most recently from 85d399a to 7456fc8 Compare September 21, 2026 12:08
@kriben
kriben force-pushed the multiple-tasks-fan-in branch from 7456fc8 to 5f84df5 Compare September 21, 2026 12:35
@kriben kriben changed the title Add collection dependencies for task outputs Add collection task outputs Sep 21, 2026
There is only one SIGALRM per process, so the previous design where the
job, each task, each mapped item, and each nested workflow_task runner
called signal.alarm() independently had three defects:

1. Any task/item/inner-runner alarm overwrote the job alarm and its
   handler; the subsequent signal.alarm(0) then cancelled it for good.
   A job with timeout_seconds=1 ran to completion in 3s as soon as one
   task declared its own timeout_seconds.

2. signal.signal() raises ValueError outside the main thread, which was
   not in the except tuple, and arming happened outside the guarded
   try block. The exception escaped Runner.run() with job.status left
   at RUNNING and no TASK_FAIL/JOB_FAIL events.

3. signal.alarm(int(seconds)) truncated: timeout_seconds=1.9 fired at
   1s while the error message claimed 1.9s.

The job deadline is now an absolute time.monotonic() timestamp carried
in a per-run _Deadline object. Before every task and mapped item the
deadline is checked and the timer armed with min(own timeout, remaining
job time), raising the matching error type. Arming happens inside the
guarded region so any failure is recorded as a task failure. ValueError
is caught, a single warning is issued per run, and execution proceeds
unenforced. Timers use signal.setitimer for float precision, and the
pre-existing SIGALRM handler is restored when the run finishes.

Regression tests cover: job deadline surviving a task with its own
timeout and a nested workflow_task; an expired deadline preventing the
next task from starting; a 1.9s timeout allowing a 1.4s task; handler
restoration; non-main-thread execution completing with one warning;
and an arming failure being recorded as FAILED rather than RUNNING.
ResultPersistenceHook wrote f"{task.name}.json" without escaping, so a
task named "../evil" (from a YAML name: override or a plugin) landed
outside output_dir. Task names are now percent-encoded like map keys
already were, via a shared _safe() helper.

Linear-mode YAML configs (no depends_on/map) were built with the
Workflow(tasks=[...]) shorthand, which ignores YAML name: overrides and
then had config_fields assigned after _validate() had already run. The
result was that per-task input keyed by the overridden name was wired to
a non-existent task, config_fields bypassed type and coverage checks,
and the failure surfaced as an unwrapped WorkflowDefinitionError from
Job(). Both modes now go through WorkflowBuilder, with linear mode
chaining each task on the previous task's registered name, so naming,
config_fields and validation behave identically. Workflow and Job
construction errors are wrapped as ConfigLoadError.

A workflow: entry referencing itself, or two files referencing each
other, recursed until RecursionError. _load_workflow_only now carries
the set of enclosing resolved paths and rejects a cycle with a
ConfigLoadError that names the chain. Reusing an inner workflow at a
different nesting level is still permitted.
…e types

- result_task passed to Workflow() or Workflow.builder() was stored
  without checking that it names a registered task, so the failure was a
  bare KeyError from .result_task or Runner.run(). _validate_result_task
  now raises WorkflowDefinitionError listing the known task names.

- Workflow(tasks=[T, T]) silently overwrote the dict entry and created a
  self-edge, which _validate_acyclic then reported as a cycle. Duplicate
  names are rejected up front with the same message the builder uses.
  _validate_unique_names is documented as satisfied by construction.

- Workflow(tasks=[]) skipped validation entirely because the guard was
  truthiness-based. An explicit empty list is now a definition error;
  tasks=None remains the "build later" escape hatch.

- Single-dependency and field-reference edges compared types with 'is',
  while fan-in and collection edges used _is_type_compatible. A producer
  emitting a subclass of the consumer's input model was rejected on the
  former and accepted on the latter. All edges now use compatibility.

- Field-reference mismatch messages used .__name__, which drops generic
  arguments ("list" instead of "list[int]"). They now go through
  _type_name like the other edge kinds.
Hook failures were reported as "Hook X raised during <event>" with the
exception discarded, so a broken hook was effectively silent. The
warning now includes repr(exc), attaches the exception as the warning's
source, and uses a dedicated HookError category (a UserWarning subclass,
so existing pytest.warns(UserWarning) and -W filters keep working while
callers can now filter or escalate hook failures specifically).

A failure inside a workflow_task raised a bare RuntimeError built from
result_job.error, losing the exception type, the traceback, and the
inner Job with its task_results. It now raises WorkflowTaskError (a
TaskExecutionError) carrying the inner Job as .inner_job and chaining
the original exception via `from`. To make that chain possible the
Runner records the raw exception on Job.exception alongside the
existing Job.error string; nested wrappers therefore produce a
__cause__ chain that can be walked back to the leaf failure.
_load_workflow_only keyed both task_classes and name_lookup by the
import path (or inner workflow file). When the same class or file
appeared twice under different name: overrides, the second entry
silently overwrote the first, so depends_on: pkg.Cls resolved to
whichever instance was declared last, and two workflow: entries for
one file could not be wired independently.

Entries are now kept positionally, and the lookup maps every key
(import path, inner file, instance name) to the set of registered
names it denotes. Resolving a key with more than one candidate raises
ConfigLoadError listing the candidates and asking for the instance
name. result_task goes through the same resolver, so it gets the same
not-found and ambiguity handling.
Whether input.yaml was flat root input or per-task configuration was
inferred purely from its shape: if every top-level key happened to be a
task name with a mapping value, per-task mode was chosen. A flat input
whose field name coincided with a task name therefore flipped mode
silently and failed with an unrelated coverage error.

workflow.input_mode now accepts 'auto' (default), 'flat' or 'per_task'.
Explicit modes skip inference; 'per_task' additionally validates that
every key is a task name and every value is a mapping or null. Under
'auto' the previous heuristic is retained for the common unambiguous
case, but when the mapping would also validate as the sole unconfigured
root task's input model the loader raises ConfigLoadError naming both
readings and asking for an explicit input_mode.
CI and publish workflows had no permissions block (so the token had the
default broad scope), no job timeouts, no concurrency group to cancel
superseded runs, and installed dependencies uncached on every run. The
third-party publish action was referenced by the mutable release/v1
branch. CI also linted only taskmaestro/ and tests/ although CLAUDE.md
lists examples/ among the paths to check.

Both workflows now declare `permissions: contents: read` at the top
level (publish's second job opts into id-token: write only), set
timeout-minutes, and enable setup-python's pip cache. CI gets a
concurrency group with cancel-in-progress, lints examples/, and fails
if coverage drops below 100%. pypa/gh-action-pypi-publish is pinned to
the commit behind v1.14.2. The 3.14 classifier is added to match the
test matrix.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Validate generic container types correctly Support mapped task expansion Add possibility that a output of multiple tasks become input for a task

1 participant