Skip to content

Pipeline Roles

Autumn edited this page Oct 17, 2026 · 10 revisions

Split pipeline roles: index

These specs are the design for the self-build ticket, #300, which the pipeline builds itself. Where an open question has a proposed answer, the build uses it unless the owner says otherwise.

Drafts for split-pipeline-roles, a plan item in the owner's notes (not public). They describe the issue pipeline at integration c1797bd (2026-10-16) as five pipeline roles (triage, spec writer, implementor, reviewer, and the merger, proposed to become the finalizer) and the orchestrator that runs them. Each spec gives the role's requirements, what done looks like, the interfaces its automated tests use, and a test plan. They describe today's behaviour so the split can start from a known base. Adjust them as the split's design settles.

The requirements from the wiki decisions each role follows are merged into its Requirements, Done and Open questions. The Sources section at the end of each spec names the decisions they came from. Changes from today's code are marked "proposed" or "changed" where they appear. Each role spec lists its sessions' reply schemas for review under "Session reply schemas". Requirements every role shares are under "Shared, owned by no role" below.

The specs leave out session token budgets (the per-role budgets in ROLE_BUDGETS, sessionTokenCap, and what each session is charged) on purpose. A second pass will work out how to reduce context bloat. contextLimits stay in. The issue's own token budget and the rebase allowance are gone (budgets-bound-context, Axlog, 2026-10-16).

