Skip to content

fix: make the state machines of the workflows engine explicit - #91

Merged
Vzlentin merged 6 commits into
mainfrom
fix/make-the-state-machines-of-the-workflows-engine
Nov 2, 2026
Merged

Vzlentin merged 6 commits into
mainfrom
fix/make-the-state-machines-of-the-workflows-engine

Conversation

@Vzlentin

@Vzlentin Vzlentin commented Nov 1, 2026

Copy link
Copy Markdown
Owner

The engine, the bundled scripts and the Pi extension tracked run and call lifecycles with loose flags, counts and separate writes. This makes the run status one union computed in one place, and closes the windows where a crash, a race or a stale branch lost or repeated work.

Run status

  • runStatus is the only place that turns a task into a status (running, waiting, done, failed, aborted, orphaned, earlier-version, later-version). taskOf, stop, prune, pastRuns, list and show all go through it
  • waiting runs are no longer past completed runs, and faulted reads as failed everywhere
  • a new task key is the highest #n + 1, so a gap or a task of another version no longer sends resume or stop to the wrong task
  • a second concurrent resume with another answer fails instead of losing its answer
  • every script status must be in meta.exit, an undeclared one exits 1

Scripts

  • thermoloop gives implement the head of the round, so a fix round that changes nothing returns blocked
  • campaign treats a failed judge as a failed item, gives the rundown the whole item patch, checks HEAD before the reset, and keeps its branch (a session that detaches HEAD no longer ends with branch: "")
  • fix.js lets a stop through, the run memory fails clearly on a bad nap format and no longer duplicates a replayed note

Crash windows

  • a run dir with no task shows in list as unstarted, and prune removes it after an hour
  • a start that lost its reply is sent again with the same key
  • a call record tells running, cancelled, cut and waiting apart in show
  • a wrong --answer fails the run, a child run dir is reused after a crash, a throw while closing sessions keeps the script result, and an abort shows as stopped

Worktrees, prune, Pi

  • the Herdr fallback closes the workspace it opened, and a branch checked out elsewhere gives a clear error
  • prune finishes after a crash between the worktree and the branch, and skips a dirty worktree
  • changeOf fetches the PR branch again when a local one exists
  • the Pi watcher watches a dropped background run again with a backoff, a gone run exits 4 and ends once, and a foreground resume posts the outcome once

