Skip to content

Pipeline Roles Shared

Autumn edited this page Oct 17, 2026 · 21 revisions

pipeline_commit and wiki provisioning: spec (draft)

Draft, 2026-10-16. Proposed for split-pipeline-roles (decisions ops-in-role-lifecycles, pipeline-commit and wiki-provisioned-by-role), from Axlog's asks:

  • move the ops into a deterministic lifecycle;
  • limit each agent's git use to a custom commit tool with git commit's contract that also pushes;
  • provision the wiki as part of the lifecycle for roles that need it;
  • have pipeline_commit commit and push the wiki too, transparently.

Each role's lifecycle is its own, in its spec's Lifecycle section, and the roles' lifecycles differ. Nothing makes them share phases. This page holds only what several roles use: the commit tool, the hooks, and how a role's declared wiki access is provisioned. Index: Pipeline roles.

Why

Today the ops (src/ops.ts) are already scripts with no model. What isn't fixed is when they run:

  • Ops come as requests: each stage's workflow code asks the sweep's driver for a batch of launches, mixing ops requests and model sessions. The protocol (BEGIN_OPS_REQUEST on stdin, the issue-pipeline-ops external-cli agent) was built for pi-subagents' workflow sandbox, which has no shell.
  • Pushing is spread over several places: a writer session only commits. The commits reach GitHub later through push, open-pr or finish-sync, or through the job's exit push. Wiki commits the push missed are parked in the wiki mirror for the next job.
  • The wiki is cloned for every job whenever the repo has one, whether the role reads it, writes it or neither.
  • Writer sessions hold a token: they get the writer's GH_TOKEN and bash, so only the pre-push hook and the prompts keep them from pushing (credential-isolation-limit).

Requirements

What every role's lifecycle keeps

These are the only rules the roles share. Each role orders its steps its own way.

  • Its steps are scripts. Only the steps its spec marks as sessions run a model.
  • A session never runs an op, and an op never starts a session. The job runs the ops directly, in the order the role's lifecycle gives, not as launches the workflow requests.
  • Loops are bounded by counts kept in the state, never by the model's choice.
  • The ops keep their reply shapes, stop codes and GitHub effects, so today's ops tests stay valid.
  • Every role can be interrupted at any point and started again, carrying on from what is done (every-role-resumes). See Restart from what is done below.
  • The pi-subagents sweep (workflow.js, lanes, /pipeline-start) can't run lifecycles, because it has no shell. Proposed: retire lanes with orchestrator-cutover.

Role machines (proposed: role-state-machines)