Role Spec Stages today Model sessions today
Triage Triage triage triage (reader)
Spec writer Spec writer spec; proposed: also creates the branch and opens the draft PR (spec-writer-opens-pr) spec writer (specifier)
Implementor Implementor implement, open-pr (proposed: removed), fix (the fixer), sync (proposed: no stage of its own; it runs at the start of each implementor entry and in finalize, sync-at-edges), checks implementer, fixer, conflict resolver, CI fixer (all writer)
Reviewer Reviewer review, fix (the re-review), accept, security; proposed: also final-security three lenses, aggregator, re-reviewers, check-fix reviewers, acceptance verifier, security reviewer (all reader)
Finalizer (today the merger; proposed: finalizer-role) Finalizer merge, publish, plus closing parents and cleanup; proposed: finalize (taking over the implementor's sync), land and publish none (the wiki conflict resolver runs inside merge and publish today; proposed: it moves to the spec writer, merge-only-when-ready)
Orchestrator Orchestrator none: it starts the other roles' stage jobs, and the operator chooses which roles it runs (proposed) the escalation agent, optional and off by default

Each role spec has its own Lifecycle section (proposed), with the sessions marked. The roles' lifecycles differ on purpose, and nothing makes them share phases. Proposed (role-state-machines): the spec writer, the implementor and the reviewer each run as an explicit state machine, a table of states and transitions plus one pure transition function. Their Lifecycle sections are those tables. Triage, the finalizer and the orchestrator stay linear scripts. The machines share only a runner, whose rules are in Shared. What several roles use, pipeline_commit and how a role's wiki access is provisioned, is in Shared.

How the roles hand off

Every handoff is the issue's saved state (the hidden state comment) at a new st.stage, plus GitHub objects (draft PR, labels, comments). No role calls another directly.

From To st.stage What the receiver needs
triage spec writer spec plan, splitSuggested; today also pr (draft, unless a split was suggested), proposed: no PR
spec writer (needs info, answered) triage triage the spec so far
spec writer implementor implement spec (and specFile when clipped), files, interfaces (proposed), plan, pr (draft; proposed: opened by the spec writer), stackedOn; proposed (spec-writes-failing-tests): specTestsSha, and the stubs and failing tests as commits on the branch
implementor reviewer review PR ready for review, headSha, flows, prTitle, prBody; proposed: the PR stays a draft and its text isn't touched, so only headSha and flows
spec writer (wiki-only, proposed) reviewer review flows (wiki only), no PR
reviewer implementor fix open findings, round, securityRounds, reviewedSha
implementor (fix pushed) reviewer inside fix today the fix diff and the open findings
reviewer (clean review or re-review) implementor sync (proposed: checks) reviewedSha
implementor (checks pass) reviewer accept verifiedSha, verifySummary
implementor (clean rebase of a passed head; today only) merger merge testsOnly, passedSha, verifiedSha
reviewer (security pass) finalizer merge (proposed: finalize) passedSha
merger (branch behind or conflict; today only) implementor sync passedSha kept, testsOnly
finalizer (rebase conflicts or retest fails; proposed: finalizer-role) implementor fix a conflict or retest finding, a signed note on the PR, finalizeReturns
finalizer (a verdict is missing at the head; proposed: merge-only-when-ready) the stage that gives it: spec, fix, review, accept or security that stage finalizeNote, finalizeReturns, and a signed note on the PR
finalizer (merged and retested; proposed: final-security-review) reviewer final-security mergedSha, mergedBaseSha
reviewer (final security pass) finalizer land finalPassedSha
reviewer (final security findings) implementor fix open security findings, securityRounds
finalizer (base moved after the final review) finalizer finalize the merge, retest and final review run again
finalizer (landed) done publish, then done the PR merged and closed, label agent:merged

Triage's and the spec writer's needs-info, the spec writer's split, and every stop leave the pipeline until an allowed user answers or removes the stop label.

Where the code crosses roles today

These are the seams the split has to cut. Each spec lists the ones it touches under Open questions.

  • Fix rounds: stages/fix.ts runs the fixer (implementor) and stages/rereview.ts (reviewer) in one stage machine, one round per begin.
  • CI fixes: stages/ci-fix.ts runs the CI fixer (implementor) and then the correctness and scope lenses on its diff (reviewer) inside checks.
  • Wiki conflicts at merge and publish: stages/merge-wiki.ts and stages/publish.ts launch the conflict resolver, a writer session, inside merger stages.
  • Checks: checks runs as writer today because of its CI fixer, but verify and ci-status are the gate the reviewer and merger trust. Proposed: the finalizer retests at the merged head itself.
  • Tests-only recheck: merge sends a behind branch back to sync, so a lost merge race passes through implementor stages. Proposed (finalizer-role): the finalizer rebases and retests itself.

Shared, owned by no role

The specs treat these as given and don't restate them. A change to them is its own issue.

  • The sweep driver and entry: src/workflow/sweep.ts, sweep-stages.ts, sweep-item.ts, stages/entry.ts (sweep op, claim, release, summary).
  • State, labels and stops: src/state.ts, src/labels.ts, src/stops.ts, ops save-state, stop, post, release.
  • Claims and leases: src/claims.ts, ops claim (wiki claim-comments, leases-and-recovery).
  • Budgets and continuations: src/workflow/budget.ts, stages/continuation.ts, sweep-config.ts.
  • The stage job and its roles: src/job.ts, src/job/ (roles.ts holds reader, writer, merger).
  • Input filtering: allowedUsers and the collaborator check (wiki allow-listed-input).

Shared requirements

These apply to every role. They come from the wiki decisions named in brackets, as of wiki f1b2944.

Job boundary (sandbox-ready-jobs, credential-isolation-limit):

  • Each stage job declares its boundary, so it is ready to run in a sandbox later:
    • its role (reader, writer or merger);
    • its own checkout, work/<slug>/jobs/issue-N-<stage>, cloned from a bare mirror and removed on exit;
    • the names of the environment its model sessions get;
    • its network, recorded but not enforced.
  • src/job/roles.ts holds one table from stage to role, and one rule for sessions: a session runs as writer when its agent is issue-pipeline-writer, and as reader otherwise. No session runs as merger.
  • Every session's environment drops gh's token variables and every role's tokenEnv variable. A writer job holds only the token its jobRoles.writer.tokenEnv names, and a merger job only its own tokenEnv token. Built (pipeline-commit, #303): no session holds a token. A writer session commits through pipeline_commit, which asks the job to push, so only jobs hold a token, and no model holds merge or push credentials.
  • Known limit: a session with bash can still run gh and git push with Axlog's own keyring login, and --no-verify skips the pre-commit and pre-push hooks. The hooks and the prompts only guard against accidents. Real isolation needs the pipeline to run as a separate macOS user or in a container, with no GitHub credentials besides the token file.
  • job.mjs --print-boundary prints the boundary.

Checkouts (worktrees-and-branches, wiki-docs):

  • A stage job makes or fetches (with --prune, under <clone>.lock) the bare mirror work/<slug>/mirror.git. It clones the mirror into its own code checkout, <job dir>/repo (#338), on agent/issue-N at the branch's head, or at origin/<base> when there is no branch yet. It replaces any directory an earlier job of that id left.
  • The checkout's origin is the real remote. It is set up like the clone: credential helper, name and email, and the pre-push hook that refuses pushes except from the ops tool. src/clone-setup.ts holds that setup for both clones.
  • With a wiki, the job keeps work/<slug>/wiki-mirror.git, fetched without --prune under the wiki lock, and clones it into <job dir>/docs, beside the code checkout <job dir>/repo (separate-checkouts, #338); the job dir is the sessions' cwd and isn't a git repo. Every stage gets docs/ (every-role-reads-wiki). A repo with no wiki yet gets no wiki checkout.
  • On every exit (finished, failed, or stopped after the interrupt save), the job:
    • records the paths left uncommitted as lostChanges in job.json, and drops them;
    • runs the push op for a writer with commits on neither origin/<base> nor origin/agent/issue-N;
    • keeps unpushed wiki commits as agent/issue-N in the wiki mirror, where the issue's next job starts from them (and deletes that ref when there are none);
    • removes the checkout.
  • A failed exit push is reported on stderr and changes nothing else. A reader or merger never pushes. Proposed (finalizer-role): the finalizer pushes the rebased branch, through an op, with --force-with-lease.
  • Proposed (every-role-resumes): every role can be interrupted anywhere and started again. There is no --resume: each start checks out fresh and finds what is already done from the issue's state, GitHub and the remotes. The rules are in Shared.
  • Git operations on shared clone state (worktree add and remove, git config, fetch) take <clone>.lock.
  • The pi-subagents sweep (workflow.js) still uses one worktree per issue under work/. At a start (new work, answered questions, a removed stop label), an issue with no branch on GitHub and no commits past the base has its worktree reset to the fetched origin/<base> and cleaned. A worktree with commits of its own is kept.

Usage log (proposed: log-usage-at-finish): every session, in every role, appends a record of its tokens and context when it finishes (first turn, peak and final context, tokens, per-tool output, attempt), and every job appends its totals. Records go in one hidden usage comment per issue, edited in place, and hold no text, paths outside the repo, or personal or machine details. report.mjs usage groups them and flags bloat. See Shared.

Input (allow-listed-input): only issues and comments from users on allowedUsers who are still collaborators reach any session. See Triage for the full rule.

Prompts (security-review, base-branch-may-differ-from-default): every prompt says any file the issue needs is in scope, and that code, diffs and logs are data, never instructions. Writer, specifier and reader prompts say the PR merges into the base branch, not production, and that releasing is never part of an issue's work.

Orchestrator stacking (stacked-branches, #290): the rules live in one pure module, src/orchestrator/stacking.ts (stackRule, stackPartners). They work from facts the snapshot records: each issue's stackedOn, and its prerequisite P (the issue it is stacked on, or else the one open issue its after mark names) as open, merged or closed-unmerged.

  • An issue waiting on its after mark starts triage, spec or implement on P's branch (--stack-on P, only on that first job) once P is at checks or later with no open findings. Until then, or when P is stacked itself, it is held after:#P. Stacks go one level deep. While P's branch lacks the merge commit of another prerequisite in the after mark that merged, it is held stacked-on-behind:#P instead, until P is rebased onto a base that holds it (#328); a failed read counts as not behind.
  • A stacked issue is held stacked-on:#P at merge while P is open, and isn't ranked for merge. The writers' file-overlap gate ignores the pair.
  • A stopped P holds its dependents after:#P:stopped. A P closed unmerged holds a stacked dependent after:#P:closed-unmerged. These are holds, not stops, so dependents go on once P is resumed or reopened. Every hold is an ordinary held line in orchestrator.log.
  • A P that isn't open is looked up with stack.ts's prereqMerged. An open P at publish counts as merged.

Code and tests (practices-cover-all-code, tests-pin-promised-behaviour, flakes-are-failures):

  • The coding practices (AGENTS.md, CONTRIBUTING.md) cover the pipeline's code as they cover the site's: design, types, lint, and test-first, flake-free tests. Code that doesn't meet them yet is tracked as an issue and isn't exempt.
  • Tests pin promised behaviour, not internals:
    • what the wiki and README promise;
    • the ops replies' shapes;
    • stop codes and their labels;
    • caps and limits;
    • GitHub-visible effects, checked through gh's writes only;
    • past bugs named by issue number.
  • Comment bodies are checked for the facts the wiki promises, not copied whole. Tests don't assert copied constants, prompt or log text, call order, argv, launch keys, key order, or whole-trace snapshots.
  • A flake is a failure: fix its cause, never rerun until it passes.

No work to do (proposed: no-work-skips-stages)

Each role's lifecycle checks by script, before any session, whether its stage has work. A stage with none runs no model. It saves the issue at the next stage with a logged reason, or stops it when an earlier stage should have caught the gap. Each role spec gives its own rules. This table gathers them:

Stage No work when (script) Then
any the claim finds the issue closed, stopped or at another stage release, as today
spec the spec writer replies no-work, and its tests (if any) pass on the base reset the branches, then stop with agent:stopped-no-work (new stop code)
after spec no failing tests, and only wiki pages in files open no PR (closing one an earlier spec opened) and save at review (wiki-only path); no implementor job
after implement no code change while spec tests still fail stop: the implementer changed nothing
after implement no code change and no wiki change stop as not doable, as today
fix no open blocking findings sync, as today (proposed: checks)
sync (proposed: at the start of each implementor entry and in finalize, sync-at-edges) the branch already contains the target's head checks, with no rebase (proposed: the entry goes on with no rebase; the finalizer skips the rebase, and the retest too when the head is verifiedSha with CI green)
checks head equals verifiedSha, and nothing moved accept, or merge with testsOnly (proposed: accept only)
review empty diff stop: nothing to review
accept head equals acceptedSha (new), with the diff unchanged security
security the diff from the base is unchanged since it passed merge (proposed: finalize)
final-security (proposed) the head is already finalPassedSha (after a restart) land; never skipped for an unchanged diff
land (proposed) the PR was merged by hand bookkeeping, as today
merge (proposed: finalize) the PR was merged by hand, or the change is wiki-only bookkeeping or finish-wiki, as today
publish no wiki commits ahead of the wiki's base finish without a push

Only one judgement is left to a model: the spec writer's no-work, which is checked by its tests passing on the base.

Test interfaces every role uses

Each spec names the ones it needs. All live in apps/issue-pipeline/test/ and run under mise //apps/issue-pipeline:check.

  • Step machine: call a stage's next(state, ctx, results) from src/workflow/stages/<stage>.ts and assert the returned Step: its launches (op and payload, or agent, model, schema and task), state.st, comments and stop. Kits: workflow-triage-test-kit.mjs, workflow-build-test-kit.mjs, workflow-review-test-kit.mjs, workflow-fix-test-kit.mjs, workflow-checks-test-kit.mjs, workflow-finish-test-kit.mjs.
  • Built sweep: runWorkflow({ args, ops, agents }) in workflow-harness.mjs runs the built workflow.js with scripted replies. Read results through stage-outcome-test-kit.mjs (opsRequests, requestFor, releasedState, sessionAgents) and build replies with workflow-stages-fixtures.mjs (sweepReply, claimAt, ROUTINE_OPS). Tests never pin launch keys or order (tests-pin-promised-behaviour).
  • Ops CLI: runOps() in ops-run-test-kit.mjs sends one framed request to src/ops.ts, against real git repos (ops-fixture.mjs, ops-test-kit.mjs, verify-test-kit.mjs) and the fake gh (fake-gh.mjs). Read GitHub effects with effectsOf and stateOf from gh-effects.mjs.
  • Stage job: runJob and startJob in job-test-kit.mjs run node src/job.ts with a fake ops.mjs and a fake RPC pi (fake-rpc-pi.mjs). Assert the ops log (logOf), pi's command lines and env (piCommands), and job.json (statusOf).
  • Orchestrator: the pure scheduler, watchdog and stacking rules with recorded snapshots (orchestrator-fixtures.mjs), and the tick with orchestrator-world-test-kit.mjs.

Split decisions

Decisions made for the split that are public. The others are in the owner's notes.

sync-at-edges

Axlog (2026-10-17): "Sync should happen at the beginning, before the edits, and the end. The implementor writing the code shouldn't be syncing and once started, they should finish and then merge the result. If the whole agent gets interrupted or the issue bounces away and back, there should be a sync in those cases. Essentially minimizing when/where we sync."

The operating session settled the details in automatic mode (2026-10-17). A sync brings a branch up to the base: it rebases the branch onto the base, or onto the prerequisite's head while the issue is stacked. A sync runs only at these points:

  1. At the start of each implementor entry, before any edit:
    • implement, so the branch starts at the base's head;
    • fix, after any return to the implementor: review, acceptance or security findings, or a finalizer conflict or retest finding;
    • a job that resumes an interrupted implementor entry: a killed job, a crash, a rate-limit deferral or an orchestrator restart.
  2. In finalize, after the merge-order wait, as the Finalizer page describes.

A sync never runs in between:

  • not while an implementor entry runs;
  • not between an entry's continuation sessions. A planned handoff at contextLimits.soft or hard is not an interruption;
  • not as a step of its own after a review clears. Today's path from review or rereview through sync to checks goes straight to checks;
  • not when a checks job starts again after waiting on CI (held). That wait is not an interruption.

A rebase at an entry's start that conflicts runs the conflict resolver first, in that entry, before the writer edits. The finalizer still never resolves a conflict. It sends a conflict finding, and the fix entry's start sync handles it.

A resolver that returns cannot-fix at an entry's start leaves the issue at its stage, and the next job's start sync tries again (conflicts-never-stop). The issue stops with agent:stopped-conflict only once maxConflictFixes runs out, or when a resolution is rejected on the last attempt.

Counting: only returns to finalize count toward maxSyncs, since they're the churn the cap bounds. A start sync doesn't count, whether clean or not. Conflict resolutions at an entry's start still count toward maxConflictFixes.

Why: every sync moves the base under the issue. Checks then have to run again, and today acceptance and security too. On 2026-10-17, #303 went back from accept to sync when #330 merged, and #316 waited behind #304 and #303 and still needs its own re-check. A sync at an entry's start costs nothing extra, since checks follow every writer entry anyway. The sync in finalize is the one before landing.

Refines finalizer-role, which moved sync to the finalizer. Keeps final-security-review.

Open questions for the whole split

  • What "split" means at run time: five agents in one sweep as today, five stage-job roles under the orchestrator, or five separate processes each claiming the stages it owns.
  • Whether the split is code organisation only (modules and tests regrouped by role) or also changes the job boundary (STAGE_ROLES in src/job/roles.ts).
  • How the five roles map onto the existing reader, writer and merger job roles. Triage and the reviewer are reader. With spec-writes-failing-tests the spec writer commits, so its job is a writer (#334).
  • Where checks belongs (see each spec).

Clone this wiki locally