docs(multitenant): add agent worktree guide and fix registry semantics

---
Build: pass | Tests: FAIL — Tests  6 failed | 1835 passed (1841)
This commit is contained in:
Operator & Codex 2026-04-25 08:22:43 +02:00
parent 153c8a77a2
commit 8cd208dc8c
5 changed files with 361 additions and 34 deletions

View file

@ -0,0 +1,59 @@
# Agent Workflow Checklist
Short operator checklist for the per-agent worktree model.
## Before Starting
- verify `git config core.hooksPath` returns `hooks`
- verify `git config extensions.worktreeConfig` is `true`
- confirm the primary FreeBSD checkout is the only place that will push
`multitenant`
## When Assigning A Linux Agent
- tell the agent to pull latest `origin/multitenant`
- tell the agent to read:
- [MULTITENANT-AGENT-WORKFLOW.md](./MULTITENANT-AGENT-WORKFLOW.md)
- [MULTITENANT-HANDOFF.md](./MULTITENANT-HANDOFF.md)
- require a dedicated worktree and feature branch
- require worktree-local identity:
- `Operator & zAI`
- `Operator & Claude`
- require branch-only push, never `multitenant`
## Before The Agent Commits
- confirm `git var GIT_AUTHOR_IDENT`
- confirm `git var GIT_COMMITTER_IDENT`
- both should start with `Operator &`
## Before The Agent Pushes
- rebase on latest `origin/multitenant`
- if local commits were rewritten, push with `--force-with-lease`
- report the feature branch name and tip commit hash
## Before Codex Integrates
- fetch latest refs
- verify the helper branch is actually ahead of `origin/multitenant`
- review the diff
- fast-forward integrate only
- run the relevant FreeBSD verification
## Before Pushing `multitenant`
- confirm local `multitenant` is based on latest `origin/multitenant`
- confirm the working tree is clean
- push only after integration and verification are done
## If Something Looks Wrong
- commit rejected by hook:
- check worktree-local `user.name`
- helper branch equals base branch:
- there is no new work yet
- push rejected non-fast-forward:
- fetch and rebase before retrying
- rebase asks for an editor:
- use `GIT_EDITOR=true git rebase --continue`

View file

