Docs · Engineering reference
Parallel worktrees for agents
docs/worktrees.mdEdit on GitHub
How up to ~20 agents work on the Life Framework at once without ever breaking
your world-editor session on main, and how their finished work auto-merges.
The model
| Checkout | Branch | Purpose |
|---|---|---|
Life-Framework (main) |
main |
Yours. The world editor. Never touched by agents. |
Life-Framework-ws-<slug> |
ws/<slug> |
One per feature, one per agent. Full git worktree. |
The CLI enforces the boundary: build / test / dev / serve / ci refuse to run
in the main checkout. Agents work in worktrees; heavy commands there are
harmless to your editor because the worktree is a separate directory.
Per-worktree isolation
- Server port — every server boot on this machine listens on 2001. In
-server <world>mode the engine ignores-bindPort(it only overrides a-configJSON, and-configcannot be combined with-addons; checked on 1.9.0.37, log lineStarting RPL server, listening on address 0.0.0.0:2001). Sotest(every tier),dev,wt e2e, andservetake one machine-wideserver-2001lock: suites queue instead of colliding withUnable to start replication.servefails fast when the lock is held. The per-worktree(gamePort, a2sPort)pair fromwt listis still passed and is what a2s uses. - Resource database —
resourceDatabase.rdbis gitignored and only Workbench writes it.wt new,test,dev,serve,session up,host start, andwt e2ecopy<main-checkout>/addons/LifeFramework/resourceDatabase.rdbinto the worktree when that copy is missing, or smaller and older. Before a server boot they then check that the file registers every.metain the addon (path and GUID,tools/lib/rdb-fresh.mjs, ~0.2 s). A resource that landed on main after anyone last opened Workbench is missing from every copy; the server refuses it (Wrong GUID/name, inventory operations that never complete). When stale, the worktree copy is refreshed by a headless-wbModule=ResourceManagerWorkbench on the hidden desktop, under thebuildlock (one line:resource database stale: N unregistered resource(s) ... -> refreshing, about 10 s).toolscli rdbdoes the same on demand, andvalidatewarns. The main checkout's copy is the human's editor file: it is only warned about, never rewritten.LF_RDB_REFRESH=0turns the refresh off. Do not commit it. - Build — the headless Workbench build is the one truly shared resource
(shader cache, resource DB). It is serialized by a machine-wide lock file:
one build at a time, everyone else
--waits.cli devskips the build entirely, so the everyday loop never contends. - Hub — coordination state lives in
<main>/tmp/wt/(git-ignored):state.json(registry + ports) andlocks/. Derived fromgit worktree list, so it survives worktrees created by hand.
Daily use
tools\cli wt new <feature> # create worktree + branch + ports
tools\cli wt list # who's working, ports, dirty/merged state
tools\cli wt test <slug> # run the ELTEST suite in that worktree
tools\cli wt dev <slug> # fast loop in that worktree (no build)
tools\cli wt gate <slug> # validate --full + build + test (tier=all)
tools\cli wt ship <slug> # gate -> push -> PR -> auto-merge (merges main only on conflict)
tools\cli wt prune <slug> # remove a merged worktree + branch
wt <command> <slug> can be run from anywhere; it always uses the latest
tooling against the named worktree.
Workbench per worktree
Each linked checkout has its own addons/LifeFramework/LifeFramework.gproj, so
Workbench can be pointed at a feature branch without changing the main editor
project. Run tools\cli wt open <slug> --workbench; it launches that checkout's
project with a worktree-specific profile directory.
Use one Workbench instance at a time. The project files and profile are isolated, but the Workbench shader cache is shared by the machine. Stop the current editor before opening another worktree to avoid cache contention and stale resource views. Do not run a headless build while an interactive Workbench is open.
Auto-merge
wt ship <slug> [--closes N] [--body-file F]:
- Refuses a dirty tree or a branch with no commits beyond
origin/main. - Merges
origin/mainonly whengit merge-treereports a conflict (the merge drivers intools/merge/then resolve the generated files). A clean branch ships as is. - Runs the gate:
validate --full(with the Workbench compile) + headless build +testtier=all. A branch that changes nothing underaddons/(docs, website, tools) runs plainvalidateinstead and never waits for a pool slot;--full-gateforces the in-game gate (wt gatefollows the same rule). - Pushes
ws/<slug>, opens the PR (or reuses the open one on a re-run).--closes Nstarts the body withCloses #N;--body-file Fsupplies the body (keep it in<worktree>/tmp/). - Queues
gh pr merge --auto --merge: GitHub merges once the requiredvalidatecheck and reviews pass, and deletes the branch. - Prints
PR <url>, head <sha>.
--pr-only stops after step 4. If the gate fails or the merge of main
conflicts, the ship stops with a message; fix, commit, and re-run the same
command. Do not merge origin/main by hand unless GitHub reports the PR
CONFLICTING or you need code from main.
Keeping PRs moving (prs)
Auto-merge stalls in two ways. Main moves after the agent has left, the PR
goes CONFLICTING (mergeStateStatus DIRTY) or BEHIND, and GitHub never
merges it. Or the checks are green and auto-merge is on, but the PR sits open
with UNKNOWN until someone merges it by hand.
tools\cli prs status # read-only: every open ws/* PR
tools\cli prs heal --dry-run [<n>...] # what heal would do, nothing touched
tools\cli prs heal [<n>...] # do it (maintainer/lead)
prs status lists number, state, auto-merge on/off, checks, age, and the
worktree: LIVE when any file in it changed in the last 15 minutes
(modified or untracked files, the worktree's git index/HEAD/reflog, tmp/,
server logs), idle, or none. An UNKNOWN state is re-read through the REST
API, which makes GitHub compute it.
prs heal, per PR:
DIRTYorBEHIND, worktree not LIVE. In its worktree (or a temporary detachedLife-Framework-heal-<n>worktree at the PR head when it is gone, removed afterwards with the junction-safe helper) it mergesorigin/mainwith thetools/merge/drivers. Conflicts only in the test registry, the language confs, the string table,features.md,debug-log-triage.md, or the enforce-lint baseline are resolved: the registry and confs byregen-tests/regen-localization, the others by the drivers' merge logic on the index stages. Any other conflicted path aborts the merge; the PR, the worktree, and the branch stay as they were, and the PR is reported "needs a human/agent" with the paths. A clean merge runsvalidateandtest --no-build --tier fast(--tier allwhen the PR touchesScripts/), pushes without force, and re-queues auto-merge. A failed gate drops the merge commit (git reset --keep) and reports the PR.- Skipped: a LIVE worktree, a worktree with uncommitted changes, or one whose branch has commits the PR does not (unpushed agent work). The last two are reported.
- Green, auto-merge on, mergeable (
CLEAN/UNKNOWN/UNSTABLE), and still open 10 minutes after auto-merge was queued or the last check finished:gh pr merge <n> --merge.
It prints one line per PR and exits 1 when any PR needs a human or its agent.
Agents run prs status after their PR is up; the maintainer or lead runs
prs heal. One heal runs at a time (hub lock prs-heal).
tools/validation/pr-keeper-selftest.mjs proves the merge logic on a
throwaway repo: a registry-only conflict is healed and pushed, a real code
conflict (alone or beside the registry) is aborted with HEAD and origin
untouched.
Test pool (every machine)
Every -server boot binds UDP 2001, so one machine runs one test server at a
time. The test pool spreads in-game runs over every machine that can boot
one: this PC and the test box (ssh alias lf-testbox), more when they are
added. Each run takes the first free slot on any machine; when every slot is
busy it queues, first come first served, and takes the first slot that frees
anywhere.
tools\cli test --no-build --tier all # first free slot anywhere (--any, the default)
tools\cli test --no-build --only EL_Test_Money --local # this PC only
tools\cli test --no-build --tier all --remote [name] # a remote machine (any, or that one)
tools\cli e2e --remote # the two-boot save/restart proof on the box
tools\cli pool status # machines, slots and holders, queue, last 20 runs
tools\cli pool stats # per machine: runs, passes, average wait and run
tools\cli pool prune [--dry-run] [--unmarked] # remote run folders of gone worktrees
- Who uses it.
test,dev,ci,wt test,wt gate,wt ship(its gate),wt e2e, ande2e. Every run prints where it ran and how long it waited (pool: test all runs on lf-testbox ...; waited 0.7 s) and a footer with the exit code, wait, and run time.--local,--remote [name],--machine <name>, and--anypick;LF_TESTBOX_AUTO=0makes the default--local. - Machines.
tools/pool.jsonlists them:name,via(localorssh),host(ssh alias),root(the remote main clone),slots,caps(server,clients,workbench). The gitignoredlf.local.jsonoverrides per machine with a"pool"key (docs/setup.md).slotsis 1 everywhere today; slot 1 is the machine's ownserver-2001lock (the oneserve,host, andsessiontake too) and slot k isserver-2001-s<k>, so a machine that can run more servers at once (a network namespace or VM per slot) only raises the number. - Queue. Tickets live in
<main>/tmp/wt/pool/queue/(time, pid, slug, machines wanted). A ticket may try a machine only when no older live ticket wants it; tickets of dead processes are deleted. A local lock whose owner is dead is taken over after anyArmaReforgerServerstill running from that owner'sserver/profileis stopped. - Remote slots. A holder (one ssh session running PowerShell on the
remote) creates the remote
server-2001lock with its own pid, prepares the run folder, and watches its ancestor processes. When the run ends, or this process is killed, ctrl-c'd, or crashes, the local ssh dies, sshd drops the session, and within about 2 s the holder stops anything still running out of the run folder and deletes the lock. A remote lock whose pid is dead is reclaimed by the next acquirer there. The remote CLI runs under the held slot (LF_SERVER_SLOT_HELDnames the lock and a token it must still carry). - Stale and hung slots. A holder can outlive its owner (observed
2026-11-08: the local ssh was gone, the remote PowerShell holder and its lock
stayed). Each queued run that finds a remote slot busy checks the lock: when
its
originis this PC and itsoriginPidis dead, the run kills that holder by its recorded pid (only if the lock still carries that pid and token and the pid is a PowerShell), stops what runs out of its run folder (by command line; nothing is deleted), removes the lock, and retries. The hang guard takes any pool slot, local or remote, held longer than max(5 min, 10x the median run of that purpose) while noArmaReforgerServerruns for it;serve/host/sessionlocks name no purpose and are never taken. A reclaim printspool: reclaimed <machine> <lock> from <slug>: <reason>, the owner printsWARN pool: the scheduler reclaimed this run's slot ...when it releases or loses it, andpool statscounts reclaims per machine.pool statusmarks a remote lock whose owner is dead here asSTALE. - Config changes while queued. Every poll re-reads
tools/pool.jsonandlf.local.json: a machine enabled while a ticket waits counts at once, and the ticket's "wants" follow (same file, same place in the queue). Only--machine <name>/--remote <name>pin a ticket to one machine;--localand--remotestay within their kind. - Health. An unreachable machine (ssh connect timeout 4 s) is skipped for
a minute (
<main>/tmp/wt/pool/health.json); the holder attempt itself is the probe. A machine whose server Steam build differs from this PC's is skipped with aWARN, unless it was named with--remote <name>. - Sync. A remote run carries the worktree as it is: HEAD plus uncommitted
edits plus untracked, non-ignored files, as one snapshot commit built from a
copy of the index (your index, HEAD, and branch do not change). The run
folder
C:\LF\Life-Framework-rt-<slug>is a linked worktree of the box's clone, kept between runs. The holder reports which of the snapshot, the last commit this worktree sent, andorigin/mainthe box has, and the git bundle carries only what is missing.resourceDatabase.rdbis gitignored:testrefreshes the local copy first (headless Workbench; the box has none) and sends it when its SHA-1 differs from the run folder's. Measured 2026-11-09: new run folder 5.1 s holder + 1.3 s sync, repeat 3.0 s + 0.0 s (no bundle, rdb unchanged); an edit sends a ~2 KiB bundle. A run folder whose rdb predatedLF_HideoutStashgot the fresh one by hash and passed the stash tests (the box's main clone rdb has noLF_HideoutStashentry). - What comes back. The run streams live. The remote
console.logis copied toserver/profile/test/logs/remote-<machine>-<logdir>/, sotools\cli logsgreps it; on a failure itsSCRIPT (E), VME, andUnable to findlines are printed. The summary is stamped intotmp/test-results/summaries.jsonlwith"host", and every run is a line in<main>/tmp/wt/pool/runs.jsonl. - Cleanup. Each run folder holds
tmp/pool-owner.json(PC, slug, worktree).wt prune <slug>removes that worktree's remote run folders;pool pruneremoves every folder whose worktree on this PC is gone (--unmarkedalso takes older folders without the marker). Both go through the box's owntools/link-safety.mjs, never a raw delete:server/profileholds junctions into the server install. - Cost. A box run of the full suite takes about 22 s door to door (10 s
suite plus ssh, checkout, CLI start, log copy), this PC about 8 s. Four
concurrent
--tier allruns from four worktrees (2026-11-09): 28-31 s wall, three on this PC and one on the box, all 224/224, against 36 s for the four in a row on this PC. The pool pays most when this PC's slot is held by something long (host,session,e2e, another agent's gate). - Limits.
buildandvalidate --fullstay on this PC. A pre-pool branch's snapshot (noLF_SERVER_SLOT_HELDin itstools/cli.mjs) runs local only until it merges main.
Sessions on a pool machine
node agent-box/session.mjs up --machine <name|any|local> --clients N.
Game clients need a desktop session. Over ssh there is none: on 2026-11-09
the box showed the logon screen (LogonUI in session 1, no explorer) and
ssh commands run in session 0, so a remote machine runs server-only
sessions (--clients 0). Its server is started through WMI
(Win32_Process.Create) because ssh kills every child of the session when
the connection closes (a detached spawn was gone seconds later). The machine
is remembered in <worktree>/tmp/session-remote.json, so down, status,
and restart from that worktree go there. --machine any picks the first
machine with a free slot that can run the requested clients (clients: this
PC only). journey drives clients, so it runs on this PC; it refuses
--machine lf-testbox and a recorded remote session. Clients on this PC can
join a box server over the LAN (manual steps below).
Box setup
Machine settings live in the gitignored lf.local.json of each machine's
main checkout (docs/setup.md "Machine-local settings"). On this PC:
{ "LF_TESTBOX": "lf-testbox" }
tools\cli testbox provision copies tools/remote/provision-testbox.ps1 to
the box and runs it: Git and Node.js 22+ through winget only when missing,
the dedicated server (app 1874900), a clone in C:\LF\Life-Framework, and
the box's lf.local.json (ENFUSION_SERVER_PATH, LF_GAME_ADDONS = that
server's own addons, whose core and data the test server loads). For
the server it uses a complete install in one of the box's Steam libraries
(the box's Steam client keeps it on the current build, like the main PC's);
without one it installs SteamCMD in C:\steamcmd and the server
anonymously into C:\LF\ArmaReforgerServer (-UseSteamCmd forces
that). It then sends this checkout's resourceDatabase.rdb. Results compare
across machines only on the same server build: every remote run prints both
builds and warns on a mismatch. Prerequisite: an ssh lf-testbox key login as
a local administrator whose default shell is Windows PowerShell.
A stale resourceDatabase.rdb fails world tests the same way on both
machines (on 2026-11-08 the main checkout's copy predated the jail and stash
prefabs: hideout/stash, jail/slip and two others failed with
not found in replication). test refreshes a stale copy on this PC
(headless Workbench) before it picks the machine, so the box always gets a
fresh one.
Game clients and filming on the box (manual)
The box's Steam client (signed into a different account than the main PC's,
so both can run Reforger at once) has Arma Reforger, Arma Reforger Tools, and
Arma Reforger Server in its library. The test runs use only the server. The
provisioning never starts, signs into, or changes Steam. Workbench is not
used on the box; build and validate --full stay on the main PC.
A client on the box joining a session on the main PC. Not wired into
session up: the harness talks to a client through files in its local
profile, so a box client is for watching and filming, not harness verbs.
Not yet exercised end to end.
-
Main PC (hostname
Gaming-Box, LAN 192.168.1.130), in your worktree:tools\cli session up --clients 1(server on UDP 2001 plus client 1). Its Windows network is on the Public profile; the Steam-created "Arma Reforger dedicated server" rules already allowArmaReforgerServerDiag.exeinbound there. -
Send the same commit to the box:
tools\cli test --no-build --only EL_Test_AccountWantedClamp --remotechecks it out inC:\LF\Life-Framework-rt-<slug>. The server refuses a client whose scripts or defines differ ("script source code checksum does not match"). -
On the box's own desktop (the client must render in the signed-in user's session, not over ssh):
"C:\Program Files (x86)\Steam\steamapps\common\Arma Reforger\ArmaReforgerSteamDiag.exe" -client Gaming-Box -addonsDir C:\LF\Life-Framework-rt-<slug>\addons -addons LifeFramework -profile C:\LF\client2 -window -screenWidth 1280 -screenHeight 720 -scrDefine LF_SESSION_IDENTITY -noBackend -disableCrashReporterUse
192.168.1.130ifGaming-Boxdoes not resolve. For--mission playtestadd-scrDefine LF_HARNESS.
The box hosting the server. The box's network is Private. An inbound rule
"LF Arma Reforger Server (LAN)" (UDP, Private profile, the Steam library's
ArmaReforgerServerDiag.exe) was added on 2026-11-08 so main-PC clients can
join a server there (-client PC1, LAN 192.168.1.128).
Filming on the box. A client started on the box's desktop is a normal
foreground window there, so screen capture sees it (on the main PC the
session clients render on the hidden LF-Agents desktop, which capture
cannot see). From the box's desktop, in its clone: node tools/film/film.mjs fetch once (downloads ffmpeg to tools/film/.bin), then
node tools/film/film.mjs record --window "Arma Reforger" --seconds 20;
the clip lands in agent-box/reports/film/. Over ssh capture gets no frames:
ssh has no desktop; use the testbox commands below, which capture in the
signed-in session.
Desktop on the box (virtual display, recording, clients from ssh)
ssh runs in session 0. The box's signed-in desktop is session 1, so
tools/remote/desktop/run-interactive.ps1 runs a program there through a
one-shot scheduled task (LogonType Interactive, the ssh user) and removes the
task afterwards. The scripts in tools/remote/desktop/ are copied to
C:\LF\desktop on every call; output comes back to tmp/testbox/.
tools\cli testbox screenshot # png of every active display; prints each screen's bounds
tools\cli testbox record 10 [--gdigrab] # 10 s mp4, screen + system audio, plus its audio levels
tools\cli testbox start "C:\Program Files (x86)\Steam\steamapps\common\Arma Reforger\ArmaReforgerSteam.exe" -window -screenWidth 1920 -screenHeight 1080
tools\cli testbox vdd # (re)install the virtual display driver, no reboot
- Virtual display. With the lid closed the box has no active display, so
nothing renders and there is nothing to capture. The Virtual Display Driver
(github.com/VirtualDrivers/Virtual-Display-Driver, MIT, 25.7.23; the
catalog is signed by SignPath Foundation) was installed on 2026-11-08 by
install-vdd.ps1: aRoot\MttVDDdisplay adapter with one monitor, "Generic Monitor (VDD by MTT)", whose modes come fromC:\VirtualDisplayDriver\vdd_settings.xml(1920x1080 and 1280x720 at 60 Hz). The install added the SignPath certificate to the machine's TrustedPublisher store so it runs without a prompt; it needed no reboot. With the lid open the internal panel stays the only desktop (the virtual monitor is enumerated but not extended onto); with the lid closed it is the only display, so Windows makes it the desktop. The lid action on AC is "do nothing", so the box stays awake. Remove it with Device Manager, "Virtual Display Driver", Uninstall device (tick delete driver). - Audio.
lf-loopback.ps1is a WASAPI loopback of the default output, written to stdout as raw PCM with silence padded in so it stays aligned with the video;record.ps1pipes it into ffmpeg next toddagrab(DXGI output 0,-Output Nfor another) orgdigrab. It compiles its C# in memory withAdd-Type, because Smart App Control blocks unsigned exes on the box (acsc-built exe ran once and was blocked after the next rebuild). Loopback records after the endpoint's volume and mute: the laptop speakers were muted at 0 %, so every clip was silent (-91 dB). The default output is now "Steam Streaming Speakers" at 100 % (lf-loopback.ps1 --use <endpoint id>), which plays to no speaker, so the room stays quiet and the loopback hears the game.lf-loopback.ps1 --diagprints the default endpoint, volume, and mute. ffmpeg isGyan.FFmpeg9.0.2 from winget (GPL-3.0, full build:ddagrab,libx264). - Clients. Start the game with its working directory set to the game
folder (
run-interactive.ps1uses the exe's folder); fromC:\Windows\System32the client fails withGame addon '58D0FB3206B6F859' not found. Close it withrun-interactive.ps1 -Exe taskkill.exe -Arguments "/IM ArmaReforgerSteam.exe"(a window close, so it shuts down cleanly). - Proof, 2026-11-08 (lid open, internal panel 2560x1440 at 150 %). The
release client drew its menu background in a 1920x1080 window 50 s after
start; a 10 srecordof it was 2560x1440 at 28 fps with mean audio -25.9 dB, max -12.9 dB (the menu music). A Windows chime looped through the loopback alone measured -30.9 dB mean. Not yet measured: a capture with the lid closed, on the virtual display.
Scratch files
Each worktree has a gitignored tmp/ (created by wt new, also home of the
tmp/wt.json marker). Put scratch there with names unique to the task, such
as tmp/pr-body.md. A shared session scratch dir lets one agent publish
another's file. validate-worktrees warns when the main checkout has
untracked files under addons/ or tools/.
One-time setup: GitHub CLI must be authenticated once per machine
(gh auth login). The REST API fallback works with a GITHUB_TOKEN env var
instead.
Keeping your world editor safe
- The main checkout stays clean; agents never run heavy commands there (CLI
enforces it,
--forceoverrides it). - One caveat: do not open the world editor while an agent's headless build is
running — concurrent Workbench instances can collide on the shader cache.
cli wt listdoes not show running builds; the build lock file at<main>/tmp/wt/locks/build.lockindicates one in flight. - Merge your own local
mainwork first: agent worktrees branch fromorigin/main, so uncommitted or unpushed changes in your checkout are not visible to agents until you push.
Cleanup
Merged worktrees show merged (prune me) in cli wt list. cli wt prune <slug> verifies the branch is merged and clean before removing it; --force
discards unmerged work. cli validate warns you about worktrees that are ready
to prune and about any registry inconsistency.
Under the hood (files)
tools/wt.mjs— hub, locks, ports, resource-database copy, git helpers, gh/REST PR + merge.tools/prs.mjs—prs status|heal: open ws/* PRs, liveness, heal merge, stuck-merge.tools/cli.mjs—wtcommand group, main-checkout guard, worktree-aware build/test/dev/serve/ci.tools/validation/validate-worktrees.ps1— registry consistency (port uniqueness, missing dirs, stale merged worktrees)..githooks/pre-commit→validate-repo.ps1— blocksmaincommits from linked worktrees.AGENTS.md(Worktrees section) — the agent-facing rules, including the junction-safe removal rule.