Volume 3 — Integration Automation and CI Orchestration — contents
  1. Foreword
  2. Chapter 1 — What Integration-Level Automation Is
  3. Chapter 2 — Why Most CI YAML Grows Monstrously
  4. Chapter 3 — The Contract: Six Typed Configuration Categories
  5. Chapter 4 — Specifications
  6. Chapter 5 — Landscape Files
  7. Chapter 6 — Topology Files
  8. Chapter 7 — Runtime Files vs Environment Variables
  9. Chapter 8 — Data Profiles
  10. Chapter 9 — Credential Vocabularies
  11. Chapter 10 — Credential Resolution
  12. Chapter 11 — Credentials in Flight
  13. Chapter 12 — Credentials Across Environments
  14. Chapter 13 — The Development-Environment Ladder
  15. Chapter 14 — Configuration Profiles as a Discipline
  16. Chapter 15 — The Local-Dev Tax and How Typed Configs Cancel It
  17. Chapter 16 — Reproducing CI Failures Locally
  18. Chapter 17 — Profile Promotion
  19. Chapter 18 — The taskplus Task Model
  20. Chapter 19 — Built-in Task Types
  21. Chapter 20 — Workflow YAML Grammar
  22. Chapter 21 — Passing Information to Jobs
  23. Chapter 22 — Flow Control as Data
  24. Chapter 23 — Extending the Task Model
  25. Chapter 24 — Wiring to a Real CI Vendor
  26. Chapter 25 — Bridges to Other Layers
  27. Chapter 26 — Case Study: Porting a Real Pipeline
  28. Chapter 27 — Where This Approach Is Not the Right Fit

Chapter 18 — The taskplus Task Model

Part V is the implementation. The four parts before this one asked what the configurations should be and how they should compose; this part asks what runs them.

Welcome to Part V. The volume's discipline half — Parts I, II, III, IV — is finished. Six configuration categories named, a credential discipline established, an environment-spanning profile system proposed. All of that has built up to one question: what is the thing that takes these typed inputs and runs an orchestration?

The answer in the portfolio is codecraftai-taskplus. Six chapters of Part V cover it: the task model (this chapter), the built-in task types (chapter 19), the workflow YAML grammar (chapter 20), how information flows from orchestration to job (chapter 21), how flow control is expressed as data (chapter 22), and how to extend the task model with new types (chapter 23).

By the end of Part V, the reader has the complete implementation substrate for the discipline. Part VI then wires it to real CI vendors with worked examples.

Why a task model at all

A workflow is a sequence of steps. At the smallest scale, "a workflow" can be a shell script — one command after another, with exits between them. At the next scale up, the shell script grows conditionals, loops, and error handlers; eventually it has more control flow than commands.

A task model is the answer to the question "what's the smallest thing the orchestration engine needs to know about a step in order to run it?" Three properties are essential:

  1. A way to identify the step. Logs, traces, results all need to refer back to a specific step. Without identity, when a step fails you can't say which step.
  2. A way to execute the step. The engine has to call into the step's implementation; the step has to return success or failure.
  3. A way to express control-flow context. Where does this step sit relative to other steps? What happens if it fails? What happens if it succeeds?

The taskplus task model captures these three properties in three classes — FlowNode, Task, TaskGroup — that together form a small inheritance hierarchy in codecraftai-taskplus/source/packages/ccai/taskplus/model/. The hierarchy is small enough to read in one sitting and rich enough to express every workflow shape Part V's chapters will build on.

FlowNode — the abstract base

The root of the task model is FlowNode, an abc.ABC class with no abstract methods. In its entirety:

class FlowNode(abc.ABC):

    def __init__(self, ordinal: str, label: str, node_info: Dict[str, Any], logger: Logger):
        self._ordinal = ordinal
        self._label = label
        self._node_info = node_info
        self._logger = logger

        self._if_expr = node_info.get("if")
        return

    @property
    def if_expr(self) -> str:
        return self._if_expr

    @property
    def label(self) -> str:
        return self._label

    @property
    def node_info(self) -> dict:
        return self._node_info

    @property
    def ordinal(self) -> str:
        return self._ordinal

Five fields and four properties. Every flow node — whether it's a leaf task or a recursively-nested group — carries these.

ordinal — the trace identity

The ordinal is a dotted string identifying the node's position in the workflow. Top-level nodes have ordinals "1", "2", "3"; nested nodes have ordinals like "2.3" (the third item inside the second top-level node) and "2.3.1" (the first item inside that).