workflows still reads the harness.tasks document by its kind, until harness exports it (Vzlentin/harness#44) and the harness range moves to the release with protocol 12.

…n memory

Several bugs in the workflow scripts gave wrong results: an
empty fix patch, a failed judge that counted as shipped, a
stale rundown patch, a blind reset, an empty branch and
duplicate notes.

- thermoloop.js gives implement the HEAD before the round as
  `start`. A fix round that changes nothing is now blocked, and
  the next review never gets an empty fix patch.
- The lib helper `writePatch(path, from)` in workflows/lib/git.js
  writes the diff from `from` to HEAD. It replaces the local
  `patch` of thermoloop.js. campaign.js also uses it.
  script.d.ts and the tool guidelines declare it.
- campaign.js `settle`: an item whose judge failed is a failed
  item, with the status `shipped with a failed judge: <error>`
  and no fix round. An unknown fix status is a failed item with
  the outcome `the fix round ended with the unknown status <s>`.
  It no longer throws a TypeError.
- The rundown gets `item-<n>/item.patch`, the change from the
  item's start to HEAD (also the fix commit), written before the
  reset. It replaces `round-<rounds>.patch`, which was missing or
  stale for some statuses.
- Before the reset of a failed item, the campaign checks that the
  item started from the last shipped commit and that HEAD contains
  it. If not, it fails with a clear message and does not reset.
- At the start of each item and at the end, the campaign checks
  that the worktree is on its branch. On a detached HEAD or
  another branch whose HEAD contains the last shipped commit, it
  moves the branch to HEAD with `checkout -B`. Else it fails with
  a clear message. So a result always has a branch.
- src/run/child.ts fails an aborted or orphaned child run with
  `child run <id> stopped: <reason>`. fix.js rethrows that error,
  so a resume of the caller runs the fix round again. It keeps
  only real failures as `failed`.
- workflows/lib/memory.js fails with a clear error when memo asks
  for a compression without `nap <a>-<b>`, and after 64
  compressions in a row.
- The plain store gives each note the key `<run-id>/<n>` in
  `notes.keys`. The note number is the line of its key. So an add
  that runs again at resume adds no line, and adds that run at
  the same time get different numbers.
- docs/spec.md describes the new behaviour.

`--rounds 0` and `--rounds 1.5` were already refused by the meta
check (`number` is a positive integer), for argv and for run()
args, so thermoloop.js does not change for them. A test now covers
it. New tests cover each fix with the faux provider. TASK.version
does not change.
…amilies unions

Each command read the task state in its own way. So a waiting run
counted as a past completed run, a faulted task read differently in
different places, and stop and resume could pick different tasks and
task keys.

- src/run/past.ts holds `runStatus`, the one function that computes a
  run's status from the task state, the outcome, the waiting result
  and the task version. It returns the union `RunStatus`. `hasEnded`
  and `hasResult` read that union. `taskOf` moves to src/run/past.ts
  next to it, so run/ and cli/ can both import it.
- `pastRuns`, `taskOf`, `list`, `show`, `stop`, `prune`, `resume`,
  `follow` and the child runs call `runStatus`. `pastRuns` returns only
  `done` runs and leaves out waiting runs. A faulted task is `failed`
  in every reader.
- `prunable` decides by `taskOf`, not by the last task.
- The key of a new task is the highest `#n` in harness's document of
  keys, plus 1 (`keysOf` in src/cli/saved.ts). `stop` and `resume`
  reach the task of `taskOf` by its own key.
- When a concurrent resume took the key with another input, the second
  resume fails with a clear message that tells to use `workflows wait`.
- `OutcomeStatus` comes from `OUTCOME` in src/tool.ts. The JSON
  outcome lines and `endOf` use it. The list marks switch on
  `RunStatus`.
- A script status that `meta.exit` does not declare exits 1, not 0.
  The built-in scripts declare their success statuses. Callers of
  implement and rpi treat any status other than `implemented` as not
  implemented.
- docs/spec.md and AGENTS.md describe the new behaviour.

TASK.version does not change: the saved input keeps its shape.
… task end

A crash or a closed connection at some points of a run left a
run directory that no command showed, reported a stop as a host
stop, mixed up call states in `show`, or faulted a task whose
script had ended.

- `start` writes the `project` file before it prepares the run.
  `list` shows a run directory with no task as `unstarted`, and
  `prune` reports it and removes it with `--yes` after 1 hour
  (`unstartedRuns` in src/cli/saved.ts).
- When the `start` request fails after it was sent, the command
  sends the same `start` again on a new connection. The host
  creates the task of a key once, so the command follows the run.
  `findTask` is removed.
- `abortRun` gives an aborted end when the host closes the
  connection after it replied to the abort, so the command
  reports `stopped`, not `host-stopped`.
- A call record saves the task that started it, and the time when
  the end of the script cancelled it. `show --calls` gives each
  call a state: done, failed, cancelled, waiting, running or cut.
- A `run` call saves the run ID of its child run before it makes
  the run directory. A rerun reuses the ID and cleans the directory.
- A failure to end the sessions does not fault the task: the host
  reports it, and the run keeps the outcome of its script.
- The end of an aborted run is saved with `stopped`. Its steps
  show `·` in the terminal, `show` and Pi.
- `resume --answer` fails with a clear message when the script
  did not reach the answered `input` call. A resume without
  `--answer` drops the earlier answer.
- docs/spec.md describes the new output lines and states.

TASK.version does not change: the saved input keeps its shape.
…shes and stale state

A crash or a stale state at some points left an orphaned Herdr
workspace, a leaked branch, a stale PR branch under review, or a
background run that the Pi extension stopped following.

- `ensureWorktree` fails with `branch <branch> is checked out at
  <path>` before Herdr or Git makes anything. When Herdr opens a
  workspace but leaves no repository, the run closes that workspace
  (`closeWorkspace` in src/herdr.ts) before Git makes the worktree.
- `prune --yes` writes the marker `runs/<run-id>/pruning` before
  it removes a run. The next prune finishes a marked run with no
  `gh` call. A removal that fails, such as of a dirty worktree, is
  reported on stderr, and prune goes on with the other runs and
  exits 1.
- `changeOf` always fetches `pull/<n>/head`, moves a missing or
  behind branch to it, and fails when the branch diverged, or when
  it is behind and checked out in a worktree.
- optimize removes the branch of the result, else the branch that
  the worktree is on. A removal that fails gives a `kept` report
  line.
- A command on a gone run (no run directory or no task) exits 4
  (`GONE_EXIT` in src/tool.ts).
- The Pi watcher waits again with a backoff from 1 s to 60 s after
  a wait with no outcome to post. It gives a gone run one end entry
  with the reason. A foreground resume of a followed run gives the
  outcome once.
- `outcomeOf` keeps the run ID in its error when the command
  printed `run <id>`.
- The run view catches a read that rejects, and shows the error of
  a later failed read under the tree.
- docs/spec.md describes the new lines, exit code and marker.

The `entries.json` write does not change: `setEntry` of harness
already writes only when the entry changes. The `worktree` report
line of `inCheckout` does not change: a checkout branch keeps its
name, so the line names its worktree.
Small state bugs hid errors, gave unclear messages, or let a
run go on in a wrong state.

- campaign fails with a clear message when `args.start` is not set.
- `squash` makes no commit and logs `nothing to commit since
  <short>` when nothing changed since `start`. A repeat stays safe.
- follow prints one warning for each series of failed progress
  reads, and the next good read catches up. A comment says why
  `seen` can index the progress events.
- `connectTo` gives the project to harness's `connect`, and does
  not set `HARNESS_PROJECT` around an `await`.
- `reading` runs its reads in one read transaction of the
  read-only store (`snapshot` in src/records/store.ts).
- A second `input` call that would wait fails the run, also when
  the script catches its error.
- `restartChild` names the kind and version of a child task that
  another version saved.
- `renameBranch` gives a `Renamed` union: on a branch, detached,
  refused or raced. The run reports each case with its own line.
- `removedByHerdr` gives false only when herdr is not installed.
  Other herdr errors fail the removal of the run.
- The Pi tool finds progress lines by their parsed JSON, and keeps
  only the last 100 other stderr lines.
- `show --agents --json` gives `ended`, and the run view stops
  reading after a read shows that the run ended.
- docs/spec.md describes the new lines and fields.
@Vzlentin
Vzlentin merged commit 073308a into main Nov 2, 2026
3 checks passed
@Vzlentin
Vzlentin deleted the fix/make-the-state-machines-of-the-workflows-engine branch November 2, 2026 07:17
@vzlentin-release vzlentin-release Bot mentioned this pull request Nov 1, 2026
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.

1 participant