The spec writer, the implementor and the reviewer each run as an explicit state machine. Triage, the finalizer (today's merger, finalizer-role) and the orchestrator stay linear scripts. Each machine is the role's own, with its own states and state type. They share only the runner and these rules:

  • Shape: a machine is a table and one pure function.

    • The table, TABLE, is data: each state's kind (script, session or end), the results it accepts, and the next state for each.
    • transition(state, result) → { state, effect } is synchronous, does no I/O, and returns only edges the table lists. A result the table doesn't list for that state is a bug, and the function throws.
  • State: { at, data }, where at is a state name and data is the role's own closed type. Counts, start SHAs and the last reply live in data. There is no carry: unknown and no flag shared across roles.

  • Effects: a state's effect is one of:

    • an op, with its payload;
    • a script check, such as spec-tests-check or a no-work check;
    • a model session, with its agent, schema and task. Only session states may return one, and the runner refuses it from any other state;
    • an end: saved(stage), stopped(code), released or held (stage again next tick). Built (#304): a stopped or released end may name an after op, such as ask or stop, which the runner runs after the end's save, with the state just saved as its state; its failure ends the run as a failed step would. It is named after, not then, because of Biome's noThenProperty. Rejected: a runner-level stop effect, which covers stop but not ask, and hand-off tricks that set stage and leave the op to a later job.
  • Entry: a job enters its role's machine at the state for the issue's stage. A machine covers all its role's stages, so the table shows how they connect. Each stage boundary in a table is one of two ends:

    • saved(stage): saves the issue at the stage and ends the job. The orchestrator schedules the next job;
    • next(stage) (implementor-one-job): saves the issue at the stage and carries on at that stage's entry in the same job, with the same checkout and lease. Only the implementor uses it, for fix → sync → checks (proposed: fix → checks, since a sync runs only at an entry's start, sync-at-edges).

    Saving at every boundary keeps the issue's stage current while the job runs, so the orchestrator sees it and a restart begins at the right stage.

  • Loops: every cycle in the table passes through an edge guarded by a count in data with its limit from the config. A test checks this over the table.

  • Hand-off: the machine's data is rebuilt by locate at each start. The issue's state gets only the hand-off fields the index's hand-off table lists and the role's checkpoints. Today's cross-stage flags (rebasedOnly, resynced, held) become hand-off fields or go away.

  • Runner: it runs locate, then the current state's effect, passes the result to transition, and reports each step in job.json. Built (#304): the job's Entry.context (RoleContext in src/roles/walk.ts: the issue, the repo config, the budgets and models, the claim's paths and the run's start time) is passed to locate with the saved state, so a role stays pure and loads nothing itself. Rejected: a role that loads its own config, which puts I/O in a pure role.

  • Budget: a session's budget bounds its context, not the issue's spend; the runner charges nothing before a session effect (budgets-bound-context, Axlog, 2026-10-16). Only the orchestrator's watchdog stops a writer whose context passes its budget, with stopped(budget).

  • Failures the runner owns: a network error ends held; any other failed effect the table doesn't list ends stopped with the step-failure code. Tables list only the failures a role handles itself.

  • Prelude and teardown: each machine starts with claim then checkout and ends with teardown (save, release the lease, push anything pipeline_commit failed to push, remove the checkout). The tables show them once.

  • Linear roles: triage and the finalizer run their steps in order on the same runner, with a locate step and no table. Triage is built (#304, src/roles/triage/). Built (#304): a linear step may give recover(data, error), which turns that step's failed effect into data and lets the run go on, as triage's start-pr only logs a failed draft PR. Rejected: a runner-wide "ignore failure" flag, which can't say what the failure changes.

Restart from what is done (proposed: every-role-resumes)

Every role, machine or linear script, and the orchestrator, can be interrupted at any point and simply started again. There is no --resume and no resume mode: every start works out what is already done and carries on after it. Some lost or repeated work is accepted where avoiding it would make the design less simple (Axlog, 2026-10-16).

  • Built today, for stops: a lifted agent:stopped-* label resumes at the stage that stopped, carrying the saved state with every limit reset, so no finished stage runs again (Axlog, 2026-10-16). A specced issue goes straight to implement. A stage after review stands only on a review of the branch's head, or of changes with the same patch id (review-keeps-on-clean-rebase), and otherwise goes back to review. A saved verifiedSha and the review's findings carry over with it. A stop with no stopped stage saved, or one that answered questions lift, restarts as before.
  • Interruptions covered: SIGTERM or SIGINT; SIGKILL, a crash or a reboot, with no cleanup; a lost network; the orchestrator restarting; a pi session dying.
  • Only durable state counts: the issue's saved state, GitHub (the issue, its comments and labels, the PR), and the code and wiki remotes. The checkout, the pi session files and job.json are disposable. job.json reports what a job did, for people and the orchestrator, and no start reads it to decide anything.
  • Every start is the same: claim, check out fresh from the pushed heads, then locate. Locate is a script in each role that reads the durable state and picks the first state or step with work left. For the machines, it is mostly the entry's no-work check (see no-work-skips-stages): the checks that skip a stage with nothing to do also skip work already done. Each role spec lists its own locate rules.
  • Sessions start fresh. An interrupted session isn't resumed. It runs again from its task, on a checkout that already holds every commit it pushed, since pipeline_commit pushes each one. Its task says that earlier commits on the branch may be its own. Lost: uncommitted edits, and the session's unfinished reasoning.
  • Every effect is safe to run twice. Each op that changes something outside the job checks before it acts, on something visible on GitHub or in git:
    • each signed comment carries a hidden marker, <!-- issue-pipeline:<issue>:<kind> -->, and is created or updated in place by that marker. A rerun replaces the comment rather than adding a second one. Built (#304) for queued comments: a comment in an ops request's envelope may give a key, and commentByKey (src/comments.ts) edits the pipeline account's comment with that key's marker, or posts one when there is none; it never edits another account's comment. Triage's post uses it. Rejected: a new upsert op, since a key on the envelope lets any op's queued comment be keyed. Not yet keyed: the comments ask and stop post themselves;
    • labels are set as a set, not toggled;
    • start-pr finds the branch's PR before opening one, and, given a body, sets that PR's body rather than opening a second (#332); marking a PR ready is a no-op when it already is;
    • pushes use --force-with-lease, and a push whose target the remote already has does nothing;
    • sub-issues carry the parent's number and their index in a marker, and split opens only the missing ones;
    • merge, close-pr and closing an issue check the PR's or issue's state first;
    • publish checks whether the wiki's base already holds the issue's wiki commits.
  • Checkpoints: a value a later step needs, which can't be read back from GitHub or git, is saved in the issue's state as soon as it is known. Each role lists its own. Examples: the spec writer's start heads, and the counts that bound loops (attempts, fixRounds, ciFixRounds, specReturns). The counts are saved when they go up, so a crash restart never resets a loop's limit. A restart after a lifted stop keeps only the start heads and clears every count, as it resets every limit (#392).
  • The lease: a stopped job releases its lease. A killed one leaves it to lapse, and the next claim takes a lapsed lease and carries on at the saved stage, as claim does today.
  • Graceful stop: SIGTERM or SIGINT aborts the running session or op, saves the issue at its current stage with no lease, and exits 128 plus the signal's number, as today. An op cut off half-way is safe to run again.
  • Teardown on every exit, as today: it saves uncommitted changes as patches in the dropped/ folder of the issue's run directory, records them (lostChanges, droppedPatches) and drops them, pushes what pipeline_commit failed to push, and removes the checkout. Nothing applies a patch (dropped-changes-saved, #369; see Dropped changes). A killed job skips teardown, and the next start's fresh checkout replaces the old one.
  • Crash loops: the orchestrator counts restarts per issue and stage (today's maxResumes, proposed renamed maxRestarts), and stops the issue with agent:stopped-job past the limit.
  • Lost and repeated work, accepted:
    • an interrupted session reruns from its task;
    • an interrupted rebase restarts sync from the pushed head, and the resolver resolves its conflicts again (proposed: the resumed implementor entry runs its start sync again, sync-at-edges);
    • review lenses that had replied run again;
    • a CI or verify run starts over.
  • The orchestrator: a tick is a pure function of a fresh snapshot, so an interrupted tick just runs again. Its start records (already kept for the gap before a job writes job.json) stop it starting a job twice.

Usage log (proposed: log-usage-at-finish)

Every session's tokens and context are logged when it finishes, so the points where context bloats can be found. The log feeds context-bloat-pass, and nothing in the pipeline reads it to decide anything.

Built: every stage job writes a session record per session and a job record at its end. A record the serializer refuses is reported in the job's job.log. Gaps closed and remaining on the Token spend page lists what the records still miss.

  • Where: one usage comment per issue, signed by the pipeline account and edited in place, found by its marker <!-- issue-pipeline:<issue>:usage --> (Axlog, 2026-10-16: public is fine, with nothing sensitive or personal in it). The records sit inside an HTML comment as JSON lines, under one visible line: "Issue pipeline usage records (hidden; see the wiki)". Editing doesn't notify the issue's subscribers, and the log lives on GitHub, so it works from any machine.
  • Size: a comment holds 65,536 characters, roughly 100 to 200 records. When the next record wouldn't fit, the runner starts a new comment with the marker usage-2, then usage-3, and so on.
  • Writes: the runner sends each record line to the ops usage op, since only the ops CLI runs gh; the op reads the comment, appends the line and edits it, or posts the next comment when it is full. Only the job holding the issue's lease writes, and the runner writes one record at a time, so writes never race. A failed write is retried once, then logged and dropped: usage is never worth stopping an issue.
  • When: the runner appends one session record whenever a session ends: a reply, a handoff, a context steer or stop, an error, or an abort on SIGTERM. A killed job writes nothing for its unfinished sessions. Their tokens still count in the issue's budget, and those records are accepted as lost.
  • Public, so nothing sensitive or personal: usageRecord builds every record from an allow-list of fields, and:
    • holds only numbers, booleans, timestamps and names from fixed sets: roles, stages, machine states, session names, agents, model ids, tool names, the end kinds and reply decisions;
    • never holds the text of prompts, tasks, replies, summaries, tool calls or tool output, nor error messages, which can quote code, paths or secrets;
    • names input files only by their path in the repo or wiki, or by their run-dir file name (issue.md, spec.md), never by an absolute path, so no user name or machine layout appears;
    • holds no host name, user name, process id, lane or pi session id. The job id is a random id. The serializer refuses a string that matches none of these, and refuses -->, so a record can't break out of the HTML comment. State, session, agent and tool names must match ^[A-Za-z0-9][\w.:-]{0,99}$, and a model id may add one / and a second such name, as in anthropic/claude-opus-5-5. A tool name that doesn't match is counted under other.
  • A session record:
{
  kind: "session";
  at: string;              // when it finished
  repo: string; issue: number; job: string; // job: a random id
  jobStartedAt?: string;   // the job's start, as the Pipeline-Session trailer names it
  key?: string;            // the session's launch key, as the trailer names it
  role: string;            // "spec-writer", "implementor", ...
  stage: string;           // the issue's stage when the session ran
  state: string;           // the machine state or linear step, e.g. "implementer", "rereview"
  name: string;            // the session's name, e.g. a lens's
  agent: string; model: string;
  attempt: number;         // retry, continuation or round number, from 1
  ended: "reply" | "handoff" | "context-stop" | "error" | "aborted";
  reply: string | null;    // the reply's decision or verdict, e.g. "done", "blocked", "pass"
  seconds: number;
  turns: number;           // assistant messages
  tokens: { input: number; output: number; cacheRead: number; cacheWrite: number;
            cacheWrite1h?: number; cost?: number }; // 1h writes are part of cacheWrite; cost in US dollars
  context: {
    first: number;         // the first turn's context: what the session loads before doing anything
    peak: number;
    atFinish: number;      // the last turn's context
    soft: number; hard: number; // the limits
    window: number | null;  // the model's window; null until a module knows it
    steered: boolean;      // passed `soft`
  };
  task: { chars: number; files: Record<string, number> }; // the task's size, and each input file's, in bytes, keyed by repo path or run-dir name; `files` is {} until roles name their input files
  tools: Record<string, { calls: number; outputChars: number }>; // per tool, from tool_execution_end
  largestToolOutput: { tool: string; chars: number } | null;
}
  • A job record, appended to the same comment when a job ends, and to jobs.jsonl in the issue's run folder: the issue, the job id and start, the role, the stages it went through, its outcome, its seconds, the sum of its sessions' tokens and cost, and the issue's token total so far (issueTokens). Records written before key, jobStartedAt, cacheWrite1h and cost existed lack them, so those fields are optional.
  • Context at a turn is measured as contextLimits measures it today (context-watch.ts): one assistant message's input, cache-read and cache-write tokens.

Finding bloat: node report.mjs usage --config <path> [--since <date>] [--role <role>] [--issue <n>] lists the repo's issue comments changed since the date (GET /repos/{owner}/{repo}/issues/comments?since=, 100 per page), keeps the pipeline account's comments with a usage marker, parses their records, drops those whose at is before --since, and prints, by script:

  • per role, state and model: count, and the median, 90th percentile and maximum of context.first, context.peak and total tokens, by nearest rank (the value at index ⌈p·n⌉−1 of the sorted values);
  • sessions flagged, each with its reason:
    • context.first above usageFlags.firstContext (default 40,000): the task, the prompts or the input files are heavy before any work;
    • context.peak past soft;
    • largestToolOutput above usageFlags.toolOutputShare (default 0.25) of context.peak: one read or command output dominates;
    • an issue and state whose context.first grows across attempts, continuations or rounds: earlier rounds are being carried forward;
  • the five tools with the most output, per role.

The flag thresholds are in the config under usageFlags. They only label the report: they stop nothing.

Wiki provisioning

  • Each role declares its wiki access per stage in its own spec. Built (#303): the access sits in one table, STAGE_WIKI_ACCESS in src/job/roles.ts, beside STAGE_ROLES and the rest of the job boundary, rather than in each role's module; spec, merge and publish are write, every other stage read. From 2026-10-17 a bridge kept implement, fix and checks write too, until the spec writer committed the spec's wiki updates; #334 removed it, so only the spec writer and the merger stages write the wiki:
    • every stage has at least read (every-role-reads-wiki, Axlog 2026-10-16; built in #338, where STAGE_WIKI_ACCESS is typed read | write), the orchestrator's sessions inside stage jobs included. none survives only as what a repo with no wiki, or a wiki mirror that can't be cloned, gets: no docs/;
    • read: a checkout at the wiki's base branch (base) or at the issue's wiki branch (issue's branch), with no commit tool for it;
    • write: a checkout at the issue's wiki branch, agent/issue-N in the wiki, or at the wiki's base when that branch doesn't exist yet. pipeline_commit commits and pushes it.
  • The role's lifecycle provisions the checkout before its first session: it fetches the wiki mirror under the wiki lock and clones it into <job dir>/docs, beside the code checkout <job dir>/repo (separate-checkouts, Axlog 2026-10-16; built in #338). The job dir work/<slug>/jobs/issue-N-<stage> is not a git repo itself, the wiki is no longer nested in the code checkout, and the code clone's info/exclude gets no wiki line. When sandboxing comes, the job dir is what is mounted at /app. Sessions run in the job dir and are told what repo/ and docs/ are and what they may do with docs/. Lanes keep their wiki at <worktree>/<wiki dir>.
  • The layout is decided once, in paths (src/config.ts): jobRepo and jobDocs name the two folders (from src/job/layout.ts, which the sandboxed workflow bundle can import), and, in a stage job, paths.wiki is the code checkout's sibling docs, so the claim, the push, exit-push and wikiAhead find docs/ with the ops request's checkout unchanged. Rejected (#338): a wiki field on that checkout, which would widen the ops request and let each caller build the layout.
  • Each stage also has a code access (codeAccessOf in src/job/roles.ts, #338): write for writer stages, read for reader and merger stages. --print-boundary shows both as access: { repo, docs }.
  • A repo whose wiki doesn't exist yet gets no wiki checkout, whatever the role declares.
  • A role with read access that edits a wiki file has the edit dropped in teardown and recorded in lostChanges, like any uncommitted change.
  • The wiki's agent/issue-N on GitHub replaces the wiki mirror's local agent/issue-N as where an issue's wiki work lives between jobs. saveWiki is kept only for commits pipeline_commit failed to push.

pipeline_commit

  • It is a pi tool, provided by a pipeline extension (-e pipeline-commit.mjs) to the sessions that commit: the spec writer (stubs and tests, spec-writes-failing-tests), the implementor's sessions, and the merger's wiki conflict resolver (proposed: part of the spec writer). It is how a session commits, and every successful commit is pushed.
  • Paths a session may commit, set by its role and checked before git runs, except the spec writer's:
    • the spec writer: test files (testPathGlobs), the stub files of its new interfaces, and the wiki (spec-writes-wiki). A stub can be any source file, so pipeline_commit lets it commit any path, and spec-tests-check enforces the test-file and stub rules after the session (#334); a broken rule retries the session;
    • the implementor's sessions: any code or test file, but no wiki file (implementor-reads-wiki);
    • the merger's wiki conflict resolver: the wiki only. Proposed (merge-only-when-ready): it moves to the spec writer, whose wiki access already covers it.
    • A refused path fails the call before any commit, naming the path.
  • Parameters: { args: string[] }, the arguments git commit would take, such as ["-am", "Add the parser"] or ["-m", "Fix", "--", "src/a.ts"].
  • Paths relative to the job dir (built in #338): pathspecs start with repo/ (the code checkout) or docs/ (the wiki checkout), which is stripped before git sees them; repo or docs alone means the whole checkout. Any other pathspec is refused, naming it. A relative -F/--file path is read from the job dir in both checkouts' commits.
  • Write access is checked first (commit-needs-write-access, Axlog 2026-10-16; built in #338): a pathspec into a checkout the stage doesn't write is refused before any git command runs, with an error naming the path, the checkout and the access, such as docs/Page.md: this stage has read access to the wiki checkout (docs/); it may not commit there. A docs/ path in a job with no wiki checkout is refused too. A refused call stages nothing, commits nothing and pushes nothing, rather than leaving the edit for teardown to drop. A call with pathspecs commits only the checkouts they name; with none, it commits every checkout the stage may write. Access is the stage's declared access, since one GitHub account does all the pipeline's work.
  • Code repo: it runs git commit <args> in the job's code checkout, repo/, so staging, -a, pathspecs, -m/-F and empty-commit refusal behave as git commit does.
  • Wiki, transparently, when the session's wiki access is write:
    • docs/ pathspecs are routed to the wiki checkout, and repo/ ones to the code repo.
    • With no wiki pathspec, every change in the wiki checkout is committed: modified, deleted and new pages (git add -A), with the same message.
    • With a wiki pathspec, only the named wiki paths are committed.
    • The session never runs git in the wiki, and needs no flag for it.
  • What counts as a commit: the call succeeds when at least one repo made a commit. A wiki-only commit succeeds even though the code repo had nothing to commit. With nothing to commit in either repo, it fails as git commit does ("nothing to commit"). A git error in either repo fails the call, and neither repo is pushed.
  • Refused arguments, which fail before git runs:
    • --amend, because the commit it would replace was already pushed;
    • --no-verify/-n;
    • --author, --date and --reset-author, because commits keep the pipeline's identity;
    • --dry-run passes through as git's own dry run and pushes nothing.
  • Push: after the commits, the tool asks the job over its control socket to push. The session holds no token and never pushes itself.
    • The job pushes the code branch to origin agent/issue-N with --force-with-lease against the head it last pushed.
    • It pushes the wiki's agent/issue-N under the wiki lock.
    • Both use the writer's token and ISSUE_PIPELINE_OPS=1, so the pre-push hook lets them through.
    • This is the same push the push op does, through the same function (push in src/checkouts.ts).
    • Built (#303): the job serves the request in-process (pushServerOf in src/job/commit-push.ts, one request at a time), through pushHead, which the push op shares, with the ops' credentials; a code push is recorded as pipeline-commit in pushed.json, which a claim adopts where it adopts exit-push.
  • During a rebase (the conflict resolver in sync, proposed in the start sync of an implementor entry, sync-at-edges; and the wiki conflict resolver, proposed in the spec writer):
    • the commit records the resolution and pushes nothing, because the branch head is detached;
    • the role's lifecycle runs rebase --continue, gives the resolver the next conflict if there is one, and pushes once in finish-sync (or publish) after the rebase checks pass;
    • the tool's reply says the push was deferred.
    • Built (#303): the job also defers every push in the sync, merge and publish stages, where finish-sync, finish-wiki and publish push after their checks. Until a lifecycle runs rebase --continue, the resolver still runs git add and GIT_EDITOR=true git rebase --continue itself, the one exception to "never run rebase" below; the sequencer's commits skip the pre-commit hook.
  • Reply to the session:
    • { code: { exitCode, stdout, stderr, sha | null }, wiki: { exitCode, stdout, stderr, sha | null } | null, pushed: "both" | "code" | "wiki" | "none" | "deferred", pushError: string | null };
    • exitCode and the output are git's own;
    • wiki is null for a session with no write wiki access.
  • A failed push keeps the commits, returns pushError and doesn't fail the call. The next pipeline_commit pushes everything still unpushed. Teardown pushes once more, and only then records the commits as unpushed.
  • Each push is recorded in job.json, under the launch that made it: the sha pushed, the repo and the time. The flows (codeChanged, wikiAhead) are worked out after the sessions from git, not from the tool's replies.
  • CI cost: pushing every commit runs CI more often than pushing once per stage, as start-pr's push does today for the draft. Proposed (spec-writer-opens-pr): the spec writer opens the draft only at ready, so CI triggered by pull-request events doesn't run on its earlier pushes. Every push moves the head, so any verdict given on an older head no longer holds (verifiedSha, passedSha).

Limiting git to pipeline_commit

  • The prompts: writer prompts say to commit only through pipeline_commit, and that the session must not run git commit, push, rebase, reset, checkout or merge itself. Built (#303) for stage jobs' implementer, fixer, CI fixer and conflict resolver, with the resolver's rebase --continue exception; lanes keep plain git.
  • A pre-commit hook: it refuses commits except from pipeline_commit (ISSUE_PIPELINE_COMMIT=1), in the code checkout and the wiki checkout both. The pre-push hook stays. Built (#303) in job checkouts (guardCommits in src/clone-setup.ts, called from cloneInto); lanes' clones get none.
  • No token in sessions: writer sessions no longer get GH_TOKEN. That changes sandbox-ready-jobs: only jobs, never sessions, hold a token. Built (#303).
  • What is still possible: with bash, a session can still set the variable, run git itself, or push with Axlog's keyring login. Like today, the hooks only stop accidents until the sandbox comes. See the first open question.

Done looks like

  • A job's job.json shows the steps it ran, and every op among them is one its role's lifecycle names.
  • No session's environment holds a gh token.
  • Every commit a writer session made is on GitHub when the session ends. Only a push failure leaves one behind, and teardown retries it.
  • A wiki change made by a writer is on the wiki's agent/issue-N with no step but pipeline_commit.
  • A role with none or read wiki access never pushes the wiki.
  • The ops' replies and GitHub effects are unchanged, and their tests pass unchanged.

Test interfaces

Interface Kind Where Status
each stage's wiki access, STAGE_WIKI_ACCESS data src/job/roles.ts, beside STAGE_ROLES (moved from the role modules, #303) built
a machine's TABLE and transition(state, result) data and pure function new, one module per machine role (src/roles/<role>/machine.ts) built (shared types in src/roles/machine.ts; each role's table comes with its part)
the runner: runMachine(machine, entry, effects) function, with effects injectable new, src/job/runner.ts built
commitArgsOf(args, scope) → { code, wiki, dryRun } argv, or the refusal; scope is { wikiDir, wikiAccess, allowed } pure function new, src/job/pipeline-commit.ts built (#303)
pipeline_commit tool pi extension tool new, src/job/pipeline-commit-tool.ts, built as pipeline-commit.mjs built (#303)
the job's push request over the control socket socket message src/job/control.ts, with the messages in src/job/control-protocol.ts and the handler in src/job/commit-push.ts built (#303)
a session's reply check over the control socket, which structured_output asks before it ends the session socket message src/job/control.ts and src/job/control-protocol.ts, with the checks in src/job/reply-checks.ts; a role's session effect names the check (replyCheck) built for the spec writer (spec-checks-in-session)
job.json steps and pushes file src/job/status.ts built (pushes #303)
the usage comment, and usageRecord(launch, context) building a record from a finished launch GitHub comment and pure function new, src/job/usage-log.ts, with the comment in src/job/usage-comment.ts and the write in the ops usage op (src/ops/usage.ts) built
report.mjs usage CLI over the usage comments new, src/report.ts and src/report/usage.ts built
each role's locate(durable) → state pure function over the issue's state, GitHub and the remotes' heads new, in each role's module built for triage (src/roles/triage/locate.ts, #304); proposed for the others
each op's check before it acts ops replies src/ops/* built for keyed post comments (commentByKey, #304); proposed for the others
ops replies CLI and functions ops.mjs, src/ops/* today, unchanged

Test plan

Unit tests on the pure functions, and integration tests that use local bare repos as the code and wiki remotes, with no GitHub.

Behaviour Test Interface
Only the spec writer and the finalizer have write wiki access (spec-writes-wiki), and only the spec writer's sessions commit to the wiki table check over each role's wiki access role modules
A session's commit outside its role's paths is refused before git runs, naming the path unit commitArgsOf
--amend, --no-verify, -n, --author, --date and --reset-author are refused before git runs unit commitArgsOf
docs/ pathspecs go to the wiki and repo/ ones to the code repo, without the prefix; any other path is refused; a relative -F is read from the job dir (#338) unit commitArgsOf
A path in a checkout the stage only reads is refused before git runs, naming the checkout and the access, and changes nothing (#338) unit, and integration with bare remotes commitArgsOf, pipeline_commit
A job dir holds repo/ and docs/ side by side for a reader and a writer stage, a triage job gets docs/ and its edits there are lost as docs/…, and a docs/-only commit is exit-pushed (#338) integration, fake pi job.mjs, job.json
A code change and a new wiki page in one call make two commits with one message, and both are pushed integration, bare remotes pipeline_commit
A wiki-only change succeeds and pushes only the wiki integration pipeline_commit
Nothing to commit anywhere fails with git's exit code and message integration pipeline_commit
A session with read wiki access commits code only, and its wiki edits are dropped and listed in lostChanges integration pipeline_commit, job.json
A push the remote refuses keeps the commit, returns pushError, and the next call pushes both commits integration, refusing remote pipeline_commit
During a rebase the commit records the resolution, pushes nothing and replies deferred; finish-sync pushes integration pipeline_commit, finish-sync
git commit run directly in a writer's checkout is refused by the hook integration hook
No session's environment holds GH_TOKEN or gh's token variables unit agentEnvOf in src/job/roles.ts
The ops' existing tests pass unchanged existing suites ops
transition returns only edges in TABLE, and throws on a result the state doesn't accept property test over each machine TABLE, transition
Only session states return a session effect table check over each machine TABLE
Every cycle passes through an edge guarded by a count with a limit graph check over each TABLE TABLE
Every state is reachable from an entry, and every non-end state reaches an end graph check TABLE
Only hand-off fields and the role's listed checkpoints reach the issue's state unit runner
Kill anywhere: for each role, kill the run before and after every effect in turn, start it again, and compare the end with an uninterrupted run: the same issue state and GitHub effects, with no duplicate comment, PR, sub-issue or label change. Sessions may run more than once property test, runner with injected effects and the fake gh every role, through locate and the runner
Every op that changes GitHub or a remote, run twice, changes nothing the second time ops CLI, each op twice ops-test-kit.mjs
A start ignores job.json, the old checkout and old session files: it checks out fresh and locates from durable state only integration, SIGKILL runner
A killed job's lapsed lease is taken by the next claim, which carries on at the saved stage ops CLI claim
A count that bounds a loop is saved when it goes up, so a killed and restarted job keeps its limit unit runner, checkpoints
An orchestrator killed mid-tick and restarted starts no job twice recorded snapshots tick, start records
A network error ends held, and an unlisted failure ends stopped with the step-failure code unit with injected effects runner
A session that ends in each way (reply, handoff, context stop, error, SIGTERM) appends exactly one session record with its role, state, attempt, tokens and context unit with injected effects runner, usageRecord
context.first, peak and atFinish come from the first, highest and last turns' usage unit, fake RPC events usageRecord
Per-tool output chars and the largest output are counted from tool_execution_end events unit, fake RPC events usageRecord
Records go into the issue's usage comment, edited in place by its marker; a full comment rolls over to usage-2 integration, fake gh runner, usage log
A failed usage write is retried once, then dropped without stopping the issue integration, fake gh runner, usage log
No record holds prompt, task, reply, summary, error or tool text, an absolute path, a host or user name, a pid, a lane or a pi session id unit, property usageRecord, serializer
The serializer refuses a string outside the allowed sets, and --> unit serializer
The report finds usage comments across issues with one paged since listing and ignores other accounts' comments unit, fake gh report.mjs usage
The report's percentiles and each flag, on a fixture log unit report.mjs usage
next(stage) saves the issue's state before entering the next stage, in the same checkout unit with injected effects runner

Open questions

  • Bash for writers: should writer sessions keep bash (proposed for now, with hooks as accident guards), or lose it for a run tool limited to the config's commands (test, lint, typecheck)? Losing it enforces "only pipeline_commit", but stops the implementer from running anything else, such as codegen or installing a package.
  • Wiki without pathspecs: -a in git doesn't add untracked files. Should a wiki commit with no wiki pathspec add new pages anyway (proposed), or follow the code repo's staging exactly?
  • Message: should the wiki commit's message say which code commit it went with (for example "(with abc1234)"), so the PR body and finish-wiki can pair them?
  • Retiring lanes: is retiring lanes and workflow.js with orchestrator-cutover acceptable? It is needed if the lifecycle replaces the ops-as-launches protocol.
  • Re-review inside a fix round: should it stay as a session inside the implementor's fix lifecycle? Or should fix end at the fixer's push, with re-review as its own reviewer stage (rereview) in its own job? A separate stage keeps one role per job, at the cost of a job start per round.

Clone this wiki locally