The ordinal is the trace key. When a step fails, the log message "step 2.3.1 failed" tells the engineer exactly where in the workflow to look — even when the workflow has nested taskgroups and parallel branches that defy a flat numbering. The shipped Workflow.parse generates ordinals as it walks the YAML; tasks and taskgroups inherit their ordinals at construction time.

The ordinal is a string rather than a number. This is deliberate — "2.10" and "2.2" would compare incorrectly as floats, and the dotted-path semantics is more important than the numeric one.

label — the human identity

Where the ordinal is for machine routing, the label is for human readability. A task labelled "Run smoke tests" shows up in logs and result reports under that name. The label has no uniqueness constraint within the workflow; two "Cleanup"-labelled tasks at different ordinals are fine.

The label is the YAML's task: or taskgroup: field — when a workflow author writes:

- task: Run smoke tests
  tasktype: my.module@SmokeTest

the string "Run smoke tests" becomes the task's label. This is the chapter-20 grammar's view of the same field.

node_info — the raw configuration

The node_info is the YAML dictionary that defined this node. Every field the workflow author wrote — including the ones the framework doesn't know about — is preserved here. The implementation detail matters: by storing the whole node info, the task model lets concrete subclasses access task-specific fields without the model needing to enumerate them.

When RunCommand (chapter 19) wants its command field, it reads node_info["command"]. When EmbeddedPython wants its script, it reads node_info["script"]. The base class doesn't know about these fields; the subclasses pull what they need.

logger — the observability hook

Every node carries a Python Logger reference. This is the mechanism for log emission during execution; concrete tasks typically emit logger.info("STEP: %s - %s" % (label, ordinal)) at the start of execution and logger.info or logger.error for results.

