Running a team from a pinned release
A team normally runs from the checkout of fabrikito it was started from. Its seats call that
checkout's fabrikito for say, run its hooks, load its plugin, and keep their state under its
.state/<team>/. So every change that lands in that checkout reaches the running seats at once,
mid-task. To keep a team on a known version while this repository keeps changing, run it from a
checkout of its own, fixed at a release, and upgrade it only when you choose to (#189).
The placeholders below stand for your own paths and names:
| Placeholder | What it is |
|---|---|
<install> |
Your main checkout of fabrikito. |
<release> |
A tag or a commit of fabrikito, for example v0.1.0, the first release tag. Release tags are annotated, and a tag's message holds that release's notes, including any change to the state layout. Creating and pushing a tag is the owner's step. A commit works the same way, and check names a lightweight tag as well as an annotated one. |
<releases> |
A folder outside <install> that holds one checkout per release, for example <releases>/fabrikito-<release>. |
<state> |
The team's state folder, outside every checkout, on the same volume as <install>. |
<team> |
The team's name (team: in the team file). |
<name> |
The project's folder under projects/, the one the team file's prompt paths name. |
What comes from where
Every path a seat uses comes from the checkout whose fabrikito ran up for it, and stays fixed
until that seat restarts:
| What | Where it comes from |
|---|---|
say |
The seat's TEAM_SCRIPT (set in its tmux session) and the fabrikito path in its prompt. Both name that checkout's fabrikito. |
| Hooks | The seat's settings name that checkout's lib/session_start.sh, lib/log_message.sh and lib/stop_gate.sh. |
| Plugin | The seat's launch script passes --plugin-dir with that checkout's plugin/. |
| Prompts | Rendered at up from that checkout's roles/ and the team file's prompts. |
| State | The team file's state:, here <state>, so every release shares one state folder: progress notes, the message log, triage records, the gate's counters and the team's copies of claude. Without state:, that checkout's .state/<team>, which can be a symlink to <state> (the older way, below). |
./fabrikito check prints the checkout it runs from and its git describe, for example
install : <releases>/fabrikito-<release> at <release>. A checkout with uncommitted changes ends
in -dirty.
Name the state folder in the team file, state: <state> (#264). up, check, say,
the hooks and every seat read it from there, so no link is needed and every release of the team file
points at one folder. Do not use TEAM_STATE_DIR for a pinned team: it is for throwaway teams and
tests, and wins over the key. (up gives the seat's session the folder it used only when it had
TEAM_STATE_DIR in its own environment.) check prints the state folder and where it came from, and
warns when the default <checkout>/.state/<team> also exists and leads somewhere else: the key moves
nothing, so a team that adds it to a running team's state moves the old folder there first. Running
seats keep their old settings until they restart. Stop them before the folder moves: once the team's
state folder is another one, down and the other commands that reach a seat stop at a seat started
with the old one, until it is handed over with the command the refusal prints
(sessions).
Set up a release checkout
- Make the checkout from your main checkout:
git -C <install> fetch --tags
git -C <install> worktree add --detach <releases>/fabrikito-<release> <release>
- Copy the team file and the project's prompts into it. Both are git-ignored, so the checkout starts without them:
cp <install>/teams/<team>.yaml <releases>/fabrikito-<release>/teams/
mkdir -p <releases>/fabrikito-<release>/projects
cp -pR <install>/projects/<name> <releases>/fabrikito-<release>/projects/
Make projects/ first: a new checkout has no projects/ folder, so without it cp makes
projects/ itself the copy of <name>. The prompts then land at projects/project.md, not
projects/<name>/project.md, and check fails with prompt file not found
(#218). teams/ needs no mkdir, because it is tracked (it holds
example.yaml).
Keep the prompt paths in the team file relative to it (../roles/common.md, ../projects/<name>/...),
so each checkout uses its own copy. An absolute path keeps pointing at whichever checkout it names.
The team file must sit in that checkout's teams/, or up treats the team as a throwaway one
(Throwaway teams).
- Point the checkout at the shared state folder. Add
state: <state>to the team file (once; each copy of the file carries it), and no link is needed. A team that runs withoutstate:links the folder in instead (optional, the older way, whichstate:replaces):
mkdir -p <releases>/fabrikito-<release>/.state
ln -s <state> <releases>/fabrikito-<release>/.state/<team>
From then on, run every command for this team with that checkout's fabrikito and team file:
<releases>/fabrikito-<release>/fabrikito -c <releases>/fabrikito-<release>/teams/<team>.yaml <command>.
A seat protects only the checkout it runs from. A release checkout is a worktree of your main checkout,
so another team on this machine, one working on fabrikito above all, could edit it, or remove
or prune it with git, and change the code the pinned team runs at its next start. Add a deny for the
releases folder to every other team file's permissions. For a releases folder at /path/to/releases,
that is deny: ["Edit(//path/to/releases/**)"] (an absolute path takes // in a rule). Or keep it
where those teams never work.
The releases-folder deny goes in the other teams' files only, never in the pinned team's own. A seat's
prompt names its progress note by its real path, so with state: outside the releases folder the note
is not under the deny. Reached through a state link inside the releases folder, a note's other spelling,
<releases>/fabrikito-<release>/.state/<team>/notes/<role>.md, is covered by it: in the pinned team's
file the deny covers that path, and a deny wins over the allow the seat's settings give its note
(#219, #264). If a deny does cover a seat's real note path, check
warns, naming the seat and the rule. Give each pinned team a releases folder of its own: two pinned teams
sharing one would each deny it in the other's file, and so block each other's notes.
The shared <state> folder, which holds the pinned team's notes, log and records, needs the same care
in both directions, and fabrikito finds neither direction itself. It finds another team's state only
as a real folder beside its own: <state> is not beside the other teams' folders, and the pinned
team's checkout holds only a link to it, which is never followed. So list them under other_teams (see Safety), in both team files:
- in every other team's file, the pinned team's
<state>:other_teams: [{ state: /path/to/state }]; - in the pinned team's file, each other team's state folder, for example
other_teams: [{ state: <install>/.state/<other team> }].
A listed folder that doesn't exist yet, or is still empty because that team hasn't run up, is
accepted, and check says so. If the pinned team works on fabrikito itself, so that its
repo.path is <install>, it may still list <install>/.state/<other team> although that is inside
its repository: a team's state folder whose parent is a real folder named .state is the one thing
inside its own work that a team may list. That holds only once the other team has run up: until then
the folder is missing or empty, and such a folder inside the repository is refused.
Each seat then gets a Read deny on the listed team's triage records, message log and notes, and an Edit
deny on the whole folder, from its next start. Add observers: [<role>] to an entry for a role the
owner allows to read that team's log and notes. Run check with each team file to see the denies.
Like the other rules, these are accident guards: a shell command that reads or writes there another
way is not stopped.
Move an existing team to a release (once)
The team's state is moved, not copied, so nothing is left behind:
- Ask every seat to write its handoff (the
fabrikito:handoffskill), and wait until each has. - Note what to check afterwards: the number of files in
<install>/.state/<team>/notes/, the last line of<install>/.state/<team>/messages.log, and the number of records intriage/, if there is one. - Stop the team from the main checkout:
<install>/fabrikito -c <install>/teams/<team>.yaml down --yes. - Move the state folder:
mv <install>/.state/<team> <state>.<state>must not exist yet: if it does,mvputs the folder inside it, at<state>/<team>, and the link would then point one level too high, soupwould start with no notes and no log. Check right after the move that<state>/notes/and<state>/messages.logexist. On one volume, the move is a rename, and nothing is copied or lost. To another volume,mvcopies and then deletes: do it only with every seat down, and check the counts before you rely on it. - Set up the release checkout as above, steps 1 to 3.
- Move the team file out of the main checkout's
teams/, so the main checkout cannot start this team by mistake. - Check that nothing was lost: the same number of notes in
<state>/notes/, the same last log line, and the same triage records. - Run
checkfrom the release checkout, thenup.upwrites new settings, prompts and launch scripts, and a new copy of claude if needed. The notes, the log and the records carry over, and each seat's prompt names its note through the link.
Upgrade to a newer release (rolling)
- Set up a checkout for the new release, with
<new>for<release>: steps 1 to 3 of Set up a release checkout, themkdirofprojects/included, linked to the same<state>. - For each seat in turn, once it has written its handoff:
<releases>/fabrikito-<new>/fabrikito -c <releases>/fabrikito-<new>/teams/<team>.yaml down --yes <role>
<releases>/fabrikito-<new>/fabrikito -c <releases>/fabrikito-<new>/teams/<team>.yaml up <role>
- When no seat runs the old release, remove its checkout (below).
A seat not yet restarted keeps running the code it started with: its say, hooks and plugin all name
the old checkout, which the upgrade does not change. So keep the old checkout in place until its last
seat has restarted. To see which release a seat runs:
tmux show-environment -t '=<prefix>-<role>' TEAM_SCRIPT.
During the overlap, old and new seats share the state folder: the message log, the notes, the locks
say takes and the claude copies. They can message each other, and both write the same log. The
commands of one copy of the team file reach the seats the other copy started, because a session is the
team's by the state folder and the repository up stored in it, not by the path of the team file
(sessions). That is safe only while the two releases agree on two things:
- The state layout. If the new release's notes (its tag's message:
git -C <install> tag -n99 -l <new>; for 0.3.0 also Release notes: 0.3.0, step 3) say it changed what is kept in the state folder, or how, upgrade with a full restart instead. - The team file. Each checkout has its own copy of the team file, and an old seat's
sayreads the old copy. A changed team file (a role added or removed, a changed prefix or session name, a changed roster) needs a full restart, not a rolling one.
A full restart: handoffs, down --yes from the old checkout, then up from the new one.
A pinned team gets a new team-file key by moving to a release that has it. check on an older release
rejects the key as unknown, roles.<id>.tmpdir (Temp files) for one. So add it
only to the new checkout's copy of the team file. That key changes no roster and an old seat never reads it
from the new copy, so a rolling upgrade is enough, and each seat picks it up when it restarts.
Remove an old release checkout
Once no seat runs it, remove the state link and the team file by their exact paths, then the checkout:
rm <releases>/fabrikito-<old>/.state/<team>
rmdir <releases>/fabrikito-<old>/.state
rm <releases>/fabrikito-<old>/teams/<team>.yaml
git -C <install> worktree remove <releases>/fabrikito-<old>
Removing the link deletes the link, never the shared state it points to. (In the live check on #189,
git worktree remove, with or without --force, also deleted only the link when it was left in
place. Remove it by its path anyway, so nothing relies on that.) Git-ignored files do not stop
git worktree remove, which deletes them with the checkout, the copied project prompts included.
Updating Claude Code is unchanged: see Updating Claude Code. The copies of claude live in the shared state folder, so every release uses them.