@ -0,0 +1,245 @@
# Agent Worktree Workflow
Practical guide for running more than one coding agent against this repo
without corrupting attribution, stash state, or branch history.
## Why This Exists
We hit two different failures while multiple agents were sharing one clone:
- commit attribution drifted because `user.name` is only one mutable slot
- `git pull` / rebase work collided because one working tree also means one
index, one stash stack, and one rebase state directory
Per-agent worktrees fix both at once.
## The Model
- Linux helper agents each get their own git worktree
- each helper works on its own feature branch:
- `multitenant-zai`
- `multitenant-claude`
- FreeBSD Codex stays on the primary `multitenant` checkout
- only FreeBSD Codex pushes `multitenant`
That means:
- helpers propose changes
- Codex reviews, tests, integrates, and pushes the shared branch
## One-Time Setup
Run once in the primary clone:
```sh
git config extensions.worktreeConfig true
git config core.hooksPath hooks
```
Why:
- `extensions.worktreeConfig=true` enables per-worktree `user.name`
- `core.hooksPath hooks` activates the attribution guard and README sync hook
## Per-Agent Setup
Example for zAI:
```sh
git worktree add -b multitenant-zai ../mevy-ai-zai multitenant
cd ../mevy-ai-zai
git config --worktree user.name "Operator & zAI"
git config --worktree user.email "hello@clawdie.si"
```
Example for Claude:
```sh
git worktree add -b multitenant-claude ../mevy-ai-claude multitenant
cd ../mevy-ai-claude
git config --worktree user.name "Operator & Claude"
git config --worktree user.email "hello@clawdie.si"
```
Check the effective identity before committing:
```sh
git var GIT_AUTHOR_IDENT
git var GIT_COMMITTER_IDENT
```
Both must start with `Operator & ...`.
## Day-To-Day Flow
### Linux helper agent
1. Pull latest refs:
```sh
git fetch origin
```
2. Work only in the agent worktree, never in the shared clone.
3. Commit locally on the feature branch.
4. Rebase on latest `origin/multitenant` before push:
```sh
git pull --rebase origin multitenant
```
5. Push the feature branch:
```sh
git push origin multitenant-zai
```
If the rebase rewrote local commits, push with lease protection:
```sh
git push --force-with-lease origin multitenant-zai
```
6. Report back the feature branch name and tip commit to Codex.
### FreeBSD Codex
1. Fetch latest refs:
```sh
git fetch origin
```
2. Review the helper branch.
3. Integrate into local `multitenant` with fast-forward merge only.
4. Run the FreeBSD-side verification that matters for the slice.
5. Push `multitenant`:
```sh
git push origin multitenant
```
## Why Integration Should Stay Fast-Forward
The helper branch is required to rebase on latest `origin/multitenant`
before push. That keeps integration simple:
- no merge bubble just to land helper work
- no ambiguity about who last reconciled the branch
- if a helper branch cannot fast-forward cleanly anymore, it needs another
rebase before Codex should integrate it
## What The Hook Guards
`hooks/pre-commit` currently does two things:
- rejects commits whose effective author/committer name is not
`Operator & <agent>` or the temporary legacy `Clawdie AI`
- runs the README version sync helper
The important detail is that it validates:
- `git var GIT_AUTHOR_IDENT`
- `git var GIT_COMMITTER_IDENT`
That means it checks the identity git will actually write, not just
`git config user.name`.
## Common Failure Modes
### Commit rejected as `Mevy Assistant`
Cause:
- the worktree is still using `.git/config` identity instead of
`.git/config.worktree`
Fix:
```sh
git config --worktree user.name "Operator & Codex"
git config --worktree user.email "hello@clawdie.si"
```
Then re-check with:
```sh
git var GIT_AUTHOR_IDENT
```
### Helper branch is identical to `origin/multitenant`
Cause:
- the helper created or pushed the branch but never committed the actual work
Check:
```sh
git log --oneline origin/multitenant..HEAD
```
If empty, there is nothing to integrate yet.
### `git push origin multitenant` is rejected
Cause:
- local `multitenant` is behind the remote tip
Fix:
- fetch
- rebase local Codex commits on `origin/multitenant`
- resolve any conflicts
- push again
### `git rebase --continue` opens `vi`
Cause:
- git wants to reopen the commit message
Fix:
```sh
GIT_EDITOR=true git rebase --continue
```
### Hook is not firing
Check:
```sh
git config core.hooksPath
```
Expected:
```text
hooks
```
If not, run:
```sh
git config core.hooksPath hooks
```
## When To Document, Not Automate
This workflow is worth a repo doc before it becomes a reusable skill.
Why:
- it is mostly operator process and branch discipline
- it is tightly coupled to this repo's `multitenant` integration model
- the value right now is a stable human runbook
If the same pattern starts repeating across repos, then it is worth turning
into a skill with setup prompts and validation commands.

0
hooks/pre-commit Normal file → Executable file
View file

View file