The logger is passed in rather than fetched. This keeps the task model decoupled from the specific logging configuration — the workflow loader (chapter 20) chooses which logger every node gets, which is how runtime configurations (chapter 7's logging levels) reach the tasks without the tasks knowing about the runtime configuration system.

if_expr — the conditional gate

The base class also surfaces an optional if expression from the node info. Workflow authors declare it like this:

- task: Run staging cleanup
  tasktype: my.module@StagingCleanup
  if: "parameters['cleanup_after_run'] == True"

The expression is a Python string evaluated by the FlowEngine at execution time. When the expression is None (the field was absent), the node always runs. When the expression evaluates to falsy, the node is skipped.

The if_expr is the chapter-22 flow control's gate; this chapter just notes that it lives on every flow node and that the base class exposes it. Chapter 22 develops the evaluation semantics.

Task — the leaf

A Task is a FlowNode that does work. It has two properties beyond the base — onfailure and onsuccess — and two methods — execute and the abstract perform_task.

class Task(FlowNode):

    def __init__(self, ordinal: str, label: str, node_info: Dict[str, Any], logger: Logger):
        super().__init__(ordinal, label, node_info, logger)

        self._onfailure = node_info.get("onfailure", None)
        self._onsuccess = node_info.get("onsuccess", None)

        return

    @property
    def onfailure(self) -> Optional[str]:
        return self._onfailure

    @property
    def onsuccess(self) -> Optional[str]:
        return self._onsuccess

    def execute(self, environment, parameters, flowstate) -> int:
        self._logger.info("STEP: %s - %s" % (self._label, self._ordinal))
        result = self.perform_task(environment, parameters, flowstate)
        return result

    @abc.abstractmethod
    def perform_task(self, environment, parameters, flowstate) -> int:
        raise AbstractMethodError("TaskBase->execute must be overloaded by derived classes.") from None

onfailure and onsuccess — declarative jumps

Both fields are optional. Each names another node's label that the engine should jump to when this task ends in the relevant outcome.

A task with onfailure: cleanup and onsuccess: notify means: "if I fail, jump to the task labelled cleanup; if I succeed, jump to the task labelled notify." Tasks in between are skipped.

This is the chapter-22 flow-control mechanism. The chapter develops the JumpContext machinery that drives the actual jumps; the model simply records the pair of fields on every task. Tasks with neither field set fall through to the next node in sequence, which is the default behaviour.

execute and perform_task — the two-method pattern

The execute method is concrete and not meant to be overridden. It logs the step and delegates to perform_task. The perform_task method is abstract; concrete tasks (chapter 19) override it with their actual work.

The split is deliberate. The base class wants every task to log its start and to be drivable through the same execute(...) interface. But the contents of the work — running a shell command, executing a Python script, doing a Git checkout — are task-specific. The Template Method pattern keeps the common-context concerns (logging, signature) in the base while letting the concrete classes specify the work.

Three signature notes:

  • The method takes three positional arguments: environment (typically the resolved runtime environment), parameters (the workflow-level parameters), and flowstate (a per-run mutable dictionary that tasks can read and write). These are the three communication channels orchestration uses to talk to tasks; chapter 21 develops the contract.
  • The method returns an int. Zero is success; any non-zero value is failure. The integer can be a process exit code (for shell-driven tasks), an enum (for application-defined error classifications), or just 0/1 for the simplest cases.
  • The method is not allowed to raise to indicate failure. Tasks that experience exceptions catch them and translate to a non-zero return. This is partly a design choice (failure semantics are a single channel) and partly a pragmatic one (the FlowEngine's sequencing relies on non-exception returns to drive onfailure jumps; an exception would skip the engine's flow logic).

The AbstractMethodError import on line 23 of task.py (ccai.errors) is the canonical error for "this base method must be overridden." A concrete task that forgets to override perform_task raises this at first call.

TaskGroup — the recursive grouping

A TaskGroup is a FlowNode that contains other flow nodes. It does no work of its own; instead, it sequences and groups its children.

class TaskGroup(FlowNode):

    def __init__(self, ordinal: str, label: str, node_info: Dict[str, Any],
                 items: List[FlowNode], logger: Logger):
        super().__init__(ordinal, label, node_info, logger)

        self._items = items
        return

    @property
    def items(self) -> List[FlowNode]:
        return self._items

One additional field beyond the base: items, a list of child FlowNode instances. Children may themselves be Task instances, or further TaskGroup instances. The recursion is unbounded — deeply nested groupings are valid.

The taskgroup is the only structure in the model that produces the dotted-ordinal pattern — a TaskGroup at ordinal "2" with three children gives them ordinals "2.1", "2.2", "2.3". Without the grouping, every node is at the top level and every ordinal is a single number.

Why grouping matters

Three reasons to group rather than just listing.

Logical separation

A workflow that has a "build" phase, a "test" phase, and a "deploy" phase reads better when each phase is a TaskGroup than when they're flat siblings. The phase becomes one node in the workflow's top-level list; what's inside the phase is a nested concern.

Conditional skipping

Because TaskGroup is itself a FlowNode, it carries the if_expr field. A whole phase can be conditionally skipped:

- taskgroup: Deploy phase
  if: "parameters['deploy_after_test'] == True"
  items:
      - task: Build artifact
        tasktype: my.module@BuildArtifact
      - task: Push to registry
        tasktype: my.module@RegistryPush
      - task: Deploy
        tasktype: my.module@Deploy

When deploy_after_test is False, the entire group is skipped — three child tasks at once, with one if decision.

Failure jumps that span groups

A task inside group A can declare onfailure: jumping to a label inside group B. The engine resolves the jump by walking through groups; the cross-group jump is allowed. This means a deploy task can jump to a cleanup taskgroup on failure, without the deploy task having to reach inside the cleanup group's implementation.

The semantics here are nuanced — chapter 22 develops them in detail — but the model's role is to make them expressible. The recursive TaskGroup is the structure the engine walks.

A worked example of the hierarchy

The four-class structure is best understood through a concrete workflow. Here is a small workflow YAML and the in-memory tree the loader produces:

# Workflow YAML (preview of chapter 20's grammar)
name: nightly-integration
flow:
    - task: Setup
      tasktype: my.module@SetupTask

    - taskgroup: Test phase
      items:
          - task: Smoke
            tasktype: my.module@SmokeTest
          - task: Integration
            tasktype: my.module@IntegrationTest
            onfailure: Skip teardown

    - taskgroup: Teardown phase
      items:
          - task: Stop services
            tasktype: my.module@StopServices
          - task: Skip teardown
            tasktype: my.module@NoOp

The in-memory representation:

Workflow
├── flow[0]: Task("Setup", ordinal="1")
├── flow[1]: TaskGroup("Test phase", ordinal="2")
│              items[0]: Task("Smoke", ordinal="2.1")
│              items[1]: Task("Integration", ordinal="2.2",
│                             onfailure="Skip teardown")
└── flow[2]: TaskGroup("Teardown phase", ordinal="3")
               items[0]: Task("Stop services", ordinal="3.1")
               items[1]: Task("Skip teardown", ordinal="3.2")

Five Task nodes and two TaskGroup nodes, every node a FlowNode carrying (ordinal, label, node_info, logger). The Integration task at ordinal "2.2" has onfailure="Skip teardown"; when it fails the engine looks up the label "Skip teardown" across the whole tree, finds it at ordinal "3.2", and jumps the execution there.

The same shape recurses arbitrarily. A workflow with a Test phase taskgroup containing a Per-host setup taskgroup containing five Run hostprep tasks would be a three-deep tree; the model handles it with no special-case code.

Universal field invariants

A few invariants worth naming, since the chapter has implicitly relied on them.

  • ordinal is unique within a workflow. The Workflow.parse machinery enforces this by construction (the loader generates ordinals in walk order).
  • label is not required to be unique. Two tasks both labelled "Cleanup" at different ordinals can coexist. The jump machinery in chapter 22 handles label collisions by matching the first task with the named label in the engine's walk order; consult that chapter for edge cases.
  • node_info is the workflow author's source of truth. Every field the author wrote is preserved; the model adds nothing. Concrete tasks pull what they need; unknown fields are ignored silently. This is permissive on purpose — workflow YAML can carry application-specific metadata the framework doesn't understand.
  • logger is constructed once and shared. Every node in a given workflow carries the same logger instance. Per-task logging differentiation is handled by the logger's filtering and routing, not by per-task logger creation.
  • The recursion is finite. A taskgroup whose items list contains itself would be a cycle; the loader's _process_flow_info_items recurses but does not detect cycles because the YAML format makes cycles impossible to express.

What's not in the task model

Three things the task model deliberately omits.

Concurrency

The model has no notion of parallel execution. Tasks within a group run sequentially in declaration order. A task group does not dispatch its items concurrently.

When parallel execution is needed, the workflow author either:

  • Uses a different task implementation (a RunCommand task that itself spawns parallel subprocesses).
  • Handles it at a higher layer (multiple workflows running in parallel from a CI vendor's matrix-build feature).
  • Extends the task model with a new node type (chapter 23 covers the extension recipe).

The omission is deliberate — Part V's discipline is that the task model is small and predictable. Adding concurrency at the model layer would require a scheduling abstraction the simple sequential model doesn't need.

Retries

The model has no notion of retries. A task that returns non-zero is a failed task; the engine's flow control (onfailure jumps) takes over. The model does not retry the task with backoff or with reset state.

When retries are needed, they live inside a task's perform_task. A concrete RetryingHttpTask could implement its own retry loop; the model would see one task that took five attempts to return zero. Chapter 23's extension recipe shows the pattern.

This is consistent with the shipped portfolio's Aspects pattern from chapter 5 of Volume 5: behaviour parameters belong on the task, not on the engine.

State

The model has no first-class state beyond what's in flowstate (the per-run mutable dictionary passed through perform_task). Tasks read from it and write to it freely; there's no schema, no type checking, no scoping.

When richer state management is needed — typed state, scoped visibility, persistence — the workflow author uses an external state store (a database, a key-value cache, a configuration map) and references it through the runtime configuration. The discipline is to keep flowstate small and ad-hoc; persistent state belongs in proper data stores.

Summary

  • The taskplus task model is a small inheritance hierarchy in codecraftai-taskplus/source/packages/ccai/taskplus/model/. Three classes: FlowNode (abstract base), Task (leaf with work), TaskGroup (recursive grouping).
  • Every flow node carries five universal fields:
    • ordinal — dotted-path machine identity (e.g., "2.3.1").
    • label — human-readable identity from the YAML's task:/taskgroup: field.
    • node_info — the raw YAML dictionary, preserved verbatim.
    • logger — observability hook injected by the loader.
    • if_expr — optional gate evaluated by the FlowEngine.
  • Task adds onfailure and onsuccess (label-based jumps) plus the abstract perform_task(environment, parameters, flowstate) -> int method. Zero is success; non-zero is failure; tasks do not raise to indicate failure.
  • TaskGroup adds items: List[FlowNode] and contains other flow nodes recursively. The recursion produces the dotted-ordinal pattern.
  • The model is deliberately small: no concurrency primitives, no retry primitives, no first-class state. Extensions live in task implementations (chapter 19, chapter 23), not in the model.
  • The same model handles flat workflows, nested phases, cross-group failure jumps, and conditionally-skipped phases uniformly — without special-case code.

The next chapter introduces the five built-in task types shipped in codecraftai-taskplus/tasks/: RunCommand, ShellScript, EmbeddedPython, GitCheckout, and CreateEnvironment. Each is a concrete Task subclass; each illustrates the model's perform_task pattern; the chapter notes which are well-shipped and which (per the audit findings) are transitional or stub.