@ -85,13 +85,24 @@ describe('tenant-registry', () => {
const yamlContent = fs.readFileSync(registryPath, 'utf-8');
fs.writeFileSync(
registryPath,
`${yamlContent} sites:\n - id: Blog\n - id: docs\n exposure: disabled\n`,
yamlContent.replace(
' sites:\n - id: blog\n exposure: internal',
' sites:\n - id: Blog\n - id: docs\n exposure: disabled',
),
'utf-8',
);
const registry = loadTenantRegistry(registryPath);
expect(registry.tenants.mevy?.sites).toEqual([
{ id: 'blog', exposure: 'internal' },
{ id: 'docs', exposure: 'disabled' },
{
id: 'blog',
exposure: 'internal',
fqdn: 'blog.mevy.home.arpa',
},
{
id: 'docs',
exposure: 'disabled',
fqdn: null,
},
]);
});
@ -100,11 +111,14 @@ describe('tenant-registry', () => {
const yamlContent = fs.readFileSync(registryPath, 'utf-8');
fs.writeFileSync(
registryPath,
`${yamlContent} sites:\n - id: blog\n exposure: public\n`,
yamlContent.replace(
' sites:\n - id: blog\n exposure: internal',
' sites:\n - id: blog\n exposure: public',
),
'utf-8',
);
expect(() => loadTenantRegistry(registryPath)).toThrow(
/publishing_mode is disabled/,
/platform\.publishing_mode is disabled/,
);
});
@ -117,7 +131,10 @@ describe('tenant-registry', () => {
);
fs.writeFileSync(
registryPath,
`${withPublishing} sites:\n - id: blog\n exposure: public\n`,
withPublishing.replace(
' sites:\n - id: blog\n exposure: internal',
' sites:\n - id: blog\n exposure: public',
),
'utf-8',
);
expect(() => loadTenantRegistry(registryPath)).toThrow(
@ -130,10 +147,13 @@ describe('tenant-registry', () => {
const yamlContent = fs.readFileSync(registryPath, 'utf-8');
fs.writeFileSync(
registryPath,
`${yamlContent} sites:\n - id: blog\n - id: Blog\n`,
yamlContent.replace(
' sites:\n - id: blog\n exposure: internal',
' sites:\n - id: blog\n - id: Blog',
),
'utf-8',
);
expect(() => loadTenantRegistry(registryPath)).toThrow(/duplicate site id/);
expect(() => loadTenantRegistry(registryPath)).toThrow(/duplicated/);
});
it('lists tenants in a stable order', () => {
@ -575,15 +595,20 @@ describe('tenant-registry', () => {
const original = fs.readFileSync(registryPath, 'utf-8');
fs.writeFileSync(
registryPath,
original.replace(
' sites:\n - id: blog\n exposure: internal',
' sites:\n - id: docs\n exposure: public',
),
original
.replace(
' publishing_mode: disabled',
' publishing_mode: public',
)
.replace(
' sites:\n - id: blog\n exposure: internal',
' sites:\n - id: docs\n exposure: public',
),
'utf-8',
);
expect(() => loadTenantRegistry(registryPath)).toThrow(
'Tenant site requires platform.public_base for public exposure: docs',
'Tenant site requires platform.public_base; public_base is unset: docs',
);
});
@ -603,7 +628,7 @@ describe('tenant-registry', () => {
);
expect(() => loadTenantRegistry(registryPath)).toThrow(
'Tenant site cannot use public exposure when platform publishing mode is internal: docs',
'Tenant site cannot use public exposure when platform.publishing_mode is internal: docs',
);
});

View file

@ -238,25 +238,23 @@ function parseTenantRecord(
internalBase?: string,
publicBase?: string,
): TenantRecord {
const sites = (raw.sites || []).map((site) => {
const exposure = site.exposure || 'internal';
return {
id: normalizeResourceId(site.id),
exposure,
fqdn:
exposure === 'public' && !publicBase
? null
: siteFqdn(site.id, id, exposure, internalBase, publicBase),
};
});
return {
id,
displayName: raw.display_name || defaultTenantDisplayName(id),
internalDomain: raw.internal_domain || tenantHomeFqdn(id, internalBase),
service: raw.service || tenantServiceName(id),
sites: (raw.sites || []).map((site) => ({
id: normalizeResourceId(site.id),
exposure: site.exposure || 'disabled',
fqdn:
(site.exposure || 'disabled') === 'public' && !publicBase
? null
: siteFqdn(
site.id,
id,
site.exposure || 'disabled',
internalBase,
publicBase,
),
})),
sites,
databases: {
brain: raw.databases?.brain || tenantBrainDbName(id),
ops: raw.databases?.ops || tenantOpsDbName(id),
@ -606,17 +604,17 @@ function validateTenantRecord(
if (registry.platform.reservedHostLabels.includes(siteIdAlias)) {
throw new Error(`Tenant site id conflicts with reserved host label: ${site.id}`);
}
if (site.exposure === 'public' && !registry.platform.publicBase) {
throw new Error(
`Tenant site requires platform.public_base for public exposure: ${site.id}`,
);
}
if (
site.exposure === 'public' &&
registry.platform.publishingMode !== 'public'
) {
throw new Error(
`Tenant site cannot use public exposure when platform publishing mode is ${registry.platform.publishingMode}: ${site.id}`,
`Tenant site cannot use public exposure when platform.publishing_mode is ${registry.platform.publishingMode}: ${site.id}`,
);
}
if (site.exposure === 'public' && !registry.platform.publicBase) {
throw new Error(
`Tenant site requires platform.public_base; public_base is unset: ${site.id}`,
);
}
}